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