initial: silero-tts-service + README
This commit is contained in:
1 parent
8c60c25ad2
commit
984b1fec6d
18 files changed
+2080
No files matched your search
Executable
+1
@@ -0,0 +1 @@
|
||||
# App package
|
||||
Executable
+284
@@ -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)}",
|
||||
)
|
||||
Executable
+69
@@ -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
|
||||
Executable
+59
@@ -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
|
||||
Executable
+181
@@ -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()
|
||||
Executable
+35
@@ -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"])
|
||||
Executable
+95
@@ -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()
|
||||
Executable
+49
@@ -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
|
||||
Reference in new issue
Block a user