DSPark 1.6.1
Header-only audio DSP framework in pure C++20 — zero dependencies
Loading...
Searching...
No Matches
PitchDetector.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
28#include "../Core/DspMath.h"
29#include "../Core/FFT.h"
30
31#include <algorithm>
32#include <atomic>
33#include <cmath>
34#include <cstddef>
35#include <memory>
36#include <span>
37#include <vector>
38
39namespace dspark {
40
52template <FloatType T>
54{
55public:
68 void prepare(double sampleRate, int windowSize = 2048, int hopSize = 512)
69 {
70 if (!std::isfinite(sampleRate) || sampleRate <= 0.0)
71 return;
72
73 fft_.reset(); // gate OFF: pushSamples() is a no-op while rebuilding
74
75 sampleRate_ = sampleRate;
76 windowSize_ = std::clamp(windowSize, 64, 1 << 20);
77 halfWindow_ = windowSize_ / 2;
78 hopSize_ = std::clamp(hopSize, 1, windowSize_);
79
80 // Mirrored buffer technique: size is 2x windowSize.
81 // Guarantees continuous memory layout without modulo operations.
82 buffer_.assign(static_cast<size_t>(windowSize_) * 2, T(0));
83 yinBuffer_.assign(static_cast<size_t>(halfWindow_), T(0));
84
85 // YIN-FFT resources: the difference function is computed via one
86 // cross-correlation in the frequency domain (3 FFTs) instead of the
87 // O(windowSize^2) direct form - ~20x faster at the default window.
88 fftSize_ = 1;
89 while (fftSize_ < windowSize_ * 2) fftSize_ <<= 1;
90 fftTime_.assign(static_cast<size_t>(fftSize_), T(0));
91 specHalf_.assign(static_cast<size_t>(fftSize_) + 2, T(0));
92 specFull_.assign(static_cast<size_t>(fftSize_) + 2, T(0));
93 corrTime_.assign(static_cast<size_t>(fftSize_), T(0));
94 prefixSq_.assign(static_cast<size_t>(windowSize_) + 1, T(0));
95
96 writePos_ = 0;
97 samplesSinceLastDetect_ = 0;
98
99 frequency_.store(T(0), std::memory_order_relaxed);
100 confidence_.store(T(0), std::memory_order_relaxed);
101
102 fft_ = std::make_unique<FFTReal<T>>(static_cast<size_t>(fftSize_)); // gate ON, last
103 }
104
119 void pushSamples(std::span<const T> samples) noexcept
120 {
121 if (fft_ == nullptr)
122 return;
123
124 for (const T sample : samples)
125 {
126 // Write into mirrored buffer
127 buffer_[static_cast<size_t>(writePos_)] = sample;
128 buffer_[static_cast<size_t>(writePos_ + windowSize_)] = sample;
129
130 writePos_++;
131 if (writePos_ >= windowSize_)
132 {
133 writePos_ = 0;
134 }
135
136 samplesSinceLastDetect_++;
137 if (samplesSinceLastDetect_ >= hopSize_)
138 {
139 detect();
140 samplesSinceLastDetect_ = 0;
141 }
142 }
143 }
144
146 [[nodiscard]] T getFrequencyHz() const noexcept
147 {
148 return frequency_.load(std::memory_order_relaxed);
149 }
150
152 [[nodiscard]] T getConfidence() const noexcept
153 {
154 return confidence_.load(std::memory_order_relaxed);
155 }
156
158 [[nodiscard]] int getMidiNote() const noexcept
159 {
160 const T freq = getFrequencyHz();
161 if (freq <= T(0)) return -1;
162 return static_cast<int>(std::round(T(69) + T(12) * std::log2(freq / T(440))));
163 }
164
166 [[nodiscard]] T getCentsOffset() const noexcept
167 {
168 const T freq = getFrequencyHz();
169 if (freq <= T(0)) return T(0);
170 T midiExact = T(69) + T(12) * std::log2(freq / T(440));
171 return (midiExact - std::round(midiExact)) * T(100);
172 }
173
178 void setThreshold(T threshold) noexcept
179 {
180 if (!std::isfinite(threshold)) return;
181 threshold_.store(std::clamp(threshold, T(0.01), T(0.5)), std::memory_order_relaxed);
182 }
183
185 [[nodiscard]] T getThreshold() const noexcept
186 {
187 return threshold_.load(std::memory_order_relaxed);
188 }
189
191 void reset() noexcept
192 {
193 std::fill(buffer_.begin(), buffer_.end(), T(0));
194 writePos_ = 0;
195 samplesSinceLastDetect_ = 0;
196 frequency_.store(T(0), std::memory_order_relaxed);
197 confidence_.store(T(0), std::memory_order_relaxed);
198 }
199
200private:
201 void detect() noexcept
202 {
203 // Obtain a perfectly contiguous block of memory representing the current window.
204 // writePos_ points to the oldest sample in the mirrored buffer.
205 const T* currentWindow = &buffer_[static_cast<size_t>(writePos_)];
206
207 // Silence check / Energy calculation on contiguous memory. The sum
208 // spans the whole window, so it doubles as the non-finite gate: with
209 // any NaN/Inf sample present the old code filled the CMND with zeros
210 // ((NaN > 0) is false) and published fs/2 at confidence 1.0 - a fake
211 // detection with maximum confidence. Report unvoiced instead; the
212 // bad samples flush out of the window on their own.
213 T energy = T(0);
214 for (int i = 0; i < windowSize_; ++i) {
215 energy += currentWindow[i] * currentWindow[i];
216 }
217
218 if (energy < T(1e-10) || !std::isfinite(energy))
219 {
220 frequency_.store(T(0), std::memory_order_relaxed);
221 confidence_.store(T(0), std::memory_order_relaxed);
222 return;
223 }
224
225 // YIN-FFT difference function:
226 // d(tau) = E1 + E2(tau) - 2*r(tau)
227 // with E1 = sum of x[0..W)^2 (constant), E2(tau) the energy of the
228 // shifted window (prefix sums), and r(tau) the cross-correlation of
229 // the first half against the full window - computed with 3 FFTs.
230 const int W = halfWindow_;
231
232 // (a) prefix sums of squared samples over the full window
233 prefixSq_[0] = T(0);
234 for (int i = 0; i < windowSize_; ++i)
235 prefixSq_[static_cast<size_t>(i + 1)] =
236 prefixSq_[static_cast<size_t>(i)] + currentWindow[i] * currentWindow[i];
237 const T e1 = prefixSq_[static_cast<size_t>(W)];
238
239 // (b) r(tau) via FFT cross-correlation: IFFT(conj(FFT(first half)) * FFT(window))
240 std::fill(fftTime_.begin(), fftTime_.end(), T(0));
241 std::copy(currentWindow, currentWindow + W, fftTime_.begin());
242 fft_->forward(fftTime_.data(), specHalf_.data());
243
244 std::fill(fftTime_.begin(), fftTime_.end(), T(0));
245 std::copy(currentWindow, currentWindow + windowSize_, fftTime_.begin());
246 fft_->forward(fftTime_.data(), specFull_.data());
247
248 const int numBins = fftSize_ / 2 + 1;
249 for (int k = 0; k < numBins; ++k)
250 {
251 const T aRe = specHalf_[static_cast<size_t>(2 * k)];
252 const T aIm = specHalf_[static_cast<size_t>(2 * k + 1)];
253 const T bRe = specFull_[static_cast<size_t>(2 * k)];
254 const T bIm = specFull_[static_cast<size_t>(2 * k + 1)];
255 // conj(A) * B
256 specFull_[static_cast<size_t>(2 * k)] = aRe * bRe + aIm * bIm;
257 specFull_[static_cast<size_t>(2 * k + 1)] = aRe * bIm - aIm * bRe;
258 }
259 fft_->inverse(specFull_.data(), corrTime_.data());
260
261 // (c) CMND from the closed-form difference function
262 const T threshold = threshold_.load(std::memory_order_relaxed);
263 yinBuffer_[0] = T(1);
264 T runningSum = T(0);
265
266 for (int tau = 1; tau < W; ++tau)
267 {
268 const T e2 = prefixSq_[static_cast<size_t>(tau + W)] - prefixSq_[static_cast<size_t>(tau)];
269 T d = e1 + e2 - T(2) * corrTime_[static_cast<size_t>(tau)];
270 if (d < T(0)) d = T(0); // guard tiny negative round-off
271
272 runningSum += d;
273 yinBuffer_[static_cast<size_t>(tau)] =
274 (runningSum > T(0)) ? d * static_cast<T>(tau) / runningSum : T(0);
275 }
276
277 // Search for dip below threshold
278 int tauEstimate = -1;
279 for (int tau = 2; tau < halfWindow_; ++tau)
280 {
281 if (yinBuffer_[static_cast<size_t>(tau)] < threshold)
282 {
283 while (tau + 1 < halfWindow_ &&
284 yinBuffer_[static_cast<size_t>(tau + 1)] < yinBuffer_[static_cast<size_t>(tau)])
285 {
286 ++tau;
287 }
288 tauEstimate = tau;
289 break;
290 }
291 }
292
293 if (tauEstimate < 0)
294 {
295 frequency_.store(T(0), std::memory_order_relaxed);
296 confidence_.store(T(0), std::memory_order_relaxed);
297 return;
298 }
299
300 // Sub-sample precision. The dip search guarantees a local minimum
301 // (left neighbour above, right neighbour not below), so the
302 // parabolic adjustment is bounded to +-0.5 by construction.
303 T betterTau = parabolicInterp(tauEstimate);
304 T finalConfidence = std::clamp(T(1) - yinBuffer_[static_cast<size_t>(tauEstimate)], T(0), T(1));
305
306 frequency_.store(static_cast<T>(sampleRate_) / betterTau, std::memory_order_relaxed);
307 confidence_.store(finalConfidence, std::memory_order_relaxed);
308 }
309
310 [[nodiscard]] T parabolicInterp(int tau) const noexcept
311 {
312 if (tau < 1 || tau >= halfWindow_ - 1)
313 return static_cast<T>(tau);
314
315 T s0 = yinBuffer_[static_cast<size_t>(tau - 1)];
316 T s1 = yinBuffer_[static_cast<size_t>(tau)];
317 T s2 = yinBuffer_[static_cast<size_t>(tau + 1)];
318
319 T denom = s0 - T(2) * s1 + s2;
320
321 // Prevent Divide by Zero on flat local minimums
322 if (std::abs(denom) < T(1e-12))
323 return static_cast<T>(tau);
324
325 T adjustment = (s0 - s2) / (T(2) * denom);
326 return static_cast<T>(tau) + adjustment;
327 }
328
329 double sampleRate_ = 44100.0;
330 int windowSize_ = 2048;
331 int halfWindow_ = 1024;
332 int hopSize_ = 512;
333 int writePos_ = 0;
334 int samplesSinceLastDetect_ = 0;
335 std::atomic<T> threshold_{ T(0.10) };
336
337 // Thread-safe outputs
338 std::atomic<T> frequency_{T(0)};
339 std::atomic<T> confidence_{T(0)};
340
341 // Mirrored buffer keeps every analysis window contiguous
342 std::vector<T> buffer_; // Size: 2 * windowSize_
343 std::vector<T> yinBuffer_; // Size: halfWindow_
344
345 // YIN-FFT resources (cross-correlation difference function)
346 int fftSize_ = 4096;
347 std::unique_ptr<FFTReal<T>> fft_; // doubles as the "prepared" gate
348 std::vector<T> fftTime_; // Size: fftSize_
349 std::vector<T> specHalf_; // Size: fftSize_ + 2
350 std::vector<T> specFull_; // Size: fftSize_ + 2
351 std::vector<T> corrTime_; // Size: fftSize_
352 std::vector<T> prefixSq_; // Size: windowSize_ + 1
353};
354
355} // 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 getMidiNote() const noexcept
Returns nearest MIDI note (69 = A4), or -1 if unvoiced.
void prepare(double sampleRate, int windowSize=2048, int hopSize=512)
Prepares the detector and allocates internal structures.
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.
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.