commit 82c306ff6933000f17174ab0e75382c7d88fff94 Author: Shaman Date: Thu Oct 1 07:38:01 2026 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..36b13f1 --- /dev/null +++ b/.gitignore @@ -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 + diff --git a/README.md b/README.md new file mode 100644 index 0000000..4b3fc4b --- /dev/null +++ b/README.md @@ -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 // предел длины одного -документа (сверх — режется по абзацам) + } +} +``` + +Модели описаны в массиве `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-билдер: /

//, 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 (/

///), разбиение по 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() → /

/// + → синтез через 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: подготовленные +сегменты собираются в ``-документы (app/ssml.py) и подаются в модель +параметром `ssml_text`. Структура: предложение → ``, абзац (пустая строка +во входе) → `

`, всё содержимое при заданном `rate`/`pitch` оборачивается в +``. + +| Метка во входе | Эффект | +|---------------------------|------------------------------------------------------------------------| +| `rate` / `pitch` (API/UI) | Темп и тон речи (``); невалидное значение → `422` | +| `|||` | Явная пауза по умолчанию (150 мс) → `` | +| `|||1200` | Явная пауза 1200 мс (обрезается до 5000 мс) → `` | +| `+` перед гласной | Ручное ударение, сохраняется до модели (рядом с автоударением) | +| Скобки/кавычки/markdown | Удаляются как символы; на их границе вставляется пауза 150 мс | + +Паузные границы кодируются внутренним sentinel-символом из PUA, который не +виден в модели и в отображаемом тексте (`normalized_text` в `/webui/speech` +показывается уже без него). Длинные тексты режутся по границам абзацев на +документы не длиннее `runtime.ssml_max_chars` (по умолчанию 500 символов — +экспериментальный предел модели). + +**Откат.** Если SSML-путь нужно полностью отключить, поставьте +`"use_ssml": false` в `config.json` — синтез вернётся на легаси-ветку `text=` +по одному сегменту за вызов (без ``/``, ручные `+` и явные +паузы в этом режиме не действуют). Поле `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** — только для некоммерческого +использования. Убедитесь, что ваше использование соответствует её условиям. diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/audio.py b/app/audio.py new file mode 100644 index 0000000..87258db --- /dev/null +++ b/app/audio.py @@ -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(" 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] diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..6203d6e --- /dev/null +++ b/app/config.py @@ -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), + ) diff --git a/app/errors.py b/app/errors.py new file mode 100644 index 0000000..6891c60 --- /dev/null +++ b/app/errors.py @@ -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, + } + }, + ) diff --git a/app/main.py b/app/main.py new file mode 100644 index 0000000..2355866 --- /dev/null +++ b/app/main.py @@ -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) diff --git a/app/normalize.py b/app/normalize.py new file mode 100644 index 0000000..8133976 --- /dev/null +++ b/app/normalize.py @@ -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") diff --git a/app/segmentation.py b/app/segmentation.py new file mode 100644 index 0000000..1186819 --- /dev/null +++ b/app/segmentation.py @@ -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 diff --git a/app/speech.py b/app/speech.py new file mode 100644 index 0000000..f952e8f --- /dev/null +++ b/app/speech.py @@ -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) diff --git a/app/ssml.py b/app/ssml.py new file mode 100644 index 0000000..a8a839e --- /dev/null +++ b/app/ssml.py @@ -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-пауз на ; сам 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'') + if buf: + parts.append(_escape("".join(buf))) + return "".join(parts) + + +def _para_block(segments) -> str: + inner = "".join(f"{_sentence_content(s.text)}" for s in segments) + return f"

{inner}

" + + +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: + """Собирает один -документ: предложения -> , группы предложений + одного абзаца (одинаковый Segment.para) ->

. rate/pitch (опционально) + оборачивают всё содержимое одним .""" + 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"{body}" + + return f"{body}" + + +def build_ssml_chunks(segments, *, rate: str | None = None, pitch: str | None = None, + max_chars: int = 5000) -> list[str]: + """Режет сегменты по границам абзацев на документы <= max_chars символов + контента (параграф = атомарный чанк; гигантский параграф режется по + предложениям). Возвращает список -документов.""" + 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] diff --git a/app/synthesis.py b/app/synthesis.py new file mode 100644 index 0000000..2aafe04 --- /dev/null +++ b/app/synthesis.py @@ -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() diff --git a/app/translit.py b/app/translit.py new file mode 100644 index 0000000..d5a5847 --- /dev/null +++ b/app/translit.py @@ -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) diff --git a/app/webui.html b/app/webui.html new file mode 100644 index 0000000..cdf4b07 --- /dev/null +++ b/app/webui.html @@ -0,0 +1,223 @@ + + + + + +Silero TTS Test + + + +

+

Silero TTS Test

+
+ +
+ + + + + + + + + + + +
+
+
+
+
+ + + + + + +
+
Normalized:
+ +
+ +
+
+ +
+ + + diff --git a/app/webui.py b/app/webui.py new file mode 100644 index 0000000..eb53617 --- /dev/null +++ b/app/webui.py @@ -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, + }) diff --git a/config.json b/config.json new file mode 100644 index 0000000..f102df5 --- /dev/null +++ b/config.json @@ -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 + } +} diff --git a/models/v3_en.pt b/models/v3_en.pt new file mode 100644 index 0000000..ba194c4 Binary files /dev/null and b/models/v3_en.pt differ diff --git a/models/v5_1_ru.pt b/models/v5_1_ru.pt new file mode 100644 index 0000000..4069ece Binary files /dev/null and b/models/v5_1_ru.pt differ diff --git a/models/v5_5_ru.pt b/models/v5_5_ru.pt new file mode 100644 index 0000000..f238567 Binary files /dev/null and b/models/v5_5_ru.pt differ diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..0fb2817 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/run.bat b/run.bat new file mode 100644 index 0000000..7256be0 --- /dev/null +++ b/run.bat @@ -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 diff --git a/run.sh b/run.sh new file mode 100644 index 0000000..205d78a --- /dev/null +++ b/run.sh @@ -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 diff --git a/silero_tts/__init__.py b/silero_tts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/silero_tts/silero_tts.py b/silero_tts/silero_tts.py new file mode 100644 index 0000000..0f176bf --- /dev/null +++ b/silero_tts/silero_tts.py @@ -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/.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 diff --git a/tests/test_api.py b/tests/test_api.py new file mode 100644 index 0000000..650ca7f --- /dev/null +++ b/tests/test_api.py @@ -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 diff --git a/tests/test_normalize.py b/tests/test_normalize.py new file mode 100644 index 0000000..d0d3706 --- /dev/null +++ b/tests/test_normalize.py @@ -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 "= 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 diff --git a/tests/test_ssml.py b/tests/test_ssml.py new file mode 100644 index 0000000..d301344 --- /dev/null +++ b/tests/test_ssml.py @@ -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 структура /

/ и --- + +def test_single_segment_structure(): + out = build_ssml([seg("Привет мир")]) + assert out == "

Привет мир

" + + +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("

") == 2 + assert out.count("") == 3 + assert "

аб

" in out + + +def test_no_prosody_by_default(): + out = build_ssml([seg("привет")]) + assert "' in out + assert out.endswith("
") + + +def test_prosody_medium_omitted(): + out = build_ssml([seg("а")], rate="medium", pitch="medium") + assert " , вырезание PUA --- + +def test_sentinel_becomes_single_break(): + out = build_ssml([seg(f"а{PAUSE_DELIM}б")]) + assert out.count(" d")]) + assert "&" in out and "<" in out and ">" in out + assert "" 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("") and c.endswith("") + # ни одно предложение не потеряно и не разорвано + assert sum(c.count("") 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("") >= 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 "

маленький абзац

" in chunks[0] + assert "

другой абзац

" 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 diff --git a/tests/test_synthesis.py b/tests/test_synthesis.py new file mode 100644 index 0000000..f4ec210 --- /dev/null +++ b/tests/test_synthesis.py @@ -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("") + assert "привет" in c["ssml_text"] + assert "мир" in c["ssml_text"] + + +def test_ssml_path_single_call_per_chunk(): + engine, calls = make_engine() + # Два разных абзаца -> два

-> (в пределах лимита) один документ/один вызов. + segments = [ + Segment(text="первый", lang="ru", para=0), + Segment(text="второй", lang="ru", para=1), + ] + engine.synthesize_segments(segments, "baya", "ru") + assert len(calls) == 1 + assert "

" 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 '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 diff --git a/update.sh b/update.sh new file mode 100644 index 0000000..7ccf06c --- /dev/null +++ b/update.sh @@ -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