Привет, Хабр!
Я уже рассказывал в предыдущих статьях о Kalinka Player, что это такое, архитектуре и как устроен “умный поиск”. В этой статье я расскажу о том, как я получил воспроизведение стык-в-стык (gapless playback).
Статья является вольным переводом моей статьи на Английском языке
В какой-то момент Kalinka уже вполне нормально справлялась с основной работой музыкального проигрывателя: открывала поток, декодировала его, отправляла PCM в ALSA, восстанавливалась после ошибок и надёжно переходила по очереди.
Но между треками всё ещё оставалась пауза.
На обычном сборнике она почти не мешает. А некоторые альбомы с ней просто перестают звучать как задумано. У Pink Floyd в The Dark Side of the Moon композиции переходят одна в другую и образуют цельное произведение. Похожие переходы есть в The Division Bell. Официальный сайт Жана-Мишеля Жарра описывает Oxygène как одно длинное, непрерывное инструментальное путешествие. Если проигрыватель каждый раз вставляет небольшую паузу, это уже слышно не как техническая мелочь, а как ошибка в самой музыке.
Тогда я и решил, что gapless playback — не дополнительная галочка в списке функций, а вещь, которую в Kalinka стоит сделать нормально.
Под gapless здесь имеется в виду вполне конкретное и проверяемое свойство: если два файла должны идти подряд, движок не добавляет, не удаляет и не повторяет PCM-семплы на их границе. После декодирования результат должен быть таким, как будто это был один непрерывный поток.
В статье я разберу, как это устроено для треков с одинаковым PCM-форматом и почему для разных частот дискретизации или числа каналов задача меняется. Особенно если проигрыватель не хочет заранее ресемплировать всё в один общий формат.
Если забежать вперёд, результат выглядит так: Kalinka заранее готовит следующий декодер, не закрывает PCM handle и сохраняет уже заполненный буфер ALSA. На границе выходной узел проверяет формат нового потока. Если он полностью совпадает со старым, запись просто продолжается. Если нет, нужно либо преобразовать звук, либо перенастроить устройство. Kalinka выбирает перенастройку, поэтому такой переход уже не гарантированно бесшовный.
Kalinka — открытый музыкальный проигрыватель для Raspberry Pi и других небольших Linux-систем. Внутри у него собственный аудиограф на C++: libFLAC++ или minimp3 декодируют звук, ALSA выводит его на устройство. Kalinka не построена поверх MPD, поэтому очередь, буферы, переключение треков и политика выбора формата находятся внутри самого проекта.
Ключевое решение получилось довольно простым:
PCM-устройство должно жить столько же, сколько вся сессия воспроизведения, а не один трек.
Почему две отдельные сессии ALSA нельзя склеить без разрыва
Самый очевидный вариант выглядит примерно так:
1. Трек A → открыть и настроить ALSA → записать PCM → drain или stop → закрыть ┆ 2. Трек B → открыть и настроить ALSA → записать PCM → drain или stop → закрыть
Главное в этой схеме — не отдельный вызов drain(), а граница между двумя полноценными PCM-сессиями.
Для ALSA PCM handle — это поток с определённой аппаратной конфигурацией и собственным состоянием. ALSA знает, сколько фреймов поставлено в очередь и находится ли поток в состояниях running, prepared или stopped. Но она ничего не знает об альбоме и не понимает, что закончившийся файл и следующий файл должны образовать один непрерывный фрагмент.
Если на каждом EOF проигрыватель завершает весь цикл работы с ALSA, он сообщает выходному слою, что закончился не только файл, но и сам PCM-поток. snd_pcm_drain() — лишь один из шагов этого цикла: он сохраняет уже поставленный в очередь хвост, дожидается его воспроизведения и останавливает PCM. snd_pcm_drop() останавливает его раньше, но выбрасывает хвост. Закрытие handle, повторное открытие, установка параметров оборудования (hardware parameters), заполнение буфера и новый запуск добавляют задержку, однако удаление какого-либо одного вызова не возвращает непрерывность.
Принципиальная потеря происходит в тот момент, когда очередь опустошается. Первые фреймы трека B не стоят в том же работающем PCM-буфере сразу после последних фреймов трека A. Если A уже завершён как отдельный поток ALSA, B приходится запускать как новый: открыть или подготовить handle, убедиться в правильной конфигурации, записать некоторое количество данных и запустить воспроизведение явно либо через start_threshold.
При 48 кГц соседние PCM-фреймы разделяют примерно 20,8 мкс. Эта цифра хорошо показывает требуемую точность, но не является реальным бюджетом на повторный запуск. Она относится к потоку, который уже работает. В ALSA нет переносимой операции со смыслом: «запусти эту отдельно подготовленную PCM-сессию ровно через один фрейм после окончания предыдущей». mmap может уменьшить количество копирований, а низкий start_threshold — объём начальной предзагрузки, но ни то ни другое не создаёт общую временную шкалу для двух остановленных и заново запущенных потоков.
Поэтому проблему нельзя исправить подбором более хитрой последовательности вызовов ALSA. Как только приложение разделило воспроизведение на отдельную PCM-сессию для каждого трека, у ALSA уже нет ни информации, ни поставленных в очередь данных, с помощью которых она могла бы соединить эти сессии семпл в семпл. Непрерывность была потеряна уровнем выше.
В ALSA есть механизмы синхронизации нескольких PCM-потоков. snd_pcm_link() может связать переходы между их состояниями, а оборудование способно заявить поддержку синхронного запуска с точностью до семпла. Но для этого нужны несколько совместимых аппаратных потоков с общей областью синхронизации. Это не универсальный способ последовательного переключения для простого ЦАП с одним playback substream и не превращает две независимо настроенные сессии разных треков в один непрерывный поток.
Плагины ALSA или аудиосервер могут решить другой вариант задачи: постоянно держать один slave-поток в фиксированном формате и ресемплировать или микшировать входные потоки. Это позволяет сохранить слышимую непрерывность, но меняет политику вывода и уже нарушает другое мое требование - воспроизведение бит-в-бит (bit perfect).
Для прямого PCM-вывода проблему нельзя решить более быстрым перезапуском ALSA. Окончание файла не должно автоматически означать завершение выходного PCM-потока.
Один выходной поток и сменяемые декодеры
У трека и звукового устройства разное время жизни. Декодер существует несколько минут. ALSA-устройство может оставаться открытым весь вечер.
Поэтому я разделил их:

В каждый момент данные отдаёт один декодер. Следующий при этом можно заранее открыть, инициализировать и заполнить его первый буфер, пока играет текущий трек.
Так мы получаем сразу две вещи:
новый источник готов до окончания старого;
кольцевой буфер ALSA не исчезает на границе треков и даёт программе время переключиться.
Второй пункт оказался не менее важным. В обычной конфигурации Kalinka держит в ALSA примерно 160 мс звука. ЦАП продолжает воспроизводить эти данные независимо от того, какой декодер подготовит следующие фреймы. Переключение не обязано происходить в одной конкретной инструкции процессора — оно просто должно завершиться раньше, чем закончится уже записанный звук.
Почему выходной узел должен знать о смене трека
Если PCM-поток остаётся открытым, следующий шаг кажется очевидным: заранее подготовить новый декодер и после окончания текущего трека просто начать передавать его фреймы в тот же выходной буфер.
Для треков с одинаковым PCM-форматом именно так всё и работает. Но полностью скрыть смену источника от выходного узла нельзя.
Предположим, первый трек — 16 бит, стерео, 44,1 кГц, а следующий — 24 бит, стерео, 96 кГц. Если переключатель просто начнёт передавать новые байты в устройство, которое всё ещё настроено на старый формат, ALSA не догадается, что они означают что-то другое. Данные будут интерпретироваться по старым параметрам. Результат прозвучит с неправильной скоростью или неправильной разметкой семплов либо запись вообще завершится ошибкой.
ALSA-плагин hw: работает напрямую с драйвером ядра и не выполняет преобразований. У открытого PCM handle есть одна активная аппаратная конфигурация, установленная через snd_pcm_hw_params(). Чтобы её изменить, текущий поток нужно остановить или дождаться его завершения, применить новые параметры и снова подготовить устройство.
Избежать такой перенастройки можно несколькими способами, но каждый из них означает свою политику вывода.
Вариант 1. Всё приводить к одному формату
Например, держать устройство постоянно настроенным на 48 кГц, 32 бита, стерео, а треки 44,1, 88,2 или 96 кГц заранее преобразовывать.
Для слушателя переход может оставаться бесшовным: физическое устройство всё время получает один и тот же формат. Но это уже не source-native и не bit-perfect вывод — семплы были пересчитаны.
Вариант 2. Отдать преобразование ALSA, PipeWire или другому аудиосерверу
Плагин ALSA plug умеет автоматически менять число каналов, частоту и формат семплов. dmix объединяет несколько потоков в одну фиксированную конфигурацию slave-устройства. На обычном десктопе это разумное и удобное решение.
Но тогда преобразованием и микшированием управляет другой слой. Прямой тракт до устройства уже не гарантируется, а фактический формат на выходе определяется конфигурацией системы.
Вариант 3. Несколько аппаратных subdevice и аппаратный микшер
Некоторые звуковые устройства действительно предоставляют несколько аппаратных подпотоков воспроизведения (playback subdevices) и умеют смешивать их аппаратно. Теоретически один поток можно заранее настроить под следующий трек и переключить или свести их на границе.
Но это свойство конкретного оборудования, а не общий механизм ALSA. Многие простые ЦАП и платы для Raspberry Pi имеют только один пригодный для воспроизведения поток. Кроме того, два независимо запущенных аппаратных потока не обязаны совпасть по семплам в точке переключения. Для переносимого бит-в-бит (bit-perfect) решения на это рассчитывать нельзя.
Вариант 4. Прямой вывод в исходном формате
Именно этот вариант использует Kalinka. Если устройство поддерживает исходный формат, проигрыватель не приводит всё к заранее выбранной частоте. При смене формата устройство перенастраивается.
Цена решения в том, что гарантированный gapless-путь работает только между совместимыми потоками. Переход с 44,1 на 96 кГц требует перенастройки и может дать слышимую паузу.
Здесь важно не смешивать два разных требования. Gapless означает отсутствие искусственного разрыва. Bit-perfect/source-native означает отсутствие преобразования семплов. Ресемплинг может сохранить непрерывность, но жертвует вторым требованием. Прямой вывод сохраняет формат источника, но смена формата становится отдельным событием.
Поэтому выходной узел должен знать о границе треков. Только он знает, как сейчас настроено устройство, и может решить, совместим ли с этой конфигурацией следующий поток.
Согласование смены источника
AudioStreamSwitcher хранит очередь входных узлов и указатель на активный. Когда текущий источник заканчивается, а следующий уже подключён, переключатель не начинает читать его автоматически:
if (state.state == AudioGraphNodeState::FINISHED && !inputNodes.empty()) { currentInputNode = nullptr; setState(StreamState(AudioGraphNodeState::SOURCE_CHANGED)); return false; }
Он убирает завершившийся вход и выставляет состояние SOURCE_CHANGED. Пока активного источника нет, чтение ничего не возвращает:
size_t AudioStreamSwitcher::read(void *data, size_t size) { // ... if (currentInput == nullptr) { return 0; } return currentInput->read(data, size); }
Передача данных возобновляется только после вызова acceptSourceChange():
// Accept the source change. This is called when the source has changed and // the reader should accept the new source. // The data transfer stops until the source is accepted.
Остановка сделана намеренно. Она даёт выходному узлу ALSA возможность проверить формат нового потока до того, как хотя бы один его фрейм будет записан со старыми параметрами.
На первый взгляд останавливать живой аудиограф опасно. Но звуковое устройство в этот момент продолжает проигрывать данные из своего буфера. Само согласование занимает ничтожную долю доступных 100–160 мс и при нормальной работе не успевает превратиться в underrun.
Для одинакового формата почти ничего делать не нужно
Состояние смены источника обрабатывает ALSA emitter:
if (inputNodeState.state != AudioGraphNodeState::STREAMING || newStreamInfo.value().format != currentStreamAudioFormat || paused) { drainPcm(); setState(StreamState(AudioGraphNodeState::SOURCE_CHANGED)); return false; }
Если следующий источник уже находится в состоянии streaming, его PCM-формат совпадает с текущей конфигурацией устройства и воспроизведение не стоит на паузе, условие не выполняется.
Не вызывается drain. Буфер не сбрасывается. Параметры не меняются. PCM handle остаётся открытым, а emitter продолжает писать в тот же mmap-буфер. Просто следующие фреймы пришли от другого декодера.
Это и есть весь быстрый путь.
Если формат отличается, Kalinka сознательно идёт по более дорогому пути: дожидается воспроизведения оставшихся данных, применяет новые аппаратные параметры и продолжает. Бесшовность такого перехода не гарантируется. Её можно было бы получить, заранее преобразуя все треки в единый формат, но для Kalinka я выбрал другую политику.
Поэтому формулировка «gapless между любыми файлами» была бы слишком широкой. Корректное утверждение звучит так:
Если соседние треки имеют одинаковый выходной PCM-формат, Kalinka не добавляет, не удаляет и не повторяет семплы и не останавливает ALSA-поток на их границе.
Почему лучше как можно реже перенастраивать устройство
Повторная настройка — это не только лишняя задержка. Именно в этом месте чаще проявляются особенности конкретного железа и программной обвязки.
На Raspberry Pi 4 с HiFiBerry Digi2 Pro у меня была проблема со старой комбинацией ядра и прошивки: после смены формата устройство сообщало о готовности раньше, чем I2S-тракт действительно начинал стабильно выводить звук. В результате пропадало начало следующего трека. Помогла только настраиваемая задержка после применения параметров:
// Hack for HiFiBerry boards on Raspberry Pi // Sleep to make sure RPi is ready to play. // I2S sync mechanism doesn't work properly // wich results in the first ~500 ms of the track being cut off.
На современных ядрах я эту проблему заново не проверял, поэтому по умолчанию задержка равна нулю.
На ноутбуке с pipewire-alsa проявилась другая особенность: в использованной тогда версии совместимого устройства смена формата требовала полного закрытия и повторного открытия, хотя прямой ALSA-вывод обходился без этого. Для такого случая в Kalinka есть второй отключённый по умолчанию fixup.
Оба обхода некрасивые, но вывод из них полезный: смена формата затрагивает гораздо больше зависящего от системы поведения, чем обычная запись фреймов. Лучшая граница — та, на которой вообще не приходится заходить в этот код.
Здесь нужно сделать ещё одну оговорку. По умолчанию Kalinka использует ALSA-устройство default, а не обязательно прямой hw:. В пользовательской конфигурации могут присутствовать преобразования ALSA. Поэтому гарантия проекта узкая: Kalinka сама не ресемплирует звук только ради сохранения одной конфигурации устройства. Будет ли весь тракт bit-perfect, зависит от выбранного ALSA PCM и его настроек.
Две детали, без которых схема не заработает
Следующий трек нужно открыть заранее
Начинать подготовку после EOF уже поздно. Источник может находиться в сети, нужно прочитать заголовки, создать декодер и дождаться первых данных.
Kalinka начинает это за пять секунд до окончания текущего трека:
PREFETCH_TIME_MS = 5000
Новая цепочка источника и декодера создаётся и подключается к переключателю, пока старый трек ещё играет. Размер буферов ограничен, поэтому после заполнения узел-источник блокируется и не расходует память бесконечно.
К моменту перехода следующий источник обычно уже находится в состоянии streaming. Согласованию остаётся только выбрать его.
Как проверить границу без звуковой карты
Проверять только на слух недостаточно. Короткий щелчок легко пропустить, а несколько потерянных семплов могут замаскироваться самой музыкой. Мне хотелось получить ответ на более простой вопрос: совпадают ли выходные байты с эталоном?
1. Захватываем вывод ALSA
В ALSA есть плагин file. Его можно назначить выходным устройством Kalinka, а в качестве slave использовать null. Тогда каждый PCM-фрейм, который обычно ушёл бы на ЦАП, записывается в raw-файл:
pcm.kalinka_capture { type file slave.pcm "null" file "/tmp/capture.raw" format raw }
Аудиограф, декодеры, переключатель и emitter остаются настоящими. Убирается только физическая карта.
В тесте используются latency_ms = 100 и period_ms = 25. При 44,1 кГц буфер ALSA содержит 4410 фреймов, разбитых на периоды по 1102. Переключение должно успеть завершиться в том же временном окне, что и при реальном воспроизведении.
2. Создаём сигнал с известным продолжением
Исходный сигнал — синусоида 441,5 Гц, 44,1 кГц, 16 бит, стерео, −6 dBFS, длительностью три секунды. Она разрезается пополам на фрейме 66 150 и сохраняется в два FLAC-файла.
Одна синусоида, разрезанная на два файла, нужна не ради красоты теста. Второй трек должен продолжить ту же волну в той же фазе и ровно со следующего семпла. Если взять два разных тона, естественный скачок между ними может скрыть ошибку склейки.
Разрез сделан рядом с положительным пиком, а не в точке пересечения нуля. Вставленную тишину, повтор или потерю семплов там проще заметить.
3. Сравниваем захват с исходным PCM
Тест проверяет:
общее число фреймов;
побайтовое совпадение;
максимальную последовательность цифровой тишины;
самый большой скачок между соседними семплами;
отсутствие
drain()при одинаковом формате;наличие
drain()и перенастройки при переходе с 44,1 на 48 кГц.
Для одинакового формата результат совпал до семпла: 132 300 фреймов, граница на фрейме 66 150. Ничего не вставлено, не удалено и не продублировано.
На графике ниже первый файл отмечен синим, второй — оранжевым. Смена цвета остаётся единственным признаком границы:

Та же граница в Audacity:

А здесь в то же место вставились 3 мс тишины:

Короткие примеры можно сравнить на слух:
captured-joined.wav — два трека подряд;
captured-with-gap.wav — тот же сигнал с 3 мс тишины;
reference-joined.wav — исходная непрерывная синусоида.
Максимальный скачок между соседними семплами в чистом захвате равен 1031. После вставки тишины он достигает 16 384 — полной амплитуды сигнала, что соответствует хорошо заметному щелчку.
Код находится в test_gapless_playback.py. Он не требует звукового оборудования и может сохранить WAV-файлы и графики, если KALINKA_GAPLESS_ARTIFACTS указывает на каталог.
Как повторить побайтовую проверку
Результат не зависит от Python или от устройства теста. Два исходных файла можно декодировать эталонным flac, объединить их PCM и сравнить с захватом Kalinka:
$ flac --decode --force-raw-format --endian=little --sign=signed a.flac -o a.raw $ flac --decode --force-raw-format --endian=little --sign=signed b.flac -o b.raw $ cat a.raw b.raw > reference.raw $ cmp reference.raw captured-joined.raw && echo identical identical $ sha256sum reference.raw captured-joined.raw 482bd54a14ee9ce215008b4ad57e14bcdbcc36167ec2fe6aabb73a0b6828d5d0 reference.raw 482bd54a14ee9ce215008b4ad57e14bcdbcc36167ec2fe6aabb73a0b6828d5d0 captured-joined.raw
Исходные файлы, raw-захват и verify.sh опубликованы вместе с материалами теста.
То же самое можно проверить через null test в аудиоредакторе: инвертировать эталон, сложить его с захватом и посмотреть, что осталось:

В данном случае остаётся цифровая тишина.
Тест покрывает цифровой тракт от декодеров до программного устройства ALSA. Это не измерение аналогового выхода. Для физической проверки нужен S/PDIF loopback с побитовым захватом либо АЦП для аналогового тракта. Такой эксперимент я пока не проводил.
Чего движок исправить не может
Проигрыватель может не добавлять собственную паузу, но не способен убрать любой разрыв, уже записанный в файлы.
С FLAC всё удобно: это lossless-формат, поэтому декодированный PCM побитово совпадает с исходным. Два FLAC-файла, вырезанные из одного PCM-потока, можно соединить точно.
С форматами с потерями сложнее. MP3-кодер обычно добавляет encoder delay и padding. Для gapless-декодирования нужны метаданные, указывающие, сколько семплов убрать в начале и конце. minimp3 обрабатывает Xing/Info header с расширением LAME, но файлы, использующие только другие соглашения о метаданных, всё ещё могут дать небольшой разрыв.
Движок также не знает, была ли секунда тишины в конце трека задумана автором. Gapless playback означает не создавать искусственную границу, а не редактировать альбом за слушателя.
Какая модель в итоге оказалась полезной
Реализация стала гораздо понятнее, когда я перестал думать о воспроизведении как о цикле «открыть файл — проиграть — всё закрыть — повторить».
Выходной поток принадлежит всей сессии. Файлы и декодеры — временные поставщики данных. На границе треков меняется поставщик, но выход продолжает жить.
Есть одно важное условие: выходной узел должен принять новый источник. Если полный PCM-формат совпадает, правильное действие — почти ничего не делать и продолжить запись в то же устройство. Если формат изменился, приходится выбирать между преобразованием и перенастройкой. Kalinka сохраняет исходный формат и принимает, что такие переходы не гарантированно бесшовны.
В итоговой архитектуре ALSA видит один непрерывный выходной поток. Треки и декодеры за ним сменяют друг друга, а PCM-устройство остаётся открытым и продолжает получать фреймы.
