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

+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)}",
)