src/fl/audio/README.md
FastLED.add(AudioConfig) (Recommended)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().
#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:
FASTLED_HAS_AUDIO_INPUT == 1): Real I2S mic is created and pumped.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:
auto fakeInput = fl::make_shared<MyTestAudioSource>();
auto audio = FastLED.add(fakeInput); // works on host/stub too
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.
#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();
}
Use onKick(), onSnare(), and onHiHat() callbacks to trigger different colors for each drum hit.
#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();
}
If you prefer polling over callbacks, AudioProcessor also provides getter methods that return uint8_t-scaled values (0-255), perfect for direct LED control.
#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();
}
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.
#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:
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):
| Bins | Range | Getter |
|---|---|---|
| 0-3 | ~60-320 Hz (bass) | getEqBass() |
| 4-10 | ~320-2560 Hz (mid) | getEqMid() |
| 11-15 | ~2560-5120 Hz (treble) | getEqTreble() |
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:
bass > bass_att means a beat is happening right nowVibe levels are not 0.0-1.0 like the Equalizer. They are ratios against the long-term average energy of the current song:
| Value | Meaning |
|---|---|
1.0 | Average level for this song/environment |
> 1.0 | Louder than recent average (spike/beat) |
< 1.0 | Quieter than recent average |
~0.7 | Quiet passage |
~1.3 | Loud 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.
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:
bass > bassAtt, energy is rising — a beat is happeningbass < bassAtt, energy is falling — fading out between beatsThe canonical MilkDrop idiom for beat-reactive effects:
// 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);
| API | Range | Use when... |
|---|---|---|
getVibeBass() | ~1.0 (unbounded) | You want self-normalizing, beat-reactive effects that adapt to any song |
getBassLevel() | 0.0-1.0 | You want simple normalized levels for brightness/color mapping |
getEqBass() | 0.0-1.0 | You want WLED-compatible spectrum analysis |
getBassRaw() | 0+ (absolute) | You need raw FFT energy for custom algorithms |
#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
}
// ---- 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
The onVibeLevels callback provides everything in one struct per frame:
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 fire on the rising edge only (transition from no-spike to spike), so you get one event per beat rather than continuous firing:
audio->onVibeBassSpike([] { /* bass beat! */ });
audio->onVibeMidSpike([] { /* mid-range transient */ });
audio->onVibeTrebSpike([] { /* high-frequency hit (hi-hat, cymbal) */ });
Create your own detector by subclassing AudioDetector. This gives you direct access to AudioContext for FFT data while integrating into the update loop.
#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;
}
};
Create an AudioContext manually to share FFT data across multiple detectors. This demonstrates the two-phase update/fireCallbacks loop.
#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();
}
For full control, use AudioSample and FFT directly. This is useful when you want to build your own visualizer or analysis pipeline.
#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();
}
For the simplest audio reactivity without FFT, use AudioSample properties directly.
#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();
}
The Synth module generates bandlimited waveforms for audio output. Useful for creating tones, alerts, or musical output from your microcontroller.
#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
}
┌──────────────────────────────────────────────┐
│ 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 │
└──────────────────────────────────────────────┘
| Level | Use when... | Key classes |
|---|---|---|
| Easiest (FastLED.add) | You want audio-reactive LEDs with zero boilerplate | FastLED.add(AudioConfig) |
| High (AudioProcessor) | You want manual control over when samples are processed | AudioProcessor |
| Mid (AudioContext) | You're writing a custom detector or need shared FFT caching | AudioContext, AudioDetector |
| Low (AudioSample/FFT) | You want raw spectrum data or PCM-level control | AudioSample, FFT, FFTBins |
| Output (Synth) | You need to generate audio waveforms | ISynthEngine, ISynthOscillator |
When you call audio.update(sample), three stages run in sequence:
Signal Conditioning — Raw PCM is cleaned: DC offset removal, spike filtering, noise gate. This stage modifies the sample before any analysis.
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.
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.
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.
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.
| Category | Callbacks |
|---|---|
| Beat | onBeat(void()), onBeatPhase(float), onOnset(float), onTempoChange(float, float) |
| Tempo | onTempo(float), onTempoWithConfidence(float, float), onTempoStable(), onTempoUnstable() |
| Frequency | onBass(float), onMid(float), onTreble(float), onFrequencyBands(float, float, float) |
| Energy | onEnergy(float), onNormalizedEnergy(float), onPeak(float), onAverageEnergy(float) |
| Transient | onTransient(), onTransientWithStrength(float), onAttack(float) |
| Silence | onSilence(u8), onSilenceStart(), onSilenceEnd(), onSilenceDuration(u32) |
| Pitch | onPitch(float), onPitchWithConfidence(float, float), onPitchChange(float), onVoiced(u8) |
| Note | onNoteOn(u8, u8), onNoteOff(u8), onNoteChange(u8, u8) |
| Percussion | onPercussion(PercussionType), onKick(), onSnare(), onHiHat(), onTom() |
| Vocal | onVocal(u8), onVocalStart(), onVocalEnd(), onVocalConfidence(float) |
| Dynamics | onCrescendo(), onDiminuendo(), onDynamicTrend(float), onCompressionRatio(float) |
| Downbeat | onDownbeat(), onMeasureBeat(u8), onMeterChange(u8), onMeasurePhase(float) |
| Backbeat | onBackbeat(u8, float, float) |
| Chord | onChord(Chord), onChordChange(Chord), onChordEnd() |
| Key | onKey(Key), onKeyChange(Key), onKeyEnd() |
| Mood | onMood(Mood), onMoodChange(Mood), onValenceArousal(float, float) |
| Buildup | onBuildupStart(), onBuildupProgress(float), onBuildupPeak(), onBuildupEnd(), onBuildup(Buildup) |
| Drop | onDrop(), onDropEvent(Drop), onDropImpact(float) |
| Equalizer | onEqualizer(const Equalizer&) |
// 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)
To create your own detector that integrates with AudioProcessor:
AudioDetector#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;
};
Wire your detector into a manual update loop (see the Multi-Detector example above), or use it standalone:
auto context = fl::make_shared<fl::AudioContext>(sample);
myDetector.update(context);
myDetector.fireCallbacks();
AudioProcessor includes a three-stage signal conditioning pipeline. Each stage can be enabled/disabled independently.
audio.setSignalConditioningEnabled(true); // DC removal, spike filter, noise gate
audio.setAutoGainEnabled(true); // Automatic gain control
audio.setNoiseFloorTrackingEnabled(true); // Adaptive noise floor
SignalConditionerConfig — Cleans raw I2S/PCM data:
| Field | Default | Description |
|---|---|---|
enableDCRemoval | true | Remove DC offset via running-average high-pass filter |
enableSpikeFilter | true | Reject I2S glitch samples beyond threshold |
enableNoiseGate | true | Hysteresis-based noise gate |
spikeThreshold | 10000 | Absolute sample value beyond which samples are rejected |
noiseGateOpenThreshold | 500 | Signal must exceed this to open the gate |
noiseGateCloseThreshold | 300 | Signal must fall below this to close the gate |
dcRemovalAlpha | 0.99f | Time constant (higher = slower DC adaptation) |
AutoGainConfig — Adaptive gain using PI controller with peak envelope tracking (WLED-style):
| Field | Default | Description |
|---|---|---|
preset | AGCPreset_Normal | Behavior preset: Normal, Vivid, Lazy, or Custom |
minGain | 1/64 | Minimum gain multiplier |
maxGain | 32.0f | Maximum gain multiplier |
targetRMSLevel | 8000.0f | Target RMS level after gain (0-32767) |
peakDecayTau | 3.3f | Peak envelope decay (seconds, Custom only) |
kp | 0.6f | PI proportional gain (Custom only) |
ki | 1.7f | PI integral gain (Custom only) |
gainFollowSlowTau | 12.3f | Slow gain-follow tau (seconds, Custom only) |
gainFollowFastTau | 0.38f | Fast gain-follow tau (seconds, Custom only) |
AGC Presets:
| Parameter | Normal | Vivid | Lazy |
|---|---|---|---|
| peakDecayTau | 3.3s | 1.3s | 6.7s |
| kp | 0.6 | 1.5 | 0.65 |
| ki | 1.7 | 1.85 | 1.2 |
| gainFollowSlowTau | 12.3s | 8.2s | 16.4s |
| gainFollowFastTau | 0.38s | 0.26s | 0.51s |
NoiseFloorTrackerConfig — Adaptive noise floor with hysteresis:
| Field | Default | Description |
|---|---|---|
decayRate | 0.99f | How slowly the floor decays (higher = more stable) |
attackRate | 0.001f | How quickly the floor rises when signal is low |
hysteresisMargin | 100.0f | Floor must drop by this before it can rise again |
minFloor | 10.0f | Prevents floor from reaching zero |
maxFloor | 5000.0f | Prevents floor from growing unbounded |
crossDomainWeight | 0.3f | Blend of time-domain (0.0) vs frequency-domain (1.0) |
// 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 | FFT? | History? | Key Callbacks | Key Polling |
|---|---|---|---|---|
| BeatDetector | Yes | No | onBeat(), onOnset(float) | isBeat(), getBeatConfidence() |
| TempoAnalyzer | No | No | onTempo(float), onTempoStable() | getTempoBPM(), isTempoStable() |
| EqualizerDetector | Yes | No | onEqualizer(const Equalizer&) | getEqBass(), getEqMid(), getEqTreble(), getEqVolume(), getEqZcf(), getEqBin(int) |
| FrequencyBands | Yes | No | onBass(float), onMid(float), onTreble(float) | getBassLevel(), getMidLevel(), getTrebleLevel() |
| EnergyAnalyzer | No | No | onEnergy(float), onPeak(float) | getEnergy(), getPeakLevel() |
| TransientDetector | Yes | No | onTransient(), onAttack(float) | isTransient(), getTransientStrength() |
| SilenceDetector | No | No | onSilenceStart(), onSilenceEnd() | isSilent(), getSilenceDuration() |
| DynamicsAnalyzer | No | No | onCrescendo(), onDiminuendo() | isCrescendo(), isDiminuendo() |
| PitchDetector | Yes | No | onPitch(float), onVoiced(u8) | getPitch(), isVoiced() |
| NoteDetector | Yes | No | onNoteOn(u8, u8), onNoteOff(u8) | getCurrentNote(), isNoteActive() |
| DownbeatDetector | No | No | onDownbeat(), onMeasureBeat(u8) | isDownbeat(), getCurrentBeatNumber() |
| BackbeatDetector | No | No | onBackbeat(u8, float, float) | getBackbeatConfidence() |
| VocalDetector | Yes | No | onVocalStart(), onVocalEnd() | isVocalActive(), getVocalConfidence() |
| PercussionDetector | Yes | No | onKick(), onSnare(), onHiHat(), onTom() | isKick(), isSnare(), isHiHat(), isTom() |
| ChordDetector | Yes | Yes | onChord(Chord), onChordChange(Chord) | hasChord(), getChordConfidence() |
| KeyDetector | Yes | Yes | onKey(Key), onKeyChange(Key) | hasKey(), getKeyConfidence() |
| MoodAnalyzer | Yes | Yes | onMood(Mood), onValenceArousal(float, float) | getMoodValence(), getMoodArousal() |
| BuildupDetector | Yes | No | onBuildupStart(), onBuildupPeak() | isBuilding(), getBuildupProgress() |
| DropDetector | Yes | No | onDrop(), onDropImpact(float) | getDropImpact() |
| VibeDetector | Yes | No | onVibeLevels(const VibeLevels&), onVibeBassSpike(), onVibeMidSpike(), onVibeTrebSpike() | getVibeBass(), getVibeMid(), getVibeTreb(), getVibeVol(), isVibeBassSpike() |
| Platform | Microphone | Configuration |
|---|---|---|
| ESP32 | INMP441 (I2S) | AudioConfig::CreateInmp441(ws, sd, clk, channel) |
| ESP32 | PDM mic | AudioConfig(AudioConfigPdm(din, clk, i2s_num)) |
| Teensy | I2S mic | AudioConfig::CreateTeensyI2S(port, channel) |
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