DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
ChordDetector.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
49#include "../Analysis/Goertzel.h"
50#include "../Core/AudioBuffer.h"
51#include "../Core/AudioSpec.h"
52#include "../Core/DspMath.h"
53#include "../Core/WindowFunctions.h"
54#include "HarmonyConstants.h"
55
56#include <algorithm>
57#include <array>
58#include <atomic>
59#include <cmath>
60#include <cstddef>
61#include <cstdint>
62#include <span>
63#include <vector>
64
65namespace dspark {
66
79template <FloatType T>
81{
82public:
84 enum class ChordType : std::uint8_t
85 {
88 };
89
91 struct Result
92 {
93 int rootPitchClass = -1;
95 float confidence = 0.0f;
96 };
97
98 // -- Lifecycle ---------------------------------------------------------------
99
161 bool prepare(const AudioSpec& spec, int windowSize = 0)
162 {
163 // Conservative no-op on invalid specs (NaN rate included): a hot
164 // detector keeps its previous configuration instead of going deaf.
165 if (!spec.isValid() || !std::isfinite(spec.sampleRate)) return false;
166 const double highestNote =
167 440.0 * std::exp2((kFirstMidi + kNumNotes - 1 - 69) / 12.0);
168 if (!(spec.sampleRate > highestNote * 2.0)) return false;
169 sampleRate_ = spec.sampleRate;
170 if (windowSize <= 0)
171 {
172 // Automatic: hold the analysis TIME SPAN constant across sample
173 // rates (the span 4096 samples cover at 48 kHz). The Hann main
174 // lobe spans ~4/timeSpan Hz, so a constant span pins the
175 // frequency resolution -- and with it the reliable register --
176 // instead of letting it degrade as fs rises. Rounding up to a
177 // power of two only narrows the lobe further.
178 const double target = sampleRate_ * (4096.0 / 48000.0);
179 int n = 1024;
180 while (n < 16384 && static_cast<double>(n) < target) n *= 2;
181 windowSize_ = n;
182 }
183 else
184 {
185 windowSize_ = std::clamp(windowSize, 1024, 16384);
186 }
187 hopSize_ = windowSize_ / 2;
188
189 ring_.assign(static_cast<std::size_t>(windowSize_), T(0));
190 writePos_ = 0;
191 sinceHop_ = 0;
192
193 window_.resize(static_cast<std::size_t>(windowSize_));
194 WindowFunctions<T>::hann(window_.data(), windowSize_, true);
195 scratch_.resize(static_cast<std::size_t>(windowSize_));
196
197 for (int n = 0; n < kNumNotes; ++n)
198 {
199 const double freq = 440.0 * std::exp2((kFirstMidi + n - 69) / 12.0);
200 notes_[static_cast<std::size_t>(n)].prepare(sampleRate_, freq, windowSize_);
201 }
202
203 prepared_.store(true, std::memory_order_release);
204 reset();
205 return true;
206 }
207
215 void reset() noexcept
216 {
217 std::fill(ring_.begin(), ring_.end(), T(0));
218 writePos_ = 0;
219 sinceHop_ = 0;
220 chromaFrame_.fill(T(0));
221 frameCount_ = 0;
222 packed_.store(pack(Result {}), std::memory_order_relaxed);
223 }
224
232 void setConfidenceThreshold(float threshold) noexcept
233 {
234 if (!std::isfinite(threshold)) return;
235 threshold_.store(std::clamp(threshold, 0.0f, 1.0f), std::memory_order_relaxed);
236 }
237
239 [[nodiscard]] float getConfidenceThreshold() const noexcept
240 {
241 return threshold_.load(std::memory_order_relaxed);
242 }
243
251 [[nodiscard]] int getWindowSize() const noexcept { return windowSize_; }
252
261 [[nodiscard]] int getHopSize() const noexcept { return hopSize_; }
262
263 // -- Shared chroma front end (stream-owner thread) ---------------------------
264
275 [[nodiscard]] std::uint64_t getFrameCount() const noexcept { return frameCount_; }
276
297 [[nodiscard]] const std::array<T, 12>& getChroma() const noexcept { return chromaFrame_; }
298
299 // -- Processing -------------------------------------------------------------------
300
303 {
304 if (!prepared_.load(std::memory_order_acquire)) return;
305 const int nCh = buffer.getNumChannels();
306 const int nS = buffer.getNumSamples();
307 if (nCh <= 0) return;
308
309 const T invCh = T(1) / static_cast<T>(nCh);
310 for (int i = 0; i < nS; ++i)
311 {
312 T m = T(0);
313 for (int ch = 0; ch < nCh; ++ch)
314 m += buffer.getChannel(ch)[i] * invCh;
315 push(m);
316 }
317 }
318
320 void pushSamples(std::span<const T> samples) noexcept
321 {
322 if (!prepared_.load(std::memory_order_acquire)) return;
323 for (const T s : samples)
324 push(s);
325 }
326
327 // -- Readout (lock-free, any thread) ------------------------------------------------
328
330 [[nodiscard]] Result getChord() const noexcept
331 {
332 return unpack(packed_.load(std::memory_order_relaxed));
333 }
334
342 static int getChordName(const Result& result, char* dest, int size) noexcept
343 {
344 if (size <= 0) return 0;
345 if (result.rootPitchClass < 0 || result.type == ChordType::None)
346 {
347 dest[0] = '\0';
348 return 0;
349 }
350 static constexpr const char* kRoots[12] = {
351 "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"
352 };
353 static constexpr const char* kSuffix[11] = {
354 "", "", "m", "dim", "aug", "sus2", "sus4", "7", "maj7", "m7", "m7b5"
355 };
356 int len = 0;
357 for (const char* p = kRoots[result.rootPitchClass]; *p && len < size - 1; ++p)
358 dest[len++] = *p;
359 for (const char* p = kSuffix[static_cast<int>(result.type)]; *p && len < size - 1; ++p)
360 dest[len++] = *p;
361 dest[len] = '\0';
362 return len;
363 }
364
365private:
366 static constexpr int kFirstMidi = 36;
367 static constexpr int kNumNotes = 48;
368 static constexpr int kNumTemplates = 10;
369
370 struct Template
371 {
372 ChordType type;
373 harmony::NoteSet mask;
374 int count;
375 };
376
377 static constexpr std::array<Template, kNumTemplates> kTemplates { {
378 { ChordType::Major, 0b000010010001, 3 }, // 0 4 7
379 { ChordType::Minor, 0b000010001001, 3 }, // 0 3 7
380 { ChordType::Diminished, 0b000001001001, 3 }, // 0 3 6
381 { ChordType::Augmented, 0b000100010001, 3 }, // 0 4 8
382 { ChordType::Sus2, 0b000010000101, 3 }, // 0 2 7
383 { ChordType::Sus4, 0b000010100001, 3 }, // 0 5 7
384 { ChordType::Dominant7, 0b010010010001, 4 }, // 0 4 7 10
385 { ChordType::Major7, 0b100010010001, 4 }, // 0 4 7 11
386 { ChordType::Minor7, 0b010010001001, 4 }, // 0 3 7 10
387 { ChordType::HalfDim7, 0b010001001001, 4 }, // 0 3 6 10
388 } };
389
390 void push(T sample) noexcept
391 {
392 ring_[static_cast<std::size_t>(writePos_)] = sample;
393 writePos_ = (writePos_ + 1) % windowSize_;
394 if (++sinceHop_ >= hopSize_)
395 {
396 sinceHop_ = 0;
397 analyze();
398 }
399 }
400
401 void analyze() noexcept
402 {
403 // Window the ring (oldest sample first).
404 for (int i = 0; i < windowSize_; ++i)
405 {
406 const int idx = (writePos_ + i) % windowSize_;
407 scratch_[static_cast<std::size_t>(i)] = ring_[static_cast<std::size_t>(idx)]
408 * window_[static_cast<std::size_t>(i)];
409 }
410
411 // Note energies -> chroma, tracking the lowest sounding note: the
412 // bass is the standard root disambiguator (e.g. Dsus4 and Gsus2 are
413 // the same pitch-class set; the bass decides which one you played).
414 std::array<double, 12> chroma {};
415 std::array<double, static_cast<std::size_t>(kNumNotes)> noteE {};
416 double total = 0.0, maxNote = 0.0;
417 for (int n = 0; n < kNumNotes; ++n)
418 {
419 auto& g = notes_[static_cast<std::size_t>(n)];
420 g.reset();
421 g.processBlock(scratch_.data(), windowSize_);
422 const double e = static_cast<double>(g.getMagnitude());
423 noteE[static_cast<std::size_t>(n)] = e * e;
424 chroma[static_cast<std::size_t>((kFirstMidi + n) % 12)] += e * e;
425 total += e * e;
426 maxNote = std::max(maxNote, e * e);
427 }
428 // Publish the frame for consumers of this front end before any of the
429 // chord-specific work, and count it even when the frame turns out to
430 // be silent: a consumer polls the count to tell a new frame from the
431 // previous one, so a frame that fails to increment it would be lost.
432 for (int pc = 0; pc < 12; ++pc)
433 chromaFrame_[static_cast<std::size_t>(pc)] =
434 static_cast<T>(chroma[static_cast<std::size_t>(pc)]);
435 ++frameCount_;
436
437 // Lowest LOCAL maximum: window-lobe leakage spreads energy onto
438 // neighbouring semitones, so a plain threshold would pick a sidelobe.
439 int bassPc = -1;
440 for (int n = 0; n < kNumNotes; ++n)
441 {
442 const double e = noteE[static_cast<std::size_t>(n)];
443 const double prev = (n > 0) ? noteE[static_cast<std::size_t>(n - 1)] : 0.0;
444 const double next = (n + 1 < kNumNotes) ? noteE[static_cast<std::size_t>(n + 1)] : 0.0;
445 if (e > 0.15 * maxNote && e >= prev && e >= next)
446 {
447 bassPc = (kFirstMidi + n) % 12;
448 break;
449 }
450 }
451 if (total < 1e-12)
452 {
453 // Silence: drop confidence but keep the last chord displayed.
454 Result held = unpack(packed_.load(std::memory_order_relaxed));
455 held.confidence = 0.0f;
456 packed_.store(pack(held), std::memory_order_relaxed);
457 return;
458 }
459
460 double norm = 0.0;
461 for (const double c : chroma) norm += c * c;
462 norm = std::sqrt(norm);
463
464 // Cosine match against every template at every root.
465 double best = 0.0, second = 0.0;
466 int bestRoot = -1;
467 ChordType bestType = ChordType::None;
468 for (int root = 0; root < 12; ++root)
469 {
470 for (const auto& tpl : kTemplates)
471 {
472 double inSum = 0.0;
473 for (int iv = 0; iv < 12; ++iv)
474 if (tpl.mask & (1u << iv))
475 inSum += chroma[static_cast<std::size_t>((root + iv) % 12)];
476 double score = inSum / (norm * std::sqrt(static_cast<double>(tpl.count)));
477 if (root == bassPc)
478 score *= 1.25; // the bass note names the chord
479 if (score > best)
480 {
481 second = best;
482 best = score;
483 bestRoot = root;
484 bestType = tpl.type;
485 }
486 else if (score > second)
487 {
488 second = score;
489 }
490 }
491 }
492
493 // Confidence: absolute quality times the margin over the runner-up.
494 const double margin = (best > 1e-9) ? std::clamp((best - second) / best * 4.0, 0.0, 1.0)
495 : 0.0;
496 const auto confidence = static_cast<float>(std::clamp(best, 0.0, 1.0) * (0.5 + 0.5 * margin));
497
498 Result out;
499 if (confidence >= threshold_.load(std::memory_order_relaxed))
500 {
501 out.rootPitchClass = bestRoot;
502 out.type = bestType;
503 out.confidence = confidence;
504 }
505 else
506 {
507 out = unpack(packed_.load(std::memory_order_relaxed)); // hold
508 out.confidence = confidence;
509 }
510 packed_.store(pack(out), std::memory_order_relaxed);
511 }
512
513 // Pack the result into one atomic word (no torn reads cross-thread).
514 [[nodiscard]] static std::uint64_t pack(const Result& r) noexcept
515 {
516 const auto conf = static_cast<std::uint32_t>(std::clamp(r.confidence, 0.0f, 1.0f) * 65535.0f);
517 return (static_cast<std::uint64_t>(static_cast<std::uint8_t>(r.rootPitchClass + 1)) << 24)
518 | (static_cast<std::uint64_t>(static_cast<std::uint8_t>(r.type)) << 16)
519 | conf;
520 }
521
522 [[nodiscard]] static Result unpack(std::uint64_t v) noexcept
523 {
524 Result r;
525 r.rootPitchClass = static_cast<int>((v >> 24) & 0xFF) - 1;
526 r.type = static_cast<ChordType>((v >> 16) & 0xFF);
527 r.confidence = static_cast<float>(v & 0xFFFF) / 65535.0f;
528 return r;
529 }
530
531 // -- Members --------------------------------------------------------------------
532 double sampleRate_ = 48000.0;
533 int windowSize_ = 4096;
534 int hopSize_ = 2048;
535 std::atomic<bool> prepared_ { false };
536
537 std::vector<T> ring_, window_, scratch_;
538 int writePos_ = 0;
539 int sinceHop_ = 0;
540
541 std::array<Goertzel<T>, static_cast<std::size_t>(kNumNotes)> notes_;
542
543 // Latest analysis frame, owned by the thread that pushes samples.
544 std::array<T, 12> chromaFrame_ {};
545 std::uint64_t frameCount_ = 0;
546
547 std::atomic<std::uint64_t> packed_ { 0 };
548 std::atomic<float> threshold_ { 0.55f };
549};
550
551} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Monophonic-buffer chord recognition with confidence gating.
int getWindowSize() const noexcept
void pushSamples(std::span< const T > samples) noexcept
Feeds mono samples directly.
bool prepare(const AudioSpec &spec, int windowSize=0)
Prepares the analysis pipeline.
void processBlock(AudioBufferView< const T > buffer) noexcept
Feeds a block (channels averaged to mono).
std::uint64_t getFrameCount() const noexcept
Number of analysis frames produced since prepare()/reset().
int getHopSize() const noexcept
const std::array< T, 12 > & getChroma() const noexcept
The chroma vector of the most recent analysis frame.
void setConfidenceThreshold(float threshold) noexcept
Confidence below which the previous chord is held (default 0.55).
Result getChord() const noexcept
float getConfidenceThreshold() const noexcept
static int getChordName(const Result &result, char *dest, int size) noexcept
Writes a human-readable chord name ("C", "F#m7", "Bbsus4"...).
ChordType
Recognized chord families.
void reset() noexcept
Clears the analysis ring and forgets the held chord.
std::uint16_t NoteSet
A 12-bit bitmask representing the 12 pitch-classes of the chromatic scale.
Main namespace for the DSPark framework.
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:37
constexpr bool isValid() const noexcept
Checks if the specification contains valid, processable parameters.
Definition AudioSpec.h:71
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:45
One detection result.
int rootPitchClass
0 = C ... 11 = B; -1 = none.
static void hann(T *output, int size, bool periodic=true) noexcept
Hann (raised cosine) window.