Всем привет, дорогие читатели! Расскажу вам о том как сделать интернет-радио на «скорую руку» без особых хлопот.

Скрытый текст

Статья не претендует на новизну, это еще один рецепт для создания интернет-радио на базе 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.

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

Спасибо большое за внимание!