Всем привет, дорогие читатели! Расскажу вам о том как сделать интернет-радио на «скорую руку» без особых хлопот.
Скрытый текст
Статья не претендует на новизну, это еще один рецепт для создания интернет-радио на базе ESP32 S3
В интернете есть авторитетные проекты по созданию интернет-радио, которые проверены временем и пользуются спросом у многих энтузиастов. Например, я приглядывался к проекту Ka-Radio32 https://github.com/karawin/Ka-Radio32. Было желание собрать устройство быстро на всем готовом, но при этом оставить за собой возможность внесения изменений в программную часть. Посмотрел код готовых проектов-понял, что вникать в чужой код буду долго, а мне на первое время достаточно лишь обеспечить прием потока радиостанции, преобразование и выход на динамики. Кажется, что это скромная функциональность может быть обеспечена в один ino файл. Я подобрал необходимые компоненты проекта на одном из маркетплейсов, сделал заказ, вооружился ИИ Deepseek, Cursor и начал потихоньку накидывать ТЗ.
Необходимые компоненты
Для обеспечения минимального набора интернет-радио с возможностью воспроизведения звука понадобятся:
ESP32S3 N16R8, у меня уже был такой контроллер (но это оверхэд для радио в такой конфигурации проекта).
DAC1334, АЦП – позволяет работать с потоком mp3. Купил на маркетплейсе.
Усилитель звука: здесь я решил использоватькомпьютерный саундбар, тут уже есть усилитель и динамики, да и корпус под интернет-радио подбирать отдельно не придется – вполне удобно и быстро.
Провода dupont для сбора макета и отладки.
Опционально OLED дисплей, я выбрал OLED дисплей 0.96 I2C SSD1306.
Хороший блок питания, я взял от телефона 100Вт, запитал от него и усилитель от саундбара и ESP32. Громоздко, непрактично, с питанием надо разобраться. Но заметил, что все работает с этим блоком питания без перезагрузок ESP32. Прошу дать рекомендации по питанию в комментариях - как лучше одновременно запитать и ESP32 и усилитель от саундбара.
Разветвитель питания USB, чтобы запитать ESP32 и усилитель. Уже такой интерфейс валялся в хозяйстве и вот нашел применение. Для быстрой реализации подойдет, но не эстетично, провода лишние торчат.
Макет и подключение контактов

Подключение ESP32S3 и UDA 1334
ESP32S3 N16R8 | UDA1334 |
41 | BCLK |
42 | WSEL |
45 | DIN |
GND | GND |
3v3 | 3v3 |
Подключение ESP32S3 и дисплея 0.96 I2C SSD1306
ESP32S3 | 0.96 I2C SSD1306 |
SDA | 21 |
SCL | 20 |
GND | GND |
3v3 | 3v3 |
Код
#include <WiFi.h> #include <HTTPClient.h> #include <WebServer.h> #include "driver/i2s.h" #include "Audio.h" #include <Wire.h> #include <Adafruit_GFX.h> #include <Adafruit_SSD1306.h> // WiFi настройки const char* ssid = "ssid"; const char* password = "password"; // Веб-сервер на порту 80 WebServer server(80); // I2S конфигурация для UDA1334 (ESP32-S3 N16R8) #define I2S_BCLK 41 #define I2S_LRC 42 #define I2S_DOUT 45 // I2C конфигурация для OLED дисплея // Подключение дисплея: // GND -> GND // VDD -> 3.3V // SDA -> GPIO 21 // SCL -> GPIO 22 #define I2C_SDA 21 #define I2C_SCL 20 #define SCREEN_WIDTH 128 #define SCREEN_HEIGHT 64 #define OLED_RESET -1 #define SCREEN_ADDRESS 0x3C // Объект дисплея Adafruit_SSD1306 display(SCREEN_WIDTH, SCREEN_HEIGHT, &Wire, OLED_RESET); // Структура для радиостанций struct RadioStation { const char* name; const char* url; }; // Список радиостанций const RadioStation radioStations[] = { {"RSJ", "http://stream.srg-ssr.ch/m/rsj/mp3_128"}, {"Revma", "https://stream.revma.ihrhls.com/zc238"}, {"Jazz Stream", "http://jazz.streamr.ru/jazz-128.mp3"}, {"Paris Jazz", "http://parisjazz.ice.infomaniak.ch/parisjazz-high.mp3"}, {"Smooth Jazz", "http://smoothjazz.cdnstream1.com/2619_128.mp3"}, {"Jazz WR06", "http://jazz-wr06.ice.infomaniak.ch/jazz-wr06-128.mp3"}, {"Jazz WR07", "http://jazz-wr07.ice.infomaniak.ch/jazz-wr07-128.mp3"}, {"Jazz WR10", "http://jazz-wr10.ice.infomaniak.ch/jazz-wr10-128.mp3"} }; const int radioStationsCount = sizeof(radioStations) / sizeof(radioStations[0]); // Текущая радиостанция int currentStationIndex = 0; const char* radioUrl; String customStationName = ""; // Название пользовательской станции String customStationUrl = ""; // URL пользовательской станции bool isCustomStation = false; // Флаг пользовательской станции // Аудио объект Audio audio; // Переменные состояния bool isConnected = false; unsigned long lastStatusCheck = 0; int currentVolume = 18; // Текущая громкость unsigned long lastDisplayUpdate = 0; int scrollPosition = 0; // Позиция скролла для бегущей строки unsigned long lastScrollUpdate = 0; // Время последнего обновления скролла // Функция для установки громкости с обновлением дисплея void setVolume(int volume) { if (volume < 0) volume = 0; if (volume > 21) volume = 21; currentVolume = volume; audio.setVolume(volume); updateDisplay(); } // Функция для обновления дисплея void updateDisplay() { display.clearDisplay(); // Проверка статуса WiFi bool wifiConnected = (WiFi.status() == WL_CONNECTED); // Заголовок - показываем "Odnolcom" если WiFi подключен, иначе "OFFLINE" display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 0); if (wifiConnected) { display.println("ONLINE"); } else { display.println("OFFLINE"); } // Разделительная линия display.drawLine(0, 10, 128, 10, SSD1306_WHITE); // Название радиостанции с бегущей строкой display.setTextSize(2); String stationName; if (isCustomStation) { stationName = customStationName.length() > 0 ? customStationName : "Custom"; } else { stationName = radioStations[currentStationIndex].name; } // Вычисляем ширину текста (примерно 12 пикселей на символ для размера 2) int textWidth = stationName.length() * 12; int maxWidth = 128; // Ширина экрана // Если текст не помещается, делаем бегущую строку if (textWidth > maxWidth) { // Обновляем позицию скролла каждые 100мс if (millis() - lastScrollUpdate > 100) { lastScrollUpdate = millis(); scrollPosition++; // Сбрасываем позицию когда текст полностью прокрутился if (scrollPosition > textWidth + 20) { scrollPosition = -maxWidth; } } // Отображаем текст с учетом скролла display.setCursor(-scrollPosition, 15); display.println(stationName); } else { // Текст помещается, отображаем по центру int xPos = (maxWidth - textWidth) / 2; if (xPos < 0) xPos = 0; display.setCursor(xPos, 15); display.println(stationName); scrollPosition = 0; // Сбрасываем скролл если текст помещается } // Громкость display.setTextSize(1); display.setCursor(0, 40); display.print("Volume: "); display.print(currentVolume); display.print("/21"); // Индикатор громкости (полоска) int barWidth = map(currentVolume, 0, 21, 0, 120); display.drawRect(0, 50, 120, 8, SSD1306_WHITE); display.fillRect(2, 52, barWidth, 4, SSD1306_WHITE); display.display(); } // Функция для генерации HTML страницы String generateHTML() { String currentStationName = isCustomStation ? customStationName : radioStations[currentStationIndex].name; String html = "<!DOCTYPE html><html><head>"; html += "<meta charset='UTF-8'>"; html += "<meta name='viewport' content='width=device-width, initial-scale=1.0'>"; html += "<title>Odnolcom Radio Control</title>"; html += "<style>"; html += "body { font-family: Arial, sans-serif; margin: 20px; background: #f0f0f0; }"; html += "h1 { color: #333; text-align: center; }"; html += ".container { max-width: 600px; margin: 0 auto; background: white; padding: 20px; border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1); }"; html += ".station-list { list-style: none; padding: 0; }"; html += ".station-item { padding: 12px; margin: 8px 0; background: #f8f8f8; border-radius: 5px; cursor: pointer; transition: background 0.3s; }"; html += ".station-item:hover { background: #e0e0e0; }"; html += ".station-item.active { background: #4CAF50; color: white; font-weight: bold; }"; html += ".status { text-align: center; margin: 20px 0; padding: 10px; background: #e3f2fd; border-radius: 5px; }"; html += ".info { text-align: center; color: #666; font-size: 12px; margin-top: 20px; }"; html += ".custom-url { margin: 20px 0; padding: 15px; background: #f8f8f8; border-radius: 5px; }"; html += ".custom-url input { width: 100%; padding: 10px; margin: 5px 0; border: 1px solid #ddd; border-radius: 5px; box-sizing: border-box; }"; html += ".custom-url button { width: 100%; padding: 10px; margin: 5px 0; background: #2196F3; color: white; border: none; border-radius: 5px; cursor: pointer; }"; html += ".custom-url button:hover { background: #1976D2; }"; html += ".volume-control { margin: 20px 0; padding: 15px; background: #f8f8f8; border-radius: 5px; }"; html += ".volume-control input[type='range'] { width: 100%; margin: 10px 0; }"; html += ".volume-control label { display: block; margin-bottom: 5px; font-weight: bold; }"; html += ".volume-value { text-align: center; font-size: 18px; margin: 10px 0; }"; html += "</style>"; html += "</head><body>"; html += "<div class='container'>"; html += "<h1>📻 ESP32 Radio Control</h1>"; html += "<div class='status'>"; html += "<strong>Текущая станция:</strong> " + currentStationName; html += "</div>"; // Ползунок громкости html += "<div class='volume-control'>"; html += "<label>Громкость: <span id='volumeValue'>" + String(currentVolume) + "</span>/21</label>"; html += "<input type='range' id='volumeSlider' min='0' max='21' value='" + String(currentVolume) + "' oninput='updateVolume(this.value)'>"; html += "</div>"; // Поле для пользовательской ссылки html += "<div class='custom-url'>"; html += "<h3>Ввод радиостанции</h3>"; html += "<input type='text' id='stationName' placeholder='Название станции (необязательно)'>"; html += "<input type='text' id='stationUrl' placeholder='URL радиостанции (например: http://...'>"; html += "<button onclick='playCustomStation()'>Воспроизвести</button>"; html += "</div>"; html += "<ul class='station-list'>"; for (int i = 0; i < radioStationsCount; i++) { html += "<li class='station-item"; if (i == currentStationIndex && !isCustomStation) { html += " active"; } html += "' onclick='changeStation(" + String(i) + ")'>"; html += radioStations[i].name; html += "</li>"; } html += "</ul>"; html += "<div class='info'>IP: " + WiFi.localIP().toString() + "</div>"; html += "</div>"; html += "<script>"; html += "function changeStation(index) {"; html += " fetch('/change?station=' + index)"; html += " .then(() => { location.reload(); });"; html += "}"; html += "function updateVolume(value) {"; html += " document.getElementById('volumeValue').textContent = value;"; html += " fetch('/volume?value=' + value);"; html += "}"; html += "function playCustomStation() {"; html += " var name = document.getElementById('stationName').value;"; html += " var url = document.getElementById('stationUrl').value;"; html += " if (url) {"; html += " fetch('/custom?name=' + encodeURIComponent(name) + '&url=' + encodeURIComponent(url))"; html += " .then(() => { location.reload(); });"; html += " } else {"; html += " alert('Введите URL радиостанции');"; html += " }"; html += "}"; html += "</script>"; html += "</body></html>"; return html; } // Обработчик главной страницы void handleRoot() { server.send(200, "text/html; charset=UTF-8", generateHTML()); } // Обработчик смены радиостанции void handleChangeStation() { if (server.hasArg("station")) { int newIndex = server.arg("station").toInt(); if (newIndex >= 0 && newIndex < radioStationsCount) { currentStationIndex = newIndex; radioUrl = radioStations[currentStationIndex].url; isCustomStation = false; Serial.print("Переключение на станцию: "); Serial.print(radioStations[currentStationIndex].name); Serial.print(" - "); Serial.println(radioUrl); // Остановка текущего воспроизведения и подключение к новой станции audio.stopSong(); delay(100); audio.connecttohost(radioUrl); isConnected = true; // Сброс скролла scrollPosition = 0; // Обновление дисплея updateDisplay(); server.send(200, "text/plain", "OK"); } else { server.send(400, "text/plain", "Invalid station index"); } } else { server.send(400, "text/plain", "Missing station parameter"); } } // Обработчик изменения громкости void handleVolume() { if (server.hasArg("value")) { int newVolume = server.arg("value").toInt(); setVolume(newVolume); server.send(200, "text/plain", "OK"); } else { server.send(400, "text/plain", "Missing value parameter"); } } // Обработчик пользовательской радиостанции void handleCustomStation() { if (server.hasArg("url")) { customStationUrl = server.arg("url"); if (server.hasArg("name")) { customStationName = server.arg("name"); if (customStationName.length() == 0) { customStationName = "Custom"; } } else { customStationName = "Custom"; } isCustomStation = true; radioUrl = customStationUrl.c_str(); // Остановка текущего воспроизведения и подключение к новой станции audio.stopSong(); delay(100); audio.connecttohost(radioUrl); isConnected = true; // Сброс скролла scrollPosition = 0; // Обновление дисплея updateDisplay(); server.send(200, "text/plain", "OK"); } else { server.send(400, "text/plain", "Missing url parameter"); } } void setup() { Serial.begin(115200); delay(1000); // Инициализация I2C для дисплея Serial.println("Инициализация OLED дисплея..."); Wire.begin(I2C_SDA, I2C_SCL); if (!display.begin(SSD1306_SWITCHCAPVCC, SCREEN_ADDRESS)) { Serial.println("❌ Ошибка инициализации дисплея!"); Serial.println("Проверьте подключение дисплея."); } else { Serial.println("✅ OLED дисплей инициализирован!"); display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 0); display.println(WiFi.localIP()); display.setCursor(0, 20); display.println("Инициализация..."); display.display(); delay(1000); } // Инициализация аудио для UDA1334 Serial.println("Настройка аудио для UDA1334..."); audio.setPinout(I2S_BCLK, I2S_LRC, I2S_DOUT); audio.setVolume(currentVolume); // Громкость 0-21 (снижена для стабильности) // Настройка аудио для работы без PSRAM audio.forceMono(true); // Принудительно моно для экономии памяти audio.setConnectionTimeout(10000, 10000); // Таймаут подключения HTTP и SSL Serial.println("Подключение к WiFi..."); Serial.print("SSID: "); Serial.println(ssid); // Подключение к WiFi WiFi.mode(WIFI_STA); WiFi.begin(ssid, password); // Ожидание подключения int attempts = 0; while (WiFi.status() != WL_CONNECTED && attempts < 30) { delay(1000); Serial.print("."); attempts++; } if (WiFi.status() == WL_CONNECTED) { Serial.println(); Serial.println("✅ WiFi подключен успешно!"); Serial.print("IP адрес: "); Serial.println(WiFi.localIP()); Serial.print("SSID: "); Serial.println(WiFi.SSID()); Serial.print("Сила сигнала: "); Serial.print(WiFi.RSSI()); Serial.println(" dBm"); // Настройка веб-сервера server.on("/", handleRoot); server.on("/change", handleChangeStation); server.on("/volume", handleVolume); server.on("/custom", handleCustomStation); server.begin(); Serial.println("Веб-сервер запущен!"); Serial.print("Откройте в браузере: http://"); Serial.println(WiFi.localIP()); // Подключение к радиостанции radioUrl = radioStations[currentStationIndex].url; Serial.println("Подключение к радиостанции..."); Serial.print("Станция: "); Serial.print(radioStations[currentStationIndex].name); Serial.print(" - "); Serial.println(radioUrl); audio.connecttohost(radioUrl); Serial.println("🎵 Воспроизведение началось!"); isConnected = true; // Обновление дисплея updateDisplay(); } else { Serial.println(); Serial.println("❌ Ошибка подключения к WiFi!"); // Показываем ошибку на дисплее display.clearDisplay(); display.setTextSize(1); display.setTextColor(SSD1306_WHITE); display.setCursor(0, 0); display.println("WiFi Error!"); display.setCursor(0, 20); display.println("Check connection"); display.display(); } } void loop() { // Обработка запросов веб-сервера server.handleClient(); if (isConnected && WiFi.status() == WL_CONNECTED) { // Обработка аудио audio.loop(); } else { // Проверка состояния WiFi if (WiFi.status() != WL_CONNECTED) { Serial.println("❌ Потеря соединения с WiFi!"); Serial.println("Переподключение..."); WiFi.reconnect(); delay(5000); } // Проверка состояния аудио if (!isConnected) { Serial.println("Попытка переподключения к радиостанции..."); audio.connecttohost(radioUrl); isConnected = true; } } // Обновление дисплея каждые 200мс для плавной бегущей строки if (millis() - lastDisplayUpdate > 200) { lastDisplayUpdate = millis(); updateDisplay(); } delay(10); }
В коде уже есть основные комментарии, но обращу внимание на некоторые моменты. Радиостанции записываются в список (массив) radioStations. При подключении к интерфейсу управления радио, по локальному ip адресу (адрес видно на дисплее или в выводе по Serial), адреса радиостанции подгружаются в список и их можно переключать прямо на странице управления радио, здесь же можно управлять громкостью. Небольшая доп фича - это подключение к стриммингу радиостанции по ссылке: здесь нужно просто вставить прямую ссылку на аудиопоток и задать наименование радиостанции (опционально, если не установите наименование станции, то будет написано Custom).

Для подключения радиостанции к вашей точке доступа wifi - нужно в скетче прописать SSID и пароль. В коде достаточно много отладочных строк - их можно убрать в финальной версии скетча для оптимизации, хотя и так все работает.
Прошивать ESP32S3 предлагаю в Arduino IDE. Копируйте код в новый проект и прошивайте. Думал сделать репозиторий под этот проект, но наверное пока что будет жирновато, поэтому копируйте.)
Что получилось


Послесловие и что дальше?
Ну куда сейчас без ИИ?). Есть желание прикрутить к интернет-радио ИИ, чтобы можно было взаимодействовать с колонкой голосом -задавать какие-нибудь элементарные вопросы. Есть задумка прикрутить проект xiaozhi.
Буду рад, если переиспользуете мой проект, буду рад вашим предложениям и идеям. И если будет интересно развитие этого проекта, то ставьте стрелку вверх или пишите комментарии.
Спасибо большое за внимание!
