docs/audio_silence_gate_design.md
Status: Design approved, Phase 1 in progress
Tracks: FastLED #2253 — volume not returning to zero in silence
Scope: src/fl/audio/ — Vibe, Reactive (dominant freq / magnitude / flux), TempoAnalyzer
FastLED's audio-reactive metrics do not return to zero when audio input stops:
Vibe::getVol() converges to 1.0 during silence (MilkDrop self-normalization — mImm / mLongAvg → noise / noise → 1.0, and the < 0.001f clamp in vibe.cpp.hpp:86-88 hard-codes mImmRel = 1.0f).Reactive::Data::volume decays but takes ~2 s because EnergyAnalyzer::mRunningMaxFilter has a 2 s decay tau and a hard floor of 1.0 on the denominator.Reactive::Data::dominantFrequency / magnitude / spectralFlux are instantaneous FFT reads with no decay — they lock onto whatever FFT bin is strongest (usually numerical noise) during silence.TempoAnalyzer::mCurrentBPM is updated only on onset detection → persists unchanged through silence.Root cause: the pipeline already computes a high-quality silence signal (NoiseFloorTracker::isAboveFloor() at noise_floor_tracker.h:95) but its output is never consumed by any detector. The tracker is orphaned.
One boolean field on audio::Context populated by Processor/Reactive from NoiseFloorTracker::isAboveFloor(). This gives every detector a zero-cost, frame-wide signal-quality signal without refactoring detector interfaces.
// audio_context.h — new methods
void setSilent(bool silent) FL_NOEXCEPT { mIsSilent = silent; }
bool isSilent() const FL_NOEXCEPT { return mIsSilent; }
// audio_processor.cpp.hpp — after NoiseFloorTracker.update()
if (mNoiseFloorTrackingEnabled && conditioned.isValid()) {
mNoiseFloorTracker.update(conditioned.rms());
mContext->setSilent(!mNoiseFloorTracker.isAboveFloor(conditioned.rms()));
} else {
mContext->setSilent(false); // tracking disabled → never claim silence
}
Default behavior preserved: mNoiseFloorTrackingEnabled stays false by default, so isSilent() returns false for existing users who haven't opted in. Silence gating is a user opt-in via Processor::enableNoiseFloorTracker(true) or the equivalent ReactiveConfig.
SilenceEnvelope — reusable decay helperA small composable class that detectors embed to translate the boolean flag into smooth per-metric decay. This preserves detector algorithm integrity (MilkDrop math stays MilkDrop math during audio) and gives each detector a tuned decay rate.
class SilenceEnvelope {
public:
struct Config {
float decayTauSeconds = 0.5f; // Time constant for decay-to-zero
float targetValue = 0.0f; // What to decay toward (usually 0)
};
SilenceEnvelope() FL_NOEXCEPT;
explicit SilenceEnvelope(const Config& cfg) FL_NOEXCEPT;
void configure(const Config& cfg) FL_NOEXCEPT;
// Pass-through during audio; exponential decay to targetValue during silence.
float update(bool isSilent, float currentValue, float dt) FL_NOEXCEPT;
// Snap to a fresh value (called on re-attack or reset)
void reset(float initialValue = 0.0f) FL_NOEXCEPT;
// True once the envelope has decayed to within epsilon of target
bool isGated(float epsilon = 1e-4f) const FL_NOEXCEPT;
};
Semantics:
!isSilent → return currentValue unchanged, cache it as the last live valueisSilent && |lastLive| > epsilon → exponentially decay the cached value toward targetValue with rate exp(-dt / decayTauSeconds)currentValue (no attack lag — users want beats to hit hard)| ID | Scope | Files | LOC | Blocks | Blocked by |
|---|---|---|---|---|---|
| PR-A | Silence flag on Context + Processor/Reactive populate it | audio_context.h/.cpp.hpp, audio_processor.cpp.hpp, audio_reactive.cpp.hpp, new test | ~60 | C, D, E | — |
| PR-B | SilenceEnvelope class + unit tests | silence_envelope.h/.cpp.hpp, new test, _build.cpp.hpp | ~120 | C, D, E | — |
PR-A and PR-B touch disjoint files — can ship in parallel (same branch, different commits, or independent branches).
| ID | Scope | Files | LOC | Blocked by |
|---|---|---|---|---|
| PR-C | Vibe adopts silence gate (fixes user-visible getVol() → 1.0 bug) | vibe.h, vibe.cpp.hpp, test | ~60 | A, B |
| PR-D | Reactive adopts: dominantFrequency, magnitude, spectralFlux gate | audio_reactive.cpp.hpp, test | ~40 | A, B |
| PR-E | TempoAnalyzer adopts BPM fast-decay (tau ~2 s) | tempo_analyzer.cpp.hpp, test | ~50 | A, B |
PR-C/D/E touch disjoint detector files — can ship in parallel once A + B land.
audio::Contextvoid setSilent(bool silent) FL_NOEXCEPT;
bool isSilent() const FL_NOEXCEPT;
false (no silence claim).false on setSample() (per-frame reset — Processor/Reactive must re-populate after NFT update).audio::SilenceEnvelope (new)See Layer 2 above. Lives in src/fl/audio/silence_envelope.{h,cpp.hpp}. Added to src/fl/audio/_build.cpp.hpp unity include. Unit test at tests/fl/audio/silence_envelope.cpp.
NoiseFloorTracker.SilenceEnvelope.isSilent() returns what setSilent() set; assert setSample() resets to false.3 * decayTauSecondsisGated() returns true once decayed below epsilongetVol() crosses below 0.01 within 1 s of silence start.dominantFrequency == 0.0f and magnitude == 0.0f after 1 s silence.deficiencies.cpp (the TDD-style test file for known issues) gets three new entries that were previously failing and now pass.mNoiseFloorTracker separate from Processor's. Both paths need to populate the Context silence flag. Done in both Processor::update() and Reactive::processSample().AudioLevel struct replacing all volume getters) — considered and rejected as too churn-heavy; preserved as Option 2 in the discussion. Can be layered on top later if demand emerges.NoiseFloorTracker (Option 5) — Silence detector already fires transition events; no need to duplicate.