LED Effects & Visualizer¶
The Music Player drives the Cyber Fidget's 4 RGBW NeoPixel LEDs in sync with audio playback, plus OLED visualizers on the Now Playing screen — a rolling amplitude graph and a VFD-style frequency spectrum display.
What is this?¶
When music plays, the LEDs react to the audio in real-time — quiet passages produce a gentle blue pulse, medium levels shift to cyan/green, and loud peaks flash red with a white burst. There's also a "Pulse" mode that breathes independently of audio, and "Off" for when you want dark. Individual LEDs can be toggled on/off from a dedicated submenu.
The OLED screen has three visualizer modes: a time-domain amplitude graph (rolling waveform), a frequency-domain spectrum analyzer (like classic 80s VFD equalizer displays), or just the normal playhead with elapsed/remaining time.
LED hardware¶
| Property | Value |
|---|---|
| Chip | RGBW NeoPixel (SK6812 compatible) |
| Count | 4 pixels |
| GPIO | 0 (PIN_NEOPIXEL from board variant) |
| Color order | GRB + White (NEO_GRBW + NEO_KHZ800) |
| Library | Adafruit NeoPixel |
The pixels are indexed 0-3: Back (0), Front Top (1), Front Middle (2), Front Bottom (3). The front three pixels get full-intensity colors; the back pixel gets a dimmed version (÷3).
Why Adafruit NeoPixel instead of neopixelWrite()?
The ESP32 Arduino built-in neopixelWrite(pin, r, g, b) only drives a single RGB pixel (24 bits, no white channel). It also uses the RMT peripheral internally, so it has the same IRAM impact. It cannot drive a multi-pixel RGBW strip. Adafruit NeoPixel provides multi-pixel buffers, RGBW support, and color utilities.
Audio analysis: SpectrumAnalyzer¶
Audio analysis is performed by SpectrumAnalyzer, a custom ModifyingStream that sits in the audio pipeline:
SD Card → AudioPlayer → MP3DecoderHelix → SpectrumAnalyzer → A2DPStream → BT Speaker
↓ (analysis)
volumeRatio() — peak amplitude 0.0–1.0
bands()[16] — frequency spectrum 0.0–1.0
SpectrumAnalyzer replaces the original VolumeMeter from arduino-audio-tools. It provides the same volumeRatio() API for LED reactive effects, plus 16-band FFT frequency analysis for the spectrum visualizer. Audio passes through unchanged — zero latency, zero quality impact.
// In createAudioPipeline():
if (!pSpectrumAnalyzer) {
pSpectrumAnalyzer = new SpectrumAnalyzer(*pA2dpStream);
pSpectrumAnalyzer->begin(44100, 2, 16);
}
pPlayer = new AudioPlayer(*pSourceSD, *pSpectrumAnalyzer, decoder);
Pipeline object lifecycle
Like all audio pipeline objects, the SpectrumAnalyzer is heap-allocated once and never destroyed. It persists across app enter/exit cycles via a null-pointer guard (if (!pSpectrumAnalyzer)). Direct access to pA2dpStream for BT management (disconnect, buffer manipulation) is unaffected.
LED effect modes¶
Three modes, configured from the LEDs submenu (main menu → LEDs):
Off (default)¶
LEDs are dark. setColorsOff() is called to ensure all pixels are cleared. This is the default mode to avoid micro-stutters when the spectrum visualizer is active.
Reactive¶
LEDs respond to the current audio amplitude with smoothed, perceptually-corrected color mapping:
- EMA smoothing: Raw amplitude is smoothed with exponential moving average (α = 0.25) to prevent flickering
- Gamma correction: Smoothed value is raised to power 0.6 for perceptual brightness linearity
- Color mapping by amplitude band:
| Amplitude | Color | Behavior |
|---|---|---|
| < 0.15 | Dim blue | Gentle sine-wave breathing pulse |
| 0.15 – 0.5 | Cyan → Green | Intensity scales linearly with amplitude |
| ≥ 0.5 | Red + White flash | Both channels scale with amplitude |
Booper pattern
The EMA + gamma approach comes from the Booper app in the main firmware, which uses audioManager.getMicVolumeLinear() to drive reactive LEDs from the microphone. Same smoothing math, different input source.
Pulse¶
A gentle breathing animation independent of audio. Uses a sine wave to modulate blue LED brightness. Runs at the same 30fps update rate as reactive mode.
LED submenu & per-LED control¶
The main menu's "LEDs" item opens a dedicated submenu with 6 options:
LEDs
├── Mode: Off / Reactive / Pulse (Enter cycles)
├── Back LED: On / Off (Enter toggles)
├── Front Top: On / Off (Enter toggles)
├── Front Mid: On / Off (Enter toggles)
├── Front Bottom: On / Off (Enter toggles)
└── Sync: 150ms (Enter cycles 0-300ms in 25ms steps)
Per-LED enable mask¶
A uint8_t ledEnableMask bitmask controls which LEDs participate in effects:
| Bit | Pixel | Name |
|---|---|---|
| 0 | 0 | Back |
| 1 | 1 | Front Top |
| 2 | 2 | Front Middle |
| 3 | 3 | Front Bottom |
Default: 0x0F (all enabled). When a LED is disabled, it stays dark regardless of the active effect mode. When mode is Off, all LEDs are dark regardless of mask.
Toggling a LED off immediately clears that pixel via HAL::setRgbLed(). The mask is persisted in NVS so your configuration survives reboots.
RGBController¶
The RGBController module manages the NeoPixel strip with a dirty-flag + throttle pattern:
- Dirty flag: Any color change calls
markDirty()but does NOT callstrip.show(). - Throttled show:
updateStrip()is called everyloopHardware()cycle. It only callsstrip.show()when dirty AND ≥33ms since last show (~30fps cap). - Dedup cache:
setDeterminedColorsAll()stores the last RGBW values and skips if identical. Prevents redundant SPI transactions when the color hasn't changed.
This keeps LED updates efficient — strip.show() for 4 RGBW pixels takes ~120μs, and it only fires when something actually changed.
markDirty() is exported from RGBController so that per-pixel HAL::setRgbLed() calls (used by the per-LED mask system) can trigger strip.show() on the next updateStrip() cycle.
OLED visualizer modes¶
Three modes, cycled with Up/Down buttons in the Now Playing screen:
Off (VIZ_OFF)¶
Normal playhead: elapsed M:SS + progress bar + remaining -M:SS.
Amplitude (VIZ_AMPLITUDE)¶
A 16-bar rolling amplitude graph showing the last ~1 second of audio:
┌────────────────────────────┐
│ ⚡ Now Playing 87 ▊│
│ The Artist Name │
│ ♫ Track Title Here ←scroll │
│ │
│ ▶ Playing [S] │
│ ▃▅▇█▅▃▁▃▅▇▆▄▂▁▃ │ ← Amplitude bars
└────────────────────────────┘
- 16 bars, each 7px wide + 1px gap = 128px total
- Height: 0–14px, proportional to amplitude at sample time
- Sample rate: ~60ms intervals → last ~1 second of audio history
- Rolling buffer: Circular array
amplitudeHistory[16], oldest sample drawn leftmost
Spectrum (VIZ_SPECTRUM)¶
A VFD-style frequency spectrum display with 16 logarithmically-spaced bands:
┌────────────────────────────┐
│ ⚡ Now Playing 87 ▊│
│ The Artist Name │
│ ♫ Track Title Here ←scroll │
│ │
│ ▶ Playing [S] │
│ █▇▅▃▅▇█▅▃▁▂▃▅▇▆▄ │ ← Frequency bars
└────────────────────────────┘
- 16 bars spanning ~60 Hz to ~16 kHz, logarithmically spaced
- Fast attack, slow decay: Bars snap up instantly but fade down smoothly (like classic VFD EQ displays)
- Per-band AGC: Each frequency band normalizes to its own peak, so bass can't flatten the treble
Display priority
The volume overlay (slider-triggered) takes priority over both visualizers. When the slider is touched, the volume bar appears for 2 seconds, then the visualizer resumes.
How the spectrum analyzer works¶
The SpectrumAnalyzer uses a Fast Fourier Transform (FFT) to decompose audio into its frequency components. Here's the plain-English version of what happens:
1. Collect audio samples¶
The write() method receives raw PCM audio data (16-bit samples at 44,100 Hz). It forwards the audio to the Bluetooth output immediately, then copies mono samples (left channel only) into a 512-sample buffer. This gives us ~11.6ms of audio per window.
2. Apply a Hann window¶
Raw audio chunks have sharp edges at the start and end of each window, which creates false frequency content (spectral leakage). The Hann window is a bell-shaped curve that smoothly fades the edges to zero:
Hann[i] = 0.5 × (1 - cos(2π × i / 511))
Each sample is multiplied by its corresponding Hann coefficient before the FFT.
3. Run the FFT¶
A radix-2 Cooley-Tukey FFT transforms the 512 time-domain samples into 256 frequency bins. Each bin represents a ~86 Hz range (44100 / 512). The FFT is a self-contained implementation (~25 lines of C) that runs in-place — no external library dependencies.
The FFT runs from the main loop via processFFT(), not inside the audio write() path. This is critical — running FFT inside write() would block the Bluetooth audio stream and cause audible stuttering.
4. Map bins to bands¶
The 256 frequency bins are grouped into 16 logarithmically-spaced bands:
| Band | Frequency Range | What you hear |
|---|---|---|
| 0 | ~60–90 Hz | Sub-bass (kick drum) |
| 1 | ~90–130 Hz | Bass |
| 2–3 | ~130–265 Hz | Low-mid |
| 4–6 | ~265–770 Hz | Midrange (vocals) |
| 7–9 | ~770–2.2 kHz | Upper-mid (guitar, snare) |
| 10–12 | ~2.2–6.4 kHz | Presence/brilliance |
| 13–15 | ~6.4–16 kHz | Air (hi-hat, cymbals) |
The formula for band edges: edge[i] = 60 × (16000/60)^(i/16)
Logarithmic spacing is used because human hearing is logarithmic — the difference between 100 Hz and 200 Hz sounds the same as 1000 Hz and 2000 Hz. Equal-width bins would cram all the bass into one bar and waste 200+ bars on inaudible ultrasonic frequencies.
5. Per-band AGC (Automatic Gain Control)¶
Each band tracks its own maximum magnitude independently. The displayed value is bandPeak / bandMax, where bandMax slowly decays over time (~3 seconds to halve). This means:
- Bass normalizes to bass levels — a loud kick drum drives band 0 to 1.0
- Treble normalizes to treble levels — a hi-hat drives band 15 to 1.0
- Neither can flatten the other
Without per-band AGC, a heavy bass line would dominate the normalization and make all the treble bars appear flat — a common problem in naive spectrum analyzers.
6. Frame skipping¶
At 44,100 Hz with 512-sample windows, the raw FFT rate would be ~86 Hz — that's a lot of FFT computation per second. The SKIP_FRAMES constant (currently 1) tells the analyzer to process every other window, yielding ~43 Hz update rate. This balances visual smoothness against CPU overhead.
LED + FFT micro-stutter
When both music-reactive LEDs and the spectrum visualizer are active simultaneously, occasional micro-stutters may occur. This is because NeoPixel strip.show() uses the RMT peripheral (~120μs) which can compete with the audio write path. LEDs default to Off to avoid this. This is earmarked for future investigation (potential fix: reduce LED update rate when FFT is active, or move LEDs to a separate FreeRTOS task).
BT latency compensation (Sync Delay)¶
The problem¶
The SpectrumAnalyzer analyzes audio as it enters the A2DP transmit buffer. The BT speaker plays that audio ~200–500ms later (A2DP buffering + codec + link latency + speaker's own buffer). Without compensation, LEDs flash before the corresponding sound is heard.
How it works¶
The SpectrumAnalyzer maintains a delay ring buffer — a circular array of 16 recent analysis frames, each timestamped with millis(). Instead of returning the current amplitude and spectrum data, the delayed accessors (delayedVolumeRatio(), delayedBands()) walk the ring buffer and return the frame closest to millis() - delayMs.
Audio pipeline timeline:
t=0ms Audio enters SpectrumAnalyzer → analysis stored in ring buffer
t=150ms delayedVolumeRatio() returns the t=0 analysis → LEDs react
t=150ms Speaker plays the t=0 audio → LEDs match what you hear
Buffer details¶
| Property | Value |
|---|---|
| Buffer size | 16 frames |
| Frame contents | 16 band magnitudes + peak amplitude + timestamp |
| Memory cost | ~1.2 KB |
| Update rate | ~43 Hz (when spectrum enabled) or main loop rate |
| Max delay | ~350ms at 43 Hz update rate |
The ring buffer is populated from two paths:
processFFT(): When the spectrum visualizer is active, each FFT result automatically stores a framestoreDelayFrame(): When spectrum is off (VIZ_AMPLITUDE or VIZ_OFF mode), the main loop explicitly stores amplitude-only frames to keep the delay buffer current for LED effects
User-tunable delay¶
The Sync setting in the LED submenu adjusts the delay from 0–300ms in 25ms steps. The default is 150ms, which works well for most BT speakers. Speakers with larger internal buffers may need 200–300ms; wired or low-latency codecs may work best at 50–100ms.
Finding the right delay
Play a track with a strong beat (kick drum on every beat). Watch the LEDs flash red on the kick. If the flash comes before the sound, increase the delay. If after, decrease it. The sweet spot is when the flash and the thump feel simultaneous.
NVS settings persistence¶
User preferences survive reboots via NVS namespace "mpsettings":
| Key | Type | Default | Values |
|---|---|---|---|
shuffle |
bool | false | Shuffle state |
ledmode |
uint8_t | 0 (Off) | 0=Off, 1=Reactive, 2=Pulse |
vizmode |
uint8_t | 0 (Off) | 0=Off, 1=Amplitude, 2=Spectrum |
ledmask |
uint8_t | 0x0F (all on) | Bitmask: bit0=Back, bit1=FrontTop, bit2=FrontMid, bit3=FrontBot |
leddelay |
uint16_t | 150 | BT latency compensation in ms (0–300) |
Settings are loaded in begin() after loadPlaybackState() and saved in end() before savePlaybackState().
Controls¶
| Button | Context | Action |
|---|---|---|
| Up / Down | Now Playing | Cycle visualizer: Off → Amplitude → Spectrum |
| Enter | LED submenu: Mode | Cycle LED mode: Off → Reactive → Pulse |
| Enter | LED submenu: LED items | Toggle individual LED on/off |
Resource impact¶
| Resource | Phase 4 | Phase 4.1 + 4.2 | Phase 4.3 | Notes |
|---|---|---|---|---|
| Flash | 74.8% | 74.9% | 75.0% | +~1 KB for AVRCP TG + delay buffer |
| RAM | 22.7% | 22.7% | 22.7% | +~1.2 KB delay ring buffer (within margin) |
| IRAM | OK | OK | OK | ~2-4 KB (NeoPixel RMT driver) |