Spaces:
Sleeping
Sleeping
Download services/tts_service.py from mahrooshishaq/Smartfolio-python: direct link, hf CLI and curl.
- Browser
- Download file 10.8 kB
-
https://huggingface.co/spaces/mahrooshishaq/Smartfolio-python/resolve/main/services/tts_service.py
- Command line
-
hf download hf://spaces/mahrooshishaq/Smartfolio-python/services/tts_service.py
-
curl -L -o tts_service.py https://huggingface.co/spaces/mahrooshishaq/Smartfolio-python/resolve/main/services/tts_service.py
10.8 kB
| """ | |
| Text-to-speech using Kokoro-82M (https://huggingface.co/hexgrad/Kokoro-82M). | |
| Two interchangeable backends, selected by TTS_BACKEND (auto | onnx | torch): | |
| - "onnx" β kokoro-onnx running the **fp32** ONNX export (bit-identical weights | |
| to the torch release, so no quality loss) + misaki for English G2P (the same | |
| phonemizer the torch pipeline uses, so pronunciation is unchanged). 2-4x | |
| faster than torch on CPU and keeps torch out of the image entirely. | |
| Needs the model files (see Dockerfile): | |
| KOKORO_ONNX_MODEL (default models/kokoro-v1.0.onnx) | |
| KOKORO_ONNX_VOICES (default models/voices-v1.0.bin) | |
| - "torch" β the original `kokoro` KPipeline (pip install kokoro soundfile). | |
| "auto" prefers ONNX when its deps + model files are present, else torch, else | |
| the service reports unavailable and callers fall back to browser speech. | |
| Everything loads lazily on first use (call `warmup()` at startup to pre-pay | |
| that cost off the request path). Synthesis results are LRU-cached per | |
| (text, voice, format) β interview questions are static text, so replays and | |
| re-entered rounds are served instantly. A semaphore keeps concurrent syntheses | |
| from thrashing each other's CPU threads. | |
| """ | |
| import io | |
| import os | |
| import re | |
| import threading | |
| from collections import OrderedDict | |
| from pathlib import Path | |
| # American English by default. The interviewer persona ("Folio") uses a male | |
| # voice β am_michael is the highest-rated US male in Kokoro v1.0 (am_fenrir and | |
| # am_puck are the alternatives; af_heart is the best female if ever needed). | |
| DEFAULT_VOICE = "am_michael" | |
| SAMPLE_RATE = 24000 | |
| _SERVICE_ROOT = Path(__file__).resolve().parent.parent | |
| _backend = None | |
| _backend_error: str | None = None | |
| _init_lock = threading.Lock() | |
| # Only TTS_MAX_CONCURRENCY syntheses run at once; both backends parallelize a | |
| # single utterance across cores, so more concurrency usually means all requests | |
| # get slower, not faster. | |
| _synth_sem = threading.BoundedSemaphore(int(os.getenv("TTS_MAX_CONCURRENCY", "1"))) | |
| _cache: "OrderedDict[tuple, tuple[bytes, str]]" = OrderedDict() | |
| _cache_lock = threading.Lock() | |
| _CACHE_MAX = int(os.getenv("TTS_CACHE_SIZE", "256")) | |
| # Second cache level: raw float32 samples per (text, voice). Synthesis is the | |
| # expensive step (seconds); encoding is milliseconds β so a request for the | |
| # same text in a different container (e.g. Safari asking for wav after the | |
| # cache was warmed with ogg) re-encodes instead of re-synthesizing. | |
| _sample_cache: "OrderedDict[tuple, object]" = OrderedDict() | |
| _SAMPLE_CACHE_MAX = int(os.getenv("TTS_SAMPLE_CACHE_SIZE", "128")) | |
| def _onnx_paths() -> tuple[Path, Path]: | |
| model = Path(os.getenv("KOKORO_ONNX_MODEL", "models/kokoro-v1.0.onnx")) | |
| voices = Path(os.getenv("KOKORO_ONNX_VOICES", "models/voices-v1.0.bin")) | |
| if not model.is_absolute(): | |
| model = _SERVICE_ROOT / model | |
| if not voices.is_absolute(): | |
| voices = _SERVICE_ROOT / voices | |
| return model, voices | |
| def _split_chunks(text: str, max_len: int = 300) -> list[str]: | |
| """Sentence-ish chunks under max_len chars (stays well inside Kokoro's | |
| phoneme-sequence limit). The torch pipeline splits internally; the ONNX | |
| path needs us to do it.""" | |
| sentences = re.findall(r"[^.?!]+[.?!]+[\"')\]]*\s*|[^.?!]+$", text) | |
| chunks: list[str] = [] | |
| current = "" | |
| for s in sentences: | |
| s = s.strip() | |
| if not s: | |
| continue | |
| if current and len(current) + len(s) + 1 > max_len: | |
| chunks.append(current) | |
| current = s | |
| else: | |
| current = f"{current} {s}".strip() | |
| while len(current) > max_len: # single overlong sentence: hard split | |
| chunks.append(current[:max_len]) | |
| current = current[max_len:].strip() | |
| if current: | |
| chunks.append(current) | |
| return chunks or [text] | |
| class _OnnxBackend: | |
| """kokoro-onnx (fp32 weights) + misaki G2P β torch-free, CPU-fast.""" | |
| name = "onnx" | |
| def __init__(self): | |
| from kokoro_onnx import Kokoro | |
| from misaki import en | |
| model, voices = _onnx_paths() | |
| if not model.exists() or not voices.exists(): | |
| raise FileNotFoundError(f"Kokoro ONNX model files missing ({model}, {voices})") | |
| # Same G2P stack as the torch pipeline: misaki dictionary first, | |
| # espeak-ng for out-of-dictionary words when available. | |
| fallback = None | |
| try: | |
| from misaki import espeak | |
| fallback = espeak.EspeakFallback(british=False) | |
| except Exception: | |
| pass # espeak-ng not installed β dictionary-only G2P still works | |
| self._g2p = en.G2P(trf=False, british=False, fallback=fallback) | |
| self._kokoro = Kokoro(str(model), str(voices)) | |
| def segments(self, text: str, voice: str): | |
| import numpy as np | |
| for chunk in _split_chunks(text): | |
| phonemes, _ = self._g2p(chunk) | |
| if not phonemes: | |
| continue | |
| samples, _sr = self._kokoro.create(phonemes, voice=voice, is_phonemes=True) | |
| yield np.asarray(samples, dtype="float32") | |
| class _TorchBackend: | |
| """The original kokoro KPipeline (heavy: pulls in torch).""" | |
| name = "torch" | |
| def __init__(self): | |
| from kokoro import KPipeline | |
| threads = os.getenv("TTS_TORCH_THREADS") | |
| if threads: | |
| import torch | |
| torch.set_num_threads(max(1, int(threads))) | |
| self._pipeline = KPipeline(lang_code="a") # 'a' = American English | |
| def segments(self, text: str, voice: str): | |
| import numpy as np | |
| for _, _, audio in self._pipeline(text, voice=voice): | |
| # Kokoro yields a torch tensor (or ndarray) of float samples per segment. | |
| if hasattr(audio, "detach"): | |
| audio = audio.detach().cpu().numpy() | |
| yield np.asarray(audio, dtype="float32") | |
| def _get_backend(): | |
| """Lazily build (and cache) the configured backend.""" | |
| global _backend, _backend_error | |
| if _backend is not None: | |
| return _backend | |
| with _init_lock: | |
| if _backend is not None: | |
| return _backend | |
| choice = os.getenv("TTS_BACKEND", "auto").lower() | |
| errors = [] | |
| if choice in ("auto", "onnx"): | |
| try: | |
| _backend = _OnnxBackend() | |
| except Exception as e: | |
| errors.append(f"onnx: {e}") | |
| if choice == "onnx": | |
| _backend_error = "; ".join(errors) | |
| raise | |
| if _backend is None and choice in ("auto", "torch"): | |
| try: | |
| _backend = _TorchBackend() | |
| except Exception as e: | |
| errors.append(f"torch: {e}") | |
| if _backend is None: | |
| _backend_error = "; ".join(errors) or f"unknown TTS_BACKEND {choice!r}" | |
| raise RuntimeError(f"No TTS backend available ({_backend_error})") | |
| _backend_error = None | |
| return _backend | |
| def is_available() -> bool: | |
| """True if some backend can plausibly run (cheap check, no model load).""" | |
| if _backend is not None: | |
| return True | |
| try: | |
| import kokoro_onnx # noqa: F401 | |
| import misaki # noqa: F401 | |
| import soundfile # noqa: F401 | |
| model, voices = _onnx_paths() | |
| if model.exists() and voices.exists(): | |
| return True | |
| except Exception: | |
| pass | |
| try: | |
| import kokoro # noqa: F401 | |
| import soundfile # noqa: F401 | |
| return True | |
| except Exception: | |
| return False | |
| def backend_status() -> dict: | |
| """For /tts/health: which backend is configured/loaded.""" | |
| return { | |
| "configured": os.getenv("TTS_BACKEND", "auto"), | |
| "loaded": _backend.name if _backend is not None else None, | |
| "error": _backend_error, | |
| } | |
| def _cache_get(key: tuple) -> tuple[bytes, str] | None: | |
| with _cache_lock: | |
| hit = _cache.get(key) | |
| if hit is not None: | |
| _cache.move_to_end(key) | |
| return hit | |
| def _cache_put(key: tuple, value: tuple[bytes, str]) -> None: | |
| with _cache_lock: | |
| _cache[key] = value | |
| _cache.move_to_end(key) | |
| while len(_cache) > _CACHE_MAX: | |
| _cache.popitem(last=False) | |
| def _samples_get(key: tuple): | |
| with _cache_lock: | |
| hit = _sample_cache.get(key) | |
| if hit is not None: | |
| _sample_cache.move_to_end(key) | |
| return hit | |
| def _samples_put(key: tuple, samples) -> None: | |
| with _cache_lock: | |
| _sample_cache[key] = samples | |
| _sample_cache.move_to_end(key) | |
| while len(_sample_cache) > _SAMPLE_CACHE_MAX: | |
| _sample_cache.popitem(last=False) | |
| def _encode(samples, fmt: str) -> tuple[bytes, str]: | |
| """Encode float32 samples β (bytes, mime). OGG/Opus is ~10x smaller than | |
| WAV for speech; falls back to WAV if libsndfile lacks Opus support.""" | |
| import soundfile as sf | |
| buf = io.BytesIO() | |
| if fmt == "ogg": | |
| try: | |
| sf.write(buf, samples, SAMPLE_RATE, format="OGG", subtype="OPUS") | |
| return buf.getvalue(), "audio/ogg" | |
| except Exception: | |
| buf = io.BytesIO() | |
| sf.write(buf, samples, SAMPLE_RATE, format="WAV") | |
| return buf.getvalue(), "audio/wav" | |
| def synthesize(text: str, voice: str | None = None, fmt: str = "wav") -> tuple[bytes, str]: | |
| """ | |
| Synthesize `text` β (audio bytes, mime type). Raises on any failure so the | |
| router can return 503 and the client can fall back to browser TTS. | |
| """ | |
| import numpy as np | |
| voice = voice or DEFAULT_VOICE | |
| fmt = "ogg" if str(fmt).lower() in ("ogg", "opus") else "wav" | |
| key = (text, voice, fmt) | |
| skey = (text, voice) | |
| cached = _cache_get(key) | |
| if cached is not None: | |
| return cached | |
| samples = _samples_get(skey) | |
| if samples is None: | |
| with _synth_sem: | |
| cached = _cache_get(key) # may have been filled while we waited | |
| if cached is not None: | |
| return cached | |
| samples = _samples_get(skey) | |
| if samples is None: | |
| backend = _get_backend() | |
| chunks = list(backend.segments(text, voice)) | |
| if not chunks: | |
| raise RuntimeError("Kokoro produced no audio") | |
| samples = np.concatenate(chunks) if len(chunks) > 1 else chunks[0] | |
| _samples_put(skey, samples) | |
| result = _encode(samples, fmt) | |
| _cache_put(key, result) | |
| return result | |
| def warmup() -> bool: | |
| """Build the backend and run a tiny synthesis so the first real request | |
| doesn't pay model-load + kernel-JIT cost. Safe to call in a thread; never | |
| raises (unavailability is already handled per-request).""" | |
| try: | |
| synthesize("Hi.", fmt="wav") | |
| return True | |
| except Exception: | |
| return False | |