mcspeak/src/tts_mcp/engines/orpheus.py
Ryan Malloy 7f9557d93b Increase Orpheus Ollama timeout from 120s to 600s
Long emotional monologues with multiple <sigh>/<laugh>/<gasp> tags
generate ~4000 tokens at ~12 tok/s, easily exceeding 2 minutes.
2026-02-21 17:09:13 -07:00

220 lines
7.1 KiB
Python

"""Orpheus TTS via Ollama + SNAC decoder.
Sends text to Ollama's Orpheus model, parses <custom_token_N> responses,
and decodes through SNAC to 24kHz WAV. SNAC is lazy-loaded on first use
and runs on CPU (RTX 5070 SM 120 not yet supported by PyTorch 2.6).
"""
import asyncio
import os
import re
import sys
import time
import numpy as np
import requests
from ..audio import wav_duration, write_wav
from ..settings import settings
from .base import TTSEngine, TTSResult
SAMPLE_RATE = 24000
SNAC_CODEBOOK_SIZE = 4096
TOKEN_PATTERN = re.compile(r"<custom_token_(\d+)>")
ALL_VOICES = ["tara", "leah", "jess", "leo", "dan", "mia", "zac", "zoe"]
def _turn_token_into_id(token_str: str, index: int) -> int:
"""Convert a <custom_token_N> string to a SNAC codebook ID.
Applies position-dependent offset: each of the 7 tokens per frame
maps to a different SNAC codebook layer at a different offset.
"""
match = TOKEN_PATTERN.search(token_str)
if not match:
return -1
raw_id = int(match.group(1))
return raw_id - 10 - ((index % 7) * SNAC_CODEBOOK_SIZE)
def _tokens_to_audio(token_strings: list[str], snac_model) -> np.ndarray | None:
"""Convert Orpheus custom token strings to audio via SNAC.
Redistributes the flat token stream into SNAC's 3 codebook layers:
- codes_0: 1 per frame (positions 0) -> coarse
- codes_1: 2 per frame (positions 1, 4) -> mid
- codes_2: 4 per frame (positions 2,3,5,6) -> fine
"""
import torch
num_frames = len(token_strings) // 7
if num_frames == 0:
return None
token_strings = token_strings[: num_frames * 7]
ids = [_turn_token_into_id(t, i) for i, t in enumerate(token_strings)]
invalid = sum(1 for x in ids if x < 0 or x >= SNAC_CODEBOOK_SIZE)
if invalid > 0:
print(f" Warning: {invalid}/{len(ids)} invalid token IDs", file=sys.stderr)
codes_0, codes_1, codes_2 = [], [], []
for i in range(num_frames):
b = i * 7
codes_0.append(ids[b + 0])
codes_1.append(ids[b + 1])
codes_2.append(ids[b + 2])
codes_2.append(ids[b + 3])
codes_1.append(ids[b + 4])
codes_2.append(ids[b + 5])
codes_2.append(ids[b + 6])
def clamp(lst):
return [max(0, min(SNAC_CODEBOOK_SIZE - 1, x)) for x in lst]
device = "cpu"
codes = [
torch.tensor(clamp(codes_0), dtype=torch.long).unsqueeze(0).to(device),
torch.tensor(clamp(codes_1), dtype=torch.long).unsqueeze(0).to(device),
torch.tensor(clamp(codes_2), dtype=torch.long).unsqueeze(0).to(device),
]
with torch.no_grad():
audio = snac_model.decode(codes)
return audio.squeeze().cpu().numpy()
def _load_snac():
"""Load SNAC model on CPU. Called once, lazily."""
os.environ["CUDA_VISIBLE_DEVICES"] = ""
from snac import SNAC
return SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to("cpu")
class OrpheusEngine(TTSEngine):
"""Orpheus TTS via Ollama's completions API + SNAC audio decoding.
SNAC is lazy-loaded on first synthesize() call to avoid holding
~200MB of RAM when the engine isn't being used.
"""
name = "orpheus"
default_voice = "tara"
def __init__(self, ollama_url: str, model_name: str) -> None:
self._snac = None
self._snac_lock = asyncio.Lock()
self._ollama_url = ollama_url
self._model = model_name
async def _get_snac(self):
"""Lazy-load SNAC on first use."""
if self._snac is not None:
return self._snac
async with self._snac_lock:
if self._snac is not None:
return self._snac
print("Loading SNAC decoder on CPU (first Orpheus call)...", file=sys.stderr)
t0 = time.time()
loop = asyncio.get_running_loop()
self._snac = await loop.run_in_executor(None, _load_snac)
print(f" SNAC ready in {time.time() - t0:.1f}s", file=sys.stderr)
return self._snac
async def synthesize(self, text: str, voice: str | None = None) -> TTSResult:
voice = voice or self.default_voice
prompt = f"<|audio|>{voice}: {text}<|eot_id|>"
loop = asyncio.get_running_loop()
# Ollama API call (blocking HTTP)
t0 = time.time()
def _call_ollama():
resp = requests.post(
f"{self._ollama_url}/v1/completions",
json={
"model": self._model,
"prompt": prompt,
"max_tokens": 8192,
"temperature": 0.6,
"top_p": 0.9,
"stream": False,
},
timeout=600,
)
resp.raise_for_status()
return resp.json()
result = await loop.run_in_executor(None, _call_ollama)
gen_time = time.time() - t0
resp_text = result.get("choices", [{}])[0].get("text", "")
# Extract <custom_token_N> strings
token_strings = TOKEN_PATTERN.findall(resp_text)
token_strings = [f"<custom_token_{t}>" for t in token_strings]
# Skip leading special tokens (values < 10)
skip = 0
for ts in token_strings:
m = TOKEN_PATTERN.search(ts)
if m and int(m.group(1)) < 10:
skip += 1
else:
break
if skip > 0:
token_strings = token_strings[skip:]
print(
f" Orpheus: {len(token_strings)} tokens "
f"({len(token_strings) // 7} frames) in {gen_time:.1f}s",
file=sys.stderr,
)
if len(token_strings) < 7:
raise RuntimeError(
f"Orpheus returned insufficient tokens ({len(token_strings)}). "
f"Response preview: {resp_text[:200]}"
)
# Lazy-load SNAC, then decode (CPU-bound)
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-")
return TTSResult(
audio_path=path,
sample_rate=SAMPLE_RATE,
duration_seconds=wav_duration(path),
engine=self.name,
voice=voice,
)
async def list_voices(self) -> list[str]:
blacklist = settings.blacklisted_voices
return sorted(v for v in ALL_VOICES if v.lower() not in blacklist)
async def check_health(self) -> dict:
try:
resp = requests.get(f"{self._ollama_url}/api/tags", timeout=5)
resp.raise_for_status()
models = [m["name"] for m in resp.json().get("models", [])]
has_orpheus = any("orpheus" in m.lower() for m in models)
return {
"status": "healthy" if has_orpheus else "degraded",
"engine": self.name,
"model_loaded": has_orpheus,
"ollama_models": len(models),
}
except Exception as e:
return {"status": "unhealthy", "engine": self.name, "error": str(e)}