В своей предыдущей статье я рассказал читателям Хабра о пути, который привёл меня к разработке автоматизированного AI-радио с новостными блоками, подкастами и музыкальным контентом. Я получил много ценных отзывов — спасибо за это! Работа над AI-вещанием переросла в полноценную платформу. В этом посте я также расскажу о ряде неожиданных проблем, с которыми столкнулся при создании современной публичной стриминг-платформы — возможно, это убережёт вас от тех же ошибок.
Платформа
Поскольку я не стал ограничиваться одним эфиром и решил разделить вещание по жанрам, со временем меня перестал устраивать ручной процесс создания трансляций через базу данных и выполнение kubectl
-команд. Кроме того, я получил несколько запросов от пользователей Хабра на запуск аналогичных тематических стримов.
В итоге я пришёл к выводу, что стоит сосредоточить усилия на создании полноценной платформы для запуска таких эфиров — с возможностью вещания на любом языке. Как говорится:
Хороший программист — ленивый программист
Идея в том, что «ленивый» программист будет стараться автоматизировать всё, что можно, чтобы не делать одну и ту же работу дважды.
Самым сложным, пожалуй, был выбор названия для будущей платформы. В итоге я остановился на «Tunio» — производное от tune (настройка) и I/O (ввод/вывод).
Ошибки на старте
Список ошибок на старте, которые впоследствии мне пришлось исправлять — порой очень мучительно. Полезно знать о них заранее, прежде чем браться за подобные проекты, связанные со стримингом.
Конвертация и вещание в MP3
Изначально я настроил вещание, конвертацию и хранение в MP3. Уже при реализации ретрансляции на стриминговые платформы (YouTube, VK Live, Telegram) стало ясно, что выбранный формат требует перекодирования потока в AAC, поскольку современные платформы работают с ним и/или с контейнером M4A. Это создавало значительную нагрузку на CPU даже при одном стриме. Контейнер M4A для AAC также удобен тем, что позволяет упаковывать метаданные (включая обложки) и корректно сохраняет длительность трека — в отличие от "сырого" AAC.
Если вы не используете в Liquidsoap эффекты вроде crossfade и обработку звука, можно транслировать поток напрямую в режиме passthrough, без перекодирования — и тогда нагрузка на процессор практически отсутствует.Использование исключительно платных TTS-решений
Изначально я использовал только платные TTS-провайдеры для генерации новостей, подкастов и джинглов. Однако быстро столкнулся с тем, что баланс расходовался очень быстро, что ограничивало масштабирование и развитие проекта. После того как я обнаружил Piper TTS — self-hosted движок, который легко запускается в Docker и работает довольно шустро даже на CPU — ситуация кардинально изменилась. Возможность генерировать неограниченное количество подкастов, новостей на разных языках и объявлений дала мощный толчок развитию платформы, при этом без дополнительных расходов — что критично для проекта на ранней стадии.Заказ джинглов у профессиональных студий
Каждый раз процесс заказа джинглов превращался в длительную переписку со студией: согласование голосов, ожидание доступности дикторов (которые нередко оказывались заняты), а результат не всегда соответствовал ожиданиям. Когда я начал создавать джинглы самостоятельно, используя голоса от ElevenLabs, большинство проблем исчезло. Я получил именно тот результат, который мне был нужен — быстро, недорого и без лишней волокиты. Также у меня появилась возможность создавать джинглы сразу на нескольких языках. Примеры джинглов с голосами ElevenLabs:Думаю, вы согласитесь, что в контексте AI-радио джинглы с голосами AI звучат органичнее, чем записанные с участием настоящих дикторов.
Проверка уникальности новостей через теги
Первоначально я пытался проверять уникальность новостей по тегам, которые генерировались из текста новости. Но создание тегов — не идемпотентная операция: одна и та же новость при разных запусках давала разные теги, несмотря на попытки адаптировать промпты. Решением стали векторные эмбеддинги. Каждая новость преобразуется в вектор с помощью моделиtext-embedding-ada-002
от OpenAI, после чего сохраняется в базу. При добавлении новой новости я нахожу ближайшую по смыслу, и если score меня не устраивает, не пропускаю новость дальше:
SELECT *, embedding <-> $1 AS score FROM your_table_name WHERE created_at >= $2 ORDER BY score LIMIT 1;
Это дало надёжную проверку на дублирование и повысило качество контента.
Фронтенд на Preact
Изначально я написал фронтенд на Preact ради лёгкости и скорости. Но отсутствие серверного рендеринга (SSR) и SEO в итоге стало критичным. Пришлось мигрировать на Next.js, что дало все необходимые преимущества: SEO, SSR, удобную маршрутизацию и гибкую архитектуру.Telegram как источник новостей
Сначала я полагал, что Telegram может служить универсальным источником мировых новостей. Однако на практике он оказался ориентирован на русскоязычную аудиторию. Поэтому я добавил поддержку RSS-лент, которые, как оказалось, до сих пор активно используются на Западе. Через них я подключил новостные потоки по различным тематикам, и пропустил их через ту же цепочку обработки: фильтрация, категоризация, суммаризация, блокирование негатива, политики и рекламыОжидания от Telegram Min App
Изначально я надеялся, что Telegram mini app станет основной платформой для потребления контента. Но впоследствии стало понятно, что нужно полноценное мобильное приложение. Я реализовал Android-приложение на React Native (Expo) с использованием WebView — это позволило быстро запустить клиент с базовым функционалом.100% ручная работа
Backend, frontend, mobile app я писал самостоятельно, чтобы прокачать навыки программирования на Go, Typescript, посмотреть на современные подходы во frontend'е. Однако на практике оказалось, что значительная часть задач — рутинные. После того как я начал активно использовать промпт-программирование с использованием агентов (в частности, Sonnet), темпы разработки выросли в разы. Особенно frontend задачи, будто мне выполняет команда программистов, остается только делать код-ревью.
Ambient и переключение источников
Популярные трансляции на YouTube в стиле “Lofi hip-hop 24/7” натолкнули меня на идею интеграции ambient-звуков в эфир. Liquidsoap оказался гораздо мощнее, чем я предполагал — он позволяет легко создавать многослойный эфир и управлять слоями, например, через Telnet.
Это открывает широкие возможности: можно добавлять в эфир любые ambient-звуки — будь то локальный файл, зацикленный в проигрывании (например дождь), или внешний поток (например, трансляция переговоров авиа-диспетчеров).
Более того, оказалось совсем несложно реализовать кнопку для переключения на живое вещание — например, если есть необходимость переключить трансляцию на человеческого ведущего.
Как это работает:
Liquidsoap поднимает сервер для приёма потока через ffmpeg
и, получая аудио от вашего live-ведущего (например, из OBS), выводит его в эфир:
live_source = input.external.rawaudio(
buffer=0.1,
max=0.3,
restart=true,
restart_on_error=true,
"while true; do ffmpeg -f flv -listen 1 -i rtmp://0.0.0.0:8181/live -threads 1 -preset ultrafast -tune zerolatency -f wav -acodec pcm_s16le -ac 2 -ar 44100 - 2>/dev/null || sleep 1; done"
)
Остаётся только добавить переключатель с помощью switch
в скрипте Liquidsoap — и вы можете динамически переключаться между источниками.
Кроме того, вы можете расширить Telnet-интерфейс собственными командами: включение/отключение ambient-аудио, переход в live-режим, и любые другие функции управления эфиром.
Запуск и остановка радио потока
Изначально проект появился как способ разобраться с Kubernetes. Однако позже я понял, что выбор этой технологии оказался крайне удачным именно для такого рода задач.
После того как пользователь настраивает свой стрим и запускает его, в Kubernetes создаются два Pod'а: один с Liquidsoap, другой — с менеджером эфира, который управляет воспроизведением Liquidsoap через Telnet. Оба Pod'а обязательно должны быть размещены на одной ноде, поскольку менеджер подготавливает нужные аудиофайлы и сохраняет их на диск, откуда их затем воспроизводит Liquidsoap.
Можно было бы объединить оба контейнера в один Pod, но тогда я бы потерял возможность обновлять менеджер эфира без остановки трансляции. Liquidsoap обновляется крайне редко, а вот логика управления эфиром постоянно развивается, и не хотелось бы прерывать вещание при каждом деплое.
Менеджер эфира формирует плейлист с запасом примерно на 10 минут вперёд. Это даёт буфер времени — если что-то пойдёт не так, у меня есть 10 минут, чтобы всё исправить, прежде чем плейлист опустеет.
Остановка стрима происходит аналогично: удалением обоих Pod'ов — с Liquidsoap и менеджера.
Основная нагрузка ложится на Pod с Liquidsoap. Поскольку я использую crossfade, ambient-слои, а также обработку звука (нормализацию и эквалайзер), я не могу использовать passthrough-трансляцию в AAC. В результате, на 7-ядерной виртуальной машине с процессором Intel(R) Xeon(R) CPU E5-2650 v2 @ 2.60GHz удаётся стабильно поддерживать около 15 Liquidsoap-подов.
Менеджер эфира почти не потребляет ресурсов — особенно в те моменты, когда виртуальный ведущий не активен.
Виртуальный ведущий
Один из читателей Хабра, работающий в сфере радиовещания уже несколько десятков лет, подсказал интересную идею для реализации виртуального ведущего. Суть в том, чтобы ведущий связывал два трека: в конце одного объявлял его название, вставлял короткую связующую фразу и затем называл следующий трек.
Чтобы не перегружать систему и не сжигать все кредиты у TTS-провайдеров, я решил реализовать предгенерацию озвучек.
Вот как это устроено:
Я заранее сгенерировал аудио версии с названиями всех треков в библиотеке — на русском и английском языках.
Сформировал шаблонные вступительные фразы, например: «Это была композиция...», «Вы слушали...» и тоже их сгенерировал в аудио
Подготовил связующие фразы, такие как: «А далее в эфире...» также в аудио.
На основе этого контента и рандома реализовал сборку аудиофайлов, содержащих фразы вида:
«Это была композиция X, а далее в эфире — Y».
Далее, при подготовке трека X для плейлиста (если рандом решает что вступит ведущий):
Понижаю громкость в конце трека X
Накладываю сгенерированную голосовую вставку
Склеиваю всё с помощью
ffmpeg
Добавляю следующий трек Y сразу за X в плейлист, создавая эффект непрерывного и "живого" эфира с участием ведущего.
Таким образом, удалось добиться эффекта настоящего ведущего без дополнительных затрат на онлайн-TTS в реальном времени.
Ретрансляция
Когда у вас уже есть предварительно сконвертированные материалы, готовые для стриминга, например:
Аудио: AAC (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 122 kb/s
Видео: H.264 (High) (avc1 / 0x31637661), yuv420p (progressive)
Рестриминг выполняется с минимальным потреблением ресурсов, поскольку отсутствует необходимость перекодирования. Пример команды ffmpeg
для непрерывного рестриминга:
ffmpeg -stream_loop -1 -re -i video.mp4 -re -i https://app.tunio.ai/live/main_live \
-c:v copy -c:a copy -map 0:v:0 -map 1:a:0 -f flv rtmp://a.rtmp.youtube.com/live2/<key>
Для автоматизации этого процесса я разработал два сервиса:
Relay Controller
Relay Worker
Relay Controller получает по API payload
с параметрами источников (видео, аудио), целевым URL и ключом трансляции. Он находит живой, наименее загруженный worker
и передаёт ему команду на запуск трансляции.
Relay Worker, получив команду, проверяет наличие видео-обложки для стрима (если нет — загружает из S3), после чего запускает бесконечный процесс ffmpeg
. В случае сбоя ffmpeg
автоматически перезапускается через 10 секунд.
Кроме того, Worker при старте регистрируется у Controller’а и регулярно отправляет heartbeat с метриками: загрузкой CPU и списком активных стримов.
Если worker
падает или перезапускается, при старте он восстанавливает свои активные трансляции, запрашивая их у Controller’а. Аналогично, если упал controller
, worker
при следующем heartbeat передаёт ему текущую актуальную информацию о всех запущенных стримах, позволяя системе продолжить работу без потерь.
Пример трансляции стрима на Youtube
4K-потоки без перекодирования, которые потребляют около 1% загрузки на одно ядро процессора Intel(R) Xeon(R) CPU E5-2650 v2 @ 2.60GHz.
Что удалось вынести в платформу:
Пока удалось перенести в интерфейс лишь часть ранее реализованного функционала. На текущий момент уже доступны следующие возможности:
Создание и управление потоком
Пользователь может создать стрим, выбрать музыкальные жанры, задать параметры дополнительного контента: включение/отключение новостей, джинглов и подкастов.Джинглы
Можно создать любое количество плейлистов с джинглами, загрузить в них аудиофайлы, прикрепить плейлист к стриму и задать интервал (в количестве треков), с которым джинглы будут автоматически вставляться в эфир.Объявления
По предложению одного из читателей Хабра реализована функция озвученных объявлений. Пользователь может ввести текст, выбрать голосовую модель (TTS), прослушать результат, а затем либо сразу воспроизвести объявление в эфире, либо задать интервал для его регулярного звучания.Регулярные аудиовставки
Поддерживается загрузка пользовательских аудиофайлов с возможностью указания времени начала и окончания трансляции, а также интервала между повторами (в треках). Такие вставки могут автоматически добавляться в эфир.Рестриминг
Пользователь может ретранслировать свой поток на YouTube, VK Live или даже напрямую в группу или канал Telegram, указав соответствующий ключ трансляции.
А что дальше?
С абсолютным удивлением я обнаружил, что существует запрос на такую платформу, где пользователи могли бы создавать собственные стримы с персонализированным контентом, не нарушающим авторские права. В связи с этим я планирую реализовать возможность генерации подкастов индивидуально для каждого пользователя (в настоящее время подкасты создаются глобально — для всей платформы).
С появлением self-hosted TTS-решений эти возможности практически не ограничены. Можно генерировать подкасты по заданному промпту, используя один или несколько виртуальных ведущих. Современные модели стремительно развиваются — некоторые уже умеют воспроизводить дыхание, шумы, интонации и даже эмоции. Лично я считаю, что TTS-технологии в ближайшее время смогут занять значительную часть ниши подкастов — и делать это на весьма достойном уровне.
Кроме того, в планах — предоставить пользователям возможность самостоятельно настраивать тематику новостей, которые будут звучать в их эфирах, а также автоматически генерировать джинглы, поскольку я понимаю, что далеко не все могут создать их вручную.
Заключение
Поскольку тема оказалась довольно востребованной среди читателей, я решил продолжать делиться процессом разработки подобного сервиса, возникающими проблемами и способами их решения. Я по-прежнему открыт к вашим вопросам — после публикации первой статьи получил множество откликов и обсуждений на схожие темы. Спасибо большое всем читателям за это!