initial: silero-tts-service + README

This commit is contained in:
Никита Тарасов committed 2026-08-24 21:28:44 +05:00
1 parent 8c60c25ad2
commit 984b1fec6d
18 files changed
+2080

No files matched your search

+17
View File
@@ -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%, первый запрос медленнее)
+13
View File
@@ -0,0 +1,13 @@
__pycache__/
*.pyc
.env
*.db
.venv/
venv/
*.egg-info/
dist/
build/
*.egg
test_tts.py
tests/
data/
+32
View File
@@ -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"]
+204
View File
@@ -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 ───
# <audio id="narration"
# data-start="0"
# data-duration="5.1"
# data-track-index="0"
# data-volume="1.0"
# src="narration.wav">
# </audio>
```
Вставить сниппет в `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']))
"
```
+1
View File
@@ -0,0 +1 @@
# App package
+284
View File
@@ -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)}",
)
+69
View File
@@ -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
+59
View File
@@ -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
+181
View File
@@ -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()
+35
View File
@@ -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"])
+95
View File
@@ -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()
+49
View File
@@ -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
+36
View File
@@ -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"
+40
View File
@@ -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"
+660
View File
@@ -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"
+23
View File
@@ -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"
+15
View File
@@ -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')
+267
View File
@@ -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("<I", audio, 24)[0]
data_size = struct.unpack_from("<I", audio, 40)[0]
duration = data_size / (sample_rate * 2) # 16-bit mono = 2 bytes/sample
else:
duration = duration_hint or 3.0
size_kb = wav_size / 1024
print(f"✅ Аудио: {output} ({size_kb:.1f} KB, {duration:.2f} сек)")
# Генерировать HTML-сниппет для HyperFrames композиции
print()
print("─── HTML-сниппет для HyperFrames (вставить в <div data-composition-id=\"...\">) ───")
print()
print(f' <audio id="narration"')
print(f' data-start="0"')
print(f' data-duration="{duration:.1f}"')
print(f' data-track-index="0"')
print(f' data-volume="1.0"')
print(f' src="{output}">')
print(f' </audio>')
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()