Back to Fastled

FastLED Audio

src/fl/audio/README.md

3.10.340.9 KB
Original Source

FastLED Audio

Quick Start Examples

The simplest way to get audio-reactive LEDs. FastLED.add() creates the microphone, wires up a scheduler task that auto-reads samples, and returns an AudioProcessor ready for callbacks. No manual update() loop needed — audio is pumped automatically during FastLED.show().

cpp
#include "FastLED.h"

#define NUM_LEDS 60
#define LED_PIN 2

// I2S pins for INMP441 microphone (adjust for your board)
#define I2S_WS  7
#define I2S_SD  8
#define I2S_CLK 4

CRGB leds[NUM_LEDS];

void setup() {
    FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
    FastLED.setBrightness(128);

    // One line: create mic + auto-pump task
    // FastLED stores the AudioProcessor internally, so no global needed
    auto config = fl::AudioConfig::CreateInmp441(I2S_WS, I2S_SD, I2S_CLK, fl::Right);
    auto audio = FastLED.add(config);
    audio->setAutoGainEnabled(true);

    // Flash white on every beat
    audio->onBeat([] {
        fill_solid(leds, NUM_LEDS, CRGB::White);
    });

    // Map bass level to hue
    audio->onBass([](float level) {
        uint8_t hue = static_cast<uint8_t>(level * 160);
        fill_solid(leds, NUM_LEDS, CHSV(hue, 255, 255));
    });

    // Dim to black on silence
    audio->onSilenceStart([] {
        fill_solid(leds, NUM_LEDS, CRGB::Black);
    });

    // audio goes out of scope here — that's fine, FastLED keeps it alive.
    // Use FastLED.remove(audio) if you ever need to tear it down.
}

void loop() {
    fadeToBlackBy(leds, NUM_LEDS, 20);
    FastLED.show();  // Audio is auto-pumped here
}

How it works: FastLED.add(config) internally calls IAudioInput::create(), starts the mic, stores the AudioProcessor in an internal list, and creates a fl::task::every_ms(1) that drains all buffered samples and feeds them to the AudioProcessor. The task runs during FastLED.show() via end-frame → async_run()Scheduler::update(). Use FastLED.remove(audio) to tear down a specific processor, or let it live for the lifetime of the program.

Platform behavior:

  • ESP32 / Teensy (FASTLED_HAS_AUDIO_INPUT == 1): Real I2S mic is created and pumped.
  • Host / WASM / AVR (FASTLED_HAS_AUDIO_INPUT == 0): Returns a valid but inert AudioProcessor — callbacks never fire, polling getters return zero. Code compiles everywhere without #ifdef.

Test injection: On any platform, pass a custom IAudioInput directly:

cpp
auto fakeInput = fl::make_shared<MyTestAudioSource>();
auto audio = FastLED.add(fakeInput);  // works on host/stub too

High-Level: Beat-Reactive LEDs (Manual Update)

If you need more control over when samples are read (e.g., reading from a buffer at a specific rate), you can create the AudioProcessor yourself and call update() manually.

cpp
#include "FastLED.h"
#include "fl/audio/input.h"
#include "fl/audio/audio_processor.h"

using namespace fl;

#define NUM_LEDS 60
#define DATA_PIN 5

CRGB leds[NUM_LEDS];
AudioProcessor audio;
shared_ptr<IAudioInput> mic;
uint8_t gHue = 0;
bool gBeat = false;

void setup() {
    FastLED.addLeds<WS2812B, DATA_PIN, GRB>(leds, NUM_LEDS);

    // Create microphone input (ESP32 + INMP441)
    auto config = AudioConfig::CreateInmp441(
        15,  // WS pin
        32,  // SD pin
        14,  // SCK pin
        Left
    );
    string errorMsg;
    mic = IAudioInput::create(config, &errorMsg);
    if (!mic) {
        // Handle error - check errorMsg
        return;
    }
    mic->start();

    // Register event callbacks
    audio.onBeat([]() {
        gBeat = true;
    });

    audio.onBass([](float level) {
        // level is 0.0 - 1.0
        fill_solid(leds, NUM_LEDS / 3, CHSV(0, 255, level * 255));
    });

    audio.onMid([](float level) {
        fill_solid(leds + NUM_LEDS / 3, NUM_LEDS / 3, CHSV(96, 255, level * 255));
    });

    audio.onTreble([](float level) {
        fill_solid(leds + 2 * NUM_LEDS / 3, NUM_LEDS / 3, CHSV(160, 255, level * 255));
    });
}

void loop() {
    AudioSample sample = mic->read();
    if (sample.isValid()) {
        audio.update(sample);  // Manual update
    }

    if (gBeat) {
        gBeat = false;
        // Flash white on beat
        fill_solid(leds, NUM_LEDS, CRGB::White);
    }

    fadeToBlackBy(leds, NUM_LEDS, 20);
    FastLED.show();
}

High-Level: Percussion-Reactive LEDs

Use onKick(), onSnare(), and onHiHat() callbacks to trigger different colors for each drum hit.

cpp
#include "FastLED.h"
#include "fl/audio/input.h"
#include "fl/audio/audio_processor.h"

using namespace fl;

#define NUM_LEDS 60
#define DATA_PIN 5

CRGB leds[NUM_LEDS];
AudioProcessor audio;
shared_ptr<IAudioInput> mic;
CRGB gFlashColor = CRGB::Black;

void setup() {
    FastLED.addLeds<WS2812B, DATA_PIN, GRB>(leds, NUM_LEDS);

    auto config = AudioConfig::CreateInmp441(15, 32, 14, Left);
    string errorMsg;
    mic = IAudioInput::create(config, &errorMsg);
    if (!mic) return;
    mic->start();

    // Each drum hit triggers a different color
    audio.onKick([]() {
        gFlashColor = CRGB::Red;
    });

    audio.onSnare([]() {
        gFlashColor = CRGB::Yellow;
    });

    audio.onHiHat([]() {
        gFlashColor = CRGB::Cyan;
    });

    audio.onTom([]() {
        gFlashColor = CRGB::Purple;
    });
}

void loop() {
    AudioSample sample = mic->read();
    if (sample.isValid()) {
        audio.update(sample);
    }

    if (gFlashColor != CRGB(CRGB::Black)) {
        fill_solid(leds, NUM_LEDS, gFlashColor);
        gFlashColor = CRGB::Black;
    }

    fadeToBlackBy(leds, NUM_LEDS, 30);
    FastLED.show();
}

High-Level: Polling API (No Callbacks)

If you prefer polling over callbacks, AudioProcessor also provides getter methods that return uint8_t-scaled values (0-255), perfect for direct LED control.

cpp
#include "FastLED.h"
#include "fl/audio/input.h"
#include "fl/audio/audio_processor.h"

using namespace fl;

#define NUM_LEDS 60
#define DATA_PIN 5

CRGB leds[NUM_LEDS];
AudioProcessor audio;
shared_ptr<IAudioInput> mic;

void setup() {
    FastLED.addLeds<WS2812B, DATA_PIN, GRB>(leds, NUM_LEDS);

    auto config = AudioConfig::CreateInmp441(15, 32, 14, Left);
    string errorMsg;
    mic = IAudioInput::create(config, &errorMsg);
    if (!mic) return;
    mic->start();
}

void loop() {
    AudioSample sample = mic->read();
    if (sample.isValid()) {
        audio.update(sample);
    }

    // Poll values directly - all return 0-255
    uint8_t bass   = audio.getBassLevel();
    uint8_t mid    = audio.getMidLevel();
    uint8_t treble = audio.getTrebleLevel();
    uint8_t energy = audio.getEnergy();

    // Map bass to hue, energy to brightness
    fill_solid(leds, NUM_LEDS, CHSV(bass, 255, energy));

    // Strobe on beat
    if (audio.isBeat()) {
        fill_solid(leds, NUM_LEDS, CRGB::White);
    }

    // Percussion polling
    if (audio.isKick()) {
        fill_solid(leds, NUM_LEDS / 3, CRGB::Red);
    }
    if (audio.isSnare()) {
        fill_solid(leds + NUM_LEDS / 3, NUM_LEDS / 3, CRGB::Yellow);
    }

    // Tempo-aware effects
    float bpm = audio.getBPM();
    // Use bpm to sync animation speed

    FastLED.show();
}

WLED-Style Equalizer (16-Bin Spectrum)

A dead-simple WLED-compatible equalizer: 16 frequency bins normalized to 0.0-1.0, plus bass/mid/treble/volume/zcf convenience getters. All values are pre-normalized — just multiply by 255 if you want bytes.

cpp
#include "FastLED.h"

#define NUM_LEDS 16
CRGB leds[NUM_LEDS];
fl::shared_ptr<fl::AudioProcessor> audio;

void setup() {
    FastLED.addLeds<WS2812B, 2, GRB>(leds, NUM_LEDS);

    auto config = fl::AudioConfig::CreateInmp441(7, 8, 4, fl::Right);
    audio = FastLED.add(config);

    // Callback: get everything in one struct
    audio->onEqualizer([](const fl::Equalizer& eq) {
        // eq.bass, eq.mid, eq.treble, eq.volume, eq.zcf — all 0.0-1.0
        // eq.bins — span<const float, 16>, each 0.0-1.0
        for (int i = 0; i < 16; ++i) {
            uint8_t brightness = static_cast<uint8_t>(eq.bins[i] * 255);
            leds[i] = CHSV(i * 16, 255, brightness);
        }
    });
}

void loop() {
    FastLED.show();
}

Or use polling — no callbacks needed:

cpp
void loop() {
    // All return 0.0-1.0
    float bass   = audio->getEqBass();
    float mid    = audio->getEqMid();
    float treble = audio->getEqTreble();
    float volume = audio->getEqVolume();
    float zcf    = audio->getEqZcf();
    float bin5   = audio->getEqBin(5);  // 16 bins (0-15)

    fill_solid(leds, NUM_LEDS, CHSV(bass * 160, 255, volume * 255));
    FastLED.show();
}

Bin layout (WLED-compatible):

BinsRangeGetter
0-3~60-320 Hz (bass)getEqBass()
4-10~320-2560 Hz (mid)getEqMid()
11-15~2560-5120 Hz (treble)getEqTreble()

VibeDetector: MilkDrop-Inspired Self-Normalizing Audio Analysis

VibeDetector provides self-normalizing, FPS-independent bass/mid/treb levels with asymmetric attack/decay smoothing. The algorithm is a direct port of Ryan Geiss's DoCustomSoundAnalysis() from MilkDrop v2.25c — the legendary Winamp visualizer. See MILK_DROP_AUDIO_REACTIVE.md for a detailed technical analysis of the original algorithm.

Key properties:

  • Self-normalizing: Levels hover around 1.0 regardless of volume, mic sensitivity, or genre
  • Asymmetric smoothing: Fast attack (beats hit hard), slow decay (graceful fade)
  • Dual timescale: Short-term average for beat tracking, long-term average for song-level adaptation
  • FPS-independent: Identical behavior at 30fps, 60fps, or 144fps
  • Spike detection: bass > bass_att means a beat is happening right now

Understanding the Values

Vibe levels are not 0.0-1.0 like the Equalizer. They are ratios against the long-term average energy of the current song:

ValueMeaning
1.0Average level for this song/environment
> 1.0Louder than recent average (spike/beat)
< 1.0Quieter than recent average
~0.7Quiet passage
~1.3Loud passage / beat hit

The self-normalization means the same preset code works on quiet acoustic songs and loud electronic music without any gain or threshold calibration.

Two Sets of Levels: Immediate vs Smoothed

Each band has two relative levels:

  • bass / mid / treb — Immediate relative level. Reacts instantly to audio changes.
  • bassAtt / midAtt / trebAtt — Smoothed ("attenuated") relative level. Follows the signal with asymmetric attack/decay: fast attack (80% new signal on beats), slow decay (graceful fadeout).

The relationship between these two is the core of MilkDrop's beat detection:

  • When bass > bassAtt, energy is rising — a beat is happening
  • When bass < bassAtt, energy is falling — fading out between beats

Beat Detection Pattern

The canonical MilkDrop idiom for beat-reactive effects:

cpp
// In your loop:
float bass = audio->getVibeBass();         // immediate relative
float bassAtt = audio->getVibeBassAtt();   // smoothed relative

// Beat intensity: positive when a beat is hitting, zero/negative otherwise
float beatIntensity = bass - bassAtt;

// Binary beat: true/false
bool beat = bass > bassAtt;
// Or use the convenience method:
bool beat = audio->isVibeBassSpike();

// Scale an effect proportionally to the music's dynamics:
float zoom = 1.0f + 0.1f * (bass - 1.0f);

// Decay that responds to bass hits:
uint8_t decay = 240 + static_cast<uint8_t>(bass * 10);

Choosing Between Vibe and Other Band APIs

APIRangeUse when...
getVibeBass()~1.0 (unbounded)You want self-normalizing, beat-reactive effects that adapt to any song
getBassLevel()0.0-1.0You want simple normalized levels for brightness/color mapping
getEqBass()0.0-1.0You want WLED-compatible spectrum analysis
getBassRaw()0+ (absolute)You need raw FFT energy for custom algorithms

Example: Callback API

cpp
#include "FastLED.h"

#define NUM_LEDS 60
#define LED_PIN 2

// I2S pins for INMP441 microphone
#define I2S_WS  7
#define I2S_SD  8
#define I2S_CLK 4

CRGB leds[NUM_LEDS];
fl::shared_ptr<fl::AudioProcessor> audio;

void setup() {
    FastLED.addLeds<WS2812B, LED_PIN, GRB>(leds, NUM_LEDS);
    FastLED.setBrightness(128);

    auto config = fl::AudioConfig::CreateInmp441(I2S_WS, I2S_SD, I2S_CLK, fl::Right);
    audio = FastLED.add(config);

    // Flash white on bass spike (rising edge only)
    audio->onVibeBassSpike([] {
        fill_solid(leds, NUM_LEDS, CRGB::White);
    });

    // React to all three bands every frame
    audio->onVibeLevels([](const fl::VibeLevels& v) {
        // v.bass/mid/treb hover around 1.0; >1 means louder than recent average
        uint8_t hue = static_cast<uint8_t>(v.mid * 80);
        uint8_t brightness = static_cast<uint8_t>(constrain(v.vol * 200, 0, 255));
        fill_solid(leds, NUM_LEDS, CHSV(hue, 255, brightness));
    });
}

void loop() {
    fadeToBlackBy(leds, NUM_LEDS, 20);
    FastLED.show();  // Audio is auto-pumped here
}

Polling API (Complete)

cpp
// ---- Immediate relative levels (~1.0 = average, unbounded) ----
float bass = audio->getVibeBass();       // Immediate relative bass
float mid  = audio->getVibeMid();        // Immediate relative mid
float treb = audio->getVibeTreb();       // Immediate relative treble
float vol  = audio->getVibeVol();        // Average of bass/mid/treb

// ---- Smoothed relative levels (for beat comparison) ----
float bassAtt = audio->getVibeBassAtt(); // Smoothed relative bass
float midAtt  = audio->getVibeMidAtt();  // Smoothed relative mid
float trebAtt = audio->getVibeTrebAtt(); // Smoothed relative treble
float volAtt  = audio->getVibeVolAtt();  // Average of smoothed bands

// ---- Spike detection (bass > bassAtt = beat) ----
bool bassBeat = audio->isVibeBassSpike();  // true when bass is rising
bool midBeat  = audio->isVibeMidSpike();   // true when mid is rising
bool trebBeat = audio->isVibeTrebSpike();  // true when treb is rising

VibeLevels Callback Struct

The onVibeLevels callback provides everything in one struct per frame:

cpp
struct VibeLevels {
    // Self-normalizing relative levels (~1.0 = average)
    float bass, mid, treb;     // Immediate relative
    float vol;                 // (bass + mid + treb) / 3

    // Spike detection
    bool bassSpike, midSpike, trebSpike;  // true when energy is rising

    // Absolute values (for advanced use)
    float bassRaw, midRaw, trebRaw;           // Immediate absolute energy
    float bassAvg, midAvg, trebAvg;           // Short-term smoothed absolute
    float bassLongAvg, midLongAvg, trebLongAvg; // Long-term average absolute
};

Spike Callbacks

Spike callbacks fire on the rising edge only (transition from no-spike to spike), so you get one event per beat rather than continuous firing:

cpp
audio->onVibeBassSpike([] { /* bass beat! */ });
audio->onVibeMidSpike([] { /* mid-range transient */ });
audio->onVibeTrebSpike([] { /* high-frequency hit (hi-hat, cymbal) */ });

Mid-Level: Custom Detector

Create your own detector by subclassing AudioDetector. This gives you direct access to AudioContext for FFT data while integrating into the update loop.

cpp
#include "FastLED.h"
#include "fl/audio/input.h"
#include "fl/audio/audio_context.h"
#include "fl/audio/audio_detector.h"
#include "fl/audio/audio_processor.h"

using namespace fl;

// Custom detector that triggers when a specific frequency band spikes
class SparkleDetector : public AudioDetector {
public:
    bool triggered = false;
    float intensity = 0.0f;

    // Tell the system we need FFT data
    bool needsFFT() const override { return true; }
    const char* getName() const override { return "SparkleDetector"; }

    void update(shared_ptr<AudioContext> context) override {
        // Access cached FFT (computed once, shared across all detectors)
        const FFTBins& bins = context->getFFT(16);

        // Check high-frequency bins (bins 12-15) for sparkle trigger
        float highEnergy = 0.0f;
        for (int i = 12; i < 16; i++) {
            highEnergy += bins.bins_raw[i];
        }
        highEnergy /= 4.0f;

        triggered = (highEnergy > 0.6f);
        intensity = highEnergy;
    }

    void fireCallbacks() override {
        // Called after all detectors have updated - safe to trigger effects
        // (In this simple example we just set flags read in loop())
    }

    void reset() override {
        triggered = false;
        intensity = 0.0f;
    }
};

Mid-Level: Multi-Detector with AudioContext

Create an AudioContext manually to share FFT data across multiple detectors. This demonstrates the two-phase update/fireCallbacks loop.

cpp
#include "FastLED.h"
#include "fl/audio/input.h"
#include "fl/audio/audio_context.h"
#include "fl/audio/audio_detector.h"

using namespace fl;

#define NUM_LEDS 60
#define DATA_PIN 5

CRGB leds[NUM_LEDS];
shared_ptr<IAudioInput> mic;

// Two simple detectors that share the same AudioContext
class BassMonitor : public AudioDetector {
public:
    float level = 0.0f;
    bool needsFFT() const override { return true; }
    const char* getName() const override { return "BassMonitor"; }

    void update(shared_ptr<AudioContext> context) override {
        const FFTBins& bins = context->getFFT(16);
        // Average first 4 bins (low frequencies)
        level = 0.0f;
        for (int i = 0; i < 4; i++) level += bins.bins_raw[i];
        level /= 4.0f;
    }
};

class TrebleMonitor : public AudioDetector {
public:
    float level = 0.0f;
    bool needsFFT() const override { return true; }
    const char* getName() const override { return "TrebleMonitor"; }

    void update(shared_ptr<AudioContext> context) override {
        const FFTBins& bins = context->getFFT(16);
        // Average last 4 bins (high frequencies)
        level = 0.0f;
        for (int i = 12; i < 16; i++) level += bins.bins_raw[i];
        level /= 4.0f;
    }
};

BassMonitor bassMonitor;
TrebleMonitor trebleMonitor;

void setup() {
    FastLED.addLeds<WS2812B, DATA_PIN, GRB>(leds, NUM_LEDS);

    auto config = AudioConfig::CreateInmp441(15, 32, 14, Left);
    string errorMsg;
    mic = IAudioInput::create(config, &errorMsg);
    if (!mic) return;
    mic->start();
}

void loop() {
    AudioSample sample = mic->read();
    if (!sample.isValid()) return;

    // Create a shared context - FFT is computed once, shared by both detectors
    auto context = make_shared<AudioContext>(sample);

    // Phase 1: Update all detectors (reads from context, no side effects)
    bassMonitor.update(context);
    trebleMonitor.update(context);

    // Phase 2: Fire callbacks (safe to trigger effects now)
    bassMonitor.fireCallbacks();
    trebleMonitor.fireCallbacks();

    // Use detector results
    uint8_t bassBrightness = bassMonitor.level * 255;
    uint8_t trebleBrightness = trebleMonitor.level * 255;

    for (int i = 0; i < NUM_LEDS / 2; i++) {
        leds[i] = CHSV(0, 255, bassBrightness);  // Red for bass
    }
    for (int i = NUM_LEDS / 2; i < NUM_LEDS; i++) {
        leds[i] = CHSV(160, 255, trebleBrightness);  // Blue for treble
    }

    FastLED.show();
}

Low-Level: Direct FFT Access

For full control, use AudioSample and FFT directly. This is useful when you want to build your own visualizer or analysis pipeline.

cpp
#include "FastLED.h"
#include "fl/audio/input.h"
#include "fl/audio/audio.h"
#include "fl/audio/fft/fft.h"

using namespace fl;

#define NUM_LEDS 16
#define DATA_PIN 5

CRGB leds[NUM_LEDS];
shared_ptr<IAudioInput> mic;
FFT fft;

void setup() {
    FastLED.addLeds<WS2812B, DATA_PIN, GRB>(leds, NUM_LEDS);

    auto config = AudioConfig::CreateInmp441(15, 32, 14, Left);
    string errorMsg;
    mic = IAudioInput::create(config, &errorMsg);
    if (!mic) return;
    mic->start();
}

void loop() {
    AudioSample sample = mic->read();
    if (!sample.isValid()) return;

    // Run FFT - 16 bins mapped to 16 LEDs
    FFTBins bins(16);
    FFT_Args args(512, 16, 174.6f, 4698.3f, 44100);
    fft.run(sample.pcm(), &bins, args);

    // Map FFT bins directly to LED brightness
    for (int i = 0; i < NUM_LEDS; i++) {
        uint8_t brightness = constrain(bins.bins_raw[i] * 255, 0, 255);
        leds[i] = CHSV(i * 16, 255, brightness);
    }

    FastLED.show();
}

Low-Level: Raw PCM Analysis

For the simplest audio reactivity without FFT, use AudioSample properties directly.

cpp
#include "FastLED.h"
#include "fl/audio/input.h"
#include "fl/audio/audio.h"

using namespace fl;

#define NUM_LEDS 30
#define DATA_PIN 5

CRGB leds[NUM_LEDS];
shared_ptr<IAudioInput> mic;

void setup() {
    FastLED.addLeds<WS2812B, DATA_PIN, GRB>(leds, NUM_LEDS);

    auto config = AudioConfig::CreateInmp441(15, 32, 14, Left);
    string errorMsg;
    mic = IAudioInput::create(config, &errorMsg);
    if (!mic) return;
    mic->start();
}

void loop() {
    AudioSample sample = mic->read();
    if (!sample.isValid()) return;

    float rms = sample.rms();       // Volume level (RMS amplitude)
    float zcf = sample.zcf();       // Zero-crossing factor (0.0 - 1.0)

    // High ZCF (> 0.4) = hissing/noise, low ZCF + high RMS = music
    bool isMusic = (zcf < 0.35f) && (rms > 500.0f);

    // Map RMS to LED count (simple VU meter)
    int litCount = map(constrain(rms, 0, 10000), 0, 10000, 0, NUM_LEDS);

    fill_solid(leds, NUM_LEDS, CRGB::Black);
    for (int i = 0; i < litCount; i++) {
        // Green -> Yellow -> Red gradient
        leds[i] = CHSV(96 - (i * 96 / NUM_LEDS), 255, isMusic ? 255 : 64);
    }

    FastLED.show();
}

Synthesizer: Generating Audio Output

The Synth module generates bandlimited waveforms for audio output. Useful for creating tones, alerts, or musical output from your microcontroller.

cpp
#include "FastLED.h"
#include "fl/audio/synth.h"

using namespace fl;

void setup() {
    Serial.begin(115200);

    // Create a shared engine (holds anti-aliasing tables)
    auto engine = ISynthEngine::create(32, 16);

    // Create oscillators
    auto saw = ISynthOscillator::create(engine, SynthShape::Sawtooth);
    auto sq  = ISynthOscillator::create(engine, SynthShape::Square);
    auto tri = ISynthOscillator::create(engine, SynthShape::Triangle);

    // Generate 256 samples of a 440 Hz tone at 44.1 kHz
    float buffer[256];
    float freq = 440.0f / 44100.0f;  // Normalized frequency
    saw->generateSamples(buffer, 256, freq);

    // Custom waveform via SynthParams(reflect, peakTime, halfHeight, zeroWait)
    SynthParams custom(1, 0.3f, 0.5f, 0.1f);
    auto osc = ISynthOscillator::create(engine, custom);
    osc->generateSamples(buffer, 256, freq);
}

void loop() {
    // Synth runs in setup for this demo
}

API Layers

┌──────────────────────────────────────────────┐
│  Your Sketch (.ino)                          │
├──────────────────────────────────────────────┤
│  FastLED.add(AudioConfig)  (integration)     │  ← Easiest
│    auto-pump via fl::task::every_ms(1)       │
│    returns shared_ptr<AudioProcessor>        │
├──────────────────────────────────────────────┤
│  AudioProcessor    (high-level facade)       │  ← Manual control
│    callbacks:  onBeat(), onBass(), ...       │
│    polling:    isBeat(), getBassLevel(), ... │
├──────────────────────────────────────────────┤
│  AudioContext      (shared FFT cache)        │  ← Intermediate
│  AudioDetector     (base class)              │
│  Detectors:  Beat, Vocal, Percussion, ...   │
├──────────────────────────────────────────────┤
│  AudioSample       (PCM + RMS + ZCF)         │  ← Low-level
│  FFT / FFTBins     (spectrum analysis)       │
│  SoundLevelMeter   (dBFS / SPL calibration)  │
├──────────────────────────────────────────────┤
│  IAudioInput       (hardware abstraction)    │  ← Platform
│    I2S, PDM, Teensy Audio Library            │
└──────────────────────────────────────────────┘

Choosing Your Level

LevelUse when...Key classes
Easiest (FastLED.add)You want audio-reactive LEDs with zero boilerplateFastLED.add(AudioConfig)
High (AudioProcessor)You want manual control over when samples are processedAudioProcessor
Mid (AudioContext)You're writing a custom detector or need shared FFT cachingAudioContext, AudioDetector
Low (AudioSample/FFT)You want raw spectrum data or PCM-level controlAudioSample, FFT, FFTBins
Output (Synth)You need to generate audio waveformsISynthEngine, ISynthOscillator

How It Works

Update Loop (3-Stage Pipeline)

When you call audio.update(sample), three stages run in sequence:

  1. Signal Conditioning — Raw PCM is cleaned: DC offset removal, spike filtering, noise gate. This stage modifies the sample before any analysis.

  2. Detector Update — Each active detector's update(context) is called with a shared AudioContext. Detectors read FFT/PCM data and compute their internal state, but do not fire callbacks yet. The FFT is computed lazily on first access and cached — if three detectors all call context->getFFT(16), the FFT runs only once.

  3. Callback Firing — After all detectors have finished updating, each detector's fireCallbacks() is called. This two-phase design prevents callback code from interfering with other detectors' analysis within the same frame.

Lazy Creation

Detectors are created only when you register a callback or call a polling getter. If you only use onBeat() and getBassLevel(), only BeatDetector and FrequencyBands are instantiated. The rest consume zero memory.

FFT Caching

AudioContext caches FFT results per frame. Multiple detectors requesting the same FFT parameters share a single computation. This is why the mid-level API passes a shared_ptr<AudioContext> — it's the shared cache.


AudioProcessor Event Reference

Callbacks

CategoryCallbacks
BeatonBeat(void()), onBeatPhase(float), onOnset(float), onTempoChange(float, float)
TempoonTempo(float), onTempoWithConfidence(float, float), onTempoStable(), onTempoUnstable()
FrequencyonBass(float), onMid(float), onTreble(float), onFrequencyBands(float, float, float)
EnergyonEnergy(float), onNormalizedEnergy(float), onPeak(float), onAverageEnergy(float)
TransientonTransient(), onTransientWithStrength(float), onAttack(float)
SilenceonSilence(u8), onSilenceStart(), onSilenceEnd(), onSilenceDuration(u32)
PitchonPitch(float), onPitchWithConfidence(float, float), onPitchChange(float), onVoiced(u8)
NoteonNoteOn(u8, u8), onNoteOff(u8), onNoteChange(u8, u8)
PercussiononPercussion(PercussionType), onKick(), onSnare(), onHiHat(), onTom()
VocalonVocal(u8), onVocalStart(), onVocalEnd(), onVocalConfidence(float)
DynamicsonCrescendo(), onDiminuendo(), onDynamicTrend(float), onCompressionRatio(float)
DownbeatonDownbeat(), onMeasureBeat(u8), onMeterChange(u8), onMeasurePhase(float)
BackbeatonBackbeat(u8, float, float)
ChordonChord(Chord), onChordChange(Chord), onChordEnd()
KeyonKey(Key), onKeyChange(Key), onKeyEnd()
MoodonMood(Mood), onMoodChange(Mood), onValenceArousal(float, float)
BuilduponBuildupStart(), onBuildupProgress(float), onBuildupPeak(), onBuildupEnd(), onBuildup(Buildup)
DroponDrop(), onDropEvent(Drop), onDropImpact(float)
EqualizeronEqualizer(const Equalizer&)

Polling Getters

cpp
// Beat Detection
audio.isBeat();              // u8 - Beat detected this frame?
audio.getBeatConfidence();   // u8 - Beat confidence 0-255
audio.getBPM();              // float - Estimated BPM

// Frequency Bands (0-255)
audio.getBassLevel();
audio.getMidLevel();
audio.getTrebleLevel();

// Energy
audio.getEnergy();           // u8 - Overall energy 0-255
audio.getPeakLevel();        // u8 - Peak level 0-255

// Percussion
audio.isKick();              // u8 - Kick drum detected?
audio.isSnare();             // u8 - Snare detected?
audio.isHiHat();             // u8 - Hi-hat detected?
audio.isTom();               // u8 - Tom detected?

// Vocal
audio.isVocalActive();       // u8 - Vocals present?
audio.getVocalConfidence();  // u8 - Vocal confidence 0-255

// Silence
audio.isSilent();            // u8 - Silence detected?
audio.getSilenceDuration();  // u32 - Silence duration in ms

// Transient
audio.isTransient();         // u8 - Transient detected?
audio.getTransientStrength();// u8 - Transient strength 0-255

// Dynamics
audio.isCrescendo();         // u8 - Volume increasing?
audio.isDiminuendo();        // u8 - Volume decreasing?
audio.getDynamicTrend();     // u8 - Trend direction 0-255

// Pitch
audio.getPitch();            // float - Pitch in Hz
audio.getPitchConfidence();  // u8 - Confidence 0-255
audio.isVoiced();            // u8 - Pitched sound detected?

// Tempo
audio.getTempoBPM();         // float - Tempo in BPM
audio.getTempoConfidence();  // u8 - Tempo confidence 0-255
audio.isTempoStable();       // u8 - Tempo locked?

// Note
audio.getCurrentNote();      // u8 - MIDI note number
audio.getNoteVelocity();     // u8 - Note velocity 0-255
audio.isNoteActive();        // u8 - Note currently playing?

// Downbeat / Structure
audio.isDownbeat();          // u8 - Downbeat this frame?
audio.getCurrentBeatNumber();// u8 - Beat within measure
audio.getMeasurePhase();     // u8 - Phase through measure 0-255

// Backbeat
audio.getBackbeatConfidence(); // u8 - Backbeat confidence 0-255
audio.getBackbeatStrength();   // u8 - Backbeat strength 0-255

// Buildup / Drop (EDM)
audio.isBuilding();          // u8 - Buildup in progress?
audio.getBuildupProgress();  // u8 - Buildup progress 0-255
audio.getBuildupIntensity(); // u8 - Buildup intensity 0-255
audio.getDropImpact();       // u8 - Drop impact 0-255

// Chord / Key / Mood
audio.hasChord();            // u8 - Chord detected?
audio.getChordConfidence();  // u8 - Chord confidence 0-255
audio.hasKey();              // u8 - Key detected?
audio.getKeyConfidence();    // u8 - Key confidence 0-255
audio.getMoodValence();      // u8 - Happy/sad 0-255
audio.getMoodArousal();      // u8 - Calm/energetic 0-255

// Equalizer (WLED-style, all 0.0-1.0)
audio.getEqBass();           // float - Bass level 0.0-1.0
audio.getEqMid();            // float - Mid level 0.0-1.0
audio.getEqTreble();         // float - Treble level 0.0-1.0
audio.getEqVolume();         // float - Volume 0.0-1.0 (AGC-normalized)
audio.getEqZcf();            // float - Zero-crossing factor 0.0-1.0
audio.getEqBin(0);           // float - Bin 0 level 0.0-1.0 (16 bins total)

Writing a Custom Detector

To create your own detector that integrates with AudioProcessor:

Step 1: Subclass AudioDetector

cpp
#include "fl/audio/audio_detector.h"
#include "fl/audio/audio_context.h"

class MyDetector : public fl::AudioDetector {
public:
    // Declare FFT needs (return true if your detector uses frequency data)
    bool needsFFT() const override { return true; }

    // Return true if you need multiple frames of FFT history
    bool needsFFTHistory() const override { return false; }

    // Unique name for debugging
    const char* getName() const override { return "MyDetector"; }

    // Phase 1: Read from context, compute internal state
    // Do NOT fire callbacks or cause side effects here
    void update(fl::shared_ptr<fl::AudioContext> context) override {
        const fl::FFTBins& bins = context->getFFT(16);
        // ... analyze bins, update internal state ...
    }

    // Phase 2: Fire callbacks, set flags, trigger effects
    // Called after ALL detectors have updated
    void fireCallbacks() override {
        // ... notify listeners ...
    }

    // Optional: handle sample rate changes
    void setSampleRate(int rate) override { mSampleRate = rate; }

    // Optional: reset state
    void reset() override { /* ... */ }

private:
    int mSampleRate = 44100;
};

Step 2: Use with AudioContext

Wire your detector into a manual update loop (see the Multi-Detector example above), or use it standalone:

cpp
auto context = fl::make_shared<fl::AudioContext>(sample);
myDetector.update(context);
myDetector.fireCallbacks();

Signal Conditioning

AudioProcessor includes a three-stage signal conditioning pipeline. Each stage can be enabled/disabled independently.

Enable/Disable

cpp
audio.setSignalConditioningEnabled(true);   // DC removal, spike filter, noise gate
audio.setAutoGainEnabled(true);             // Automatic gain control
audio.setNoiseFloorTrackingEnabled(true);   // Adaptive noise floor

Configuration Structs

SignalConditionerConfig — Cleans raw I2S/PCM data:

FieldDefaultDescription
enableDCRemovaltrueRemove DC offset via running-average high-pass filter
enableSpikeFiltertrueReject I2S glitch samples beyond threshold
enableNoiseGatetrueHysteresis-based noise gate
spikeThreshold10000Absolute sample value beyond which samples are rejected
noiseGateOpenThreshold500Signal must exceed this to open the gate
noiseGateCloseThreshold300Signal must fall below this to close the gate
dcRemovalAlpha0.99fTime constant (higher = slower DC adaptation)

AutoGainConfig — Adaptive gain using PI controller with peak envelope tracking (WLED-style):

FieldDefaultDescription
presetAGCPreset_NormalBehavior preset: Normal, Vivid, Lazy, or Custom
minGain1/64Minimum gain multiplier
maxGain32.0fMaximum gain multiplier
targetRMSLevel8000.0fTarget RMS level after gain (0-32767)
peakDecayTau3.3fPeak envelope decay (seconds, Custom only)
kp0.6fPI proportional gain (Custom only)
ki1.7fPI integral gain (Custom only)
gainFollowSlowTau12.3fSlow gain-follow tau (seconds, Custom only)
gainFollowFastTau0.38fFast gain-follow tau (seconds, Custom only)

AGC Presets:

ParameterNormalVividLazy
peakDecayTau3.3s1.3s6.7s
kp0.61.50.65
ki1.71.851.2
gainFollowSlowTau12.3s8.2s16.4s
gainFollowFastTau0.38s0.26s0.51s

NoiseFloorTrackerConfig — Adaptive noise floor with hysteresis:

FieldDefaultDescription
decayRate0.99fHow slowly the floor decays (higher = more stable)
attackRate0.001fHow quickly the floor rises when signal is low
hysteresisMargin100.0fFloor must drop by this before it can rise again
minFloor10.0fPrevents floor from reaching zero
maxFloor5000.0fPrevents floor from growing unbounded
crossDomainWeight0.3fBlend of time-domain (0.0) vs frequency-domain (1.0)

Tuning Example

cpp
// Noisy environment: raise thresholds, slower adaptation
SignalConditionerConfig scConfig;
scConfig.spikeThreshold = 15000;
scConfig.noiseGateOpenThreshold = 1000;
scConfig.noiseGateCloseThreshold = 700;
audio.configureSignalConditioner(scConfig);

// Quiet venue: vivid preset for faster adaptation
AutoGainConfig agcConfig;
agcConfig.preset = AGCPreset_Vivid;
agcConfig.targetRMSLevel = 12000.0f;
audio.configureAutoGain(agcConfig);

// Outdoor use: wide hysteresis to handle wind noise
NoiseFloorTrackerConfig nfConfig;
nfConfig.hysteresisMargin = 300.0f;
nfConfig.decayRate = 0.995f;
audio.configureNoiseFloorTracker(nfConfig);

Detector Quick Reference

DetectorFFT?History?Key CallbacksKey Polling
BeatDetectorYesNoonBeat(), onOnset(float)isBeat(), getBeatConfidence()
TempoAnalyzerNoNoonTempo(float), onTempoStable()getTempoBPM(), isTempoStable()
EqualizerDetectorYesNoonEqualizer(const Equalizer&)getEqBass(), getEqMid(), getEqTreble(), getEqVolume(), getEqZcf(), getEqBin(int)
FrequencyBandsYesNoonBass(float), onMid(float), onTreble(float)getBassLevel(), getMidLevel(), getTrebleLevel()
EnergyAnalyzerNoNoonEnergy(float), onPeak(float)getEnergy(), getPeakLevel()
TransientDetectorYesNoonTransient(), onAttack(float)isTransient(), getTransientStrength()
SilenceDetectorNoNoonSilenceStart(), onSilenceEnd()isSilent(), getSilenceDuration()
DynamicsAnalyzerNoNoonCrescendo(), onDiminuendo()isCrescendo(), isDiminuendo()
PitchDetectorYesNoonPitch(float), onVoiced(u8)getPitch(), isVoiced()
NoteDetectorYesNoonNoteOn(u8, u8), onNoteOff(u8)getCurrentNote(), isNoteActive()
DownbeatDetectorNoNoonDownbeat(), onMeasureBeat(u8)isDownbeat(), getCurrentBeatNumber()
BackbeatDetectorNoNoonBackbeat(u8, float, float)getBackbeatConfidence()
VocalDetectorYesNoonVocalStart(), onVocalEnd()isVocalActive(), getVocalConfidence()
PercussionDetectorYesNoonKick(), onSnare(), onHiHat(), onTom()isKick(), isSnare(), isHiHat(), isTom()
ChordDetectorYesYesonChord(Chord), onChordChange(Chord)hasChord(), getChordConfidence()
KeyDetectorYesYesonKey(Key), onKeyChange(Key)hasKey(), getKeyConfidence()
MoodAnalyzerYesYesonMood(Mood), onValenceArousal(float, float)getMoodValence(), getMoodArousal()
BuildupDetectorYesNoonBuildupStart(), onBuildupPeak()isBuilding(), getBuildupProgress()
DropDetectorYesNoonDrop(), onDropImpact(float)getDropImpact()
VibeDetectorYesNoonVibeLevels(const VibeLevels&), onVibeBassSpike(), onVibeMidSpike(), onVibeTrebSpike()getVibeBass(), getVibeMid(), getVibeTreb(), getVibeVol(), isVibeBassSpike()

Platform Support

PlatformMicrophoneConfiguration
ESP32INMP441 (I2S)AudioConfig::CreateInmp441(ws, sd, clk, channel)
ESP32PDM micAudioConfig(AudioConfigPdm(din, clk, i2s_num))
TeensyI2S micAudioConfig::CreateTeensyI2S(port, channel)

File Structure

fl/audio/
├── README.md                    # This file
├── input.h                      # AudioConfig, IAudioInput (hardware abstraction)
├── audio_context.h/.cpp.hpp     # Shared FFT cache (lazy evaluation)
├── audio_detector.h             # Base class for all detectors
├── audio_processor.h/.cpp.hpp   # High-level facade (callbacks + polling + auto-pump)
├── synth.h/.cpp.hpp             # Bandlimited waveform synthesizer
├── auto_gain.h/.cpp.hpp         # Automatic gain control
├── signal_conditioner.h/.cpp.hpp # Signal conditioning pipeline
├── noise_floor_tracker.h/.cpp.hpp # Adaptive noise floor
├── frequency_bin_mapper.h/.cpp.hpp # FFT bin frequency mapping
├── spectral_equalizer.h/.cpp.hpp # Spectral equalization
└── detectors/                   # All detector implementations
    ├── beat.h         # Beat detection
    ├── vocal.h        # Vocal presence detection
    ├── percussion.h   # Kick/snare/hihat/tom detection
    ├── pitch.h        # Pitch estimation
    ├── note.h         # MIDI-style note detection
    ├── chord.h        # Chord recognition
    ├── key.h          # Musical key detection
    ├── mood_analyzer.h # Valence/arousal analysis
    ├── tempo_analyzer.h # BPM estimation
    ├── downbeat.h     # Downbeat / measure tracking
    ├── backbeat.h     # Backbeat detection
    ├── buildup.h      # Buildup detection (EDM)
    ├── drop.h         # Drop detection (EDM)
    ├── vibe.h/.cpp.hpp # MilkDrop-inspired self-normalizing audio analysis
    ├── transient.h    # Transient / attack detection
    ├── silence.h      # Silence detection
    ├── dynamics_analyzer.h # Crescendo / diminuendo
    ├── energy_analyzer.h   # RMS energy tracking
    ├── equalizer.h    # WLED-style 16-bin equalizer (0.0-1.0)
    └── frequency_bands.h   # Bass/mid/treble splitting