FastLED 3.9.15
Loading...
Searching...
No Matches
audio_reactive.cpp.hpp
Go to the documentation of this file.
3#include "fl/stl/assert.h"
7#include "fl/math/math.h"
8#include "fl/stl/span.h"
9#include "fl/stl/int.h"
10#include "fl/stl/unique_ptr.h" // For unique_ptr
11#include "fl/stl/noexcept.h"
12
13namespace fl {
14namespace audio {
15
17 : mConfig{}, mContext(fl::make_shared<Context>(Sample())), mFFTBins(16) // Initialize with 16 frequency bins
18{
19 // Initialize enhanced beat detection components
22
23 // Initialize previous magnitudes array to zero
24 for (fl::size i = 0; i < mPreviousMagnitudes.size(); ++i) {
25 mPreviousMagnitudes[i] = 0.0f;
26 }
27
28 // Configure spectral silence envelopes — tau=0.2s chosen because FFT noise
29 // floor is brittle; these metrics should snap to zero quickly in silence.
31 envCfg.decayTauSeconds = 0.2f;
32 envCfg.targetValue = 0.0f;
33 mDominantFrequencyEnvelope.configure(envCfg);
34 mMagnitudeEnvelope.configure(envCfg);
35 mSpectralFluxEnvelope.configure(envCfg);
36}
37
39
40void Reactive::begin(const ReactiveConfig& config) {
41 setConfig(config);
42
43 // Reset state
46 mLastBeatTime = 0;
47 mPreviousVolume = 0.0f;
48
49 // Configure signal conditioning components
51 scConfig.enableDCRemoval = config.enableSignalConditioning;
52 scConfig.enableSpikeFilter = config.enableSignalConditioning;
53 scConfig.enableNoiseGate = config.noiseGate && config.enableSignalConditioning;
54 mSignalConditioner.configure(scConfig);
55 mSignalConditioner.reset();
56
58 nfConfig.enabled = config.enableNoiseFloorTracking;
59 mNoiseFloorTracker.configure(nfConfig);
60 mNoiseFloorTracker.reset();
61
62 // Configure frequency bin mapper (obligatory - fixes hardcoded sample rate)
65 fbmConfig.sampleRate = config.sampleRate;
66 fbmConfig.useLogSpacing = config.enableLogBinSpacing;
67 fbmConfig.minFrequency = 20.0f;
68 fbmConfig.maxFrequency = static_cast<float>(config.sampleRate) / 2.0f; // Nyquist
69 // fftBinCount will be set when we know the fft::FFT size (after first processSample)
70 fbmConfig.fftBinCount = 256; // Default, overridden when actual fft::FFT size known
71 mFrequencyBinMapper.configure(fbmConfig);
72
73 // Compute pink noise compensation gains from log-spaced CQ bin centers.
74 // CQ bins are log-spaced from fmin to fmax (default: 90-14080 Hz),
75 // matching Bins::binToFreq().
76 {
77 const float fmin = fft::Args::DefaultMinFrequency();
78 const float fmax = fft::Args::DefaultMaxFrequency();
79 const float m = fl::logf(fmax / fmin);
80 float binCenters[16];
81 for (int i = 0; i < 16; ++i) {
82 binCenters[i] = fmin * fl::expf(m * static_cast<float>(i) / 15.0f);
83 }
85 mPinkNoiseComputed = true;
86 }
87
88 // Reset enhanced beat detection components
90 mSpectralFluxDetector->reset();
91 mSpectralFluxDetector->setThreshold(config.spectralFluxThreshold);
92 }
93
94 // Configure musical beat detection (Phase 3 middleware - lazy creation)
95 if (config.enableMusicalBeatDetection) {
98 }
100 mbdConfig.minBPM = config.musicalBeatMinBPM;
101 mbdConfig.maxBPM = config.musicalBeatMaxBPM;
102 mbdConfig.minBeatConfidence = config.musicalBeatConfidence;
103 mbdConfig.sampleRate = config.sampleRate;
104 mbdConfig.samplesPerFrame = 512; // Typical fft::FFT frame size
105 mMusicalBeatDetector->configure(mbdConfig);
106 mMusicalBeatDetector->reset();
107 } else {
108 mMusicalBeatDetector.reset();
109 }
110
111 if (config.enableMultiBandBeats) {
114 }
116 mbbdConfig.bassThreshold = config.bassThreshold;
117 mbbdConfig.midThreshold = config.midThreshold;
118 mbbdConfig.trebleThreshold = config.trebleThreshold;
119 mMultiBandBeatDetector->configure(mbbdConfig);
120 mMultiBandBeatDetector->reset();
121 } else {
123 }
124
125 // Configure spectral equalizer (optional - lazy creation)
126 if (config.enableSpectralEqualizer) {
127 if (!mSpectralEqualizer) {
129 }
132 seConfig.numBands = 16;
133 mSpectralEqualizer->configure(seConfig);
134 } else {
135 mSpectralEqualizer.reset();
136 }
137
138 // Reset previous magnitudes
139 for (fl::size i = 0; i < mPreviousMagnitudes.size(); ++i) {
140 mPreviousMagnitudes[i] = 0.0f;
141 }
142
143 // Reset silence envelopes for spectral metrics.
144 mDominantFrequencyEnvelope.reset(0.0f);
145 mMagnitudeEnvelope.reset(0.0f);
146 mSpectralFluxEnvelope.reset(0.0f);
147
148 // Update Context sample rate
149 mContext->setSampleRate(config.sampleRate);
150
151 // Ensure internal Processor exists and is configured.
152 // Eager creation so processSample() can always source volume from it.
154 mAudioProcessor->setSampleRate(config.sampleRate);
155 mAudioProcessor->reset();
156 // Force EnergyAnalyzer detector registration so updateFromContext() updates it
157 mAudioProcessor->getEnergy();
158}
159
161 mConfig = config;
162}
163
165 if (!sample.isValid()) {
166 return; // Invalid sample, ignore
167 }
168
169 // Extract timestamp from the Sample
170 fl::u32 currentTimeMs = sample.timestamp();
171
172 // Phase 1: Signal conditioning pipeline
173 Sample processedSample = sample;
174
175 // Step 1: Signal conditioning (DC removal, spike filtering, noise gate)
176 if (mConfig.enableSignalConditioning) {
177 processedSample = mSignalConditioner.processSample(processedSample);
178 if (!processedSample.isValid()) {
179 return; // Signal was completely filtered out
180 }
181 }
182
183 // Step 2: Noise floor tracking (update tracker, but don't modify signal)
184 if (mConfig.enableNoiseFloorTracking) {
185 float rms = processedSample.rms();
186 mNoiseFloorTracker.update(rms);
187 }
188
189 // Set conditioned sample on shared Context (clears per-frame FFT cache)
190 mContext->setSample(processedSample);
191
192 // Populate silence flag after setSample() resets it — detectors run via
193 // updateFromContext() below and will read context->isSilent().
194 //
195 // Uses an absolute RMS threshold (not the adaptive noise floor). The
196 // adaptive floor's first-sample init matches the current level, which
197 // causes isAboveFloor() to be stuck at false for any steady signal —
198 // wrongly flagging loud constant tones as silent. Absolute RMS is the
199 // right primitive for "no signal present": a steady loud tone has
200 // large RMS and is not silent regardless of noise-floor adaptation.
201 if (mConfig.enableNoiseFloorTracking) {
202 constexpr float kSilenceRmsThreshold = 10.0f;
203 mContext->setSilent(processedSample.rms() < kSilenceRmsThreshold);
204 }
205
206 // Process the conditioned Sample - timing is gated by sample availability
207 processFFT(processedSample);
208
209 // Update internal Processor BEFORE populating Data fields,
210 // so updateVolumeAndPeak() can source normalized volume.
212 mAudioProcessor->updateFromContext(mContext);
213
214 updateVolumeAndPeak(processedSample);
215
216 // Enhanced processing pipeline
219
220 // Apply pink noise compensation AFTER band energy calculation
221 // so that bassEnergy/midEnergy/trebleEnergy reflect actual spectral content
222 if (mPinkNoiseComputed) {
223 for (int i = 0; i < 16; ++i) {
224 mCurrentData.frequencyBins[i] *= mPinkNoiseGains[i];
225 }
226 }
227
228 // Apply A-weighting BEFORE spectral flux and beat detection so that
229 // high-frequency attenuation is visible to onset/beat algorithms.
230 // Previously this ran after beat detection, causing bin 15 to appear
231 // disproportionately active (boosted by pink noise but not yet attenuated).
233
235
236 // Silence gate for spectral metrics (FastLED#2253).
237 // During audio the envelopes are pass-through; during silence they
238 // exponentially decay (tau=0.2s) the raw argmax / flux outputs toward
239 // zero so the FFT noise floor cannot lock onto arbitrary bins.
240 // dt is the exact audio duration of this PCM buffer (frame-rate
241 // independent). When enableNoiseFloorTracking is false, isSilent()
242 // is always false, making this a strict pass-through (no behavior
243 // change for users who haven't opted in).
244 {
245 const bool silent = mContext->isSilent();
246 const float dt = computeAudioDt(processedSample.pcm().size(),
247 mConfig.sampleRate);
248 mCurrentData.dominantFrequency = mDominantFrequencyEnvelope.update(
249 silent, mCurrentData.dominantFrequency, dt);
250 mCurrentData.magnitude = mMagnitudeEnvelope.update(
251 silent, mCurrentData.magnitude, dt);
252 mCurrentData.spectralFlux = mSpectralFluxEnvelope.update(
253 silent, mCurrentData.spectralFlux, dt);
254 }
255
256 // Enhanced beat detection (includes original)
257 detectBeat(currentTimeMs);
258 detectEnhancedBeats(currentTimeMs);
259
260 // Loudness compensation stays after beat detection — it is a global
261 // level adjustment that should not affect relative frequency balance.
263
264 applyGain();
265 applyScaling();
267
268 mCurrentData.timestamp = currentTimeMs;
269
270 // Processor was already updated earlier in this method (before updateVolumeAndPeak).
271}
272
273void Reactive::update(fl::u32 currentTimeMs) {
274 // This method handles updates without new sample data
275 // Just apply smoothing and update timestamp
277 mCurrentData.timestamp = currentTimeMs;
278}
279
281 // Get PCM data from Sample
282 const auto& pcmData = sample.pcm();
283 if (pcmData.empty()) return;
284
285 // Use shared Context for cached FFT (avoids recomputation if Processor also needs it)
286 auto cachedBins = mContext->getFFT16();
287 mFFTBins = *cachedBins;
288
289 // Map fft::FFT bins to frequency channels using WLED-compatible mapping
291}
292
294 // Sample::fft() returns CQ-kernel bins that are already
295 // frequency-mapped (linearly spaced from fmin to fmax). Copy them
296 // directly instead of re-mapping through FrequencyBinMapper, which
297 // incorrectly treats CQ bins as raw DFT bins.
298 fl::span<const float> rawBins = mFFTBins.raw();
299 if (rawBins.empty()) {
300 for (int i = 0; i < 16; ++i) {
301 mCurrentData.frequencyBins[i] = 0.0f;
302 }
303 return;
304 }
305
306 // Copy CQ bins directly to frequency bins (already frequency-mapped)
307 for (int i = 0; i < 16; ++i) {
308 if (i < static_cast<int>(rawBins.size())) {
309 mCurrentData.frequencyBins[i] = rawBins[i];
310 } else {
311 mCurrentData.frequencyBins[i] = 0.0f;
312 }
313 }
314
315 // Note: Pink noise compensation is applied later in processSample(),
316 // AFTER band energies are calculated from the raw CQ bins.
317 // This ensures bassEnergy/midEnergy/trebleEnergy reflect actual
318 // spectral content, not display-oriented compensation.
319
320 // Find dominant frequency bin
321 float maxMagnitude = 0.0f;
322 int maxBin = 0;
323 for (int i = 0; i < 16; ++i) {
324 if (mCurrentData.frequencyBins[i] > maxMagnitude) {
325 maxMagnitude = mCurrentData.frequencyBins[i];
326 maxBin = i;
327 }
328 }
329
330 // CQ bins are log-spaced — use the same formula as Bins::binToFreq().
331 mCurrentData.dominantFrequency = mFFTBins.binToFreq(maxBin);
332 mCurrentData.magnitude = maxMagnitude;
333}
334
336 // Get PCM data from Sample
337 const auto& pcmData = sample.pcm();
338 if (pcmData.empty()) {
339 mCurrentData.volume = 0.0f;
340 mCurrentData.volumeRaw = 0.0f;
341 mCurrentData.peak = 0.0f;
342 return;
343 }
344
345 // Raw volume: simple bounded normalization (no adaptive smoothing)
346 // 23170 ≈ max RMS of a full-scale 16-bit sine wave (32767 / √2).
347 // Clamp so clipped/square-wave input stays at 1.0.
348 float rms = sample.rms();
349 float raw = rms / 23170.0f;
350 mCurrentData.volumeRaw = (raw > 1.0f) ? 1.0f : raw;
351
352 // Normalized volume from Processor's EnergyAnalyzer (adaptive 0.0-1.0).
353 // mAudioProcessor is guaranteed non-null: ensureAudioProcessor() is called
354 // in processSample() immediately before this method.
355 FL_ASSERT(mAudioProcessor, "mAudioProcessor is null — was begin() called before processSample()?");
356 mCurrentData.volume = mAudioProcessor->getEnergy();
357
358 // Peak: simple bounded normalization
359 float maxSample = 0.0f;
360 for (fl::i16 pcmSample : pcmData) {
361 float absSample = (pcmSample < 0) ? -static_cast<float>(pcmSample) : static_cast<float>(pcmSample);
362 maxSample = (maxSample > absSample) ? maxSample : absSample;
363 }
364 mCurrentData.peak = maxSample / 32768.0f;
365}
366
367void Reactive::detectBeat(fl::u32 currentTimeMs) {
368 // Need minimum time since last beat
369 if (currentTimeMs - mLastBeatTime < BEAT_COOLDOWN) {
370 mCurrentData.beatDetected = false;
371 return;
372 }
373
374 // Use raw volume for beat detection — it is proportional to amplitude.
375 // Adaptive volume (mCurrentData.volume) converges toward a steady state,
376 // masking the transient jumps that indicate beats.
377 // NOTE: this runs BEFORE applyGain(), so beat detection is gain-independent.
378 float currentVolume = mCurrentData.volumeRaw;
379
380 // Beat detected if volume significantly increased
381 if (currentVolume > mPreviousVolume + mVolumeThreshold &&
382 currentVolume > 0.02f) { // Minimum volume threshold
383 mCurrentData.beatDetected = true;
384 mLastBeatTime = currentTimeMs;
385 } else {
386 mCurrentData.beatDetected = false;
387 }
388
389 // Update previous volume for next comparison using attack/decay
390 float beatAttackRate = mConfig.attack / 255.0f * 0.5f + 0.1f; // 0.1 to 0.6
391 float beatDecayRate = mConfig.decay / 255.0f * 0.3f + 0.05f; // 0.05 to 0.35
392
393 if (currentVolume > mPreviousVolume) {
394 // Rising volume - use attack rate (faster tracking)
395 mPreviousVolume = mPreviousVolume * (1.0f - beatAttackRate) + currentVolume * beatAttackRate;
396 } else {
397 // Falling volume - use decay rate (slower tracking)
398 mPreviousVolume = mPreviousVolume * (1.0f - beatDecayRate) + currentVolume * beatDecayRate;
399 }
400}
401
403 // Apply gain setting (0-255 maps to 0.0-2.0 multiplier)
404 float gainMultiplier = static_cast<float>(mConfig.gain) / 128.0f;
405
406 // Don't apply gain to adaptive volume — it's self-normalizing (converges
407 // to 1.0 regardless of amplitude), so scaling it is meaningless.
408 mCurrentData.volumeRaw *= gainMultiplier;
409 mCurrentData.peak *= gainMultiplier;
410
411 // Clamp to documented 0.0-1.0 range after gain
412 if (mCurrentData.volumeRaw > 1.0f) mCurrentData.volumeRaw = 1.0f;
413 if (mCurrentData.peak > 1.0f) mCurrentData.peak = 1.0f;
414
415 for (int i = 0; i < 16; ++i) {
416 mCurrentData.frequencyBins[i] *= gainMultiplier;
417 }
418}
419
421 // Apply scaling mode to frequency bins
422 for (int i = 0; i < 16; ++i) {
423 float value = mCurrentData.frequencyBins[i];
424
425 switch (mConfig.scalingMode) {
426 case 1: // Logarithmic scaling
427 if (value > 1.0f) {
428 value = logf(value) * 20.0f; // Scale factor
429 } else {
430 value = 0.0f;
431 }
432 break;
433
434 case 2: // Linear scaling (no change)
435 // value remains as-is
436 break;
437
438 case 3: // Square root scaling
439 if (value > 0.0f) {
440 value = sqrtf(value) * 8.0f; // Scale factor
441 } else {
442 value = 0.0f;
443 }
444 break;
445
446 case 0: // No scaling
447 default:
448 // value remains as-is
449 break;
450 }
451
452 mCurrentData.frequencyBins[i] = value;
453 }
454}
455
457 // Attack/decay smoothing - different rates for rising vs falling values
458 // Convert attack/decay times to smoothing factors
459 // Shorter times = less smoothing (faster response)
460 float attackFactor = 1.0f - (mConfig.attack / 255.0f * 0.9f); // Range: 0.1 to 1.0
461 float decayFactor = 1.0f - (mConfig.decay / 255.0f * 0.95f); // Range: 0.05 to 1.0
462
463 // volume is already adaptively smoothed by EnergyAnalyzer — copy directly
464 // to avoid double-smoothing which would make it sluggish.
465 mSmoothedData.volume = mCurrentData.volume;
466
467 // Apply attack/decay smoothing to volumeRaw
468 if (mCurrentData.volumeRaw > mSmoothedData.volumeRaw) {
469 mSmoothedData.volumeRaw = mSmoothedData.volumeRaw * (1.0f - attackFactor) +
470 mCurrentData.volumeRaw * attackFactor;
471 } else {
472 mSmoothedData.volumeRaw = mSmoothedData.volumeRaw * (1.0f - decayFactor) +
473 mCurrentData.volumeRaw * decayFactor;
474 }
475
476 // Apply attack/decay smoothing to peak
477 if (mCurrentData.peak > mSmoothedData.peak) {
478 mSmoothedData.peak = mSmoothedData.peak * (1.0f - attackFactor) +
479 mCurrentData.peak * attackFactor;
480 } else {
481 mSmoothedData.peak = mSmoothedData.peak * (1.0f - decayFactor) +
482 mCurrentData.peak * decayFactor;
483 }
484
485 // Apply attack/decay smoothing to frequency bins
486 for (int i = 0; i < 16; ++i) {
487 if (mCurrentData.frequencyBins[i] > mSmoothedData.frequencyBins[i]) {
488 // Rising - use attack time
489 mSmoothedData.frequencyBins[i] = mSmoothedData.frequencyBins[i] * (1.0f - attackFactor) +
490 mCurrentData.frequencyBins[i] * attackFactor;
491 } else {
492 // Falling - use decay time
493 mSmoothedData.frequencyBins[i] = mSmoothedData.frequencyBins[i] * (1.0f - decayFactor) +
494 mCurrentData.frequencyBins[i] * decayFactor;
495 }
496 }
497
498 // Copy non-smoothed values
499 mSmoothedData.beatDetected = mCurrentData.beatDetected;
500 mSmoothedData.dominantFrequency = mCurrentData.dominantFrequency;
501 mSmoothedData.magnitude = mCurrentData.magnitude;
502 mSmoothedData.timestamp = mCurrentData.timestamp;
503
504 // Copy enhanced beat detection fields
505 mSmoothedData.bassBeatDetected = mCurrentData.bassBeatDetected;
506 mSmoothedData.midBeatDetected = mCurrentData.midBeatDetected;
507 mSmoothedData.trebleBeatDetected = mCurrentData.trebleBeatDetected;
508 mSmoothedData.spectralFlux = mCurrentData.spectralFlux;
509 mSmoothedData.bassEnergy = mCurrentData.bassEnergy;
510 mSmoothedData.midEnergy = mCurrentData.midEnergy;
511 mSmoothedData.trebleEnergy = mCurrentData.trebleEnergy;
512}
513
514const Data& Reactive::getData() const {
515 return mCurrentData;
516}
517
519 return mSmoothedData;
520}
521
522float Reactive::getVolume() const {
523 return mCurrentData.volume;
524}
525
526float Reactive::getBass() const {
527 // Average of bins 0-1 (sub-bass and bass)
528 return (mCurrentData.frequencyBins[0] + mCurrentData.frequencyBins[1]) / 2.0f;
529}
530
531float Reactive::getMid() const {
532 // Average of bins 6-7 (midrange around 1kHz)
533 return (mCurrentData.frequencyBins[6] + mCurrentData.frequencyBins[7]) / 2.0f;
534}
535
536float Reactive::getTreble() const {
537 // Average of bins 14-15 (high frequencies)
538 return (mCurrentData.frequencyBins[14] + mCurrentData.frequencyBins[15]) / 2.0f;
539}
540
541bool Reactive::isBeat() const {
542 return mCurrentData.beatDetected;
543}
544
546 return mCurrentData.bassBeatDetected;
547}
548
550 return mCurrentData.midBeatDetected;
551}
552
554 return mCurrentData.trebleBeatDetected;
555}
556
558 return mCurrentData.spectralFlux;
559}
560
562 return mCurrentData.bassEnergy;
563}
564
566 return mCurrentData.midEnergy;
567}
568
570 return mCurrentData.trebleEnergy;
571}
572
574 float scaled = mCurrentData.volume * 255.0f;
575 if (scaled < 0.0f) scaled = 0.0f;
576 if (scaled > 255.0f) scaled = 255.0f;
577 return static_cast<fl::u8>(scaled);
578}
579
580CRGB Reactive::volumeToColor(const CRGBPalette16& /* palette */) const {
581 fl::u8 index = volumeToScale255();
582 // Simplified color palette lookup
583 return CRGB(index, index, index); // For now, return grayscale
584}
585
587 if (binIndex >= 16) return 0;
588 // Bin values have no fixed upper bound (depend on FFT output, scaling
589 // mode, gain, and equalization). Best-effort clamp to 0-255.
590 float value = mCurrentData.frequencyBins[binIndex];
591 if (value < 0.0f) value = 0.0f;
592 if (value > 255.0f) value = 255.0f;
593 return static_cast<fl::u8>(value);
594}
595
596// Enhanced beat detection methods
598 span<const float> bins(mCurrentData.frequencyBins, 16);
599 mCurrentData.bassEnergy = mFrequencyBinMapper.getBassEnergy(bins);
600 mCurrentData.midEnergy = mFrequencyBinMapper.getMidEnergy(bins);
601 mCurrentData.trebleEnergy = mFrequencyBinMapper.getTrebleEnergy(bins);
602}
603
605 if (!mConfig.enableSpectralEqualizer) {
606 return;
607 }
608
609 // Apply spectral EQ in-place on the frequency bins
610 float equalizedBins[16];
611 span<const float> inputSpan(mCurrentData.frequencyBins, 16);
612 span<float> outputSpan(equalizedBins, 16);
613 mSpectralEqualizer->apply(inputSpan, outputSpan);
614
615 // Copy back
616 for (int i = 0; i < 16; ++i) {
617 mCurrentData.frequencyBins[i] = equalizedBins[i];
618 }
619}
620
622 // Compute spectral flux from Reactive's own previous magnitudes.
623 //
624 // IMPORTANT ordering contract (called from processSample):
625 // Step 7: updateSpectralFlux() — uses & updates Reactive::mPreviousMagnitudes
626 // Step 9: detectEnhancedBeats() — calls SpectralFluxDetector::detectOnset()
627 // which uses & updates SpectralFluxDetector::mPreviousMagnitudes
628 //
629 // Both arrays converge to the same values (both set to mCurrentData.frequencyBins
630 // each frame), but they are separate to avoid a state-ordering bug: calling
631 // mSpectralFluxDetector->calculateSpectralFlux() here would update the
632 // detector's internal state and cause detectOnset() to see zero flux.
633 float flux = 0.0f;
634 for (int i = 0; i < 16; ++i) {
635 float diff = mCurrentData.frequencyBins[i] - mPreviousMagnitudes[i];
636 if (diff > 0.0f) {
637 flux += diff;
638 }
639 }
640 mCurrentData.spectralFlux = flux;
641
642 // Update previous magnitudes for next frame
643 for (int i = 0; i < 16; ++i) {
644 mPreviousMagnitudes[i] = mCurrentData.frequencyBins[i];
645 }
646}
647
648void Reactive::detectEnhancedBeats(fl::u32 currentTimeMs) {
649 // Reset beat flags
650 mCurrentData.bassBeatDetected = false;
651 mCurrentData.midBeatDetected = false;
652 mCurrentData.trebleBeatDetected = false;
653
654 // Skip if enhanced beat detection is disabled
655 if (!mConfig.enableSpectralFlux && !mConfig.enableMultiBand &&
656 !mConfig.enableMusicalBeatDetection && !mConfig.enableMultiBandBeats) {
657 return;
658 }
659
660 // Need minimum time since last beat for enhanced detection too
661 if (currentTimeMs - mLastBeatTime < BEAT_COOLDOWN) {
662 return;
663 }
664
665 // Spectral flux-based onset detection (preliminary)
666 bool onsetDetected = false;
667 float onsetStrength = 0.0f;
668
669 if (mConfig.enableSpectralFlux && mSpectralFluxDetector) {
670 onsetDetected = mSpectralFluxDetector->detectOnset(
671 mCurrentData.frequencyBins
672 );
673
674 // Use already-computed spectral flux for onset strength.
675 // Do NOT call calculateSpectralFlux() again — detectOnset() already
676 // updated the detector's internal state, so a second call would see
677 // current == previous and return 0.
678 if (onsetDetected) {
679 onsetStrength = mCurrentData.spectralFlux;
680 }
681 }
682
683 // Phase 3: Musical beat detection - validates onsets as true musical beats
684 if (mConfig.enableMusicalBeatDetection) {
685 mMusicalBeatDetector->processSample(onsetDetected, onsetStrength);
686
687 if (mMusicalBeatDetector->isBeat()) {
688 // This is a validated musical beat, not just a random onset
689 mCurrentData.beatDetected = true;
690 mLastBeatTime = currentTimeMs;
691 }
692 } else if (onsetDetected) {
693 // Fall back to simple onset detection if musical beat detection disabled
694 mCurrentData.beatDetected = true;
695 mLastBeatTime = currentTimeMs;
696 }
697
698 // Phase 3: Multi-band beat detection - per-frequency beat tracking
699 if (mConfig.enableMultiBandBeats) {
700 mMultiBandBeatDetector->detectBeats(mCurrentData.frequencyBins);
701
702 mCurrentData.bassBeatDetected = mMultiBandBeatDetector->isBassBeat();
703 mCurrentData.midBeatDetected = mMultiBandBeatDetector->isMidBeat();
704 mCurrentData.trebleBeatDetected = mMultiBandBeatDetector->isTrebleBeat();
705 } else if (mConfig.enableMultiBand) {
706 // Fall back to simple threshold-based detection if multi-band disabled
707 // Bass beat detection (bins 0-1)
708 if (mCurrentData.bassEnergy > mConfig.bassThreshold) {
709 mCurrentData.bassBeatDetected = true;
710 }
711
712 // Mid beat detection (bins 6-7)
713 if (mCurrentData.midEnergy > mConfig.midThreshold) {
714 mCurrentData.midBeatDetected = true;
715 }
716
717 // Treble beat detection (bins 14-15)
718 if (mCurrentData.trebleEnergy > mConfig.trebleThreshold) {
719 mCurrentData.trebleBeatDetected = true;
720 }
721 }
722}
723
726 mPerceptualWeighting->applyAWeighting(mCurrentData);
727 }
728}
729
732 mPerceptualWeighting->applyLoudnessCompensation(mCurrentData, 0.28f);
733 }
734}
735
736// Helper methods
737float Reactive::mapFrequencyBin(int fromBin, int toBin) {
738 if (fromBin < 0 || toBin >= static_cast<int>(mFFTBins.bands()) || fromBin > toBin) {
739 return 0.0f;
740 }
741
742 float sum = 0.0f;
743 for (int i = fromBin; i <= toBin; ++i) {
744 if (i < static_cast<int>(mFFTBins.raw().size())) {
745 sum += mFFTBins.raw()[i];
746 }
747 }
748
749 return sum / static_cast<float>(toBin - fromBin + 1);
750}
751
753 if (samples.empty()) return 0.0f;
754
755 float sumSquares = 0.0f;
756 for (const auto& sample : samples) {
757 float f = static_cast<float>(sample);
758 sumSquares += f * f;
759 }
760
761 return sqrtf(sumSquares / samples.size());
762}
763
764// SpectralFluxDetector implementation
766 : mFluxThreshold(0.1f)
768 , mHistoryIndex(0)
769#endif
770{
771 // Initialize previous magnitudes to zero
772 for (fl::size i = 0; i < mPreviousMagnitudes.size(); ++i) {
773 mPreviousMagnitudes[i] = 0.0f;
774 }
775
776#if SKETCH_HAS_LARGE_MEMORY
777 // Initialize flux history to zero
778 for (fl::size i = 0; i < mFluxHistory.size(); ++i) {
779 mFluxHistory[i] = 0.0f;
780 }
781#endif
782}
783
785
787 for (fl::size i = 0; i < mPreviousMagnitudes.size(); ++i) {
788 mPreviousMagnitudes[i] = 0.0f;
789 }
790
791#if SKETCH_HAS_LARGE_MEMORY
792 for (fl::size i = 0; i < mFluxHistory.size(); ++i) {
793 mFluxHistory[i] = 0.0f;
794 }
795 mHistoryIndex = 0;
796#endif
797}
798
800 float flux = calculateSpectralFlux(currentBins, span<const float, 16>(mPreviousMagnitudes.data(), 16));
801
802#if SKETCH_HAS_LARGE_MEMORY
803 // Store flux in history for adaptive threshold calculation
804 mFluxHistory[mHistoryIndex] = flux;
805 mHistoryIndex = (mHistoryIndex + 1) % mFluxHistory.size();
806
807 float adaptiveThreshold = calculateAdaptiveThreshold();
808 return flux > adaptiveThreshold;
809#else
810 // Simple fixed threshold for memory-constrained platforms
811 return flux > mFluxThreshold;
812#endif
813}
814
816 float flux = 0.0f;
817
818 // Calculate spectral flux as sum of positive differences
819 for (int i = 0; i < 16; ++i) {
820 float diff = currentBins[i] - previousBins[i];
821 if (diff > 0.0f) {
822 flux += diff;
823 }
824 }
825
826 // Update previous magnitudes for next calculation
827 for (int i = 0; i < 16; ++i) {
828 mPreviousMagnitudes[i] = currentBins[i];
829 }
830
831 return flux;
832}
833
835 mFluxThreshold = threshold;
836}
837
839 return mFluxThreshold;
840}
841
842#if SKETCH_HAS_LARGE_MEMORY
843float SpectralFluxDetector::calculateAdaptiveThreshold() {
844 // Calculate moving average of flux history
845 float sum = 0.0f;
846 for (fl::size i = 0; i < mFluxHistory.size(); ++i) {
847 sum += mFluxHistory[i];
848 }
849 float average = sum / mFluxHistory.size();
850
851 // Adaptive threshold is base threshold plus some multiple of recent average
852 return mFluxThreshold + (average * 0.5f);
853}
854#endif
855
856// BeatDetectors implementation
862
864
866#if SKETCH_HAS_LARGE_MEMORY
867 bass.reset();
868 mid.reset();
869 treble.reset();
870#else
871 combined.reset();
872#endif
873
874 mBassEnergy = 0.0f;
875 mMidEnergy = 0.0f;
876 mTrebleEnergy = 0.0f;
877 mPreviousBassEnergy = 0.0f;
878 mPreviousMidEnergy = 0.0f;
880}
881
883 // Calculate current band energies
884 mBassEnergy = (frequencyBins[0] + frequencyBins[1]) / 2.0f;
885 mMidEnergy = (frequencyBins[6] + frequencyBins[7]) / 2.0f;
886 mTrebleEnergy = (frequencyBins[14] + frequencyBins[15]) / 2.0f;
887
888 // Simple threshold-based detection using per-band energy deltas.
889 audioData.bassBeatDetected = (mBassEnergy > mPreviousBassEnergy * 1.3f) && (mBassEnergy > 0.1f);
890 audioData.midBeatDetected = (mMidEnergy > mPreviousMidEnergy * 1.25f) && (mMidEnergy > 0.08f);
891 audioData.trebleBeatDetected = (mTrebleEnergy > mPreviousTrebleEnergy * 1.2f) && (mTrebleEnergy > 0.05f);
892
893 // Update previous energies
897}
898
899void BeatDetectors::setThresholds(float bassThresh, float midThresh, float trebleThresh) {
900#if SKETCH_HAS_LARGE_MEMORY
901 bass.setThreshold(bassThresh);
902 mid.setThreshold(midThresh);
903 treble.setThreshold(trebleThresh);
904#else
905 combined.setThreshold((bassThresh + midThresh + trebleThresh) / 3.0f);
906#endif
907}
908
909// PerceptualWeighting implementation
911#if SKETCH_HAS_LARGE_MEMORY
912 : mHistoryIndex(0)
913#endif
914{
915#if SKETCH_HAS_LARGE_MEMORY
916 // Initialize loudness history to zero
917 for (fl::size i = 0; i < mLoudnessHistory.size(); ++i) {
918 mLoudnessHistory[i] = 0.0f;
919 }
920 // Suppress unused warning until mHistoryIndex is implemented
921 (void)mHistoryIndex;
922#endif
923}
924
926
928 // Apply A-weighting coefficients to frequency bins
929 for (int i = 0; i < 16; ++i) {
930 data.frequencyBins[i] *= A_WEIGHTING_COEFFS[i];
931 }
932}
933
934void PerceptualWeighting::applyLoudnessCompensation(Data& data, float referenceLevel) const {
935 // Calculate current loudness level from raw (non-adaptive) volume.
936 // data.volume is adaptive (converges to ~1.0) and cannot distinguish
937 // quiet from loud signals. volumeRaw preserves actual amplitude.
938 float currentLoudness = data.volumeRaw;
939
940 // Calculate compensation factor based on difference from reference
941 float compensationFactor = 1.0f;
942 if (currentLoudness < referenceLevel) {
943 // Boost quiet signals
944 compensationFactor = 1.0f + (referenceLevel - currentLoudness) / referenceLevel * 0.3f;
945 } else if (currentLoudness > referenceLevel * 1.5f) {
946 // Slightly reduce very loud signals
947 compensationFactor = 1.0f - (currentLoudness - referenceLevel * 1.5f) / (referenceLevel * 2.0f) * 0.2f;
948 }
949
950 // Apply compensation to frequency bins
951 for (int i = 0; i < 16; ++i) {
952 data.frequencyBins[i] *= compensationFactor;
953 }
954
955#if SKETCH_HAS_LARGE_MEMORY
956 // Store in history for future adaptive compensation (not implemented yet)
957 // This would be used for more sophisticated dynamic range compensation
958#endif
959}
960
961void Reactive::setGain(float gain) {
963}
964
965float Reactive::getGain() const {
966 if (mAudioProcessor) {
967 return mAudioProcessor->getGain();
968 }
969 return 1.0f;
970}
971
972// Signal conditioning stats accessors
976
980
982 return mConfig.enableSpectralEqualizer;
983}
984
986 if (!mSpectralEqualizer) {
987 static const SpectralEqualizer::Stats kEmpty{};
988 return kEmpty;
989 }
990 return mSpectralEqualizer->getStats();
991}
992
993// ----- Polling Getter Forwarding (via internal Processor) -----
994
996 if (!mAudioProcessor) {
998 mAudioProcessor->setSampleRate(mConfig.sampleRate);
999 }
1000 return *mAudioProcessor;
1001}
1002
1040
1041} // namespace audio
1042} // namespace fl
float rms(fl::span< const int16_t > data)
Definition simple.h:104
#define FL_ASSERT(x, MSG)
Definition assert.h:6
Get current statistics (for monitoring/debugging)
void applyLoudnessCompensation(Data &data, float referenceLevel) const
static constexpr float A_WEIGHTING_COEFFS[16]
void applyAWeighting(Data &data) const
float getMoodArousal() FL_NOEXCEPT
float getMidLevel() FL_NOEXCEPT
bool isSilent() FL_NOEXCEPT
u32 getSilenceDuration() FL_NOEXCEPT
float getNoteVelocity() FL_NOEXCEPT
float getEnergy() FL_NOEXCEPT
float getDownbeatConfidence() FL_NOEXCEPT
float getChordConfidence() FL_NOEXCEPT
u8 getCurrentNote() FL_NOEXCEPT
float getNoteConfidence() FL_NOEXCEPT
u8 getCurrentBeatNumber() FL_NOEXCEPT
float getBackbeatStrength() FL_NOEXCEPT
bool isCrescendo() FL_NOEXCEPT
float getBassLevel() FL_NOEXCEPT
float getMoodValence() FL_NOEXCEPT
float getDropImpact() FL_NOEXCEPT
bool isDiminuendo() FL_NOEXCEPT
float getTrebleLevel() FL_NOEXCEPT
void setGain(float gain) FL_NOEXCEPT
Set a simple digital gain multiplier applied to each sample before detector.
float getDynamicTrend() FL_NOEXCEPT
float getKeyConfidence() FL_NOEXCEPT
float getTransientStrength() FL_NOEXCEPT
float getTempoConfidence() FL_NOEXCEPT
float getPitch() FL_NOEXCEPT
float getBPM() FL_NOEXCEPT
float getMeasurePhase() FL_NOEXCEPT
float getTempoBPM() FL_NOEXCEPT
float getBuildupIntensity() FL_NOEXCEPT
float getBuildupProgress() FL_NOEXCEPT
float getBeatConfidence() FL_NOEXCEPT
float getBackbeatConfidence() FL_NOEXCEPT
float getPitchConfidence() FL_NOEXCEPT
float getPeakLevel() FL_NOEXCEPT
float getVocalConfidence() FL_NOEXCEPT
float mapFrequencyBin(int fromBin, int toBin)
void detectEnhancedBeats(fl::u32 currentTimeMs)
void begin(const ReactiveConfig &config=ReactiveConfig{})
fl::u8 volumeToScale255() const
void update(fl::u32 currentTimeMs)
fl::u8 frequencyToScale255(fl::u8 binIndex) const
const SignalConditioner::Stats & getSignalConditionerStats() const
void detectBeat(fl::u32 currentTimeMs)
void processSample(const Sample &sample)
fl::unique_ptr< SpectralFluxDetector > mSpectralFluxDetector
SilenceEnvelope mDominantFrequencyEnvelope
fl::unique_ptr< SpectralEqualizer > mSpectralEqualizer
CRGB volumeToColor(const CRGBPalette16 &palette) const
fl::unique_ptr< detector::MultiBandBeat > mMultiBandBeatDetector
shared_ptr< Context > mContext
fl::unique_ptr< Processor > mAudioProcessor
bool isSpectralEqualizerEnabled() const
Processor & ensureAudioProcessor()
void processFFT(const Sample &sample)
SilenceEnvelope mMagnitudeEnvelope
ReactiveConfig mConfig
void setConfig(const ReactiveConfig &config)
SignalConditioner mSignalConditioner
NoiseFloorTracker mNoiseFloorTracker
const Data & getSmoothedData() const
fl::unique_ptr< detector::MusicalBeat > mMusicalBeatDetector
const SpectralEqualizer::Stats & getSpectralEqualizerStats() const
fl::unique_ptr< PerceptualWeighting > mPerceptualWeighting
fl::array< float, 16 > mPreviousMagnitudes
const NoiseFloorTracker::Stats & getNoiseFloorStats() const
static constexpr fl::u32 BEAT_COOLDOWN
const Data & getData() const
~Reactive() FL_NOEXCEPT
float computeRMS(const fl::vector< fl::i16 > &samples)
SilenceEnvelope mSpectralFluxEnvelope
FrequencyBinMapper mFrequencyBinMapper
void updateVolumeAndPeak(const Sample &sample)
bool isValid() const FL_NOEXCEPT
Definition audio.h:35
float rms() const FL_NOEXCEPT
const VectorPCM & pcm() const FL_NOEXCEPT
Definition audio.cpp.hpp:73
Get current statistics (for debugging/monitoring)
Get statistics (for debugging/monitoring)
float calculateSpectralFlux(span< const float, 16 > currentBins, span< const float, 16 > previousBins)
bool detectOnset(span< const float, 16 > currentBins)
fl::array< float, 16 > mPreviousMagnitudes
constexpr bool empty() const FL_NOEXCEPT
Definition span.h:510
constexpr fl::size size() const FL_NOEXCEPT
Definition span.h:458
fl::size size() const FL_NOEXCEPT
bool empty() const FL_NOEXCEPT
High-resolution microphone frequency response data and utilities.
u32 samplesPerFrame
Samples per frame - used for timing calculations.
float midThreshold
Mid beat threshold (0.0-1.0) Energy increase required to trigger mid beat.
u32 sampleRate
Sample rate (Hz) - used for timing calculations.
float maxBPM
Maximum BPM to detect (default: 250 BPM)
float trebleThreshold
Treble beat threshold (0.0-1.0) Energy increase required to trigger treble beat.
float minBeatConfidence
Minimum beat confidence to report a beat (0.0-1.0) Higher values = fewer false positives,...
float minBPM
Minimum BPM to detect (default: 50 BPM)
float bassThreshold
Bass beat threshold (0.0-1.0) Energy increase required to trigger bass beat.
Configuration for musical beat detection.
Configuration for multi-band beat detection.
float computeAudioDt(fl::size pcmSize, int sampleRate) FL_NOEXCEPT
Compute the time delta (in seconds) for an audio buffer.
void computePinkNoiseGains(const float *binCenters, int numBins, float *out)
Compute pink noise compensation gains for all bins.
bool enabled
Enable noise floor tracking.
bool enableDCRemoval
Enable DC offset removal (running average high-pass filter)
bool enableNoiseGate
Enable noise gate with hysteresis.
EqualizationCurve curve
Equalization curve type.
float frequencyBins[16]
bool useLogSpacing
Use logarithmic spacing (recommended for audio) Logarithmic spacing provides better bass/mid/treble s...
u32 fftBinCount
Number of FFT bins available from FFT output For 512-sample FFT at 22050 Hz: 256 bins (512/2)
bool enableSpikeFilter
Enable spike filtering for I2S glitches.
float minFrequency
Minimum frequency (Hz) - default 20 Hz (bass)
float maxFrequency
Maximum frequency (Hz) - default 16000 Hz (treble)
u32 sampleRate
Sample rate (Hz) - must match FFT sample rate.
size numBands
Number of frequency bands (must match FrequencyBinMapper output)
FrequencyBinMode mode
Number of output frequency bins (16 or 32)
Configuration for frequency bin mapping.
Configuration for signal conditioning pipeline.
Configuration for noise floor tracking.
Configuration for spectral equalizer.
unsigned char u8
Definition s16x16x4.h:132
unsigned char u8
Definition stdint.h:131
float sqrtf(float value) FL_NOEXCEPT
Definition math.h:453
fl::CRGB CRGB
Definition video.h:15
constexpr int type_rank< T >::value
float expf(float value) FL_NOEXCEPT
Definition math.h:398
fl::enable_if<!fl::is_array< T >::value, unique_ptr< T > >::type make_unique(Args &&... args) FL_NOEXCEPT
Definition unique_ptr.h:261
CRGB sample(const CRGB *grid, const XYMap &xyMap, float x, float y, SampleMode mode)
Sample a pixel from a 2D CRGB grid at floating-point coordinates.
Definition sample.cpp.hpp:9
shared_ptr< T > make_shared(Args &&... args) FL_NOEXCEPT
Definition shared_ptr.h:414
float logf(float value) FL_NOEXCEPT
Definition math.h:418
Base definition for an LED controller.
Definition crgb.hpp:179
#define FL_NOEXCEPT
#define SKETCH_HAS_LARGE_MEMORY
Representation of an 8-bit RGB pixel (Red, Green, Blue)
Definition crgb.h:38
void setThresholds(float bassThresh, float midThresh, float trebleThresh)
~BeatDetectors() FL_NOEXCEPT
void detectBeats(span< const float, 16 > frequencyBins, Data &audioData)
SpectralFluxDetector combined
static float DefaultMaxFrequency() FL_NOEXCEPT
Definition fft.h:128
static float DefaultMinFrequency() FL_NOEXCEPT
Definition fft.h:127