import io import re import logging import numpy as np import torch from fastapi import FastAPI, HTTPException from fastapi.responses import Response from pydantic import BaseModel import scipy.io.wavfile as wavfile from pydub import AudioSegment logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) app = FastAPI(title="Silero TTS") # ── Config ──────────────────────────────────────────────────────────────────── DEVICE = "cuda" if torch.cuda.is_available() else "cpu" SAMPLE_RATE = 24000 MAX_CHUNK = 800 # chars per Silero call # Model identifiers (passed as `speaker` to torch.hub.load — selects model file) MODEL_ID = {"ru": "v3_1_ru", "en": "v3_en"} # Silero speakers (passed to apply_tts) RU_SPEAKERS = ["aidar", "baya", "kseniya", "xenia", "eugene"] EN_SPEAKERS = [f"en_{i}" for i in range(10)] # OpenAI voice → Silero speaker VOICE_MAP = { "ru": {"alloy": "eugene", "echo": "aidar", "fable": "baya", "onyx": "eugene", "nova": "kseniya", "shimmer": "xenia"}, "en": {"alloy": "en_3", "echo": "en_1", "fable": "en_2", "onyx": "en_3", "nova": "en_4", "shimmer": "en_5"}, } # ── Model cache ─────────────────────────────────────────────────────────────── _models: dict[str, object] = {} def _get_model(language: str): if language not in _models: logger.info(f"Loading Silero model {MODEL_ID[language]} lang={language} device={DEVICE}") model, _ = torch.hub.load( repo_or_dir="snakers4/silero-models", model="silero_tts", language=language, speaker=MODEL_ID[language], trust_repo=True, ) model.to(DEVICE) _models[language] = model logger.info(f"Model ready: lang={language}") return _models[language] @app.on_event("startup") async def preload(): """Preload both language models to avoid cold-start on first request.""" for lang in ("ru", "en"): try: _get_model(lang) except Exception as e: logger.warning(f"Preload failed for lang={lang}: {e}") # ── Helpers ─────────────────────────────────────────────────────────────────── def _is_russian(text: str) -> bool: return bool(re.search(r"[а-яёА-ЯЁ]", text)) def _split_sentences(text: str) -> list[str]: """Split on sentence boundaries, keeping chunks under MAX_CHUNK chars.""" if len(text) <= MAX_CHUNK: return [text] parts = re.split(r"(?<=[.!?;])\s+", text.strip()) chunks, cur = [], "" for part in parts: if len(cur) + len(part) + 1 <= MAX_CHUNK: cur = f"{cur} {part}" if cur else part else: if cur: chunks.append(cur) # If single part is too long, split mid-word as last resort cur = part[:MAX_CHUNK] if len(part) > MAX_CHUNK else part if cur: chunks.append(cur) return chunks or [text[:MAX_CHUNK]] def _to_bytes(audio: torch.Tensor, fmt: str) -> bytes: pcm = (audio.cpu().numpy() * 32767).astype(np.int16) if fmt == "pcm": return pcm.tobytes() buf = io.BytesIO() wavfile.write(buf, SAMPLE_RATE, pcm) if fmt == "wav": return buf.getvalue() seg = AudioSegment.from_wav(io.BytesIO(buf.getvalue())) out = io.BytesIO() seg.export(out, format="mp3") return out.getvalue() # ── API ─────────────────────────────────────────────────────────────────────── class SpeechRequest(BaseModel): model: str = "silero" input: str voice: str = "alloy" response_format: str = "mp3" speed: float = 1.0 @app.get("/health") async def health(): return {"status": "ok", "device": DEVICE} @app.get("/v1/models") async def list_models(): return { "object": "list", "data": [{"id": "silero", "object": "model", "owned_by": "silero"}], } @app.post("/v1/audio/speech") async def speech(req: SpeechRequest): text = req.input.strip() if not text: raise HTTPException(status_code=400, detail="input is empty") language = "ru" if _is_russian(text) else "en" vm = VOICE_MAP[language] speaker = vm.get(req.voice, vm["alloy"]) try: model = _get_model(language) chunks = _split_sentences(text) parts = [ model.apply_tts(text=chunk, speaker=speaker, sample_rate=SAMPLE_RATE) for chunk in chunks ] audio = parts[0] if len(parts) == 1 else torch.cat(parts) except Exception as e: logger.error(f"TTS error: {e}") raise HTTPException(status_code=500, detail=str(e)) fmt = req.response_format.lower() audio_bytes = _to_bytes(audio, fmt) media_types = {"wav": "audio/wav", "pcm": "audio/pcm", "mp3": "audio/mpeg"} media_type = media_types.get(fmt, "audio/mpeg") return Response(content=audio_bytes, media_type=media_type)