DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
KeyDetector.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
62#include "../Core/AudioBuffer.h"
63#include "../Core/AudioSpec.h"
64#include "../Core/DspMath.h"
65#include "ChordDetector.h"
66#include "HarmonyConstants.h"
67
68#include <algorithm>
69#include <array>
70#include <atomic>
71#include <cmath>
72#include <cstddef>
73#include <cstdint>
74#include <span>
75#include <string_view>
76
77namespace dspark {
78
91template <FloatType T>
92class KeyDetector final
93{
94public:
118 enum class Profile : std::uint8_t
119 {
120 KrumhanslKessler = 0,
121 Temperley
122 };
123
125 struct Key
126 {
128 bool isMinor = false;
129 T confidence = T(0);
131 bool runnerUpMinor = false;
135 };
136
137 // -- Lifecycle ---------------------------------------------------------------
138
159 void prepare(const AudioSpec& spec, int windowSize = 0)
160 {
161 // The shared front end validates the complete specification and its
162 // strict Nyquist bound before changing any state. Only commit wrapper
163 // state after it reports that the same request was accepted.
164 if (!front_.prepare(spec, windowSize)) return;
165 prepared_.store(true, std::memory_order_release);
166 reset();
167 }
168
176 void reset() noexcept
177 {
178 front_.reset();
179 accum_.fill(0.0);
180 chromaOut_.fill(T(0));
181 maxFrameTotal_ = 0.0;
182 lastFrame_ = 0;
183 framesUsed_ = 0;
184 packed_.store(pack(Key {}), std::memory_order_relaxed);
185 }
186
194 void setProfile(Profile p) noexcept
195 {
196 profile_.store(static_cast<std::uint8_t>(p), std::memory_order_relaxed);
197 }
198
200 [[nodiscard]] Profile getProfile() const noexcept
201 {
202 return static_cast<Profile>(profile_.load(std::memory_order_relaxed));
203 }
204
212 [[nodiscard]] int getWindowSize() const noexcept { return front_.getWindowSize(); }
213
214 // -- Processing -------------------------------------------------------------------
215
224 {
225 if (!prepared_.load(std::memory_order_acquire)) return;
226 const int hop = front_.getHopSize();
227 const int n = buffer.getNumSamples();
228 if (hop <= 0 || n <= 0) return;
229 for (int off = 0; off < n; off += hop)
230 {
231 front_.processBlock(buffer.getSubView(off, std::min(hop, n - off)));
232 consumeFrames();
233 }
234 }
235
237 void pushSamples(std::span<const T> samples) noexcept
238 {
239 if (!prepared_.load(std::memory_order_acquire)) return;
240 const int hop = front_.getHopSize();
241 if (hop <= 0) return;
242 const auto step = static_cast<std::size_t>(hop);
243 for (std::size_t off = 0; off < samples.size(); off += step)
244 {
245 front_.pushSamples(samples.subspan(off, std::min(step, samples.size() - off)));
246 consumeFrames();
247 }
248 }
249
250 // -- Readout ----------------------------------------------------------------------
251
276 [[nodiscard]] Key getKey() const noexcept
277 {
278 return unpack(packed_.load(std::memory_order_relaxed));
279 }
280
297 [[nodiscard]] const std::array<T, 12>& chroma() const noexcept { return chromaOut_; }
298
300 [[nodiscard]] std::uint64_t getFrameCount() const noexcept { return framesUsed_; }
301
312 static int getKeyName(const Key& key, char* dest, int size) noexcept
313 {
314 if (dest == nullptr || size <= 0) return 0;
315 // Parenthesised on purpose: inside a class template MSVC can read a
316 // bare `a.b < ... || a.b > ...` chain as a template argument list.
317 if ((key.tonicPitchClass < 0) || (key.tonicPitchClass > 11))
318 {
319 dest[0] = '\0';
320 return 0;
321 }
322 const std::string_view root =
323 harmony::noteName(key.tonicPitchClass, key.tonicPitchClass);
324 int len = 0;
325 for (char c : root)
326 {
327 if (len >= size - 1) break;
328 dest[len++] = c;
329 }
330 if (key.isMinor && len < size - 1) dest[len++] = 'm';
331 dest[len] = '\0';
332 return len;
333 }
334
335 // -- Published key profiles -------------------------------------------------------
336
345 static constexpr std::array<double, 12> kKrumhanslKesslerMajor {
346 6.35, 2.23, 3.48, 2.33, 4.38, 4.09, 2.52, 5.19, 2.39, 3.66, 2.29, 2.88
347 };
348
357 static constexpr std::array<double, 12> kKrumhanslKesslerMinor {
358 6.33, 2.68, 3.52, 5.38, 2.60, 3.53, 2.54, 4.75, 3.98, 2.69, 3.34, 3.17
359 };
360
369 static constexpr std::array<double, 12> kTemperleyMajor {
370 5.0, 2.0, 3.5, 2.0, 4.5, 4.0, 2.0, 4.5, 2.0, 3.5, 1.5, 4.0
371 };
372
381 static constexpr std::array<double, 12> kTemperleyMinor {
382 5.0, 2.0, 3.5, 4.5, 2.0, 4.0, 2.0, 4.5, 3.5, 2.0, 1.5, 4.0
383 };
384
385private:
396 static constexpr double kFrameGateFraction = 1.0e-3;
397
399 static constexpr double kFrameEnergyFloor = 1.0e-12;
400
401 void consumeFrames() noexcept
402 {
403 const std::uint64_t fc = front_.getFrameCount();
404 if (fc == lastFrame_) return;
405 lastFrame_ = fc;
406
407 const std::array<T, 12>& frame = front_.getChroma();
408 double total = 0.0;
409 for (const T v : frame) total += static_cast<double>(v);
410 if (!(total > kFrameEnergyFloor)) return;
411
412 if (total > maxFrameTotal_) maxFrameTotal_ = total;
413 if (total < kFrameGateFraction * maxFrameTotal_) return;
414
415 // Equal weight per frame: the input to the method is how LONG each
416 // pitch class sounds, so a frame's own level must not scale its vote.
417 const double inv = 1.0 / total;
418 for (int pc = 0; pc < 12; ++pc)
419 accum_[static_cast<std::size_t>(pc)] +=
420 static_cast<double>(frame[static_cast<std::size_t>(pc)]) * inv;
421 ++framesUsed_;
422
423 estimate();
424 }
425
426 void estimate() noexcept
427 {
428 double sum = 0.0;
429 for (const double v : accum_) sum += v;
430 if (!(sum > 0.0)) return;
431
432 // The correlation reads the ACCUMULATOR, not the normalized readout
433 // built below. Pearson subtracts each argument's mean and divides by
434 // its spread, so multiplying the chroma by any positive constant
435 // divides straight back out of r: normalizing first cannot move an
436 // argmax, a top correlation or a confidence, only their last bits.
437 // The normalization exists for the readout alone, for the reason
438 // stated at chroma(), and is deliberately not in the path of the
439 // estimate.
440 const double inv = 1.0 / sum;
441 for (int pc = 0; pc < 12; ++pc)
442 chromaOut_[static_cast<std::size_t>(pc)] =
443 static_cast<T>(accum_[static_cast<std::size_t>(pc)] * inv);
444
445 const bool temperley = (getProfile() == Profile::Temperley);
446 const auto& major = temperley ? kTemperleyMajor : kKrumhanslKesslerMajor;
447 const auto& minor = temperley ? kTemperleyMinor : kKrumhanslKesslerMinor;
448
449 std::array<double, 24> scores {};
450
451 for (int mode = 0; mode < 2; ++mode)
452 {
453 const auto& p = (mode == 0) ? major : minor;
454 for (int tonic = 0; tonic < 12; ++tonic)
455 {
456 const double r = pearsonRotated(accum_, p, tonic);
457 scores[static_cast<std::size_t>(mode * 12 + tonic)] = r;
458 }
459 }
460
461 // Fixed-size stable insertion sort: score descending, exact ties in
462 // candidate ordinal order. Retaining the complete order makes both the
463 // numerical runner-up and the relative candidate's real rank explicit
464 // without allocating on the processing path.
465 std::array<int, 24> order {};
466 for (int ordinal = 0; ordinal < 24; ++ordinal)
467 {
468 int pos = ordinal;
469 while (pos > 0
470 && scores[static_cast<std::size_t>(ordinal)]
471 > scores[static_cast<std::size_t>(order[static_cast<std::size_t>(pos - 1)])])
472 {
473 order[static_cast<std::size_t>(pos)] =
474 order[static_cast<std::size_t>(pos - 1)];
475 --pos;
476 }
477 order[static_cast<std::size_t>(pos)] = ordinal;
478 }
479
480 const int bestOrdinal = order[0];
481 const int secondOrdinal = order[1];
482 const double best = scores[static_cast<std::size_t>(bestOrdinal)];
483 const double second = scores[static_cast<std::size_t>(secondOrdinal)];
484 const int bestPc = bestOrdinal % 12;
485 const bool bestMinor = bestOrdinal >= 12;
486
487 // A major key's relative minor is nine semitones above it; a minor
488 // key's relative major is three above. It is a named policy candidate,
489 // never a replacement for the numerical runner-up.
490 const int relativePc = (bestPc + (bestMinor ? 3 : 9)) % 12;
491 const bool relativeMinor = !bestMinor;
492 const int relativeOrdinal = (relativeMinor ? 12 : 0) + relativePc;
493 int relativeRank = 0;
494 for (int rank = 0; rank < 24; ++rank)
495 {
496 if (order[static_cast<std::size_t>(rank)] == relativeOrdinal)
497 {
498 relativeRank = rank + 1;
499 break;
500 }
501 }
502
503 Key key;
504 key.tonicPitchClass = bestPc;
505 key.isMinor = bestMinor;
506 key.runnerUpPitchClass = secondOrdinal % 12;
507 key.runnerUpMinor = secondOrdinal >= 12;
508 key.relativeAlternativePitchClass = relativePc;
509 key.relativeAlternativeMinor = relativeMinor;
510 key.relativeAlternativeRank = relativeRank;
511 // Undefined where the winner is not itself a positive correlation:
512 // dividing by a zero or negative top would turn "no tonal structure"
513 // into a large number of the wrong sign.
514 key.confidence = (best > 0.0)
515 ? static_cast<T>(std::clamp(
516 (best - second) / best, 0.0, 1.0))
517 : T(0);
518 packed_.store(pack(key), std::memory_order_relaxed);
519 }
520
533 [[nodiscard]] static double pearsonRotated(const std::array<double, 12>& x,
534 const std::array<double, 12>& profile,
535 int tonic) noexcept
536 {
537 double mx = 0.0, mp = 0.0;
538 for (int i = 0; i < 12; ++i)
539 {
540 mx += x[static_cast<std::size_t>(i)];
541 mp += profile[static_cast<std::size_t>(i)];
542 }
543 mx /= 12.0;
544 mp /= 12.0;
545
546 double num = 0.0, dx = 0.0, dp = 0.0;
547 for (int pc = 0; pc < 12; ++pc)
548 {
549 const double a = x[static_cast<std::size_t>(pc)] - mx;
550 const double b = profile[static_cast<std::size_t>((pc - tonic + 12) % 12)] - mp;
551 num += a * b;
552 dx += a * a;
553 dp += b * b;
554 }
555 const double den = std::sqrt(dx * dp);
556 return (den > 0.0) ? (num / den) : 0.0;
557 }
558
559 // One packed word so a reader never sees fields from different estimates:
560 // bits 0-15 confidence; 16/17-20 winner mode/tonic+1; 21/22-25 numerical
561 // runner-up mode/tonic+1; 26/27-30 relative mode/tonic+1; 31-35 rank.
562 [[nodiscard]] static std::uint64_t pack(const Key& k) noexcept
563 {
564 const auto q = static_cast<std::uint64_t>(
565 std::clamp(static_cast<double>(k.confidence), 0.0, 1.0) * 65535.0 + 0.5);
566 const auto t = static_cast<std::uint64_t>(
567 std::clamp(k.tonicPitchClass, -1, 11) + 1);
568 const auto ru = static_cast<std::uint64_t>(
569 std::clamp(k.runnerUpPitchClass, -1, 11) + 1);
570 const auto rel = static_cast<std::uint64_t>(
571 std::clamp(k.relativeAlternativePitchClass, -1, 11) + 1);
572 const auto rank = static_cast<std::uint64_t>(
573 std::clamp(k.relativeAlternativeRank, 0, 24));
574 return q
575 | (static_cast<std::uint64_t>(k.isMinor ? 1 : 0) << 16)
576 | (t << 17)
577 | (static_cast<std::uint64_t>(k.runnerUpMinor ? 1 : 0) << 21)
578 | (ru << 22)
579 | (static_cast<std::uint64_t>(k.relativeAlternativeMinor ? 1 : 0) << 26)
580 | (rel << 27)
581 | (rank << 31);
582 }
583
584 [[nodiscard]] static Key unpack(std::uint64_t v) noexcept
585 {
586 Key k;
587 k.confidence = static_cast<T>(static_cast<double>(v & 0xFFFFu) / 65535.0);
588 k.isMinor = ((v >> 16) & 1u) != 0u;
589 k.tonicPitchClass = static_cast<int>((v >> 17) & 0xFu) - 1;
590 k.runnerUpMinor = ((v >> 21) & 1u) != 0u;
591 k.runnerUpPitchClass = static_cast<int>((v >> 22) & 0xFu) - 1;
592 k.relativeAlternativeMinor = ((v >> 26) & 1u) != 0u;
593 k.relativeAlternativePitchClass = static_cast<int>((v >> 27) & 0xFu) - 1;
594 k.relativeAlternativeRank = static_cast<int>((v >> 31) & 0x1Fu);
595 return k;
596 }
597
598 static_assert(std::atomic<std::uint64_t>::is_always_lock_free,
599 "audio-thread stores must not lock");
600
601 ChordDetector<T> front_;
602 std::atomic<bool> prepared_ { false };
603
604 std::array<double, 12> accum_ {};
605 std::array<T, 12> chromaOut_ {};
606 double maxFrameTotal_ = 0.0;
607 std::uint64_t lastFrame_ = 0;
608 std::uint64_t framesUsed_ = 0;
609
610 std::atomic<std::uint64_t> packed_ { 0 };
611 std::atomic<std::uint8_t> profile_ { static_cast<std::uint8_t>(Profile::KrumhanslKessler) };
612};
613
614} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Key estimation by profile correlation over an accumulated chroma.
Definition KeyDetector.h:93
static constexpr std::array< double, 12 > kKrumhanslKesslerMinor
Krumhansl & Kessler (1982) probe-tone ratings, minor.
void pushSamples(std::span< const T > samples) noexcept
Feeds mono samples directly. Allocation-free.
void prepare(const AudioSpec &spec, int windowSize=0)
Prepares the analysis pipeline.
static constexpr std::array< double, 12 > kKrumhanslKesslerMajor
Krumhansl & Kessler (1982) probe-tone ratings, major.
static int getKeyName(const Key &key, char *dest, int size) noexcept
Writes a key name ("C", "F#m", "Bbm") into dest.
static constexpr std::array< double, 12 > kTemperleyMajor
Temperley (1999) revised profiles, major.
Profile
Which published key profile to correlate against.
@ KrumhanslKessler
Krumhansl & Kessler 1982 probe-tone ratings.
@ Temperley
Temperley 1999 revised profiles.
void processBlock(AudioBufferView< const T > buffer) noexcept
Feeds a block (channels averaged to mono). Allocation-free.
std::uint64_t getFrameCount() const noexcept
static constexpr std::array< double, 12 > kTemperleyMinor
Temperley (1999) revised profiles, minor.
Key getKey() const noexcept
void setProfile(Profile p) noexcept
Selects the key profile (default KrumhanslKessler).
Profile getProfile() const noexcept
const std::array< T, 12 > & chroma() const noexcept
The accumulated chroma the estimate was formed from.
void reset() noexcept
Clears the accumulated chroma and forgets the estimate.
int getWindowSize() const noexcept
constexpr std::string_view noteName(int midi, int root=0) noexcept
Returns a human-readable note name for the given MIDI note (0..127).
Main namespace for the DSPark framework.
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:37
One key estimate.
bool relativeAlternativeMinor
Mode of the relative key.
int runnerUpPitchClass
Numerical rank 2; -1 = nothing yet.
bool isMinor
Mode of the numerical winner.
int tonicPitchClass
Numerical rank 1; -1 = nothing yet.
int relativeAlternativeRank
Its actual 1-based numerical rank.
int relativeAlternativePitchClass
Winner's relative key.
bool runnerUpMinor
Mode of the numerical runner-up.
T confidence
Numerical rank-1/rank-2 margin.