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


Играет громко для одной комнаты, несмотря на маломощный усилитель (об этом далее). Можно регулировать уровень звука и ставить на паузу с помощью ручки энкодера. На дисплее отображается визуализация частотного спектра.
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
Прозрачный ПЭТ‑пластик для экрана
Металлическая сетка
Сборка электроники
Компоненты я спаял по схеме:

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

Центральным компонентом устройства выступает модуль 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 вольт).
В процессе сборки важно не забыть запаять перемычки на плате ЦАП. Они нужны, чтобы устройство работало в нужном нам режиме.

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

Ну и напоследок: я поставил два керамических конденсатора по 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‑пластика.



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

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

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

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

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

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

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

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

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

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

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

Jury_78
29.07.2026 05:46Действительно так написано, правда сопротивление 6Ом. Для вашего напряжения будет около 20Вт. Это тоже не мало.

Winnie_The_Pooh
29.07.2026 05:46Мне кажется, оптимально подавать на вход питания есп32 +5 вольт. LDO будет работать в оптимальном режиме. Когда Вы подаете 3.65 на вход LDO - вполне возможна ситуация, когда при пиках потребления или понижении или повышении температуры окружающей среды LDO выйдет из режима стабилизации. Все же перепад в 300 мВ с учетом достаточно шумного питания - на грани...
Рассуждения о необходимости питания ЦАПА с цифровым входом I2S и платы с ЕСП32 от разных питателей - странно звучат для меня. Думаю, достаточно запитать ЦАП от выхода +3.3 ЕСП32 и все будет хорошо. Заодно понизится выходное напряжение и не надо будет ставить делитель на выходе.
Рассуждения о толерантности входов ЕСП32 к 5 вольтам и с указанием, что в мануале нет упоминания об этом - точно неверны. Входы ЕСП32 НЕ толерантны к 5 вольтам. Защитные диоды там есть, но надежная работа в таком режиме не гарантируется.
В вашем случае ВЫХОДЫ ЕСП32 с напряжением макс 3.3 вольта питают ВХОДЫ устройства с более высоким напряжением питания и Вам просто везет, что высокое напряжение на выходе ЕСП32 воспринимается высоким на входе. Это далеко не всегда так. Хотя часто и так.
ivan_zhuck Автор
29.07.2026 05:46Спасибо за комментарий.
Когда Вы подаете 3.65 на вход LDO - вполне возможна ситуация, когда при пиках потребления или понижении или повышении температуры окружающей среды LDO выйдет из режима стабилизации.
Не могу ни подтвердить это ни опровергнуть, но пока все вроде работает. На жару в 30 градусов не выносил, в помещении почти всегда работает кондиционер на комфортных +23 градуса. Но если начнет отваливаться в жару буду знать куда копать.
Думаю, достаточно запитать ЦАП от выхода +3.3 ЕСП32 и все будет хорошо.
Это я пробовал, и при таком подключении ЦАП, шум был максимальный. Я буквально слышал щелчки поворота энкодера и высокочастнотный писк во время обновления картинки на дисплее. Возможно я что-то делал не так конечно.
Рассуждения о необходимости питания ЦАПА с цифровым входом I2S и платы с ЕСП32 от разных питателей - странно звучат для меня.
К этому рассуждению меня подтолкнул комментарий на Reddit: https://www.reddit.com/r/ECE/comments/12jowed/comment/jg00cz8/?utm_source=share&utm_medium=web3x&utm_name=web3xcss&utm_term=1&utm_content=share_button
Именно с этой точки я начал экспериментировать с подачей питания. Возможно, я бы более глубоко вник в этот вопрос, но на тот момент я почти полностью собрал устройство и уже хотел решить проблему хоть как-то.
Входы ЕСП32 НЕ толерантны к 5 вольтам
Информацию про 5 вольт я взял от сюда: https://github.com/bdring/6-Pack_CNC_Controller/issues/31

Winnie_The_Pooh
29.07.2026 05:46Почитал указанные источники, любопытно.
По поводу помех - думаю, что вероятнее всего это наводки по земляным петлям. Возможно, если сделать землю по стандартам "любителей высококачественного звука" - помехи исчезнут. Я имею в виду одна точка заземления и соединения земли звездой.Ну и конечно конденсаторов по питанию побольше, побольше :) и электролитов и керамики.
Насчет 5 в толерантности... в доках Эспрессиф нет прямого указания на таковую возможность. По принятым в этом мире нормам это означает "иногда может быть, но не гарантируется".
Из прочитанного следует, что вроде бы можно использовать выход в режиме открытого стока для коммутации 5 в нагрузки.
Также вроде бы можно использовать вход с последовательным резистором для источника с напряжением 5 в - ограничительный диод на шину питания там есть и он не даст переходу пробиться.
И то и то как бы очевидно, но это совсем не то, что есть полноценная 5 в толерантность.
Т.е. например если соединить пуш-пул выход 5 в драйвера со входом ЕСП32 - ЕСП32 сгорит.И точно так же если включить ЕСП32 - то пин после сброса встанет в режим входа, а на нем висит нагрузка на 5 вольт, но с малым токоограничивающим резистором. Полагаю, вход сгорит...
ЗЫ Недавно отлаживаю некий прибор, в котором сигнал с ЕСП32 идет на конвертор 3.3 -> 12. А ноги конвертора (вход - выход) немного неудачно рядом расположены, типа 3 и 4. И щуп осциллографа соскочил и замкнул 4 и 3 ноги... Есп32 мгновенно испортился. Стал греться немерянно. Пришлось сдуть и заменить :)

MiDoS
29.07.2026 05:46Название трека на экран выводится?

ivan_zhuck Автор
29.07.2026 05:46В моем случае — нет, но это можно добавить всего несколькими строчками кода. Прием мета-данных (и заголовка и времени воспроизведения) уже реализован в библиотеке: https://github.com/pschatzmann/ESP32-A2DP#support-for-metadata
KonstLedCloud
Когда на телефон идёт входящий звонок, он уходит на ESP?
ivan_zhuck Автор
На практике я этот сценарий не проверял. Проверю, когда буду рядом с колонками.
Вообще A2DP — это протокол для потокового аудио. Для реализации режима hands free используется другой протокол. Поэтому, полагаю, что воспроизведение остановится при входящем звонке, а телефон перехватит управление звуком на себя. Либо все сломается, и надо будет исправлять это в прошивке.