How I wired Home Assistant, Music Assistant, a Raspberry Pi, and a WLED ESP32-S3 together to get audio-reactive lights driven entirely from the music stream — no microphone required.

(Claude helped with both the project and this post)

The Goal

I wanted my WLED-controlled lights to react to whatever was playing through my HiFi system — pulsing, flashing, and shifting colours in time with the music. The catch: I didn’t want a microphone picking up room noise, and I wanted it to work automatically whenever music played, without any manual intervention.


The Stack

Before diving in, here’s what the final working system looks like end-to-end:

flowchart TD
    MA[Music Assistant
Home Assistant] -->|Streams audio| SL[Squeezelite
Raspberry Pi 3B+] SL -->|ALSA combined device| HB[HifiBerry DAC+
card: sndrpihifiberry] SL -->|ALSA combined device| LP[ALSA Loopback
hw:CARD=Loopback,DEV=0] HB -->|Analogue audio| SP[Speakers] LP -->|hw:CARD=Loopback,DEV=1
capture side| PY[wled_sr.py
Docker container] PY -->|FFT analysis| UDP[UDP packets
port 11988
44 bytes V2 format] UDP -->|WiFi| WLED[WLED ESP32-S3
AudioReactive usermod
UDP receive mode v2] WLED -->|WS2812B data| LED[258 LEDs
WS2812B strip]

Hardware

ComponentModelNotes
Single board computerRaspberry Pi 3B+Runs Squeezelite + Docker
DACHifiBerry DAC+Outputs to speakers via ALSA
LED controller (original)Wemos D1 Mini (ESP8266)Too underpowered for AudioReactive
LED controller (final)Lolin S3 Mini (ESP32-S3FH4R2)4MB flash, 2MB PSRAM, dual core
LED stripWS2812B, 258 LEDsData pin GPIO2

💡 Lesson Learned: ESP8266 Won’t Cut It

The original D1 Mini (ESP8266) had only 17KB of free heap at runtime — nowhere near enough to process incoming UDP audio sync packets alongside driving 258 LEDs at 42fps. The AudioReactive usermod is ESP32 only.

When choosing an ESP32 board for WLED AudioReactive, avoid the ESP32-S2 (single core). The ESP32-S3 is the sweet spot — dual core at 240MHz, hardware floating point, and PSRAM support. The Lolin S3 Mini delivered 214KB free heap and 1963KB free PSRAM — a night-and-day difference.


Software

ComponentSoftwareNotes
Smart homeHome AssistantOrchestrates everything
Music player backendMusic AssistantReplaces LMS
Squeezebox clientSqueezelitePlays audio on the Pi
LED firmwareWLED v0.15.3 “Kōsen”With AudioReactive usermod
Audio tapwled_sr.pyCustom Python script in Docker

Step 1: Tapping the Audio Stream with ALSA Loopback

The Pi runs Squeezelite pointed directly at the HifiBerry DAC via ALSA. To intercept the audio without a microphone — and without interrupting playback — we used the ALSA loopback kernel module combined with a multi device in asound.conf.

Load the loopback module

sudo modprobe snd-aloop

# Make it persist across reboots
echo "snd-aloop" | sudo tee /etc/modules-load.d/snd-aloop.conf

Configure ALSA to mirror audio to both DAC and loopback

/etc/asound.conf:

pcm.multi {
    type multi
    slaves {
        a { pcm "hw:CARD=sndrpihifiberry,DEV=0" channels 2 }
        b { pcm "hw:CARD=Loopback,DEV=0" channels 2 }
    }
    bindings {
        0 { slave a channel 0 }
        1 { slave a channel 1 }
        2 { slave b channel 0 }
        3 { slave b channel 1 }
    }
}

pcm.combined {
    type route
    slave.pcm "multi"
    slave.channels 4
    ttable {
        0.0 1
        1.1 1
        0.2 1
        1.3 1
    }
}

ctl.combined {
    type hw
    card sndrpihifiberry
}

Point Squeezelite at the combined device

/etc/default/squeezelite:

SL_SOUNDCARD="combined"

💡 Lesson Learned: Use Card Names, Not Numbers

Initially we used hw:1,0 and hw:3,0 in asound.conf. After a reboot, the kernel loaded modules in a different order and card numbers shuffled — breaking everything silently. Always use hw:CARD=sndrpihifiberry,DEV=0 and hw:CARD=Loopback,DEV=0 so the config survives reboots regardless of card numbering.

💡 Lesson Learned: The Loopback Mirror Pairing

The ALSA loopback has two devices (0 and 1) that mirror each other:

flowchart LR
    W[Writer
e.g. Squeezelite] -->|writes to| D0P[card Loopback
device 0
playback] D0P -.->|mirrors to| D1C[card Loopback
device 1
capture] D1C -->|reads from| R[Reader
e.g. wled_sr.py]

Write to device 0 playback → read from device 1 capture. This is hw:CARD=Loopback,DEV=1 for the reader. Only one process can hold a capture subdevice open at a time — if something else has it open, your reader gets silence.


Step 2: Flashing WLED with AudioReactive

💡 Lesson Learned: AudioReactive is a Usermod — Check Your Build

WLED’s stock builds from install.wled.me do not always include the AudioReactive usermod. To verify:

curl http://<wled-ip>/json/info | python3 -m json.tool | grep -A5 AudioReactive

If you don’t see an AudioReactive key, reflash with a build that includes it. For ESP32-S3 we used wled-install.github.io and selected:

ESP32-S3 (4MB Flash, with Audio reactive Usermod)

For flashing without a working web serial connection, the Jason2866 ESP_Flasher (github.com/Jason2866/ESP_Flasher) is a reliable standalone GUI tool. Note: ESPHome Flasher is archived and no longer maintained.

Configure AudioReactive in WLED

  1. Enable AudioReactive via the info page toggle or API:
curl -X POST http://<wled-ip>/json \
  -H "Content-Type: application/json" \
  -d '{"AudioReactive":{"enabled":true}}'
  1. Go to Config → AudioReactive and set:

    • Audio Source: UDP Sound Sync
    • UDP Sync mode: Receive
  2. Select an AudioReactive effect (look for the 🎵 icon) — Freqmap, Blurz, or DJ Lights are good starting points.

When working correctly, the info page shows:

Audio Source:    UDP sound sync - receiving
Sound Processing: suspended
UDP Sound Sync:  receive mode v2

“Sound Processing: suspended” is correct — it means the local DSP is suspended in favour of incoming UDP data.


Step 3: The Audio Analysis Script

The custom Python script wled_sr.py runs in a Docker container on the Pi. It:

  1. Captures audio from the ALSA loopback
  2. Computes an FFT with mel-spaced frequency bands
  3. Sends the result to WLED as a V2 UDP audio sync packet

💡 Lesson Learned: The V2 Packet Format Matters

This was the hardest bug to track down. WLED AudioReactive expects a specific 44-byte packet with header "00002\0" — not the "WLED" header used by older WLED-SR forks. The full structure:

packet-beta
0-47: "header '00002\\0' (6 bytes)"
48-63: "gap (2 bytes)"
64-95: "sampleRaw float (4 bytes)"
96-127: "sampleSmth float (4 bytes)"
128-135: "samplePeak uint8"
136-143: "frameCounter uint8"
144-271: "fftResult[16] uint8 (16 bytes)"
272-287: "gap (2 bytes)"
288-319: "FFT_Magnitude float (4 bytes)"
320-351: "FFT_MajorPeak float (4 bytes)"

Sending the wrong packet format causes WLED to silently stay in idle state — packets arrive but are discarded.

💡 Lesson Learned: Never Do I/O in the Audio Callback

The audio callback runs on a real-time thread. Any blocking operation — including HTTP requests or even slow Python code — causes input overflow, where the audio buffer fills up faster than it’s being consumed. The result is audio glitches and corrupted data.

The solution: the callback does only CPU work (FFT), then signals via a queue.Queue. All network I/O happens on the main thread.

flowchart LR
    AC[Audio callback thread
FFT only, no I/O] -->|put_nowait| Q[queue.Queue] Q -->|get_nowait
20ms poll| MT[Main thread
UDP send to WLED]

Docker setup

The script runs in Docker to keep all dependencies isolated from the Pi’s OS:

FROM python:3.12-slim AS builder
RUN apt-get update && apt-get install -y --no-install-recommends gcc
RUN pip install --no-cache-dir --prefix=/install sounddevice numpy requests

FROM python:3.12-slim
RUN apt-get update && apt-get install -y --no-install-recommends \
    libasound2 libportaudio2
COPY --from=builder /install /usr/local
COPY wled_sr.py .
ENTRYPOINT ["python3", "wled_sr.py"]

Run command (using card name for reboot resilience):

docker run -d --name wled-sr \
  --device /dev/snd \
  --restart unless-stopped \
  wled-sr \
  --wled 192.168.1.195 \
  --device hw:CARD=Loopback,DEV=1

Step 4: Silence Detection — Playing Nicely with Home Assistant

Without silence detection, the script continuously sends UDP packets even when nothing is playing. WLED interprets near-zero packets as “on” and turns itself back on after Home Assistant turns it off.

The fix: track RMS energy and stop streaming after SILENCE_TIMEOUT seconds of silence. WLED then returns to HA control.

stateDiagram-v2
    [*] --> Monitoring
    Monitoring --> Streaming: RMS > SILENCE_THRESHOLD
audio detected Streaming --> Monitoring: RMS < SILENCE_THRESHOLD
for SILENCE_TIMEOUT seconds Streaming --> Streaming: Audio playing
send UDP packets Monitoring --> Monitoring: Silence
no packets sent
HA controls WLED

The Scripts

Both scripts are available as GitHub Gists:


Troubleshooting Reference

SymptomLikely CauseFix
No input device matching 'hw:X,Y'snd-aloop not loaded or card numbers shuffledmodprobe snd-aloop, use card names
Audio levels all zeroAnother process holds the capture deviceps aux | grep wled, kill stale process
input overflow warningsI/O in audio callbackMove network calls off callback thread
WLED shows UDP sound sync - idleWrong packet format or no packets arrivingCheck header is "00002\0", verify with tcpdump
WLED shows UDP sound sync - receiving but no effectNon-AudioReactive effect selectedPick an effect with 🎵 icon
WLED turns itself back on after HA turns it offScript sending packets during silenceEnable silence detection / SILENCE_TIMEOUT
ESP8266 UDP sync not workingNot enough free heap (17KB)Upgrade to ESP32-S3
BPM double what’s expectedBeat cooldown too shortIncrease BEAT_COOLDOWN to 0.3s+

Final Architecture

flowchart TD
    subgraph HA["Home Assistant Server"]
        MA[Music Assistant]
    end

    subgraph PI["Raspberry Pi 3B+"]
        SL[Squeezelite]
        ALSA[ALSA combined device
asound.conf] LP[snd-aloop
Loopback kernel module] DC[Docker: wled_sr.py
FFT analysis
UDP sender] end subgraph AUDIO["Audio Output"] HB[HifiBerry DAC+] SP[Speakers] end subgraph LIGHTS["Lighting"] ESP[ESP32-S3
WLED v0.15.3
AudioReactive usermod] LED[WS2812B
258 LEDs] end MA -->|Squeezebox protocol| SL SL --> ALSA ALSA --> HB ALSA --> LP HB --> SP LP -->|hw:CARD=Loopback,DEV=1| DC DC -->|UDP port 11988
44-byte V2 packets
~75/s| ESP ESP --> LED MA -.->|HTTP/WebSocket
ON/OFF control| ESP

Total LEDs: 258 × WS2812B — Average FPS: 43 — Free heap: 214KB — Free PSRAM: 1963KB