Files

285 lines
9.6 KiB
Python
Executable File

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