Сгенерировать нули.
В этом месяце (точнее в прошлом) я не прошёл бы тест на продуктивность (недавно узнал про такие: productivity test): 5 тасок и не все в завершённом состоянии.
С одной из них провозился 1.5 недели. Хотя сложного, на первый, взгляд ничего и нет.
А суть её заключается вот в чём: генерировать нули в звуковом устройстве, то есть - тишину.
Поехали!
Точнее генерировать ничего и не нужно: буфера в ALSA кольцевые, нужно его заполнить нулями один раз и просто двигать указатель по нему.
Двигать указатель.
Казалось бы, а тут-то какие сложности?
В предыдущей заметке я конспективно указал основные термины и понятия связанные с PCM. Нам нужен период, его размер и sample rate, он же - частота дискретизации.
Берём таймер. Пусть будет
High Resolution Timer. Когда срабатывает ALSA колбек struct snd_pcm_ops::prepare
у нас уже есть все нужные параметры для стрима, типа количества каналов, битности семплов, частоты. Есть и информация о размере выделенного кольцевого буфера: substream->runtime->period_size
. Только нужно быть внимательным, смотрим на тип: snd_pcm_uframes_t
и понимаем, что это размер периода не в герцах, не в единицах времени, а во фреймах.
Так вот, в этом колбеке, мы настраиваем таймер, что бы он тикал один раз в Period Size. Максимально точно в целых числах получается вот так:
period = runtime->period_size; // во фреймах, тут как договоритесь в hw_params, может быть нифига не кратен rate
rate = runtime->rate; // в HZ, например 48000, это частата **фреймов**
sec = period / rate; // вычленим секунды, в более-менее low-latency здесь всегда будет 0
// ну и посчитаем максимально точно ту часть, что в наносекундах
period %= rate;
nsecs = div_u64((u64)period * 1000000000UL + rate - 1, rate);
// чисто хелпер, по сути sec * 10^9 + nsecs
period_time = ktime_set(sec, nsecs);
После чего, в обработчике struct snd_pcm_ops::trigger
, по START активируем таймер и он будет тикать раз в period_time
наносекунд. А дальше, в обработчике прерывания таймера, мы вызываем специально предназначенную для этого функцию:
void snd_pcm_period_elapsed(struct snd_pcm_substream *substream)
Как сказано в документации, вольный перевод:
обновляет статус PCM для следующего периода
Вызывается из обработчика прерывания, когда источник семплов запроцессил (сиречь - положил в память) period_size фреймов. Заобновится указатель на данные, пробудятся те, кто данные ждёт и т.п.
Если вдруг между вызовами случилось несколько period_size - нужно сделать один вызов.
По сути, эта функция, вызывает другой наш колбек: struct snd_pcm_ops::pointer
, который и говорит текущую позицию в кольцевом буфере.
То есть, на основании показаний таймера мы можем сказать виртуальную позицию в буфере. И тут никаких проблем: нам нужно спросить сколько таймер накрутил времени относительно какой-то точки отсчёта (логично сохранить некий аналог now()
при старте таймера). И пересчитать эту дельту на основании rate
и buffer_size
в позицию. В коде это выглядит так:
now = hrtimer_cb_get_time(timer); // текущее время таймера, обновляется пока он работает
delta = ktime_us_delta(now, base_time); // дельта между текущим временем и базовым
delta = div_u64(delta * runtime->rate + 999999, 1000000); // время множим на частоту, получаем дельту во фреймах
// относительно старта таймера
// применяем трюки для минимизации ошибки округления
// делим на 10^6, потому как выше дельту в мкс (us) получили
div_u64_rem(delta, runtime->buffer_size, &pos); // Ну и берём остаток от деления на размер буфера, получаем искомую
// позцию
Действительно, текста много, а сложностей - ноль. Оно работает, данные шлются. Вот только процесс в пользовательском пространстве жрёт 100% CPU. А при работе с реальным железом - нет.
Начинаем разбираться.
Для начала открываем для себя (ещё кто-то будет спрашивать, зачем я ВСЁ ядро в IDE гружу? :), что struct snd_pcm_ops::pointer
вызывается далеко не из одного места:
Красными кружками обведены наиболее активные потребители, выявленные при помощи логов.
__snd_pcm_lib_xfer()
используется для любого трансфера, осуществляющегося через read()
/write()
на устройстве. И частота его следований, в целом, соответствовало таковому для железа. А вот snd_pcm_delay()
, который является обработчиком для IOCTL: SNDRV_IOCTL_DELAY
зовётся очень активно. При этом, было замечено, что при работе с реальным железом, задержка или 0, что означает - данных ещё нет, или 8 и больше, что означает - приложению есть что прочитать. А вот у меня с тишиной (помимо 0) и 1, и 2, и, редко, больше.
А всё почему? А потому, что между вызовами таймер тикает и время движется. И в любой момент времени (ну практически) выходит так, что данные как бы есть и софт старательно их вычитывает.
Вылечилось, как обычно, при помощи sleep()
… Шутка. Почти.
Для железного модуля позиция высчитывалась на основании переданных байт в регистре счётчика. Согласно документации, данные эти обновляются каждый period_size
, но на самом деле - каждые 8 фреймов. Точнее так: каждые 32 байта: модуль, скорее всего, завязан именно на байты, просто в моём режиме: PCM_S16LE/Stereo получается 8 фреймов.
Выходит, что у железа есть задержка в 8 фреймов. На частоте 48000Hz это примерно 166мкс. И этого хватает, что бы не вводить процессор в busy loop.
В общем, я добавил такую “виртуальную” задержку и в программный генератор: считается позиция, если она сдвинулась на 8 или более фреймов, то сохраняем и возвращаем новое значение. В противном случае возвращаем старое значение.
Вот теперь - работает!
Для чего это нужно или чуть больше конкретики
Есть IP Core от Xilinx: Audio PCM Formatter. Он нужен для того, что бы переложить данные из других PL (FPGA) модулей, подключенных в цепочку по AXI шине в память, к которой можно достучаться из CPU в прочитать устройством. Это режим Capture. Есть и обратный режим: переложить данные из памяти и передать по AXI шине.
Как и всё на шине этот модуль тактируется, когда данные есть. Если источником данных является ADC, то данные есть всегда, пока работает ADC, а он работает, обычно, всегда когда устройство открыто и в работе. ADC без разницы что оцифровывать: реальный звук или наводки на проводах.
Другое дело, когда источником сигнала является SDI или HDMI вход. У этих чудиков может быть и физически кабель воткнут, а аудио не быть. Или просто кабель не воткнут. С одной стороны это удобно: семплы не идут, значит сигнала нет. Пробуем потом (ага, тут или поллинг или платформоспецифичные проверки со стороны user-space).
Но теряется унификация: для аналогового аудио у нас данные всегда идут: или реальные или условные нули (наводки).
В общем возникла идея: а что если в драйвере PCM Formatter’а детектировать отсутствие данных и переключаться на программный генератор тишины. И переключаться обратно, когда данные появились. Если это сделать быстрее, чем period size, то мало кто и заметит. Точнее так, мы не озаботились о плавном переходе между позицией указателя между генератором и железом, поэтому в момент переключения могут быть некие переходные процессы, но, в целом, всё работает нормально.
Поэтому 1.5 недели включали в изучение вопроса - как это вообще скрестить между собой.