Мы в SberDevices делаем устройства, на которых можно послушать музыку, посмотреть кино и ещё много всего. Как вы понимаете, без звука это всё не представляет интереса. Давайте посмотрим, что происходит со звуком в устройстве, начиная со школьной физики и заканчивая ALSA-подсистемой в Linux.

Что же такое звук, который мы слышим? Если совсем упрощать, то это колебания частиц воздуха, которые доходят до нашей барабанной перепонки. Мозг их, разумеется, потом переводит в приятную музыку или в звук проезжающего за окном мотоциклиста, но давайте пока остановимся на колебаниях.

Люди ещё в 19 веке поняли, что можно попытаться записать звуковые колебания, а потом их воспроизвести.

Для начала посмотрим, как работало одно из первых звукозаписывающих устройств.


Фонограф и его изобретатель Томас Эдисон
Источник фото

Тут всё просто. Брали какой-нибудь цилиндр, обматывали фольгой. Потом брали что-нибудь конусообразное (чтобы было погромче) с мембраной на конце. К мембране присоединена маленькая иголка. Иголку прислоняли к фольге. Потом специально обученный человек крутил цилиндр и что-нибудь говорил в резонатор. Иголка, приводимая в движение мембраной, делала в фольге углубления. Если достаточно равномерно крутить цилиндр, то получится «намотанная» на цилиндр зависимость амплитуды колебаний мембраны от времени.



Чтобы проиграть сигнал, надо было просто прокрутить цилиндр ещё раз с начала — иголка будет попадать в углубления и передавать записанные колебания в мембрану, а та — в резонатор. Вот мы и слышим запись. Можно легко найти интересные записи энтузиастов на ютубе.

Переход к электричеству


Теперь рассмотрим что-нибудь более современное, но не очень сложное. Например, катушечный микрофон. Колебания воздуха теперь изменяют положение магнита внутри катушки и благодаря электромагнитной индукции мы получаем на выходе зависимость амплитуды колебаний магнита (а значит, и мембраны) от времени. Только теперь эта зависимость выражается не углублениями на фольге, а зависимостью электрического напряжения на выходе микрофона от времени.



Чтобы можно было хранить такое представление колебаний в памяти компьютера, их надо дискретизировать. Этим занимается специальная железка — аналогово-цифровой преобразователь (АЦП). АЦП умеет много раз за одну секунду запоминать значение напряжения (с точностью до разрешения целочисленной арифметики АЦП) на входе и записывать его в память. Количество таких отсчётов за секунду называется sample rate. Типичные значения 8000 Hz – 96000 Hz.

Не будем вдаваться в подробности работы АЦП, потому что это заслуживает отдельной серии статей. Перейдём к главному — весь звук, с которым работают Linux-драйверы и всякие устройства, представляется именно в виде зависимости амплитуды от времени. Такой формат записи называется PCM (Pulse-code modulation). Для каждого кванта времени длительностью 1/sample_rate указано значение амплитуды звука. Именно из PCM состоят .wav-файлы.

Пример визуализации PCM для .wav-файла с музыкой, где по горизонтальной оси отложено время, а по вертикальной — амплитуда сигнала:



Так как на нашей плате стереовыход под динамики, надо научиться хранить в одном .wav-файле стереозвук: левый и правый канал. Тут всё просто — сэмплы будут чередоваться вот так:



Такой способ хранения данных называется interleaved. Бывают и другие способы, но сейчас их рассматривать не будем.

Теперь разберёмся, какие электрические сигналы нам нужны, чтобы можно было организовать передачу данных между устройствами. А нужно не много:

  1. Bit Clock(BCLK) — тактирующий сигнал (или клок), по которому аппаратура определяет, когда надо отправить следующий бит.
  2. Frame Clock (FCLK или его ещё называют LRCLK) — тактирующий сигнал, по которому аппаратура понимает, когда надо начать передавать другой канал.
  3. 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).

Что происходит?
Вот краткая блок-схема, а потом будут пояснения:



  1. [userspace] Парсим .wav header, чтобы узнать PCM-параметры (sample rate, bit width, channels) воспроизводимого файла. Складываем все параметры в struct snd_pcm_hw_params.
  2. [userspace] Открываем устройство /dev/snd/pcmC0D0p.
  3. [userspace] Обращаемся к ядру с помощью ioctl(…, SNDRV_PCM_IOCTL_HW_PARAMS ,…), чтобы узнать поддерживаются такие PCM-параметры или нет.
  4. [kernel] Проверяем PCM-параметры, которые пытается использовать пользователь. Тут есть два типа проверок:
    • на общую корректность и согласованность параметров;
    • поддерживает ли каждый задействованный кодек такие параметры.
  5. настраиваем под них все кодеки, которые прикреплены к /dev/snd/pcmC0D0p интерфейсу (но пока не включаем), возвращаем успех.
  6. [userspace] выделяем временный буфер, куда будем класть PCM-данные.
  7. [userspace] отдаем заполненный временный буфер ядру с помощью ioctl(…, SNDRV_PCM_IOCTL_WRITEI_FRAMES, …). Буква I в конце слова WRITEI указывает, что PCM-данные хранятся в interleaved-формате.
  8. [kernelspace] включаем кодеки, которые прикреплены к /dev/snd/pcmC0D0p интерфейсу, если они еще не включены.
  9. [kernelspace] копируем пользовательский буфер buf в hwbuf (см. пункт «Общая схема») с помощью copy_from_user().
  10. [userspace] goto 6.

Реализацию ядерной части ioctl можно посмотреть, поискав по слову SNDRV_PCM_IOCTL_*

Заключение


Теперь у нас есть представление о том, куда попадает звук в Linux ядре. В следующих статьях будет разбор того, как звук проигрывается из Android-приложений, а для этого ему надо пройти ещё немалый путь.