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
+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)}",
|
||||
)
|
||||
Reference in new issue
Block a user