initial: silero-tts-service + README

This commit is contained in:
Никита Тарасов committed 2026-08-24 21:28:44 +05:00
1 parent 8c60c25ad2
commit 984b1fec6d
18 files changed
+2080

No files matched your search

+1
View File
@@ -0,0 +1 @@
# App package
+284
View File
@@ -0,0 +1,284 @@
import io
import logging
import time
from fastapi import APIRouter, HTTPException, Request, Response
from pydantic import BaseModel, Field
from pydub import AudioSegment
from .metrics import limiter, _synthesis_latency, _audio_size_bytes, _errors_total
from .engine import get_model
from .pipeline import process_tts
from .validators import validate_text, validate_speaker
logger = logging.getLogger(__name__)
router = APIRouter()
# Доступные голоса v5_5_ru (5 голосов для русского языка)
SPEAKERS = {
"aidar": "Мужской, нейтральный",
"baya": "Женский, мягкий",
"kseniya": "Женский, энергичный",
"eugene": "Мужской, спокойный",
"xenia": "Женский, молодой",
}
# Маппинг OpenAI голосов → Silero голоса
# Модель v5_5_ru поддерживает: aidar, baya, kseniya, eugene, xenia
OPENAI_VOICE_MAP = {
"alloy": "aidar", # мужской, нейтральный
"echo": "eugene", # мужской, спокойный
"fable": "baya", # женский, мягкий
"onyx": "aidar", # fallback на aidar (нет ruslan в v5_5_ru)
"nova": "kseniya", # женский, энергичный
"shimmer": "xenia", # женский, молодой
}
# ─── Модели ────────────────────────────────────────────────────────
class TTSRequest(BaseModel):
text: str = Field(..., min_length=1, max_length=4000, description="Текст для синтеза")
speaker: str = Field(default="aidar", description="Голос")
speed: float = Field(default=1.0, ge=0.5, le=2.0, description="Скорость (0.5x - 2.0x)")
class VoicesResponse(BaseModel):
speakers: dict
sample_rate: int = 48000
class OpenAITTSRequest(BaseModel):
"""OpenAI-совместимый TTS запрос"""
model: str = "tts-1"
input: str = Field(..., min_length=1, max_length=4000)
voice: str = "alloy"
response_format: str = "wav"
speed: float = 1.0
# ─── Роуты ─────────────────────────────────────────────────────────
@router.get("/v1/models", tags=["openai"])
async def list_openai_models():
"""OpenAI-совместимый список моделей"""
return {
"object": "list",
"data": [
{"id": "tts-1", "object": "model", "created": 1700000000, "owned_by": "silero"},
{"id": "tts-1-hd", "object": "model", "created": 1700000000, "owned_by": "silero"},
]
}
@router.get("/v1/audio/voices", tags=["metadata"])
async def list_voices():
"""Список голосов: OpenAI + нативные Silero"""
openai_voices = {k: f"{v} → Silero: {OPENAI_VOICE_MAP.get(k, k)}" for k, v in {
"alloy": "мужской, нейтральный",
"echo": "мужской, спокойный",
"fable": "женский, мягкий",
"onyx": "мужской (→ aidar)",
"nova": "женский, энергичный",
"shimmer": "женский, молодой",
}.items()}
return {"voices": {**openai_voices, **SPEAKERS}}
@router.get("/v1/voices", response_model=VoicesResponse, tags=["metadata"])
async def list_voices_legacy():
"""Список голосов (совместимость с Silero API)"""
return VoicesResponse(speakers=SPEAKERS)
@router.get("/v1/health", tags=["metadata"])
async def health_check():
"""Проверка здоровья сервиса"""
try:
model, device = get_model()
return {
"status": "healthy",
"model_loaded": True,
"device": str(device),
}
except Exception as e:
return {
"status": "unhealthy",
"model_loaded": False,
"error": str(e),
}
@router.post(
"/v1/audio/speech",
response_class=Response,
responses={200: {"content": {"audio/wav": {}}, "description": "WAV audio"}},
tags=["openai"],
)
async def synthesize_openai(req: OpenAITTSRequest, request: Request):
"""
OpenAI-совместимый TTS эндпоинт.
Принимает голоса двумя способами:
1. OpenAI: alloy, echo, fable, onyx, nova, shimmer → автоматический маппинг
2. Нативные Silero: aidar, baya, kseniya, eugene, xenia → прямой проход
"""
start = time.time()
# Сначала проверяем нативный Silero голос, потом маппинг OpenAI
if req.voice in SPEAKERS:
speaker = req.voice
elif req.voice in OPENAI_VOICE_MAP:
speaker = OPENAI_VOICE_MAP[req.voice]
else:
raise HTTPException(
status_code=400,
detail=f"Неизвестный голос '{req.voice}'. "
f"OpenAI: {', '.join(OPENAI_VOICE_MAP.keys())} | "
f"Silero: {', '.join(SPEAKERS.keys())}",
)
try:
validate_speaker(speaker, SPEAKERS)
text = validate_text(req.input)
settings = request.app.state.config
wav_data = await process_tts(
text=text,
speaker=speaker,
sample_rate=settings.sample_rate,
)
duration = time.time() - start
_synthesis_latency.labels(voice=speaker).observe(duration)
_audio_size_bytes.labels(voice=speaker).observe(len(wav_data))
# OpenAI API возвращает audio/wav по умолчанию
return Response(
content=wav_data,
media_type="audio/wav",
headers={
"Content-Disposition": 'attachment; filename="tts.wav"',
},
)
except HTTPException:
_errors_total.labels(error_type="validation").inc()
raise
except Exception as e:
_errors_total.labels(error_type="synthesis").inc()
logger.error("Ошибка синтеза: %s", str(e), exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Ошибка синтеза: {str(e)}",
)
@router.post(
"/v1/tts",
responses={200: {"content": {"audio/wav": {}}, "description": "WAV audio"}},
tags=["tts"],
)
@limiter.limit("500/minute")
async def synthesize_tts(req: TTSRequest, request: Request):
"""Синтез речи в WAV формате (родной Silero API)"""
start = time.time()
try:
validate_speaker(req.speaker, SPEAKERS)
text = validate_text(req.text)
settings = request.app.state.config
wav_data = await process_tts(
text=text,
speaker=req.speaker,
sample_rate=settings.sample_rate,
)
duration = time.time() - start
_synthesis_latency.labels(voice=req.speaker).observe(duration)
_audio_size_bytes.labels(voice=req.speaker).observe(len(wav_data))
return Response(
content=wav_data,
media_type="audio/wav",
headers={
"Content-Disposition": 'attachment; filename="tts.wav"',
},
)
except HTTPException:
_errors_total.labels(error_type="validation").inc()
raise
except Exception as e:
_errors_total.labels(error_type="synthesis").inc()
logger.error("Ошибка синтеза: %s", str(e), exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Ошибка синтеза: {str(e)}",
)
@router.post(
"/v1/tts-stream",
responses={200: {"content": {"audio/mpeg": {}}, "description": "Streamed MP3"}},
tags=["tts"],
)
@limiter.limit("10/minute")
async def synthesize_tts_stream(req: TTSRequest, request: Request):
"""Синтез речи потоком в MP3 (chunked transfer)"""
start = time.time()
if req.speaker != "aidar":
raise HTTPException(status_code=400, detail="Стриминг поддерживает только голос 'aidar'")
if len(req.text) > 500:
raise HTTPException(status_code=400, detail="Максимум 500 символов для стрима")
try:
settings = request.app.state.config
wav_data = await process_tts(
text=validate_text(req.text),
speaker=req.speaker,
sample_rate=settings.sample_rate,
)
audio_segment = AudioSegment(
wav_data,
sample_width=2,
channels=1,
frame_rate=48000,
)
mp3_data = io.BytesIO()
audio_segment.export(mp3_data, format="mp3", bitrate="192k")
mp3_data.seek(0)
mp3_bytes = mp3_data.read()
duration = time.time() - start
_synthesis_latency.labels(voice="aidar").observe(duration)
_audio_size_bytes.labels(voice="aidar").observe(len(mp3_bytes))
return Response(
content=mp3_bytes,
media_type="audio/mpeg",
headers={
"Transfer-Encoding": "chunked",
"Content-Disposition": 'inline; filename="tts.mp3"',
},
)
except HTTPException:
_errors_total.labels(error_type="stream_validation").inc()
raise
except Exception as e:
_errors_total.labels(error_type="stream_synthesis").inc()
logger.error("Ошибка стриминга: %s", str(e), exc_info=True)
raise HTTPException(
status_code=500,
detail=f"Ошибка стриминга: {str(e)}",
)
+69
View File
@@ -0,0 +1,69 @@
import os
from typing import List
import torch
from pydantic_settings import BaseSettings
def _auto_device() -> str:
"""Автоопределение GPU → fallback на CPU"""
if torch.cuda.is_available():
# Если CUDA-совместимые устройства есть, берём cuda:0
return "cuda:0"
return "cpu"
def _cpu_count() -> int:
"""Возвращает число CPU-ядер или 0 (авто для torch)"""
return 0 # 0 = torch сам подберёт
class Settings(BaseSettings):
"""Конфигурация приложения"""
# Модель Silero
model: str = "v5_5_ru"
device: str = "cpu" # переопределяется в __post_init__
sample_rate: int = 48000
# CPU-оптимизации
torch_threads: int = 0 # 0 = авто (все ядра)
jit_compile: bool = False # TorchScript JIT (CPU +10-30%)
# CORS
cors_origins: List[str] = ["*"]
# Rate limiting
rate_limit: str = "100/minute" # slowapi формат: "100/minute", "10/second"
# Кэширование ответов (секунды)
cache_ttl: int = 604800 # 7 дней
# FFmpeg
ffmpeg_bin: str = "ffmpeg"
loudnorm_i: float = -16.0
loudnorm_tp: float = -1.5
loudnorm_lra: float = 7.0
# Prometheus метрики
prometheus_enabled: bool = False
prometheus_port: int = 9090
# Логирование
log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
def __init__(self, **kwargs):
super().__init__(**kwargs)
# Автоопределение device
if self.device == "cpu" and _auto_device() != "cpu":
# Пользователь не указал DEVICE явно — автоопределение
pass # оставляем cpu, если явным образом не задано
# Если DEVICE не задан в env — автоопределение
if "DEVICE" not in os.environ:
self.device = _auto_device()
if self.torch_threads == 0:
self.torch_threads = _cpu_count() or os.cpu_count() or 4
+59
View File
@@ -0,0 +1,59 @@
import logging
import os
import torch
from silero import silero_tts
from .config import Settings
logger = logging.getLogger(__name__)
_models: dict = {}
_accentor = None
def load_models(cfg: Settings) -> None:
global _models, _accentor
is_cpu = cfg.device == "cpu"
if is_cpu:
threads = cfg.torch_threads
if threads == 0:
threads = os.cpu_count() or 4
logger.info(f"CPU-режим: авто-определение ({threads} потоков), JIT={cfg.jit_compile}")
else:
logger.info(f"CPU-режим: {threads} потоков, JIT={cfg.jit_compile}")
torch.set_num_threads(threads)
torch.set_num_interop_threads(max(1, threads // 2))
device = torch.device(cfg.device)
logger.info(f"Загрузка модели Silero {cfg.model} на {device}...")
model, _ = silero_tts(language='ru', speaker=cfg.model)
model.to(device)
_models = {
'model': model,
'device': device,
'sample_rate': cfg.sample_rate
}
# Accentor — опционально, может не работать в некоторых версиях Python
try:
from silero_stress import load_accentor
logger.info("Загрузка silero-stress...")
_accentor = load_accentor(lang='ru')
logger.info("Accentor загружен успешно")
except Exception as e:
logger.warning(f"Accentor не загружен: {e}. TTS будет работать без интонаций.")
_accentor = None
logger.info(f"Модели успешно загружены device={cfg.device}")
def get_model():
if 'model' not in _models:
raise RuntimeError("Модели не загружены. Вызовите load_models() при старте.")
return _models['model'], _models['device']
def get_accentor():
if _accentor is None:
logger.debug("Accentor не загружен, возвращаем None")
return None
return _accentor
+181
View File
@@ -0,0 +1,181 @@
import logging
import signal
import sys
import time
from contextlib import asynccontextmanager
from typing import Optional
import torch
from fastapi import FastAPI, Request, Response
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from prometheus_client import generate_latest, CONTENT_TYPE_LATEST
from slowapi import Limiter
from .api import router as tts_router, SPEAKERS
from .config import Settings
from .engine import load_models, get_model
from .metrics import limiter, _synthesis_latency, _audio_size_bytes, _errors_total, _request_counter
logger = logging.getLogger(__name__)
# ─── Глобальный state ─────────────────────────────────────────────
config: Optional[Settings] = None
@asynccontextmanager
async def lifespan(app: FastAPI):
"""Управление жизненным циклом приложения"""
global config
cfg = app.state.config
logger.info("=" * 60)
logger.info("🚀 Запуск Silero TTS API")
logger.info("=" * 60)
# Показываем конфигурацию
logger.info("Конфигурация:")
logger.info(f" Модель: {cfg.model}")
logger.info(f" Устройство: {cfg.device}")
logger.info(f" Sample rate: {cfg.sample_rate} Hz")
logger.info(f" Голоса: {', '.join(SPEAKERS.keys())}")
logger.info(f" CORS: {cfg.cors_origins}")
logger.info(f" Логирование: {cfg.log_level}")
logger.info("-" * 60)
# Загрузка моделей
load_models(cfg)
logger.info("✅ ТTS API готов к работе")
logger.info("=" * 60)
yield # ← сервер работает
# Graceful shutdown
logger.info("🛑 Остановка сервиса...")
# Освобождаем GPU память
if cfg.device.startswith("cuda"):
torch.cuda.empty_cache()
logger.info("👋 ТTS API остановлен")
# ─── Создание приложения ──────────────────────────────────────────
def create_app() -> FastAPI:
global config
cfg = Settings()
config = cfg
app = FastAPI(
title="Silero TTS API",
version="0.2.0",
description="REST API синтеза русской речи на Silero v5.5",
lifespan=lifespan,
)
# ── Настройка логгера ──
logging.basicConfig(
level=getattr(logging, cfg.log_level.upper(), logging.INFO),
format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s",
datefmt="%H:%M:%S",
handlers=[logging.StreamHandler(sys.stdout)],
)
# ── Конфиг в app.state ──
app.state.config = cfg
# ── SlowAPI лимитер ──
app.state.limiter = limiter
# ── SlowAPI middleware ──
from slowapi.errors import RateLimitExceeded
from starlette.responses import JSONResponse
@app.exception_handler(RateLimitExceeded)
async def rate_limit_handler(request: Request, exc: RateLimitExceeded):
return JSONResponse(
status_code=429,
content={
"detail": "Превышен лимит запросов. Попробуйте позже."
},
)
# ── CORS middleware ──
app.add_middleware(
CORSMiddleware,
allow_origins=cfg.cors_origins,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# ── Request logging middleware ──
@app.middleware("http")
async def log_requests(request: Request, call_next):
start = time.perf_counter()
response = await call_next(request)
duration_ms = (time.perf_counter() - start) * 1000
logger.info(
"%s %s → %s (%.1f ms)",
request.method,
request.url.path,
response.status_code,
duration_ms,
)
# ── Prometheus метрика ──
if request.url.path.startswith("/tts"):
_request_counter.labels(
endpoint=request.url.path,
status=str(response.status_code),
).inc()
return response
# ── Prometheus /metrics endpoint ──
@app.get("/metrics", tags=["monitoring"])
async def metrics(request: Request):
return Response(
content=generate_latest(),
media_type=CONTENT_TYPE_LATEST,
)
# ── Health check (до загрузки моделей) ──
@app.get("/health", tags=["metadata"])
async def health():
try:
get_model()
return {"status": "healthy", "model_loaded": True}
except Exception as e:
return JSONResponse(
status_code=503,
content={"status": "unhealthy", "error": str(e)},
)
# ── Роуты TTS ──
app.include_router(tts_router)
return app
# ─── Точка входа ──────────────────────────────────────────────────
def main():
"""Запуск через uvicorn"""
import uvicorn
app = create_app()
uvicorn.run(
app,
host="0.0.0.0",
port=8001,
log_level="info",
)
if __name__ == "__main__":
main()
+35
View File
@@ -0,0 +1,35 @@
"""Prometheus метрики и rate limiter для TTS API."""
import time
from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST
from slowapi import Limiter
from slowapi.util import get_remote_address
# ─── Prometheus метрики ─────────────────────────────────────────
_request_counter = Counter(
"tts_requests_total",
"Общее число запросов к TTS API",
["endpoint", "status"],
)
_synthesis_latency = Histogram(
"tts_synthesis_seconds",
"Время синтеза речи",
["voice"],
)
_audio_size_bytes = Histogram(
"tts_audio_size_bytes",
"Размер сгенерированного аудио в байтах",
["voice"],
)
_errors_total = Counter(
"tts_errors_total",
"Количество ошибок синтеза",
["error_type"],
)
# ─── Rate Limiter ─────────────────────────────────────────────────
limiter = Limiter(key_func=get_remote_address, default_limits=["100 per minute"])
+95
View File
@@ -0,0 +1,95 @@
import asyncio
import hashlib
import io
import logging
import wave
from functools import lru_cache
from typing import Optional
import numpy as np
import torch
from .engine import get_model, get_accentor
logger = logging.getLogger(__name__)
# ─── LRU-кэш ответов ───────────────────────────────────────────────
# Кэшируем по хэшу текста + голоса. Убираем из кэша, если модель
# переключили — для этого вызывается reset_cache().
@lru_cache(maxsize=1024)
def _cached_tts(text_hash: str, speaker: str, sample_rate: int) -> bytes:
"""Кэш синтеза: текст_хэш + голос → WAV bytes"""
# Этот вызов НЕ должен происходить, только для типов
raise RuntimeError("Кэш пуст — нужно синтезировать")
def reset_cache() -> None:
"""Очистить кэш (вызывать при смене модели/настроек)"""
_cached_tts.cache_clear()
# ─── Основной пайплайн ─────────────────────────────────────────────
async def process_tts(
text: str,
speaker: str,
sample_rate: int,
cache_ttl: int = 604800,
cache_enabled: bool = True,
) -> bytes:
"""
Пайплайн синтеза:
1. Хэш текста (для кэша)
2. Генерация аудио (apply_tts встроенный accent/yo)
3. tensor → WAV bytes
"""
# 1. Хэш текста
text_hash = hashlib.md5(text.encode("utf-8")).hexdigest()
# 2. Синтез (Silero apply_tts встроенный accent/yo)
model, device = get_model()
def _synthesize_sync():
return model.apply_tts(
text=text,
speaker=speaker,
sample_rate=sample_rate,
put_accent=True,
put_yo=True,
)
loop = asyncio.get_event_loop()
audio_tensor = await loop.run_in_executor(None, _synthesize_sync)
# 4. tensor → WAV
audio_np = audio_tensor.cpu().numpy()
wav_bytes = tensor_to_wav(audio_np, sample_rate)
logger.debug(
"Синтез завершён",
text_len=len(text),
wav_size=len(wav_bytes),
device=str(device),
)
return wav_bytes
# ─── Утилиты ───────────────────────────────────────────────────────
def tensor_to_wav(audio_np: np.ndarray, sample_rate: int) -> bytes:
"""Конвертация numpy array в WAV bytes (16-bit mono)"""
audio_int16 = (audio_np * 32767).astype(np.int16)
buffer = io.BytesIO()
with wave.open(buffer, "wb") as wav_file:
wav_file.setnchannels(1)
wav_file.setsampwidth(2) # 16-bit
wav_file.setframerate(sample_rate)
wav_file.writeframes(audio_int16.tobytes())
buffer.seek(0)
return buffer.getvalue()
+49
View File
@@ -0,0 +1,49 @@
"""Валидаторы входных данных.
Не зависят от torch/silero — можно тестировать изолированно.
"""
from fastapi import HTTPException
def validate_text(text: str) -> str:
"""
Очистка и валидация входного текста:
- Убирает управляющие символы (кроме \n \r \t)
- Нормализует множественные пробелы
- Проверяет длину
"""
if not text.strip():
raise HTTPException(
status_code=400,
detail="Текст не может быть пустым или пробелами",
)
# Удаляем управляющие символы
text = "".join(
ch for ch in text
if ch in ("\n", "\r", "\t") or (ord(ch) >= 32 and ch != "\x7f")
)
# Нормализуем множественные пробелы (но не табы/ньюлайны)
import re
text = re.sub(r"[^\S\n\t]+", " ", text)
text = text.strip()
if len(text) > 4000:
raise HTTPException(
status_code=400,
detail=f"Текст слишком длинный (max 4000, got {len(text)})",
)
return text
def validate_speaker(speaker: str, speakers: dict) -> str:
"""Проверка валидности голоса."""
if speaker not in speakers:
raise HTTPException(
status_code=400,
detail=f"Неизвестный голос. Доступные: {', '.join(speakers.keys())}",
)
return speaker