DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
ChordDetector.h
Go to the documentation of this file.
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
35#include "../Analysis/Goertzel.h"
36#include "../Core/AudioBuffer.h"
37#include "../Core/AudioSpec.h"
38#include "../Core/DspMath.h"
39#include "../Core/WindowFunctions.h"
40#include "HarmonyConstants.h"
41
42#include <algorithm>
43#include <array>
44#include <atomic>
45#include <cmath>
46#include <cstdint>
47#include <span>
48#include <vector>
49
50namespace dspark {
51
58template <FloatType T>
60{
61public:
63 enum class ChordType : uint8_t
64 {
67 };
68
70 struct Result
71 {
72 int rootPitchClass = -1;
74 float confidence = 0.0f;
75 };
76
77 // -- Lifecycle ---------------------------------------------------------------
78
84 void prepare(const AudioSpec& spec, int windowSize = 4096)
85 {
86 // Conservative no-op on invalid specs (NaN rate included): a hot
87 // detector keeps its previous configuration instead of going deaf.
88 if (!spec.isValid()) return;
89 sampleRate_ = spec.sampleRate;
90 windowSize_ = std::clamp(windowSize, 1024, 16384);
91 hopSize_ = windowSize_ / 2;
92
93 ring_.assign(static_cast<size_t>(windowSize_), T(0));
94 writePos_ = 0;
95 sinceHop_ = 0;
96
97 window_.resize(static_cast<size_t>(windowSize_));
98 WindowFunctions<T>::hann(window_.data(), windowSize_, true);
99 scratch_.resize(static_cast<size_t>(windowSize_));
100
101 for (int n = 0; n < kNumNotes; ++n)
102 {
103 const double freq = 440.0 * std::exp2((kFirstMidi + n - 69) / 12.0);
104 notes_[static_cast<size_t>(n)].prepare(sampleRate_, freq, windowSize_);
105 }
106
107 prepared_.store(true, std::memory_order_release);
108 reset();
109 }
110
118 void reset() noexcept
119 {
120 std::fill(ring_.begin(), ring_.end(), T(0));
121 writePos_ = 0;
122 sinceHop_ = 0;
123 packed_.store(pack(Result {}), std::memory_order_relaxed);
124 }
125
133 void setConfidenceThreshold(float threshold) noexcept
134 {
135 if (!std::isfinite(threshold)) return;
136 threshold_.store(std::clamp(threshold, 0.0f, 1.0f), std::memory_order_relaxed);
137 }
138
140 [[nodiscard]] float getConfidenceThreshold() const noexcept
141 {
142 return threshold_.load(std::memory_order_relaxed);
143 }
144
145 // -- Processing -------------------------------------------------------------------
146
149 {
150 if (!prepared_.load(std::memory_order_acquire)) return;
151 const int nCh = buffer.getNumChannels();
152 const int nS = buffer.getNumSamples();
153 if (nCh <= 0) return;
154
155 const T invCh = T(1) / static_cast<T>(nCh);
156 for (int i = 0; i < nS; ++i)
157 {
158 T m = T(0);
159 for (int ch = 0; ch < nCh; ++ch)
160 m += buffer.getChannel(ch)[i] * invCh;
161 push(m);
162 }
163 }
164
166 void pushSamples(std::span<const T> samples) noexcept
167 {
168 if (!prepared_.load(std::memory_order_acquire)) return;
169 for (const T s : samples)
170 push(s);
171 }
172
173 // -- Readout (lock-free, any thread) ------------------------------------------------
174
176 [[nodiscard]] Result getChord() const noexcept
177 {
178 return unpack(packed_.load(std::memory_order_relaxed));
179 }
180
188 static int getChordName(const Result& result, char* dest, int size) noexcept
189 {
190 if (size <= 0) return 0;
191 if (result.rootPitchClass < 0 || result.type == ChordType::None)
192 {
193 dest[0] = '\0';
194 return 0;
195 }
196 static constexpr const char* kRoots[12] = {
197 "C", "C#", "D", "D#", "E", "F", "F#", "G", "G#", "A", "A#", "B"
198 };
199 static constexpr const char* kSuffix[11] = {
200 "", "", "m", "dim", "aug", "sus2", "sus4", "7", "maj7", "m7", "m7b5"
201 };
202 int len = 0;
203 for (const char* p = kRoots[result.rootPitchClass]; *p && len < size - 1; ++p)
204 dest[len++] = *p;
205 for (const char* p = kSuffix[static_cast<int>(result.type)]; *p && len < size - 1; ++p)
206 dest[len++] = *p;
207 dest[len] = '\0';
208 return len;
209 }
210
211private:
212 static constexpr int kFirstMidi = 36;
213 static constexpr int kNumNotes = 48;
214 static constexpr int kNumTemplates = 10;
215
216 struct Template
217 {
218 ChordType type;
219 harmony::NoteSet mask;
220 int count;
221 };
222
223 static constexpr std::array<Template, kNumTemplates> kTemplates { {
224 { ChordType::Major, 0b000010010001, 3 }, // 0 4 7
225 { ChordType::Minor, 0b000010001001, 3 }, // 0 3 7
226 { ChordType::Diminished, 0b000001001001, 3 }, // 0 3 6
227 { ChordType::Augmented, 0b000100010001, 3 }, // 0 4 8
228 { ChordType::Sus2, 0b000010000101, 3 }, // 0 2 7
229 { ChordType::Sus4, 0b000010100001, 3 }, // 0 5 7
230 { ChordType::Dominant7, 0b010010010001, 4 }, // 0 4 7 10
231 { ChordType::Major7, 0b100010010001, 4 }, // 0 4 7 11
232 { ChordType::Minor7, 0b010010001001, 4 }, // 0 3 7 10
233 { ChordType::HalfDim7, 0b010001001001, 4 }, // 0 3 6 10
234 } };
235
236 void push(T sample) noexcept
237 {
238 ring_[static_cast<size_t>(writePos_)] = sample;
239 writePos_ = (writePos_ + 1) % windowSize_;
240 if (++sinceHop_ >= hopSize_)
241 {
242 sinceHop_ = 0;
243 analyze();
244 }
245 }
246
247 void analyze() noexcept
248 {
249 // Window the ring (oldest sample first).
250 for (int i = 0; i < windowSize_; ++i)
251 {
252 const int idx = (writePos_ + i) % windowSize_;
253 scratch_[static_cast<size_t>(i)] = ring_[static_cast<size_t>(idx)]
254 * window_[static_cast<size_t>(i)];
255 }
256
257 // Note energies -> chroma, tracking the lowest sounding note: the
258 // bass is the standard root disambiguator (e.g. Dsus4 and Gsus2 are
259 // the same pitch-class set; the bass decides which one you played).
260 std::array<double, 12> chroma {};
261 std::array<double, static_cast<size_t>(kNumNotes)> noteE {};
262 double total = 0.0, maxNote = 0.0;
263 for (int n = 0; n < kNumNotes; ++n)
264 {
265 auto& g = notes_[static_cast<size_t>(n)];
266 g.reset();
267 g.processBlock(scratch_.data(), windowSize_);
268 const double e = static_cast<double>(g.getMagnitude());
269 noteE[static_cast<size_t>(n)] = e * e;
270 chroma[static_cast<size_t>((kFirstMidi + n) % 12)] += e * e;
271 total += e * e;
272 maxNote = std::max(maxNote, e * e);
273 }
274 // Lowest LOCAL maximum: window-lobe leakage spreads energy onto
275 // neighbouring semitones, so a plain threshold would pick a sidelobe.
276 int bassPc = -1;
277 for (int n = 0; n < kNumNotes; ++n)
278 {
279 const double e = noteE[static_cast<size_t>(n)];
280 const double prev = (n > 0) ? noteE[static_cast<size_t>(n - 1)] : 0.0;
281 const double next = (n + 1 < kNumNotes) ? noteE[static_cast<size_t>(n + 1)] : 0.0;
282 if (e > 0.15 * maxNote && e >= prev && e >= next)
283 {
284 bassPc = (kFirstMidi + n) % 12;
285 break;
286 }
287 }
288 if (total < 1e-12)
289 {
290 // Silence: drop confidence but keep the last chord displayed.
291 Result held = unpack(packed_.load(std::memory_order_relaxed));
292 held.confidence = 0.0f;
293 packed_.store(pack(held), std::memory_order_relaxed);
294 return;
295 }
296
297 double norm = 0.0;
298 for (const double c : chroma) norm += c * c;
299 norm = std::sqrt(norm);
300
301 // Cosine match against every template at every root.
302 double best = 0.0, second = 0.0;
303 int bestRoot = -1;
304 ChordType bestType = ChordType::None;
305 for (int root = 0; root < 12; ++root)
306 {
307 for (const auto& tpl : kTemplates)
308 {
309 double inSum = 0.0;
310 for (int iv = 0; iv < 12; ++iv)
311 if (tpl.mask & (1u << iv))
312 inSum += chroma[static_cast<size_t>((root + iv) % 12)];
313 double score = inSum / (norm * std::sqrt(static_cast<double>(tpl.count)));
314 if (root == bassPc)
315 score *= 1.25; // the bass note names the chord
316 if (score > best)
317 {
318 second = best;
319 best = score;
320 bestRoot = root;
321 bestType = tpl.type;
322 }
323 else if (score > second)
324 {
325 second = score;
326 }
327 }
328 }
329
330 // Confidence: absolute quality times the margin over the runner-up.
331 const double margin = (best > 1e-9) ? std::clamp((best - second) / best * 4.0, 0.0, 1.0)
332 : 0.0;
333 const auto confidence = static_cast<float>(std::clamp(best, 0.0, 1.0) * (0.5 + 0.5 * margin));
334
335 Result out;
336 if (confidence >= threshold_.load(std::memory_order_relaxed))
337 {
338 out.rootPitchClass = bestRoot;
339 out.type = bestType;
340 out.confidence = confidence;
341 }
342 else
343 {
344 out = unpack(packed_.load(std::memory_order_relaxed)); // hold
345 out.confidence = confidence;
346 }
347 packed_.store(pack(out), std::memory_order_relaxed);
348 }
349
350 // Pack the result into one atomic word (no torn reads cross-thread).
351 [[nodiscard]] static uint64_t pack(const Result& r) noexcept
352 {
353 const auto conf = static_cast<uint32_t>(std::clamp(r.confidence, 0.0f, 1.0f) * 65535.0f);
354 return (static_cast<uint64_t>(static_cast<uint8_t>(r.rootPitchClass + 1)) << 24)
355 | (static_cast<uint64_t>(static_cast<uint8_t>(r.type)) << 16)
356 | conf;
357 }
358
359 [[nodiscard]] static Result unpack(uint64_t v) noexcept
360 {
361 Result r;
362 r.rootPitchClass = static_cast<int>((v >> 24) & 0xFF) - 1;
363 r.type = static_cast<ChordType>((v >> 16) & 0xFF);
364 r.confidence = static_cast<float>(v & 0xFFFF) / 65535.0f;
365 return r;
366 }
367
368 // -- Members --------------------------------------------------------------------
369 double sampleRate_ = 48000.0;
370 int windowSize_ = 4096;
371 int hopSize_ = 2048;
372 std::atomic<bool> prepared_ { false };
373
374 std::vector<T> ring_, window_, scratch_;
375 int writePos_ = 0;
376 int sinceHop_ = 0;
377
378 std::array<Goertzel<T>, static_cast<size_t>(kNumNotes)> notes_;
379
380 std::atomic<uint64_t> packed_ { 0 };
381 std::atomic<float> threshold_ { 0.55f };
382};
383
384} // namespace dspark
Comprehensive musical harmony calculations - scales, chords, MIDI, theory.
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Monophonic-buffer chord recognition with confidence gating.
void prepare(const AudioSpec &spec, int windowSize=4096)
Prepares the analysis pipeline.
void pushSamples(std::span< const T > samples) noexcept
Feeds mono samples directly.
ChordType
Recognized chord families.
void processBlock(AudioBufferView< const T > buffer) noexcept
Feeds a block (channels averaged to mono).
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"...).
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:35
constexpr bool isValid() const noexcept
Checks if the specification contains valid, processable parameters.
Definition AudioSpec.h:67
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:43
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.