Когда речь заходит о выводе или вводе звука из/в микроконтроллер(а), если это чуть сложнее меандрового биппера, то почти всегда подразумевается I²S - многие МК имеют нужную периферию, и выпускается широкая номенклатура соответствующих аудио ЦАП и АЦП. Но это, как правило, максимум 2 канала на вывод звука и 2 на ввод (и то, не всегда одновременно).
С другой стороны, существует целый мир под названием High Definition Audio, разработанный Intel в 2004г. С большой долей вероятности, на вашем ПК или ноутбуке, на котором вы читаете эту статью, звук передаётся с помощью этого интерфейса. HDA позволяет работать 15 каналам на ввод, 15 на вывод, с разрядностью до 32 бит и частотой дискретизации до 192 кГц. Правда, не одновременно - ширина полосы пропускания канала ограничена чуть более 46 мбит/с на вывод звука, тем не менее возможности гораздо шире, чем у I²S.
Но я не встречал МК, которые нативно поддерживали бы HDA. Почему-то считается, что хостами могут быть только чипсеты (южные мосты) — то есть «взрослая» системная логика ПК. Ещё можно что-то сгородить на ПЛИС, но для многих любителей «наколенного DIY» эта планка слишком высока.
Я тоже долгое время так думал. И новость о выпуске RP2040 сначала прошла мимо меня - типа «ещё один МК», тысячи их. Пока не наткнулся на проект ZX_RGBI2VGA-HDMI, где этот МК в реальном времени поглощает RGB видеосигнал от ZX-Spectrum и выводит его в виде HDMI (точнее, псевдо-HDMI) сигнала, потребного для современных мониторов. И я подумал: а почему бы и не попробовать? Ну, нет так нет, сказал я себе, и приступил к работе, благо от предыдущей попытки осталась плата с таким кодеком, а именно ALC889.

Основы HDA-Link
Линии и передача данных
HDA link, он же Azalia - несложный интерфейс, для пересылки аудиоданных используется всего 4 провода, но он хитрый и требовательный к некоторым вещам. Первая проблема состоит в том, что тактовый сигнал BCLK должен быть как бы строго 24МГц - от него работает сидящая в кодеке PLL. То есть нельзя сделать программный «ногодрыг» через GPIO, как для SPI или I²C, ибо нужен стабильный меандр, как с генератора.

Следующие 2 сигнала - SYNC и SDO, работают синхронно, и их состояние может меняться на каждом фронте BCLK, то есть с частотой 48МГц (DDR в чистом виде). Вывод разбит на кадры по 1000 бит, частота кадров получается 48кГц - это базовая частота дискретизации аудио, каждый кадр - один семпл. Остальные частоты, 96 и 192, получаются удвоением/учетверением количества семплов в кадре. А частоты, пришедшие из CD и основанные на 44.1кГц, получаются так же, только на каждые 160 кадров нужно передавать 13 пустых, без аудиоданных, при этом нужно равномерно «размазать» эти 13 пустых кадра по 160.
Где находится начало кадра, точнее, его конец, определяется сигналом SYNC - в этот момент там передаётся последовательность из 8 единичек.

Первые 40 бит каждого кадра на линии SDO отведены на управляющие последовательности (команды), так называемые «глаголы» (verb). Когда хосту нужно настроить кодек или что-нибудь запросить у него, тогда такая последовательность вставляется в поток, в противном случае там передаются 0. В каждом 48кГц кадре можно передать только один глагол, но делать это нужно нечасто, так что здесь проблем нет.
Ещё в SYNC «сидят» метки, обозначающие нумерованные потоки каждого стереоканала. После каждой таком метки сигнала SYNC, на линии SDO начинается передача данных для своего канала.
Последний сигнал, SDI, отличается от SDO. Его состояние меняется только по переднему фронту BCLK, то есть полоса пропускания в 2 раза ниже. Кроме того, метки каждого стереоканала сидят в нём же, что ещё сильнее сужает полосу. И в начале каждого кадра первые 36 бит отведены под ответы (response). Ответ там может быть, а может и нет - это определяется битом «valid». Ответы могут быть как запрашиваемыми (solicited responses, ответ на глагол), так и незапрашиваемыми (unsolicited responses - реакция, например, на подключение джека к гнезду).
Указанная полоса пропускания SDO позволяет впритык вывести 10 каналов 24-битного звука на частоте 192кГц, именно столько (5 стерео каналов) находится в кодеке ALC889. С SDI немного грустнее - 3 стереоканала 24/192 в полосу не влезает, надо чем-то жертвовать - частотой, разрядностью или количеством каналов. Почему Intel так поступила - для меня загадка. Видимо решили, что 640кб 4 канала хватит всем.
Назначение адреса кодеку
Для того, что бы кодек заработал, недостаточно сформировать все сигналы. Нужно сделать предпоследний рывок и назначить ему адрес на шине, который потом будет использоваться во всех запросах-глаголах. Без этого кодек не будет реагировать на них.

Происходит это следующим образом:
в определенный момент, после подачи питания или сброса, кодек выдаёт 1 на линии SDI, после чего переводит её в высокоимпедансное состояние. На картинке это обозначено цифрой (1). Хост должен отследить этот момент.
хост начинает вести эту линию, и синхронно со следующим сигналом SYNC выдаёт на ней 1 - цифра (3) на картинке.
по истечении 4 тактов BCLK (длительность SYNC), хост может растянуть эту 1 на линии SDI ещё на несколько тактов - от этого зависит адрес, который кодек будет считать своим. Если не растягивать (то есть SDI перейдёт в 0 одновременно с SYNC), кодек примет адрес 0, на картинке (4).
хост отпускает линию SDI, и со следующего кадра её снова начинает вести кодек (8).
После этого кодек начинает реагировать на команды, сопровождённые указанным адресом, и можно запросить его конфигурацию и настроить на работу.
Настройка нод и путей прохождения сигнала
Это последняя часть инициализации кодека. Нода - это небольшой узел кодека, выполняющий одну функцию. АЦП, ЦАП, миксеры и входные/выходные ножки - это всё ноды.

Если мы хотим, например, вывести звук от ЦАП №1 «Front» (нода 02h) на ножки «Front» (он же Port-D), нода 14h, мы должны:
настроить ЦАП (нода 02) - указать номер потока, частоту дискретизации, разрядность и пр., что делается глаголами 2h и 706h;
настроить промежуточную ноду 0Ch - регулятор громкости/миксер;
настроить ножку (14h) на выход, указав брать сигнал с ноды 0Ch. Выходная ножка содержит коммутатор-мультиплексор, который позволяет ей брать сигнал от одного из источников. Как всё это настроить, расскажу ниже, в описании проекта.
Настроив кодек, можно отправлять ему аудиопоток с указанием номера, и сигнал появится на выходе.
Реализация на RP2040
Особенностью этого МК, выводящей его из разряда «очередной МК на Cortex-M0+», является наличие блоков PIO (programmable input/output). Кратко: помимо классического управления через GPIO или встроенную периферию, выводы МК могут взаимодействовать с PIO, в которых сидит несколько независимых от центрального процессора конечных автоматов-сопроцессоров (state machines, SM). Каждый SM работает по своей короткой программе с небольшим набором инструкций и обрабатываемых данных. Каждая инструкция выполняется за 1 такт, что позволяет точно выдерживать временные рамки и синхронно работать нескольким SM. У каждого SM есть входной и выходной сдвиговые регистры, FIFO для связи с ЦП/DMA, пару 32-битных регистров, и ещё несколько полезных свойств. Большая часть из этого используется в реализации HDA link.
Демо-проект
Для сопряжения RP2040 и HDA link создан проект. Все отсылки к исходным коды в первую очередь относятся к нему. (Далее будет много ссылок на нужные куски кода в репозитории, я думаю, это лучше, чем тащить их сюда. Напишите, если считаете, что я не прав.)
Вход-выход сигналов
Сигналы HDA link формируются и принимаются при помощи автоматов PIO, запущенных одновременно и синхронно работающих. Исходный ассемблерный код для них содержится в hda.pio.
SM использованы так:
SM0 - вывод сигнала SDO. За это отвечает программа out_1000bit. SM в нужный момент выводит наружу очередной бит при помощи выходного сдвигового регистра. Сама битовая последовательность формируется программно (основным процессором) в выделенном для этого буфере, и передаётся в FIFO SM c поочередным использованием двух каналов DMA. Применена автозагрузка (auto-pull) в регистр сдвига. Поскольку длина кадра составляет 1000 бит и не кратна 32, на регистре SM организован счётчик выходных бит. Загрузить в регистры SM константу длиннее 5 бит проблематично, поэтому она грузится из основной программы и хранится в другом регистре. Посчитав нужное количество бит, остаток регистра сдвига (24 бита) выводится наружу, что приводит к загрузке в него следующей последовательности.
SM1 - вывод сигнала SYNC, работает аналогично SM0 и выполняет тот же самый код. Отличие - использованы другой буфер и каналы DMA.
SM2 - ввод с линии SDI, программа in_500bit. Входной сдвиговый регистр заполняется по 1 биту. По мере его заполнения происходит autopush, а по достижении 499 цикла делается сдвиг сразу на всю длину регистра (in pins, 32), что вызывает внеочередной autopush. Далее работает цепочка FIFO - DMA - буфер ОЗУ. Этот же код при помощи т.н. ‘side-set’ формирует сигналы BCLK и вспомогательный Start of Frame (SoF, удобно синхронизировать осциллоскоп при отладке).
SM3 - назначение адреса кодеку, программа codec_address. Автомат ждёт импульс на линии SDI, переводит её в активное состояние, отсчитывает нужный интервал, формирует импульс нужной длины и переводит линию в неактивное состояние. После выполнения своей задачи он посылает какой-то мусор в сторону ЦП, и впадает в вечный цикл. После этого он может быть остановлен и использован для других задач. ЦП же, получив отправленный мусор, понимает, что фаза назначения адреса успешно пройдена и можно начинать инициализацию кодека. Эта SM запускается чуть позже остальных, после сброса, иначе она может раньше времени сработать от мусора, идущего на SDI в момент появления BCLK.
На осциллограмме ниже это показано. Линия SDI подтянута резистивным делителем к уровню примерно +0.9в и видно, как она переходит в высокоимпедансное состояние и обратно:

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

Цифрами обозначены:
мусор на линии SDI, который появляется после подачи BCLK (кодек работал, потом сбросили МК и сигнал BCLK пропал, а когда появился обратно, то кодек отправил скопившиеся в буфере отсчёты АЦП);
CAD;
ответы на команды (сами команды на SDO не показаны, т.к. каналов не хватило).
Сборка-разборка цифровых последовательностей
для ЦАП и от АЦП делается программно, под это выделено ядро #1 (RP2040 - двухъядерный МК). Получив прерывание от DMA, то есть когда очередная порция данных отправлена наружу на SDO и принята от SDI, ядро начинает упаковывать набор семплов в последовательность, которая будет понятна кодеку. При необходимости в неё добавляется управляющий глагол. Далее ЦП распаковывает принятые по SDI данные от АЦП, попутно отделяя котлеты от мух аудиоданные от ответов, складывая первые в один буфер, а вторые в свои очереди ответов (смотря какой ответ - запрошенный или нет). Вся упаковочная логика описана в файле data_pack.c, в зависимости от текущего режима вызываются нужные функции.
Генератор синуса
В проекте он тоже есть. Для максимального быстродействия он организован в виде таблицы, 1024 точки на квадрант, с последующей кусочно-линейной интерполяцией. Не полиномы Чебышёва, конечно (я пробовал и их, но на cortex-m0 без DSP инструкций работает очень медленно), и есть гармоники, но самая сильная, 3-я, на симуляции не превышает уровня -123дБ, что для демо-проекта более чем. Можно подключить и бо́льшую таблицу синусов, 4096 точек и -142дБ, такая тоже есть. Для этого нужно отредактировать CMakeLists.txt. Размер кода вырастет, но ресурсы позволяют. Польза от этого, правда, околонулевая - аналоговые части кодека вносят куда больше искажений, и улучшение скорее всего будет незаметно.
Использование ЦАП
ALC889 содержит 5 стерео ЦАП, то есть 10 независимых каналов. В проекте через них, как уже наверное догадались, выводятся синусоидальные сигналы с разной частотой, начиная от 880Гц при частоте дискретизации 192кГц, и увеличиваясь в 1.5 раза в каждом следующем канале. Квинты, стало быть. Последние 2 канала выводят синусы с частотой 0.333 и 0.400 от частоты дискретизации.
Использование АЦП
Все 6 каналов АЦП (3 стерео входа) работают на светодиод на плате RP2040: как только на любом из входов появляется сигнал, немного превышающий уровень шумов, светик начинает мигать. Моя фантазия в тот день взяла отгул, и я не смог придумать ничего более интересного.
Разгон
Можно получить частоту дискретизации больше 192кГц, если завести МК на большей частоте. Для этого в функции main, в строке set_sys_clock_pll(24000000 * LINK_BCLK_LEN*2 * 6, 3, 2) число 24000000 нужно заменить другим - системная частота МК и BCLK изменятся пропорционально. Коэффициенты подобраны так, что 24000000 - это, собственно, и есть частота сигнала BCLK (Гц). Я успешно разгонял его до 32Мгц (256кГц дискрет.) - кодек работал, но грелся так, что палец можно было удерживать с трудом.
Ну а если вы добавили в проект вычислений, и вам немного не хватает быстродействия на семпл - можно в файле hda.pio увеличить константу LINK_BCLK_LEN, по умолчанию она равна 4. Системная частота МК увеличится пропорционально, частота BCLK останется прежней.
Пути прохождения сигналов
Кодек можно гибко настраивать. Можно задать пути прохождения сигналов, направления ножек, уровни громкости и пр. Здесь кратко расскажу, как реализованы поиск и настройка пути.
Если нода может получать сигнал от других нод, то для неё существует список этих нод-источников. Запросить у кодека их количество можно командой F00h:0Eh, а список командой F02h. В табличке с результатами инициализации это есть в соответствующих столбцах, ближе к правому краю. Любую ноду можно связать с практически любой другой, и иногда таких путей больше одного.
В проекте ножки жёстко привязаны к ЦАП/АЦП, пути описаны в небольшой таблице. После того, как считаны свойства кодека и его нод, вызывается функция, которая рекурсивно ищет путь к ноде назначения. Если такой найден, то настраиваются все ноды, участвующие в нём.
Проект USB-аудио устройства
Ещё один проект демонстрирует связку ALC889 и RP2040 как устройства UAC 2.0. Сразу замечу, что проект сырой и по кодовой базе сильно отстал от описанного выше. Пользоваться им можно, но есть нюансы…
Разбирать его здесь я не буду, кому нужно - разберётся сам. Скажу только, что для USB стека использована библиотека CherryUSB, хотя наверное нужно было бы выбрать tinyUSB из состава Pico SDK. Ну, что есть, то есть.
В папке bin есть несколько собранных файлов для прошивки, каждый реализует свой компромисс из набора «разрядность - частота дискретизации - количество каналов». К этому пришлось прибегнуть, т.к. полоса пропускания шины USB в full-speed режиме сильно меньше той, что необходима, что бы использовать всю прелесть HDA link. В каждой прошивке зашит уникальный USB DID - для того, что бы исключить танцы с бубном под виндой при переходе к следующей.
Hidden text
ненавижу USB
Выводы, наблюдения, замечания
1. Цифровые фильтры кодека режут, в том числе, постоянную составляющую. Они ведут себя как ФВЧ 1-го порядка с частотой среза около 4Гц на 192кГц (на более низких не пробовал, но скорее всего будет пропорционально). Насколько я могу судить, отключить это нельзя. Фильтры стоят и перед ЦАП, и после АЦП. Поэтому не получится использовать кодек ни как регулируемый источник постоянных напряжений, ни для оцифровки, например, положения потенциометров. Только переменка, только хардкор.
2. Во время работы ALC889 ощутимо потребляет энергию и преобразует её в тепло. Ниже в таблице приведено, сколько мА от +3.3в потребляет плата кодека в зависимости от частоты дискретизации и количества работающих каналов. (кодеку нужно два напряжения - +3.3в и +5.0в. Первое подаётся снаружи, второе получается из него повышающим DC-DC)
SR, kHz |
3+5 |
1+1 |
|---|---|---|
256 |
271 |
- |
192 |
244 |
198 |
176 |
237 |
- |
96 |
218 |
- |
88 |
215 |
- |
48 |
204 |
- |
44 |
202 |
187 |
idle |
147 |
Здесь 3+5 и 1+1 - сколько стереоканалов ЦАП+АЦП было активно.
3. Предыдущая попытка утилизовать HDA кодек на МК была предпринята на связке STM32F103 + Spartan-3 + ALC889. Но там объём буфера USB периферии составлял 512 байт на все эндпойнты и двойные буфера, что вылилось в никакую полосу пропускания USB, так что тот проект был благополучно отпет и похоронен (но плата с кодеком осталась). Упомянутая в этой статье попытка оказалась чуть лучше, ибо ограничения имеют другую природу - суммарный объём на все изохронные передачи внутри 1мс USB-фрейма ограничен 1кб (что близко к 1.5кб полосы пропускания full-speed USB).
Что бы подобный кодек смог показать себя во всей красе, нужно пропускную способность примерно на порядок выше, а это уже High-speed, и это совсем другие МК. Может быть RP когда нибудь порадуют нас таким.
4. Особо нужно сказать про упаковку семплов в потоки, ибо по картинкам может быть не сразу понятно, как оно на самом деле. Допустим, мы воспроизводим 3 стереоканала на частоте 48кГц. Чередование семплов выглядит так:
(1) R L (2) R L (3) R L,
где цифра в скобках - номер стереоканала (его начало), а R L - это его правая и левая половинки (или наоборот, тут неважно). На 48кГц всё понятно и логично. А вот на 192кГц, где отсчётов в 4 раза больше, порядок будет такой:
(1) R L R L R L R L (2) R L R L R L R L (3) R L R L R L R L
То есть сначала идут все семплы для первого ЦАП, потом для второго, и т.д. Это нужно учитывать при заполнении буферов ЦАП и разгребании АЦП. В генераторе синуса этому служат 3 вложенных цикла, в USB проекте похожий код занимается переупаковкой потока перед отправкой по USB.
5. USB-audio версия этого проекта рисковала быть поставленной на вечную паузу из-за особенностей RP2040. Проблема: когда одновременно работали 2 канала DMA (перекидывание из ОЗУ в FIFO PIO) и начиналось общение по USB, да ещё при этом шла выборка команды из XIP (SPI FLASH), то один из DMA каналов отправлял в полный FIFO PIO лишнее слово, что приводило к переполнению последнего, потере 32 бит и как результат рассинхрону на HDA link между сигналом SYNC и остальными. Спасибо парням из raspberry pi, они в итоге нашли причину и предложили рабочее решение. Сейчас работает нормально, но в коде на всякий случай потому что мне жалко его удалять остался кусочек, который отслеживает это и дёргает отладочную ножку.
6. При освоении RP2040 я активно пользовался помощью гугло-ИИ, он почти всегда давал дельные советы, и довольно часто генерил рабочий код, который пошёл в проект с минимумом изменений - его можно узнать по специфичным комментариям: 1, 2, 3. Можно резюмировать - с работой наставника он справляется отлично (рекомендую, пока доступно). А вот попытки улучшить статью мне не понравилась - он так усердно пытается придать ей правильную литературность, размывая смысл избытком пафоса, что вызвало у меня ассоциацию, как на бутерброд с колбасой намазывают медузу. Пардон. Но, несмотря на, в статье таки есть парочка его предложений, можете попробовать угадать их.
7. Какая от всего этого польза. Моя фантазия отказывается возвращаться из отгулов, поэтому могу предложить только: а) многоканальный USB аудиоинтерфейс, б) автономный генератор сигналов. Как вариант последнего - многоканальный генератор сигналов с обратной связью - получили сигнал(ы), обработали и отдали обратно, но тут лучше использовать RP2350 - там Cortex-M33 с DSP инструкциями и плавающей точной, и обрабатывать сигналы будет веселее. Если у вас вдруг есть своё видение - предлагайте, пожалуйста.
На этом я закругляюсь. Спасибо, что дочитали до конца.
С уважением, Виктор. Буду рад вашим комментариям.
PS. Здесь не будет рекламы ТГ-каналов, и поддерживать меня тоже не надо.