FastLED 3.9.15
Loading...
Searching...
No Matches
tempo_analyzer.cpp.hpp
Go to the documentation of this file.
3#include "fl/math/math.h"
4#include "fl/stl/algorithm.h"
5#include "fl/stl/noexcept.h"
6
7namespace fl {
8namespace audio {
9namespace detector {
10
12 : mCurrentBPM(120.0f)
13 , mConfidence(0.0f)
14 , mIsStable(false)
15 , mStability(0.0f)
16 , mMinBPM(60.0f)
17 , mMaxBPM(180.0f)
19 , mPreviousFlux(0.0f)
20 , mAdaptiveThreshold(0.0f)
22{
23 mPreviousMagnitudes.resize(8, 0.0f); // Track first 8 bins for spectral flux
25
26 // BPM confidence fades over ~2s of silence — musical tempo has natural
27 // persistence; snapping faster feels wrong. Raw BPM is NOT gated so that
28 // when audio returns, beat sync resumes from the same tempo estimate.
30 cfg.decayTauSeconds = 2.0f;
31 cfg.targetValue = 0.0f;
32 mConfidenceEnvelope.configure(cfg);
33}
34
36
38 mRetainedFFT = context->getFFT16();
39 const fft::Bins& fft = *mRetainedFFT;
40 u32 timestamp = context->getTimestamp();
41
42 // Calculate spectral flux for onset detection
43 float flux = calculateSpectralFlux(fft);
44
45 // Update adaptive threshold
47
48 // Detect onsets
49 if (detectOnset(timestamp)) {
50 mOnsetTimes.push_back(timestamp);
51 if (mOnsetTimes.size() > MAX_ONSET_HISTORY) {
52 mOnsetTimes.pop_front();
53 }
54
55 // Update tempo hypotheses
56 updateHypotheses(timestamp);
57 }
58
59 mPreviousFlux = flux;
60
61 // Update per-bin magnitudes for next frame's spectral flux calculation
62 size numBins = fl::min(static_cast<size>(8), fft.raw().size());
63 for (size i = 0; i < numBins && i < mPreviousMagnitudes.size(); i++) {
64 mPreviousMagnitudes[i] = fft.raw()[i];
65 }
66
67 // Prune weak hypotheses
69
70 // Update current tempo based on best hypothesis
72
73 // Update stability analysis
75
76 // Silence gate: fade confidence toward 0 during silence. The BPM estimate
77 // itself survives unchanged so beat sync is seamless on audio re-entry.
78 // dt derived from timestamp deltas (ms→s). First frame: dt=0 → pass-through.
79 float dt = 0.0f;
80 if (mHasPrevTimestamp && timestamp >= mPrevTimestamp) {
81 dt = static_cast<float>(timestamp - mPrevTimestamp) * 0.001f;
82 }
83 mPrevTimestamp = timestamp;
84 mHasPrevTimestamp = true;
85
86 const bool isSilent = context->isSilent();
88
89 // Track state changes for fireCallbacks()
90 float bpmDiff = fl::abs(mCurrentBPM - mPreviousBPM);
91 mBpmChanged = (bpmDiff > 5.0f);
93}
94
113
115 mCurrentBPM = 120.0f;
116 mConfidence = 0.0f;
117 mIsStable = false;
118 mStability = 0.0f;
119 mPreviousFlux = 0.0f;
120 mAdaptiveThreshold = 0.0f;
123 mHypotheses.clear();
124 mOnsetTimes.clear();
125 mFluxAvg.reset();
126 mBPMMedian.reset();
127 mBPMHistory.clear();
128 mConfidenceEnvelope.reset(0.0f);
129 mPrevTimestamp = 0;
130 mHasPrevTimestamp = false;
131}
132
134 // Focus on low-to-mid frequencies for beat detection
135 float flux = 0.0f;
136 size numBins = fl::min(static_cast<size>(8), fft.raw().size());
137 numBins = fl::min(numBins, mPreviousMagnitudes.size());
138
139 for (size i = 0; i < numBins; i++) {
140 float diff = fft.raw()[i] - mPreviousMagnitudes[i];
141 if (diff > 0.0f) {
142 flux += diff;
143 }
144 }
145
146 return flux / static_cast<float>(numBins);
147}
148
150 // O(1) running average via MovingAverage filter
151 float mean = mFluxAvg.update(mPreviousFlux);
152 mAdaptiveThreshold = mean * 1.5f;
153}
154
155bool TempoAnalyzer::detectOnset(u32 timestamp) {
156 // Simple onset detection: flux exceeds adaptive threshold
158 return false;
159 }
160
161 // Avoid detecting onsets too close together
162 if (!mOnsetTimes.empty()) {
163 u32 timeSinceLastOnset = timestamp - mOnsetTimes.back();
164 if (timeSinceLastOnset < 50) { // 50ms minimum gap
165 return false;
166 }
167 }
168
169 return true;
170}
171
173 // For each existing onset, create/update hypotheses
174 for (size i = 0; i < mOnsetTimes.size(); i++) {
175 if (i == mOnsetTimes.size() - 1) continue; // Skip the just-added onset
176
177 u32 interval = timestamp - mOnsetTimes[i];
178
179 // Check if interval is in valid BPM range
180 if (interval < MIN_BEAT_INTERVAL_MS || interval > MAX_BEAT_INTERVAL_MS) {
181 continue;
182 }
183
184 float bpm = 60000.0f / static_cast<float>(interval);
185
186 // Check if this BPM is within user-defined range
188 continue;
189 }
190
191 // Check if we already have a similar hypothesis
192 bool foundExisting = false;
193 for (size j = 0; j < mHypotheses.size(); j++) {
194 float bpmDiff = fl::abs(mHypotheses[j].bpm - bpm);
195 if (bpmDiff < 3.0f) { // Within 3 BPM tolerance
196 // Update existing hypothesis
197 mHypotheses[j].bpm = (mHypotheses[j].bpm + bpm) * 0.5f;
198 mHypotheses[j].score += calculateIntervalScore(interval);
199 mHypotheses[j].lastOnsetTime = timestamp;
200 mHypotheses[j].onsetCount++;
201 foundExisting = true;
202 break;
203 }
204 }
205
206 // Create new hypothesis if not found and we have room
207 if (!foundExisting && mHypotheses.size() < MAX_HYPOTHESES) {
208 TempoHypothesis hyp;
209 hyp.bpm = bpm;
210 hyp.score = calculateIntervalScore(interval);
211 hyp.lastOnsetTime = timestamp;
212 hyp.onsetCount = 1;
213 mHypotheses.push_back(hyp);
214 }
215 }
216}
217
219 // Remove hypotheses that haven't been updated recently
220 for (size i = 0; i < mHypotheses.size(); ) {
221 // Decay score over time
222 mHypotheses[i].score *= 0.95f;
223
224 if (mHypotheses[i].score < 0.1f) {
225 mHypotheses.erase(mHypotheses.begin() + i);
226 } else {
227 i++;
228 }
229 }
230
231 // Sort by score (highest first)
232 for (size i = 0; i < mHypotheses.size(); i++) {
233 for (size j = i + 1; j < mHypotheses.size(); j++) {
234 if (mHypotheses[j].score > mHypotheses[i].score) {
236 mHypotheses[i] = mHypotheses[j];
237 mHypotheses[j] = temp;
238 }
239 }
240 }
241
242 // Keep only top hypotheses
243 if (mHypotheses.size() > MAX_HYPOTHESES) {
245 }
246}
247
249 if (mHypotheses.empty()) {
250 mConfidence = 0.0f;
251 return;
252 }
253
254 // Use the best hypothesis, filtered through median for outlier rejection
255 const TempoHypothesis& best = mHypotheses[0];
256 mCurrentBPM = mBPMMedian.update(best.bpm);
258
259 // Add to BPM history for stability analysis
260 mBPMHistory.push_back(mCurrentBPM);
261 if (mBPMHistory.size() > BPM_HISTORY_SIZE) {
262 mBPMHistory.pop_front();
263 }
264}
265
267 if (mBPMHistory.size() < 5) {
268 mStability = 0.0f;
269 mIsStable = false;
271 return;
272 }
273
274 // Calculate variance of recent BPM estimates
275 float sum = 0.0f;
276 for (size i = 0; i < mBPMHistory.size(); i++) {
277 sum += mBPMHistory[i];
278 }
279 float mean = sum / static_cast<float>(mBPMHistory.size());
280
281 float variance = 0.0f;
282 for (size i = 0; i < mBPMHistory.size(); i++) {
283 float diff = mBPMHistory[i] - mean;
284 variance += diff * diff;
285 }
286 variance /= static_cast<float>(mBPMHistory.size());
287
288 // Convert variance to stability score (low variance = high stability)
289 float stddev = fl::sqrt(variance);
290 mStability = fl::max(0.0f, 1.0f - (stddev / 10.0f));
291
292 // Check if stable
296 mIsStable = true;
297 }
298 } else {
300 mIsStable = false;
301 }
302}
303
305 float bpm = 60000.0f / static_cast<float>(interval);
306
307 // All BPM values within the valid range score equally
308 if (bpm >= mMinBPM && bpm <= mMaxBPM) {
309 return 1.0f;
310 }
311
312 // Outside the range, penalize based on distance from nearest boundary
313 float distOutside;
314 if (bpm < mMinBPM) {
315 distOutside = mMinBPM - bpm;
316 } else {
317 distOutside = bpm - mMaxBPM;
318 }
319 float range = mMaxBPM - mMinBPM;
320 float normalizedDist = distOutside / range;
321 return fl::max(0.1f, 1.0f - normalizedDist);
322}
323
325 // Confidence based on:
326 // 1. Hypothesis score
327 // 2. Number of onsets supporting it
328 // 3. Stability
329
330 float scoreComponent = fl::min(1.0f, hyp.score);
331 float onsetComponent = fl::min(1.0f, static_cast<float>(hyp.onsetCount) / 10.0f);
332 float stabilityComponent = mStability;
333
334 return (scoreComponent * 0.4f + onsetComponent * 0.3f + stabilityComponent * 0.3f);
335}
336
337} // namespace detector
338} // namespace audio
339} // namespace fl
void bpm()
static bool isSilent
function_list< void()> onTempoUnstable
vector< TempoHypothesis > mHypotheses
function_list< void()> onTempoStable
function_list< void(float bpm, float confidence)> onTempoWithConfidence
float calculateSpectralFlux(const fft::Bins &fft)
static constexpr size BPM_HISTORY_SIZE
static constexpr size MAX_ONSET_HISTORY
static constexpr u32 MAX_BEAT_INTERVAL_MS
void update(shared_ptr< Context > context) override
float calculateTempoConfidence(const TempoHypothesis &hyp)
function_list< void(float bpm)> onTempoChange
MovingAverage< float, 43 > mFluxAvg
MedianFilter< float, 21 > mBPMMedian
~TempoAnalyzer() FL_NOEXCEPT override
shared_ptr< const fft::Bins > mRetainedFFT
function_list< void(float bpm)> onTempo
static constexpr u32 STABLE_FRAMES_REQUIRED
static constexpr size MAX_HYPOTHESES
FL_DISABLE_WARNING_PUSH U constexpr common_type_t< T, U > min(T a, U b) FL_NOEXCEPT
Definition math.h:71
constexpr common_type_t< T, U > max(T a, U b) FL_NOEXCEPT
Definition math.h:75
constexpr enable_if< is_fixed_point< T >::value, T >::type sqrt(T x) FL_NOEXCEPT
void fill(Iterator first, Iterator last, const T &value) FL_NOEXCEPT
Definition algorithm.h:204
constexpr enable_if< is_fixed_point< T >::value, T >::type abs(T x) FL_NOEXCEPT
Base definition for an LED controller.
Definition crgb.hpp:179
#define FL_NOEXCEPT