Всех приветствую, и сегодня мы займемся воспроизведением музыки и сделаем свой собственный мини-аудио-плеер. Конечно же, на базе контроллера STM32, как же без него. Итак, воспроизводить будем файл формата WAV, соответственно нам понадобятся:
- Карта памяти для хранения аудио-файлов. Я буду использовать MicroSD, подключенную к микроконтроллеру по интерфейсу SDIO. Кроме того, для организации работы с файловой системой нужно добавить в проект поддержку FatFs.
- Динамик и аудио-усилитель, в качестве которого возьмем микросхему LM386. А сами данные с контроллера будем выдавать при помощи цифро-аналогового преобразователя (DAC).
- И из периферии нам еще понадобится таймер, но об этом чуть позже.
Схема подключения.
В итоге структурная схема нашего устройства будет выглядеть так:
Я сегодня буду использовать STM32F103VE, но это не особо важно, для другого контроллера последовательность действий, да и код, будут практически идентичны. LM386 подключаем по стандартной схеме:
Обвязка для LM386 стандартная, варианты подключения можно найти в даташите. Сигнал с DAC подаем через потенциометр для того, чтобы иметь возможность регулировать громкость, меняя сопротивление.
Разъем для MicroSD на моей плате подключен так:
SDIO для карты памяти используем в 4-х битном режиме. Питание контроллера, внешний кварцевый резонатор, SWD разъем для программирования - здесь все стандартно.
Так, со схемой закончили, давайте обсудим, как мы будем получать нужные аудио-данные из WAV-файла. Структура файла довольно проста - после заголовка сразу же следуют непосредственно цифровые данные, которые мы можем преобразовывать в аналоговый сигнал при помощи ЦАП и выдавать на динамик.
Можно использовать два варианта представления этих данных - 8-битное и 16-битное. Вспоминаем, что ЦАП у нас 12-ти битный, а значит при использовании 8-битного формата мы не задействуем его возможности в полной мере. Поэтому лучше возьмем WAV-файл с 16-ти битным представлением данных, а в программе масштабируем эти данные до 12-ти бит. Итоговое качество звука при этом будет выше.
В WAV-файле данные хранятся со знаком, то есть для 16 бит мы получаем диапазон значений от -32767 - до 32768. На ЦАП нам необходимо выдавать значения от 0 до 4095 (12 бит). Поэтому сначала преобразуем исходные данные в беззнаковые, добавив 32767:
DAC_{temp} = DAC_{raw} + 32767
Полученные числа (0 - 65535) надо преобразовать в значения от 0 до 4095:
DAC_{final} = DAC_{temp} \medspace \frac{4095}{65535} \approx \frac{DAC_{temp}}{16}
Заголовок WAV-файла может содержать разное количество байт, в зависимости от хранимой в нем дополнительной информации (исполнитель, название и т. д.). Чуть позже, создавая практический пример, разберемся, как определить конец заголовка и, соответственно, начало аудио-данных.
С преобразованием в нужный нам вид разобрались, но открытым остается вопрос, как часто нужно будет изменять сигнал на выходе DAC, с какой периодичностью. И тут все зависит от частоты дискретизации аудио-файла. Будем использовать частоту 16 КГц. Это значит, что именно с такой частотой надо обновлять данные на выходе ЦАП. Рассчитаем период, соответствующий частоте 16 КГц.
T = \frac{1 \medspace с}{16000} = 62.5 \medspace мкс
Получается, что нам нужен таймер, который будет генерировать прерывания каждые 62.5 мкс. И в прерывании мы будем выдавать на модуль DAC новое значение.
Таким образом, при конвертировании, к примеру, mp3 в wav, необходимо выбирать именно эти параметры - 16 бит данных + частота дискретизации 16 КГц.
Создание проекта.
Итак, все готово, переходим к созданию проекта в STM32CubeMx. Первым делом активируем DAC, второй канал, то есть вывод PA5. Настроек минимум:
Сразу же настраиваем таймер и его прерывание:
Частота тактирования TIM6 у меня 72 МГц. Предделитель 180 (179 + 1) дает нам итоговую частоту таймера равную:
f_{timer} = \frac{72 \medspace МГц}{180} = 400 \medspace КГц
То есть один "тик" таймера - 2.5 мкс (такой период соответствует частоте 400 КГц). Значит период таймера в отсчетах должен быть равен:
\frac{62.5 \medspace мкс}{2.5 \medspace мкс} = 25
Это мы и задали в его настройках. Осталось добавить поддержку SD-карты по SDIO и драйвер FatFs:
В новых версиях STM32CubeMx в конфигурации FatFs появилась вкладка Platform Settings. Здесь можно настроить какой-либо из входов контроллера для использования в качестве сигнала, по уровню которого можно отследить подключение и извлечение карты памяти:
В сегодняшнем проекте не будем использовать этот функционал, что приведет к тому, что CubeMx будет выдавать предупреждение:
Игнорируем его и переходим к генерации кода.
Программная реализация.
Все приготовления позади, нам остается только написать код. Начинаем двигаться поэтапно, монтируем файловую систему и открываем аудио-файл. Пусть файл, который мы предварительно закинули на карту памяти, называется audio.wav. Помещаем прямо в функцию main()
до цикла while(1)
:
/* USER CODE BEGIN 2 */ res = f_mount(&fileSystem, SDPath, 1); uint8_t path[10] = "audio.wav"; res = f_open(&audioFile, (char*)path, FA_READ);
Чтобы не нагромождать пример, не будем проверять результат res
выполнения функций FatFs, в "боевом" проекте лучше, конечно, этим не пренебрегать и отслеживать все возможные ошибки.
Следующим шагом нужно определить длину заголовка WAV-файла. Для этого воспользуемся простым механизмом - считываем 512 байт (WAV_BUF_SIZE
) из файла и проверяем последовательно полученные байты. Особенностью заголовка является то, что в его конце всегда расположены символы 'data', после чего следует размер данных (4 байта). И вот после этого уже начинаются именно аудио-данные.
Таким образом, находим позицию в буфере, которая соответствует символу 'd' и прибавляем к этому значению 8 (4 байта для 'data' и 4 байта для размера данных):
uint16_t dataOffset = 0; res = f_read(&audioFile, wavBuf[0], WAV_BUF_SIZE, &readBytes); for (uint16_t i = 0; i < (WAV_BUF_SIZE - 3); i++) { if ((wavBuf[0][i] == 'd') && (wavBuf[0][i + 1] == 'a') && (wavBuf[0][i + 2] == 't') && (wavBuf[0][i + 3] == 'a')) { dataOffset = i + 8; break; } }
Заголовок обнаружен, перемещаем указатель FatFs для работы с файлом на аудио-данные и заодно определяем количество байт данных. Для этого вычитаем из общего размера файла размер заголовка:
res = f_lseek(&audioFile, dataOffset); wavDataSize = f_size(&audioFile) - dataOffset;
И теперь необходимо остановиться на еще одном нюансе... Нам нужно обновлять данные на выходе DAC каждые 62.5 мкс, то есть относительно часто. Чтобы не было задержек, связанных с временем, которое требуется для того, чтобы прочитать очередные 16 бит из wav-файла, нужно организовать буферизацию аудио-данных.
Идея здесь заключается в следующем. Объявим два буфера размером 512 байт, в которые поочередно будем считывать новые данные из файла с карты памяти. Алгоритм будет такой:
- Сначала заполняем оба буфера данными и начинаем воспроизведение (то есть вывод данных на ЦАП) из буфера 1.
- Как только буфер 1 подошел к концу (512 его байт выданы на ЦАП), переходим к выводу данных из буфера 2. И параллельно заполняем буфер 1 новыми данными с карты.
- Когда мы использовали данные буфера 2, переходим снова к буферу 1, а в буфер 2 считываем новую порцию.
То есть переключаемся между этими буферами, и во время активности одного из них заполняем неактивный буфер новыми данными. Реализуем этот механизм и первым делом заполняем оба буфера данными:
res = f_read(&audioFile, wavBuf[0], WAV_BUF_SIZE, &readBytes); res = f_read(&audioFile, wavBuf[1], WAV_BUF_SIZE, &readBytes);
Поскольку данные готовы, спокойно включаем DAC и TIM6 на генерацию прерываний:
HAL_DAC_Start(&hdac, DAC_CHANNEL_2); HAL_TIM_Base_Start_IT(&htim6);
Размер буфера задан в main.h:
/* USER CODE BEGIN Private defines */ #define WAV_BUF_SIZE 512 /* USER CODE END Private defines */
В основном цикле программы занимаемся обновлением данных, то есть чтением их с карты памяти:
if (wavReadFlag == 1) { uint8_t readBufIdx = 0; if (curBufIdx == 0) { readBufIdx = 1; } res = f_read(&audioFile, wavBuf[readBufIdx], WAV_BUF_SIZE, &readBytes); wavReadFlag = 0; }
В переменную wavReadFlag
в прерывании по таймеру будем записывать единицу в том случае, если буфер подошел к концу и его данные можно обновлять. Счетчик curBufIdx
содержит текущий номер активного буфера, то есть того, из которого сейчас идет считывание данных для DAC. Соответственно, это либо значение 0 - для первого буфера, либо 1 - для второго.
Поскольку буфер с индексом curBufIdx
активен, то считывать данные нам напротив нужно в неактивный, номер которого помещаем в readBufIdx
. После этого обнуляем флаг wavReadFlag
.
Кроме того, сразу же в while(1)
помещаем код, который отвечает за окончание воспроизведения, когда файл подошел к концу:
if (stopFlag == 1) { res = f_close(&audioFile); stopFlag = 0; }
В main()
нам в этом случае требуется только закрыть файл. Аналогично, флагом stopFlag
мы управляем из прерывания таймера, к которому и переходим:
void TIM6_IRQHandler(void) { /* USER CODE BEGIN TIM6_IRQn 0 */ /* USER CODE END TIM6_IRQn 0 */ HAL_TIM_IRQHandler(&htim6); /* USER CODE BEGIN TIM6_IRQn 1 */ uint16_t dacData = (((wavBuf[curBufIdx][curBufOffset + 1] << 8) | wavBuf[curBufIdx][curBufOffset]) + 32767); dacData /= 16; HAL_DAC_SetValue(&hdac, DAC_CHANNEL_2, DAC_ALIGN_12B_R, dacData); curBufOffset += 2; curWavIdx += 2; if (curWavIdx >= wavDataSize) { HAL_TIM_Base_Stop_IT(&htim6); stopFlag = 1; } else { if (curBufOffset == WAV_BUF_SIZE) { curBufOffset = 0; if (curBufIdx == 0) { curBufIdx = 1; } else { curBufIdx = 0; } wavReadFlag = 1; } } /* USER CODE END TIM6_IRQn 1 */ }
Здесь мы, во-первых, формируем данные для DAC в точности так, как обсудили в начале статьи, и отправляем их на выход. Также увеличиваем счетчики байт curBufOffset
и curWavIdx
на 2, поскольку одна порция данных у нас равна двум байтам (16 битам):
curBufOffset
- номер текущего байта в буфере, этот счетчик, соответственно, будет изменяться от 0 до 512.curWavIdx
- общий счетчик байт wav-файла. Его мы используем, чтобы обнаружить конец трека и остановить воспроизведение.
В общем-то по окончанию файла отключаем таймер и выставляем флаг stopFlag
в 1:
if (curWavIdx >= wavDataSize) { HAL_TIM_Base_Stop_IT(&htim6); stopFlag = 1; }
Если конец файла еще не достигнут, то проверяем счетчик curBufOffset
. Если значение равно 512 (WAV_BUF_SIZE
), то буфер подошел к концу, а значит надо изменить номер активного буфера и выставить флаг wavReadFlag
:
if (curBufOffset == WAV_BUF_SIZE) { curBufOffset = 0; if (curBufIdx == 0) { curBufIdx = 1; } else { curBufIdx = 0; } wavReadFlag = 1; }
Вот, в принципе, на этом и все. Собираем проект и прошиваем плату, результатом будет звук из динамика 👍 Конечно, качество не будет таким как в серийно выпускающихся проигрывателях, но, в общем-то, довольно-таки годным.
И на этом на сегодня заканчиваем, любые вопросы можно писать в комментарии, либо на форуме, либо в группе ВК, короче, где угодно )
Ссылка на проект - MT_AudioPlayer.
Когда то собирал wav плеер на attiny85, попробуем теперь на stm-ке.
Великолепный урок, закрывающий сразу две не малые области - чтение с SD карты и вывод данных через DAC. Их не так то просто связать, а тут все работает "из коробки"... Лично мне сильно помог.
Отлично, спасибо!
Добрый день. В предыдущих версиях CubeMX максимальный объем карты с которым работала файловая система был не более 4ГБ. У Вас на картинке карточка 128ГБ. Неужели Cube доработали и он теперь может создавать проекты для работы с нормальными объемами карточек?
Да карточку на картинку вставил просто первую попавшуюся... Заменю во избежание недоразумений )
Какая жалость, я было обрадовался, что Cube доработали и теперь можно использовать карточки с нормальным объемом.
Оказывается ничего у них не поменялось.
Я на самом деле не пробовал с большим объемом, может и есть какие-то улучшения в этом плане.
Если время позволит, то я попробую в ближайшем будущем. И тогда отпишусь по результатам. Я слышал, что в Cube библиотека FATFS обновлялась, но не знаю в чем именно. В общем, надо ручками пощупать)))
Вот бы аналогичный проект воспроизведения wav файла с помощью PWM для stm32f103f8, у которого нет на борту DAC (ЦАП)
А чтобы ШИМ использовать на камне где нет ЦАП, что нужно переделать в текущем проекте?
Спасибо!
Добрый день!
Вместо вызовов HAL_DAC_SetValue() надо менять скважность ШИМ.
К сожалению 16 битный (моно) звук не смог вывести через ШИМ, только 8 бит с такой конструкцией: uint16_t dacData = wavBuf[curBufIdx][curBufOffset + 1 ] ;
Вместо HAL_DAC_SetValue(), делал для 16 бит:
__HAL_TIM_SET_COMPARE(&htim2, TIM_CHANNEL_1, dacData);
с 8 бит нормальный результат получился?
RAR архив битый, ошибка открытия.
Через какой браузер? Я попробовал firefox/chrome, winrar - разархивировалось.
Без преувеличения, этот сайт по stm32 и по другим темам - лучший!
Пробовал оцифровывать звук и тут же конвертировать назад в аналог на stm32f407, скорость позволяла работать на 44100.гц
Интересно, можно ли реализовать беспроводные наушники на nrf24l01, с внешними, более "продвинутыми" АЦП и ЦАП.
Благодарю!
Попробовал передачу потокового аудио, вроде получилось, но sample rate максимум удалось сделать 32K. Вопрос: а можно ли передавать по nrf сразу 16 битные переменные? Просто я разделял 12 битные переменные на 2 восьмибитные.
А там как передача построена?
Доброго времени !
А не подскажите, может находили программу которая может "готовить" из wav файлов, файлы для размещения непосредственно в программе микроконтроллера. При выполнении которых контроллер будет выводить звук "говорилки".
По идее, там ничего сложного. Надо считать данные (по одному байту) и объединить их в 16 бит (и преобразовать в 12 бит) )
Добрый день! Сам не искал, но думаю какая-нибудь онлайн утилита точно должна быть. Ну либо по-быстрому свою написать, может так проще всего будет )