macayaven's picture
Upload folder using huggingface_hub
24e5b39 verified
Raw
History Blame Contribute Delete
4.38 kB
"""Text-to-speech pipeline with pluggable local backends."""
from __future__ import annotations
import os
import threading
import time
from dataclasses import dataclass
from functools import cache
from typing import Protocol
import numpy as np
@dataclass(frozen=True)
class Speech:
sample_rate: int
audio: np.ndarray
backend: str
model_id: str
latency_s: float
class TTSBackend(Protocol):
name: str
model_id: str
def synthesize(self, text: str) -> tuple[int, np.ndarray]: ...
class MockTTSBackend:
name = "mock"
model_id = "mock-tts-0"
def synthesize(self, text: str) -> tuple[int, np.ndarray]:
sample_rate = 24_000
duration_s = min(2.0, max(0.2, len(text) / 40))
encoded = text.encode("utf-8")
frequency = 220 + sum((i + 1) * byte for i, byte in enumerate(encoded)) % 220
samples = int(sample_rate * duration_s)
t = np.arange(samples, dtype=np.float32) / sample_rate
audio = 0.25 * np.sin(2 * np.pi * frequency * t)
return sample_rate, audio.astype(np.float32, copy=False)
class KokoroBackend:
name = "kokoro"
model_id = "hexgrad/Kokoro-82M"
def __init__(self) -> None:
self._pipeline = None
self._load_lock = threading.Lock()
def _load(self):
with self._load_lock:
if self._pipeline is None:
try:
from kokoro import KPipeline
except ImportError as exc:
raise RuntimeError(
"Kokoro TTS is not installed. Run `uv sync --extra tts` to enable it."
) from exc
# Pin to CPU: on ZeroGPU the hijacked CUDA is only usable inside
# @spaces.GPU, and the speak path runs outside it. Forcing
# map_location keeps torch.load from initializing CUDA in the
# main process while restoring the checkpoint — that cuInit
# poisons every later ZeroGPU worker fork ("No CUDA GPUs are
# available").
import torch
device = os.environ.get("SMALL_CUTS_TTS_DEVICE", "cpu")
original_load = torch.load
def _cpu_load(*args, **kwargs):
kwargs["map_location"] = "cpu"
return original_load(*args, **kwargs)
torch.load = _cpu_load
try:
self._pipeline = KPipeline(lang_code="a", device=device)
finally:
torch.load = original_load
return self._pipeline
def synthesize(self, text: str) -> tuple[int, np.ndarray]:
pipeline = self._load()
voice = os.environ.get("SMALL_CUTS_TTS_VOICE", "af_heart")
segments = []
for _, _, audio in pipeline(text, voice=voice):
if hasattr(audio, "detach"):
audio = audio.detach().cpu().numpy()
segment = np.asarray(audio, dtype=np.float32).reshape(-1)
segments.append(segment)
if not segments:
return 24_000, np.zeros(0, dtype=np.float32)
return 24_000, np.clip(np.concatenate(segments), -1.0, 1.0).astype(np.float32, copy=False)
_BACKENDS = {
"mock": MockTTSBackend,
"kokoro": KokoroBackend,
}
@cache
def _backend_instance(key: str) -> TTSBackend:
return _BACKENDS[key]()
def get_tts_backend(name: str | None = None) -> TTSBackend:
key = (name or os.environ.get("SMALL_CUTS_TTS_BACKEND", "mock")).lower()
if key not in _BACKENDS:
raise ValueError(f"Unknown TTS backend {key!r}; expected one of {sorted(_BACKENDS)}")
# One instance per backend: the Kokoro pipeline loads once per process.
return _backend_instance(key)
def speak(text: str, backend: TTSBackend | None = None) -> Speech:
text = text.strip()
if not text:
raise ValueError("Cannot synthesize empty text")
backend = backend or get_tts_backend()
start = time.perf_counter()
sample_rate, audio = backend.synthesize(text)
audio = np.asarray(audio, dtype=np.float32).reshape(-1)
audio = np.clip(audio, -1.0, 1.0).astype(np.float32, copy=False)
return Speech(
sample_rate=sample_rate,
audio=audio,
backend=backend.name,
model_id=backend.model_id,
latency_s=time.perf_counter() - start,
)