Мы в 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-приложений, а для этого ему надо пройти ещё немалый путь.