mcspeak/orpheus_tts.py
Ryan Malloy 2fd84f0df7 Initial TTS MCP server with 3 engines
FastMCP 3.0 Streamable HTTP server exposing Piper (Wyoming/Docker),
Kokoro (ONNX), and Orpheus (Ollama+SNAC) as MCP tools. Includes a
FIFO speech queue so concurrent agents don't talk over each other —
waiting callers get queue position updates via ctx.info().

Tools: speak, generate_audio, list_voices, list_engines
Resource: audio://recent
2026-02-20 18:10:15 -07:00

196 lines
6.0 KiB
Python

"""Orpheus TTS via Ollama + SNAC decoder.
Sends text to Ollama's Orpheus model via OpenAI-compatible completions API,
parses the <custom_token_N> responses, and decodes through SNAC to 24kHz WAV.
Voices: tara, leah, jess, leo, dan, mia, zac, zoe
Emotion tags: <laugh>, <chuckle>, <sigh>, <cough>, <gasp>, <yawn>, <groan>
"""
import re
import subprocess
import sys
import tempfile
import time
import wave
import numpy as np
import requests
import torch
SAMPLE_RATE = 24000
SNAC_CODEBOOK_SIZE = 4096
OLLAMA_URL = "http://127.0.0.1:11434"
MODEL = "legraphista/Orpheus:3b-ft-q4_k_m"
TOKEN_PATTERN = re.compile(r'<custom_token_(\d+)>')
# Cache the SNAC model across calls
_snac_model = None
def _get_snac():
"""Load and cache the SNAC decoder model."""
global _snac_model
if _snac_model is None:
from snac import SNAC
device = "cuda" if torch.cuda.is_available() else "cpu"
print(f" Loading SNAC decoder on {device}...", file=sys.stderr)
_snac_model = SNAC.from_pretrained("hubertsiuzdak/snac_24khz").to(device)
return _snac_model
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]) -> 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
"""
num_frames = len(token_strings) // 7
if num_frames == 0:
return None
token_strings = token_strings[:num_frames * 7]
# Convert all tokens to IDs with position-dependent offsets
ids = [turn_token_into_id(t, i) for i, t in enumerate(token_strings)]
# Check for invalid tokens
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)
# Redistribute into 3 SNAC layers
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])
# Clamp values to valid range
def clamp(lst):
return [max(0, min(SNAC_CODEBOOK_SIZE - 1, x)) for x in lst]
codes_0 = clamp(codes_0)
codes_1 = clamp(codes_1)
codes_2 = clamp(codes_2)
device = "cuda" if torch.cuda.is_available() else "cpu"
snac = _get_snac()
codes = [
torch.tensor(codes_0, dtype=torch.long).unsqueeze(0).to(device),
torch.tensor(codes_1, dtype=torch.long).unsqueeze(0).to(device),
torch.tensor(codes_2, dtype=torch.long).unsqueeze(0).to(device),
]
with torch.no_grad():
audio = snac.decode(codes)
return audio.squeeze().cpu().numpy()
def generate_speech(text: str, voice: str = "tara") -> np.ndarray | None:
"""Generate speech from text using Orpheus via Ollama's OpenAI-compatible API."""
prompt = f"<|audio|>{voice}: {text}<|eot_id|>"
print(f" Sending to Orpheus ({voice})...", file=sys.stderr)
t0 = time.time()
response = requests.post(
f"{OLLAMA_URL}/v1/completions",
json={
"model": MODEL,
"prompt": prompt,
"max_tokens": 8192,
"temperature": 0.6,
"top_p": 0.9,
"stream": False,
},
)
result = response.json()
resp_text = result.get("choices", [{}])[0].get("text", "")
gen_time = time.time() - t0
# Extract all <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 (start/header tokens with small values)
# Audio tokens have values in range ~10-28681; tokens <10 are special
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:
print(f" Skipping {skip} leading special tokens", file=sys.stderr)
token_strings = token_strings[skip:]
print(
f" Got {len(token_strings)} audio tokens ({len(token_strings)//7} frames) "
f"in {gen_time:.1f}s",
file=sys.stderr,
)
if len(token_strings) < 7:
print(f" Insufficient tokens. Response: {resp_text[:300]}", file=sys.stderr)
return None
return tokens_to_audio(token_strings)
def speak(text: str, voice: str = "tara"):
"""Generate speech and play it through PipeWire."""
audio = generate_speech(text, voice)
if audio is None:
print("Failed to generate audio", file=sys.stderr)
return
# Normalize to 16-bit PCM
peak = max(abs(audio.max()), abs(audio.min()), 1e-8)
audio_int16 = (audio / peak * 32767).astype(np.int16)
tmp = tempfile.NamedTemporaryFile(suffix=".wav", delete=False, prefix="orpheus-")
with wave.open(tmp.name, "wb") as wf:
wf.setnchannels(1)
wf.setsampwidth(2)
wf.setframerate(SAMPLE_RATE)
wf.writeframes(audio_int16.tobytes())
duration = len(audio_int16) / SAMPLE_RATE
print(f" Audio: {duration:.1f}s at {SAMPLE_RATE}Hz -> {tmp.name}", file=sys.stderr)
subprocess.run(["pw-play", tmp.name])
if __name__ == "__main__":
text = " ".join(sys.argv[1:]) or (
"Hey Ryan, this is Orpheus speaking from the fix TTS project. "
"Pretty wild that a language model can sound this natural, right?"
)
speak(text)