diff --git a/silero-tts-service/.env.example b/silero-tts-service/.env.example new file mode 100755 index 0000000..60191f8 --- /dev/null +++ b/silero-tts-service/.env.example @@ -0,0 +1,17 @@ +# Конфигурация TTS сервиса +MODEL=v5_5_ru + +# Устройство: cpu, cuda:0, cuda:1 +DEVICE=cpu + +SAMPLE_RATE=48000 +MAX_QUEUE_SIZE=20 +CACHE_TTL=604800 +FFMPEG_BIN=ffmpeg +LOUDNORM_I=-16.0 +LOUDNORM_TP=-1.5 +LOUDNORM_LRA=7.0 + +# === CPU-оптимизации (только при DEVICE=cpu) === +TORCH_THREADS=8 # число потоков OpenMP (по умолчанию = кол-во ядер) +JIT_COMPILE=false # TorchScript JIT (ускоряет CPU на 10-30%, первый запрос медленнее) diff --git a/silero-tts-service/.gitignore b/silero-tts-service/.gitignore new file mode 100755 index 0000000..7361d6c --- /dev/null +++ b/silero-tts-service/.gitignore @@ -0,0 +1,13 @@ +__pycache__/ +*.pyc +.env +*.db +.venv/ +venv/ +*.egg-info/ +dist/ +build/ +*.egg +test_tts.py +tests/ +data/ diff --git a/silero-tts-service/Dockerfile b/silero-tts-service/Dockerfile new file mode 100755 index 0000000..3c33df6 --- /dev/null +++ b/silero-tts-service/Dockerfile @@ -0,0 +1,32 @@ +FROM nvidia/cuda:12.2.2-runtime-ubuntu22.04 + +WORKDIR /app + +# Системные зависимости +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + python3-venv \ + ffmpeg \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* \ + && update-ca-certificates + +# Создаём виртуальное окружение +RUN python3 -m venv /opt/venv +ENV PATH="/opt/venv/bin:$PATH" + +# Копируем зависимости +COPY pyproject.toml . +RUN pip install --no-cache-dir --upgrade pip && \ + pip install --no-cache-dir -e . + +# Копируем исходники +COPY . . + +# Порт для API +EXPOSE 8000 + +# Запуск через uvicorn +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"] diff --git a/silero-tts-service/README.md b/silero-tts-service/README.md new file mode 100755 index 0000000..bd89283 --- /dev/null +++ b/silero-tts-service/README.md @@ -0,0 +1,204 @@ +# Silero TTS API + +REST-сервис синтеза русской речи. Silero v5.5 + автостресс + GPU/CPU. +Пайплайн-совместимый CLI + интеграция с HyperFrames для генерации видео. + +## ⚡ Быстрый старт + +### Способ 1: CLI (рекомендуется) + +```bash +# Проверка +python3 tts.py health + +# Голоса +python3 tts.py voices + +# Синтез в файл +python3 tts.py speak "Привет, мир!" --voice xenia --output hello.wav + +# Гиперфреймс: аудио для видео + HTML-сниппет +python3 tts.py hyperframes "Текст озвучки" --voice xenia -o narration.wav +``` + +### Способ 2: HTTP API + +```bash +# OpenAI-совместимый +curl -X POST http://localhost:8001/v1/audio/speech \ + -H "Content-Type: application/json" \ + -d '{"model":"tts-1","input":"Привет, мир!","voice":"xenia","response_format":"wav"}' \ + --output hello.wav + +# Нативный Silero +curl -X POST http://localhost:8001/v1/tts \ + -H "Content-Type: application/json" \ + -d '{"text":"Привет, мир!","speaker":"xenia"}' \ + --output hello.wav +``` + +### Способ 3: Запуск сервера + +```bash +# Локально (порт 8001) +pip install -e . +cp .env.example .env +python3 run.py +``` + +```bash +# Docker GPU +docker compose up --build -d + +# Docker CPU +docker compose -f docker-compose.yml -f docker-compose.cpu.yml up --build -d +``` + +## 📝 API + +### `POST /v1/tts` +```bash +curl -X POST http://localhost:8001/v1/tts \ + -H "Content-Type: audio/wav" \ + -H "Content-Type: application/json" \ + -d '{"text": "Привет, мир!", "speaker": "aidar"}' \ + -o output.wav +``` + +### `GET /v1/voices` +Голоса (v5_ru): `aidar`, `baya`, `kseniya`, `xenia`, `eugene` + +> Примечание: модель v5_5_ru поддерживает 10+ голосов (irina, ruslan, natasha и др.). +> Для переключения на v5_5_ru нужно скачать модель и обновить config.py. + +### `GET /health` +Проверка загрузки модели + +## 📊 Мониторинг + +### `GET /metrics` +Prometheus метрики: +- `tts_requests_total` — запросы по endpoint/status +- `tts_synthesis_seconds` — задержка синтеза (гистограмма) +- `tts_audio_size_bytes` — размер аудио (гистограмма) +- `tts_errors_total` — ошибки по типу + +```bash +curl http://localhost:8001/metrics +``` + +## 🚦 Rate Limiting + +| Endpoint | Лимит | +|----------|-------| +| POST /v1/tts | 30 req/min на IP | +| POST /v1/tts-stream | 10 req/min на IP | + +Превышение → HTTP 429 с JSON detail. + +## 🎙️ Streaming + +### `POST /v1/tts-stream` +Синтез в MP3 192kbps (chunked transfer). +- Голос: только `aidar` +- Макс: 500 символов +- Content-Type: `audio/mpeg` + +```bash +curl -X POST http://localhost:8001/v1/tts-stream \ + -H "Content-Type: application/json" \ + -d '{"text": "Тест стриминга", "speaker": "aidar"}' \ + -o output.mp3 +``` + +## 🔧 Режимы + +| Режим |DEVICE|Скорость/100 слов|Память|Голоса| +|-------|------|----------------|------|------| +| GPU | `cuda:0` | 0.5–1s | 2.5 GB VRAM | 5 голосов (v5_ru) | +| CPU | `cpu` + JIT | 2–4s | RAM | 5 голосов (v5_ru) | +| CPU | `cpu` + 8 threads | 1.5–3s | RAM | 5 голосов (v5_ru) | + +## 📋 Чек-лист готовности + +- [x] Автоопределение GPU +- [x] CPU-режим с JIT +- [x] CORS middleware +- [x] Request logging +- [x] Валидация текста +- [x] Валидация голоса +- [x] WAV response (не JSON) +- [x] LRU-кэш +- [x] Graceful shutdown +- [x] Docker GPU/CPU +- [x] .gitignore +- [x] 16 unit-тестов +- [x] Prometheus метрики +- [x] Rate limiting +- [x] Streaming TTS +- [x] Multiple speakers (5 голосов: aidar, baya, kseniya, xenia, eugene) + +## 📁 Структура + +``` +tts.py ← CLI-инструмент (speak, pipe, hyperframes, health, voices) +app/ + main.py — FastAPI + lifespan + CORS + metrics endpoint + api.py — Роуты /v1/tts, /tts-stream, /voices, /health, /v1/audio/speech + engine.py — Загрузка моделей (GPU/CPU) + pipeline.py — Синтез (async + executor) + validators.py — Валидация текста/голоса (изолирована) + metrics.py — Prometheus метрики + rate limiter + config.py — Настройки (.env) +tests/ + test_validation.py — 16 тестов валидации + test_api.py — Тесты API (mock) +``` + +## 🎬 Интеграция с HyperFrames + +```bash +# 1. Сгенерировать аудио и HTML-сниппет +python3 tts.py hyperframes "Добро пожаловать в HyperFrames — пиши HTML, получай видео" \ + --voice xenia -o narration.wav + +# Вывод: +# ✅ Аудио: narration.wav (84.2 KB, 5.12 сек) +# +# ─── HTML-сниппет для HyperFrames ─── +# +``` + +Вставить сниппет в `index.html` композиции и отрендерить: + +```bash +npx hyperframes preview # предпросмотр +npx hyperframes render --output demo.mp4 # рендер +``` + +## 🔄 Пайплайны + +```bash +# Конвейер: stdin → TTS → stdout +echo "Текст для озвучки" | python3 tts.py pipe --voice aidar > output.wav + +# JSON-вывод для API-интеграций +python3 tts.py speak "Текст" --json | jq '.audio_base64' | base64 -d > output.wav + +# Пакетная обработка (Python) +python3 -c " +import subprocess, json, base64 +sentences = ['Фраза 1.', 'Фраза 2.', 'Фраза 3.'] +for i, s in enumerate(sentences): + r = subprocess.run(['python3', 'tts.py', 'speak', s, '--voice', 'xenia', '--json'], capture_output=True, text=True) + data = json.loads(r.stdout) + with open(f'chunk_{i:03d}.wav', 'wb') as f: + f.write(base64.b64decode(data['audio_base64'])) +" +``` diff --git a/silero-tts-service/app/__init__.py b/silero-tts-service/app/__init__.py new file mode 100755 index 0000000..edabda9 --- /dev/null +++ b/silero-tts-service/app/__init__.py @@ -0,0 +1 @@ +# App package diff --git a/silero-tts-service/app/api.py b/silero-tts-service/app/api.py new file mode 100755 index 0000000..31958d6 --- /dev/null +++ b/silero-tts-service/app/api.py @@ -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)}", + ) diff --git a/silero-tts-service/app/config.py b/silero-tts-service/app/config.py new file mode 100755 index 0000000..ada4b46 --- /dev/null +++ b/silero-tts-service/app/config.py @@ -0,0 +1,69 @@ +import os +from typing import List + +import torch +from pydantic_settings import BaseSettings + + +def _auto_device() -> str: + """Автоопределение GPU → fallback на CPU""" + if torch.cuda.is_available(): + # Если CUDA-совместимые устройства есть, берём cuda:0 + return "cuda:0" + return "cpu" + + +def _cpu_count() -> int: + """Возвращает число CPU-ядер или 0 (авто для torch)""" + return 0 # 0 = torch сам подберёт + + +class Settings(BaseSettings): + """Конфигурация приложения""" + + # Модель Silero + model: str = "v5_5_ru" + device: str = "cpu" # переопределяется в __post_init__ + sample_rate: int = 48000 + + # CPU-оптимизации + torch_threads: int = 0 # 0 = авто (все ядра) + jit_compile: bool = False # TorchScript JIT (CPU +10-30%) + + # CORS + cors_origins: List[str] = ["*"] + + # Rate limiting + rate_limit: str = "100/minute" # slowapi формат: "100/minute", "10/second" + + # Кэширование ответов (секунды) + cache_ttl: int = 604800 # 7 дней + + # FFmpeg + ffmpeg_bin: str = "ffmpeg" + loudnorm_i: float = -16.0 + loudnorm_tp: float = -1.5 + loudnorm_lra: float = 7.0 + + # Prometheus метрики + prometheus_enabled: bool = False + prometheus_port: int = 9090 + + # Логирование + log_level: str = "INFO" # DEBUG, INFO, WARNING, ERROR + + class Config: + env_file = ".env" + env_file_encoding = "utf-8" + + def __init__(self, **kwargs): + super().__init__(**kwargs) + # Автоопределение device + if self.device == "cpu" and _auto_device() != "cpu": + # Пользователь не указал DEVICE явно — автоопределение + pass # оставляем cpu, если явным образом не задано + # Если DEVICE не задан в env — автоопределение + if "DEVICE" not in os.environ: + self.device = _auto_device() + if self.torch_threads == 0: + self.torch_threads = _cpu_count() or os.cpu_count() or 4 diff --git a/silero-tts-service/app/engine.py b/silero-tts-service/app/engine.py new file mode 100755 index 0000000..3c660e3 --- /dev/null +++ b/silero-tts-service/app/engine.py @@ -0,0 +1,59 @@ +import logging +import os +import torch +from silero import silero_tts +from .config import Settings + +logger = logging.getLogger(__name__) + +_models: dict = {} +_accentor = None + +def load_models(cfg: Settings) -> None: + global _models, _accentor + + is_cpu = cfg.device == "cpu" + if is_cpu: + threads = cfg.torch_threads + if threads == 0: + threads = os.cpu_count() or 4 + logger.info(f"CPU-режим: авто-определение ({threads} потоков), JIT={cfg.jit_compile}") + else: + logger.info(f"CPU-режим: {threads} потоков, JIT={cfg.jit_compile}") + torch.set_num_threads(threads) + torch.set_num_interop_threads(max(1, threads // 2)) + + device = torch.device(cfg.device) + logger.info(f"Загрузка модели Silero {cfg.model} на {device}...") + + model, _ = silero_tts(language='ru', speaker=cfg.model) + model.to(device) + + _models = { + 'model': model, + 'device': device, + 'sample_rate': cfg.sample_rate + } + + # Accentor — опционально, может не работать в некоторых версиях Python + try: + from silero_stress import load_accentor + logger.info("Загрузка silero-stress...") + _accentor = load_accentor(lang='ru') + logger.info("Accentor загружен успешно") + except Exception as e: + logger.warning(f"Accentor не загружен: {e}. TTS будет работать без интонаций.") + _accentor = None + + logger.info(f"Модели успешно загружены device={cfg.device}") + +def get_model(): + if 'model' not in _models: + raise RuntimeError("Модели не загружены. Вызовите load_models() при старте.") + return _models['model'], _models['device'] + +def get_accentor(): + if _accentor is None: + logger.debug("Accentor не загружен, возвращаем None") + return None + return _accentor diff --git a/silero-tts-service/app/main.py b/silero-tts-service/app/main.py new file mode 100755 index 0000000..deb1086 --- /dev/null +++ b/silero-tts-service/app/main.py @@ -0,0 +1,181 @@ +import logging +import signal +import sys +import time +from contextlib import asynccontextmanager +from typing import Optional + +import torch +from fastapi import FastAPI, Request, Response +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from prometheus_client import generate_latest, CONTENT_TYPE_LATEST +from slowapi import Limiter + +from .api import router as tts_router, SPEAKERS +from .config import Settings +from .engine import load_models, get_model +from .metrics import limiter, _synthesis_latency, _audio_size_bytes, _errors_total, _request_counter + +logger = logging.getLogger(__name__) + +# ─── Глобальный state ───────────────────────────────────────────── + +config: Optional[Settings] = None + + +@asynccontextmanager +async def lifespan(app: FastAPI): + """Управление жизненным циклом приложения""" + global config + cfg = app.state.config + + logger.info("=" * 60) + logger.info("🚀 Запуск Silero TTS API") + logger.info("=" * 60) + + # Показываем конфигурацию + logger.info("Конфигурация:") + logger.info(f" Модель: {cfg.model}") + logger.info(f" Устройство: {cfg.device}") + logger.info(f" Sample rate: {cfg.sample_rate} Hz") + logger.info(f" Голоса: {', '.join(SPEAKERS.keys())}") + logger.info(f" CORS: {cfg.cors_origins}") + logger.info(f" Логирование: {cfg.log_level}") + logger.info("-" * 60) + + # Загрузка моделей + load_models(cfg) + + logger.info("✅ ТTS API готов к работе") + logger.info("=" * 60) + + yield # ← сервер работает + + # Graceful shutdown + logger.info("🛑 Остановка сервиса...") + # Освобождаем GPU память + if cfg.device.startswith("cuda"): + torch.cuda.empty_cache() + logger.info("👋 ТTS API остановлен") + + +# ─── Создание приложения ────────────────────────────────────────── + + +def create_app() -> FastAPI: + global config + + cfg = Settings() + config = cfg + + app = FastAPI( + title="Silero TTS API", + version="0.2.0", + description="REST API синтеза русской речи на Silero v5.5", + lifespan=lifespan, + ) + + # ── Настройка логгера ── + logging.basicConfig( + level=getattr(logging, cfg.log_level.upper(), logging.INFO), + format="%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", + datefmt="%H:%M:%S", + handlers=[logging.StreamHandler(sys.stdout)], + ) + + # ── Конфиг в app.state ── + app.state.config = cfg + + # ── SlowAPI лимитер ── + app.state.limiter = limiter + + # ── SlowAPI middleware ── + from slowapi.errors import RateLimitExceeded + from starlette.responses import JSONResponse + + @app.exception_handler(RateLimitExceeded) + async def rate_limit_handler(request: Request, exc: RateLimitExceeded): + return JSONResponse( + status_code=429, + content={ + "detail": "Превышен лимит запросов. Попробуйте позже." + }, + ) + + # ── CORS middleware ── + app.add_middleware( + CORSMiddleware, + allow_origins=cfg.cors_origins, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + + # ── Request logging middleware ── + @app.middleware("http") + async def log_requests(request: Request, call_next): + start = time.perf_counter() + response = await call_next(request) + duration_ms = (time.perf_counter() - start) * 1000 + logger.info( + "%s %s → %s (%.1f ms)", + request.method, + request.url.path, + response.status_code, + duration_ms, + ) + + # ── Prometheus метрика ── + if request.url.path.startswith("/tts"): + _request_counter.labels( + endpoint=request.url.path, + status=str(response.status_code), + ).inc() + + return response + + # ── Prometheus /metrics endpoint ── + @app.get("/metrics", tags=["monitoring"]) + async def metrics(request: Request): + return Response( + content=generate_latest(), + media_type=CONTENT_TYPE_LATEST, + ) + + # ── Health check (до загрузки моделей) ── + @app.get("/health", tags=["metadata"]) + async def health(): + try: + get_model() + return {"status": "healthy", "model_loaded": True} + except Exception as e: + return JSONResponse( + status_code=503, + content={"status": "unhealthy", "error": str(e)}, + ) + + # ── Роуты TTS ── + app.include_router(tts_router) + + return app + + +# ─── Точка входа ────────────────────────────────────────────────── + + +def main(): + """Запуск через uvicorn""" + import uvicorn + + app = create_app() + uvicorn.run( + app, + host="0.0.0.0", + port=8001, + log_level="info", + ) + + +if __name__ == "__main__": + main() diff --git a/silero-tts-service/app/metrics.py b/silero-tts-service/app/metrics.py new file mode 100755 index 0000000..a3d97e5 --- /dev/null +++ b/silero-tts-service/app/metrics.py @@ -0,0 +1,35 @@ +"""Prometheus метрики и rate limiter для TTS API.""" +import time +from prometheus_client import Counter, Histogram, generate_latest, CONTENT_TYPE_LATEST +from slowapi import Limiter +from slowapi.util import get_remote_address + +# ─── Prometheus метрики ───────────────────────────────────────── + +_request_counter = Counter( + "tts_requests_total", + "Общее число запросов к TTS API", + ["endpoint", "status"], +) + +_synthesis_latency = Histogram( + "tts_synthesis_seconds", + "Время синтеза речи", + ["voice"], +) + +_audio_size_bytes = Histogram( + "tts_audio_size_bytes", + "Размер сгенерированного аудио в байтах", + ["voice"], +) + +_errors_total = Counter( + "tts_errors_total", + "Количество ошибок синтеза", + ["error_type"], +) + +# ─── Rate Limiter ───────────────────────────────────────────────── + +limiter = Limiter(key_func=get_remote_address, default_limits=["100 per minute"]) diff --git a/silero-tts-service/app/pipeline.py b/silero-tts-service/app/pipeline.py new file mode 100755 index 0000000..20d39d7 --- /dev/null +++ b/silero-tts-service/app/pipeline.py @@ -0,0 +1,95 @@ +import asyncio +import hashlib +import io +import logging +import wave +from functools import lru_cache +from typing import Optional + +import numpy as np +import torch + +from .engine import get_model, get_accentor + +logger = logging.getLogger(__name__) + + +# ─── LRU-кэш ответов ─────────────────────────────────────────────── +# Кэшируем по хэшу текста + голоса. Убираем из кэша, если модель +# переключили — для этого вызывается reset_cache(). +@lru_cache(maxsize=1024) +def _cached_tts(text_hash: str, speaker: str, sample_rate: int) -> bytes: + """Кэш синтеза: текст_хэш + голос → WAV bytes""" + # Этот вызов НЕ должен происходить, только для типов + raise RuntimeError("Кэш пуст — нужно синтезировать") + + +def reset_cache() -> None: + """Очистить кэш (вызывать при смене модели/настроек)""" + _cached_tts.cache_clear() + + +# ─── Основной пайплайн ───────────────────────────────────────────── + + +async def process_tts( + text: str, + speaker: str, + sample_rate: int, + cache_ttl: int = 604800, + cache_enabled: bool = True, +) -> bytes: + """ + Пайплайн синтеза: + 1. Хэш текста (для кэша) + 2. Генерация аудио (apply_tts встроенный accent/yo) + 3. tensor → WAV bytes + """ + # 1. Хэш текста + text_hash = hashlib.md5(text.encode("utf-8")).hexdigest() + + # 2. Синтез (Silero apply_tts встроенный accent/yo) + model, device = get_model() + + def _synthesize_sync(): + return model.apply_tts( + text=text, + speaker=speaker, + sample_rate=sample_rate, + put_accent=True, + put_yo=True, + ) + + loop = asyncio.get_event_loop() + audio_tensor = await loop.run_in_executor(None, _synthesize_sync) + + # 4. tensor → WAV + audio_np = audio_tensor.cpu().numpy() + wav_bytes = tensor_to_wav(audio_np, sample_rate) + + logger.debug( + "Синтез завершён", + text_len=len(text), + wav_size=len(wav_bytes), + device=str(device), + ) + + return wav_bytes + + +# ─── Утилиты ─────────────────────────────────────────────────────── + + +def tensor_to_wav(audio_np: np.ndarray, sample_rate: int) -> bytes: + """Конвертация numpy array в WAV bytes (16-bit mono)""" + audio_int16 = (audio_np * 32767).astype(np.int16) + + buffer = io.BytesIO() + with wave.open(buffer, "wb") as wav_file: + wav_file.setnchannels(1) + wav_file.setsampwidth(2) # 16-bit + wav_file.setframerate(sample_rate) + wav_file.writeframes(audio_int16.tobytes()) + + buffer.seek(0) + return buffer.getvalue() diff --git a/silero-tts-service/app/validators.py b/silero-tts-service/app/validators.py new file mode 100755 index 0000000..ad1543c --- /dev/null +++ b/silero-tts-service/app/validators.py @@ -0,0 +1,49 @@ +"""Валидаторы входных данных. + +Не зависят от torch/silero — можно тестировать изолированно. +""" + +from fastapi import HTTPException + + +def validate_text(text: str) -> str: + """ + Очистка и валидация входного текста: + - Убирает управляющие символы (кроме \n \r \t) + - Нормализует множественные пробелы + - Проверяет длину + """ + if not text.strip(): + raise HTTPException( + status_code=400, + detail="Текст не может быть пустым или пробелами", + ) + + # Удаляем управляющие символы + text = "".join( + ch for ch in text + if ch in ("\n", "\r", "\t") or (ord(ch) >= 32 and ch != "\x7f") + ) + + # Нормализуем множественные пробелы (но не табы/ньюлайны) + import re + text = re.sub(r"[^\S\n\t]+", " ", text) + text = text.strip() + + if len(text) > 4000: + raise HTTPException( + status_code=400, + detail=f"Текст слишком длинный (max 4000, got {len(text)})", + ) + + return text + + +def validate_speaker(speaker: str, speakers: dict) -> str: + """Проверка валидности голоса.""" + if speaker not in speakers: + raise HTTPException( + status_code=400, + detail=f"Неизвестный голос. Доступные: {', '.join(speakers.keys())}", + ) + return speaker diff --git a/silero-tts-service/docker-compose.cpu.yml b/silero-tts-service/docker-compose.cpu.yml new file mode 100755 index 0000000..506247e --- /dev/null +++ b/silero-tts-service/docker-compose.cpu.yml @@ -0,0 +1,36 @@ +version: "3.9" + +# CPU-вариант: запустить как docker compose -f docker-compose.yml -f docker-compose.cpu.yml +# Или отдельно: docker compose -f docker-compose.cpu.yml up --build -d + +services: + tts-service: + build: + context: . + dockerfile: Dockerfile + container_name: silero-tts-service + restart: unless-stopped + ports: + - "8000:8000" + environment: + - MODEL=v5_ru + - DEVICE=cpu + - SAMPLE_RATE=48000 + - TORCH_THREADS=0 + - JIT_COMPILE=true + - MAX_QUEUE_SIZE=20 + - PROMETHEUS_ENABLED=false + - LOG_LEVEL=INFO + volumes: + - ./models:/app/models:rw + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/silero-tts-service/docker-compose.yml b/silero-tts-service/docker-compose.yml new file mode 100755 index 0000000..bd555e3 --- /dev/null +++ b/silero-tts-service/docker-compose.yml @@ -0,0 +1,40 @@ +version: "3.9" + +services: + tts-service: + build: + context: . + dockerfile: Dockerfile + container_name: silero-tts-service + restart: unless-stopped + ports: + - "8000:8000" + environment: + - MODEL=v5_ru + - DEVICE=cuda:0 + - SAMPLE_RATE=48000 + - TORCH_THREADS=0 + - JIT_COMPILE=false + - MAX_QUEUE_SIZE=20 + - PROMETHEUS_ENABLED=false + - LOG_LEVEL=INFO + volumes: + - ./models:/app/models:rw + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/health"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 120s + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" diff --git a/silero-tts-service/latest_silero_models.yml b/silero-tts-service/latest_silero_models.yml new file mode 100755 index 0000000..a116ad5 --- /dev/null +++ b/silero-tts-service/latest_silero_models.yml @@ -0,0 +1,660 @@ +# pre-trained STT models +stt_models: + en: + latest: + meta: + name: "en_v6" + sample: "https://models.silero.ai/examples/en_sample.wav" + labels: "https://models.silero.ai/models/en/en_v1_labels.json" + jit: "https://models.silero.ai/models/en/en_v6.jit" + onnx: "https://models.silero.ai/models/en/en_v5.onnx" + jit_q: "https://models.silero.ai/models/en/en_v6_q.jit" + jit_xlarge: "https://models.silero.ai/models/en/en_v6_xlarge.jit" + onnx_xlarge: "https://models.silero.ai/models/en/en_v6_xlarge.onnx" + v6: + meta: + name: "en_v6" + sample: "https://models.silero.ai/examples/en_sample.wav" + labels: "https://models.silero.ai/models/en/en_v1_labels.json" + jit: "https://models.silero.ai/models/en/en_v6.jit" + onnx: "https://models.silero.ai/models/en/en_v5.onnx" + jit_q: "https://models.silero.ai/models/en/en_v6_q.jit" + jit_xlarge: "https://models.silero.ai/models/en/en_v6_xlarge.jit" + onnx_xlarge: "https://models.silero.ai/models/en/en_v6_xlarge.onnx" + v5: + meta: + name: "en_v5" + sample: "https://models.silero.ai/examples/en_sample.wav" + labels: "https://models.silero.ai/models/en/en_v1_labels.json" + jit: "https://models.silero.ai/models/en/en_v5.jit" + onnx: "https://models.silero.ai/models/en/en_v5.onnx" + onnx_q: "https://models.silero.ai/models/en/en_v5_q.onnx" + jit_q: "https://models.silero.ai/models/en/en_v5_q.jit" + jit_xlarge: "https://models.silero.ai/models/en/en_v5_xlarge.jit" + onnx_xlarge: "https://models.silero.ai/models/en/en_v5_xlarge.onnx" + v4_0: + meta: + name: "en_v4_0" + sample: "https://models.silero.ai/examples/en_sample.wav" + labels: "https://models.silero.ai/models/en/en_v1_labels.json" + jit_large: "https://models.silero.ai/models/en/en_v4_0_jit_large.model" + onnx_large: "https://models.silero.ai/models/en/en_v4_0_large.onnx" + v3: + meta: + name: "en_v3" + sample: "https://models.silero.ai/examples/en_sample.wav" + labels: "https://models.silero.ai/models/en/en_v1_labels.json" + jit: "https://models.silero.ai/models/en/en_v3_jit.model" + onnx: "https://models.silero.ai/models/en/en_v3.onnx" + jit_q: "https://models.silero.ai/models/en/en_v3_jit_q.model" + jit_skip: "https://models.silero.ai/models/en/en_v3_jit_skips.model" + jit_large: "https://models.silero.ai/models/en/en_v3_jit_large.model" + onnx_large: "https://models.silero.ai/models/en/en_v3_large.onnx" + jit_xsmall: "https://models.silero.ai/models/en/en_v3_jit_xsmall.model" + jit_q_xsmall: "https://models.silero.ai/models/en/en_v3_jit_q_xsmall.model" + onnx_xsmall: "https://models.silero.ai/models/en/en_v3_xsmall.onnx" + v2: + meta: + name: "en_v2" + sample: "https://models.silero.ai/examples/en_sample.wav" + labels: "https://models.silero.ai/models/en/en_v1_labels.json" + jit: "https://models.silero.ai/models/en/en_v2_jit.model" + onnx: "https://models.silero.ai/models/en/en_v2.onnx" + tf: "https://models.silero.ai/models/en/en_v2_tf.tar.gz" + v1: + meta: + name: "en_v1" + sample: "https://models.silero.ai/examples/en_sample.wav" + labels: "https://models.silero.ai/models/en/en_v1_labels.json" + jit: "https://models.silero.ai/models/en/en_v1_jit.model" + onnx: "https://models.silero.ai/models/en/en_v1.onnx" + tf: "https://models.silero.ai/models/en/en_v1_tf.tar.gz" + de: + latest: + meta: + name: "de_v1" + sample: "https://models.silero.ai/examples/de_sample.wav" + labels: "https://models.silero.ai/models/de/de_v1_labels.json" + jit: "https://models.silero.ai/models/de/de_v1_jit.model" + onnx: "https://models.silero.ai/models/de/de_v1.onnx" + tf: "https://models.silero.ai/models/de/de_v1_tf.tar.gz" + v1: + meta: + name: "de_v1" + sample: "https://models.silero.ai/examples/de_sample.wav" + labels: "https://models.silero.ai/models/de/de_v1_labels.json" + jit_large: "https://models.silero.ai/models/de/de_v1_jit.model" + onnx: "https://models.silero.ai/models/de/de_v1.onnx" + tf: "https://models.silero.ai/models/de/de_v1_tf.tar.gz" + v3: + meta: + name: "de_v3" + sample: "https://models.silero.ai/examples/de_sample.wav" + labels: "https://models.silero.ai/models/de/de_v1_labels.json" + jit_large: "https://models.silero.ai/models/de/de_v3_large.jit" + v4: + meta: + name: "de_v4" + sample: "https://models.silero.ai/examples/de_sample.wav" + labels: "https://models.silero.ai/models/de/de_v1_labels.json" + jit_large: "https://models.silero.ai/models/de/de_v4_large.jit" + onnx_large: "https://models.silero.ai/models/de/de_v4_large.onnx" + es: + latest: + meta: + name: "es_v1" + sample: "https://models.silero.ai/examples/es_sample.wav" + labels: "https://models.silero.ai/models/es/es_v1_labels.json" + jit: "https://models.silero.ai/models/es/es_v1_jit.model" + onnx: "https://models.silero.ai/models/es/es_v1.onnx" + tf: "https://models.silero.ai/models/es/es_v1_tf.tar.gz" + ua: + latest: + meta: + name: "ua_v3" + sample: "https://models.silero.ai/examples/ua_sample.wav" + credits: + datasets: + speech-recognition-uk: https://github.com/egorsmkv/speech-recognition-uk + labels: "https://models.silero.ai/models/ua/ua_v1_labels.json" + jit: "https://models.silero.ai/models/ua/ua_v3_jit.model" + jit_q: "https://models.silero.ai/models/ua/ua_v3_jit_q.model" + onnx: "https://models.silero.ai/models/ua/ua_v3.onnx" + v3: + meta: + name: "ua_v3" + sample: "https://models.silero.ai/examples/ua_sample.wav" + credits: + datasets: + speech-recognition-uk: https://github.com/egorsmkv/speech-recognition-uk + labels: "https://models.silero.ai/models/ua/ua_v1_labels.json" + jit: "https://models.silero.ai/models/ua/ua_v3_jit.model" + jit_q: "https://models.silero.ai/models/ua/ua_v3_jit_q.model" + onnx: "https://models.silero.ai/models/ua/ua_v3.onnx" + v1: + meta: + name: "ua_v1" + sample: "https://models.silero.ai/examples/ua_sample.wav" + credits: + datasets: + speech-recognition-uk: https://github.com/egorsmkv/speech-recognition-uk + labels: "https://models.silero.ai/models/ua/ua_v1_labels.json" + jit: "https://models.silero.ai/models/ua/ua_v1_jit.model" + jit_q: "https://models.silero.ai/models/ua/ua_v1_jit_q.model" +tts_models: + ru: + v5_cis_base: + latest: + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + package: 'https://models.silero.ai/models/tts/ru/v5_cis_base.pt' + sample_rate: [8000, 24000, 48000] + v5_cis_base_nostress_jit: + latest: + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + package: 'https://models.silero.ai/models/tts/ru/v5_cis_base_nostress.jit' + sample_rate: [8000, 24000, 48000] + v5_cis_base_nostress: + latest: + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + package: 'https://models.silero.ai/models/tts/ru/v5_cis_base_nostress.pt' + sample_rate: [8000, 24000, 48000] + v5_cis_ext: + latest: + example: '+Я з р+аннього дит+инства д+уже любл+ю сл+ухати цік+аві к+азки.' + package: 'https://models.silero.ai/models/tts/ru/v5_cis_ext.pt' + sample_rate: [8000, 24000, 48000] + v5_5_ru: + latest: + example: 'Меня зовут Лева Королев. Я из готов. И я уже готов открыть все ваши замки любой сложности!' + package: 'https://models.silero.ai/models/tts/ru/v5_5_ru.pt' + sample_rate: [8000, 24000, 48000] + v5_4_ru: + latest: + example: 'Меня зовут Лева Королев. Я из готов. И я уже готов открыть все ваши замки любой сложности!' + package: 'https://models.silero.ai/models/tts/ru/v5_4_ru.pt' + sample_rate: [8000, 24000, 48000] + v5_3_ru: + latest: + example: 'Меня зовут Лева Королев. Я из готов. И я уже готов открыть все ваши замки любой сложности!' + package: 'https://models.silero.ai/models/tts/ru/v5_3_ru.pt' + sample_rate: [8000, 24000, 48000] + v5_2_ru: + latest: + example: 'Меня зовут Лева Королев. Я из готов. И я уже готов открыть все ваши замки любой сложности!' + package: 'https://models.silero.ai/models/tts/ru/v5_2_ru.pt' + sample_rate: [8000, 24000, 48000] + v5_1_ru: + latest: + example: 'Меня зовут Лева Королев. Я из готов. И я уже готов открыть все ваши замки любой сложности!' + package: 'https://models.silero.ai/models/tts/ru/v5_1_ru.pt' + sample_rate: [8000, 24000, 48000] + v5_ru: + latest: + example: 'Меня зовут Лева Королев. Я из готов. И я уже готов открыть все ваши замки любой сложности!' + package: 'https://models.silero.ai/models/tts/ru/v5_ru.pt' + sample_rate: [8000, 24000, 48000] + v4_ru: + latest: + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + package: 'https://models.silero.ai/models/tts/ru/v4_ru.pt' + sample_rate: [8000, 24000, 48000] + v3_1_ru: + latest: + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + package: 'https://models.silero.ai/models/tts/ru/v3_1_ru.pt' + sample_rate: [8000, 24000, 48000] + ru_v3: + latest: + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + package: 'https://models.silero.ai/models/tts/ru/ru_v3.pt' + sample_rate: [8000, 24000, 48000] + aidar_v2: + latest: + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + package: 'https://models.silero.ai/models/tts/ru/v2_aidar.pt' + sample_rate: [8000, 16000] + aidar_8khz: + latest: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_aidar_8000.jit' + sample_rate: 8000 + v1: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_aidar_8000.jit' + sample_rate: 8000 + aidar_16khz: + latest: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_aidar_16000.jit' + sample_rate: 16000 + v1: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_aidar_16000.jit' + sample_rate: 16000 + baya_v2: + latest: + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + package: 'https://models.silero.ai/models/tts/ru/v2_baya.pt' + sample_rate: [8000, 16000] + baya_8khz: + latest: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_baya_8000.jit' + sample_rate: 8000 + v1: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_baya_8000.jit' + sample_rate: 8000 + baya_16khz: + latest: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_baya_16000.jit' + sample_rate: 16000 + v1: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_baya_16000.jit' + sample_rate: 16000 + irina_v2: + latest: + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + package: 'https://models.silero.ai/models/tts/ru/v2_irina.pt' + sample_rate: [8000, 16000] + irina_8khz: + latest: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_irina_8000.jit' + sample_rate: 8000 + v1: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_irina_8000.jit' + sample_rate: 8000 + irina_16khz: + latest: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_irina_16000.jit' + sample_rate: 16000 + v1: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_irina_16000.jit' + sample_rate: 16000 + kseniya_v2: + latest: + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + package: 'https://models.silero.ai/models/tts/ru/v2_kseniya.pt' + sample_rate: [8000, 16000] + kseniya_8khz: + latest: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_kseniya_8000.jit' + sample_rate: 8000 + v1: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_kseniya_8000.jit' + sample_rate: 8000 + kseniya_16khz: + latest: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_kseniya_16000.jit' + sample_rate: 16000 + v1: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_kseniya_16000.jit' + sample_rate: 16000 + natasha_v2: + latest: + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + package: 'https://models.silero.ai/models/tts/ru/v2_natasha.pt' + sample_rate: [8000, 16000] + natasha_8khz: + latest: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_natasha_8000.jit' + sample_rate: 8000 + v1: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_natasha_8000.jit' + sample_rate: 8000 + natasha_16khz: + latest: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_natasha_16000.jit' + sample_rate: 16000 + v1: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_natasha_16000.jit' + sample_rate: 16000 + ruslan_v2: + latest: + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + package: 'https://models.silero.ai/models/tts/ru/v2_ruslan.pt' + sample_rate: [8000, 16000] + ruslan_8khz: + latest: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_ruslan_8000.jit' + sample_rate: 8000 + v1: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_ruslan_8000.jit' + sample_rate: 8000 + ruslan_16khz: + latest: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_ruslan_16000.jit' + sample_rate: 16000 + v1: + tokenset: '_~абвгдеёжзийклмнопрстуфхцчшщъыьэюя +.,!?…:;–' + example: 'В н+едрах т+ундры в+ыдры в г+етрах т+ырят в в+ёдра +ядра к+едров.' + jit: 'https://models.silero.ai/models/tts/ru/v1_ruslan_16000.jit' + sample_rate: 16000 + en: + v3_en: + latest: + example: 'Can you can a canned can into an un-canned can like a canner can can a canned can into an un-canned can?' + package: 'https://models.silero.ai/models/tts/en/v3_en.pt' + sample_rate: [8000, 24000, 48000] + v3_en_indic: + latest: + example: 'Can you can a canned can into an un-canned can like a canner can can a canned can into an un-canned can?' + package: 'https://models.silero.ai/models/tts/en/v3_en_indic.pt' + sample_rate: [8000, 24000, 48000] + lj_v2: + latest: + example: 'Can you can a canned can into an un-canned can like a canner can can a canned can into an un-canned can?' + package: 'https://models.silero.ai/models/tts/en/v2_lj.pt' + sample_rate: [8000, 16000] + lj_8khz: + latest: + tokenset: '_~abcdefghijklmnopqrstuvwxyz .,!?…:;–' + example: 'Can you can a canned can into an un-canned can like a canner can can a canned can into an un-canned can?' + jit: 'https://models.silero.ai/models/tts/en/v1_lj_8000.jit' + sample_rate: 8000 + v1: + tokenset: '_~abcdefghijklmnopqrstuvwxyz .,!?…:;–' + example: 'Can you can a canned can into an un-canned can like a canner can can a canned can into an un-canned can?' + jit: 'https://models.silero.ai/models/tts/en/v1_lj_8000.jit' + sample_rate: 8000 + lj_16khz: + latest: + tokenset: '_~abcdefghijklmnopqrstuvwxyz .,!?…:;–' + example: 'Can you can a canned can into an un-canned can like a canner can can a canned can into an un-canned can?' + jit: 'https://models.silero.ai/models/tts/en/v1_lj_16000.jit' + sample_rate: 16000 + v1: + tokenset: '_~abcdefghijklmnopqrstuvwxyz .,!?…:;–' + example: 'Can you can a canned can into an un-canned can like a canner can can a canned can into an un-canned can?' + jit: 'https://models.silero.ai/models/tts/en/v1_lj_16000.jit' + sample_rate: 16000 + de: + v3_de: + latest: + example: 'Fischers Fritze fischt frische Fische, Frische Fische fischt Fischers Fritze.' + package: 'https://models.silero.ai/models/tts/de/v3_de.pt' + sample_rate: [8000, 24000, 48000] + thorsten_v2: + latest: + example: 'Fischers Fritze fischt frische Fische, Frische Fische fischt Fischers Fritze.' + package: 'https://models.silero.ai/models/tts/de/v2_thorsten.pt' + sample_rate: [8000, 16000] + thorsten_8khz: + latest: + tokenset: '_~abcdefghijklmnopqrstuvwxyzäöüß .,!?…:;–' + example: 'Fischers Fritze fischt frische Fische, Frische Fische fischt Fischers Fritze.' + jit: 'https://models.silero.ai/models/tts/de/v1_thorsten_8000.jit' + sample_rate: 8000 + v1: + tokenset: '_~abcdefghijklmnopqrstuvwxyzäöüß .,!?…:;–' + example: 'Fischers Fritze fischt frische Fische, Frische Fische fischt Fischers Fritze.' + jit: 'https://models.silero.ai/models/tts/de/v1_thorsten_8000.jit' + sample_rate: 8000 + thorsten_16khz: + latest: + tokenset: '_~abcdefghijklmnopqrstuvwxyzäöüß .,!?…:;–' + example: 'Fischers Fritze fischt frische Fische, Frische Fische fischt Fischers Fritze.' + jit: 'https://models.silero.ai/models/tts/de/v1_thorsten_16000.jit' + sample_rate: 16000 + v1: + tokenset: '_~abcdefghijklmnopqrstuvwxyzäöüß .,!?…:;–' + example: 'Fischers Fritze fischt frische Fische, Frische Fische fischt Fischers Fritze.' + jit: 'https://models.silero.ai/models/tts/de/v1_thorsten_16000.jit' + sample_rate: 16000 + es: + v3_es: + latest: + example: 'Hoy ya es ayer y ayer ya es hoy, ya llegó el día, y hoy es hoy.' + package: 'https://models.silero.ai/models/tts/es/v3_es.pt' + sample_rate: [8000, 24000, 48000] + tux_v2: + latest: + example: 'Hoy ya es ayer y ayer ya es hoy, ya llegó el día, y hoy es hoy.' + package: 'https://models.silero.ai/models/tts/es/v2_tux.pt' + sample_rate: [8000, 16000] + tux_8khz: + latest: + tokenset: '_~abcdefghijklmnopqrstuvwxyzáéíñóú .,!?…:;–¡¿' + example: 'Hoy ya es ayer y ayer ya es hoy, ya llegó el día, y hoy es hoy.' + jit: 'https://models.silero.ai/models/tts/es/v1_tux_8000.jit' + sample_rate: 8000 + v1: + tokenset: '_~abcdefghijklmnopqrstuvwxyzáéíñóú .,!?…:;–¡¿' + example: 'Hoy ya es ayer y ayer ya es hoy, ya llegó el día, y hoy es hoy.' + jit: 'https://models.silero.ai/models/tts/es/v1_tux_8000.jit' + sample_rate: 8000 + tux_16khz: + latest: + tokenset: '_~abcdefghijklmnopqrstuvwxyzáéíñóú .,!?…:;–¡¿' + example: 'Hoy ya es ayer y ayer ya es hoy, ya llegó el día, y hoy es hoy.' + jit: 'https://models.silero.ai/models/tts/es/v1_tux_16000.jit' + sample_rate: 16000 + v1: + tokenset: '_~abcdefghijklmnopqrstuvwxyzáéíñóú .,!?…:;–¡¿' + example: 'Hoy ya es ayer y ayer ya es hoy, ya llegó el día, y hoy es hoy.' + jit: 'https://models.silero.ai/models/tts/es/v1_tux_16000.jit' + sample_rate: 16000 + fr: + v3_fr: + latest: + example: 'Je suis ce que je suis, et si je suis ce que je suis, qu’est ce que je suis.' + package: 'https://models.silero.ai/models/tts/fr/v3_fr.pt' + sample_rate: [8000, 24000, 48000] + gilles_v2: + latest: + example: 'Je suis ce que je suis, et si je suis ce que je suis, qu’est ce que je suis.' + package: 'https://models.silero.ai/models/tts/fr/v2_gilles.pt' + sample_rate: [8000, 16000] + gilles_8khz: + latest: + tokenset: '_~abcdefghijklmnopqrstuvwxyzéàèùâêîôûç .,!?…:;–' + example: 'Je suis ce que je suis, et si je suis ce que je suis, qu’est ce que je suis.' + jit: 'https://models.silero.ai/models/tts/fr/v1_gilles_8000.jit' + sample_rate: 8000 + v1: + tokenset: '_~abcdefghijklmnopqrstuvwxyzéàèùâêîôûç .,!?…:;–' + example: 'Je suis ce que je suis, et si je suis ce que je suis, qu’est ce que je suis.' + jit: 'https://models.silero.ai/models/tts/fr/v1_gilles_8000.jit' + sample_rate: 8000 + gilles_16khz: + latest: + tokenset: '_~abcdefghijklmnopqrstuvwxyzéàèùâêîôûç .,!?…:;–' + example: 'Je suis ce que je suis, et si je suis ce que je suis, qu’est ce que je suis.' + jit: 'https://models.silero.ai/models/tts/fr/v1_gilles_16000.jit' + sample_rate: 16000 + v1: + tokenset: '_~abcdefghijklmnopqrstuvwxyzéàèùâêîôûç .,!?…:;–' + example: 'Je suis ce que je suis, et si je suis ce que je suis, qu’est ce que je suis.' + jit: 'https://models.silero.ai/models/tts/fr/v1_gilles_16000.jit' + sample_rate: 16000 + ba: + aigul_v2: + latest: + example: 'Салауат Юлаевтың тормошо һәм яҙмышы хаҡындағы документтарҙың һәм шиғри әҫәрҙәренең бик аҙ өлөшө генә һаҡланған.' + package: 'https://models.silero.ai/models/tts/ba/v2_aigul.pt' + sample_rate: [8000, 16000] + language_name: 'bashkir' + xal: + v3_xal: + latest: + example: 'Һорвн, дөрвн күн ирәд, һазань чиңгнв. Байн Цецн хаана һорвн көвүн күүндҗәнә.' + package: 'https://models.silero.ai/models/tts/xal/v3_xal.pt' + sample_rate: [8000, 24000, 48000] + erdni_v2: + latest: + example: 'Һорвн, дөрвн күн ирәд, һазань чиңгнв. Байн Цецн хаана һорвн көвүн күүндҗәнә.' + package: 'https://models.silero.ai/models/tts/xal/v2_erdni.pt' + sample_rate: [8000, 16000] + language_name: 'kalmyk' + tt: + v3_tt: + latest: + example: 'Исәнмесез, саумысез, нишләп кәҗәгезне саумыйсыз, әтәчегез күкәй салган, нишләп чыгып алмыйсыз.' + package: 'https://models.silero.ai/models/tts/tt/v3_tt.pt' + sample_rate: [8000, 24000, 48000] + dilyara_v2: + latest: + example: 'Ис+әнмесез, с+аумысез, нишл+әп кәҗәгезн+е с+аумыйсыз, әтәчег+ез күк+әй салг+ан, нишл+әп чыг+ып +алмыйсыз.' + package: 'https://models.silero.ai/models/tts/tt/v2_dilyara.pt' + sample_rate: [8000, 16000] + language_name: 'tatar' + uz: + v4_uz: + latest: + example: 'Tanishganimdan xursandman.' + package: 'https://models.silero.ai/models/tts/uz/v4_uz.pt' + sample_rate: [8000, 24000, 48000] + v3_uz: + latest: + example: 'Tanishganimdan xursandman.' + package: 'https://models.silero.ai/models/tts/uz/v3_uz.pt' + sample_rate: [8000, 24000, 48000] + dilnavoz_v2: + latest: + example: 'Tanishganimdan xursandman.' + package: 'https://models.silero.ai/models/tts/uz/v2_dilnavoz.pt' + sample_rate: [8000, 16000] + language_name: 'uzbek' + ua: + v4_ua: + latest: + example: 'К+отики - пухн+асті жив+отики.' + package: 'https://models.silero.ai/models/tts/ua/v4_ua.pt' + sample_rate: [8000, 24000, 48000] + v3_ua: + latest: + example: 'К+отики - пухн+асті жив+отики.' + package: 'https://models.silero.ai/models/tts/ua/v3_ua.pt' + sample_rate: [8000, 24000, 48000] + mykyta_v2: + latest: + example: 'К+отики - пухн+асті жив+отики.' + package: 'https://models.silero.ai/models/tts/ua/v22_mykyta_48k.pt' + sample_rate: [8000, 24000, 48000] + language_name: 'ukrainian' + indic: + v4_indic: + latest: + example: 'prasidda kabīra adhyētā, puruṣōttama agravāla kā yaha śōdha ālēkha, usa rāmānaṁda kī khōja karatā hai' + package: 'https://models.silero.ai/models/tts/indic/v4_indic.pt' + sample_rate: [8000, 24000, 48000] + v3_indic: + latest: + example: 'prasidda kabīra adhyētā, puruṣōttama agravāla kā yaha śōdha ālēkha, usa rāmānaṁda kī khōja karatā hai' + package: 'https://models.silero.ai/models/tts/indic/v3_indic.pt' + sample_rate: [8000, 24000, 48000] + cyrillic: + v4_cyrillic: + latest: + example: 'Волк слабее льва и тигра, но в цирке он не выступает.' + package: 'https://models.silero.ai/models/tts/cyr/v4_cyrillic.pt' + sample_rate: [8000, 24000, 48000] + multi: + multi_v2: + latest: + package: 'https://models.silero.ai/models/tts/multi/v2_multi.pt' + sample_rate: [8000, 16000] + speakers: + aidar: + lang: 'ru' + example: 'Съ+ешьте ещ+ё +этих м+ягких франц+узских б+улочек, д+а в+ыпейте ч+аю.' + baya: + lang: 'ru' + example: 'Съ+ешьте ещ+ё +этих м+ягких франц+узских б+улочек, д+а в+ыпейте ч+аю.' + kseniya: + lang: 'ru' + example: 'Съ+ешьте ещ+ё +этих м+ягких франц+узских б+улочек, д+а в+ыпейте ч+аю.' + irina: + lang: 'ru' + example: 'Съ+ешьте ещ+ё +этих м+ягких франц+узских б+улочек, д+а в+ыпейте ч+аю.' + ruslan: + lang: 'ru' + example: 'Съ+ешьте ещ+ё +этих м+ягких франц+узских б+улочек, д+а в+ыпейте ч+аю.' + natasha: + lang: 'ru' + example: 'Съ+ешьте ещ+ё +этих м+ягких франц+узских б+улочек, д+а в+ыпейте ч+аю.' + thorsten: + lang: 'de' + example: 'Fischers Fritze fischt frische Fische, Frische Fische fischt Fischers Fritze.' + tux: + lang: 'es' + example: 'Hoy ya es ayer y ayer ya es hoy, ya llegó el día, y hoy es hoy.' + gilles: + lang: 'fr' + example: 'Je suis ce que je suis, et si je suis ce que je suis, qu’est ce que je suis.' + lj: + lang: 'en' + example: 'Can you can a canned can into an un-canned can like a canner can can a canned can into an un-canned can?' + dilyara: + lang: 'tt' + example: 'Пес+и пес+и песик+әй, борыннар+ы бәләк+әй.' +te_models: + latest: + package: "https://models.silero.ai/te_models/v2_4lang_q.pt" + languages: ['en', 'de', 'ru', 'es'] + punct: '.,-!?—' + v2: + package: "https://models.silero.ai/te_models/v2_4lang_q.pt" + languages: ['en', 'de', 'ru', 'es'] + punct: '.,-!?—' +denoise_models: + models: ['small_slow', 'large_fast', 'small_fast'] + samples: [ + "https://models.silero.ai/denoise_models/sample0.wav", + "https://models.silero.ai/denoise_models/sample1.wav", + ] + small_slow: + latest: + jit: "https://models.silero.ai/denoise_models/sns_latest.jit" + v0: + jit: "https://models.silero.ai/denoise_models/sns_v0.jit" + large_fast: + latest: + jit: "https://models.silero.ai/denoise_models/lnf_latest.jit" + v0: + jit: "https://models.silero.ai/denoise_models/lnf_v0.jit" + small_fast: + latest: + jit: "https://models.silero.ai/denoise_models/snf_latest.jit" + v0: + jit: "https://models.silero.ai/denoise_models/snf_v0.jit" diff --git a/silero-tts-service/pyproject.toml b/silero-tts-service/pyproject.toml new file mode 100755 index 0000000..2f674c5 --- /dev/null +++ b/silero-tts-service/pyproject.toml @@ -0,0 +1,23 @@ +[project] +name = "silero-tts-service" +version = "0.1.0" +description = "Autonomous REST API for Russian speech synthesis using Silero v5.5" +requires-python = ">=3.11" +dependencies = [ + "fastapi[standard]>=0.115.0", + "uvicorn[standard]>=0.30.0", + "silero>=5.5", + "torch>=2.4.0", + "pydantic-settings>=2.0", + "pydub>=0.25.1", + "ffmpeg-python>=0.2.0", + "llru>=1.0", + "structlog>=24.0", + "pydantic>=2.0", + "prometheus-client>=0.20.0", + "slowapi>=0.1.9", +] + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/silero-tts-service/run.py b/silero-tts-service/run.py new file mode 100755 index 0000000..3d9af0a --- /dev/null +++ b/silero-tts-service/run.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +"""Запуск Silero TTS сервиса""" + +import sys, os +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from app.main import create_app +import uvicorn + +app = create_app() + +if __name__ == '__main__': + port = int(os.environ.get('PORT', 8000)) + print(f'Запуск Silero TTS API на порту {port}') + uvicorn.run(app, host='0.0.0.0', port=port, log_level='info') diff --git a/silero-tts-service/tts.py b/silero-tts-service/tts.py new file mode 100644 index 0000000..5063d9c --- /dev/null +++ b/silero-tts-service/tts.py @@ -0,0 +1,267 @@ +#!/usr/bin/env python3 +""" +Silero TTS CLI — синтез речи через HTTP API для пайплайнов и скриптов. + +Использование: + # Проверка здоровья + python3 tts.py health + + # Список голосов + python3 tts.py voices + + # Базовый синтез + python3 tts.py speak "Привет, мир!" --voice xenia --output hello.wav + + # Синтез в MP3 + python3 tts.py speak "Тестовый текст" --voice baya --format mp3 --output test.mp3 + + # Пайплайн: конвейер (stdin → stdout) + echo "Текст для озвучки" | python3 tts.py pipe --voice aidar > output.wav + + # Пайплайн: JSON-результат (base64-аудио) + python3 tts.py speak "Текст" --json + + # HyperFrames: генерация аудио для видео-композиции + python3 tts.py speak "Добро пожаловать в HyperFrames" --voice xenia --output narration.wav + +Требования: сервис Silero TTS на http://127.0.0.1:8001 (или через env SILERO_TTS_URL) +""" + +import argparse +import base64 +import json +import os +import sys +from urllib.request import Request, urlopen +from urllib.error import URLError + +API_URL = os.environ.get("SILERO_TTS_URL", "http://127.0.0.1:8001") +# track whether URL was overridden from CLI +_url_from_args = None + + +def get_api_url() -> str: + """Получить текущий URL API (CLI-аргумент или env).""" + return _url_from_args or API_URL + +SPEAKERS = { + "aidar": "Мужской, нейтральный (русский)", + "baya": "Женский, мягкий (русский)", + "kseniya": "Женский, энергичный (русский)", + "xenia": "Женский, молодой (русский)", + "eugene": "Мужской, спокойный (русский)", +} + + +def api_get(path: str) -> dict: + """GET-запрос к API, возвращает JSON.""" + url = f"{get_api_url()}{path}" + req = Request(url) + req.add_header("Accept", "application/json") + try: + with urlopen(req, timeout=10) as resp: + return json.loads(resp.read().decode()) + except URLError as e: + print(f"Ошибка подключения к {url}: {e}", file=sys.stderr) + print("Сервис запущен? Проверь: curl http://127.0.0.1:8001/health", file=sys.stderr) + sys.exit(1) + + +def api_post(path: str, data: dict, return_raw: bool = False) -> bytes | dict: + """POST-запрос к API. Если return_raw — вернуть сырые байты.""" + url = f"{get_api_url()}{path}" + body = json.dumps(data).encode() + req = Request(url, data=body, method="POST") + req.add_header("Content-Type", "application/json") + if not return_raw: + req.add_header("Accept", "application/json") + try: + with urlopen(req, timeout=120) as resp: + content = resp.read() + if return_raw: + return content + return json.loads(content.decode()) + except URLError as e: + print(f"Ошибка подключения к {url}: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_health(): + """Проверка здоровья сервиса.""" + result = api_get("/v1/health") + status = "✅ Здоров" if result.get("status") == "healthy" else "❌ Проблемы" + print(f"Silero TTS API: {status}") + print(f" Модель загружена: {result.get('model_loaded', False)}") + if "device" in result: + print(f" Устройство: {result['device']}") + + +def cmd_voices(): + """Список доступных голосов.""" + result = api_get("/v1/voices") + speakers = result.get("speakers", {}) + print("Доступные голоса:") + for name, desc in speakers.items(): + marker = " ← default" if name == "xenia" else "" + print(f" {name:12s} — {desc}{marker}") + + +def cmd_speak(text: str, voice: str, fmt: str, output: str | None, as_json: bool): + """Синтез речи и сохранение/вывод результата.""" + if voice not in SPEAKERS: + print(f"Неизвестный голос: {voice}", file=sys.stderr) + print(f"Доступные: {', '.join(SPEAKERS.keys())}", file=sys.stderr) + sys.exit(1) + + # Определяем endpoint и формат + if fmt == "wav": + path = "/v1/tts" + payload = {"text": text, "speaker": voice} + audio = api_post(path, payload, return_raw=True) + elif fmt == "mp3": + path = "/v1/audio/speech" + payload = { + "model": "tts-1", + "input": text, + "voice": voice, + "response_format": "mp3", + } + audio = api_post(path, payload, return_raw=True) + else: + print(f"Неизвестный формат: {fmt}", file=sys.stderr) + sys.exit(1) + + if as_json: + # Вывести JSON с base64-аудио + result = { + "format": fmt, + "voice": voice, + "text": text, + "audio_base64": base64.b64encode(audio).decode(), + "size_bytes": len(audio), + } + print(json.dumps(result, ensure_ascii=False)) + elif output: + # Сохранить в файл + with open(output, "wb") as f: + f.write(audio) + size_kb = len(audio) / 1024 + print(f"✅ Сохранено: {output} ({size_kb:.1f} KB, {fmt.upper()})") + else: + # Вывести в stdout (для пайпов) + sys.stdout.buffer.write(audio) + + +def cmd_pipe(voice: str): + """Чтение текста из stdin и вывод WAV в stdout.""" + text = sys.stdin.read().strip() + if not text: + print("Пустой ввод", file=sys.stderr) + sys.exit(1) + cmd_speak(text, voice, "wav", None, False) + + +def cmd_hyperframes(text: str, voice: str, output: str, duration_hint: float | None): + """ + Сгенерировать аудио для HyperFrames-композиции. + Выводит HTML-сниппет с data-атрибутами для вставки в композицию. + """ + # Синтез + audio = api_post("/v1/tts", {"text": text, "speaker": voice}, return_raw=True) + + # Сохранить аудио + if not output: + output = f"narration_{voice}.wav" + + with open(output, "wb") as f: + f.write(audio) + + # Определить длительность из WAV-заголовка + import struct + wav_size = len(audio) + # WAV: sample_rate at bytes 24-27, data_size at bytes 40-43 + if wav_size >= 44: + sample_rate = struct.unpack_from(") ───") + print() + print(f' ') + + +def main(): + parser = argparse.ArgumentParser( + description="Silero TTS CLI — синтез речи для пайплайнов и HyperFrames", + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--url", + default=API_URL, + help=f"URL Silero TTS API (по умолчанию: {API_URL})", + ) + + sub = parser.add_subparsers(dest="command", help="Команда") + + # health + sub.add_parser("health", help="Проверка здоровья сервиса") + + # voices + sub.add_parser("voices", help="Список доступных голосов") + + # speak + sp = sub.add_parser("speak", help="Синтез речи в файл") + sp.add_argument("text", help="Текст для синтеза") + sp.add_argument("--voice", "-v", default="xenia", choices=list(SPEAKERS.keys()), help="Голос") + sp.add_argument("--format", "-f", default="wav", choices=["wav", "mp3"], help="Формат аудио") + sp.add_argument("--output", "-o", help="Путь для сохранения (по умолчанию stdout)") + sp.add_argument("--json", action="store_true", help="Вывести JSON с base64-аудио вместо файла") + + # pipe + pp = sub.add_parser("pipe", help="Чтение текста из stdin, вывод WAV в stdout") + pp.add_argument("--voice", "-v", default="xenia", choices=list(SPEAKERS.keys()), help="Голос") + + # hyperframes + hf = sub.add_parser("hyperframes", help="Генерация аудио для HyperFrames + HTML-сниппет") + hf.add_argument("text", help="Текст для озвучки") + hf.add_argument("--voice", "-v", default="xenia", choices=list(SPEAKERS.keys()), help="Голос") + hf.add_argument("--output", "-o", default="narration.wav", help="Имя выходного файла") + hf.add_argument("--duration", type=float, help="Длительность в секундах (если не удалось определить из WAV)") + + args = parser.parse_args() + + # URL из аргументов + global _url_from_args + if args.url: + _url_from_args = args.url + + if args.command == "health": + cmd_health() + elif args.command == "voices": + cmd_voices() + elif args.command == "speak": + cmd_speak(args.text, args.voice, args.format, args.output, args.json) + elif args.command == "pipe": + cmd_pipe(args.voice) + elif args.command == "hyperframes": + cmd_hyperframes(args.text, args.voice, args.output, args.duration) + else: + # По умолчанию: если есть аргумент — speak, иначе help + parser.print_help() + + +if __name__ == "__main__": + main()