DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
PitchDetector.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
29#include "../Core/DspMath.h"
30#include "../Core/FFT.h"
31
32#include <algorithm>
33#include <atomic>
34#include <cmath>
35#include <cstddef>
36#include <memory>
37#include <span>
38#include <vector>
39
40namespace dspark {
41
53template <FloatType T>
55{
56public:
102 void prepare(double sampleRate, int windowSize = 0, int hopSize = 0)
103 {
104 if (!std::isfinite(sampleRate) || sampleRate <= 0.0)
105 return;
106
107 fft_.reset(); // gate OFF: pushSamples() is a no-op while rebuilding
108
109 sampleRate_ = sampleRate;
110 if (windowSize <= 0)
111 {
112 // Automatic: hold the analysis TIME SPAN constant across sample
113 // rates (the span 2048 samples cover at 48 kHz). The reachable
114 // register is fs/(windowSize/2 - 1), so a constant span pins the
115 // lowest detectable fundamental instead of letting it climb with
116 // the rate.
117 const double target = sampleRate_ * (kAutoSpanRef / kAutoSpanRate);
118 int n = kAutoMinWindow;
119 while (n < kAutoMaxWindow && static_cast<double>(n) < target) n <<= 1;
120 windowSize_ = n;
121 }
122 else
123 {
124 windowSize_ = std::clamp(windowSize, 64, 1 << 20);
125 }
126 halfWindow_ = windowSize_ / 2;
127 hopSize_ = (hopSize <= 0) ? std::max(1, windowSize_ / 4)
128 : std::clamp(hopSize, 1, windowSize_);
129
130 // Mirrored buffer technique: size is 2x windowSize.
131 // Guarantees continuous memory layout without modulo operations.
132 buffer_.assign(static_cast<size_t>(windowSize_) * 2, T(0));
133 yinBuffer_.assign(static_cast<size_t>(halfWindow_), T(0));
134 rawDiff_.assign(static_cast<size_t>(halfWindow_), T(0));
135
136 // YIN-FFT resources: the difference function is computed via one
137 // cross-correlation in the frequency domain (3 FFTs) instead of the
138 // O(windowSize^2) direct form - ~20x faster at the default window.
139 fftSize_ = 1;
140 while (fftSize_ < windowSize_ * 2) fftSize_ <<= 1;
141 fftTime_.assign(static_cast<size_t>(fftSize_), T(0));
142 specHalf_.assign(static_cast<size_t>(fftSize_) + 2, T(0));
143 specFull_.assign(static_cast<size_t>(fftSize_) + 2, T(0));
144 corrTime_.assign(static_cast<size_t>(fftSize_), T(0));
145 prefixSq_.assign(static_cast<size_t>(windowSize_) + 1, T(0));
146
147 writePos_ = 0;
148 samplesSinceLastDetect_ = 0;
149
150 frequency_.store(T(0), std::memory_order_relaxed);
151 confidence_.store(T(0), std::memory_order_relaxed);
152
153 fft_ = std::make_unique<FFTReal<T>>(static_cast<size_t>(fftSize_)); // gate ON, last
154 }
155
185 void pushSamples(std::span<const T> samples) noexcept
186 {
187 if (fft_ == nullptr)
188 return;
189
190 for (const T sample : samples)
191 {
192 // Write into mirrored buffer
193 buffer_[static_cast<size_t>(writePos_)] = sample;
194 buffer_[static_cast<size_t>(writePos_ + windowSize_)] = sample;
195
196 writePos_++;
197 if (writePos_ >= windowSize_)
198 {
199 writePos_ = 0;
200 }
201
202 samplesSinceLastDetect_++;
203 if (samplesSinceLastDetect_ >= hopSize_)
204 {
205 detect();
206 samplesSinceLastDetect_ = 0;
207 }
208 }
209 }
210
212 [[nodiscard]] T getFrequencyHz() const noexcept
213 {
214 return frequency_.load(std::memory_order_relaxed);
215 }
216
218 [[nodiscard]] T getConfidence() const noexcept
219 {
220 return confidence_.load(std::memory_order_relaxed);
221 }
222
224 [[nodiscard]] int getMidiNote() const noexcept
225 {
226 const T freq = getFrequencyHz();
227 if (freq <= T(0)) return -1;
228 return static_cast<int>(std::round(T(69) + T(12) * std::log2(freq / T(440))));
229 }
230
232 [[nodiscard]] T getCentsOffset() const noexcept
233 {
234 const T freq = getFrequencyHz();
235 if (freq <= T(0)) return T(0);
236 T midiExact = T(69) + T(12) * std::log2(freq / T(440));
237 return (midiExact - std::round(midiExact)) * T(100);
238 }
239
244 void setThreshold(T threshold) noexcept
245 {
246 if (!std::isfinite(threshold)) return;
247 threshold_.store(std::clamp(threshold, T(0.01), T(0.5)), std::memory_order_relaxed);
248 }
249
251 [[nodiscard]] T getThreshold() const noexcept
252 {
253 return threshold_.load(std::memory_order_relaxed);
254 }
255
263 [[nodiscard]] int getWindowSize() const noexcept { return windowSize_; }
264
267 [[nodiscard]] int getHopSize() const noexcept { return hopSize_; }
268
270 void reset() noexcept
271 {
272 std::fill(buffer_.begin(), buffer_.end(), T(0));
273 writePos_ = 0;
274 samplesSinceLastDetect_ = 0;
275 frequency_.store(T(0), std::memory_order_relaxed);
276 confidence_.store(T(0), std::memory_order_relaxed);
277 }
278
279private:
280 void detect() noexcept
281 {
282 // Obtain a perfectly contiguous block of memory representing the current window.
283 // writePos_ points to the oldest sample in the mirrored buffer.
284 const T* currentWindow = &buffer_[static_cast<size_t>(writePos_)];
285
286 // Silence check / Energy calculation on contiguous memory. The sum
287 // spans the whole window, so it doubles as the non-finite gate: with
288 // any NaN/Inf sample present the old code filled the CMND with zeros
289 // ((NaN > 0) is false) and published fs/2 at confidence 1.0 - a fake
290 // detection with maximum confidence. Report unvoiced instead; the
291 // bad samples flush out of the window on their own.
292 T energy = T(0);
293 for (int i = 0; i < windowSize_; ++i) {
294 energy += currentWindow[i] * currentWindow[i];
295 }
296
297 if (energy < T(1e-10) || !std::isfinite(energy))
298 {
299 frequency_.store(T(0), std::memory_order_relaxed);
300 confidence_.store(T(0), std::memory_order_relaxed);
301 return;
302 }
303
304 // YIN-FFT difference function:
305 // d(tau) = E1 + E2(tau) - 2*r(tau)
306 // with E1 = sum of x[0..W)^2 (constant), E2(tau) the energy of the
307 // shifted window (prefix sums), and r(tau) the cross-correlation of
308 // the first half against the full window - computed with 3 FFTs.
309 const int W = halfWindow_;
310
311 // (a) prefix sums of squared samples over the full window
312 prefixSq_[0] = T(0);
313 for (int i = 0; i < windowSize_; ++i)
314 prefixSq_[static_cast<size_t>(i + 1)] =
315 prefixSq_[static_cast<size_t>(i)] + currentWindow[i] * currentWindow[i];
316 const T e1 = prefixSq_[static_cast<size_t>(W)];
317
318 // (b) r(tau) via FFT cross-correlation: IFFT(conj(FFT(first half)) * FFT(window))
319 std::fill(fftTime_.begin(), fftTime_.end(), T(0));
320 std::copy(currentWindow, currentWindow + W, fftTime_.begin());
321 fft_->forward(fftTime_.data(), specHalf_.data());
322
323 std::fill(fftTime_.begin(), fftTime_.end(), T(0));
324 std::copy(currentWindow, currentWindow + windowSize_, fftTime_.begin());
325 fft_->forward(fftTime_.data(), specFull_.data());
326
327 const int numBins = fftSize_ / 2 + 1;
328 for (int k = 0; k < numBins; ++k)
329 {
330 const T aRe = specHalf_[static_cast<size_t>(2 * k)];
331 const T aIm = specHalf_[static_cast<size_t>(2 * k + 1)];
332 const T bRe = specFull_[static_cast<size_t>(2 * k)];
333 const T bIm = specFull_[static_cast<size_t>(2 * k + 1)];
334 // conj(A) * B
335 specFull_[static_cast<size_t>(2 * k)] = aRe * bRe + aIm * bIm;
336 specFull_[static_cast<size_t>(2 * k + 1)] = aRe * bIm - aIm * bRe;
337 }
338 fft_->inverse(specFull_.data(), corrTime_.data());
339
340 // (c) CMND from the closed-form difference function
341 const T threshold = threshold_.load(std::memory_order_relaxed);
342 yinBuffer_[0] = T(1);
343 T runningSum = T(0);
344
345 for (int tau = 1; tau < W; ++tau)
346 {
347 const T e2 = prefixSq_[static_cast<size_t>(tau + W)] - prefixSq_[static_cast<size_t>(tau)];
348 T d = e1 + e2 - T(2) * corrTime_[static_cast<size_t>(tau)];
349 if (d < T(0)) d = T(0); // guard tiny negative round-off
350
351 rawDiff_[static_cast<size_t>(tau)] = d;
352 runningSum += d;
353 yinBuffer_[static_cast<size_t>(tau)] =
354 (runningSum > T(0)) ? d * static_cast<T>(tau) / runningSum : T(0);
355 }
356
357 // Search for dip below threshold
358 int tauEstimate = -1;
359 for (int tau = 2; tau < halfWindow_; ++tau)
360 {
361 if (yinBuffer_[static_cast<size_t>(tau)] < threshold)
362 {
363 while (tau + 1 < halfWindow_ &&
364 yinBuffer_[static_cast<size_t>(tau + 1)] < yinBuffer_[static_cast<size_t>(tau)])
365 {
366 ++tau;
367 }
368 tauEstimate = tau;
369 break;
370 }
371 }
372
373 if (tauEstimate < 0)
374 {
375 frequency_.store(T(0), std::memory_order_relaxed);
376 confidence_.store(T(0), std::memory_order_relaxed);
377 return;
378 }
379
380 // Sub-sample precision from the raw difference function (see
381 // parabolicInterp()); the CMND only selects the dip.
382 T betterTau = parabolicInterp(tauEstimate);
383 T finalConfidence = std::clamp(T(1) - yinBuffer_[static_cast<size_t>(tauEstimate)], T(0), T(1));
384
385 frequency_.store(static_cast<T>(sampleRate_) / betterTau, std::memory_order_relaxed);
386 confidence_.store(finalConfidence, std::memory_order_relaxed);
387 }
388
401 [[nodiscard]] T parabolicInterp(int tau) const noexcept
402 {
403 if (tau < 2 || tau >= halfWindow_ - 1)
404 return static_cast<T>(tau);
405
406 // Centre on the raw function's own lowest sample next to the CMND dip.
407 const int centre = tau;
408 if (rawDiff_[static_cast<size_t>(centre - 1)] < rawDiff_[static_cast<size_t>(tau)]) tau = centre - 1;
409 if (rawDiff_[static_cast<size_t>(centre + 1)] < rawDiff_[static_cast<size_t>(tau)]) tau = centre + 1;
410 if (tau < 2 || tau >= halfWindow_ - 1)
411 return static_cast<T>(tau);
412
413 const double s0 = static_cast<double>(rawDiff_[static_cast<size_t>(tau - 1)]);
414 const double s1 = static_cast<double>(rawDiff_[static_cast<size_t>(tau)]);
415 const double s2 = static_cast<double>(rawDiff_[static_cast<size_t>(tau + 1)]);
416
417 const double denom = s0 - 2.0 * s1 + s2;
418
419 // Flat or inverted curvature (no usable dip on the raw function).
420 if (!(denom > 1e-30))
421 return static_cast<T>(tau);
422
423 const double vertex = std::clamp((s0 - s2) / (2.0 * denom), -0.5, 0.5);
424 const double w = 6.283185307179586 / static_cast<double>(tau);
425 const double offset = std::atan(2.0 * vertex * std::tan(0.5 * w)) / w;
426 return static_cast<T>(static_cast<double>(tau) + offset);
427 }
428
431 static constexpr double kAutoSpanRef = 2048.0;
432 static constexpr double kAutoSpanRate = 48000.0;
433 static constexpr int kAutoMinWindow = 512;
434 static constexpr int kAutoMaxWindow = 16384;
435
436 double sampleRate_ = 44100.0;
437 int windowSize_ = 2048;
438 int halfWindow_ = 1024;
439 int hopSize_ = 512;
440 int writePos_ = 0;
441 int samplesSinceLastDetect_ = 0;
442 std::atomic<T> threshold_{ T(0.10) };
443
444 // Thread-safe outputs
445 std::atomic<T> frequency_{T(0)};
446 std::atomic<T> confidence_{T(0)};
447
448 // Mirrored buffer keeps every analysis window contiguous
449 std::vector<T> buffer_; // Size: 2 * windowSize_
450 std::vector<T> yinBuffer_; // Size: halfWindow_
451 std::vector<T> rawDiff_; // Size: halfWindow_ (difference function d(tau))
452
453 // YIN-FFT resources (cross-correlation difference function)
454 int fftSize_ = 4096;
455 std::unique_ptr<FFTReal<T>> fft_; // doubles as the "prepared" gate
456 std::vector<T> fftTime_; // Size: fftSize_
457 std::vector<T> specHalf_; // Size: fftSize_ + 2
458 std::vector<T> specFull_; // Size: fftSize_ + 2
459 std::vector<T> corrTime_; // Size: fftSize_
460 std::vector<T> prefixSq_; // Size: windowSize_ + 1
461};
462
463} // namespace dspark
Thread-safe YIN pitch detector with lock-free readout.
T getConfidence() const noexcept
Returns the detection confidence [0.0 - 1.0] safely from any thread.
T getFrequencyHz() const noexcept
Returns the detected frequency in Hz safely from any thread.
int getWindowSize() const noexcept
int getMidiNote() const noexcept
Returns nearest MIDI note (69 = A4), or -1 if unvoiced.
void reset() noexcept
Resets state buffers. Not thread-safe with pushSamples().
void setThreshold(T threshold) noexcept
Sets the sensitivity threshold (clamped to 0.01 - 0.5). Lower is stricter. Non-finite values are igno...
void pushSamples(std::span< const T > samples) noexcept
Pushes audio samples into the analysis buffer.
int getHopSize() const noexcept
void prepare(double sampleRate, int windowSize=0, int hopSize=0)
Prepares the detector and allocates internal structures.
T getCentsOffset() const noexcept
Returns cent offset from the nearest MIDI note [-50, +50].
T getThreshold() const noexcept
Returns the sensitivity threshold.
Main namespace for the DSPark framework.