This commit is contained in:
Shaman
2026-10-01 07:38:01 +03:00
commit 82c306ff69
33 changed files with 2629 additions and 0 deletions
+176
View File
@@ -0,0 +1,176 @@
# ---> Python
# Byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
*$py.class
# C extensions
*.so
# Distribution / packaging
.Python
build/
develop-eggs/
dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/
var/
wheels/
share/python-wheels/
*.egg-info/
.installed.cfg
*.egg
MANIFEST
# PyInstaller
# Usually these files are written by a python script from a template
# before PyInstaller builds the exe, so as to inject date/other infos into it.
*.manifest
*.spec
# Installer logs
pip-log.txt
pip-delete-this-directory.txt
# Unit test / coverage reports
htmlcov/
.tox/
.nox/
.coverage
.coverage.*
.cache
nosetests.xml
coverage.xml
*.cover
*.py,cover
.hypothesis/
.pytest_cache/
cover/
# Translations
*.mo
*.pot
# Django stuff:
*.log
local_settings.py
db.sqlite3
db.sqlite3-journal
# Flask stuff:
instance/
.webassets-cache
# Scrapy stuff:
.scrapy
# Sphinx documentation
docs/_build/
# PyBuilder
.pybuilder/
target/
# Jupyter Notebook
.ipynb_checkpoints
# IPython
profile_default/
ipython_config.py
# pyenv
# For a library or package, you might want to ignore these files since the code is
# intended to run in multiple environments; otherwise, check them in:
# .python-version
# pipenv
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
# However, in case of collaboration, if having platform-specific dependencies or dependencies
# having no cross-platform support, pipenv may install dependencies that don't work, or not
# install all needed dependencies.
#Pipfile.lock
# UV
# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
#uv.lock
# poetry
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
# This is especially recommended for binary packages to ensure reproducibility, and is more
# commonly ignored for libraries.
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
#poetry.lock
# pdm
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
#pdm.lock
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
# in version control.
# https://pdm.fming.dev/latest/usage/project/#working-with-version-control
.pdm.toml
.pdm-python
.pdm-build/
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
__pypackages__/
# Celery stuff
celerybeat-schedule
celerybeat.pid
# SageMath parsed files
*.sage.py
# Environments
.env
.venv
env/
venv/
ENV/
env.bak/
venv.bak/
# Spyder project settings
.spyderproject
.spyproject
# Rope project settings
.ropeproject
# mkdocs documentation
/site
# mypy
.mypy_cache/
.dmypy.json
dmypy.json
# Pyre type checker
.pyre/
# pytype static type analyzer
.pytype/
# Cython debug symbols
cython_debug/
# PyCharm
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
# and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/
# Ruff stuff:
.ruff_cache/
# PyPI configuration file
.pypirc
+369
View File
@@ -0,0 +1,369 @@
# Silero TTS Server
Локальный сервер синтеза речи на базе [Silero TTS](https://github.com/snakers4/silero-models) с **OpenAI-совместимым** API.
Принимает текст на русском и английском, определяет язык запроса по наличию
кириллицы, нормализует числа/аббревиатуры (`rutextnorm`), транслитерирует
латинские слова в кириллицу (RU) и возвращает готовый аудиопоток.
Работает полностью офлайн — модели грузятся из локальных файлов `.pt`, сеть не требуется.
---
## Возможности
- 🔌 **OpenAI-совместимые** эндпоинты: `/v1/audio/speech`, `/v1/models`, `/v1/audio/voices`
- 🌍 **Один язык и один голос на запрос** — если в тексте есть хоть одна
кириллическая буква, весь текст читает русская модель; иначе — английская.
Голос не меняется внутри ответа.
- 🔢 **Нормализация текста** — цифры, даты, единицы измерения читаются словами (только RU)
- 🎙️ **14 голосов** (имена из набора OpenAI) с отдельными спикерами для RU и EN
- 🎵 **6 форматов вывода**: `mp3`, `wav`, `pcm`, `opus`, `aac`, `flac`
- 📡 **Потоковая передача** (`stream: true`)
- 🖥️ **Веб-интерфейс** для тестирования на `/webui` (включается в `config.json`)
- ⚙️ Полностью **CPU**-синтез, без обязательной GPU
---
## Быстрый старт
### Windows
```bat
run.bat
```
### Linux / macOS
```bash
./run.sh
```
Скрипты автоматически создают `.venv`, устанавливают зависимости и запускают
сервер на `http://0.0.0.0:5215`.
### Обновление из репозитория
```bat
update.bat
```
```bash
./update.sh
```
Останавливают сервис, подтягивают изменения из `origin` (ветка `main`),
переустанавливают зависимости и перезапускают сервер.
> **Зависимости от системы**
> - **Python 3.12**
> - **ffmpeg** — нужен для `mp3`/`opus`/`aac`/`flac` (для `wav`/`pcm` не требуется).
> Ставится автоматически вместе с `imageio-ffmpeg` (входит в зависимости),
> отдельных действий не нужно. Свой бинарь можно указать в
> `config.json` → `runtime.ffmpeg_path`.
### Ручной запуск
```bash
python -m venv .venv
.venv/Scripts/pip install -e ".[dev]" # Windows
.venv/bin/pip install -e ".[dev]" # Linux/macOS
.venv/bin/python -m uvicorn app.main:app --host 0.0.0.0 --port 5215
```
---
## Использование
### Базовый запрос
```bash
curl http://localhost:5215/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"voice": "alloy", "input": "Привет, мир! This is a test.", "response_format": "mp3"}' \
--output speech.mp3
```
### Параметры запроса
| Параметр | Тип | По умолчанию | Описание |
|-------------------|-----------|-------------------|-------------------------------------------------|
| `model` | `string` | `UniosTTS` | Игнорируется (совместимость с OpenAI) |
| `voice` | `string` | — | Имя голоса (см. ниже), при неизвестном — `default` |
| `input` | `string` | — | Текст для синтеза (не пустой) |
| `response_format` | `string` | `mp3` | `mp3` / `wav` / `pcm` / `opus` / `aac` / `flac` |
| `speed` | `float` | `1.0` | Пока поддерживается **только** `1.0` |
| `stream` | `bool` | `false` | Потоковая отдача аудио |
| `stream_format` | `string` | — | `audio` включает стрим; `sse` не поддерживается |
| `rate` | `string` | — | Темп просодии: `x-slow`/`slow`/`medium`/`fast`/`x-fast` (иное значение → 422) |
| `pitch` | `string` | — | Тон просодии: `x-low`/`low`/`medium`/`high`/`x-high` (иное значение → 422) |
### Голоса
Имена голосов соответствуют встроенному набору OpenAI. RU-сторона ограничена
5 спикерами модели `v5_5_ru`, EN-сторона — спикерами `v3_en`.
| Имя | RU-спикер | EN-спикер |
|------------|-----------|-----------|
| `alloy` | `eugene` | `en_2` |
| `ash` | `aidar` | `en_1` |
| `ballad` | `baya` | `en_3` |
| `coral` | `kseniya` | `en_4` |
| `echo` | `aidar` | `en_8` |
| `fable` | `baya` | `en_5` |
| `nova` | `xenia` | `en_6` |
| `onyx` | `eugene` | `en_8` |
| `sage` | `xenia` | `en_7` |
| `shimmer` | `kseniya` | `en_9` |
| `verse` | `eugene` | `en_10` |
| `marin` | `aidar` | `en_11` |
| `cedar` | `kseniya` | `en_12` |
| `default` | `baya` | `en_0` |
### Проверка работоспособности
```bash
curl http://localhost:5215/health
# {"status":"ok"}
```
### Эндпоинты
| Метод | Путь | Назначение |
|--------|---------------------|-------------------------------------------------------|
| `POST` | `/v1/audio/speech` | Синтез речи (OpenAI-совместимый) |
| `GET` | `/v1/models` | Список моделей (бренд-модель `UniosTTS`) |
| `GET` | `/v1/audio/voices` | Список голосов из `config.json` |
| `GET` | `/health` | Проверка работоспособности |
| `GET` | `/webui` | Веб-интерфейс (если `webui: true`) |
| `GET` | `/webui/models` | Список моделей для UI (`UniosTTS`) |
| `GET` | `/webui/voices` | Список голосов для UI |
| `POST` | `/webui/speech` | Синтез + метаданные (JSON: base64, язык, нормализация) |
---
## Веб-интерфейс
Для интерактивного тестирования. Включается флагом `"webui": true` в
`config.json`; по умолчанию выключен (эндпоинты `/webui/*` отдают 404).
Откройте `http://localhost:5215/webui`: вводите текст, выбирайте голос и
формат, слушайте результат. Поведение идентично API — модель выбирается
автоматически по языку текста (`model` в UI — это `UniosTTS`, фактически
игнорируется), голос берётся из стандартного справочника.
Отличие от API — `/webui/speech` возвращает JSON с base64-аудио и
метаданными вместо бинарного потока, чтобы показать диагностику:
| Поле | Описание |
|--------------------|-------------------------------------------|
| `audio` | Аудио в base64 |
| `language` | Определённый язык (`ru` / `en`) |
| `model` / `voice` | Модель и имя голоса из справочника |
| `speaker` | Реальный спикер модели |
| `normalized_text` | Текст после нормализации |
| `segments` | Число сегментов |
| `synthesis_time_ms`| Время синтеза, мс |
Интерфейс также хранит историю запросов текущей сессии и даёт скачать файл.
---
## Конфигурация
Настройки в `config.json`:
```jsonc
{
"webui": true, // включить веб-интерфейс на /webui
"use_ssml": true, // SSML-просодия/паузы; false = откат на легаси text= путь
"models": [ // список моделей, порядок = приоритет при выборе по языку
{ "file": "v5_5_ru", "languages": ["ru"] }, // models/v5_5_ru.pt
{ "file": "v3_en", "languages": ["en"] } // models/v3_en.pt
],
"model_voices": { // сопоставление стандартного имени голоса со спикером каждой модели
"v5_5_ru": { "default": "baya", "alloy": "eugene" /* ... */ },
"v3_en": { "default": "en_0", "alloy": "en_2" /* ... */ }
},
"runtime": {
"sample_rate": 24000,
"ffmpeg_path": "ffmpeg",
"translit_dict": "translit_dict.json",
"ssml_max_chars": 500 // предел длины одного <speak>-документа (сверх — режется по абзацам)
}
}
```
Модели описаны в массиве `models` и грузятся через `torch.package` при старте
(см. `app/synthesis.py`). Стандартные имена голосов (`alloy`, `ash`, …)
одинаковы для всех моделей, а во `model_voices` задано, какой реальный спикер
соответствует имени в каждой конкретной модели. Язык текста определяет, какая
модель используется, — из её `languages`.
---
## Структура проекта
```
.
├── app/ # FastAPI-приложение
│ ├── main.py # инициализация приложения, /health, /v1/models
│ ├── speech.py # эндпоинты /v1/audio/speech и /v1/audio/voices
│ ├── webui.py # веб-интерфейс /webui и /webui/* (опционально)
│ ├── webui.html # одностраничный UI (inline CSS/JS)
│ ├── synthesis.py # движок синтеза (набор моделей Silero)
│ ├── segmentation.py # определение языка запроса и подготовка сегментов
│ ├── normalize.py # нормализация RU-текста (rutextnorm)
│ ├── ssml.py # SSML-билдер: <s>/<p>/<break>/<prosody>, sentinel-паузы
│ ├── translit.py # транслитерация латинских слов в кириллицу (RU)
│ ├── audio.py # кодирование PCM → mp3/wav/opus/… (ffmpeg)
│ ├── config.py # загрузка config.json
│ └── errors.py # OpenAI-формат ошибок
├── silero_tts/ # локальный шим-обёртка над моделями Silero
├── models/ # файлы моделей .pt (грузятся офлайн)
├── config.json # модели, голоса и рантайм-настройки
├── translit_dict.json # словари транслитерации (exact + abbreviations)
├── tests/ # pytest-тесты (транслит, сегментация, синтез)
├── pyproject.toml # зависимости и сборка
├── run.bat / run.sh # скрипты запуска
├── update.bat / update.sh # скрипты обновления из git
```
### Как это работает
```
текст → определение языка запроса (есть кириллица → RU, иначе EN)
→ извлечение явных пауз `|||` / `|||мс` (→ sentinel)
→ допостановка точек на концах строк
→ разметка паузных границ (разделители удаляются, вставляется sentinel)
→ разбиение на абзацы и предложения (razdel)
→ транслитерация латинских слов в кириллицу (только RU)
→ нормализация RU (rutextnorm) — см. «Нормализация текста»
→ сборка SSML (<speak>/<p>/<s>/<break>/<prosody>), разбиение по ssml_max_chars
→ синтез одним голосом через ssml_text (автоударения + ручные «+»)
→ кодирование в нужный формат (ffmpeg)
```
---
## Нормализация текста
Перед синтезом текст нормализуется в **два слоя**:
1. **Общий слой** — языконезависимые правила, применяются ко **всему тексту**
целиком, независимо от языка.
2. **Региональный слой** — правила конкретного языка, применяются только к
сегментам распознанного языка (сейчас реализован только для русского).
```
сырой текст
→ detect_language() # один язык на весь запрос
→ extract_explicit_pauses() # `|||`/`|||мс` → sentinel-пауза (без цифр в тексте)
→ normalize_common() # общий слой (+ разметка паузных границ sentinel'ом)
→ разбиение на абзацы (пустые строки) и предложения (razdel.sentenize)
→ transliterate_latin() # только для RU: латынь → кириллица
→ normalize_language(sent, lang) # региональный слой по языку
→ build_ssml() → <speak>/<p>/<s>/<break>/<prosody>
→ синтез через ssml_text (put_accent=True, put_yo)
```
Логика сосредоточена в `app/normalize.py`, `app/translit.py` и `app/segmentation.py`.
### Общий слой — `normalize_common()` (любой язык)
| Вид | Метод | Что делает |
|-----|-------|------------|
| Точка на переносе строки | `ensure_line_punctuation` | Если строка заканчивается на букву/цифру без знака препинания, перед переводом строки добавляется `.`. Нужно для заголовков и списков: иначе `razdel` склеивает строку со следующей, и синтез читает их слитно без паузы. Строки, уже заканчивающиеся на пунктуацию (`.` `!` `?` `,` `:` …) или на закрывающие `» ) "`, не изменяются. Каждый перевод строки трактуется как граница — пауза ставится намеренно. |
| Пробелы | (в `normalize_common`) | Схлопывание повторяющихся пробелов и табуляций. |
### Региональный слой — `normalize_language()` (по языку)
Региональные нормализаторы перечислены в словаре
`_REGIONAL_NORMALIZERS = {"ru": normalize_russian}` (app/normalize.py).
Функция вызывается для каждого сегмента с его языком; языки без собственных
правил (например, английский) проходят **без изменений** — числа и даты в них
произносит сама модель.
> **Как добавить язык.** Положите функцию-нормализатор в `_REGIONAL_NORMALIZERS`
> под нужным кодом языка — общий слой останется прежним.
#### Русский — `normalize_russian()` (rutextnorm)
Применяется к сегментам языка `ru`. Методы идут в таком порядке:
| Группа | Методы |
|--------|--------|
| Типографика и пробелы | `normalize_typography`, схлопывание повторных пробелов |
| Веб-текст | `normalize_web` (URL, e-mail, домены, символы `@ / . #`) |
| Сокращения и разделы | `normalize_abbreviations`, `expand_abbreviations`, `normalize_sections` (§, ст., п.), `normalize_case_context` |
| Числа | `normalize_number_groups` (разряды), `normalize_ranges` (диапазоны), `normalize_ordinals` + `normalize_ordinal_triggers` (порядковые), `normalize_decimals` (десятичные), `normalize_negatives` (отрицания), `normalize_text_with_numbers` (цифры → слова) |
| Даты и время | `normalize_dates`, `normalize_time`, `normalize_scores` (счёт N:M) |
| Дроби и проценты | `normalize_fractions`, `normalize_percent`, `normalize_multipliers` (×, кратное) |
| Деньги и величины | `normalize_symbol_currency`, `currency_normalization`, `normalize_measurements` (кг, м, …), `normalize_symbols` |
| Прочее | `normalize_math` (`+ − = %`), `normalize_versions`, `normalize_text_with_phone_numbers` |
| Латиница в RU-тексте | обрабатывается **до** вызова `normalize_russian` — транслитерацией из `app/translit.py` (см. «Смешанный RU/EN текст»), поэтому внутренние latin-правила rutextnorm (`cyrrilize`) фактически получают уже кириллический текст |
| Буква Ё | `restore_yo` |
### Смешанный RU/EN текст
Язык выбирается **на весь запрос**: хотя бы одна кириллическая буква (U+0400–U+04FF)
— русская модель и RU-спикер, иначе — английская модель и EN-спикер. Голос не
меняется внутри ответа. Чтобы английские слова не выпадали из аудиодорожки
(русская модель Silero их не произносит), перед нормализацией они транслитерируются
в кириллицу (`app/translit.py`):
1. **Словарь аббревиатур** (`abbreviations`) — для токенов только в верхнем
регистре: `IT` → «айти», `DVD` → «ди-вэ-ди».
2. **Словарь точных соответствий** (`exact`) — бренды и термины: `Ozon` → «Озон»,
`Python` → «Пайтон» (регистр выхода повторяет вход).
3. **Фонетические правила** — для слов вне словаря: `shipping` → «шиппинг».
Словарь лежит в `translit_dict.json` (путь настраивается в
`config.json` → `runtime.translit_dict`) и пополняется **без изменения кода** —
достаточно добавить строку `"spotify": "спотифай"` в раздел `exact`.
Транслитерация применяется только к RU-запросам; чисто английский текст идёт
в EN-модель без изменений.
### Автоударения (на стороне модели)
При синтезе включены `put_accent=True` и `put_yo=True` (app/synthesis.py) —
расстановка ударений и разрешение омографов средствами модели `v5_5_ru`
(это функция Silero, а не нормализатора).
---
## SSML-разметка (просодия, паузы, ручные ударения)
По умолчанию (`"use_ssml": true`) синтез идёт через SSML: подготовленные
сегменты собираются в `<speak>`-документы (app/ssml.py) и подаются в модель
параметром `ssml_text`. Структура: предложение → `<s>`, абзац (пустая строка
во входе) → `<p>`, всё содержимое при заданном `rate`/`pitch` оборачивается в
`<prosody>`.
| Метка во входе | Эффект |
|---------------------------|------------------------------------------------------------------------|
| `rate` / `pitch` (API/UI) | Темп и тон речи (`<prosody>`); невалидное значение → `422` |
| `|||` | Явная пауза по умолчанию (150 мс) → `<break time="150ms"/>` |
| `|||1200` | Явная пауза 1200 мс (обрезается до 5000 мс) → `<break time="1200ms"/>` |
| `+` перед гласной | Ручное ударение, сохраняется до модели (рядом с автоударением) |
| Скобки/кавычки/markdown | Удаляются как символы; на их границе вставляется пауза 150 мс |
Паузные границы кодируются внутренним sentinel-символом из PUA, который не
виден в модели и в отображаемом тексте (`normalized_text` в `/webui/speech`
показывается уже без него). Длинные тексты режутся по границам абзацев на
документы не длиннее `runtime.ssml_max_chars` (по умолчанию 500 символов —
экспериментальный предел модели).
**Откат.** Если SSML-путь нужно полностью отключить, поставьте
`"use_ssml": false` в `config.json` — синтез вернётся на легаси-ветку `text=`
по одному сегменту за вызов (без `<break>`/`<prosody>`, ручные `+` и явные
паузы в этом режиме не действуют). Поле `rate`/`pitch` при этом игнорируется.
---
## Ограничения
- Скорость воспроизведения (`speed`) отличная от `1.0` не поддерживается.
- `stream_format: "sse"` возвращает ошибку.
- Синтез выполняется на CPU (`num_threads=4`); для реального времени при больших
нагрузках рекомендуется GPU.
---
## Лицензия
Модели Silero TTS (включая используемые здесь `v5_5_ru` и `v3_en`)
распространяются под лицензией **CC BY-NC 4.0** — только для некоммерческого
использования. Убедитесь, что ваше использование соответствует её условиям.
View File
+104
View File
@@ -0,0 +1,104 @@
import shutil
import struct
import subprocess
from io import BytesIO
def _find_ffmpeg(configured: str = "ffmpeg") -> str:
if shutil.which(configured):
return configured
try:
from imageio_ffmpeg import get_ffmpeg_exe
return get_ffmpeg_exe()
except Exception:
raise RuntimeError(
"ffmpeg not found: install it on PATH, set runtime.ffmpeg_path "
"in config.json, or `pip install imageio-ffmpeg`"
) from None
_SUPPORTED_FORMATS = {"mp3", "wav", "pcm", "opus", "aac", "flac"}
_CONTENT_TYPES = {
"mp3": "audio/mpeg",
"wav": "audio/wav",
"pcm": "audio/pcm",
"opus": "audio/opus",
"aac": "audio/aac",
"flac": "audio/flac",
}
_FFMPEG_CODECS = {
"mp3": "libmp3lame",
"opus": "libopus",
"aac": "aac",
"flac": "flac",
}
def pcm_to_wav(pcm: bytes, sample_rate: int = 24000) -> bytes:
data_size = len(pcm)
byte_rate = sample_rate * 2
header = struct.pack(
"<4sI4s4sI4sI",
b"RIFF",
36 + data_size,
b"WAVE",
b"fmt ",
16,
b"data",
data_size,
)
fmt = struct.pack("<HHIIHH", 1, 1, sample_rate, byte_rate, 2, 16)
return header[:20] + fmt + header[20:] + pcm
def pcm_to_raw(pcm: bytes) -> bytes:
return pcm
def pcm_to_ffmpeg(
pcm: bytes, fmt: str, sample_rate: int = 24000, ffmpeg_path: str = "ffmpeg"
) -> bytes:
codec = _FFMPEG_CODECS.get(fmt)
if codec is None:
raise ValueError(f"Unsupported ffmpeg format: {fmt}")
cmd = [
ffmpeg_path, "-y",
"-f", "s16le",
"-ar", str(sample_rate),
"-ac", "1",
"-i", "pipe:0",
"-f", _ffprobe_fmt(fmt),
"-codec:a", codec,
"pipe:1",
]
proc = subprocess.run(
cmd,
input=pcm,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {proc.stderr.decode(errors='replace')}")
return proc.stdout
def _ffprobe_fmt(fmt: str) -> str:
return {"mp3": "mp3", "opus": "opus", "aac": "adts", "flac": "flac"}.get(fmt, fmt)
def encode(pcm: bytes, fmt: str, sample_rate: int = 24000, ffmpeg_path: str = "ffmpeg") -> tuple[bytes, str]:
if fmt not in _SUPPORTED_FORMATS:
raise ValueError(f"Unsupported response_format: {fmt}")
if fmt == "wav":
data = pcm_to_wav(pcm, sample_rate)
elif fmt == "pcm":
data = pcm
else:
data = pcm_to_ffmpeg(pcm, fmt, sample_rate, _find_ffmpeg(ffmpeg_path))
return data, _CONTENT_TYPES[fmt]
+54
View File
@@ -0,0 +1,54 @@
import json
from pathlib import Path
from dataclasses import dataclass, field
@dataclass
class ModelConfig:
file: str
languages: list[str] = field(default_factory=list)
@dataclass
class RuntimeConfig:
sample_rate: int = 24000
ffmpeg_path: str = "ffmpeg"
translit_dict: str = "translit_dict.json"
port: int = 5215
host: str = "0.0.0.0"
ssml_max_chars: int = 500
@dataclass
class AppConfig:
models: list[ModelConfig] = field(default_factory=list)
model_voices: dict[str, dict[str, str]] = field(default_factory=dict)
runtime: RuntimeConfig = field(default_factory=RuntimeConfig)
webui: bool = False
use_ssml: bool = True
@classmethod
def load(cls, path: str | Path = "config.json") -> "AppConfig":
with open(path, encoding="utf-8") as f:
raw = json.load(f)
models = [
ModelConfig(file=m["file"], languages=m.get("languages", []))
for m in raw.get("models", [])
]
model_voices = raw.get("model_voices", {})
rt = raw.get("runtime", {})
runtime = RuntimeConfig(
sample_rate=rt.get("sample_rate", 24000),
ffmpeg_path=rt.get("ffmpeg_path", "ffmpeg"),
translit_dict=rt.get("translit_dict", "translit_dict.json"),
port=int(rt.get("port", 5215)),
host=rt.get("host", "0.0.0.0"),
ssml_max_chars=int(rt.get("ssml_max_chars", 500)),
)
return cls(
models=models,
model_voices=model_voices,
runtime=runtime,
webui=raw.get("webui", False),
use_ssml=raw.get("use_ssml", True),
)
+15
View File
@@ -0,0 +1,15 @@
from fastapi.responses import JSONResponse, StreamingResponse
def invalid_request_error(message: str, status_code: int = 400) -> JSONResponse:
return JSONResponse(
status_code=status_code,
content={
"error": {
"message": message,
"type": "invalid_request_error",
"param": None,
"code": None,
}
},
)
+58
View File
@@ -0,0 +1,58 @@
import time
from contextlib import asynccontextmanager
from fastapi import FastAPI
from app.config import AppConfig
from app.speech import router as speech_router
from app.synthesis import SynthesisEngine
from app.translit import set_dict_path
config = AppConfig.load()
set_dict_path(config.runtime.translit_dict)
engine = SynthesisEngine()
@asynccontextmanager
async def lifespan(app: FastAPI):
app.state.config = config
app.state.engine = engine
engine.use_ssml = config.use_ssml
engine.ssml_max_chars = config.runtime.ssml_max_chars
engine.load(
models_config=config.models,
sample_rate=config.runtime.sample_rate,
)
yield
app = FastAPI(title="Silero TTS", version="0.1.0", lifespan=lifespan)
app.include_router(speech_router)
if config.webui:
from app.webui import router as webui_router
app.include_router(webui_router)
_SUPPORTED_MODELS = ["UniosTTS"]
@app.get("/v1/models")
async def models():
created = int(time.time())
return {
"object": "list",
"data": [
{"id": m, "object": "model", "created": created, "owned_by": "silero"}
for m in _SUPPORTED_MODELS
],
}
@app.get("/health")
async def health():
return {"status": "ok"}
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host=config.runtime.host, port=config.runtime.port)
+77
View File
@@ -0,0 +1,77 @@
import re
from rutextnorm import normalize_russian
from app.ssml import PAUSE_DELIM
_TRAILING_CLOSERS = "\"'»)]`"
# Региональные (языкоспецифичные) нормализаторы: lang -> функция.
# Общие шаги, применимые к любому языку, вынесены в normalize_common().
_REGIONAL_NORMALIZERS = {"ru": normalize_russian}
_DELIM_CHARS = "([{«“<)]}»>\"'`*_~"
_DELIM_RUN_RE = re.compile(r"([ \t]*)([" + re.escape(_DELIM_CHARS) + r"]+)([ \t]*)")
def _prev_nonspace(text: str, pos: int) -> str:
i = pos - 1
while i >= 0 and text[i] in " \t":
i -= 1
return text[i] if i >= 0 else ""
def _next_nonspace(text: str, pos: int) -> str:
while pos < len(text) and text[pos] in " \t":
pos += 1
return text[pos] if pos < len(text) else ""
def mark_pause_delimiters(text: str) -> str:
"""Удаляет выделительные разделители (скобки, кавычки, markdown-разметку).
Если по обе стороны от прогона есть буквы/цифры, ставит sentinel паузы
(паузная граница для SSML-слоя) вместо литеральной запятой; иначе просто
удаляет. Одинарная кавычка между буквами (апостроф) сохраняется."""
def repl(m: re.Match) -> str:
left, run, right = m.group(1), m.group(2), m.group(3)
start, end = m.start(2), m.end(2)
left_touch = text[start - 1] if start > 0 else ""
right_touch = text[end] if end < len(text) else ""
if set(run) == {"'"} and left_touch.isalnum() and right_touch.isalnum():
return m.group(0)
if _prev_nonspace(text, start).isalnum() and _next_nonspace(text, end).isalnum():
return " " + PAUSE_DELIM + " "
return " " if (left or right) else ""
return _DELIM_RUN_RE.sub(repl, text)
def ensure_line_punctuation(text: str) -> str:
result = []
for line in text.split("\n"):
line = line.rstrip()
core = line.rstrip(_TRAILING_CLOSERS).rstrip()
if core and core[-1].isalnum():
line = line + "."
result.append(line)
return "\n".join(result)
def normalize_common(text: str) -> str:
"""Языконезависимая нормализация. Применяется ко всему тексту целиком,
до разбиения на предложения, для любого языка."""
text = mark_pause_delimiters(text)
text = ensure_line_punctuation(text)
text = re.sub(r"[ \t]{2,}", " ", text)
return text
def normalize_language(text: str, lang: str) -> str:
"""Региональная нормализация для конкретного языка. Без изменений для
языков, у которых нет собственных правил (например, en)."""
fn = _REGIONAL_NORMALIZERS.get(lang)
return fn(text) if fn else text
def normalize_ru(text: str) -> str:
return normalize_language(text, "ru")
+60
View File
@@ -0,0 +1,60 @@
import re
from dataclasses import dataclass
from razdel import sentenize
from app.normalize import normalize_common, normalize_language
from app.ssml import DEFAULT_PAUSE_MS, explicit_pause_char
from app.translit import transliterate_latin
_CYRILIC_RE = re.compile(r'[\u0400-\u04FF]')
_PARAGRAPH_RE = re.compile(r"\n[ \t]*\n")
# Явная отметка паузы во входном тексте: `|||` (пауза по умолчанию) или
# `|||1500` (миллисекунды). Извлекается ДО нормализации, длительность
# ограничивается здесь, чтобы цифры не попадали в rutextnorm.
_EXPLICIT_PAUSE_RE = re.compile(r"\|\|\|(?:(\d+))?")
@dataclass
class Segment:
text: str
lang: str
para: int = 0
def split_sentences(text: str) -> list[str]:
return [s.text for s in sentenize(text)]
def split_paragraphs(text: str) -> list[str]:
return [p for p in (block.strip() for block in _PARAGRAPH_RE.split(text)) if p]
def extract_explicit_pauses(text: str) -> str:
def repl(m: re.Match) -> str:
ms = int(m.group(1)) if m.group(1) else DEFAULT_PAUSE_MS
return explicit_pause_char(ms)
return _EXPLICIT_PAUSE_RE.sub(repl, text)
def has_cyrillic(text: str) -> bool:
return bool(_CYRILIC_RE.search(text))
def detect_language(text: str) -> str:
return "ru" if has_cyrillic(text) else "en"
def prepare_segments(text: str) -> list[Segment]:
lang = detect_language(text)
text = extract_explicit_pauses(text)
text = normalize_common(text)
all_segments: list[Segment] = []
for para_idx, para in enumerate(split_paragraphs(text)):
for sent in split_sentences(para):
if lang == "ru":
sent = transliterate_latin(sent)
seg_text = normalize_language(sent, lang)
if seg_text.strip():
all_segments.append(Segment(text=seg_text, lang=lang, para=para_idx))
return all_segments
+126
View File
@@ -0,0 +1,126 @@
from fastapi import APIRouter, Request
from fastapi.responses import Response, StreamingResponse
from pydantic import BaseModel, ValidationError, field_validator
from app.audio import encode, _SUPPORTED_FORMATS
from app.config import AppConfig
from app.errors import invalid_request_error
from app.segmentation import detect_language, prepare_segments
from app.ssml import PITCH_VALUES, RATE_VALUES
from app.synthesis import SynthesisEngine
router = APIRouter()
class SpeechRequest(BaseModel):
model: str = "UniosTTS"
voice: str
input: str
response_format: str = "mp3"
speed: float = 1.0
stream: bool | None = None
stream_format: str | None = None
rate: str | None = None
pitch: str | None = None
@field_validator("rate")
@classmethod
def _check_rate(cls, v: str | None) -> str | None:
if v is not None and v not in RATE_VALUES:
raise ValueError(f"rate must be one of {', '.join(RATE_VALUES)}")
return v
@field_validator("pitch")
@classmethod
def _check_pitch(cls, v: str | None) -> str | None:
if v is not None and v not in PITCH_VALUES:
raise ValueError(f"pitch must be one of {', '.join(PITCH_VALUES)}")
return v
def _resolve_speaker(config: AppConfig, model_id: str, voice_name: str) -> str | None:
voices = config.model_voices.get(model_id, {})
return voices.get(voice_name) or voices.get("default")
@router.get("/v1/audio/voices")
async def voices(request: Request):
config: AppConfig = request.app.state.config
ids: list[str] = []
for voices_map in config.model_voices.values():
for v in voices_map:
if v not in ids:
ids.append(v)
ids.sort()
return {
"object": "list",
"data": [{"id": name, "object": "voice"} for name in ids],
"voices": ids,
}
@router.post("/v1/audio/speech")
async def speech(request: Request):
body = await request.json()
try:
req = SpeechRequest(**body)
except ValidationError as e:
msg = str(e.errors()[0].get("msg", "invalid parameter")) if e.errors() else "invalid request"
return invalid_request_error(msg, status_code=422)
if not req.input.strip():
return invalid_request_error("input must be a non-empty string")
if req.response_format not in _SUPPORTED_FORMATS:
return invalid_request_error(
f"Unsupported response_format: '{req.response_format}'"
)
if req.speed != 1.0:
return invalid_request_error(
"speech rate adjustment is not supported; speed must be 1.0"
)
if req.stream_format == "sse":
return invalid_request_error("stream_format 'sse' is not supported")
config: AppConfig = request.app.state.config
engine: SynthesisEngine = request.app.state.engine
fmt = req.response_format
sr = config.runtime.sample_rate
ff = config.runtime.ffmpeg_path
is_stream = req.stream is True or req.stream_format == "audio"
lang = detect_language(req.input)
model = engine.find_model(lang)
if model is None:
return invalid_request_error(f"no model supports language '{lang}'")
speaker = _resolve_speaker(config, model.model_id, req.voice)
if speaker is None:
return invalid_request_error(f"voice '{req.voice}' not found for model '{model.model_id}'")
segments = prepare_segments(req.input)
if not segments:
return invalid_request_error("no speakable text found after processing")
if is_stream:
def _stream_gen():
for seg in segments:
seg_pcm = engine.synthesize_segments(
[seg], speaker, lang, rate=req.rate, pitch=req.pitch
)
data, _ = encode(seg_pcm, fmt, sr, ff)
yield data
_, ct = encode(b"\x00" * 4, fmt, sr, ff)
return StreamingResponse(
_stream_gen(), media_type=ct, headers={"Transfer-Encoding": "chunked"}
)
pcm = engine.synthesize_segments(segments, speaker, lang, rate=req.rate, pitch=req.pitch)
data, content_type = encode(pcm, fmt, sr, ff)
return Response(content=data, media_type=content_type)
+167
View File
@@ -0,0 +1,167 @@
from __future__ import annotations
import re
from dataclasses import dataclass
# Sentinel, который нормализатор оставляет вместо удалённого выделительного
# разделителя (пауза фиксированной длины).
PAUSE_DELIM = "\uE000"
DEFAULT_PAUSE_MS = 150
MAX_BREAK_MS = 5000
# Явная пользовательская пауза кодируется ОДНИМ символом из PUA:
# код = EXPLICIT_BASE + длительность_в_мс (0..MAX_BREAK_MS). Одно кодо-point
# переживает rutextnorm/translit (в отличие от длинных прогонов и цифр).
_EXPLICIT_BASE = 0xE100
RATE_VALUES = ("x-slow", "slow", "medium", "fast", "x-fast")
PITCH_VALUES = ("x-low", "low", "medium", "high", "x-high")
def _escape(text: str) -> str:
return (
text.replace("&", "&amp;")
.replace("<", "&lt;")
.replace(">", "&gt;")
)
def explicit_pause_char(ms: int) -> str:
ms = max(0, min(int(ms), MAX_BREAK_MS))
return chr(_EXPLICIT_BASE + ms)
def _is_sentinel(char: str) -> bool:
return _segment_to_break(char) is not None
def strip_sentinels(text: str, replacement: str = "") -> str:
"""Убирает sentinel-символы (для отображения нормализованного текста)."""
out = [replacement if _is_sentinel(ch) else ch for ch in text]
return re.sub(r" {2,}", " ", "".join(out)).strip()
def _segment_to_break(char: str) -> int | None:
"""Длительность паузы (мс) для sentinel-символа или None, если это текст."""
code = ord(char)
if char == PAUSE_DELIM:
return DEFAULT_PAUSE_MS
if _EXPLICIT_BASE <= code <= _EXPLICIT_BASE + MAX_BREAK_MS:
return code - _EXPLICIT_BASE
return None
def _sentence_content(text: str) -> str:
"""Текст предложения с заменой sentinel-пауз на <break .../>; сам sentinel
вырезается, XML-спецсимволы экранируются."""
parts: list[str] = []
buf: list[str] = []
for ch in text:
ms = _segment_to_break(ch)
if ms is None:
buf.append(ch)
continue
if buf:
parts.append(_escape("".join(buf)))
buf = []
ms = max(0, min(ms, MAX_BREAK_MS))
parts.append(f'<break time="{ms}ms"/>')
if buf:
parts.append(_escape("".join(buf)))
return "".join(parts)
def _para_block(segments) -> str:
inner = "".join(f"<s>{_sentence_content(s.text)}</s>" for s in segments)
return f"<p>{inner}</p>"
def _content_length(segments) -> int:
total = 0
for s in segments:
total += sum(1 for ch in s.text if _segment_to_break(ch) is None)
return total
def build_ssml(segments, *, rate: str | None = None, pitch: str | None = None) -> str:
"""Собирает один <speak>-документ: предложения -> <s>, группы предложений
одного абзаца (одинаковый Segment.para) -> <p>. rate/pitch (опционально)
оборачивают всё содержимое одним <prosody>."""
if not segments:
return ""
groups: list[list] = []
for seg in segments:
key = (seg.lang, getattr(seg, "para", 0))
if groups and (
(groups[-1][0].lang, getattr(groups[-1][0], "para", 0)) == key
):
groups[-1].append(seg)
else:
groups.append([seg])
body = "".join(_para_block(g) for g in groups)
attrs: list[str] = []
if rate and rate != "medium":
attrs.append(f'rate="{rate}"')
if pitch and pitch != "medium":
attrs.append(f'pitch="{pitch}"')
if attrs:
body = f"<prosody {' '.join(attrs)}>{body}</prosody>"
return f"<speak>{body}</speak>"
def build_ssml_chunks(segments, *, rate: str | None = None, pitch: str | None = None,
max_chars: int = 5000) -> list[str]:
"""Режет сегменты по границам абзацев на документы <= max_chars символов
контента (параграф = атомарный чанк; гигантский параграф режется по
предложениям). Возвращает список <speak>-документов."""
if not segments:
return []
paragraphs: list[list] = []
for seg in segments:
key = (seg.lang, getattr(seg, "para", 0))
if paragraphs and (
(paragraphs[-1][0].lang, getattr(paragraphs[-1][0], "para", 0)) == key
):
paragraphs[-1].append(seg)
else:
paragraphs.append([seg])
chunks: list[list] = []
current: list = []
current_len = 0
for para in paragraphs:
para_len = _content_length(para)
if para_len > max_chars:
if current:
chunks.append(current)
current = []
current_len = 0
piece: list = []
piece_len = 0
for seg in para:
seg_len = _content_length([seg])
if piece and piece_len + seg_len > max_chars:
chunks.append(piece)
piece = []
piece_len = 0
piece.append(seg)
piece_len += seg_len
if piece:
current = piece
current_len = piece_len
continue
if current and current_len + para_len > max_chars:
chunks.append(current)
current = []
current_len = 0
current.extend(para)
current_len += para_len
if current:
chunks.append(current)
return [build_ssml(ch, rate=rate, pitch=pitch) for ch in chunks]
+112
View File
@@ -0,0 +1,112 @@
import numpy as np
from dataclasses import dataclass, field
from silero_tts.silero_tts import SileroTTS
from app.segmentation import Segment
from app.ssml import build_ssml_chunks
SSML_MAX_CHARS = 500
@dataclass
class ModelEntry:
model_id: str
tts: SileroTTS
languages: list[str] = field(default_factory=list)
@dataclass
class SynthesisEngine:
models: list[ModelEntry] = field(default_factory=list)
sample_rate: int = 24000
use_ssml: bool = True
ssml_max_chars: int = SSML_MAX_CHARS
def load(self, models_config, sample_rate: int = 24000) -> None:
self.sample_rate = sample_rate
self.models = []
for m in models_config:
tts = SileroTTS(
model_id=m.file,
language=m.languages[0] if m.languages else "ru",
speaker="default",
sample_rate=sample_rate,
device="cpu",
num_threads=4,
)
self.models.append(ModelEntry(
model_id=m.file,
tts=tts,
languages=m.languages,
))
def find_model(self, lang: str) -> ModelEntry | None:
for m in self.models:
if lang in m.languages:
return m
return None
def get_model(self, model_id: str) -> ModelEntry | None:
for m in self.models:
if m.model_id == model_id:
return m
return None
def available_speakers(self) -> dict[str, list[str]]:
return {m.model_id: list(m.tts.tts_model.speakers) for m in self.models}
def _synthesize_text(self, model: ModelEntry, speaker: str, text: str) -> np.ndarray:
audio = model.tts.tts_model.apply_tts(
text=text,
speaker=speaker,
sample_rate=model.tts.sample_rate,
put_accent=model.tts.put_accent,
put_yo=model.tts.put_yo,
)
return (audio.numpy() * 32767).astype(np.int16)
def _synthesize_ssml(self, model: ModelEntry, speaker: str, ssml_text: str) -> np.ndarray:
audio = model.tts.tts_model.apply_tts(
ssml_text=ssml_text,
speaker=speaker,
sample_rate=model.tts.sample_rate,
put_accent=True,
put_yo=model.tts.put_yo,
)
return (audio.numpy() * 32767).astype(np.int16)
def synthesize_segments(
self, segments: list[Segment], speaker: str, lang: str,
*, rate: str | None = None, pitch: str | None = None, use_ssml: bool | None = None,
) -> bytes:
model = self.find_model(lang)
if model is None:
raise ValueError(f"no model supports language '{lang}'")
return self._synthesize_multi(segments, model, speaker, rate, pitch, use_ssml)
def synthesize_segments_model(
self, segments: list[Segment], model_id: str, speaker: str,
*, rate: str | None = None, pitch: str | None = None, use_ssml: bool | None = None,
) -> bytes:
model = self.get_model(model_id)
if model is None:
raise ValueError(f"model '{model_id}' not loaded")
return self._synthesize_multi(segments, model, speaker, rate, pitch, use_ssml)
def _synthesize_multi(
self, segments: list[Segment], model: ModelEntry, speaker: str,
rate: str | None, pitch: str | None, use_ssml: bool | None,
) -> bytes:
parts: list[np.ndarray] = []
ssml = self.use_ssml if use_ssml is None else use_ssml
if ssml:
for chunk in build_ssml_chunks(
segments, rate=rate, pitch=pitch, max_chars=self.ssml_max_chars
):
parts.append(self._synthesize_ssml(model, speaker, chunk))
else:
for seg in segments:
parts.append(self._synthesize_text(model, speaker, seg.text))
if not parts:
return b""
return np.concatenate(parts).tobytes()
+131
View File
@@ -0,0 +1,131 @@
import json
import re
from pathlib import Path
_LATIN_RUN_RE = re.compile(r"[A-Za-z]+")
# Фонетические правила en->ru. Применяются слева направо, длиннейшее
# совпадение выигрывает (триграфы раньше диграфов, диграфы раньше букв).
_RULES: dict[str, str] = {
"tion": "шн",
"sure": "шур",
"ture": "тур",
"ough": "оф",
"tch": "ч",
"sch": "ш",
"igh": "ай",
"ng": "нг",
"nk": "нк",
"qu": "кв",
"th": "т",
"sh": "ш",
"ch": "ч",
"ck": "к",
"ph": "ф",
"wh": "у",
"wr": "р",
"kn": "н",
"ee": "и",
"ea": "и",
"oo": "у",
"ou": "ау",
"ow": "ау",
"ai": "ай",
"ay": "эй",
"oi": "ой",
"oy": "ой",
"au": "о",
"aw": "о",
"ir": "ер",
"ur": "ер",
"er": "ер",
"ar": "ар",
"or": "ор",
"a": "а",
"b": "б",
"c": "к",
"d": "д",
"e": "е",
"f": "ф",
"g": "г",
"h": "х",
"i": "и",
"j": "дж",
"k": "к",
"l": "л",
"m": "м",
"n": "н",
"o": "о",
"p": "п",
"q": "к",
"r": "р",
"s": "с",
"t": "т",
"u": "у",
"v": "в",
"w": "у",
"x": "кс",
"y": "и",
"z": "з",
}
_RULE_KEYS = sorted(_RULES, key=len, reverse=True)
_dict_path = Path("translit_dict.json")
_cache: tuple[dict[str, str], dict[str, str]] | None = None
def set_dict_path(path: str | Path) -> None:
global _dict_path, _cache
_dict_path = Path(path)
_cache = None
def _load_dict() -> tuple[dict[str, str], dict[str, str]]:
global _cache
if _cache is None:
data = json.loads(_dict_path.read_text(encoding="utf-8"))
exact = {k.lower(): v for k, v in data.get("exact", {}).items()}
abbrev = {k.upper(): v for k, v in data.get("abbreviations", {}).items()}
_cache = (exact, abbrev)
return _cache
def _apply_rules(word: str) -> str:
out: list[str] = []
i = 0
n = len(word)
while i < n:
for key in _RULE_KEYS:
if word.startswith(key, i):
out.append(_RULES[key])
i += len(key)
break
else:
out.append(word[i])
i += 1
result = "".join(out)
if word.endswith("e") and len(result) > 2 and result.endswith("е"):
result = result[:-1]
return result
def _match_case(token: str, lower_out: str) -> str:
if token.isupper():
return lower_out.upper()
if token[:1].isupper():
return lower_out.capitalize()
return lower_out
def transliterate_token(token: str) -> str:
exact, abbrev = _load_dict()
if token.isupper() and token in abbrev:
return abbrev[token]
key = token.lower()
if key in exact:
return _match_case(token, exact[key])
return _match_case(token, _apply_rules(key))
def transliterate_latin(text: str) -> str:
return _LATIN_RUN_RE.sub(lambda m: transliterate_token(m.group()), text)
+223
View File
@@ -0,0 +1,223 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Silero TTS Test</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #1a1a2e; color: #e0e0e0; min-height: 100vh; padding: 2rem; }
h1 { font-size: 1.4rem; margin-bottom: 1.5rem; color: #a0c4ff; }
.container { max-width: 720px; margin: 0 auto; }
.controls { display: flex; flex-direction: column; gap: 0.8rem; margin-bottom: 1.2rem; }
textarea { width: 100%; min-height: 100px; padding: 0.7rem; border: 1px solid #333; border-radius: 6px; background: #16213e; color: #e0e0e0; font-size: 0.95rem; resize: vertical; }
textarea:focus { outline: none; border-color: #a0c4ff; }
.row { display: flex; gap: 0.8rem; align-items: center; flex-wrap: wrap; }
.row label { font-size: 0.85rem; color: #888; }
select { padding: 0.4rem 0.6rem; border: 1px solid #333; border-radius: 4px; background: #16213e; color: #e0e0e0; font-size: 0.9rem; }
button { padding: 0.5rem 1.2rem; border: none; border-radius: 4px; background: #a0c4ff; color: #1a1a2e; font-weight: 600; cursor: pointer; font-size: 0.9rem; }
button:hover { background: #7db4f0; }
button:disabled { background: #555; color: #999; cursor: not-allowed; }
.btn-download { background: #4ecca3; }
.btn-download:hover { background: #3db892; }
.result { margin-top: 1.2rem; padding: 1rem; background: #16213e; border-radius: 8px; border: 1px solid #333; display: none; }
.result.visible { display: block; }
.meta { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 0.8rem; font-size: 0.85rem; }
.meta .badge { background: #0f3460; padding: 0.2rem 0.5rem; border-radius: 3px; }
.meta .lang { background: #e94560; color: #fff; font-weight: 600; }
.normalized { font-size: 0.85rem; color: #aaa; margin-bottom: 0.8rem; word-break: break-word; }
.normalized strong { color: #ccc; }
audio { width: 100%; margin: 0.5rem 0; }
.actions { display: flex; gap: 0.6rem; margin-top: 0.5rem; }
.history { margin-top: 1.5rem; }
.history h2 { font-size: 1rem; color: #888; margin-bottom: 0.6rem; }
.history table { width: 100%; border-collapse: collapse; font-size: 0.82rem; }
.history th { text-align: left; color: #666; padding: 0.3rem 0.5rem; border-bottom: 1px solid #333; }
.history td { padding: 0.3rem 0.5rem; border-bottom: 1px solid #222; }
.history td.truncate { max-width: 250px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #555; border-top-color: #a0c4ff; border-radius: 50%; animation: spin 0.6s linear infinite; margin-right: 0.4rem; vertical-align: middle; }
@keyframes spin { to { transform: rotate(360deg); } }
.status { font-size: 0.85rem; color: #888; min-height: 1.2em; }
</style>
</head>
<body>
<div class="container">
<h1>Silero TTS Test</h1>
<div class="controls">
<textarea id="input" placeholder="Enter text..."></textarea>
<div class="row">
<label>Model</label>
<select id="model"></select>
<label>Voice</label>
<select id="voice"></select>
<label>Format</label>
<select id="format">
<option value="mp3">mp3</option>
<option value="wav">wav</option>
<option value="opus">opus</option>
<option value="aac">aac</option>
<option value="flac">flac</option>
</select>
<label>Rate</label>
<select id="rate">
<option value="">normal</option>
<option value="x-slow">x-slow</option>
<option value="slow">slow</option>
<option value="medium">medium</option>
<option value="fast">fast</option>
<option value="x-fast">x-fast</option>
</select>
<label>Pitch</label>
<select id="pitch">
<option value="">normal</option>
<option value="x-low">x-low</option>
<option value="low">low</option>
<option value="medium">medium</option>
<option value="high">high</option>
<option value="x-high">x-high</option>
</select>
<button id="generate">Generate</button>
</div>
<div class="status" id="status"></div>
</div>
<div class="result" id="result">
<div class="meta">
<span class="badge lang" id="r-lang"></span>
<span class="badge" id="r-model"></span>
<span class="badge" id="r-voice"></span>
<span class="badge" id="r-speaker"></span>
<span class="badge" id="r-segs"></span>
<span class="badge" id="r-time"></span>
</div>
<div class="normalized"><strong>Normalized:</strong> <span id="r-text"></span></div>
<audio id="r-audio" controls></audio>
<div class="actions">
<button class="btn-download" id="r-download">Download</button>
</div>
</div>
<div class="history" id="history-section" style="display:none">
<h2>History</h2>
<table>
<thead><tr><th>Time</th><th>Lang</th><th>Voice</th><th>Speaker</th><th>Text</th><th>Time</th></tr></thead>
<tbody id="history-body"></tbody>
</table>
</div>
</div>
<script>
const $ = id => document.getElementById(id);
const history = [];
async function loadModels() {
const res = await fetch('/webui/models');
const data = await res.json();
const sel = $('model');
sel.innerHTML = '';
for (const m of data.models) {
const opt = document.createElement('option');
opt.value = m; opt.textContent = m;
sel.appendChild(opt);
}
}
async function loadVoices() {
const res = await fetch('/webui/voices');
const data = await res.json();
const sel = $('voice');
sel.innerHTML = '';
for (const v of data.voices) {
const opt = document.createElement('option');
opt.value = v; opt.textContent = v;
if (v === 'default') opt.selected = true;
sel.appendChild(opt);
}
}
function setLoading(loading) {
$('generate').disabled = loading;
$('status').innerHTML = loading ? '<span class="spinner"></span>Generating...' : '';
}
function base64ToBlob(b64, format) {
const mimeMap = {mp3:'audio/mpeg', wav:'audio/wav', opus:'audio/opus', aac:'audio/aac', flac:'audio/flac', pcm:'audio/pcm'};
const binary = atob(b64);
const array = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) array[i] = binary.charCodeAt(i);
return new Blob([array], { type: mimeMap[format] || 'application/octet-stream' });
}
function addToHistory(entry) {
history.unshift(entry);
$('history-section').style.display = '';
const tbody = $('history-body');
const tr = document.createElement('tr');
tr.innerHTML = `<td>${entry.time}</td><td>${entry.lang}</td><td>${entry.voice}</td><td>${entry.speaker}</td><td class="truncate">${entry.text}</td><td>${entry.duration}</td>`;
tbody.prepend(tr);
}
async function generate() {
const text = $('input').value;
if (!text.trim()) return;
setLoading(true);
$('result').classList.remove('visible');
try {
const res = await fetch('/webui/speech', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
input: text,
model: $('model').value,
voice: $('voice').value,
response_format: $('format').value,
rate: $('rate').value || undefined,
pitch: $('pitch').value || undefined
})
});
if (!res.ok) {
const err = await res.json().catch(() => ({error:{message:'request failed'}}));
$('status').textContent = 'Error: ' + (err.error?.message || res.statusText);
return;
}
const data = await res.json();
const blob = base64ToBlob(data.audio, data.format);
const url = URL.createObjectURL(blob);
$('r-audio').src = url;
$('r-lang').textContent = data.language;
$('r-model').textContent = data.model;
$('r-voice').textContent = 'voice: ' + data.voice;
$('r-speaker').textContent = 'speaker: ' + data.speaker;
$('r-segs').textContent = 'Segs: ' + data.segments;
$('r-time').textContent = data.synthesis_time_ms + 'ms';
$('r-text').textContent = data.normalized_text;
$('r-download').onclick = () => {
const a = document.createElement('a');
a.href = url;
a.download = 'tts_' + Date.now() + '.' + data.format;
a.click();
};
$('result').classList.add('visible');
const now = new Date();
addToHistory({
time: now.toLocaleTimeString(),
lang: data.language,
voice: data.voice,
speaker: data.speaker,
text: text.length > 40 ? text.slice(0, 40) + '...' : text,
duration: data.synthesis_time_ms + 'ms'
});
$('r-audio').play().catch(() => {});
} catch (e) {
$('status').textContent = 'Error: ' + e.message;
} finally {
setLoading(false);
}
}
$('generate').addEventListener('click', generate);
$('input').addEventListener('keydown', e => {
if (e.ctrlKey && e.key === 'Enter') { e.preventDefault(); generate(); }
});
loadModels();
loadVoices();
</script>
</body>
</html>
+118
View File
@@ -0,0 +1,118 @@
import base64
import time
from pathlib import Path
from fastapi import APIRouter, Request
from fastapi.responses import FileResponse, JSONResponse
from app.audio import encode, _SUPPORTED_FORMATS
from app.config import AppConfig
from app.errors import invalid_request_error
from app.segmentation import detect_language, prepare_segments
from app.ssml import PITCH_VALUES, RATE_VALUES, strip_sentinels
from app.synthesis import SynthesisEngine
router = APIRouter()
_HTML_PATH = Path(__file__).parent / "webui.html"
_MODEL_NAME = "UniosTTS"
def _standard_voices(config: AppConfig) -> list[str]:
ids: list[str] = []
for voices_map in config.model_voices.values():
for v in voices_map:
if v not in ids:
ids.append(v)
return sorted(ids)
def _resolve_speaker(config: AppConfig, model_id: str, voice_name: str) -> str | None:
voices = config.model_voices.get(model_id, {})
return voices.get(voice_name) or voices.get("default")
@router.get("/webui")
async def serve_ui():
return FileResponse(_HTML_PATH, media_type="text/html")
@router.get("/webui/models")
async def models():
return {"models": [_MODEL_NAME]}
@router.get("/webui/voices")
async def voices(request: Request):
config: AppConfig = request.app.state.config
return {"voices": _standard_voices(config)}
@router.post("/webui/speech")
async def speech(request: Request):
body = await request.json()
text = body.get("input", "")
if not text.strip():
return invalid_request_error("input must be a non-empty string")
voice = body.get("voice", "default")
fmt = body.get("response_format", "mp3")
rate = body.get("rate") or None
pitch = body.get("pitch") or None
if fmt not in _SUPPORTED_FORMATS:
return invalid_request_error(f"Unsupported response_format: '{fmt}'")
if rate is not None and rate not in RATE_VALUES:
return invalid_request_error(
f"Unsupported rate: '{rate}' (expected one of {', '.join(RATE_VALUES)})"
)
if pitch is not None and pitch not in PITCH_VALUES:
return invalid_request_error(
f"Unsupported pitch: '{pitch}' (expected one of {', '.join(PITCH_VALUES)})"
)
config: AppConfig = request.app.state.config
engine: SynthesisEngine = request.app.state.engine
sr = config.runtime.sample_rate
ff = config.runtime.ffmpeg_path
lang = detect_language(text)
model = engine.find_model(lang)
if model is None:
return invalid_request_error(f"no model supports language '{lang}'")
speaker = _resolve_speaker(config, model.model_id, voice)
if speaker is None:
return invalid_request_error(
f"voice '{voice}' not found for model '{model.model_id}'"
)
segments = prepare_segments(text)
if not segments:
return invalid_request_error("no speakable text found after processing")
normalized_text = strip_sentinels(" ".join(seg.text for seg in segments), " · ")
t0 = time.perf_counter()
pcm = engine.synthesize_segments_model(
segments, model.model_id, speaker, rate=rate, pitch=pitch
)
audio_bytes, _ = encode(pcm, fmt, sr, ff)
elapsed_ms = int((time.perf_counter() - t0) * 1000)
return JSONResponse({
"audio": base64.b64encode(audio_bytes).decode(),
"format": fmt,
"language": lang,
"model": _MODEL_NAME,
"voice": voice,
"speaker": speaker,
"normalized_text": normalized_text,
"segments": len(segments),
"synthesis_time_ms": elapsed_ms,
})
+47
View File
@@ -0,0 +1,47 @@
{
"webui": true,
"models": [
{ "file": "v5_5_ru", "languages": ["ru"] },
{ "file": "v3_en", "languages": ["en"] }
],
"model_voices": {
"v5_5_ru": {
"default": "baya",
"alloy": "eugene",
"ash": "aidar",
"ballad": "baya",
"coral": "kseniya",
"echo": "aidar",
"fable": "baya",
"nova": "xenia",
"onyx": "eugene",
"sage": "xenia",
"shimmer": "kseniya",
"verse": "eugene",
"marin": "aidar",
"cedar": "kseniya"
},
"v3_en": {
"default": "en_0",
"alloy": "en_2",
"ash": "en_1",
"ballad": "en_3",
"coral": "en_4",
"echo": "en_8",
"fable": "en_5",
"nova": "en_6",
"onyx": "en_8",
"sage": "en_7",
"shimmer": "en_9",
"verse": "en_10",
"marin": "en_11",
"cedar": "en_12"
}
},
"runtime": {
"sample_rate": 24000,
"ffmpeg_path": "ffmpeg",
"translit_dict": "translit_dict.json",
"port": 5215
}
}
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
BIN
View File
Binary file not shown.
+24
View File
@@ -0,0 +1,24 @@
[project]
name = "silero-tts-server"
version = "0.1.0"
requires-python = ">=3.10"
dependencies = [
"fastapi>=0.110",
"uvicorn[standard]>=0.29",
"torch>=2.0",
"torchaudio>=2.0",
"rutextnorm>=1.2",
"razdel>=0.5",
"numpy>=1.24",
"imageio-ffmpeg>=0.5",
]
[project.optional-dependencies]
dev = ["pytest>=8.0", "httpx>=0.27"]
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["app", "silero_tts"]
+7
View File
@@ -0,0 +1,7 @@
@echo off
cd /d "%~dp0"
if not exist .venv (
py -3.12 -m venv .venv
.venv\Scripts\pip install -e ".[dev]"
)
.venv\Scripts\python -m app.main
+7
View File
@@ -0,0 +1,7 @@
#!/usr/bin/env bash
cd "$(dirname "$0")"
if [ ! -d .venv ]; then
python3.12 -m venv .venv
.venv/bin/pip install -e ".[dev]"
fi
exec .venv/bin/python -m app.main
View File
+31
View File
@@ -0,0 +1,31 @@
# Совместимый шим над silero_tts (для OpenCode-проекта silero_open_ai).
# Пакет silero-tts>=6.0 из pyproject.toml — приватный билд OpenCode'а, на PyPI таково нет.
# Даёт интерфейс, который ожидает app/synthesis.py: SileroTTS(...).tts_model.apply_tts(...)
# Модель грузится из models/<model_id>.pt (torch.package) — сеть не нужна.
import os
import torch
_MODELS_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "models")
class SileroTTS:
def __init__(self, model_id: str, language: str = "ru", speaker: str = "baya",
sample_rate: int = 24000, device: str = "cpu", num_threads: int = 4,
**kwargs):
self.language = language
self.speaker = speaker
self.sample_rate = int(sample_rate)
self.put_accent = kwargs.pop("put_accent", True)
self.put_yo = kwargs.pop("put_yo", True)
torch.set_num_threads(int(num_threads))
path = os.path.join(_MODELS_DIR, f"{model_id}.pt")
if not os.path.isfile(path):
raise FileNotFoundError(f"model not found: {path}")
self.tts_model = torch.package.PackageImporter(path).load_pickle("tts_models", "model")
try:
self.tts_model.to(torch.device(device))
except (AttributeError, NotImplementedError):
pass # модель без .to() — остаётся на CPU
+51
View File
@@ -0,0 +1,51 @@
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from pydantic import ValidationError
from app.speech import SpeechRequest, router
@pytest.fixture
def client():
app = FastAPI()
app.include_router(router)
return TestClient(app)
@pytest.mark.parametrize("bad", ["loud", "nope", "x-slower", "1.5"])
def test_rate_invalid_rejected_by_model(bad):
with pytest.raises(ValidationError):
SpeechRequest(voice="a", input="текст", rate=bad)
@pytest.mark.parametrize("bad", ["loud", "very-high"])
def test_pitch_invalid_rejected_by_model(bad):
with pytest.raises(ValidationError):
SpeechRequest(voice="a", input="текст", pitch=bad)
@pytest.mark.parametrize("good", ["x-slow", "slow", "medium", "fast", "x-fast"])
def test_rate_valid_accepted(good):
assert SpeechRequest(voice="a", input="текст", rate=good).rate == good
@pytest.mark.parametrize("good", ["x-low", "low", "medium", "high", "x-high"])
def test_pitch_valid_accepted(good):
assert SpeechRequest(voice="a", input="текст", pitch=good).pitch == good
def test_endpoint_returns_422_for_invalid_rate(client):
r = client.post(
"/v1/audio/speech",
json={"model": "UniosTTS", "voice": "baya", "input": "привет", "rate": "loud"},
)
assert r.status_code == 422
def test_endpoint_returns_422_for_invalid_pitch(client):
r = client.post(
"/v1/audio/speech",
json={"model": "UniosTTS", "voice": "baya", "input": "привет", "pitch": "loud"},
)
assert r.status_code == 422
+132
View File
@@ -0,0 +1,132 @@
import pytest
from app.normalize import mark_pause_delimiters, normalize_common
from app.segmentation import prepare_segments
from app.ssml import PAUSE_DELIM as P, build_ssml, strip_sentinels
DELIMS = "([{«“<)]}»>\"'`*_~"
# 5.1/5.2: разделительные символы удаляются, в паузообразующих позициях
# остаётся sentinel (НЕ запятая). Проверяем инварианты, а не литеральные запятые.
@pytest.mark.parametrize("text, expected_pauses", [
("скажи, что именно интересует (мир, техника, курсы валют), и я поищу.", 1),
('он сказал "сейчас проверю" и ушёл', 2),
('он сказал: "привет"', 0),
("мы сходим (надо)", 1),
('вот текст (важно)"', 1),
("он сказал **важно** слово", 2),
("слово *курсив* конец", 2),
("это _важно_ для дела", 2),
("список ~зачёркнут~ конец", 2),
("код `inline` конец", 2),
("тема «мир» конец", 2),
("сравнение x < 5 > 3 конец", 2),
("это {фигурные} и [квадратные] скобки", 4),
("слово 'вот это' конец", 2),
])
def test_delimiters_removed_pause_is_sentinel(text, expected_pauses):
result = mark_pause_delimiters(text)
for ch in DELIMS:
assert ch not in result, f"символ {ch!r} не удалён из {result!r}"
assert result.count(P) == expected_pauses
@pytest.mark.parametrize("text, expected", [
("мы сходим (надо)", f"мы сходим {P} надо"),
('вот текст (важно)"', f"вот текст {P} важно"),
("он сказал **важно** слово", f"он сказал {P} важно {P} слово"),
('он сказал: "привет"', "он сказал: привет"),
("погода в твоём городе?), и я поищу.", "погода в твоём городе?, и я поищу."),
])
def test_delimiter_pause_positions(text, expected):
assert mark_pause_delimiters(text) == expected
@pytest.mark.parametrize("text", [
"д'Катт и O'Neill пришли",
])
def test_apostrophes_inside_words_untouched(text):
assert mark_pause_delimiters(text) == text
@pytest.mark.parametrize("text", [
"текст (важно).",
"текст (важно), конец",
"текст [важно]!",
"(важно)",
])
def test_no_delimiter_chars_left(text):
result = mark_pause_delimiters(text)
for ch in DELIMS:
assert ch not in result
@pytest.mark.parametrize("text", [
"текст (важно).",
"текст (важно), конец",
"текст (важно)! и ещё (так) конец",
])
def test_no_dangling_artifacts(text):
# Sentinel не добавляет запятых; после удаления артефактов не остаётся.
result = mark_pause_delimiters(text)
assert ",," not in result
assert not result.lstrip().startswith(",")
assert ",." not in result
def test_line_boundaries():
text = "(в начале строки) текст\nтекст (в конце строки)\n(снова) конец"
result = mark_pause_delimiters(text)
for ch in "()":
assert ch not in result
assert P in result
clean = strip_sentinels(result)
assert "снова" in clean and "конец" in clean and "текст" in clean
def test_multiline_delimiters_processed_in_every_line():
text = "первая (строка) тут\nвторая «строка» тут"
result = mark_pause_delimiters(text)
assert "(" not in result and "«" not in result and "»" not in result
clean = strip_sentinels(result)
assert "строка" in clean and "тут" in clean
assert P in result
def test_english_text_same_rule():
segments = prepare_segments("The (parenthetical) works")
ssml = build_ssml(segments)
assert "<break" in ssml
assert "parenthetical" in ssml
assert "(" not in ssml and ")" not in ssml
def test_bug_example_pauses_before_and_after_parentheses():
text = ("скажи, что именно интересует (мир, техника, курсы валют, "
"погода в твоём городе?), и я поищу.")
segments = prepare_segments(text)
stripped = strip_sentinels(" ".join(s.text for s in segments))
assert "(" not in stripped and ")" not in stripped
assert ",," not in stripped
assert "интересует" in stripped and "город" in stripped
# Единственная паузная граница — от открывающей скобки перед «мир»;
# закрывающая скобка перед запятой границы не создаёт.
ssml = build_ssml(segments)
assert ssml.count("<break") == 1
assert P not in ssml
def test_pause_delim_becomes_break_in_ssml():
segments = prepare_segments("он сказал **важно** слово")
ssml = build_ssml(segments)
assert ssml.count("<break") == 2
assert P not in ssml
def test_underscore_not_verbalized_before_regional_normalization():
segments = prepare_segments("это _важно_ для дела")
joined = strip_sentinels(" ".join(s.text for s in segments))
assert "_" not in joined
assert "подчеркивание" not in joined.lower()
assert "подчёркивание" not in joined.lower()
+103
View File
@@ -0,0 +1,103 @@
from app.segmentation import (
detect_language,
extract_explicit_pauses,
prepare_segments,
split_paragraphs,
)
from app.ssml import DEFAULT_PAUSE_MS, explicit_pause_char
def test_detect_language_pure_ru():
assert detect_language("Привет мир") == "ru"
def test_detect_language_pure_en():
assert detect_language("Hello world") == "en"
def test_detect_language_single_cyrillic_letter():
assert detect_language("tshirt 1") == "en"
assert detect_language("tshirt 1 е") == "ru"
def test_detect_language_latin_with_digits():
assert detect_language("iPhone 15 Pro 24/7") == "en"
def test_ru_request_segments_uniform_and_latin_free():
segments = prepare_segments("Закажи на Ozon через Python-скрипт.")
assert segments
assert all(s.lang == "ru" for s in segments)
assert not any(any(ch.isascii() and ch.isalpha() for ch in s.text) for s in segments)
joined = " ".join(s.text for s in segments)
assert "Озон" in joined or "озон" in joined
assert "Пайтон" in joined or "пайтон" in joined
def test_en_request_not_transliterated():
segments = prepare_segments("Hello world, this is a test.")
assert segments
assert all(s.lang == "en" for s in segments)
assert any("Hello" in s.text for s in segments)
def test_ru_numbers_normalized():
segments = prepare_segments("Купи 5 штук в Shopify.")
joined = " ".join(s.text for s in segments)
assert "пять" in joined.lower()
# --- 5.3: абзацы и явные паузы ---
def test_split_paragraphs_on_blank_lines():
paras = split_paragraphs("Первый абзац.\n\nВторой абзац.\n \nТретий.")
assert paras == ["Первый абзац.", "Второй абзац.", "Третий."]
def test_paragraphs_get_distinct_para_index():
segments = prepare_segments("Первое предложение.\n\nВторой абзац тут.")
assert segments
paras = {s.para for s in segments}
assert len(paras) >= 2
assert min(paras) == 0
def test_sentences_within_paragraph_share_para():
segments = prepare_segments("Раз. Два. Три.")
assert len(segments) >= 2
assert all(s.para == 0 for s in segments)
def test_delimiter_removal_does_not_break_sentence_split():
# Удаление разделителей (без подмены запятыми) не ломает разбиение на предложения.
segments = prepare_segments("Он сказал (тихо) и ушёл. Потом сразу спал.")
assert len(segments) >= 2
joined = " ".join(s.text for s in segments)
for ch in "()":
assert ch not in joined
def test_explicit_pause_default_marker_to_single_char():
out = extract_explicit_pauses("стоп ||| продолжение")
assert "|||" not in out
assert explicit_pause_char(DEFAULT_PAUSE_MS) in out
def test_explicit_pause_timed_marker_encodes_ms():
out = extract_explicit_pauses("а |||1500 б")
assert "1500" not in out
assert explicit_pause_char(1500) in out
def test_explicit_pause_clamped_to_max():
out = extract_explicit_pauses("а |||999999 б")
assert "999999" not in out
assert explicit_pause_char(5000) in out
def test_explicit_pause_survives_prepare_segments():
segments = prepare_segments("первое |||2000 второе")
joined = "".join(s.text for s in segments)
assert "|||" not in joined
assert "2000" not in joined
assert explicit_pause_char(2000) in joined
+131
View File
@@ -0,0 +1,131 @@
from app.segmentation import Segment
from app.ssml import (
PAUSE_DELIM,
build_ssml,
build_ssml_chunks,
explicit_pause_char,
strip_sentinels,
)
def seg(text, lang="ru", para=0):
return Segment(text=text, lang=lang, para=para)
# --- 2.1 структура <speak>/<p>/<s> и <prosody> ---
def test_single_segment_structure():
out = build_ssml([seg("Привет мир")])
assert out == "<speak><p><s>Привет мир</s></p></speak>"
def test_empty_returns_empty():
assert build_ssml([]) == ""
def test_paragraph_grouping_nesting():
out = build_ssml([seg("а", para=0), seg("б", para=0), seg("в", para=1)])
assert out.count("<p>") == 2
assert out.count("<s>") == 3
assert "<p><s>а</s><s>б</s></p>" in out
def test_no_prosody_by_default():
out = build_ssml([seg("привет")])
assert "<prosody" not in out
def test_prosody_rate_present_once():
out = build_ssml([seg("а"), seg("б")], rate="fast")
assert out.count("<prosody") == 1
assert 'rate="fast"' in out
def test_prosody_rate_and_pitch():
out = build_ssml([seg("а")], rate="slow", pitch="high")
assert '<prosody rate="slow" pitch="high">' in out
assert out.endswith("</prosody></speak>")
def test_prosody_medium_omitted():
out = build_ssml([seg("а")], rate="medium", pitch="medium")
assert "<prosody" not in out
# --- 2.2 sentinel -> <break>, вырезание PUA ---
def test_sentinel_becomes_single_break():
out = build_ssml([seg(f"а{PAUSE_DELIM}б")])
assert out.count("<break") == 1
assert 'time="150ms"' in out
assert PAUSE_DELIM not in out
def test_explicit_pause_to_break_ms():
out = build_ssml([seg("а" + explicit_pause_char(800) + "б")])
assert 'time="800ms"' in out
assert "а<break" in out and "б" in out
def test_explicit_pause_clamped_in_builder():
out = build_ssml([seg("а" + explicit_pause_char(9999) + "б")])
assert 'time="5000ms"' in out
def test_break_not_escaped():
out = build_ssml([seg(f"а{PAUSE_DELIM}б")])
assert "&lt;break" not in out
# --- экранирование текста ---
def test_text_is_xml_escaped():
out = build_ssml([seg("a & b <c> d")])
assert "&amp;" in out and "&lt;" in out and "&gt;" in out
assert "<c>" not in out
# --- 2.4 разбиение по абзацам/лимиту ---
def test_chunking_splits_into_valid_documents():
segs = [seg("предложение номер " + str(i) + " достаточно длинное") for i in range(20)]
chunks = build_ssml_chunks(segs, max_chars=200)
assert len(chunks) >= 2
for c in chunks:
assert c.startswith("<speak>") and c.endswith("</speak>")
# ни одно предложение не потеряно и не разорвано
assert sum(c.count("<s>") for c in chunks) == len(segs)
def test_chunking_respects_max_chars():
segs = [seg("с" * 60) for _ in range(10)]
chunks = build_ssml_chunks(segs, max_chars=200)
assert len(chunks) > 1
for c in chunks:
assert c.count("<s>") >= 1
def test_paragraphs_never_split_mid_segment():
segs = [seg("маленький абзац", para=0), seg("другой абзац", para=1)]
chunks = build_ssml_chunks(segs, max_chars=5000)
assert len(chunks) == 1
assert "<p><s>маленький абзац</s></p>" in chunks[0]
assert "<p><s>другой абзац</s></p>" in chunks[0]
def test_chunking_applies_prosody_to_each_document():
segs = [seg("текст " * 30) for _ in range(10)]
chunks = build_ssml_chunks(segs, rate="fast", max_chars=200)
assert len(chunks) >= 2
for c in chunks:
assert 'rate="fast"' in c
# --- strip_sentinels (для webui-отображения) ---
def test_strip_sentinels_removes_all_and_collapses_spaces():
raw = f"он {PAUSE_DELIM} сказал {explicit_pause_char(500)} слово"
clean = strip_sentinels(raw)
assert PAUSE_DELIM not in clean
assert explicit_pause_char(500) not in clean
assert " " not in clean
+155
View File
@@ -0,0 +1,155 @@
import numpy as np
import torch
from app.segmentation import Segment
from app.synthesis import SynthesisEngine, ModelEntry
class FakeModel:
def __init__(self, name, calls):
self.name = name
self.calls = calls
self.speakers = ["spk1", "spk2"]
def apply_tts(self, *, text=None, ssml_text=None, speaker, sample_rate,
put_accent, put_yo):
self.calls.append({
"name": self.name, "speaker": speaker, "text": text,
"ssml_text": ssml_text, "put_accent": put_accent,
})
return torch.zeros(10)
class FakeTts:
def __init__(self, name, calls, put_accent=True):
self.sample_rate = 24000
self.put_accent = put_accent
self.put_yo = True
self.tts_model = FakeModel(name, calls)
def make_engine(use_ssml=True, put_accent=True):
calls: list[dict] = []
engine = SynthesisEngine(
models=[
ModelEntry(model_id="ru_model", tts=FakeTts("ru", calls, put_accent), languages=["ru"]),
ModelEntry(model_id="en_model", tts=FakeTts("en", calls, put_accent), languages=["en"]),
],
sample_rate=24000,
use_ssml=use_ssml,
)
return engine, calls
def test_single_speaker_for_all_segments():
engine, calls = make_engine(use_ssml=False)
segments = [Segment(text="Озон", lang="ru"), Segment(text="Пайтон", lang="ru")]
pcm = engine.synthesize_segments(segments, "baya", "ru")
assert pcm
assert len(calls) == 2
assert all(c["name"] == "ru" and c["speaker"] == "baya" for c in calls)
def test_en_lang_selects_en_model():
engine, calls = make_engine(use_ssml=False)
segments = [Segment(text="hello", lang="en")]
engine.synthesize_segments(segments, "en_0", "en")
assert calls[0]["name"] == "en"
assert calls[0]["text"] == "hello"
def test_legacy_path_uses_text_not_ssml():
engine, calls = make_engine(use_ssml=False)
engine.synthesize_segments([Segment(text="привет", lang="ru")], "baya", "ru")
assert calls[0]["text"] == "привет"
assert calls[0]["ssml_text"] is None
def test_no_silence_padding_between_segments():
engine, calls = make_engine(use_ssml=False)
segments = [Segment(text="а", lang="ru"), Segment(text="б", lang="ru")]
pcm = engine.synthesize_segments(segments, "baya", "ru")
expected = (np.zeros(10) * 32767).astype(np.int16)
assert len(pcm) == 2 * len(expected.tobytes())
# --- ssml_text path (task 3) ---
def test_ssml_path_passes_ssml_text():
engine, calls = make_engine()
segments = [Segment(text="привет", lang="ru"), Segment(text="мир", lang="ru")]
pcm = engine.synthesize_segments(segments, "baya", "ru")
assert pcm
assert len(calls) == 1 # один чанк на абзац
c = calls[0]
assert c["text"] is None
assert c["ssml_text"].startswith("<speak>")
assert "<s>привет</s>" in c["ssml_text"]
assert "<s>мир</s>" in c["ssml_text"]
def test_ssml_path_single_call_per_chunk():
engine, calls = make_engine()
# Два разных абзаца -> два <p> -> (в пределах лимита) один документ/один вызов.
segments = [
Segment(text="первый", lang="ru", para=0),
Segment(text="второй", lang="ru", para=1),
]
engine.synthesize_segments(segments, "baya", "ru")
assert len(calls) == 1
assert "<p>" in calls[0]["ssml_text"]
def test_ssml_prosody_rate_and_pitch_passed_to_builder():
engine, calls = make_engine()
engine.synthesize_segments(
[Segment(text="привет", lang="ru")], "baya", "ru",
rate="slow", pitch="high",
)
ssml = calls[0]["ssml_text"]
assert '<prosody' in ssml
assert 'rate="slow"' in ssml
assert 'pitch="high"' in ssml
def test_ssml_medium_prosody_omitted():
engine, calls = make_engine()
engine.synthesize_segments(
[Segment(text="привет", lang="ru")], "baya", "ru",
rate="medium", pitch="medium",
)
assert "<prosody" not in calls[0]["ssml_text"]
def test_ssml_use_ssml_false_overrides_for_call():
engine, calls = make_engine(use_ssml=True)
engine.synthesize_segments(
[Segment(text="привет", lang="ru")], "baya", "ru", use_ssml=False
)
assert calls[0]["ssml_text"] is None
assert calls[0]["text"] == "привет"
def test_put_accent_true_on_ssml_path():
# put_accent=True даже если модель по умолчанию False (ручные + работают).
engine, calls = make_engine(put_accent=False)
engine.synthesize_segments([Segment(text="привет", lang="ru")], "baya", "ru")
assert calls[0]["put_accent"] is True
def test_manual_stress_marker_reaches_model_via_ssml():
engine, calls = make_engine()
engine.synthesize_segments(
[Segment(text="втор+ой сл+ово", lang="ru")], "baya", "ru"
)
assert "втор+ой" in calls[0]["ssml_text"]
assert "сл+ово" in calls[0]["ssml_text"]
def test_manual_stress_non_vowel_passes_without_error():
engine, calls = make_engine()
pcm = engine.synthesize_segments(
[Segment(text="т+ест", lang="ru")], "baya", "ru"
)
assert pcm
assert "т+ест" in calls[0]["ssml_text"]
+37
View File
@@ -0,0 +1,37 @@
import pytest
from app.translit import transliterate_latin, transliterate_token
def test_exact_dict_word():
assert transliterate_token("Ozon") == "Озон"
assert transliterate_token("ozon") == "озон"
def test_rule_based_word():
assert transliterate_token("shipping") == "шиппинг"
def test_abbreviation_uppercase():
assert transliterate_token("IT") == "айти"
assert transliterate_token("DVD") == "ди-вэ-ди"
def test_unknown_abbreviation_falls_back_to_rules():
result = transliterate_token("KPI")
assert "айти" != result
assert result == "КПИ"
def test_mixed_tokens_preserve_non_letters():
assert transliterate_latin("Python-скрипт") == "Пайтон-скрипт"
assert transliterate_latin("4G") == "4Г"
def test_noop_without_latin():
text = "Привет, мир! Числа 123 и знак #."
assert transliterate_latin(text) == text
def test_uppercase_rule_output_keeps_case():
assert transliterate_token("NETFLIX") == "НЕТФЛИКС"
+47
View File
@@ -0,0 +1,47 @@
{
"exact": {
"ozon": "озон",
"wildberries": "вайлдберриз",
"spotify": "спотифай",
"netflix": "нетфликс",
"youtube": "ютуб",
"telegram": "телеграм",
"whatsapp": "вотсапп",
"instagram": "инстаграм",
"facebook": "фейсбук",
"google": "гугл",
"microsoft": "майкрософт",
"windows": "виндовс",
"android": "андроид",
"iphone": "айфон",
"ipad": "айпад",
"apple": "эпл",
"samsung": "самсунг",
"xiaomi": "сяоми",
"huawei": "хуавей",
"tesla": "тесла",
"uber": "убер",
"python": "пайтон",
"java": "джава",
"javascript": "джаваскрипт",
"css": "цсс",
"html": "хтмл",
"docker": "докер",
"kubernetes": "кубернетис",
"git": "гит",
"github": "гитхаб",
"steam": "стим"
},
"abbreviations": {
"IT": "айти",
"DVD": "ди-вэ-ди",
"GPS": "джи-пи-эс",
"USB": "ю-эс-би",
"PDF": "пи-ди-эф",
"VPN": "ви-пи-эн",
"API": "а-пи-ай",
"CPU": "си-пи-ю",
"SMS": "эс-эм-эс",
"TV": "ти-ви"
}
}
+28
View File
@@ -0,0 +1,28 @@
@echo off
cd /d "%~dp0"
echo [1/4] Stopping service on port 5215...
for /f "tokens=5" %%p in ('netstat -ano ^| findstr :5215 ^| findstr LISTENING') do (
taskkill /PID %%p /F >nul 2>&1
)
echo [2/4] Pulling latest changes...
git pull origin main
if errorlevel 1 (
echo ERROR: git pull failed. Resolve conflicts manually.
exit /b 1
)
echo [3/4] Updating dependencies...
if not exist .venv (
py -3.12 -m venv .venv
)
.venv\Scripts\python -m pip install --upgrade pip >nul
.venv\Scripts\pip install -e ".[dev]"
if errorlevel 1 (
echo ERROR: dependency install failed.
exit /b 1
)
echo [4/4] Starting service...
call run.bat
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -e
cd "$(dirname "$0")"
# systemd user units need XDG_RUNTIME_DIR when run over a non-login (ssh) shell.
export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
SERVICE=silero-tts.service
UNIT_FILE="$HOME/.config/systemd/user/$SERVICE"
if [ -f "$UNIT_FILE" ]; then HAS_SYSTEMD=1; else HAS_SYSTEMD=0; fi
echo "[1/4] Stopping service..."
PORT=$(python3 -c "import json;print(json.load(open('config.json'))['runtime'].get('port',5215))" 2>/dev/null || echo 5215)
if [ "$HAS_SYSTEMD" = "1" ]; then
systemctl --user stop "$SERVICE" 2>/dev/null || true
fi
fuser -k "$PORT/tcp" 2>/dev/null || true
sleep 1
echo "[2/4] Pulling latest changes..."
git pull origin main
echo "[3/4] Updating dependencies..."
if [ ! -d .venv ]; then
python3.12 -m venv .venv
fi
.venv/bin/python -m pip install --upgrade pip >/dev/null
.venv/bin/pip install -e ".[dev]"
echo "[4/4] Starting service..."
if [ "$HAS_SYSTEMD" = "1" ]; then
echo " systemd user unit '$SERVICE' found -> restarting it"
systemctl --user restart "$SERVICE"
sleep 5
systemctl --user is-active "$SERVICE" || true
else
echo " no systemd unit -> starting via run.sh (port $PORT)"
exec ./run.sh
fi