## Немного предыстории
С этим расширением всё началось довольно просто: я хотел упростить себе озвучку книг и больших текстов внутри своего проекта, а не прыгать каждый раз между разными сервисами и программами.
План был обычный: вставил текст, выбрал движок, получил озвучку.
Но потом, как это часто бывает, всё поехало чуть дальше:
- LLM подкинула несколько идей
- кто-то попросил добавить дополнительные возможности
- а мне самому пришлось разбираться с символами, которые вообще не должны озвучиваться
В итоге из обычной функции озвучки выросло отдельное расширение для веб-панели AutoCraft.
Сразу уточню: эта статья именно про расширение Win TTS для веб-панели.
Про саму веб-панель я потом напишу отдельно, когда доведу её до состояния, которое меня устроит.
---
## Коротко про AutoCraft
Про AutoCraft я уже писал раньше, поэтому тут без длинной вводной.
Это мой проект, который я постепенно развиваю как платформу с расширениями.
Мне хотелось уйти от схемы, где ради каждой новой функции надо лезть в основной код и всё там перекраивать. Хотелось, чтобы новые возможности можно было подключать отдельно, а не устраивать каждый раз маленький ремонт вселенной.
И в какой-то момент наконец получилось что-то рабочее:
есть основа, а нужную функциональность можно добавлять через расширения.
Win TTS стал одним из таких расширений.
GitHub проекта:
<https://github.com/andreykadelite/AutoCraft-Bot>
Предыдущая статья на Хабре:
<https://habr.com/ru/articles/926112/>
---
## Что это за расширение
Win TTS это расширение именно для веб-панели AutoCraft.
У него есть фронтенд и бэкенд:
- фронтенд на JavaScript, который отвечает за интерфейс
- бэкенд на Python, который принимает параметры, режет текст, запускает синтез и собирает результат
Сейчас в расширении используются такие движки:
- Edge TTS
- gTTS
- pyttsx3
- дополнительный RHVoice, который можно установить отдельно
Если говорить честно, то лучше всего сейчас у меня работает Edge TTS.
По качеству, по скорости и по общему поведению он пока самый удачный вариант.
С gTTS тоже можно работать, но он быстрее упирается в ограничения. Поэтому для него я планирую оставить предел примерно до 10000 символов. Для остальных движков хочу оставлять большие объёмы, вплоть до 2 миллионов символов, если конкретный сценарий это нормально переваривает.
---
## Почему появился нормализатор
Нормализатор появился не ради галочки и не ради красивого чекбокса в интерфейсе.
Я начал гонять через озвучку техническую литературу, и быстро выяснилось, что синтезаторы слишком честные. Если в тексте есть мусор, они его тоже попробуют озвучить. Причём с полной самоотдачей.
В итоге можно было получить что-то вроде:
> подсистема Unix звёздочка звёздочка звёздочка
Или чтение кусков разметки, повторов символов, всяких служебных вставок, которые в тексте жить могут, а в озвучке звучат уже как цифровой полтергейст.
Чтобы не чистить всё это руками каждый раз, я сделал нормализатор текста.
Сначала он у меня работал только с Edge TTS,
но потом я переделал его так, чтобы он работал и с другими движками тоже.
---
## Зачем понадобился анализатор
Когда появился нормализатор, стало понятно, что автоматической чистки иногда недостаточно.
Бывает так, что текст можно поправить автоматически, но всё равно хочется увидеть, что именно в нём мешает: какие символы лезут чаще всего, где шум, почему один кусок звучит нормально, а другой уже нет.
Так появился анализатор текста.
Если по-простому:
- нормализатор пытается исправить текст
- анализатор помогает понять, что в тексте вообще происходит
На технических текстах это оказалось особенно полезно.
---
## Паузы между разными языками
Отдельная история, которая меня прямо раздражала, это паузы между разными языками.
Когда в одном тексте смешиваются русский и английский, на стыках часто вылезает ерунда:
- где-то пауза слишком длинная
- где-то наоборот переход слишком резкий
- где-то всё формально работает, но слушается так, будто текст сам об себя споткнулся
Поэтому я полез и во фронтенд, и в бэкенд.
Во фронтенде появились настройки второго языка и режима пауз. Вот реальный фрагмент из static/wintts.js, где в запрос собираются параметры для смешанной озвучки:
```javascript
dual_language: requestMeta.dualLanguageEnabled
? {
enabled: true,
secondary_language: requestMeta.secondaryLanguage,
secondary_voice: requestMeta.secondaryVoice,
secondary_edge_rate: requestMeta.secondaryEdgeRate,
secondary_edge_pitch: requestMeta.secondaryEdgePitch,
secondary_edge_volume: requestMeta.secondaryEdgeVolume,
pause_mode: requestMeta.dualPauseMode,
pause_ms: requestMeta.dualPauseMs,
}
: null,
```
А вот кусок фронтенда, который управляет подсказкой в интерфейсе:
```javascript
if (!dualPauseActive) {
dualPauseHint.textContent = "Нормализация пауз доступна после включения второго языка.";
} else if (dualPauseManual) {
dualPauseHint.textContent = Фиксированная пауза между сегментами: ${dualPauseSettings.pauseMs} мс.;
} else if (dualPauseSettings.mode === "off") {
dualPauseHint.textContent = "Нормализация отключена: используется исходная пауза движка.";
} else {
dualPauseHint.textContent = String(
dualPauseConfig?.auto?.hint
|| "Авто-режим уменьшает лишние паузы между фрагментами разных языков."
);
}
```
На стороне бэкенда в plugin.py у меня уже идёт нормализация режима паузы и сборка целевых значений:
```python
def concatdual_parts_with_pause(
parts: list[str],
out_path: str,
out_suffix: str,
rendered_segments: list[dict[str, Any]],
pause_mode: str,
pause_ms: int,
) -> tuple[bool, dict[str, Any]]:
normalized_mode = normalizedual_pause_mode(pause_mode)
normalized_pause_ms = normalizedual_pause_ms(pause_ms)
if normalized_mode == "off":
return False, {
"mode": normalized_mode,
"requested_ms": normalized_pause_ms,
"applied": False,
"reason": "disabled",
}
targets = builddual_pause_targets(rendered_segments, normalized_mode, normalized_pause_ms)
```
То есть логика тут уже не “ну пусть движок сам как-нибудь расставит паузы”, а отдельная обработка стыков между сегментами разных языков.
На практике это дало заметно более аккуратную озвучку смешанных текстов.
---
## Как устроена нарезка текста
Когда текст становится большим, просто целиком отправить его в синтезатор и ждать счастья уже не выйдет.
Нужна нарезка на части, иначе либо упрёшься в лимиты движка, либо поймаешь нестабильное поведение.
В plugin.py у меня для этого есть отдельная функция:
```python
def splittext_utf8(text: str, max_bytes: int) -> list[str]:
"""Режем текст на куски по лимиту UTF-8 байт."""
cleaned = (text or "").replace("\r\n", "\n").replace("\r", "\n")
cleaned = re.sub(r"[\t ]+", " ", cleaned)
cleaned = re.sub(r"\n{3,}", "\n\n", cleaned).strip()
if not cleaned:
return []
sentences = re.split(r"(?<=[\.\!\?…])\s+", cleaned)
chunks: list[str] = []
buf = ""
```
Мне тут было важно не просто резать текст абы как, а учитывать лимит именно в UTF-8 байтах, а не только длину строки. Для смешанных и больших текстов это работает заметно адекватнее.
Дальше уже каждый движок использует свою логику чанков и свои лимиты.
Например, у Edge TTS это идёт через набор ограничений _EDGE_CHUNK_LIMITS, а у gTTS через _GOOGLE_CHUNK_LIMITS.
---
## Немного про потоки
С потоками быстро стало понятно, что универсального ответа нет.
### Edge TTS
Для Edge TTS многопоточность реально помогает.
Если текст большой, разница по скорости вполне заметна.
### gTTS
С gTTS всё сложнее.
Там идея “сейчас накрутим потоков и будет быстрее” довольно быстро упирается в ограничения. Поэтому я не стал делать вид, что все движки одинаково хорошо переваривают параллельную работу.
---
## Как в расширении подключается RHVoice без перекомпиляции EXE
Отдельно мне хотелось, чтобы дополнительный движок можно было подключить без перекомпиляции EXE.
Именно для этого в расширении появился вариант с RHVoice. Он не вшивается намертво внутрь основной сборки, а ставится отдельно, во внешнее окружение.
В addon_runtime.py у меня для этого есть отдельная логика. Сначала создаётся собственное окружение для addon:
```python
def venvpaths(addon_root: Path) -> dict[str, Path]:
venv_dir = addon_root / VENV_DIR_NAME
if os.name == "nt":
venv_python = venv_dir / "Scripts" / "python.exe"
venv_pip = venv_dir / "Scripts" / "pip.exe"
site_packages = venv_dir / "Lib" / "site-packages"
```
Потом ставятся сами пакеты RHVoice:
```python
RHVOICE_PACKAGES = ("rhvoice-wrapper==0.8.0", "rhvoice-wrapper-bin==0.5.0")
code, output = runcommand(
[str(venv_python), "-m", "pip", "install", "--upgrade", *RHVOICE_PACKAGES],
install_root,
timeout_seconds,
)
```
А после этого идёт проверка, что модули действительно импортируются:
```python
code, output = runcommand(
[str(venv_python), "-c", "import rhvoice_wrapper, rhvoice_wrapper_bin; print('ok')"],
install_root,
timeout_seconds,
)
```
Дальше уже при загрузке расширение просто добавляет site-packages этого окружения в путь и поднимает класс TTS:
```python
def load_rhvoice_tts_class(state: dict[str, Any]) -> tuple[Any | None, str]:
site_packages = str(state.get("venv_site_packages") or "").strip()
site_path = Path(site_packages)
ensuresite_packages_on_path(str(site_path))
module = importlib.import_module("rhvoice_wrapper")
tts_class = getattr(module, "TTS", None)
```
То есть по факту схема такая:
1. у расширения есть основной набор движков
2. RHVoice ставится отдельно
3. он работает во внешнем окружении
4. основной EXE перекомпилировать не нужно
Вот этот вариант мне как раз нравится намного больше, чем попытка запихнуть вообще всё внутрь сборки сразу.
---
## Почему мне вообще зашла эта схема
Наверное, главный плюс всей этой истории для меня даже не в самом озвучивателе, а в том, что он встроился в общую идею проекта.
Я как раз и хотел, чтобы новые возможности добавлялись через расширения, а не через постоянное переписывание основы.
Не всё ещё доведено до финального состояния, не всё уже оформлено так, как хотелось бы, но хотя бы уже видно нормальное направление.
И это, пожалуй, для меня самое приятное во всей истории:
наконец что-то начало работать не только “здесь и сейчас”, но и в плане дальнейшего развития.
---
## Что дальше
Сейчас Win TTS для меня это не история “сделал и забыл”, а рабочая часть проекта, которую я дальше допиливаю.
Что хочу сделать дальше:
- довести до ума динамическое подключение движков
- перенести это в настройки
- продолжить развивать веб-панель
- попробовать часть сценариев под Docker
- добавлять новый функционал через расширения, а не через переписывание ядра
И да, про саму веб-панель AutoCraft я потом напишу отдельно.
Там уже тоже накопилось что показать.
---
## Итог
Изначально задача была простой:
сделать для себя более удобную озвучку книг и больших текстов.
Потом добавились идеи, просьбы, история с символами, которые не должны озвучиваться, нормализатор, анализатор, работа с паузами между разными языками, многопоточность и отдельный дополнительный движок.
В результате получилось уже не просто поле для текста и кнопка “озвучить”, а отдельное расширение для веб-панели AutoCraft, которое можно дальше спокойно развивать.
Сейчас лучше всего у меня работает Edge TTS,
но и остальные направления я тоже продолжаю дорабатывать.
Если будут баги, идеи или просто захочется написать по делу, пишите. Это реально полезно.
GitHub проекта:
<https://github.com/andreykadelite/AutoCraft-Bot>
Telegram для связи:
Всем добра.
