| """The Voice β text to speech. |
| |
| Synthesises NPC dialogue using Kokoro-82M via ONNX (~82M params, no transformers dependency). |
| |
| Design notes: |
| - Voice selection reads Character.tts_voice_description (frozen at creation) and scores every |
| Kokoro preset by keyword overlap β the best-scoring voice wins. sprite_seed breaks ties so |
| two characters with the same description still get different voices. |
| - `tts_voice_description` is frozen at character creation (see state.apply_directives and |
| orchestrator.init_world), ensuring voice identity is stable for the whole session. |
| - Results are cached in memory by SHA-256(text + voice_id + seed) so repeated lines are free. |
| |
| Model files (~337 MB total) live in models/kokoro/ and are downloaded once: |
| curl -L -o models/kokoro/kokoro-v1.0.onnx \\ |
| https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx |
| curl -L -o models/kokoro/voices-v1.0.bin \\ |
| https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin |
| """ |
|
|
| from __future__ import annotations |
|
|
| import hashlib |
| import io |
| from typing import Protocol |
|
|
| from . import config |
|
|
| |
| |
| |
| |
| |
| _VOICE_PROFILES: list[tuple[str, str, frozenset[str]]] = [ |
| |
| ( |
| "af_heart", |
| "f", |
| frozenset({"warm", "bright", "breathless", "tender", "sweet", "laughs", "light"}), |
| ), |
| ( |
| "af_bella", |
| "f", |
| frozenset({"soft", "gentle", "quiet", "shy", "delicate", "whisper", "whispered"}), |
| ), |
| ("af_sarah", "f", frozenset({"clear", "professional", "calm", "steady", "cool", "composed"})), |
| ("af_sky", "f", frozenset({"airy", "bubbly", "cheerful", "energetic", "lively", "peppy"})), |
| ( |
| "af_jessica", |
| "f", |
| frozenset({"bold", "confident", "sharp", "spirited", "assertive", "direct"}), |
| ), |
| ("af_nicole", "f", frozenset({"rich", "smooth", "soothing", "mature", "warm", "velvet"})), |
| ("af_nova", "f", frozenset({"crisp", "precise", "intelligent", "focused", "analytical"})), |
| ("af_river", "f", frozenset({"flowing", "peaceful", "serene", "dreamy", "drifts", "trails"})), |
| ("af_kore", "f", frozenset({"strong", "powerful", "serious", "stern", "commanding"})), |
| ("af_aoede", "f", frozenset({"melodic", "musical", "lyrical", "poetic", "ethereal", "sings"})), |
| ("af_alloy", "f", frozenset({"neutral", "balanced", "modern", "even"})), |
| |
| ("bf_alice", "f", frozenset({"british", "english", "uk", "posh", "accent", "clipped"})), |
| ("bf_emma", "f", frozenset({"british", "english", "uk", "homely", "friendly", "warm"})), |
| ( |
| "bf_isabella", |
| "f", |
| frozenset({"british", "english", "uk", "refined", "elegant", "sophisticated"}), |
| ), |
| ("bf_lily", "f", frozenset({"british", "english", "uk", "gentle", "soft", "sweet"})), |
| |
| ("am_adam", "m", frozenset({"confident", "deep", "steady", "bass", "strong", "rich"})), |
| ("am_echo", "m", frozenset({"resonant", "full", "commanding", "measured"})), |
| ("am_eric", "m", frozenset({"friendly", "casual", "warm", "approachable", "cheerful"})), |
| ( |
| "am_fenrir", |
| "m", |
| frozenset({"gruff", "rough", "intense", "dark", "rugged", "growl", "harsh"}), |
| ), |
| ("am_liam", "m", frozenset({"young", "energetic", "bright", "enthusiastic", "lively"})), |
| ("am_michael", "m", frozenset({"mature", "calm", "authoritative", "professorial", "quiet"})), |
| ("am_onyx", "m", frozenset({"smooth", "velvet", "baritone", "low", "silky", "suave"})), |
| ("am_puck", "m", frozenset({"playful", "mischievous", "quick", "witty", "impish", "teasing"})), |
| |
| ("bm_daniel", "m", frozenset({"british", "english", "uk", "smooth", "refined", "crisp"})), |
| ( |
| "bm_fable", |
| "m", |
| frozenset({"british", "english", "uk", "storyteller", "dramatic", "expressive"}), |
| ), |
| ("bm_george", "m", frozenset({"british", "english", "uk", "distinguished", "formal", "noble"})), |
| ("bm_lewis", "m", frozenset({"british", "english", "uk", "young", "casual", "easy"})), |
| ] |
|
|
| _MALE_GENDER_HINTS = {"man", "male", "his", "him", "he", "boy", "lad", "gentleman"} |
| _FEMALE_GENDER_HINTS = {"woman", "female", "her", "she", "girl", "lady"} |
|
|
| _MODEL_PATH = config.MODELS_DIR / "kokoro" / "kokoro-v1.0.onnx" |
| _VOICES_PATH = config.MODELS_DIR / "kokoro" / "voices-v1.0.bin" |
| _KOKORO_BASE_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0" |
|
|
|
|
| def _ensure_kokoro_models() -> None: |
| """Download Kokoro model files if absent (handles HF Space ephemeral filesystem).""" |
| files = { |
| _MODEL_PATH: f"{_KOKORO_BASE_URL}/kokoro-v1.0.onnx", |
| _VOICES_PATH: f"{_KOKORO_BASE_URL}/voices-v1.0.bin", |
| } |
| missing = {p: u for p, u in files.items() if not p.exists()} |
| if not missing: |
| return |
| import urllib.request |
|
|
| _MODEL_PATH.parent.mkdir(parents=True, exist_ok=True) |
| for path, url in missing.items(): |
| print(f"[tts] Downloading {path.name} β¦", flush=True) |
| urllib.request.urlretrieve(url, path) |
| print(f"[tts] {path.name} ready ({path.stat().st_size // 1_000_000} MB)", flush=True) |
|
|
|
|
| def _pick_voice(voice_description: str, sprite_seed: int) -> str: |
| """Score every Kokoro voice against the description; use sprite_seed to break ties.""" |
| words = set(voice_description.lower().split()) |
|
|
| |
| is_male = bool(words & _MALE_GENDER_HINTS) |
| is_female = bool(words & _FEMALE_GENDER_HINTS) |
| |
| target_gender = "m" if is_male and not is_female else "f" |
|
|
| candidates = [(vid, kws) for vid, g, kws in _VOICE_PROFILES if g == target_gender] |
|
|
| |
| scored = [(len(words & kws), vid) for vid, kws in candidates] |
| scored.sort(key=lambda x: x[0], reverse=True) |
|
|
| |
| top_score = scored[0][0] |
| top_voices = [vid for score, vid in scored if score == top_score] |
| return top_voices[sprite_seed % len(top_voices)] |
|
|
|
|
| class TTSBackend(Protocol): |
| def synthesize(self, text: str, voice_description: str, seed: int) -> bytes | None: ... |
|
|
|
|
| class MockTTS: |
| def synthesize(self, text: str, voice_description: str, seed: int) -> bytes | None: |
| return None |
|
|
|
|
| class KokoroTTS: |
| def __init__(self) -> None: |
| import logging |
|
|
| from kokoro_onnx import Kokoro |
|
|
| |
| |
| logging.getLogger("phonemizer").addFilter(lambda r: r.levelno >= logging.ERROR) |
|
|
| _ensure_kokoro_models() |
| self._kokoro = Kokoro(str(_MODEL_PATH), str(_VOICES_PATH)) |
| |
| available = set(self._kokoro.get_voices()) |
| self._available_voices = available |
| self._cache: dict[str, bytes] = {} |
|
|
| def synthesize(self, text: str, voice_description: str, seed: int) -> bytes | None: |
| import soundfile as sf |
|
|
| voice_id = _pick_voice(voice_description, seed) |
| |
| if voice_id not in self._available_voices: |
| fallback = [ |
| vid for vid, g, _ in _VOICE_PROFILES if g == "f" and vid in self._available_voices |
| ] |
| voice_id = ( |
| fallback[seed % len(fallback)] if fallback else next(iter(self._available_voices)) |
| ) |
|
|
| text = text.strip() |
| if not text: |
| return None |
|
|
| cache_key = hashlib.sha256(f"{text}\x00{voice_id}\x00{seed}".encode()).hexdigest() |
| if cache_key in self._cache: |
| return self._cache[cache_key] |
|
|
| try: |
| samples, sample_rate = self._kokoro.create( |
| text, voice=voice_id, speed=1.0, lang="en-us" |
| ) |
| except (ValueError, Exception): |
| return None |
|
|
| if samples is None or (hasattr(samples, "__len__") and len(samples) == 0): |
| return None |
|
|
| buf = io.BytesIO() |
| sf.write(buf, samples, sample_rate, format="WAV") |
| wav_bytes = buf.getvalue() |
|
|
| self._cache[cache_key] = wav_bytes |
| return wav_bytes |
|
|
|
|
| def get_tts() -> TTSBackend: |
| if config.TTS_BACKEND == "kokoro": |
| return KokoroTTS() |
| return MockTTS() |
|
|