"""Orpheus TTS via Ollama + SNAC decoder. Sends text to Ollama's Orpheus model via OpenAI-compatible completions API, parses the responses, and decodes through SNAC to 24kHz WAV. Voices: tara, leah, jess, leo, dan, mia, zac, zoe Emotion tags: , , , , , , """ 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'') # 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 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 strings token_strings = TOKEN_PATTERN.findall(resp_text) token_strings = [f"" 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)