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