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>
This commit is contained in:
153
openai/silero-tts/server.py
Normal file
153
openai/silero-tts/server.py
Normal file
@@ -0,0 +1,153 @@
|
||||
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)
|
||||
Reference in New Issue
Block a user