Webradio V4: ESP32-S3 trifft PCM5102 Hi-Fi Audio mit Anzeige Interpret und Titel

Nach meinen bisherigen Webradio-Projekten mit dem VS1053-Hardwaredecoder war es für mich Zeit für einen kleinen technologischen Generationswechsel: weg vom spezialisierten Audio-Decoder und hin zu einer Lösung, bei der deutlich mehr Arbeit direkt vom ESP32 übernommen wird.

In diesem Aufbau übernimmt der leistungsfähige ESP32-S3 die Audio-Dekodierung in Software. Die digitalen Audiodaten werden anschließend über den I2S-Bus an den PCM5102 weitergegeben. Dieser arbeitet als Digital-Analog-Wandler (DAC) und wandelt das digitale Audiosignal in ein analoges Signal um, das anschließend beispielsweise an einen Verstärker weitergegeben werden kann.

Klanglich gefällt mir diese Kombination sehr gut und sie wirkt für mich deutlich besser als meine bisherige Lösung mit dem VS1053-Modul. Nun läuft hier auch zeitgleich das auslesen der Metadaten. Interpret, Titel und weitere Informationen werden auf dem Display angezeigt. Die Streams bereiten mir somit nicht nur einen guten Hörgenuss, sondern zeigen auch was da gerade läuft.

Für dieses Projekt verwende ich außerdem die R16N8-Variante des ESP32-S3 mit 16 MB Flashspeicher und 8 MB PSRAM. Der zusätzliche Speicher ermöglicht einen großzügigen Audio-Puffer, der Schwankungen der WLAN-Verbindung besser abfangen soll. In der Theorie sollten kurze Netzwerkprobleme dadurch seltener zu hörbaren Aussetzern führen. Bisher störungsfrei.

  1. Bussysteme: Wir nutzen I2C für das Display und I2S für Audio.
  2. Ressourcenmanagement: Konfiguration des PSRAM
  3. Interrupt-Handling: Der Rotary Encoder steuert die Lautstärke unterbrechungsfrei parallel zur Musikwiedergabe.

So sieht der derzeitige Aufbau aus:

Das Hardware-Setup & Die Lötbrücken

Hardware:
ESP32-S3 N16R8
PCM5102
Encoder Zum Tippen und regeln
Display SH1107 mit 128 x 128 Pixel

Vorbereitung des PCM5102:

Damit der DAC mit dem ESP32-S3 zusammenarbeitet, musst du auf der Rückseite des Moduls vier kleine Lötbrücken (H1L bis H4L) setzen. Verbinde dazu jeweils den mittleren Kontakt mit der Seite, die als (L) LOW markiert ist:

H1 (FLT) auf LOW: Stellt den Filter auf Normal (bester Klang für Webradio).

H2 (DEMP) auf LOW: De-Emphasis aus (unverfälschter Sound).

H3 (XSMT) auf HIGH: Das ist der entscheidende Punkt! XSMT ist „Active Low Mute“. Das heißt: Wenn du H3 auf LOW (GND) lötest, bleibt der Chip stumm geschaltet. Er muss auf HIGH (3.3V) liegen, damit wir etwas hören!

H4 (FMT) auf LOW: Stellt den Chip auf den I2S-Modus, den wir in der main.cpp nutzen.

Verdrahtungsplan:

KomponentePin am BauteilESP32-S3 GPIOFunktion / Anmerkung
PCM5102 (I2S)VCC3.3VStromversorgung
GNDGNDMasse
BCKGPIO 7Bit Clock
LCK (WS)GPIO 6Word Select / Left-Right Clock
DIN / DOUTGPIO 5Data In
SCKGNDWICHTIG: Muss auf GND liegen!
SH1107 (I2C)VCC3.3VStromversorgung Display
GNDGNDMasse
SDAGPIO 8I2C Data
SCLGPIO 9I2C Clock
EncoderCLKGPIO 4Dreh-Signal A
DTGPIO 1Dreh-Signal B
SWGPIO 2Taster (Click)
+ / VCC3.3VVersorgung Encoder
GNDGNDMasse

Library:

ESP-audioI2S-master von Schreibfaul (Wolle)
Rotary Encoder von Matthias Hertel
U82g Library von olikraus

Code:

#include <WiFi.h>
#include <Audio.h>
#include <U8g2lib.h>
#include <RotaryEncoder.h>
#include <Wire.h>

#define WIFI_SSID "xxx"
#define WIFI_PASS "xxx"
#define OLED_SDA 8
#define OLED_SCL 9
#define ENCODER_CLK 1
#define ENCODER_DT 4
#define ENCODER_SW 2
#define I2S_BCK 7
#define I2S_DOUT 5
#define I2S_WS 6

// --- STATIONEN LISTE ---
struct Station { const char* name; const char* url; };

Station stationList[] = {
  {"WDR 4", "http://wdr-wdr4-live.icecast.wdr.de/wdr/wdr4/live/mp3/128/stream.mp3"},
  {"1LIVE", "http://wdr-1live-live.icecast.wdr.de/wdr/1live/live/mp3/128/stream.mp3"},
  {"Deutschlandfunk", "http://st01.dlf.de/dlf/01/128/mp3/stream.mp3"},
  {"Antenne Bayern", "http://stream.antenne.de/antenne/stream/mp3"},
  {"Radio Swiss Pop", "http://stream.srg-ssr.ch/m/rsp/mp3_128"},
  {"ROCK ANTENNE 80er", "http://stream.rockantenne.de/80er-rock/stream/mp3"},
  {"Radio BOB! 80s Rock", "http://streams.radiobob.de/bob-80errock/mp3-192/mediaplayer"},
  {"80s80s Rock", "http://streams.80s80s.de/rock/mp3-192/"}
};
const int maxStations = sizeof(stationList) / sizeof(stationList[0]);
int currentStationIndex = 0;

// --- OBJEKTE ---
Audio audio;
U8G2_SH1107_128X128_F_HW_I2C u8g2(U8G2_R3, U8X8_PIN_NONE, OLED_SCL, OLED_SDA);
RotaryEncoder encoder(ENCODER_CLK, ENCODER_DT, RotaryEncoder::LatchMode::TWO03);

// --- VARIABLEN ---
int volume = 10;
String currentTitle = "Warte auf Titel...";
unsigned long lastDisplayUpdate = 0;

// --- DISPLAY UPDATE ---
void updateDisplay() {
  u8g2.clearBuffer();

  u8g2.setFont(u8g2_font_8x13B_tf);
  u8g2.drawUTF8(0, 25, stationList[currentStationIndex].name);
  u8g2.drawHLine(0, 28, 128);

  u8g2.setFont(u8g2_font_6x12_tf);
  if (currentTitle.length() > 20) {
    u8g2.drawUTF8(0, 45, currentTitle.substring(0, 20).c_str());
    if (currentTitle.length() > 40) {
      u8g2.drawUTF8(0, 60, currentTitle.substring(20, 40).c_str());
      u8g2.drawUTF8(0, 70, currentTitle.substring(40, 60).c_str());
    } else {
      u8g2.drawUTF8(0, 60, currentTitle.substring(20).c_str());
    }
  } else {
    u8g2.drawUTF8(0, 55, currentTitle.c_str());
  }

  u8g2.drawHLine(0, 105, 128);
  u8g2.setFont(u8g2_font_6x12_tf);

  char buf[40];
  int totalBuf = audio.getInBufferSize();
  int fillPercent = 0;
  if (totalBuf > 0) {
    fillPercent = (audio.inBufferFilled() * 100) / totalBuf;
  }
  if (fillPercent > 100) fillPercent = 100;

  snprintf(buf, sizeof(buf), "Vol:%d | Buf:%d%%", volume, fillPercent);
  u8g2.drawStr(0, 118, buf);

  u8g2.sendBuffer();
}

// --- NEUER ZENTRALER CALLBACK ---
// Ersetzt audio_info / audio_showstation / audio_showstreamtitle / audio_showicy
// Muss global definiert sein und exakt diese Signatur haben!
void my_audio_info(Audio::msg_t m) {
  switch (m.e) {
    case Audio::evt_streamtitle:
      Serial.printf("TITEL ERKANNT: %s\n", m.msg);
      currentTitle = String(m.msg);
      break;

    case Audio::evt_name:
      Serial.printf("Sender gefunden: %s\n", m.msg);
      currentTitle = "Stream laeuft...";
      break;

    case Audio::evt_bitrate:
      Serial.printf("Bitrate: %s\n", m.msg);
      break;

    case Audio::evt_info:
      Serial.printf("Audio-Info: %s\n", m.msg);
      break;

    case Audio::evt_icydescription:
      Serial.printf("ICY-Description: %s\n", m.msg);
      break;

    case Audio::evt_icyurl:
      Serial.printf("ICY-URL: %s\n", m.msg);
      break;

    case Audio::evt_lasthost:
      Serial.printf("Letzter Host: %s\n", m.msg);
      break;

    case Audio::evt_eof:
      Serial.println("Stream EOF");
      break;

    default:
      // Optional: Alle anderen Events zum Debuggen ausgeben
      // Serial.printf("Event [%d]: %s\n", m.e, m.msg ? m.msg : "(null)");
      break;
  }
}

// --- SETUP ---
void setup() {
  Serial.begin(115200);
  delay(2000);

  Serial.println("\n--- PRILCHEN WEBRADIO V4 START ---");

  // 0. WICHTIG: Callback registrieren BEVOR Audio benutzt wird
  Audio::audio_info_callback = my_audio_info;
  Serial.println("Metadaten-Callback registriert.");

  // 1. Hardware Check
  Serial.printf("Chip Modell: %s\n", ESP.getChipModel());
  Serial.printf("Flash Größe: %d MB\n", ESP.getFlashChipSize() / (1024 * 1024));
  if (psramFound()) {
    Serial.printf("PSRAM erkannt: %d MB\n", ESP.getPsramSize() / (1024 * 1024));
  } else {
    Serial.println("ACHTUNG: Kein PSRAM erkannt! Bitte PSRAM in IDE aktivieren.");
  }

  // 2. I2C & Display
  Wire.begin(OLED_SDA, OLED_SCL);
  Wire.setClock(400000);
  u8g2.begin();

  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_8x13B_tf);
  u8g2.drawStr(25, 45, "Webradio");
  u8g2.drawStr(10, 65, "Prilchen LABS");
  u8g2.setFont(u8g2_font_6x12_tf);
  u8g2.drawStr(0, 100, "WLAN verbinden...");
  u8g2.sendBuffer();

  // 3. Encoder Button
  pinMode(ENCODER_SW, INPUT_PULLUP);

  // 4. WiFi
  WiFi.begin(WIFI_SSID, WIFI_PASS);
  int wifiTimeout = 0;
  while (WiFi.status() != WL_CONNECTED && wifiTimeout < 40) {
    delay(250);
    Serial.print(".");
    wifiTimeout++;
  }

  u8g2.clearBuffer();
  u8g2.setFont(u8g2_font_8x13B_tf);
  u8g2.drawStr(25, 45, "Webradio");
  u8g2.drawStr(10, 65, "Prilchen LABS");
  u8g2.setFont(u8g2_font_6x12_tf);

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("\nWiFi Verbunden! IP: " + WiFi.localIP().toString());
    u8g2.drawStr(0, 100, "WLAN Erfolgreich!");
    u8g2.drawStr(0, 110, WiFi.localIP().toString().c_str());
  } else {
    Serial.println("\nWiFi Fehler!");
    u8g2.drawStr(0, 80, "WLAN Fehler!");
    u8g2.drawStr(0, 100, "Bitte Reset druecken");
  }
  u8g2.sendBuffer();
  delay(3000);

  // 5. Audio Init
  audio.setPinout(I2S_BCK, I2S_WS, I2S_DOUT);

  if (psramFound()) {
    Serial.println("PSRAM aktiv: Audio-Bibliothek nutzt erweiterten Puffer automatisch.");
  }

  audio.setVolume(volume);

  if (WiFi.status() == WL_CONNECTED) {
    Serial.println("Verbinde zu: " + String(stationList[currentStationIndex].name));
    audio.connecttohost(stationList[currentStationIndex].url);
  }
}

// --- LOOP ---
void loop() {
  audio.loop();
  encoder.tick();

  if (millis() - lastDisplayUpdate > 1000) {
    updateDisplay();
    lastDisplayUpdate = millis();
  }

  // Lautstärke
  static int lastPos = 0;
  int newPos = encoder.getPosition();
  if (lastPos != newPos) {
    if (newPos > lastPos) volume = min(volume + 1, 21);
    else volume = max(volume - 1, 0);

    audio.setVolume(volume);
    lastPos = newPos;
    updateDisplay();
  }

  // Senderwechsel
  static bool lastBtnState = HIGH;
  bool currentBtnState = digitalRead(ENCODER_SW);
  if (lastBtnState == HIGH && currentBtnState == LOW) {
    delay(50);
    if (digitalRead(ENCODER_SW) == LOW) {
      currentStationIndex++;
      if (currentStationIndex >= maxStations) currentStationIndex = 0;

      Serial.println("Wechsle Sender zu: " + String(stationList[currentStationIndex].name));

      currentTitle = "Lade Sender...";

      u8g2.clearBuffer();
      u8g2.setFont(u8g2_font_8x13B_tf);
      u8g2.drawStr(0, 60, "Lade Sender...");
      u8g2.drawUTF8(0, 80, stationList[currentStationIndex].name);
      u8g2.sendBuffer();

      audio.connecttohost(stationList[currentStationIndex].url);
    }
  }
  lastBtnState = currentBtnState;
}

Arduino IDE Einstellungen:

Für ein generisches ESP32-S3 N16R8-Board habe ich in der Arduino IDE diese Einstellungen verwendet:

Arduino IDE → WerkzeugeEinstellung
BoardESP32S3 Dev Module
USB CDC On BootEnabled
CPU Frequency240 MHz (WiFi)
Core Debug LevelNone
USB DFU On BootDisabled
Erase All Flash Before Sketch UplDisabled
Events Run OnCore 1
Flash ModeQIO 80 MHz
Flash Size16MB (128Mb)
JTAG AdapterDisabled
Arduino Runs OnCore 1
USBDisabled
Partition Schemez. B. 16M Flash (3MB APP/9.9MB FATFS)
PSRAMOPI PSRAM
Upload ModeUART0 / Hardware CDC
Upload Speed921600
USB ModeHardware CDC and JTA

Fazit

Es klingt sehr angenehm aus den kleinen Aktivboxen. Da auch hier deutlich mehr RAM zu Verfügung steht, wurde es mal Zeit auch die Metadaten der gespielten Titel abzufragen und auf dem Display anzeigen zu lassen. Dann sollte ich mich mal dran machen ein passendes Gehäuse zu erstellen.


, ,
Datenschutz-Übersicht

Diese Website verwendet Cookies, damit wir dir die bestmögliche Benutzerkomfort bieten können. Cookie-Informationen werden in deinem Browser gespeichert und führen Funktionen aus, wie das Wiedererkennen von dir, wenn du auf unsere Website zurückkehrst und hilft uns zu verstehen, welche Abschnitte der Website für dich am interessantesten und nützlichsten sind.