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
196 lines
6.0 KiB
Python
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)
|