Содержание
<nav class="toc-nav"> <ul> <li><a href="#что-должно-было-получиться">Что должно было получиться</a></li> <li><a href="#первая-попытка-piper-и-почему-она-провалилась">Первая попытка: Piper, и почему она провалилась</a></li> <li><a href="#silero-v5-то-что-было-нужно">Silero v5: то, что было нужно</a></li> <li><a href="#установка">Установка</a></li> <li><a href="#грабля-первая-scipy-не-объявлен-в-зависимостях">Грабля первая: scipy не объявлен в зависимостях</a></li> <li><a href="#грабля-вторая-torchhub-ломается-когда-вешаешь-на-расписание">Грабля вторая: torch.hub ломается, когда вешаешь на расписание</a></li> <li><a href="#грабля-третья-позывные-перестали-клеиться">Грабля третья: позывные перестали клеиться</a></li> <li><a href="#ssml-как-перестать-бубнить">SSML: как перестать бубнить</a></li> <li><a href="#правила-сценария-которые-выяснились-опытом">Правила сценария, которые выяснились опытом</a></li> <li><a href="#словарь-произношения-вместо-костылей">Словарь произношения вместо костылей</a></li> <li><a href="#громкость-чтобы-не-крутить-ручку-в-машине">Громкость: чтобы не крутить ручку в машине</a></li> <li><a href="#журнал-тем-чтобы-не-повторяться">Журнал тем, чтобы не повторяться</a></li> <li><a href="#расписание">Расписание</a></li> <li><a href="#сколько-это-стоило">Сколько это стоило</a></li> <li><a href="#что-бы-я-сделал-иначе">Что бы я сделал иначе</a></li> </ul> </nav>У меня есть утренний дайджест: погода, календарь, счета, состояние серверов. Полезная штука, читаю каждый день. Проблема в том, что читаю я его стоя на кухне, а потом сажусь в машину и сорок минут еду на работу, где текст бесполезен совершенно.
Так родилась мысль сделать себе радиопередачу. Не текст, а звук: три минуты, которые можно просто включить и слушать. Собственная утренняя программа на одного слушателя.
Получилось за вечер. Ниже - как, и главное, обо что я по дороге спотыкался.
Что должно было получиться
Конвейер простой:
- Собрать факты дня из того, что у меня уже и так опрашивается по расписанию - погода, календарь, мониторинг, баланс у хостера.
- Написать поверх этих фактов сценарий передачи.
- Озвучить.
- Склеить с позывными студии и прислать в мессенджер.
Ключевое решение принял сразу: текст передачи не шаблонный. Шаблон выдаёт мёртвый эфир вида «температура плюс пять, осадков нет». Поэтому сценарий каждое утро пишется заново языковой моделью поверх свежей сводки - цифры настоящие, слова живые. Можно вести рубрики.
И сразу главное требование: никакого облачного синтеза. В передаче звучит мой календарь и остатки на счетах. Отправлять это на чужой сервер ради красивого голоса я не готов, даже за копейки.
Первая попытка: Piper, и почему она провалилась
На хосте уже лежал готовый локальный синтез - sherpa-onnx с русскими голосами Piper. Бесплатно, офлайн, быстро. Казалось, половина дела сделана.
Собрал продюсера, записал пилотный выпуск на 2 минуты 39 секунд. Послушал. Звучало мертво.
Нарезал пробы всеми четырьмя русскими голосами на одном и том же тексте, чтобы сравнение было честным. Темп у них разный, разброс почти шесть секунд на полминуты:
dmitri 25.0 c
ruslan 27.7 c
denis 29.6 c
irina 30.4 c
Не помогло. Все четыре мимо. А дальше выяснилась неприятная вещь: для русского языка в Piper этих четырёх голосов и всё. Перебирать больше нечего, ассортимент исчерпан.
Значит, менять движок целиком.
Silero v5: то, что было нужно
Пошёл смотреть, что появилось нового, и нашёл свежий релиз silero-tts v5 для русского. Три вещи, которые меня в нём купили:
- 48 кГц против 22 у Piper. Вдвое выше частота дискретизации, верх голоса перестаёт быть мутным.
- Автоматическая расстановка ударений, букв ё и ударений в омографах. Вот это оказалось главным. Мёртвое звучание Piper - не вопрос качества записи. Он просто не знает, где в слове ударение, и ставит его куда попало. Каждое десятое слово произнесено неправильно, и слух всё время спотыкается.
- Поддержка SSML. Темп, высота тона, паузы прямо в тексте. То есть подачу можно режиссировать, а не просто скармливать простыню.
Модель около 140 мегабайт. На четырёх ядрах без видеокарты синтезирует порядка сотни секунд звука в секунду - трёхминутный выпуск готовится за пару секунд. Для офлайн-движка это прилично.
Установка
Ставим в отдельное окружение, чтобы не засорять системный Python:
apt install python3-venv
python3 -m venv /opt/silero-venv
/opt/silero-venv/bin/pip install --upgrade pip
А вот теперь единственный по-настоящему важный момент этого раздела:
/opt/silero-venv/bin/pip install \
--index-url https://download.pytorch.org/whl/cpu torch
/opt/silero-venv/bin/pip install silero scipy
Обратите внимание на --index-url с CPU-сборкой torch. Без него подтянется вариант с CUDA и всем выводком библиотек NVIDIA - несколько гигабайт, которые на машине без видеокарты не нужны вообще. Разница в размере установки получается кратная.
Скачивание занимает время: окружение в итоге весит около 850 мегабайт. Если кажется, что процесс завис, он скорее всего распаковывает.
Грабля первая: scipy не объявлен в зависимостях
Поставил, запускаю синтез:
ModuleNotFoundError: No module named 'scipy'
silero требует scipy, но в зависимостях его не указывает. Я выше уже дописал его в команду установки, но если ставили по документации - вот оно, ловите.
И до кучи, чтобы не пугались, на старте сыпется вот такое:
[W NNPACK.cpp:56] Could not initialize NNPACK! Reason: Unsupported hardware.
Предупреждение безвредное. NNPACK не поддерживает этот процессор, torch тихо откатывается на обычный путь вычислений. На результат не влияет, в логах можно отфильтровать.
Грабля вторая: torch.hub ломается, когда вешаешь на расписание
Эта самая коварная, потому что проявляется не сразу.
Первый запуск руками: модель качается, и в конце движок спрашивает, доверяю ли я сторонннему репозиторию. Нажимаю y, всё работает, иду дальше. Повесил на cron - и выпуск не вышел.
В логе:
The repository snakers4_silero-models does not belong to the list of trusted
repositories and as such cannot be downloaded. Do you trust this repository
and wish to add it to the trusted list of repositories (y/N)?
EOFError: EOF when reading a line
У задания по расписанию стандартный ввод закрыт. Нажать y некому, и torch.hub падает. При ручной отладке вы этого никогда не увидите - именно поэтому грабля и обидная.
Лечится явным флагом:
model, _ = torch.hub.load(repo_or_dir='snakers4/silero-models',
model='silero_tts',
language='ru', speaker='v5_ru',
trust_repo=True, verbose=False)
Важный момент: pip-пакет silero внутри дёргает тот же самый torch.hub и спотыкается точно так же. Поэтому основной путь у меня идёт через torch.hub.load с флагом, а пакет оставлен запасным вариантом.
Мораль общая и применимая далеко за пределами синтеза речи: любой конвейер, который вы собираетесь ставить на расписание, надо хоть раз прогнать без терминала. Всё, что ждёт ввода, обнаружится только там.
Грабля третья: позывные перестали клеиться
Сборка выпуска у меня устроена так: каждый смысловой блок озвучивается отдельно, между блоками вставляется короткий позывной студии, всё склеивается concat-демуксером без пересжатия.
Позывной я генерирую синтетически, тремя синусоидами мажорным арпеджио. Никаких лицензий на музыку, полторы секунды, звучит прилично:
notes = ((523.25, 0), (659.25, 140), (783.99, 280))
inputs, filters, labels = [], [], []
for i, (freq, delay) in enumerate(notes):
inputs += ['-f', 'lavfi', '-i',
f'sine=frequency={freq}:duration=1.1:sample_rate={sr}']
gain = 0.5 - i * 0.05
filters.append(f'[{i}]adelay={delay}|{delay},volume={gain:.2f}[n{i}]')
labels.append(f'[n{i}]')
chain = ';'.join(filters) + ';' + ''.join(labels) + \
f'amix=inputs={len(notes)}:normalize=0,' \
'afade=t=in:st=0:d=0.03,afade=t=out:st=0.75:d=0.65,' \
'aecho=0.7:0.85:55:0.25,alimiter=limit=0.9'
Пока движком был Piper, позывной лежал в 22050 Гц и всё совпадало. После перехода на Silero голос стал 48000, а позывной остался старым - и склейка развалилась. concat -c copy не терпит частей с разной частотой дискретизации, это принципиальное ограничение, а не баг.
Чинить ресемплингом на ходу - плохая идея, теряется смысл копирования без пересжатия. Поэтому позывной теперь генерируется под частоту движка и кешируется как jingle-<частота>.wav. Нужного файла нет - собирается на лету перед выпуском.
Мелочь, но ровно такие мелочи и съедают вечер.
SSML: как перестать бубнить
Вот это, по-моему, самое ценное из всей затеи. Silero понимает SSML, а значит подачу можно размечать по смыслу:
<speak>
<prosody rate="fast">
Обычный текст идёт быстро.
<prosody pitch="x-high">А это шутка, тоном выше.</prosody>
<prosody pitch="x-low">А это серьёзно, тоном ниже.</prosody>
И умею держать паузу <break time="900ms"/> перед главным.
</prosody>
</speak>
Перед тем как закладываться на вложенность, я её проверил - мало ли, движок возьмёт только внешний тег. Замерил одну и ту же фразу двумя способами:
вложенная разметка (rate снаружи, pitch внутри) 4.500 c
плоская разметка (теги подряд, без вложения) 4.875 c
Вложенный вариант короче, то есть внутренний блок с другим тоном сохранил заданный снаружи быстрый темп. Темп наследуется. Это сильно упрощает жизнь: обёртка нужна одна на блок, а внутри достаточно размечать только акценты.
Базовый быстрый темп я навесил автоматически в коде, чтобы не писать его в каждом сценарии. В итоге выпуск с разметкой получился 2 минуты 21 секунда против 2:33 у неразмеченной пробы - при том, что текста в нём было больше.
Правила сценария, которые выяснились опытом
Их три, и все три неочевидны, пока не услышишь.
Числа и время - только словами. «В восемь вечера», а не «20:00». «Четыре тысячи триста рублей», а не цифрами. Синтез читает числительные неровно, особенно длинные и особенно время.
Латиницу писать по-русски. Я специально сунул в пробный выпуск DNS, SSML и Postfix латиницей, чтобы услышать результат. Результат предсказуемо плохой. В сценариях теперь «ДНС» и «Постфикс».
Длинные куски текста - враг. Авторы Silero прямо предупреждают, что на длинных фрагментах просаживается и скорость, и качество, а оптимум - от пяти до двадцати секунд звука. Поэтому синтезатор режет текст по границам предложений, не превышая шестисот символов, и склеивает результат.
Словарь произношения вместо костылей
Когда я дал послушать первые выпуски, прилетели замечания: имя произносится не с тем ударением, а название хостера читается с мягким «те» вместо «тэ».
Самое очевидное решение - поправить сценарий. Самое неправильное тоже. Выпуски собираются по расписанию автоматически, и завтрашний сценарий про сегодняшнюю правку ничего знать не будет.
Поэтому сделал словарь произношения отдельным конфигом, который правится руками:
{
"Джарвис": "дж+арвис",
"Джарвиса": "дж+арвиса",
"радио": "р+адио",
"Селектел": "селект+эл",
"Селектеле": "селект+эле"
}
Ударение в Silero помечается знаком + перед ударной гласной. Звучание правится прямой заменой букв: селект+эл даёт твёрдое «т» и «э».
Подстановка применяется автоматически к любому тексту перед озвучкой. В сценариях я пишу обычные слова, без плюсов.
Два момента, которые пришлось продумать.
Замена только по целым словам. Иначе «радиостанция» превращается в «р+адиостанцию». Проверил на словах «Радиостанция» и «джарвисовский» - не портятся.
Внутрь SSML-тегов лезть нельзя. Текст режется по тегам, подмена идёт только в произносимых частях. Иначе атрибуты разметки превратятся в кашу.
И отдельная тонкость: границы слова приходится проверять вручную через lookaround, а не привычным \b. Знак + в значениях словаря для регулярного выражения не является частью слова, и стандартная граница срабатывает не там, где ожидаешь:
pattern = re.compile(
r'(?<![0-9A-Za-zА-Яа-яЁё])(' +
'|'.join(re.escape(w) for w in sorted(table, key=len, reverse=True)) +
r')(?![0-9A-Za-zА-Яа-яЁё])', re.IGNORECASE)
Морфологию словарь не знает, падежные формы приходится перечислять отдельными ключами. Для десятка имён собственных это терпимо.
Громкость: чтобы не крутить ручку в машине
Последний шаг сборки - выравнивание громкости:
ffmpeg -y -i joined.wav \
-af 'loudnorm=I=-16:TP=-1.5:LRA=11' \
-c:a libmp3lame -b:a 96k -ar 44100 episode.mp3
Это не прихоть. Блоки синтезируются по отдельности, и без выравнивания один читается громче другого. В машине это раздражает сильнее, чем любая кривая интонация.
Контроль результата:
ffmpeg -hide_banner -i episode.mp3 -af volumedetect -f null /dev/null 2>&1 \
| grep -E 'mean_volume|max_volume'
У готовых выпусков получается средняя около -16.5 dB при пике -1.7 dB. Клиппинга нет, динамика сохранена.
Заодно проверяю, не осталось ли провалов тишины от неудачной склейки:
ffmpeg -hide_banner -i episode.mp3 -af 'silencedetect=noise=-45dB:d=1.2' \
-f null /dev/null 2>&1 | grep -c silence_start
Ноль - значит нигде ничего не отвалилось.
Журнал тем, чтобы не повторяться
В передаче есть рубрика «штука дня» - одна небольшая история про то, как на самом деле устроено то, с чем я работаю руками. Почему письмо с вложением толстеет на треть. Почему реклама на телевидении казалась громче фильма. Откуда в системе доменных имён взялся TTL.
Это, собственно, и есть душа проекта. Погоду я и так знаю, а вот такую историю каждое утро - приятно.
Проблема в том, что через месяц ежедневных выпусков темы неизбежно начнут повторяться, и первым этого не заметит как раз тот, кто их пишет. Поэтому рядом лежит простой журнал в Markdown: что уже прозвучало и в какой день, плюс запас идей на будущее. Перед записью он читается, после - дописывается.
Примитивно. Работает.
Расписание
Дальше дело за автоматизацией. Задание ставится на будни, по московскому времени, за полчаса до того, как я сажусь в машину:
15 7 * * 1-5
Будни - потому что в субботу мне никуда не ехать, а передача существует ровно для дороги.
В само задание я вынес всё, что выяснил за вечер: голос, быстрый базовый темп, разметку только акцентами, правило про числа словами и латиницу по-русски, запрет на повторы в рубрике, указание дописывать неверно произносимые слова в словарь, а не чинить сценарий. Если этого не зафиксировать, выпуски медленно сползут обратно к ровному бубнению.
Отдельной строкой - что делать при сбое: если синтез или сборка упали, не молчать, а написать одной фразой, что выпуска не будет и почему. Автоматизация, которая тихо ничего не делает, хуже отсутствующей.
Сколько это стоило
Ноль рублей в месяц. Весь синтез локальный, ни одного обращения к платному API, и текст передачи с моим календарём и балансами никуда не уходит.
Из ресурсов: около 850 мегабайт на окружение с torch, 140 на модель, примерно два мегабайта на каждый выпуск. Выпуски чистятся, в папке остаются последние тридцать.
По времени - вечер, из которого примерно половина ушла на описанные выше грабли, а не на собственно задачу. Что, в общем, нормально для любого проекта, где участвует машинное обучение и ffmpeg.
Что бы я сделал иначе
Начал бы сразу с выбора движка, а не с того, что уже стояло на машине. Экономия на изучении вариантов вышла дороже: я собрал весь конвейер под 22 кГц и без ударений, потом послушал и переделывал частоты, позывные и разметку.
Мораль простая и не новая: прежде чем строить трубу, стоит проверить, что из неё в принципе может политься. Особенно если речь про синтез речи, где качество нельзя оценить по документации - только ухом.