
Пожалуй, самый любимый мой аудиоформат — это CD Audio. Все плюсы цифрового звука: он либо читается идеально, либо не читается совсем — в отличие от кассет и катушек, кинематику проигрывателей которых нужно то и дело обслуживать. При этом и все плюсы физического носителя: компакт-диск обладает такой же тактильностью, как и винил, но существенно меньшими размерами — весьма внушительная коллекция займёт от силы пару небольших книжных шкафов. Ретрофутуризма в нём, как в минидиске, нет, но за отсутствие артефактов ATRAC'а это мы ему простим :-)
Конечно же, у меня есть и проигрыватель для компакт-дисков — настольный Sony CDP-790, подобранный где-то на развалах, и компактный Panasonic, подаренный дедушкой на один из первых дней рождения.
Однако ещё с детства у меня была мечта собрать проигрыватель компакт-дисков самому. В те времена слово «микроконтроллер» звучало гордо, и скорее всего обозначало, что для сборки и наладки схемы нужно иметь целый стенд оборудования на несколько тысяч баксов. Да и приводы компакт-дисков от компьютера были в цене, выкорчевать его из компьютера для своей поделки его никто бы не дал.
Прошло 20 лет, что такое компакт-диск — некоторые не вспомнят, а некоторые уже и не знают. Те приводы, что ещё не постигла участь металлолома, то и дело валяются по мусорным ящикам комиссионок за копейки. Микроконтроллеры же наоборот, стали встречаться в ящике любого самодельщика пачками, да и я со своими часами уже поднаторел в программировании оных.
Поэтому — погнали! Делаем свой CD-player с караоке и CD TEXT'ом!
❯ Что получится в итоге
Да, читать статьи до конца не особо популярно, поэтому спойлер с фичами уходит в зрительный зал самое начало статьи. По итогу проект на текущий момент умеет:
-
Читать треклист с диска (была попытка и прикрутить ISO9660 через VFS, но на текущей ревизии платы уж дюже медленно)
-
Воспроизводить/останавливать/перематывать/листать треки с диска, а также играть вперемешку
-
Если привод вмещает больше одного диска, то играть всю стопку подряд
-
Читать названия песен из CD TEXT, Musicbrainz или CDDB и кэшировать их в сжатом виде на флешку
-
Отображать караоке, полученное из LRCLib, QQ Music или Netease
-
Скробблить играющие треки на Last.fm
Лежит орёт «Вашу маму и там и тут показывают!» :-) -
Принимать интернет-радио в формате mp3 или AAC
-
Принимать звук с телефона по блютусу (SBC) и управлять им через AVRC
-
Всё это добро управляется с восьми кнопок на передней панели или пульта от Playstation 2 (нахожу их буквально ящиками в комиссионках, а на ощупь они поприятнее китайских «карточек» с алиэкспресса, при том что сами по себе бесполезны 99% юзеров)
❯ Подбор железа
Для простоты проектирования первую версию сделаем с IDE-интерфейсом. Многие приводы этой эпохи умеют играть CD Audio сами: старожилы даже вспомнят модели, у которых была кнопка «PLAY» прямо на морде — чтобы слушать музыку, не отвлекая компьютер. Попадались мне и приводы имеющие посадочное место под нераспаянную кнопку, кстати, причём даже весьма новые — примерно так 2006 года выпуска.

Увы, аналоговый интерфейс на них в большинстве случаев был очень не очень. Пяток приводов, который я испробовал, пестрил разнообразными проблемами — то на конденсаторах сэкономили, и не покидает ощущение, что кто-то из музыкантов вечно бреется «Агиделью» прямо на выступлении; то зачем-то вообще вставили восьмибитный ЦАП с программной интерполяцией напополам.
Поэтому ищем тот привод, у которого есть не очень часто встречающийся загадочный разъём Digital Interface. Из него выходит самый обычный SPDIF, так что получить оттуда качественный звук — раз плюнуть.

В качестве микроконтроллера я взял ESP32-WROVER с 8 мегабайтами флешки и 4 мегабайтами оперативной памяти. С учётом копеечных цен на такие жирные модули на том же таобао — можно писать на C++ как на джаве, и особо не заморачиваться, пока не прижмёт.
Для звука — PCM5102A, достаточно доступный по цене при хорошем качестве звука дельта-сигма ЦАП с выходом линейного уровня. Последнее было критично, т.к. за всю жизнь нешумящих схем на ОУ у меня получилось примерно ноль :-)
В качестве SPDIF-ресивера был взят Wolfson WM8805. Хоть он и снят с производства, найти его всё ещё достаточно просто, а по отзывам других аудио-самодельщиков с точки зрения стойкости к помехам и ошибкам по таймингу ему нет равных.

Чтобы подключить IDE-привод к МК, я взял PCA9555D, 16-битный I2C регистр. В следующей версии, где вместо SPDIF в планах читать аудиоданные напрямую, нужно будет взять что-то на SPI и побыстрее, ибо таким макаром у нас шина получается со скоростью от силы 200кбит/с — даже mp3 читать не всякий выйдет. Для посылки чисто служебных команд, впрочем, этого нам за глаза.

На роль дисплея хотелось ВЛИ вместо скучного ЖК или ОЛЕГа, но не хотелось тратить бешеные деньги, которые за них нынче просят. Поэтому были взяты графические дисплеи от современных сеговских аркадных автоматов, которые стоят там в блоке картридера — Futaba GP1232A02. Подвыгоревшие, но не побеждённые :-)

Из недостатков: интерфейс RS232 115200/8N1 — что очень медленно, поэтому анимаций в этот раз придётся сыпать поменьше. Из плюсов — цена в 1000 йен за штуку у знакомого на аркадных развалах, против 10к+ за новый ВЛИ на ибее или в радиомагазинах.

Архитектура по итогу получилась весьма похожа на другой Arduino CD-плеер — Atapiduino. Тот, правда, использовал восьмибитные регистры, код писал приснопамятный «профессиональный программист на паскале», да и функционалом особо прошивка не блистает — но в те времена, для «классической» ардуины на атмеге, проект вполне себе впечатлял.
❯ Проектирование схемы
Про это рассказывать особо и нечего — все детали включаются по даташиту согласно своей задаче в устройстве. Аудиочасть получает свой отдельный земляной полигон и отдельный стабилизатор на 3 вольта. Плюс конденсатор пожирнее — в магазине, где я обычно закупаюсь, как раз по скидке списывали Nichicon Fine Gold. Ведь все знают, что если на конденсаторе золотистая обёртка с огромной надписью Audio, то он шумит в два раза меньше при полностью идентичных паспортных параметрах с обычной серией :-)
Рисуем печатку в диптрейсе, попробовал первый раз обойтись без автотрассы:


И, получив наши Printed Cirno Board с завода, запаиваем туда всё:

Потом понимаем, что впаянная муратовская понижайка была не с 12 вольт на 5, а с 24 на 5. Материмся, вскрываем её и заменяем задающий скважность ШИМки резистор, после чего всё оживает. И даже все напряжения в норме, значит можно прошивать!
❯ Прошивка и отладка
Что-то настолько масштабное уже «по-ардуински» принтами в консоль отлаживать дюже не с руки — хотелось бы и трассировку нормальную, и в переменные чтобы потыкаться можно было. К счастью, ESP32 поддерживает OpenOCD, поэтому я просто сделал себе фишку из демо-платы от FT2232, подключённую следующим образом (а про ESP-PROG узнал слишком поздно):
Вывод ESP32 |
Вывод FT2232 |
Сигнал |
IO13 |
AD0 |
TCK |
IO12 |
AD1 |
TDI |
IO15 |
AD2 |
TDO |
IO14 |
AD3 |
TMS |
RX |
BD0 |
UART для логов |
TX |
BD1 |
UART для логов |
После этого в VS Code при активном platformIO нажатие на F5 прошивает контроллер и запускает отладку — всё по-взрослому.

Стоит отметить, что реализация неидеальна — если запустить шину I2S, например, или ещё как-то иначе занять любой из пинов в таблице выше, то отладка и прошивка становятся невозможны. Также прошивка по JTAG невозможно из бутрома, поэтому без UART всё равно не обойтись.
❯ Модуляризация
В надежде на ускорение времени компиляции, в этот раз я раздробил прошивку на модули. Не сказать, чтобы это сильно помогло (хотя — после переноса проекта с «чистой» ардуины на ESP-IDF, время пересборки существенно ниже, если меняешь лишь скетч, а не эти самые модули!), но как минимум видеть границы между железом и логикой было проще в этот раз, чем в PIS-OS.
-
ESPer-CORE: прикладные штуки, связанные с самой платой — шина IDE, i2c, обвязка для работы с SPDIF-трансивером, дисплеем на газоразрядниках (который ушёл в жертву киноискусству и не был установлен по итогу), обвязка вокруг функций сжатия данных из ПЗУ и всё такое
-
ESPer-CDP: всё, связанное с воспроизведением компакт-дисков — протокол ATAPI, конечный автомат состояний плеера, загрузка метаданных и их кэширование и всё вот это вот
-
ESPer-GUI: всё, связанное с работой с дисплеем и графическим интерфейсом
❯ Интерфейс и графика
Поначалу была мысль добавить поддержку моего дисплея в библиотеку u8g2. Покурив её исходники пару деньков и поняв, что без поллитры и пары грамм тут не разобраться, пришёл к выводу, что NIH — это не всегда плохо. Графический слой от PIS-OS, правда, вышел всё же не очень удачным, поэтому пришлось написать новую библиотеку — ESPer-GUI, конкретно под задачи этого проекта.
Статей по работе с однобитной графикой в интернете вагон и маленькая тележка, поэтому коснёмся лишь основной архитектуры, не вдаваясь в детали:
-
Каждый View может содержать внутри себя сколько-то дочерних SubView. При этом отвечает он только за себя, т.е. при изменении своего стейта он должен вызвать свой метод
set_needs_display()
, а за изменениями дочерних следить не должен. Дочерние, в свою очередь, тоже понятия не имеют, что лежат внутри какого-то SuperView. -
Каждый «кадр» Compositor обходит дерево View'шников, проверяя, не надо ли кому-то перерисоваться. Если кому-то надо ещё и координаты поменять, то значит перерисоваться надо всем внутри родителя, причём рекурсивно, то бишь вообще всему экрану. А то мало ли оно кого-то перекрывало по своим старым координатам :-)
-
Каждому View'шнику для рендера подсовывается свой временный буфер, куда он может рендериться в своей системе координат. (Ну или не рендериться, если он
hidden
). Содержимое этого буфера потом будет переброшено в корневой фреймбуфер перед рендером следующего View. Если View'шник имеет размер, равный корневому фреймбуферу, и координаты (0,0), то он рендерится прямо в этот самый фреймбуфер. -
Параллельно собирается список прямоугольников всех view'шников, которые пришлось перерендерить. В него попадают только координаты внешнего view, т.е. если «протух» контейнер, в котором «протухли» 4 дочерних view из 10 — в список попадёт только прямоугольник контейнера, ибо дочерние view будут уже переброшены на экран внутри него.
-
После того как всех обошли и всех отрендерили во фреймбуфер, оный, вместе с нормализованным списком прямоугольников с изменениями, отдаётся DisplayDriver'у на растерзание.
-
Драйвер дисплея, конечно, имеет право весь кадр вывалить в дисплей за раз, но так как на 115200 бод особо не разгонишься, то пересылает только изменившиеся куски фреймбуфера.
Отчасти кому-то, особенно из мобильных разработчиков, такая архитектура может показаться Подозрительно Знакомой™ — все совпадения случайны :-)

Шрифты были так же подтянуты из PIS-OS в простейшем формате с говорящим названием MoFo (Monospace Font, а не то что вы могли подумать :-) — уж больно те шрифты мне понравились.
Накидываем простенький тест, убеждаемся, что всё рендерится:
И идём дальше, писать самую весёлую часть.
Работа с приводом
NB: целиком спецификация ATAPI/MMC — это толстенный талмуд на несколько сотен страниц, читая который от корки до корки можно уснуть вечным сном, не написав ни строчки кода. Поэтому проект пишется по принципу «лишь бы заработало», с периодической сверкой с конкретными главами стандарта.
Многие приводы также интерпретируют стандарт как захотят — кто-то требует команду START UNIT перед работой, а кто-то от неё, наоборот, работать перестаёт; кто-то по-умолчанию SPDIF включает, а кому-то отдельное приглашение нужно... Короче, мы не в микрософте, поэтому пробуйте, что прокатывает с вашим конкретным приводом.
Ну или копите базу приводов для теста :-)
Подавляющее большинство IDE-приводов компакт-дисков общаются с хостом по протоколу ATAPI — что, по сути, завёртка подмножества MMC-команд SCSI в шину IDE. Поэтому сначала нам придётся кратко ознакомиться с архитектурой именно IDE.
Ну IDE это мы?
Максимально кратко о шине на физическом уровне в простейшем режиме (PIO), глядя на распиновку:

-
Есть 16 двунаправленных линий данных (DD0~DD15) и 5 адресных линий (#CS0, #CS1, A0~A2)
-
В начале все устройства на шине сбрасываются, когда хост дёргает вниз линию #RST.
-
Перед записью или чтением хост выбирает блок регистров комбинацией линий #CS0 и #CS1, а также номер регистра комбинацией линий A0, A1, A2. В дальнейшем будем представлять эти 5 бит как младшие 5 бит байта в номерах регистров, как это обычно делалось на x86-совместимых компьютерах.
-
При записи с хоста в устройство хост выставляет нужные логические уровни на этих линиях и дёргает линию #DIOW вниз.
-
При чтении с устройства в хост устройство выставляет данные на шину по спадающему фронту линии #DIOR, после чего хост их считывает.
В абсолютно проклятый способ сосуществования двух устройств на одном шлейфе (та самая мычка master/slave/cable select) и тот факт, что это не столько функция контроллера, сколько привода, мы пока лезть не будем, нам это не надо (да у меня и не завелось — хотя играть два диска подряд с двух приводов было заманчивой идеей!).
Это всё барахло в проекте абстрагировано в прошивке в отдельный класс IDEBus, который реализует это всё на двух I2C-регистрах.
Самих регистров нам понадобится тоже не так много:
Адрес |
Назначение при чтении |
Назначение при записи |
0x1F0 |
Data |
|
0x1F1 |
Error |
Feature |
0x1F2 |
Sector count |
|
0x1F3 |
Sector number |
|
0x1F4 |
Cylinder low |
|
0x1F5 |
Cylinder high |
|
0x1F6 |
Drive select |
|
0x1F7 |
Status |
Command |
0x3F6 |
Alternate status |
Device control |
❯ Интерфейс ATAPI
Название ATA Packet Interface как бы намекает — общаться с девайсом мы будем не через регистры, а некими пакетами. Фактически это означает, что мы должны для каждой транзакции в общем виде сделать следующее:
-
Выключить в регистре Device Control (0x3F6) бит Interrupt Enable, т.к. мы не имеем возможности получить прерывание с шины и отреагировать на него. Этот бит сбрасывается после каждой выполненной транзакции.
-
Записать в регистр команд (0x1F7) команду WRITE_PACKET (0xA0).
-
Записать в регистр данных (0x1F0) поочерёдно каждый байт пакета, при этом в младших битах шины находится i'тый байт пакета, а в старших — (i + 1)'ый. Обычно ATAPI устройства работают с 12-байтными либо 16-байтными пакетами, но вторых я ещё не встречал. Если данных в пакете меньше чем 12 (или 16), остаток следует добить нулями.
-
Если устройство нам должно ответить, прочитать из регистра данных ожидаемое количество байт, либо пока не «погаснет» бит DRQ в регистре статуса (0x1F7).
Тут стоит отметить, что у себя в коде я «склеил» логику ATAPI и MMC в один класс. Если описанную выше процедуру вынести в отдельный слой, то последующей заменой оного можно легко адаптировать этот ардуино-скетч под работу со SCSI-приводами (или даже SATA, но с ней я вообще не ковырялся).
❯ Инициализация привода
-
Сбрасываем шину, дёргая #RST, затем ожидаем снятия флага BSY в регистре состояния.
-
Записываем в регистр команд 0xFF91 — программный сброс устройства.
-
Читаем нижние биты регистров Cylinder Low и Cylinder High. По стандарту, для ATAPI-устройств в них должны быть значения 0x14 и 0xEB соответственно.
-
Записываем в регистр команд 0xFF90 — выполнение самодиагностики. После этого в регистре ошибки должно появиться 0xFF01, хотя на некоторых даже исправных моих приводах старший байт отличался.
-
Записываем в регистры Cylinder Low/High соответственно 0xFF02, 0xFF00 — тем самым сообщаем устройству о том, что размер буфера PIO будет 0x200 байт.
-
Записываем в регистр команд 0xA1 (IDENTIFY_PACKET_DEVICE), а затем читаем ответ, откуда получаем размер пакетов и название привода, версию прошивки и серийный номер:
struct ATAPI_PKT IdentifyPacket {
uint16_t general_config;
uint16_t reserved1;
uint16_t specific_config;
uint16_t reserved2[7];
char serial_no[20];
uint16_t reserved3[3];
char firmware_rev[8];
char model[40];
// ... хрен бы с ним с остальным пока что ...
};
// ------
read_response(&rslt, sizeof(Responses::IdentifyPacket), true);
xSemaphoreGive(semaphore);
char buf[41] = { 0 };
strncpy(buf, rslt.model, 40);
ata_str_to_human(buf, 41);
info.model = std::string(buf);
strncpy(buf, rslt.firmware_rev, 40);
ata_str_to_human(buf, 41);
info.firmware = std::string(buf);
strncpy(buf, rslt.serial_no, 40);
ata_str_to_human(buf, 41);
info.serial = std::string(buf);
packet_size = ((rslt.general_config & 0x1) != 0) ? 16 : 12;
ESP_LOGI(LOG_TAG, "Drive Model = '%s', packet size = %i", info.model.c_str(), packet_size);
Если все эти шаги завершились без ошибок, можно работать с устройством и посылать ему команды в виде пакетов по алгоритму, описанному ранее.
Приводить здесь полную цитату стандарта я не вижу смысла, ведь пока что его ещё можно найти в интернете. Потом всё равно окажется, что половину я не понял, а вторую — понял неправильно, и треть от непонятого ещё и реализовал криво :-)
Приведу далее лишь некоторые нюансы, которые не встречаются в самых часто попадающихся его копиях в интернете.
❯ Терминология
Краткий список терминов, которые будут встречаться дальше:
-
TOC — Table Of Contents. Сектор диска, содержащий список дорожек и адреса, на которых они начинаются и заканчиваются, а также атрибуты навроде типа (аудио или данные), раскладки каналов (стерео или квадро), эквализации (с фонокоррекцией или без) и разрешения цифрового копирования (например, по оптическому кабелю на минидиск)
-
MSF — Minute:Second:Frame. Формат адресации преимущественно на аудио компакт-дисках, представляемый в виде трёх байтов. Первый байт — минута звучания (от 0 до 99 включительно), второй — секунда (аналогично), третий — кадр, равный 1/75 секунды (от 0 до 74 включительно)
-
Lead-out — последняя дорожка на диске, обозначающая его конец. Если вы в былые времена при прожиге диска ставили галочку «закрыть диск» (или забывали её поставить, а потом удивлялись, почему мафон не читает болванку со свежими хитами :-) — то это был именно выбор, прожигать ли эту дорожку. Она всегда имеет номер 170 (0xAA), а её MSF-адрес на обычном CD Audio равен длине звучания диска. Для совмещённых дисков её адрес также включает и длительность треков с данными, поэтому для вычисления длины альбома lead-out в таких случаях не подойдёт.
❯ Чтение CD TEXT
Для чтения CD TEXT нужно передать команду READ TOC/PMA/ATIP (0x43), указав в поле Format число 5, обозначенное в большинстве копий стандарта как Reserved. При условии поддержки приводом такого формата и наличия текста на диске — в ответ прилетит блок данных, у которого, за исключением идущей вначале 16-битной длины блока, первые два байта должны быть 0. Если это так, то можно попытаться распарсить ответ как CD TEXT — массив блоков вида:
struct CDTextPack {
enum Kind: uint8_t {
TITLE = 0x80,
ARTIST = 0x81,
// хрен бы с ними с остальными
};
Kind kind;
uint8_t track_no; // номер трека, к которому относится ПЕРВЫЙ СИМВОЛ в payload
uint8_t sequence_no; // порядковый номер записи
uint8_t char_pos: 4; // количество символов, унаследованных треком от прошлой записи
uint8_t block_no: 3;
bool wide_char: 1;
char payload[12];
uint16_t crc; // нестандартный, но описание алгоритма я не нашёл
};
Sequence № должен расти с каждым элементом. Block № обозначает, к массиву какого языка принадлежит запись (0 = английский), Wide Char — используется ли двухбайтовая кодировка. С парсингом других языков и широких кодировок я не заморачивался, ибо там используется не юникод.

Внимательный читатель, конечно же, задастся вопросом — а что, если исполнитель или название трека имеют длину больше чем 12 символов? Всё дело в том, что места в субкоде на диске не очень много, поэтому записи «уполтнены» следующим образом:
-
track_no
обозначает, какому треку принадлежит первый символ массиваpayload
-
Если в
payload
попадается нуль-терминатор (0x00) — всё, что идёт после него, принадлежит уже треку под номером(track_no + 1)
-
Если название трека повторяет название предыдущего, то вместо строки будет символ TAB (0x09)
Реализация парсинга в меру моего понимания
CDTextPack * cur;
std::vector<std::string> tmp_artists(album.tracks.size() + 1);
std::vector<std::string> tmp_titles(album.tracks.size() + 1);
uint8_t last_seq_no = 0xFF;
int cur_trk_no_artist = 0;
int cur_trk_no_title = 0;
for(int pos = 0; pos < raw_data.size(); pos += sizeof(CDTextPack)) {
cur = (CDTextPack*) &raw_data[pos];
last_seq_no++;
if(cur->sequence_no != last_seq_no) {
ESP_LOGE(LOG_TAG, "Seq no jump from %i to %i at pos=%i, bail out!", last_seq_no, cur->sequence_no, pos);
break;
}
if(cur->block_no != 0) continue; // maybe one day
if(cur->wide_char) continue; // maybe one day
if(cur->kind == CDTextPack::Kind::TITLE) {
cur_trk_no_title = cur->track_no;
for(int i = 0; i < sizeof(cur->payload); i++) {
const char p = cur->payload[i];
if(p == 0) {
cur_trk_no_title++;
} else if(p == 0x9 && cur_trk_no_title > 0) {
tmp_titles[cur_trk_no_title] = tmp_titles[cur_trk_no_title - 1];
} else {
tmp_titles[cur_trk_no_title] += p;
}
}
}
else if(cur->kind == CDTextPack::Kind::ARTIST) {
cur_trk_no_artist = cur->track_no;
for(int i = 0; i < sizeof(cur->payload); i++) {
const char p = cur->payload[i];
if(p == 0) {
cur_trk_no_artist++;
} else if(p == 0x9 && cur_trk_no_title > 0) {
tmp_artists[cur_trk_no_artist] = tmp_artists[cur_trk_no_title - 1];
} else {
tmp_artists[cur_trk_no_artist] += p;
}
}
}
}
if(album.artist.empty()) album.artist = tmp_artists[0];
if(album.title.empty()) album.title = tmp_titles[0];
for(int i = 0; i < tmp_artists.size(); i++) {
ESP_LOGV(LOG_TAG, "Artist %i: %s", i, tmp_artists[i].c_str());
if(i > 0 && album.tracks[i - 1].artist.empty() && tmp_artists[0] != tmp_artists[i]) {
album.tracks[i - 1].artist = tmp_artists[i];
}
}
for(int i = 0; i < tmp_titles.size(); i++) {
ESP_LOGV(LOG_TAG, "Title %i: %s", i, tmp_titles[i].c_str());
if(i > 0 && album.tracks[i - 1].title.empty()) {
album.tracks[i - 1].title = tmp_titles[i];
}
}
После обхода массива таким образом от начала до конца у нас должно остаться ровно (число треков на диске + 1) элементов типа TITLE, и не меньше элементов типа ARTIST. В элементах, где track№ = 0, будут записаны исполнитель и название всего диска, а в остальных — таковые для каждой песни в отдельности.

Даже если ваш привод умеет читать теги, полезность этого способа остаётся под сомнением, ибо на коллекцию в 600 дисков у меня нашёлся только один с CD TEXT — да и тот самописный! Ходят слухи, что чаще всего оно встречалось на поздних прессах от Sony Music, но таковых у меня не оказалось.
❯ Получение метаданных по-современному
Впрочем, а что же делать, если CD TEXT на диск не заштампован или привод его не читает, но видеть названия песен таки хочется? К счастью, ESP32 оборудован вайфаем и может ходить в интернет!
В былые времена за доступ к базе данных компакт-дисков нужно было башлять дикие бабки компании Gracenote. Сейчас, к счастью, есть более доступные ресурсы — как бесплатный Musicbrainz, так и GnuDB, преемник почившего FreeDB.
Во времена FreeDB был придуман алгоритм расчёта «отпечатка» диска, по которому можно было уникально идентифицировать конкретный альбом. Основывается он, как и его преемники, на том, что очень мала вероятность выхода нескольких альбомов с треклистом, совпадающим с точностью до 1/75 секунды — ну а если уж такое произошло (иногда попадается!), то можно предложить пользователю выбрать верный диск, или, в нашем случае, просто ничего не показывать.
К сожалению, алгоритм FreeDB был полон коллизий, и GnuDB перешёл на собственный — но из-за того, что сервер, по сути, выполняет редирект на другую запись в базе, мы не можем использовать посчитанный локально отпечаток как ключ для кэша, а посчитать по новому алгоритму мы не можем — он нигде не опубликован (или я не нашёл).
Поэтому будем использовать для кэширования отпечаток по алгоритму MusicBrainz, который реализуется весьма просто:
-
Составить строку по формату:
-
Две шестнадцатеричные цифры (здесь и далее — в ASCII) с номером первого трека на диске (как правило, «01»)
-
Две шестнадцатеричные цифры с номером последнего трека на диске без учёта lead-out
-
Восемь шестнадцатеричных цифр с длиной диска в кадрах, включая дата-треки — проще говоря, адрес lead-out, пересчитанный из MSF в кадры
-
99 раз по восемь шестнадцатеричных цифр, обозначающих длительность каждого трека, включая дата-треки, с номера 1 по 99 в кадрах. Если трека под таким номером на диске нет, то принять его длительность равной нулю.
-
-
Всё это загнать в SHA-1
-
Полученный 20-байтный хэш закодировать в Base64, но по стандарту RFC822
Код вместо тысячи слов
char *rfc822_binary(void *src,unsigned long srcl,size_t *len)
{
char *ret,*d;
char *s = (char *) src;
const char *v = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._";
unsigned long i = ((srcl + 2) / 3) * 4;
*len = i += 2 * ((i / 60) + 1);
d = ret = (char *) malloc ((size_t) ++i);
for (i = 0; srcl; s += 3) { /* process tuplets */
*d++ = v[s[0] >> 2]; /* byte 1: high 6 bits (1) */
/* byte 2: low 2 bits (1), high 4 bits (2) */
*d++ = v[((s[0] << 4) + (--srcl ? (s[1] >> 4) : 0)) & 0x3f];
/* byte 3: low 4 bits (2), high 2 bits (3) */
*d++ = srcl ? v[((s[1] << 2) + (--srcl ? (s[2] >> 6) : 0)) & 0x3f] : '-';
/* byte 4: low 6 bits (3) */
*d++ = srcl ? v[s[2] & 0x3f] : '-';
if (srcl) srcl--; /* count third character if processed */
if ((++i) == 15) { /* output 60 characters? */
i = 0; /* restart line break count, insert CRLF */
*d++ = '15'; *d++ = '12';
}
}
*d = ''; /* tie off string */
return ret; /* return the resulting string */
}
const std::string MusicBrainzMetadataProvider::generate_id(const Album& album) {
mbedtls_sha1_context ctx;
mbedtls_sha1_init(&ctx);
mbedtls_sha1_starts_ret(&ctx);
char temp[16] = { 0 };
unsigned char sha[20] = { 0 };
size_t dummy;
sprintf(temp, "%02X", album.tracks[0].disc_position.number);
mbedtls_sha1_update_ret(&ctx, (unsigned char*) temp, strlen(temp));
sprintf(temp, "%02X", album.tracks.back().disc_position.number);
mbedtls_sha1_update_ret(&ctx, (unsigned char*) temp, strlen(temp));
sprintf(temp, "%08X", MSF_TO_FRAMES(album.lead_out));
mbedtls_sha1_update_ret(&ctx, (unsigned char*) temp, strlen(temp));
for (int i = 0; i < 99; i++) {
if(i < album.tracks.size()) {
sprintf(temp, "%08X", MSF_TO_FRAMES(album.tracks[i].disc_position.position));
mbedtls_sha1_update_ret(&ctx, (unsigned char*) temp, strlen(temp));
}
else {
mbedtls_sha1_update_ret(&ctx, (unsigned char*) "00000000", 8);
}
}
mbedtls_sha1_finish_ret(&ctx, sha);
mbedtls_sha1_free(&ctx);
char * mbid = rfc822_binary(sha, 20, &dummy);
ESP_LOGV(LOG_TAG, "Disc ID = %s", mbid);
const std::string rslt = std::string(mbid);
free(mbid);
return rslt;
}
После этого получится строка-идентификатор, однозначно определяющая диск в базе MusicBrainz. К примеру, для первого попавшегося на полке около стола Alstroemeria Records — The Brilliant Flowers это будет F9XV7VTnpufEOtGcaHcitqk0k2s-
. Подставляем его в ссылку к API: https://musicbrainz.org/ws/2/discid/F9XV7VTnpufEOtGcaHcitqk0k2s-?inc=recordings+artist-credits — и действительно, наблюдаем на выходе именно этот самый альбом.
Как парсить полученный из API выше JSON, думаю, рассказывать тоже не надо — этим и так многие из нас повседневно занимаются :-)
Однако, если в MusicBrainz диска нет, то можно прибегнуть к помощи GnuDB. Например, многие штучные CD-R релизы японских исполнителей есть только там. Формат CDDB хоть и хорошо описан, но фактически никакому здравому смыслу не подчиняется — чего стоят только, например, вроде-бы-комментарии в начале файла, обязательные к записи при создании файла и к парсингу при его чтении.
Спецификация на комментарии, sic!
The comments should also contain the string "# Track frame offsets:" followed by the list of track offsets (the # of frames from the beginning of the CD) obtained from the table of contents on the CD itself, with any amount of white space between the "#" and the offset.
There should be no other comments interspersed between the list of track offsets. This list must follow the initial identifier string described above.
Following the offset list should be at least one blank comment, even though database entries without such a blank comment are also considered valid.
Чтобы переложить с себя весь этот геморрой, пришлось выкопать такую реликтовую вещь, как libCDDB — несмотря на свою старость, она спокойно и с минимумом изменений собралась для ESP32. Работа с ней уже не настолько тривиальна, поэтому приведу пример кода для получения метаданных альбома с сервера:
Получение данных из CDDB
void CDDBMetadataProvider::fetch_album(Album& album) {
int matches = 0;
cddb_disc_t * disc = NULL;
cddb_track_t * trk = NULL;
auto cddb = cddb_new();
if(!cddb) {
ESP_LOGE(LOG_TAG, "memory allocation failed");
return;
}
cddb_log_set_level(cddb_log_level_t::CDDB_LOG_INFO);
cddb_cache_disable(cddb); // <- у нас свой кэш, ибо новая схема идентификаторов GnuDB ломает кэш libcddb
cddb_set_server_name(cddb, server.c_str()); // куда стучимся? IP или домен сервера
disc = cddb_disc_new();
if(!disc) {
ESP_LOGE(LOG_TAG, "memory allocation failed");
goto bail;
}
// указать длину альбома в секундах, включая дорожки с данными
cddb_disc_set_length(disc, FRAMES_TO_SECONDS(MSF_TO_FRAMES(album.lead_out)));
// указать все аудио дорожки, включая дорожки с данными
for(int i = 0; i < album.tracks.size(); i++) {
trk = cddb_track_new();
if(!trk) {
ESP_LOGE(LOG_TAG, "memory allocation failed");
goto bail;
}
cddb_disc_add_track(disc, trk);
cddb_track_set_frame_offset(trk, MSF_TO_FRAMES(album.tracks[i].disc_position.position));
}
// посчитать CDDB ID
cddb_disc_calc_discid(disc);
ESP_LOGI(LOG_TAG, "Disc ID = %08x", cddb_disc_get_discid(disc));
// получить список дисков с сервера
matches = cddb_query(cddb, disc);
if(matches == -1) {
ESP_LOGE(LOG_TAG, "Query failed: (%i) %s", cddb_errno(cddb), cddb_error_str(cddb_errno(cddb)));
}
else if(matches > 1) {
ESP_LOGE(LOG_TAG, "Multiple matches found. Ignoring as there is no way to choose (for now)");
}
else if(matches == 0) {
ESP_LOGW(LOG_TAG, "No matches");
}
else {
bool success = cddb_read(cddb, disc);
if(!success) {
ESP_LOGE(LOG_TAG, "Read failed: (%i) %s", cddb_errno(cddb), cddb_error_str(cddb_errno(cddb)));
} else {
if(album.title.empty()) album.title = std::string(cddb_disc_get_title(disc));
if(album.artist.empty()) album.artist = std::string(cddb_disc_get_artist(disc));
trk = cddb_disc_get_track_first(disc);
for(int i = 0; i < album.tracks.size(); i++) {
if(trk != NULL) {
const char * tmp = nullptr;
if(album.tracks[i].title.empty()) {
tmp = cddb_track_get_title(trk);
if(tmp != nullptr) album.tracks[i].title = std::string(tmp);
}
if(album.tracks[i].artist.empty()) {
tmp = cddb_track_get_artist(trk);
if(tmp != nullptr && strcmp(tmp, album.artist.c_str()) != 0) album.tracks[i].artist = std::string(tmp);
}
trk = cddb_disc_get_track_next(disc);
}
}
}
}
bail:
if(disc) cddb_disc_destroy(disc);
if(cddb) cddb_destroy(cddb);
}
Из плюсов такого совмещения двух баз и CD TEXT'а — названия песен вы будете чаще видеть, чем не видеть. Из минусов — в силу тогдашней эпохи у CDDB не было прозрачной системы модерации, поэтому местами встречаются такие записи, вместо которых я бы предпочёл, конечно, бездушное Unknown Artist — Track 1 (или нет, так веселее):

Храниться кэш будет просто в директории в LittleFS. Набрать столько дисков, чтобы поиск файла в директории занимал существенное место, будет сложно (да и в таком случае — загрузкой метаданных занимается отдельный от остального поток). Ну а с учётом, что после сжатия даже длинные диски занимают лишь 300-500 байт, что помещается даже не в один кластер файловой системы, а прямиком в таблицу размещения файлов — раздела в мегабайт хватит, чтобы загнать туда целиком немаленькую коллекцию дисков и больше в интернет за треклистами не ходить!
❯ Щас спою!
Ну раз уж у нас есть такой большой дисплей, интернет и шрифты, то и метаданные нужно получать на полную. В программные плееры я, помимо прочего, первым делом ставлю плагин караоке, который позволяет показывать синхронизированные слова песни.
Почему-то они, в основном, находятся на разных китайских стриминговых сайтах, которые эти плагины парсят путём разбора HTML. Однако, недавно появился такой проект, как LRCLib, имеющий сравнительно сносное API.
Предоставляет он слова в формате LRC, который достаточно тривиально парсится:
Не идеально, но работает
void LyricProvider::process_lrc_line(const std::string& line, std::vector<Lyric>& lyrics) {
ESP_LOGD(LOG_TAG,"Line = %s", line.c_str());
std::string content = "";
std::vector<int> times = {};
int time = 0;
content.reserve(line.length());
bool token = false;
unsigned int min = 0;
unsigned int sec = 0;
unsigned int decas = 0;
int offset_millis = 0;
bool skip_token = false;
bool first_token = false;
unsigned int* timepos = &min;
if(line.substr(0, 9) == "[offset:") {
offset_millis = -1 * std::stoi(line.substr(9, line.length() - 11));
ESP_LOGI(LOG_TAG, "Offset = %i", offset_millis);
}
else {
for(const char c: line) {
if(!token) {
if(c == '[') {
token = true;
skip_token = false;
min = 0;
sec = 0;
decas = 0;
}
else if(!first_token) {
ESP_LOGV(LOG_TAG, "LRC line must start from token: %s", line.c_str());
break;
}
else {
if(!content.empty() || c != ' ')
content += c;
}
}
else if(skip_token) {
// ignore all until end of token
if(c == ']') {
token = false;
}
}
else {
if(c == ']') {
token = false;
int total_millis = (decas + 100 * (sec + 60 * min)) * 10;
times.push_back(total_millis);
ESP_LOGD(LOG_TAG, "Time: %02im%02is.%02id = %i", min, sec, decas, total_frames);
first_token = true;
}
else if(c >= '0' && c <= '9') {
// number
*timepos *= 10;
*timepos += (c - '0');
}
else if(c == ':') {
// mm:ss separator
timepos = &sec;
}
else if(c == '.') {
// ms separator
timepos = &decas;
}
else if(c == ' ') {
// ignore
}
else {
skip_token = true;
}
}
}
}
if(!times.empty() && !content.empty()) {
for(unsigned int ftime: times) {
lyrics.push_back(Lyric {
.millisecond = ftime,
.line = content
});
}
}
}
На выходе получаем строки с таймштампами в миллисекундах, которые можно уже отображать на экране. Несмотря на то, что на CD Audio время лишь с точностью до 1/75 секунды, плюс какую-то погрешность вносит периодический опрос привода, да и мой алгоритм подбора размера шрифта далеко не самый оптимальный, отображается всё довольно точно — при условии, что слова в базе были хорошо промаркированы изначально. Просто вставляем любой достаточно популярный диск в привод и смотрим!
❯ ATAPI's Death? (да, полнейший)
В качестве послесловия, хочется уточнить ещё раз насчёт выбора привода. Спецификация ATAPI хоть и является попыткой пропихнуть SCSI внутри IDE, но целиком утряслась лишь к концу девяностых, а приводы пробыли актуальными для массового пользователя лишь ещё лет десять после. Поэтому трактовка большинства команд, не являющихся основными (навроде чтения секторов диска), была достаточно вольной от производителя к производителю.
Возьми привод слишком ранний — его, скорее всего, делали под собственный драйвер для ДОСа, и ведёт он себя как захочет. Слишком поздний — может вообще не знать, что такое CD Audio и как его играть. Ну а если на задней панели вместо надписи «Digital Interface» стоит отметка типа «S1», «Reserved» или «Unused», то не стоит тешить себя надеждой, что где-то внутри SPDIF таки бегает — из тех, что мне встречались, даже если на плате дорожка протянута к правильному чипу, сам чип стоит из упрощённой серии и ничего подобного не выводит. Да и парочка таковых, которые пометку имеют, но ничего на этот порт не выводят, тоже попалась.

Помимо этого, например, я столкнулся со следующими приколами в тестировании:
-
NEC ND-3500A (08/2004) — идеально совместимый привод, все бы такие делали! Видимо за косяки в девяностых, когда ради НЭКовских приводов даже обновления на винду специальные делали с обходом их багов, кто-то им таки настучал по голове :-) Перемотка, воспроизведение как из пушки. CD TEXT тоже читает и корректно отдаёт. Ещё и работает еле слышно, кроме щелчка головки об концевик на старте звуков не издаёт!
-
Teac DV-W58G (02/2005) — всё отлично, читает CD TEXT, но не понимает команду перемотки. Если верить поздним ревизиям спецификации ATAPI, то при наличии цифрового порта и не обязан — но другие попадавшиеся мне приводы перемотку умели даже при выводе по SPDIF. До кучи цифровой выход ещё и подтупляет при старте и паузе — выдаёт шумы.
-
Philips/Lite-On DH-20A4P, он же Buffalo DVSM-XE1219FB — то же, что и DV-W58G по работоспособности. Не знаю, это мой экземпляр изношен, или модель такая неудачная, но шумный до жути! Какое тут прослушивание музыки, когда постоянно следишь, как бы он со своими вибрациями со стола не убежал.
-
Panasonic SW-9583S — читает диск шикарно и быстро, но почему-то по SPDIF ничего не выдаёт, хотя разъём и отмечен как Digital Out на корпусе (но не в паспорте). Или ему нужна команда Mode Select, которая его включит, или же опять на нём сэкономили, но два раза — один раз на схеме, а второй раз на заглушке для надписи на пуансоне.
-
Matsushita SR-8171 — ноутбучный привод с разъёмом JAE50, работает с одного питания в 5 вольт. С переходником работает, но TOC вычитывается лишь на третий раз. В остальном работоспособен, но за неимением SPDIF малополезен.
-
Teac CD-C68E (02/1997) — не имеет SPDIF, не позволяет переключаться на пустой слот. Из-за последнего обойтись чисто программным eject нельзя, нужно оставлять кнопки выбора диска на морде в доступности. Иногда как будто отдаёт TOC, не до конца сформировав его в памяти, в последних байтах какой-то мусор и номера треков вперемешку — из-за этого функция чтения оного в прошивке проверяет его «на вшивость» и перечитывает 20 раз до получения вменяемого результата.
-
NEC CDR-1400C (01/1997) — не передаёт правильный Media Type Code, только DOOR_OPEN, либо CLOSED_UNKNOWN(0x0). При этом если подать команду воспроизведения аудио, то играет корректно. Не гасит DRQ даже в конце посылки данных, если её размер динамический, пока не отправит ровно
allocation_length
байт — и внутри у него этот счётчик знаковый, так что если команды отправлять с 0xFFFF в этом поле, всё вешается намертво. До кучи очень капризная в плане переборки кинематика: снимешь лоток, потом будешь переставлять десяток раз, чтобы всё открывалось и закрывалось. Не рекомендуется. -
Cyber Drive CW038D (02/2002) — не сразу передаёт верный Media Type Code, но даже опознав CD Audio не отзывается на команду воспроизведения (Play Audio MSF). С кнопки на морде при этом играет тот же диск спокойно. Не заработал совсем.
-
Teac CD-516E (04/1997) — не имеет SPDIF, не умеет работать асинхронно: если во время раскрутки шпинделя при первом чтении запросить тип диска, шпиндель остановится, а хост получит ответ MTC=TRAY_CLOSED_UNKNOWN. Если после этого не дать ему «раздуплиться» хотя бы 2-3 секунды и продолжить опрашивать, диск не будет прочитан никогда.
-
LG Super Multi GSA-4163B (11/2004) — не имеет SPDIF, не передаёт Media Type Code. Если посылать команды игнорируя type code, как на NEC, то в ответ приходит какая-то абсолютная дичь.
Остаётся только позавидовать терпению и усидчивости тех программистов, которые поддерживают драйверы CD-приводов во «взрослых» системах, типа Windows и Linux. Да и откуда во всяких документах для разработчиков ОС встречаются перлы типа «если работаем с CD-ROM, то читаем шину всегда от 35 до 60 раз подряд не трогая строб и принимаем за истину лишь последнее прочитанное значение» тоже становится сильно понятнее.
❯ Бонусом — Блютус и интернет-радио
Особо прозорливые могли заметить, что I2S-шина идёт на ЦАП не только с CD-привода, но и с самого ESP32 :-) Ибо и правда, раз уж такой мощный чип туда вставлять, то зачем его использовать лишь для рисования на дисплее и опроса IDE-шины пару раз в секунду.
Чтобы упихнуть одновременно и классический блютус, и вайфай, пришлось перевести проект на ESP-IDF — иначе заканчивалась память IRAM, в которую размещается часто вызываемый код, ещё на стадии сборки. Благо, для радио в лучшем случае нужно 320 килобит в секунду, поэтому часть драйвера можно было штатным способом вынести в чуть более медленную DRAM.
❯ Приём аудио по A2DP
К счастью, задача для поделок на ESP32 типовая до безобразия. Штатный SDK предоставляет лишь кодек SBC, но для большинства вещей, которые можно послушать с телефона, и его за глаза.
До кучи, есть очень удобная завёртка штатного API в один удобный класс: ESP32-A2DP. Она предоставляет и саму передачу звука с A2DP в I2S, и удобные методы для запроса пин-кода сопряжения, и всё, что надо для работы с медиаклавишами и метаданными по AVRC.
В итоге задача настолько просто решается, что проще привести здесь код всего режима целиком, чем описывать принцип его работы:
Меньше 100 строк на приём звука по блютусу
BluetoothMode::BluetoothMode(const PlatformSharedResources res, ModeHost * host):
a2dp(*res.router->get_output_port()),
stopEject(res.keypad, (1 << 0)),
playPause(res.keypad, (1 << 1)),
prev(res.keypad, (1 << 4)),
next(res.keypad, (1 << 5)),
Mode(res, host) {
rootView = new BluetoothView({{0, 0}, {160, 32}});
_that = this;
}
void BluetoothMode::setup() {
rootView->set_disconnected();
AudioToolsLogger.begin(Serial, AudioToolsLogLevel::Warning);
// отключаем вход звука с сидюка и вместо него используем внутренний
resources.router->activate_route(Platform::AudioRoute::ROUTE_INTERNAL_CPU);
Core::Services::WLAN::stop(); // лучше отключить вайфай, чтобы не мешал, ибо радиомодуль у нас один
a2dp.set_avrc_metadata_attribute_mask(ESP_AVRC_MD_ATTR_TITLE | ESP_AVRC_MD_ATTR_ARTIST);
a2dp.set_avrc_metadata_callback(avrc_metadata_callback);
a2dp.set_avrc_rn_playstatus_callback(avrc_rn_playstatus_callback);
a2dp.activate_pin_code(true);
a2dp.start(("ESPer-CDP_" + Core::Services::WLAN::chip_id()).c_str(), true);
a2dp.set_discoverability(esp_bt_discovery_mode_t::ESP_BT_GENERAL_DISCOVERABLE);
}
void BluetoothMode::loop() {
if(a2dp.is_connected()) {
rootView->set_connected(a2dp.get_peer_name()); // обновляем имя устройства
// обрабатываем медиаклавиши
if(playPause.is_clicked()) {
if(cur_sts == ESP_AVRC_PLAYBACK_PLAYING || cur_sts == ESP_AVRC_PLAYBACK_FWD_SEEK || cur_sts == ESP_AVRC_PLAYBACK_REV_SEEK) {
a2dp.pause();
}
else {
a2dp.play();
}
}
else if(next.is_clicked()) a2dp.next();
else if(prev.is_clicked()) a2dp.previous();
else if(stopEject.is_clicked()) a2dp.stop();
} else {
if(a2dp.pin_code() != 0) { // показать пин-код сопряжения, если есть
rootView->set_pairing(a2dp.pin_code());
// если нажата кнопка, то подтвердить сопряжение
if(playPause.is_clicked()) {
rootView->set_wait();
a2dp.confirm_pin_code();
delay(1000);
}
}
else {
rootView->set_disconnected();
}
}
delay(125);
}
void BluetoothMode::play_status_callback(esp_avrc_playback_stat_t sts) {
cur_sts = sts;
rootView->set_playing(cur_sts == ESP_AVRC_PLAYBACK_PLAYING || cur_sts == ESP_AVRC_PLAYBACK_FWD_SEEK || cur_sts == ESP_AVRC_PLAYBACK_REV_SEEK);
}
void BluetoothMode::metadata_callback(uint8_t id, const char *text) {
rootView->update_metadata(text, (esp_avrc_md_attr_mask_t) id);
}
void BluetoothMode::teardown() {
rootView->set_wait();
if(a2dp.is_connected()) {
a2dp.disconnect();
}
a2dp.end();
resources.router->activate_route(Platform::AudioRoute::ROUTE_NONE_INACTIVE);
// a2dp.end(true) бы грохнул нам ещё и драйверы, а мы хотим иметь возможность перезапустить блютус по новой
esp_bluedroid_disable();
esp_bt_controller_disable();
Core::Services::WLAN::start();
}
BluetoothMode::~BluetoothMode() {
_that = nullptr;
delete rootView;
}
UI::View& BluetoothMode::main_view() {
return *rootView;
}
❯ Интернет-радио
Радиоприёмники на ESP32 тоже делают все, кому не лень, и даже с программным декодированием чип справляется на ура. Библиотека для блютуса уже тянет за собой такой удобный фреймворк, как Arduino Audio Tools, посему я и решил воспользоваться им для радио в том числе. Благо, даже пример есть и выглядит всё очень просто:
ICYStream urlStream(wifi, password);
AudioSourceURL source(urlStream, urls, "audio/mp3");
I2SStream i2s;
MP3DecoderHelix decoder;
AudioPlayer player(source, i2s, decoder);
void printMetaData(MetaDataType type, const char* str, int len){
Serial.print("==> ");
Serial.print(toStr(type));
Serial.print(": ");
Serial.println(str);
}
void setup() {
Serial.begin(115200);
AudioToolsLogger.begin(Serial, AudioToolsLogLevel::Info);
// setup output
auto cfg = i2s.defaultConfig(TX_MODE);
i2s.begin(cfg);
// setup player
player.setMetadataCallback(printMetaData);
player.begin();
}
void loop() {
player.copy();
}
Вот тут-то и началось то самое пресловутое приключение на двадцать пять минут, по итогу сожравшее недельку из жизни у меня и у автора библиотеки, который помогал по доброте душевной задачу мучать.
Сначала вылезла задача подключать декодер AAC либо MP3, в зависимости от того, в чём вещает станция. Автор говорит — не беда, у меня уже есть класс MIMEDetector, который определяет по содержимому потока формат. Всё идёт нормально, пока я не решаю послушать уфимский Relax FM, и всё разваливается со спецэффектами. В итоге оказывается, что их поток прикидывается mp3 и отдаёт соответствующий контейнер, но внутри чанки в формате AAC!
После этого выяснилось, что у автора-то на большинстве радиостанций всё работает идеально, а у меня — каждые несколько секунд затыкается. Не то интернет у него сильно шустрее, не то ESP32 собрана получше, в отличие от моей с Таобао — но ему 1024 байт буфера, которые по всему пайплайну воспроизведения гонялись, хватало, а мне вообще никак.
В итоге родилась дичайшая махина говнокода, ни разу не похожая на простейший пример из библиотеки:
-
Запускаем HTTP-запрос, при этом требуем HTTP v1.0, так как некоторые станции почему-то не очень дружат с парсером библиотеки при `Transfer-Encoding: chunked`
-
Получаем MIME из заголовка Content-Type, чтобы избежать всратых потоков с нестандартными комбинациями контейнеров и кодеков
-
Подписываем ЦАП на изменения частоты дискретизации выходящих из кодека данных
-
Буферизируем 128 килобайт сжатых данных в медленную оперативу (PSRAM), но при этом выделяем ещё столько же запаса, если вдруг сервер нам шлёт данные быстрее реального времени, или кодек не успевает декодировать данные
-
Из медленной оперативы декодируем сжатые данные в PCM и помещаем их в 8КБ буфер в быстрой (DRAM), из которого уже пачками по 1КБ перебрасываем уже в более тесную IRAM, откуда их забирает по DMA драйвер I2S и отдаёт на ЦАП
Всё это работает в три потока, аккуратно раскиданных по ядрам: переброска DRAM->IRAM — с приоритетом пониже и на том же ядре, что и UI и иже с ними; декодирование и приём из сети — с приоритетами повыше, но не выше чем у драйвера вайфая и TCP/IP-стека, и на отдельном, выделенном чисто на них ядре. Всё это обильно сдобрено delay'ами, чтобы хоть как-то давать операционке переключаться между потоками и обеспечивать стабильный приток данных, и завёрнуто в разного сорта таймауты, чтобы в случае «завеса» сетевого потока перезапускать его автоматом.
Выглядит ужасно, читается тоже, и скорее всего потребует переписывания в скором времени — но играет чуть ли не стабильнее, чем fb2k на моём ноутбуке в той же комнате.
❯ Демонстрация
Целиком текущую функциональность постарался показать на видео:
❯ Дальнейшие планы на проект
Как известно, пет-проекты — как ремонт: совершенству нет предела, и доделать никак его нельзя :-) Но лично для себя я определил краткий план развития проекта так:
-
OTA-обновления прошивки и сборка её в CI — как уже сделано в PIS-OS
-
Передача с CD/радио назад на блютус, например, на наушники?
-
Корпус, конечно же, и покрасивее!
-
Сделать вторую ревизию с быстрой шиной и читать данные напрямую? (помимо отсутствия требования наличия Digital Interface, в теории это даст ещё и возможность использовать SATA-приводы через переходник)
-
Ну и в таком случае и плеер MP3/MOD/XM/IT/S3M уже сам собой напрашивается — благо файловую систему спарсить частично получается, но вот скорость чуть ниже среднего флопика в былые времена не позволяет воспользоваться этим на полную.
Заголовок ISO9660 нашёлся, но треть секунды на его чтение — это уж совсем безбашенно
-
Ну а сделаю я это всё или нет — вы сможете увидеть в реальном времени в моём дневнике лунного скитальца в телеграме :-)
❯ Ссылки и использованные материалы
-
Проект на гитхабе: схемы и платы (формат Diptrace) — перед сборкой обязательно читайте errata, нужна перемычка на плате для работы I2S-интерфейса к ESP32!!, тестовая болванка с сигналами и CUE, исходный код прошивки
-
Свалка PDF со стандартами, ибо хрен их найти
-
Поиск метаданных
-
Плагин к fb2k для поиска всякого караоке: https://github.com/jacquesh/foo_openlyrics/tree/main/src/sources
-
Прочие похожие проекты
-
daniel1111/ArduinoCdPlayer: то же самое, но читабельное
Автор: vladkorotnev