Replace Ollama with llama-server for 15x Orpheus throughput

Build llama.cpp from source with SM 120 CUDA kernels and FORCE_CUBLAS
for RTX 5070 Blackwell. Rewrite OrpheusEngine to stream tokens via SSE
and decode SNAC in overlapping 28-token batches (4 frames), replacing
the blocking requests+stream:false approach.

Performance: 13.5 → 170-213 tok/s. 100s audio generates in ~48s (2x
faster than realtime). Replaces requests with httpx async client.

Also switch MCP transport to stateless_http mode so container restarts
don't invalidate client sessions.
This commit is contained in:
Ryan Malloy 2026-02-21 21:33:23 -07:00
parent 7f9557d93b
commit 538b8a513e
9 changed files with 234 additions and 70 deletions

View File

@ -1,7 +1,7 @@
include .env include .env
export export
.PHONY: build up down logs restart status .PHONY: build up down logs restart status bench
build: build:
docker compose build docker compose build
@ -26,3 +26,10 @@ status:
@docker compose ps @docker compose ps
@echo "---" @echo "---"
@curl -s http://localhost:8371/mcp 2>/dev/null | head -5 || echo "Server not responding" @curl -s http://localhost:8371/mcp 2>/dev/null | head -5 || echo "Server not responding"
bench:
@echo "Benchmarking llama-server throughput..."
@docker exec orpheus-llama-server curl -s "http://127.0.0.1:8081/v1/completions" \
-H "Content-Type: application/json" \
-d '{"prompt":"<|audio|>tara: Hello, how are you doing today?<|eot_id|>","max_tokens":500,"stream":false}' | \
python3 -c "import sys,json; d=json.load(sys.stdin); u=d['usage']; t=d.get('timings',{}); print(f\"{u['completion_tokens']} tokens, {t.get('predicted_per_second',0):.1f} tok/s\")"

View File

@ -7,11 +7,9 @@ services:
environment: environment:
# Override for Docker networking (container DNS instead of IPs) # Override for Docker networking (container DNS instead of IPs)
TTS_PIPER_HOST: piper-tts TTS_PIPER_HOST: piper-tts
TTS_OLLAMA_URL: http://host.docker.internal:11434 TTS_ORPHEUS_URL: http://llama-server:8081
# PipeWire client config # PipeWire client config
XDG_RUNTIME_DIR: /run/user/1000 XDG_RUNTIME_DIR: /run/user/1000
extra_hosts:
- "host.docker.internal:host-gateway"
volumes: volumes:
# Kokoro ONNX models (read-only) # Kokoro ONNX models (read-only)
- ./models:/app/models:ro - ./models:/app/models:ro
@ -19,6 +17,9 @@ services:
- hf-cache:/home/tts/.cache/huggingface - hf-cache:/home/tts/.cache/huggingface
# PipeWire socket for audio playback through host speakers # PipeWire socket for audio playback through host speakers
- /run/user/1000/pipewire-0:/run/user/1000/pipewire-0 - /run/user/1000/pipewire-0:/run/user/1000/pipewire-0
depends_on:
llama-server:
condition: service_healthy
networks: networks:
- caddy - caddy
- dootie-internal - dootie-internal
@ -26,6 +27,36 @@ services:
caddy: voice.l.supported.systems caddy: voice.l.supported.systems
caddy.reverse_proxy: "{{upstreams 8371}}" caddy.reverse_proxy: "{{upstreams 8371}}"
llama-server:
build:
context: .
dockerfile: llama-server.Dockerfile
container_name: orpheus-llama-server
restart: unless-stopped
deploy:
resources:
reservations:
devices:
- driver: nvidia
count: 1
capabilities: [gpu]
volumes:
# GGUF model file (set ORPHEUS_GGUF_PATH in .env, e.g. from Ollama blob storage)
- ${ORPHEUS_GGUF_PATH}:/models/orpheus.gguf:ro
command: >-
--host 0.0.0.0 --port 8081
--model /models/orpheus.gguf
--n-gpu-layers 999 --ctx-size 4096
--flash-attn --cont-batching
networks:
- dootie-internal
healthcheck:
test: ["CMD", "curl", "-sf", "http://127.0.0.1:8081/health"]
interval: 15s
timeout: 5s
start_period: 120s
retries: 5
volumes: volumes:
hf-cache: hf-cache:

48
llama-server.Dockerfile Normal file
View File

@ -0,0 +1,48 @@
# llama-server built from source with SM 120 (Blackwell / RTX 5070) CUDA kernels.
# Multi-stage: ~8GB devel toolkit stays in builder, runtime image is ~2GB.
# ── Builder ──────────────────────────────────────────────────────────────
FROM nvidia/cuda:12.8.1-devel-ubuntu24.04 AS builder
RUN apt-get update && apt-get install -y --no-install-recommends \
cmake git build-essential curl ca-certificates \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /build
# Pin to a release tag for reproducibility
ARG LLAMA_CPP_VERSION=b5460
RUN git clone --depth 1 --branch ${LLAMA_CPP_VERSION} \
https://github.com/ggerganov/llama.cpp.git
WORKDIR /build/llama.cpp
# CUDA driver symbols (cuMemCreate, etc.) are resolved at runtime by nvidia-container-runtime.
# --allow-shlib-undefined lets the linker accept unresolved refs in libggml-cuda.so.
RUN cmake -B build \
-DCMAKE_BUILD_TYPE=Release \
-DCMAKE_CUDA_ARCHITECTURES=120 \
-DGGML_CUDA=ON \
-DGGML_CUDA_FORCE_CUBLAS=ON \
-DLLAMA_BUILD_SERVER=ON \
-DLLAMA_CURL=OFF \
-DCMAKE_EXE_LINKER_FLAGS="-Wl,--allow-shlib-undefined" \
&& cmake --build build --target llama-server -j$(nproc)
# ── Runtime ──────────────────────────────────────────────────────────────
FROM nvidia/cuda:12.8.1-runtime-ubuntu24.04
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates libgomp1 \
&& rm -rf /var/lib/apt/lists/*
# Copy server binary and its shared libraries (libggml-*.so)
COPY --from=builder /build/llama.cpp/build/bin/llama-server /usr/local/bin/llama-server
COPY --from=builder /build/llama.cpp/build/bin/lib*.so /usr/local/lib/
RUN ldconfig
RUN useradd -u 1000 -m llama 2>/dev/null || true
USER 1000
ENTRYPOINT ["llama-server"]

View File

@ -11,11 +11,11 @@ license = "MIT"
authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}] authors = [{name = "Ryan Malloy", email = "ryan@supported.systems"}]
dependencies = [ dependencies = [
"fastmcp>=3.0.0", "fastmcp>=3.0.0",
"httpx",
"kokoro-onnx>=0.5.0", "kokoro-onnx>=0.5.0",
"numpy", "numpy",
"onnxruntime", "onnxruntime",
"pydantic-settings", "pydantic-settings",
"requests",
"snac>=1.2.1", "snac>=1.2.1",
"soundfile", "soundfile",
"torch", "torch",

View File

@ -5,7 +5,12 @@ from .settings import settings
def main(): def main():
mcp.run(transport="streamable-http", host=settings.host, port=settings.port) mcp.run(
transport="streamable-http",
host=settings.host,
port=settings.port,
stateless_http=True,
)
if __name__ == "__main__": if __name__ == "__main__":

View File

@ -1,18 +1,20 @@
"""Orpheus TTS via Ollama + SNAC decoder. """Orpheus TTS via llama-server + streaming SNAC decoder.
Sends text to Ollama's Orpheus model, parses <custom_token_N> responses, Sends text to a llama-server completions endpoint with streaming enabled,
and decodes through SNAC to 24kHz WAV. SNAC is lazy-loaded on first use parses <custom_token_N> responses as they arrive via SSE, and decodes
and runs on CPU (RTX 5070 SM 120 not yet supported by PyTorch 2.6). through SNAC in batches of 28 tokens (4 frames) for overlapped inference.
SNAC runs on CPU; the LLM runs on GPU via llama-server.
""" """
import asyncio import asyncio
import json
import os import os
import re import re
import sys import sys
import time import time
import httpx
import numpy as np import numpy as np
import requests
from ..audio import wav_duration, write_wav from ..audio import wav_duration, write_wav
from ..settings import settings from ..settings import settings
@ -21,6 +23,9 @@ from .base import TTSEngine, TTSResult
SAMPLE_RATE = 24000 SAMPLE_RATE = 24000
SNAC_CODEBOOK_SIZE = 4096 SNAC_CODEBOOK_SIZE = 4096
TOKEN_PATTERN = re.compile(r"<custom_token_(\d+)>") TOKEN_PATTERN = re.compile(r"<custom_token_(\d+)>")
TOKENS_PER_FRAME = 7
FRAMES_PER_BATCH = 4
BATCH_SIZE = TOKENS_PER_FRAME * FRAMES_PER_BATCH # 28
ALL_VOICES = ["tara", "leah", "jess", "leo", "dan", "mia", "zac", "zoe"] ALL_VOICES = ["tara", "leah", "jess", "leo", "dan", "mia", "zac", "zoe"]
@ -87,7 +92,12 @@ def _tokens_to_audio(token_strings: list[str], snac_model) -> np.ndarray | None:
def _load_snac(): def _load_snac():
"""Load SNAC model on CPU. Called once, lazily.""" """Load SNAC model on CPU. Called once, lazily.
Sets CUDA_VISIBLE_DEVICES="" for the entire process to prevent PyTorch
from allocating GPU memory for SNAC. Safe because all GPU inference is
handled by llama-server in a separate container.
"""
os.environ["CUDA_VISIBLE_DEVICES"] = "" os.environ["CUDA_VISIBLE_DEVICES"] = ""
from snac import SNAC from snac import SNAC
@ -95,20 +105,28 @@ def _load_snac():
class OrpheusEngine(TTSEngine): class OrpheusEngine(TTSEngine):
"""Orpheus TTS via Ollama's completions API + SNAC audio decoding. """Orpheus TTS via llama-server completions API + streaming SNAC decode.
SNAC is lazy-loaded on first synthesize() call to avoid holding Streams tokens from llama-server via SSE, decodes in batches of 28
~200MB of RAM when the engine isn't being used. tokens (4 SNAC frames) to overlap GPU inference with CPU audio decode.
SNAC is lazy-loaded on first synthesize() call.
""" """
name = "orpheus" name = "orpheus"
default_voice = "tara" default_voice = "tara"
def __init__(self, ollama_url: str, model_name: str) -> None: def __init__(self, orpheus_url: str) -> None:
self._snac = None self._snac = None
self._snac_lock = asyncio.Lock() self._snac_lock = asyncio.Lock()
self._ollama_url = ollama_url self._url = orpheus_url
self._model = model_name self._client = httpx.AsyncClient(
timeout=httpx.Timeout(connect=10.0, read=600.0, write=30.0, pool=30.0),
limits=httpx.Limits(max_connections=4, max_keepalive_connections=2),
)
async def close(self) -> None:
"""Clean up httpx client."""
await self._client.aclose()
async def _get_snac(self): async def _get_snac(self):
"""Lazy-load SNAC on first use.""" """Lazy-load SNAC on first use."""
@ -129,66 +147,119 @@ class OrpheusEngine(TTSEngine):
prompt = f"<|audio|>{voice}: {text}<|eot_id|>" prompt = f"<|audio|>{voice}: {text}<|eot_id|>"
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
snac = await self._get_snac()
# Ollama API call (blocking HTTP) # Stream tokens from llama-server via SSE
t0 = time.time() t0 = time.time()
token_strings: list[str] = []
audio_chunks: list[np.ndarray] = []
total_tokens = 0
dropped_lines = 0
stream_interrupted = False
def _call_ollama(): try:
resp = requests.post( async with self._client.stream(
f"{self._ollama_url}/v1/completions", "POST",
f"{self._url}/v1/completions",
json={ json={
"model": self._model,
"prompt": prompt, "prompt": prompt,
"max_tokens": 8192, "max_tokens": 8192,
"temperature": 0.6, "temperature": 0.6,
"top_p": 0.9, "top_p": 0.9,
"stream": False, "stream": True,
}, },
timeout=600, ) as response:
) try:
resp.raise_for_status() response.raise_for_status()
return resp.json() except httpx.HTTPStatusError as e:
body = await response.aread()
raise RuntimeError(
f"llama-server returned {e.response.status_code}: "
f"{body[:500].decode(errors='replace')}"
) from e
async for line in response.aiter_lines():
# SSE format: "data: {...}" or "data: [DONE]"
if not line.startswith("data: "):
continue
payload = line[6:]
if payload == "[DONE]":
break
try:
chunk = json.loads(payload)
except json.JSONDecodeError:
dropped_lines += 1
continue
chunk_text = chunk.get("choices", [{}])[0].get("text", "")
matches = TOKEN_PATTERN.findall(chunk_text)
for m in matches:
token_val = int(m)
# Skip leading special tokens (value < 10)
if total_tokens == 0 and token_val < 10:
continue
token_strings.append(f"<custom_token_{m}>")
total_tokens += 1
# Batch decode every 28 tokens (4 SNAC frames)
while len(token_strings) >= BATCH_SIZE:
batch = token_strings[:BATCH_SIZE]
token_strings = token_strings[BATCH_SIZE:]
chunk_audio = await loop.run_in_executor(
None, _tokens_to_audio, batch, snac
)
if chunk_audio is not None:
audio_chunks.append(chunk_audio)
except httpx.ConnectError as e:
raise RuntimeError(f"Cannot reach llama-server at {self._url}: {e}") from e
except (httpx.ReadError, httpx.RemoteProtocolError) as e:
if not audio_chunks:
raise RuntimeError(
f"llama-server connection lost with no audio decoded: {e}"
) from e
stream_interrupted = True
print(
f" Warning: llama-server connection lost after {total_tokens} tokens. "
f"Using {len(audio_chunks)} partial chunks.",
file=sys.stderr,
)
result = await loop.run_in_executor(None, _call_ollama)
gen_time = time.time() - t0 gen_time = time.time() - t0
resp_text = result.get("choices", [{}])[0].get("text", "") if dropped_lines > 0:
print(
f" Warning: {dropped_lines} SSE lines had malformed JSON",
file=sys.stderr,
)
# Extract <custom_token_N> strings # Flush remaining tokens (drop partial frame — at most 0.29ms lost)
token_strings = TOKEN_PATTERN.findall(resp_text) if token_strings:
token_strings = [f"<custom_token_{t}>" for t in token_strings] usable = len(token_strings) - (len(token_strings) % TOKENS_PER_FRAME)
if usable > 0:
# Skip leading special tokens (values < 10) chunk_audio = await loop.run_in_executor(
skip = 0 None, _tokens_to_audio, token_strings[:usable], snac
for ts in token_strings: )
m = TOKEN_PATTERN.search(ts) if chunk_audio is not None:
if m and int(m.group(1)) < 10: audio_chunks.append(chunk_audio)
skip += 1
else:
break
if skip > 0:
token_strings = token_strings[skip:]
num_frames = total_tokens // TOKENS_PER_FRAME
tok_per_sec = total_tokens / gen_time if gen_time > 0 else 0
status = " (TRUNCATED)" if stream_interrupted else ""
print( print(
f" Orpheus: {len(token_strings)} tokens " f" Orpheus: {total_tokens} tokens ({num_frames} frames) "
f"({len(token_strings) // 7} frames) in {gen_time:.1f}s", f"in {gen_time:.1f}s ({tok_per_sec:.1f} tok/s){status}",
file=sys.stderr, file=sys.stderr,
) )
if len(token_strings) < 7: if not audio_chunks:
raise RuntimeError( raise RuntimeError(
f"Orpheus returned insufficient tokens ({len(token_strings)}). " f"Orpheus returned insufficient tokens ({total_tokens}). "
f"Response preview: {resp_text[:200]}" "Check llama-server logs."
) )
# Lazy-load SNAC, then decode (CPU-bound) audio = np.concatenate(audio_chunks)
snac = await self._get_snac()
audio = await loop.run_in_executor(
None, _tokens_to_audio, token_strings, snac
)
if audio is None:
raise RuntimeError("SNAC decoding produced no audio")
path = write_wav(audio, SAMPLE_RATE, prefix="orpheus-") path = write_wav(audio, SAMPLE_RATE, prefix="orpheus-")
return TTSResult( return TTSResult(
@ -205,15 +276,15 @@ class OrpheusEngine(TTSEngine):
async def check_health(self) -> dict: async def check_health(self) -> dict:
try: try:
resp = requests.get(f"{self._ollama_url}/api/tags", timeout=5) resp = await self._client.get(f"{self._url}/health")
resp.raise_for_status() resp.raise_for_status()
models = [m["name"] for m in resp.json().get("models", [])] data = resp.json()
has_orpheus = any("orpheus" in m.lower() for m in models) status = data.get("status", "unknown")
return { return {
"status": "healthy" if has_orpheus else "degraded", "status": "healthy" if status == "ok" else "degraded",
"engine": self.name, "engine": self.name,
"model_loaded": has_orpheus, "backend": "llama-server",
"ollama_models": len(models), "server_status": status,
} }
except Exception as e: except Exception as e:
return {"status": "unhealthy", "engine": self.name, "error": str(e)} return {"status": "unhealthy", "engine": self.name, "error": str(e)}

View File

@ -44,7 +44,7 @@ async def app_lifespan(server: FastMCP):
engines: dict[str, TTSEngine] = { engines: dict[str, TTSEngine] = {
"piper": PiperEngine(settings.piper_host, settings.piper_port), "piper": PiperEngine(settings.piper_host, settings.piper_port),
"kokoro": KokoroEngine(kokoro_model), "kokoro": KokoroEngine(kokoro_model),
"orpheus": OrpheusEngine(settings.ollama_url, settings.orpheus_model), "orpheus": OrpheusEngine(settings.orpheus_url),
} }
# Health check all engines at startup # Health check all engines at startup
@ -66,6 +66,9 @@ async def app_lifespan(server: FastMCP):
finally: finally:
print("TTS MCP server shutting down", file=sys.stderr) print("TTS MCP server shutting down", file=sys.stderr)
await queue.stop() await queue.stop()
for eng in engines.values():
if hasattr(eng, "close"):
await eng.close()
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@ -79,7 +82,7 @@ mcp = FastMCP(
"through the host speakers (queued so agents don't talk over each other). " "through the host speakers (queued so agents don't talk over each other). "
"Use 'generate_audio' to synthesize without playing. " "Use 'generate_audio' to synthesize without playing. "
"Engines: kokoro (fast ONNX, ~50 voices), piper (Wyoming/Docker), " "Engines: kokoro (fast ONNX, ~50 voices), piper (Wyoming/Docker), "
"orpheus (Ollama LLM, supports <laugh> etc.)." "orpheus (LLM via llama-server, supports <laugh> etc.)."
), ),
lifespan=app_lifespan, lifespan=app_lifespan,
) )

View File

@ -20,9 +20,8 @@ class Settings(BaseSettings):
kokoro_model: Path = Path("models/kokoro/kokoro-v1.0.onnx") kokoro_model: Path = Path("models/kokoro/kokoro-v1.0.onnx")
kokoro_voices: Path = Path("models/kokoro/voices-v1.0.bin") kokoro_voices: Path = Path("models/kokoro/voices-v1.0.bin")
# Orpheus (Ollama + SNAC) # Orpheus (llama-server + SNAC)
ollama_url: str = "http://127.0.0.1:11434" orpheus_url: str = "http://127.0.0.1:8081"
orpheus_model: str = "legraphista/Orpheus:3b-ft-q4_k_m"
# Voice filtering # Voice filtering
voice_blacklist: str = "amy,jess,zoe,adam" voice_blacklist: str = "amy,jess,zoe,adam"

4
uv.lock generated
View File

@ -1977,11 +1977,11 @@ version = "2026.2.20"
source = { editable = "." } source = { editable = "." }
dependencies = [ dependencies = [
{ name = "fastmcp" }, { name = "fastmcp" },
{ name = "httpx" },
{ name = "kokoro-onnx" }, { name = "kokoro-onnx" },
{ name = "numpy" }, { name = "numpy" },
{ name = "onnxruntime" }, { name = "onnxruntime" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "requests" },
{ name = "snac" }, { name = "snac" },
{ name = "soundfile" }, { name = "soundfile" },
{ name = "torch" }, { name = "torch" },
@ -1991,11 +1991,11 @@ dependencies = [
[package.metadata] [package.metadata]
requires-dist = [ requires-dist = [
{ name = "fastmcp", specifier = ">=3.0.0" }, { name = "fastmcp", specifier = ">=3.0.0" },
{ name = "httpx" },
{ name = "kokoro-onnx", specifier = ">=0.5.0" }, { name = "kokoro-onnx", specifier = ">=0.5.0" },
{ name = "numpy" }, { name = "numpy" },
{ name = "onnxruntime" }, { name = "onnxruntime" },
{ name = "pydantic-settings" }, { name = "pydantic-settings" },
{ name = "requests" },
{ name = "snac", specifier = ">=1.2.1" }, { name = "snac", specifier = ">=1.2.1" },
{ name = "soundfile" }, { name = "soundfile" },
{ name = "torch" }, { name = "torch" },