Звук. От механических колебаний до ALSA SoC Layer
Мы в SberDevices делаем устройства, на которых можно послушать музыку, посмотреть кино и ещё много всего. Как вы понимаете, без звука это всё не представляет интереса. Давайте посмотрим, что происходит со звуком в устройстве, начиная со школьной физики и заканчивая ALSA-подсистемой в Linux.
Что же такое звук, который мы слышим? Если совсем упрощать, то это колебания частиц воздуха, которые доходят до нашей барабанной перепонки. Мозг их, разумеется, потом переводит в приятную музыку или в звук проезжающего за окном мотоциклиста, но давайте пока остановимся на колебаниях.
Люди ещё в 19 веке поняли, что можно попытаться записать звуковые колебания, а потом их воспроизвести.
Для начала посмотрим, как работало одно из первых звукозаписывающих устройств.
Фонограф и его изобретатель Томас Эдисон
Источник фото
Тут всё просто. Брали какой-нибудь цилиндр, обматывали фольгой. Потом брали что-нибудь конусообразное (чтобы было погромче) с мембраной на конце. К мембране присоединена маленькая иголка. Иголку прислоняли к фольге. Потом специально обученный человек крутил цилиндр и что-нибудь говорил в резонатор. Иголка, приводимая в движение мембраной, делала в фольге углубления. Если достаточно равномерно крутить цилиндр, то получится «намотанная» на цилиндр зависимость амплитуды колебаний мембраны от времени.
Чтобы проиграть сигнал, надо было просто прокрутить цилиндр ещё раз с начала — иголка будет попадать в углубления и передавать записанные колебания в мембрану, а та — в резонатор. Вот мы и слышим запись. Можно легко найти интересные записи энтузиастов на ютубе.
Переход к электричеству
Теперь рассмотрим что-нибудь более современное, но не очень сложное. Например, катушечный микрофон. Колебания воздуха теперь изменяют положение магнита внутри катушки и благодаря электромагнитной индукции мы получаем на выходе зависимость амплитуды колебаний магнита (а значит, и мембраны) от времени. Только теперь эта зависимость выражается не углублениями на фольге, а зависимостью электрического напряжения на выходе микрофона от времени.
Чтобы можно было хранить такое представление колебаний в памяти компьютера, их надо дискретизировать. Этим занимается специальная железка — аналогово-цифровой преобразователь (АЦП). АЦП умеет много раз за одну секунду запоминать значение напряжения (с точностью до разрешения целочисленной арифметики АЦП) на входе и записывать его в память. Количество таких отсчётов за секунду называется sample rate. Типичные значения 8000 Hz — 96000 Hz.
Не будем вдаваться в подробности работы АЦП, потому что это заслуживает отдельной серии статей. Перейдём к главному — весь звук, с которым работают Linux-драйверы и всякие устройства, представляется именно в виде зависимости амплитуды от времени. Такой формат записи называется PCM (Pulse-code modulation). Для каждого кванта времени длительностью 1/sample_rate указано значение амплитуды звука. Именно из PCM состоят .wav-файлы.
Пример визуализации PCM для .wav-файла с музыкой, где по горизонтальной оси отложено время, а по вертикальной — амплитуда сигнала:
Так как на нашей плате стереовыход под динамики, надо научиться хранить в одном .wav-файле стереозвук: левый и правый канал. Тут всё просто — сэмплы будут чередоваться вот так:
Такой способ хранения данных называется interleaved. Бывают и другие способы, но сейчас их рассматривать не будем.
Теперь разберёмся, какие электрические сигналы нам нужны, чтобы можно было организовать передачу данных между устройствами. А нужно не много:
- Bit Clock (BCLK) — тактирующий сигнал (или клок), по которому аппаратура определяет, когда надо отправить следующий бит.
- Frame Clock (FCLK или его ещё называют LRCLK) — тактирующий сигнал, по которому аппаратура понимает, когда надо начать передавать другой канал.
- Data — сами данные.
Например, у нас есть файл со следующими характеристиками:
- sample width = 16 bits;
- sampling rate = 48000 Hz;
- channels = 2.
Тогда нам надо выставить следующие значения частот:
- FCLK = 48000 Hz;
- BCLK = 48000×16 * 2 Hz.
Чтобы передавать ещё больше каналов, используется протокол TDM, который отличается от I2S тем, что FCLK теперь не обязан иметь скважность 50%, и восходящий фронт лишь задаёт начало пакета сэмплов, принадлежащих разным каналам.
Общая схема
Под рукой как раз оказалась плата amlogic s400, к которой можно подключить динамик. На неё установлено ядро Linux из upstream. На этом примере и будем работать.
Наша плата состоит из SoC (amlogic A113x), к которому подключен ЦАП TAS5707PHPR. И общая схема выглядит следующим образом:
Что умеет SoC:
- SoC имеет 3 пина: BCLK, LRCLK, DATA;
- можно сконфигурировать CLK-пины через специальные регистры SoC, чтобы на них были правильные частоты;
- ещё этому SoC можно сказать: «Вот тебе адрес в памяти. Там лежат PCM-данные. Передавай эти данные бит за битом через DATA-линию». Такую область памяти будем называть hwbuf.
Чтобы воспроизвести звук, Linux-драйвер говорит SoC, какие нужно выставить частоты на линиях BCLK и LRCLK. К тому же Linux-драйвер подсказывает, где находится hwbuf. После этого ЦАП (TAS5707) получает данные по DATA-линии и преобразует их в два аналоговых электрических сигнала. Эти сигналы потом передаются по паре проводов {analog+; analog-} в два динамика.
Переходим к Linux
Мы готовы перейти к тому, как эта схема выглядит в Linux. Во-первых, для работы со звуком в Linux есть «библиотека», которая размазана между ядром и userspace. Называется она ALSA, и рассматривать мы будем именное её. Суть ALSA в том, чтобы userspace и ядро «договорились» об интерфейсе работы со звуковыми устройствами.
Пользовательская ALSA-библиотека взаимодействует с ядерной частью с помощью интерфейса ioctl. При этом используются созданные в директории /dev/snd/ устройства pcmC{x}D{y}{c, p}. Эти устройства создаёт драйвер, который должен быть написан вендором SoC. Вот, например, содержимое этой папки на amlogic s400:
# ls /dev/snd/
controlC0 pcmC0D0p pcmC0D0с pcmC0D1c pcmC0D1p pcmC0D2c
В названии pcmC{x}D{y}{c, p}:
X — номер звуковой карты (их может быть несколько);
Y — номер интерфейса на карте (например, pcmC0D0p может отвечать за воспроизведение в динамики по tdm интерфейсу, а pcmC0D1c — за запись звука с микрофонов уже по другому аппаратному интерфейсу);
p — говорит, что устройство для воспроизведения звука (playback);
c — говорит, что устройство для записи звука (capture).
В нашем случае устройство pcmC0D0p как раз соответствует playback I2S-интерфейсу. D1 — это spdif, а D2 — pdm-микрофоны, но о них мы говорить не будем.
Device tree
Описание звуковой карты начинается с device_tree [arch/arm64/boot/dts/amlogic/meson-axg-s400.dts]:
…
sound {
compatible = "amlogic,axg-sound-card";
model = "AXG-S400";
audio-aux-devs = <&tdmin_a>, <&tdmin_b>, <&tdmin_c>,
<&tdmin_lb>, <&tdmout_c>;
…
dai-link-6 {
sound-dai = <&tdmif_c>;
dai-format = "i2s";
dai-tdm-slot-tx-mask-2 = <1 1>;
dai-tdm-slot-rx-mask-1 = <1 1>;
mclk-fs = <256>;
codec-1 {
sound-dai = <&speaker_amp1>;
};
};
…
dai-link-7 {
sound-dai = <&spdifout>;
codec {
sound-dai = <&spdif_dit>;
};
};
dai-link-8 {
sound-dai = <&spdifin>;
codec {
sound-dai = <&spdif_dir>;
};
};
dai-link-9 {
sound-dai = <&pdm>;
codec {
sound-dai = <&dmics>;
};
};
};
…
&i2c1 {
speaker_amp1: audio-codec@1b {
compatible = "ti,tas5707";
reg = <0x1b>;
reset-gpios = <&gpio_ao GPIOAO_4 GPIO_ACTIVE_LOW>;
#sound-dai-cells = <0>;
…
};
};
&tdmif_c {
pinctrl-0 = <&tdmc_sclk_pins>, <&tdmc_fs_pins>,
<&tdmc_din1_pins>, <&tdmc_dout2_pins>,
<&mclk_c_pins>;
pinctrl-names = "default";
status = "okay";
};
Тут мы видим те 3 устройства, которые потом окажутся в /dev/snd: tdmif_c, spdif, pdm.
Устройство, по которому пойдёт звук, называется dai-link-6. Работать оно будет под управлением TDM-драйвера. Возникает вопрос: вроде мы говорили про то, как передавать звук по I2S, а тут, вдруг, TDM. Это легко объяснить: как я уже писал выше, I2S — это всё тот же TDM, но с чёткими требованиями по скважности LRCLK и количеству каналов — их должно быть два. TDM-драйвер потом прочитает поле dai-format = «i2s»; и поймёт, что ему надо работать именно в I2S-режиме.
Далее указано, какой ЦАП (внутри Linux они входят в понятие «кодек») установлен на плате с помощью структуры speaker_amp1. Заметим, что тут же указано, к какой I2C-линии (не путать с I2S!) подключен наш ЦАП TAS5707. Именно по этой линии будет потом производиться включение и настройка усилителя из драйвера.
Структура tdmif_c описывает, какие пины SoC будут выполнять роли I2S-интерфейса.
ALSA SoC Layer
Для SoC, внутри которых есть поддержка аудио, в Linux есть ALSA SoC layer. Он позволяет описывать кодеки (напомню, что именно так называется любой ЦАП в терминах ALSA), позволяет указывать, как эти кодеки соединены.
Кодеки в терминах Linux kernel называются DAI (Digital Audio Interface). Сам TDM/I2S интерфейс, который есть в SoC, тоже называется DAI, и работа с ним проходит схожим образом.
Драйвер описывает кодек с помощью struct snd_soc_dai. Самая интересная часть в описании кодека — операции по выставлению параметров передачи TDM. Находятся они тут: struct snd_soc_dai → struct snd_soc_dai_driver → struct snd_soc_dai_ops. Рассмотрим самые важные для понимания поля (sound/soc/soc-dai.h):
struct snd_soc_dai_ops {
/*
* DAI clocking configuration.
* Called by soc_card drivers, normally in their hw_params.
*/
int (*set_sysclk)(struct snd_soc_dai *dai,
int clk_id, unsigned int freq, int dir);
int (*set_pll)(struct snd_soc_dai *dai, int pll_id, int source,
unsigned int freq_in, unsigned int freq_out);
int (*set_clkdiv)(struct snd_soc_dai *dai, int div_id, int div);
int (*set_bclk_ratio)(struct snd_soc_dai *dai, unsigned int ratio);
...
Те самые функции, с помощью которых выставляются TDM-клоки. Эти функции обычно имплементированы вендором SoC.
...
int (*hw_params)(struct snd_pcm_substream *,
struct snd_pcm_hw_params *, struct snd_soc_dai *);
...
Самая интересная функция — hw_params ().
Она нужна для того, чтобы настроить всё оборудование SoC согласно параметрам PCM-файла, который мы пытаемся проиграть. Именно она в дальнейшем вызовет функции из группы выше, чтобы установить TDM-клоки.
...
int (*trigger)(struct snd_pcm_substream *, int,
struct snd_soc_dai *);
...
А эта функция делает самый последний шаг после настройки кодека — переводит кодек в активный режим.
ЦАП, который будет выдавать аналоговый звук на динамик, описывается ровно такой же структурой. snd_soc_dai_ops в этом случае будут настраивать ЦАП на прием данных в правильном формате. Такая настройка ЦАП как правило осуществляется через I2C-интерфейс.
Все кодеки, которые указаны в device tree в структуре,
dai-link-6 {
...
codec-1 {
sound-dai = <&speaker_amp1>;
};
};
—, а их может быть много, добавляются в один список и прикрепляются к /dev/snd/pcm* устройству. Это нужно для того, чтобы при воспроизведении звука ядро могло обойти все необходимые драйверы кодеков и настроить/включить их.
Каждый кодек должен сказать какие PCM-параметры он поддерживает. Это он делает с помощью структуры:
struct snd_soc_pcm_stream {
const char *stream_name;
u64 formats; /* SNDRV_PCM_FMTBIT_* */
unsigned int rates; /* SNDRV_PCM_RATE_* */
unsigned int rate_min; /* min rate */
unsigned int rate_max; /* max rate */
unsigned int channels_min; /* min channels */
unsigned int channels_max; /* max channels */
unsigned int sig_bits; /* number of bits of content */
};
Если какой-нибудь из кодеков в цепочке не поддерживает конкретные параметры, всё закончится ошибкой.
Соответствующую реализацию TDM-драйвера для amlogic s400 можно посмотреть в sound/soc/meson/axg-tdm-interface.c. А реализацию драйвера кодека TAS5707 — в sound/soc/codecs/tas571x.c
Пользовательская часть
Теперь посмотрим что происходит, когда пользователь хочет проиграть звук. Удобный для изучения пример реализации пользовательской ALSA — это tinyalsa. Исходный код, относящийся ко всему нижесказанному, можно посмотреть там.
В комплект входит утилита tinyplay. Чтобы проиграть звук надо запустить:
bash$ tinyplay ./music.wav -D 0 -d 0
(-D и -d параметры говорят, что звук надо проигрывать через /dev/snd/pcmC0D0p).
Что происходит?
Вот краткая блок-схема, а потом будут пояснения:
- [userspace] Парсим .wav header, чтобы узнать PCM-параметры (sample rate, bit width, channels) воспроизводимого файла. Складываем все параметры в struct snd_pcm_hw_params.
- [userspace] Открываем устройство /dev/snd/pcmC0D0p.
- [userspace] Обращаемся к ядру с помощью ioctl (…, SNDRV_PCM_IOCTL_HW_PARAMS, …), чтобы узнать поддерживаются такие PCM-параметры или нет.
- [kernel] Проверяем PCM-параметры, которые пытается использовать пользователь. Тут есть два типа проверок:
- на общую корректность и согласованность параметров;
- поддерживает ли каждый задействованный кодек такие параметры.
- настраиваем под них все кодеки, которые прикреплены к /dev/snd/pcmC0D0p интерфейсу (но пока не включаем), возвращаем успех.
- [userspace] выделяем временный буфер, куда будем класть PCM-данные.
- [userspace] отдаем заполненный временный буфер ядру с помощью ioctl (…, SNDRV_PCM_IOCTL_WRITEI_FRAMES, …). Буква I в конце слова WRITEI указывает, что PCM-данные хранятся в interleaved-формате.
- [kernelspace] включаем кодеки, которые прикреплены к /dev/snd/pcmC0D0p интерфейсу, если они еще не включены.
- [kernelspace] копируем пользовательский буфер buf в hwbuf (см. пункт «Общая схема») с помощью copy_from_user ().
- [userspace] goto 6.
Реализацию ядерной части ioctl можно посмотреть, поискав по слову SNDRV_PCM_IOCTL_*
Заключение
Теперь у нас есть представление о том, куда попадает звук в Linux ядре. В следующих статьях будет разбор того, как звук проигрывается из Android-приложений, а для этого ему надо пройти ещё немалый путь.