Files
AgapHost/openai/silero-tts/server.py
Alvis 85033136d8 openai: add AI stack (litellm + langfuse + pipecat + silero-tts) and oO aliases
- LiteLLM proxy with langfuse callbacks, postgres backends, and OpenRouter fallbacks.
- Langfuse observability UI.
- Pipecat voice pipeline (LiveKit + STT + TTS + LLM) and Silero TTS build contexts.
- Ollama tuned for GPU (OLLAMA_NUM_GPU=999, mem_limit=4g, max 2 loaded models).
- open-webui wired to litellm + faster-whisper + silero for voice.
- litellm-config.yaml publishes oO's model aliases (tip-generator, embedder, judge)
  pointing at the host ollama on :11434 so ml/serving can call them via LiteLLM.

.env skipped (secrets).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-20 14:28:24 +00:00

154 lines
5.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)