285 lines
9.6 KiB
Python
Executable File
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)}",
|
|
)
|