init
This commit is contained in:
+176
@@ -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
|
||||||
|
|
||||||
@@ -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** — только для некоммерческого
|
||||||
|
использования. Убедитесь, что ваше использование соответствует её условиям.
|
||||||
+104
@@ -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]
|
||||||
@@ -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),
|
||||||
|
)
|
||||||
@@ -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
@@ -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)
|
||||||
@@ -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")
|
||||||
@@ -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
@@ -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
@@ -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("&", "&")
|
||||||
|
.replace("<", "<")
|
||||||
|
.replace(">", ">")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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]
|
||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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"]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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()
|
||||||
@@ -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
|
||||||
@@ -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 "<break" not in out
|
||||||
|
|
||||||
|
|
||||||
|
# --- экранирование текста ---
|
||||||
|
|
||||||
|
def test_text_is_xml_escaped():
|
||||||
|
out = build_ssml([seg("a & b <c> d")])
|
||||||
|
assert "&" in out and "<" in out and ">" 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
|
||||||
@@ -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"]
|
||||||
@@ -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") == "НЕТФЛИКС"
|
||||||
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user