company_banner

Звук. От механических колебаний до 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. Бывают и другие способы, но сейчас их рассматривать не будем.

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

    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-приложений, а для этого ему надо пройти ещё немалый путь.
    Сбер
    Больше чем банк

    Комментарии 5

      +1
      Мы в SberDevices делаем устройства, на которых можно послушать музыку, посмотреть кино и ещё много всего.

      И где они? Все эти мифологические устройства которые вы делаете?

        +3
        У нас уже есть Okko Smart Box. Интересные анонсы еще впереди.
        0
        Весьма интересно, спасибо.
          0
          Мне понравилась статья. А можно как-то отдавать заполненный буффер без сискола (ioctl)? Т.е. замэпить hwbuf в userspace?
            +3
            Поддерживает ли аппаратура hwbuf в userspace можно узнать с помощью ioctl SNDRV_PCM_INFO_MMAP. Если поддерживает, то мы можем положить hwbuf в userspace с помощью mmap (пример можно посмотреть в функции pcm_open() из все той же tinyalsa)

            Но тут важная оговорка!
            При работе с hwbuf нам нужны два указателя:
            1) hw_ptr — это указатель на самые последние данные которые обработала аппаратура. Этот hw_ptr обычно можно узнать, прочитав какой-нибудь регистр SoC
            2) appl_ptr — это указатель на последние обработанные _пользователем_ данные.

            При нахождении hwbuf в kernelspace, ядро само аккуратно с ними работает — синхронизирует и смотрит, чтобы один указатель не обогнал второй. Такое бывает, например, когда система перегружена и пользовательский процесс работы со звуком просто не успевает выгребать данные. В таком случае надо либо дропать фреймы, либо аварийно завершаться.

            В случае mmap вся эта работа ложится на пользовательский код. Он должен сам обновлять указатели с помощью ioctl SNDRV_PCM_SYNC_PTR_HWSYNC и подобных.

            Итог. При mmap мы не избавляемся от ioctl, из-за работы с указателями и добавляем проблем в пользовательский код. Зато экономим copy_from/to_user().

          Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

          Самое читаемое