Showing off my audio project!

Been a minute.
I’ve been working on an audio project that works out really well! Basically I used some of the Arduino Audio Tools examples to come up with a esp32-c3 music streamer for internet radio, which outputs to a 3W dac via I2S. The main challenges were to set up a captive portal, wifi management, a web interface and the music streaming logic within the 400 kb RAM restriction, and I ended up with ~40 or 50KB to spare.
What’s next: I already have a new variant in the works. I decided to amp up to a 24V 2x 30W end-amp, with a sp-dif to i2s module added for good measure. The MCU will listen to the toslink lock-pin to auto-switch between inputs, and soft-switch the amp off with a mosfet switching module when neither are playing (this should hopefully avoid any switching pops, and any unfiltered speaker noise when not in use).

I was very happy that my first project, which literally only consists of a esp32 and a mono dac, worked so well. Playing to a recycled 7W speaker it could easily fill a room at 100%, and is typically playing at 10% or less. The web interface is also pretty responsive.

Code and more photos available on request. A lot of code is generated using Claude AI, which I debugged and finetuned when necessary (which was fairly minimal, since Claude does code management quite well and is very context aware as a result).

1 Like

May be publish on GitHub and share a link ?

Seems cool.

What the point to show all this without a code?

Thanks. I’m still in the process of adding a graphical equaliser, but once that’s up I’ll post a repo if I remember.

Apologies for the delay, it took me too long to realise that adding an equaliser on a esp32-c3 simply isn’t going to work due to the cpu constraints.

Here’s the code: GitHub - clogboy/EspWebradioPlayer: Webradio player based on esp32 supermini MCU

Anyone willing to give it a try on a more capable MCU should use this code:

#include "AudioModule.h"

// Single URL holder for dynamic changes
static const char* dynamicURL[1] = { "" };

AudioModule::AudioModule(const char* ssid, const char* password) 
    : wifiSSID(ssid), wifiPassword(password), playing(false), currentVolume(0.05),
      sleepTimerActive(false), sleepEndTime(0), sleepFadeStart(0), sleepStartVolume(0),
      eqBass(0), eqMid(0), eqTreble(0) {
    
    urlStream = new URLStream(wifiSSID, wifiPassword);
    source = new AudioSourceURL(*urlStream, dynamicURL, "audio/mp3");
    i2s = new I2SStream();
    
    // Create equalizer - it wraps the I2S output
    equalizer = new Equalizer3Bands(*i2s);
    
    // Configure equalizer with default settings (unity gain = 1.0)
    // MONO for single DAC!
    auto& eq_cfg = equalizer->config();
    eq_cfg.channels = 1;  // MONO - single DAC
    eq_cfg.bits_per_sample = 16;
    eq_cfg.sample_rate = 44100;
    eq_cfg.gain_low = 1.0;      // Bass (1.0 = unity, no change)
    eq_cfg.gain_medium = 1.0;   // Mid
    eq_cfg.gain_high = 1.0;     // Treble
    
    decoder = new MP3DecoderHelix();
    
    // Player outputs to equalizer, which outputs to I2S
    player = new AudioPlayer(*source, *equalizer, *decoder);
}

bool AudioModule::begin() {
    Serial.println("Setting up audio...");
    
    // I2S setup
    auto cfg = i2s->defaultConfig(TX_MODE);
    cfg.pin_bck = I2S_BCLK_PIN;
    cfg.pin_ws = I2S_LRCK_PIN;
    cfg.pin_data = I2S_DATA_PIN;
    cfg.channels = 1;  // MONO for single DAC
    cfg.sample_rate = 44100;
    cfg.bits_per_sample = 16;
    cfg.buffer_count = 8;  // Increase buffer count
    cfg.buffer_size = 512; // Increase buffer size
    
    Serial.print("I2S Config - Channels: ");
    Serial.print(cfg.channels);
    Serial.print(", Sample Rate: ");
    Serial.print(cfg.sample_rate);
    Serial.print(", Bits: ");
    Serial.println(cfg.bits_per_sample);
    
    if (!i2s->begin(cfg)) {
        Serial.println("I2S init failed");
        return false;
    }
    Serial.println("I2S: OK");
    
    // Initialize equalizer
    if (!equalizer->begin()) {
        Serial.println("Equalizer init failed");
        return false;
    }
    Serial.println("Equalizer: OK");
    
    // Player setup
    if (!player->begin()) {
        Serial.println("Player init failed");
        return false;
    }
    Serial.println("Player: OK");
    
    // Start paused - wait for user to select a station
    playing = false;
    player->setVolume(currentVolume);
    
    Serial.println("Audio ready (paused - select station to play)");
    return true;
}

void AudioModule::process() {
    if (playing) {
        player->copy();
    }
    
    // Handle sleep timer
    if (sleepTimerActive) {
        processSleepTimer();
    }
}

void AudioModule::play() {
    playing = true;
    Serial.println("Audio: playing");
}

void AudioModule::pause() {
    playing = false;
    Serial.println("Audio: paused");
}

bool AudioModule::isPlaying() {
    return playing;
}

void AudioModule::setVolume(float vol) {
    if (vol < 0.0) vol = 0.0;
    if (vol > 1.0) vol = 1.0;
    currentVolume = vol;
    player->setVolume(currentVolume);
    Serial.print("Volume: ");
    Serial.println(currentVolume);
}

float AudioModule::getVolume() {
    return currentVolume;
}

bool AudioModule::setURL(const char* url) {
    if (!url || strlen(url) == 0) {
        Serial.println("setURL: Invalid URL");
        return false;
    }
    
    Serial.print("Changing stream to: ");
    Serial.println(url);
    
    currentURL = String(url);
    
    // Stop current playback
    bool wasPlaying = playing;
    playing = false;
    delay(200); // Give audio thread time to stop
    
    // Clean up old objects (in reverse order of creation)
    if (player) { delete player; player = nullptr; }
    if (decoder) { delete decoder; decoder = nullptr; }
    if (equalizer) { delete equalizer; equalizer = nullptr; }
    if (source) { delete source; source = nullptr; }
    if (i2s) { delete i2s; i2s = nullptr; }
    if (urlStream) { delete urlStream; urlStream = nullptr; }
    
    // Recreate with new URL
    dynamicURL[0] = currentURL.c_str();
    
    urlStream = new URLStream(wifiSSID, wifiPassword);
    source = new AudioSourceURL(*urlStream, dynamicURL, "audio/mp3");
    i2s = new I2SStream();
    
    // Recreate equalizer
    equalizer = new Equalizer3Bands(*i2s);
    
    // Restore EQ settings
    float bassGain = pow(10.0, eqBass / 20.0);
    float midGain = pow(10.0, eqMid / 20.0);
    float trebleGain = pow(10.0, eqTreble / 20.0);
    
    auto& eq_cfg = equalizer->config();
    eq_cfg.channels = 1;  // MONO for single DAC
    eq_cfg.bits_per_sample = 16;
    eq_cfg.sample_rate = 44100;
    eq_cfg.gain_low = bassGain;
    eq_cfg.gain_medium = midGain;
    eq_cfg.gain_high = trebleGain;
    
    decoder = new MP3DecoderHelix();
    player = new AudioPlayer(*source, *equalizer, *decoder);
    
    // Reinitialize
    auto cfg = i2s->defaultConfig(TX_MODE);
    cfg.pin_bck = I2S_BCLK_PIN;
    cfg.pin_ws = I2S_LRCK_PIN;
    cfg.pin_data = I2S_DATA_PIN;
    cfg.channels = 1;  // MONO for single DAC
    cfg.sample_rate = 44100;
    cfg.bits_per_sample = 16;
    
    if (!i2s->begin(cfg)) {
        Serial.println("I2S reinit failed");
        return false;
    }
    
    if (!equalizer->begin()) {
        Serial.println("Equalizer reinit failed");
        return false;
    }
    
    if (!player->begin()) {
        Serial.println("Player reinit failed");
        return false;
    }
    
    player->setVolume(currentVolume);
    
    // Resume if was playing
    if (wasPlaying) {
        playing = true;
    }
    
    Serial.println("Stream changed successfully");
    return true;
}

String AudioModule::getCurrentURL() {
    return currentURL;
}

void AudioModule::setEQ(int bass, int mid, int treble) {
    // Clamp values to -12 to +12 dB
    eqBass = constrain(bass, -12, 12);
    eqMid = constrain(mid, -12, 12);
    eqTreble = constrain(treble, -12, 12);
    
    // Convert dB to gain factor: gain = 10^(dB/20)
    // Gain range: 0.0 to 2.0 (0.0 = silence, 1.0 = unity, 2.0 = +6dB)
    float bassGain = pow(10.0, eqBass / 20.0);
    float midGain = pow(10.0, eqMid / 20.0);
    float trebleGain = pow(10.0, eqTreble / 20.0);
    
    // Update equalizer config directly
    auto& eq_cfg = equalizer->config();
    eq_cfg.gain_low = bassGain;
    eq_cfg.gain_medium = midGain;
    eq_cfg.gain_high = trebleGain;
    
    // Apply changes by calling begin()
    equalizer->begin();
    
    Serial.print("EQ set - Bass: ");
    Serial.print(eqBass);
    Serial.print("dB (");
    Serial.print(bassGain, 2);
    Serial.print("), Mid: ");
    Serial.print(eqMid);
    Serial.print("dB (");
    Serial.print(midGain, 2);
    Serial.print("), Treble: ");
    Serial.print(eqTreble);
    Serial.print("dB (");
    Serial.print(trebleGain, 2);
    Serial.println(")");
}

void AudioModule::getEQ(int &bass, int &mid, int &treble) {
    bass = eqBass;
    mid = eqMid;
    treble = eqTreble;
}

void AudioModule::setSleepTimer(unsigned long durationMinutes) {
    unsigned long durationMs = durationMinutes * 60 * 1000;
    unsigned long fadeMs = 2 * 60 * 1000; // 2 minute fade
    
    sleepEndTime = millis() + durationMs;
    sleepFadeStart = sleepEndTime - fadeMs;
    sleepStartVolume = currentVolume;
    sleepTimerActive = true;
    
    Serial.print("Sleep timer set for ");
    Serial.print(durationMinutes);
    Serial.println(" minutes");
}

void AudioModule::cancelSleepTimer() {
    sleepTimerActive = false;
    Serial.println("Sleep timer cancelled");
}

bool AudioModule::hasSleepTimer() {
    return sleepTimerActive;
}

unsigned long AudioModule::getSleepTimeRemaining() {
    if (!sleepTimerActive) return 0;
    
    unsigned long now = millis();
    if (now >= sleepEndTime) return 0;
    
    return (sleepEndTime - now) / 1000; // return seconds
}

void AudioModule::processSleepTimer() {
    unsigned long now = millis();
    
    // Time's up - pause
    if (now >= sleepEndTime) {
        pause();
        cancelSleepTimer();
        Serial.println("Sleep timer ended - paused");
        return;
    }
    
    // In fade period - gradually reduce volume
    if (now >= sleepFadeStart) {
        unsigned long fadeElapsed = now - sleepFadeStart;
        unsigned long fadeDuration = sleepEndTime - sleepFadeStart;
        float fadeProgress = (float)fadeElapsed / fadeDuration;
        
        float newVolume = sleepStartVolume * (1.0 - fadeProgress);
        player->setVolume(newVolume);
    }
}

Schematic....

Tom.... :smiley: :+1: :coffee: :australia:

I’m not great with those, so forgive me if I refer to image 4 in OP as something self explanatory. I’m only doing this as a hobby. Pin layout depends on the MCU that you’re using, which renders any schematic unreliable.

Ideally, both parts are fed externally with a common ground. The layout on the photos worked well for me, but results may vary. Also see the readme in my repository.