Bluetooth-проигрыватель на ESP32
Bluetooth‑проигрыватель на ESP32

На моем шкафу несколько лет пылились две замечательные пятидесятиватные колонки Yamaha, которым я не мог найти применение. А в ящике стола лежала плата на базе модуля ESP32 с такой же проблемой. Я решил немного скрасить свой досуг и собрать своими руками Bluetooth‑проигрыватель для этих колонок. Конечно можно было купить готовый, это было бы экономически целесообразней да и быстрее, но уже не так интересно.

Забегая вперед сразу покажу что получилось:

Bluetooth‑проигрыватель на ESP32 вместе с колонками
Bluetooth‑проигрыватель на ESP32 вместе с колонками
Bluetooth‑проигрыватель на ESP32 (экран)
Bluetooth‑проигрыватель на ESP32 (экран)

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

Bluetooth подключается автоматически при включении проигрывателя. Управление громкостью и воспроизведением с телефона также работает.

Далее расскажу все этапы производства этого устройства своими руками.

Компоненты

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

Электронные компоненты:

  • Модуль ESP32 WROOM-32

  • Модуль ЦАП (DAC) GY‑PCM5102 с интерфейсом I2S

  • Стерео усилитель звука XH‑A232

  • Модуль поворотного энкодера HW-040 (KY-040)

  • OLED дисплей 0.96" 128×64, I2C

  • Печатная макетная плата

  • Два керамических конденсатора 0.1мкФ (маркировка 104)

  • Два резистора 100Ом

  • Два резистора 680Ом

Питание и выводы:

  • Блок питания 12V для светодиодной ленты и светильников, 5А

  • Понижающий DC‑DC преобразователь XL4015E

  • Разъем сетевой С8 на корпус с выключателем

  • Кабель питания C8

  • Колодка акустическая

Корпус:

  • Катушка PLA‑пластика

  • Крепежные втулки M2

  • Крепежные втулки M3

  • Винты M2

  • Винты M3

  • Прозрачный ПЭТ‑пластик для экрана

  • Металлическая сетка

Сборка электроники

Компоненты я спаял по схеме:

Схема электроники для Bluetooth‑проигрывателя на ESP32
Схема электроники для Bluetooth‑проигрывателя на ESP32

В живую первый варинт сборки выглядел так:

Первый вариант сборки
Первый вариант сборки

Центральным компонентом устройства выступает модуль ESP32 WROOM-32. В нём уже есть всё необходимое для Bluetooth‑подключения и управления периферией. К модулю подключён ЦАП (DAC) GY‑PCM5102. На него приходит сигнал по интерфейсу I2S (не путать с I2C), а выходит аналоговый звуковой сигнал на усилитель.

Между ESP32 и ЦАП можно обнаружить делитель напряжения. Он нужен для того, чтобы понизить среднеквадратичное значение напряжения, выходящего с ЦАП, с 2,1vrms до 0,27vrms. Если не понижать, то усилитель начнёт захлёбываться и издавать ужасные шумы. Коэффициент делителя подобран опытным путём. В вашем случае он может быть другим даже с такой же моделью усилителя (поскольку дешёвые железки не отличаются точностью и надёжностью).

Кстати, об усилителе XH‑A232. Я его взял просто потому, что он у меня уже был. Его пиковая мощность — 30 ватт на канал, в то время как номинальная мощность колонок — 50 ватт. Но для моих целей он подошёл, громкость звука меня устраивает, поэтому я оставил его.

Подключение энкодера и OLED‑экрана не должно вызвать вопросов — оно стандартное, как написано в сотнях мануалов к ним.

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

А вот если вы заметили на схеме проблему с питанием, то вы молодец. Если питать ЦАП и управляющую плату одним источником питания внутри одной цепи, то прослушивание музыки будет сопровождаться цифровыми шумами. В идеальном случае нужно питать ЦАП и ESP32 независимо, но, к сожалению, мы в реальном мире, и об этой проблеме я подумал уже тогда, когда обнаружил её акустически. Пришлось искать обходной путь решения проблемы.

Вся схема питается от блока на 12 вольт. Усилитель подключён к нему напрямую, а ESP32 — через понижающий преобразователь. ESP32 питается от 3,3 вольт. Если подать напряжение больше на соответствующий порт питания, модуль сгорит. Для ЦАП требование к входному напряжению не так критично: он работает в диапазоне 3,3–5 вольт. Изначально я настроил преобразователь на требуемые 3,3 вольта и подал питание и на ЦАП, и на ESP32 одновременно, запустил музыку и услышал шум.

На плате WROOM также есть встроенный понижающий преобразователь напряжения. Можно подать на его вход напряжение значительно выше 3,3 вольт — он понизит до нужного уровня сам. Я подал 3,3 вольта туда — и ничего не заработало. Повысил до 3,65 — плата запустилась. И поскольку теперь ЦАП питается от одного понижающего преобразователя, а ESP32 — фактически от другого, шумы исчезли.

Напряжение после понижающего преобразователя
Напряжение после понижающего преобразователя

Вопреки расхожему мнению, что на GPIO‑порты ESP32 нельзя подавать больше 3,3 вольт, скажу: в даташите об этом явно нигде не написано. Сказано, что нельзя подавать питание на сам модуль выше 3,3 вольт. GPIO нормально работают и в пятивольтовом режиме, как и остальные компоненты системы. Однако я решил ограничиться минимально необходимым, поэтому вся цепь у меня работает на напряжении 3,65 вольт (кроме усилителя, там 12 вольт).

В процессе сборки важно не забыть запаять перемычки на плате ЦАП. Они нужны, чтобы устройство работало в нужном нам режиме.

Схема пайки перемычек на плате модуля GY-PCM5102
Схема пайки перемычек на плате модуля GY‑PCM5102

Между ЦАП и усилителем провода должны быть короткими и их нужно скрутить, это тоже влияет на шумы.

Скрученные провода от ЦАП к усилителю
Скрученные провода от ЦАП к усилителю

Ну и напоследок: я поставил два керамических конденсатора по 0,1 мкФ после ЦАП — опять же из‑за шумов. Это я сделал скорее уже для собственного спокойствия, по совету из интернета. По факту шумов на этом этапе я уже не слышал ухом; возможно, их можно было бы увидеть осциллографом, но для моей задачи это избыточно.

В остальном сборка не должна вызывать вопросов.

Прошивка

Весь код проекта я залил на GitHub: https://github.com/IvanZhuck/esp32-iz‑music

Решать низкоуровневые задачи по передаче звука по Bluetooth не придётся — за нас уже разработали протокол A2DP и реализовали его в качестве библиотеки для Arduino: https://github.com/pschatzmann/ESP32-A2DP

Остаётся только правильно ей воспользоваться — и всё будет готово. Я не буду расписывать весь код целиком, остановлюсь лишь на основных моментах.

В качестве фреймворка для разработки я использовал PlatformIO.

Содержимое файла конфигурации (platformio.ini):

[env:esp32dev]
platform = espressif32
board = esp32dev
framework = arduino
monitor_speed = 115200
lib_deps = 
	adafruit/Adafruit SSD1306@^2.5.16
	https://github.com/pschatzmann/ESP32-A2DP
	https://github.com/pschatzmann/arduino-audio-tools.git
	maffooclock/ESP32RotaryEncoder@^1.2.0
board_build.partitions = huge_app.csv
monitor_filters = esp32_exception_decoder

Как видно из зависимостей, используются следующие библиотеки:

  • pschatzmann/arduino‑audio‑tools — библиотека для работы с аудио, нужна в том числе для управления ЦАП;

  • pschatzmann/ESP32-A2DP — реализация A2DP, как сказано выше. По сути, это дополнение к arduino‑audio‑tools от того же автора;

  • adafruit/Adafruit — нужна для работы с OLED‑экраном;

  • maffooclock/ESP32RotaryEncoder — библиотека для взаимодействия с энкодером.

Чтобы устройство минимально работало, нам нужно инициализировать A2DP:

BluetoothA2DPSink a2dpSink;
...
void setup() {
    ...
    a2dpSink.set_auto_reconnect(true, 1000);
    a2dpSink.set_stream_reader(a2dpDataCallback, false);
    a2dpSink.set_raw_stream_reader(a2dpRawDataCallback);
    a2dpSink.set_on_connection_state_changed(a2dpConnectionStateChanged);
    a2dpSink.set_on_audio_state_changed(a2dpAudioStateChanged);
    a2dpSink.start("IZMusic");
    ...
}

Стоит обратить внимание на функцию обратного вызова a2dpDataCallback. Она вызывается, каждый раз, когда по Bluetooth прилетает новая порция аудио‑данных:

constexpr int MIXER_BUFFER_MAX_SIZE = 5000;

SineWaveGenerator<int16_t> sineWave(500);
GeneratedSoundStream<int16_t> generatedSineSound(sineWave);

uint8_t mixerBuffer[MIXER_BUFFER_MAX_SIZE];

... 
  
void a2dpDataCallback(const uint8_t* data, uint32_t length) {
    generatedSineSound.readBytes(mixerBuffer, length);

    outputMixer.write(mixerBuffer, length);
    outputMixer.write(data, length);
}

...
  
void setup() {
      ...
      sineWave.begin(sineWaveConfig, 3.0f);
      ...
}

Из текста функции можно понять, что она зачем‑то подмешивает сгенерированную тут же низкочастотную волну в 3 герца в outputMixer.

outputMixer — это поток звуковых данных, который дальше отправится в ЦАП.

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

И раз уж мы увидели outputMixer, давайте посмотрим, из чего он состоит:

I2SStream i2sStream;

VolumeStream volumeStream(i2sStream);
Equalizer3Bands equalizerStream(volumeStream);

...

OutputMixer<int16_t> outputMixer(equalizerStream, 2);

void setup() {
    auto i2sConfig = i2sStream.defaultConfig();
    i2sConfig.sample_rate = 44100;
    i2sConfig.buffer_count = 6;
    i2sConfig.buffer_size = 512;
    i2sConfig.pin_bck = I2S_PIN_BCK;
    i2sConfig.pin_ws = I2S_PIN_WS;
    i2sConfig.pin_data = I2S_PIN_DATA;
    i2sStream.begin(i2sConfig);

    auto equalizerConfig = equalizerStream.defaultConfig();
    equalizerConfig.copyFrom(i2sConfig);
    equalizerConfig.gain_low = 1.8;
    equalizerConfig.gain_medium = 0.7;
    equalizerConfig.gain_high = 0.5;
    equalizerStream.begin(equalizerConfig);

    auto volumeConfig = volumeStream.defaultConfig();
    volumeConfig.copyFrom(i2sConfig);
    volumeStream.begin(volumeConfig);

    outputMixer.begin(MIXER_BUFFER_MAX_SIZE);
}

Цифровое аудио, полученное по Bluetooth, проходит ряд модификаций, прежде чем попасть в ЦАП. Данные передаются по цепочке:

Bluetooth → outputMixer → equalizerStream → volumeStream → i2sStream

Из названий можно догадаться, что equalizerStream — это эквалайзер, а volumeStream — это уровень громкости.

Эквалайзер мы настраиваем под конкретный усилитель. Я поднял низкие частоты и убрал верхние, чтобы компенсировать писклявость моего усилителя и получить более глубокий бас.

Громкостью volumeStream мы будем управлять с помощью энкодера. Класс управления громкостью находится в lib/SoundVolume/src.

Класс очень простой, не думаю, что требует пояснений.

В финале звук попадает в i2sStream — этот поток уже отправляется в ЦАП, где превращается в аналоговый звук.

На этом работа со звуком окончена. Из оставшихся частей интерес представляет, пожалуй, только визуализатор.

Как видно из кода выше, a2dpSink дёргает ещё одну функцию обратного вызова в процессе работы:

AudioRealFFT fft;

...

void a2dpRawDataCallback(const uint8_t* data, uint32_t length) {
    fft.write(data, length);
}

...

void setup() {
    ...
    a2dpSink.set_raw_stream_reader(a2dpRawDataCallback);
    ...
}

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

Класс AudioRealFFT отвечает за быстрое преобразование Фурье — алгоритм, необходимый для того, чтобы разделить звуковую волну на спектр частот. Очень доступно принцип работы этого алгоритма рассматривается в этом ролике: https://www.youtube.com/watch?v=9bqrTYCS6DQ

Спектр частот — это буквально те колонки визуализатора, что нам нужны. Единственное, что частота разбиения выше, чем нужно: 512 диапазонов частот на оба канала (у нас стереозвук), или 256 на каждый. Нам потребуется получить из этого 16 колонок. И, казалось бы, всё просто: делим 256 на 16 и рисуем 16 колонок.

Но проблема заключается в том, что мы слышим звук в диапазоне 20–22000 Гц, и частота дискретизации каждого канала по этой же причине — 22 000 Гц, но наиболее интересующие нас частоты находятся в диапазоне 100–5000 Гц. Именно в этом диапазоне играет большинство музыкальных инструментов и находится человеческий голос, следовательно, при визуализации стоит уделить этим частотам (колонкам) больше внимания, чем прочим.

Я не дружу с математикой и логарифмическими функциями, поэтому просто прикинул на калькуляторе, какой диапазон спектра частот какой колонке визуализатора соответствует:

/// Разделение спектра частот на 16 полос для далтнейшей визуализации
uint8_t m_visualizerDataRanges[DISPLAY_VISUALIZER_COLS][2] = {
    {0,0}, //до 86,13 Гц
    {0,1}, //до 172,27 Гц
    {1,2}, //до 258,4 Гц
    {2,3}, //до 344,53 Гц
    {3,5}, //до 516,8 Гц
    {5,7}, //до 689,06 Гц
    {7,9}, //до 861,33 Гц
    {9,11}, //до 1033,59 Гц
    {11,17}, //до 1550,39 Гц
    {17,23}, //до 2067,19 Гц
    {23,31}, //до 2756,25 Гц
    {31,45}, //до 3962,1 Гц
    {45,64}, //до 5598,63 Гц
    {64,88}, //до 7665,82 Гц
    {88,120}, //до 10422,07 Гц
    {120,255} //до 22050 Гц
};

Исходя из этого, написал функции для рассчета высот колонок визуализатора:

void Display::fillVisualizerDataRawBuffer() {
    float* magnitudes = m_fft.magnitudes();
    uint8_t percent = 0;

    for (int i = 0; i < m_fft.config().length / 2; i++) {
        /**
         * Значения амплитуд изменяются от 0 до 3-6 в зависимости от громкости звука.
         * Но бывают выпады и выше. Коэффециент 0.05 подобран "на глаз". Чтобы примерно загнать
         * амплитуду в процентный диапазон от 0 до 100.
         */
        percent = std::min<uint8_t>(static_cast<uint8_t>(magnitudes[i] / 0.05), 100);
        m_visualizerDataRawBuffer[i] = static_cast<uint8_t>(DISPLAY_VISUALIZER_ROWS * 0.01 * percent);
    }
}

void Display::computeVisualizerData() {
    for (uint8_t i = 0; i < DISPLAY_VISUALIZER_COLS; i++) {
        unsigned int totalValue = 0;
        uint8_t length = 0;

        for (uint16_t j = m_visualizerDataRanges[i][0]; j < m_visualizerDataRanges[i][1] + 1; j++) {
            if (m_visualizerDataRawBuffer[j] < 10) {
                continue;
            }

            totalValue += m_visualizerDataRawBuffer[j];
            length++;
        }

        length = std::max<uint8_t>(length, 1);

        /*
         * Для визуализации используется среднее от суммы текущего значения и предыдущего.
         * Это нужно для плавной анимации колонок визуализатора.
         */
        m_visualizerData[i] = static_cast<uint8_t>((totalValue / length + m_visualizerData[i]) / 2);
    }
}

Этот код можно найти в lib/Display/src. Важные моменты уже прокомментированы.

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

Корпус

Хочется, чтобы устройство не выглядело как груда проводов, прибитых к макетной плате.Я спроектировал корпус в программе «КОМПАС-3D» и распечатал все его части на 3D‑принтере из PLA‑пластика.

Сборка корпуса в  «КОМПАС-3D»
Сборка корпуса в «КОМПАС-3D»
Печать деталей корпуса на 3D-принтере
Печать деталей корпуса на 3D‑принтере
Напечатанный корпус
Напечатанный корпус

Сборка

Когда все детали были у меня на руках, а работа устройства отлажена, я приступил к сборке. Сначала впаял втулки в места, куда будут вкручиваться крепежные винты:

Пайка втулок
Пайка втулок

Затем установил все компоненты внутрь корпуса:

Компоненты установлены на свои места и закреплены
Компоненты установлены на свои места и закреплены

Вклеил кусок прозрачного ПЭТ‑пластика, он выполняет роль защитного экрана:

Экран из ПЭТ-пластика
Экран из ПЭТ‑пластика

Установил OLED‑дисплей и проклеил его черной изолентой по контуру, чтобы в зазоры между ним и корпусом проигрывателя, не попадал свет и конструкция выглядела монолитной снаружи:

Установка дисплея
Установка дисплея

Приклеил решетку с помощью термоклея:

Установка решетки на корпус
Установка решетки на корпус

Подключил шлейфы энкодера/дисплея и закрыл крышку:

Финальный этап сборки
Финальный этап сборки

Устройство готово:

Bluetooth‑проигрыватель на ESP32 с внешним ЦАП
Bluetooth‑проигрыватель на ESP32 с внешним ЦАП

В заключении

Проигрыватель стоит у меня в мастерской и радует меня звуком. На весь проект у меня ушло около трёх недель в формате «по вечерам и выходным». Этот проект я делал в образовательных целях и не планирую его развивать. Не уверен, что лицензия домашней версии «КОМПАС-3D» предусматривает публикацию моделей в общий доступ, поэтому их здесь нет. Но если они вам нужны, то со мной можно связаться по контактам из профиля.

Буду рад комментариям, отзывам и предложениям. Спасибо, что прочитали статью.

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


  1. KonstLedCloud
    29.07.2026 05:46

    Когда на телефон идёт входящий звонок, он уходит на ESP?


    1. ivan_zhuck Автор
      29.07.2026 05:46

      На практике я этот сценарий не проверял. Проверю, когда буду рядом с колонками.

      Вообще A2DP — это протокол для потокового аудио. Для реализации режима hands free используется другой протокол. Поэтому, полагаю, что воспроизведение остановится при входящем звонке, а телефон перехватит управление звуком на себя. Либо все сломается, и надо будет исправлять это в прошивке.


  1. Jury_78
    29.07.2026 05:46

    пятидесяти ватные колонки Yamaha

    Из фото понять сложно... Но как то они мелковаты для 50Вт, может это пиковая мощность (PMPO)?


    1. ivan_zhuck Автор
      29.07.2026 05:46

      Модель колонок NS-E55. Номинальная мощность 50Вт, пиковая 150Вт. По крайней мере, если верить наклейке на корпусе.


      1. BSOZ
        29.07.2026 05:46

        50Вт сигнал превратится в 50Вт шума, 150Вт сигнал приведёт к разрушению драйверов. Всё честно. 15Вт будет звучать хорошо.