FastLED 3.9.15
Loading...
Searching...
No Matches
key.cpp.hpp
Go to the documentation of this file.
1// KeyDetector implementation
2
5#include "fl/audio/fft/fft.h"
6#include "fl/math/math.h"
7#include "fl/log/log.h"
8#include "fl/stl/stdio.h"
9#include "fl/stl/noexcept.h"
10
11namespace fl {
12namespace audio {
13namespace detector {
14
15// Krumhansl-Schmuckler key profiles
16// Based on empirical studies of Western tonal music perception
17// Values represent perceptual importance of each scale degree
18
19// Major key profile (Krumhansl & Kessler, 1982)
20const float KeyDetector::MAJOR_PROFILE[12] = {
21 6.35f, // Tonic (C)
22 2.23f, // Minor 2nd (C#)
23 3.48f, // Major 2nd (D)
24 2.33f, // Minor 3rd (Eb)
25 4.38f, // Major 3rd (E)
26 4.09f, // Perfect 4th (F)
27 2.52f, // Tritone (F#)
28 5.19f, // Perfect 5th (G)
29 2.39f, // Minor 6th (Ab)
30 3.66f, // Major 6th (A)
31 2.29f, // Minor 7th (Bb)
32 2.88f // Major 7th (B)
33};
34
35// Minor key profile (Krumhansl & Kessler, 1982)
36const float KeyDetector::MINOR_PROFILE[12] = {
37 6.33f, // Tonic (A)
38 2.68f, // Minor 2nd (A#)
39 3.52f, // Major 2nd (B)
40 5.38f, // Minor 3rd (C)
41 2.60f, // Major 3rd (C#)
42 3.53f, // Perfect 4th (D)
43 2.54f, // Tritone (D#)
44 4.75f, // Perfect 5th (E)
45 3.98f, // Minor 6th (F)
46 2.69f, // Major 6th (F#)
47 3.34f, // Minor 7th (G)
48 3.17f // Major 7th (G#)
49};
50
51// Note names for root note display
52static const char* NOTE_NAMES[12] = {
53 "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"
54};
55
56//------------------------------------------------------------------------------
57// Key struct methods
58//------------------------------------------------------------------------------
59
60const char* Key::getRootName() const {
61 if (rootNote >= 12) return "?";
62 return NOTE_NAMES[rootNote];
63}
64
65void Key::getKeyName(char* buffer, size_t bufferSize) const {
66 if (bufferSize < 8) return; // Need space for "C# min\0"
67 const char* root = getRootName();
68 const char* quality = getQuality();
69 fl::snprintf(buffer, bufferSize, "%s %s", root, quality);
70}
71
72//------------------------------------------------------------------------------
73// KeyDetector implementation
74//------------------------------------------------------------------------------
75
77 : mCurrentKey()
78 , mPreviousKey()
79 , mKeyStartTime(0)
80 , mKeyActive(false)
82 , mMinKeyDuration(2000)
84 , mHistoryIndex(0)
85 , mHistorySize(0)
86{
87 // Initialize chroma history arrays
88 for (int i = 0; i < 12; i++) {
90 }
91
92 // Pre-compute profile statistics once (eliminates 24 recomputations per frame)
94}
95
97
99 // Pre-compute statistics for MAJOR_PROFILE
100 float majorSum = 0.0f, majorSqSum = 0.0f;
101 for (int i = 0; i < 12; i++) {
102 majorSum += MAJOR_PROFILE[i];
103 majorSqSum += MAJOR_PROFILE[i] * MAJOR_PROFILE[i];
104 }
105 mMajorProfileMean = majorSum / 12.0f;
107
108 // Pre-compute statistics for MINOR_PROFILE
109 float minorSum = 0.0f, minorSqSum = 0.0f;
110 for (int i = 0; i < 12; i++) {
111 minorSum += MINOR_PROFILE[i];
112 minorSqSum += MINOR_PROFILE[i] * MINOR_PROFILE[i];
113 }
114 mMinorProfileMean = minorSum / 12.0f;
116}
117
119 // Get fft::FFT data
120 mRetainedFFT = context->getFFT(32); // Use more bins for better pitch resolution
121 const fft::Bins& fft = *mRetainedFFT;
122 u32 timestamp = context->getTimestamp();
123
124 // Extract chroma features
125 float chroma[12] = {0};
126 extractChroma(fft, chroma);
127 normalizeChroma(chroma);
128
129 // Update temporal averaging
130 updateChromaHistory(chroma);
131
132 // Get averaged chroma for stable detection
133 float avgChroma[12] = {0};
134 getAveragedChroma(avgChroma);
135
136 // Detect key from averaged chroma
137 Key detectedKey = detectKey(avgChroma, timestamp);
138
139 // Update key duration if same key
140 if (mKeyActive && detectedKey == mCurrentKey) {
141 mCurrentKey.duration = timestamp - mKeyStartTime;
142 }
143
144 // Check for key change
145 if (detectedKey != mCurrentKey) {
146 // Only accept key change if:
147 // 1. Confidence is above threshold
148 // 2. Previous key was held for minimum duration OR new key is much stronger
149 bool acceptChange = false;
150
151 if (detectedKey.confidence >= mConfidenceThreshold) {
152 if (!mKeyActive) {
153 // No previous key, accept new key
154 acceptChange = true;
155 } else if (mCurrentKey.duration >= mMinKeyDuration) {
156 // Previous key held long enough, allow change
157 acceptChange = true;
158 } else if (detectedKey.confidence > mCurrentKey.confidence * 1.2f) {
159 // New key is significantly stronger, allow early change
160 acceptChange = true;
161 }
162 }
163
164 if (acceptChange) {
166 mCurrentKey = detectedKey;
167 mKeyStartTime = timestamp;
168 mKeyActive = true;
169
170 FL_DBG("Key change: " << mCurrentKey.getRootName() << " "
171 << mCurrentKey.getQuality() << " (confidence: "
172 << mCurrentKey.confidence << ")");
173
174 mFireKeyChange = true;
175 }
176 }
177
178 // Check for key end (confidence drop)
179 if (mKeyActive && detectedKey.confidence < mConfidenceThreshold * 0.5f) {
180 FL_DBG("Key ended: " << mCurrentKey.getRootName() << " " << mCurrentKey.getQuality());
181
182 mFireKeyEnd = true;
183 mKeyActive = false;
184 mCurrentKey.confidence = 0.0f;
185 }
186
187 // Set onKey flag every frame if key is active
188 if (mKeyActive) mFireKey = true;
189}
190
192 mCurrentKey = Key();
193 mPreviousKey = Key();
194 mKeyStartTime = 0;
195 mKeyActive = false;
196 mHistoryIndex = 0;
197 mHistorySize = 0;
198
199 // Clear chroma history
200 for (int i = 0; i < 12; i++) {
201 mChromaHistory[i].clear();
202 }
203}
204
205//------------------------------------------------------------------------------
206// Chroma extraction and processing
207//------------------------------------------------------------------------------
208
209void KeyDetector::extractChroma(const fft::Bins& fft, float* chroma) {
210 // Initialize chroma to zero
211 for (int i = 0; i < 12; i++) {
212 chroma[i] = 0.0f;
213 }
214
215 // Map fft::FFT bins to pitch classes using linearly-rebinned magnitudes.
216 // Linear bins give evenly-spaced frequency mapping: freq = fmin + bin * binWidth.
217 fl::span<const float> linearBins = fft.linear();
218 const int numBins = static_cast<int>(linearBins.size());
219 const float fmin = fft.linearFmin();
220 const float fmax = fft.linearFmax();
221 const float linearBinWidth = (numBins > 0) ? (fmax - fmin) / static_cast<float>(numBins) : 1.0f;
222
223 // Process each linear bin
224 for (int bin = 0; bin < numBins; bin++) {
225 float magnitude = linearBins[bin];
226 if (magnitude < 1e-6f) continue;
227
228 // Calculate frequency for this linearly-spaced bin
229 float freq = fmin + (static_cast<float>(bin) + 0.5f) * linearBinWidth;
230
231 // Skip frequencies below 60 Hz (below C2)
232 if (freq < 60.0f) continue;
233
234 // Convert frequency to MIDI note number
235 // MIDI note = 69 + 12 * log2(freq / 440)
236 float midiNote = 69.0f + 12.0f * (fl::logf(freq / 440.0f) / fl::logf(2.0f));
237
238 // Get pitch class (0-11)
239 int pitchClass = static_cast<int>(midiNote + 0.5f) % 12;
240 if (pitchClass < 0) pitchClass += 12;
241
242 // Accumulate magnitude into pitch class
243 chroma[pitchClass] += magnitude;
244 }
245}
246
247void KeyDetector::normalizeChroma(float* chroma) {
248 // Find maximum value
249 float maxVal = 0.0f;
250 for (int i = 0; i < 12; i++) {
251 if (chroma[i] > maxVal) {
252 maxVal = chroma[i];
253 }
254 }
255
256 // Normalize to 0-1 range
257 if (maxVal > 1e-6f) {
258 for (int i = 0; i < 12; i++) {
259 chroma[i] /= maxVal;
260 }
261 }
262}
263
264void KeyDetector::updateChromaHistory(const float* chroma) {
265 // Add current chroma to history (circular buffer)
266 for (int i = 0; i < 12; i++) {
267 if (static_cast<int>(mChromaHistory[i].size()) < mAveragingFrames) {
268 mChromaHistory[i].push_back(chroma[i]);
269 } else {
270 mChromaHistory[i][mHistoryIndex] = chroma[i];
271 }
272 }
273
276 mHistorySize++;
277 }
278}
279
281 if (mHistorySize == 0) {
282 // No history yet, return zeros
283 for (int i = 0; i < 12; i++) {
284 chroma[i] = 0.0f;
285 }
286 return;
287 }
288
289 // Average across history
290 for (int i = 0; i < 12; i++) {
291 float sum = 0.0f;
292 for (int j = 0; j < mHistorySize; j++) {
293 sum += mChromaHistory[i][j];
294 }
295 chroma[i] = sum / mHistorySize;
296 }
297}
298
299//------------------------------------------------------------------------------
300// Key detection using Krumhansl-Schmuckler algorithm
301//------------------------------------------------------------------------------
302
303Key KeyDetector::detectKey(const float* chroma, u32 timestamp) {
304 float bestCorrelation = -1.0f;
305 u8 bestRoot = 0;
306 bool bestIsMinor = false;
307
308 // Try all 24 possible keys (12 major + 12 minor)
309 for (int root = 0; root < 12; root++) {
310 // Try major key
311 float majorCorr = correlateWithProfile(chroma, MAJOR_PROFILE, root);
312 if (majorCorr > bestCorrelation) {
313 bestCorrelation = majorCorr;
314 bestRoot = root;
315 bestIsMinor = false;
316 }
317
318 // Try minor key
319 float minorCorr = correlateWithProfile(chroma, MINOR_PROFILE, root);
320 if (minorCorr > bestCorrelation) {
321 bestCorrelation = minorCorr;
322 bestRoot = root;
323 bestIsMinor = true;
324 }
325 }
326
327 // Convert correlation to 0-1 confidence
328 // Correlation ranges from -1 to 1, but we typically see 0.5-0.9 for good matches
329 float confidence = (bestCorrelation + 1.0f) / 2.0f; // Map [-1,1] to [0,1]
330 confidence = fl::max(0.0f, fl::min(1.0f, confidence));
331
332 return Key(bestRoot, bestIsMinor, confidence, timestamp);
333}
334
335float KeyDetector::correlateWithProfile(const float* chroma, const float* profile, int rootNote) {
336 // Pearson correlation coefficient between chroma and rotated profile
337 // Uses pre-computed profile statistics (calculated once in constructor)
338
339 // Use pre-computed profile statistics based on which profile
340 float profileMean, profileStdDev;
341 if (profile == MAJOR_PROFILE) {
342 profileMean = mMajorProfileMean;
343 profileStdDev = mMajorProfileStdDev;
344 } else {
345 profileMean = mMinorProfileMean;
346 profileStdDev = mMinorProfileStdDev;
347 }
348 if (profileStdDev < 1e-6f) profileStdDev = 1.0f;
349
350 // Calculate chroma mean and std dev
351 float chromaSum = 0.0f;
352 float chromaSqSum = 0.0f;
353 for (int i = 0; i < 12; i++) {
354 chromaSum += chroma[i];
355 chromaSqSum += chroma[i] * chroma[i];
356 }
357 float chromaMean = chromaSum / 12.0f;
358 float chromaStdDev = sqrtf((chromaSqSum / 12.0f) - (chromaMean * chromaMean));
359 if (chromaStdDev < 1e-6f) chromaStdDev = 1.0f;
360
361 // Calculate correlation with rotated profile
362 float correlation = 0.0f;
363 for (int i = 0; i < 12; i++) {
364 int profileIdx = (i - rootNote + 12) % 12; // Rotate profile to match root
365 float chromaNorm = (chroma[i] - chromaMean) / chromaStdDev;
366 float profileNorm = (profile[profileIdx] - profileMean) / profileStdDev;
367 correlation += chromaNorm * profileNorm;
368 }
369 correlation /= 12.0f; // Average
370
371 return correlation;
372}
373
375 if (mFireKeyEnd) {
376 if (onKeyEnd) onKeyEnd();
377 mFireKeyEnd = false;
378 }
379 if (mFireKeyChange) {
381 mFireKeyChange = false;
382 }
383 if (mFireKey) {
384 if (onKey) onKey(mCurrentKey);
385 mFireKey = false;
386 }
387}
388
389} // namespace detector
390} // namespace audio
391} // namespace fl
shared_ptr< const fft::Bins > mRetainedFFT
Definition key.h:134
void normalizeChroma(float *chroma)
Definition key.cpp.hpp:247
float correlateWithProfile(const float *chroma, const float *profile, int rootNote)
Definition key.cpp.hpp:335
static const float MAJOR_PROFILE[12]
Definition key.h:125
~KeyDetector() FL_NOEXCEPT override
vector< float > mChromaHistory[12]
Definition key.h:120
function_list< void()> onKeyEnd
Definition key.h:96
void extractChroma(const fft::Bins &fft, float *chroma)
Definition key.cpp.hpp:209
function_list< void(const Key &key)> onKeyChange
Definition key.h:95
static const float MINOR_PROFILE[12]
Definition key.h:126
void updateChromaHistory(const float *chroma)
Definition key.cpp.hpp:264
function_list< void(const Key &key)> onKey
Definition key.h:94
void update(shared_ptr< Context > context) override
Definition key.cpp.hpp:118
Key detectKey(const float *chroma, u32 timestamp)
Definition key.cpp.hpp:303
void getAveragedChroma(float *chroma)
Definition key.cpp.hpp:280
constexpr fl::size size() const FL_NOEXCEPT
Definition span.h:458
#define FL_DBG
Definition log.h:388
Centralized logging categories for FastLED hardware interfaces and subsystems.
static const char * NOTE_NAMES[12]
Definition key.cpp.hpp:52
FL_DISABLE_WARNING_PUSH U constexpr common_type_t< T, U > min(T a, U b) FL_NOEXCEPT
Definition math.h:71
unsigned char u8
Definition stdint.h:131
float sqrtf(float value) FL_NOEXCEPT
Definition math.h:453
constexpr common_type_t< T, U > max(T a, U b) FL_NOEXCEPT
Definition math.h:75
int snprintf(char *buffer, fl::size size, const char *format, const Args &... args) FL_NOEXCEPT
Snprintf-like formatting function that writes to a buffer.
Definition stdio.h:666
float logf(float value) FL_NOEXCEPT
Definition math.h:418
Base definition for an LED controller.
Definition crgb.hpp:179
#define FL_NOEXCEPT
const char * getRootName() const
Definition key.cpp.hpp:60
const char * getQuality() const
Definition key.h:62
void getKeyName(char *buffer, size_t bufferSize) const
Definition key.cpp.hpp:65