DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
PitchFollower.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
52#include "../Core/AudioBuffer.h"
53#include "../Core/AudioSpec.h"
54#include "../Core/DspMath.h"
55#include "PitchDetector.h"
56
57#include <algorithm>
58#include <atomic>
59#include <cmath>
60#include <cstddef>
61#include <cstdint>
62#include <limits>
63#include <span>
64#include <vector>
65
66namespace dspark {
67
74template <FloatType T>
76{
77public:
78 // -- Lifecycle -------------------------------------------------------------
79
103 void prepare(const AudioSpec& spec, int windowSize = 0)
104 {
105 if (!spec.isValid()) return;
106 sampleRate_ = spec.sampleRate;
107 detector_.prepare(spec.sampleRate, windowSize,
108 (windowSize > 0) ? windowSize / 4 : 0);
109 monoScratch_.assign(static_cast<size_t>(std::max(spec.maxBlockSize, 1)), T(0));
110 prepared_.store(true, std::memory_order_relaxed);
111 reset();
112 }
113
119 void reset() noexcept
120 {
121 hasTarget_ = false;
122 targetSt_ = 0.0;
123 currentSt_ = 0.0;
124 pendingSt_ = 0.0;
125 pendingCount_ = 0;
126 samplesSinceValid_ = std::numeric_limits<int64_t>::max() / 2;
127 smoothedHz_.store(T(0), std::memory_order_relaxed);
128 tracking_.store(false, std::memory_order_relaxed);
129 }
130
131 // -- Parameters (thread-safe) ------------------------------------------------
132
138 void setRange(T minHz, T maxHz) noexcept
139 {
140 if (!std::isfinite(minHz) || !std::isfinite(maxHz)) return;
141 minHz = std::max(minHz, T(10));
142 maxHz = std::max(maxHz, minHz);
143 minHz_.store(minHz, std::memory_order_relaxed);
144 maxHz_.store(maxHz, std::memory_order_relaxed);
145 }
146
148 void setConfidence(T threshold) noexcept
149 {
150 if (!std::isfinite(threshold)) return;
151 confidence_.store(std::clamp(threshold, T(0), T(1)), std::memory_order_relaxed);
152 }
153
159 void setGlide(T msPerOctave) noexcept
160 {
161 if (!std::isfinite(msPerOctave)) return;
162 glideMs_.store(std::max(msPerOctave, T(0)), std::memory_order_relaxed);
163 }
164
166 [[nodiscard]] T getMinHz() const noexcept { return minHz_.load(std::memory_order_relaxed); }
167
169 [[nodiscard]] T getMaxHz() const noexcept { return maxHz_.load(std::memory_order_relaxed); }
170
172 [[nodiscard]] T getConfidenceThreshold() const noexcept { return confidence_.load(std::memory_order_relaxed); }
173
175 [[nodiscard]] T getGlide() const noexcept { return glideMs_.load(std::memory_order_relaxed); }
176
181 [[nodiscard]] int getWindowSize() const noexcept { return detector_.getWindowSize(); }
182
183 // -- Readout (lock-free, any thread) ------------------------------------------
184
186 [[nodiscard]] T getSmoothedHz() const noexcept
187 {
188 return smoothedHz_.load(std::memory_order_relaxed);
189 }
190
192 [[nodiscard]] T getRawHz() const noexcept { return detector_.getFrequencyHz(); }
193
195 [[nodiscard]] T getConfidence() const noexcept { return detector_.getConfidence(); }
196
198 [[nodiscard]] bool isTracking() const noexcept
199 {
200 return tracking_.load(std::memory_order_relaxed);
201 }
202
203 // -- Processing ----------------------------------------------------------------
204
210 {
211 if (!prepared_.load(std::memory_order_relaxed)) return;
212 const int nCh = buffer.getNumChannels();
213 const int nS = buffer.getNumSamples();
214 if (nCh <= 0 || nS <= 0) return;
215
216 int i = 0;
217 while (i < nS)
218 {
219 const int chunk = std::min(nS - i, static_cast<int>(monoScratch_.size()));
220 if (nCh == 1)
221 {
222 const T* src = buffer.getChannel(0) + i;
223 std::copy(src, src + chunk, monoScratch_.begin());
224 }
225 else
226 {
227 const T invCh = T(1) / static_cast<T>(nCh);
228 std::fill(monoScratch_.begin(), monoScratch_.begin() + chunk, T(0));
229 for (int ch = 0; ch < nCh; ++ch)
230 {
231 const T* src = buffer.getChannel(ch) + i;
232 for (int k = 0; k < chunk; ++k)
233 monoScratch_[static_cast<size_t>(k)] += src[k] * invCh;
234 }
235 }
236 pushSamples({ monoScratch_.data(), static_cast<size_t>(chunk) });
237 i += chunk;
238 }
239 }
240
245 void pushSamples(std::span<const T> samples) noexcept
246 {
247 if (!prepared_.load(std::memory_order_relaxed) || samples.empty()) return;
248 detector_.pushSamples(samples);
249 update(static_cast<int>(samples.size()));
250 }
251
252private:
254 void update(int numSamples) noexcept
255 {
256 const T raw = detector_.getFrequencyHz();
257 const T conf = detector_.getConfidence();
258 const bool valid = conf >= confidence_.load(std::memory_order_relaxed)
259 && raw >= minHz_.load(std::memory_order_relaxed)
260 && raw <= maxHz_.load(std::memory_order_relaxed);
261
262 if (valid)
263 {
264 double st = 12.0 * std::log2(static_cast<double>(raw) / 440.0);
265
266 if (!hasTarget_)
267 {
268 targetSt_ = currentSt_ = st; // first lock: no sweep-in
269 hasTarget_ = true;
270 }
271 else
272 {
273 double dev = st - targetSt_;
274 if (std::abs(dev) > 7.0)
275 {
276 // Octave folding: a 2x/0.5x detector error lands within
277 // ~1.5 st of the target after removing whole octaves.
278 for (const double oct : { -24.0, -12.0, 12.0, 24.0 })
279 {
280 if (std::abs(st + oct - targetSt_) < 1.5)
281 {
282 st += oct;
283 dev = st - targetSt_;
284 break;
285 }
286 }
287 }
288
289 if (std::abs(dev) > 7.0)
290 {
291 // A real large interval: require three consistent readings.
292 if (pendingCount_ > 0 && std::abs(st - pendingSt_) < 1.0)
293 {
294 if (++pendingCount_ >= 3)
295 {
296 targetSt_ = st;
297 pendingCount_ = 0;
298 }
299 }
300 else
301 {
302 pendingSt_ = st;
303 pendingCount_ = 1;
304 }
305 }
306 else
307 {
308 targetSt_ = st;
309 pendingCount_ = 0;
310 }
311 }
312 samplesSinceValid_ = 0;
313 }
314 else
315 {
316 samplesSinceValid_ += numSamples; // freeze: target stays put
317 }
318
319 if (hasTarget_)
320 {
321 const double glideMs = static_cast<double>(glideMs_.load(std::memory_order_relaxed));
322 if (glideMs < 0.5)
323 {
324 currentSt_ = targetSt_;
325 }
326 else
327 {
328 // Constant musical speed: 12 semitones in glideMs milliseconds.
329 const double maxStep = 12.0 * static_cast<double>(numSamples)
330 / (glideMs * 0.001 * sampleRate_);
331 currentSt_ += std::clamp(targetSt_ - currentSt_, -maxStep, maxStep);
332 }
333 smoothedHz_.store(static_cast<T>(440.0 * std::exp2(currentSt_ / 12.0)),
334 std::memory_order_relaxed);
335 }
336
337 tracking_.store(hasTarget_
338 && samplesSinceValid_ < static_cast<int64_t>(0.25 * sampleRate_),
339 std::memory_order_relaxed);
340 }
341
342 // -- Members --------------------------------------------------------------------
343 double sampleRate_ = 48000.0;
344 std::atomic<bool> prepared_{ false };
345
346 PitchDetector<T> detector_;
347 std::vector<T> monoScratch_;
348
349 bool hasTarget_ = false;
350 double targetSt_ = 0.0;
351 double currentSt_ = 0.0;
352 double pendingSt_ = 0.0;
353 int pendingCount_ = 0;
354 int64_t samplesSinceValid_ = 0;
355
356 std::atomic<T> minHz_ { T(60) };
357 std::atomic<T> maxHz_ { T(1200) };
358 std::atomic<T> confidence_ { T(0.85) };
359 std::atomic<T> glideMs_ { T(60) };
360
361 std::atomic<T> smoothedHz_ { T(0) };
362 std::atomic<bool> tracking_ { false };
363};
364
365} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
Gated, octave-safe, semitone-smoothed pitch tracking source.
void reset() noexcept
Forgets the tracked pitch (parameters are kept). Allocation-free; call from the stream owner (it touc...
T getGlide() const noexcept
Returns the glide time in milliseconds per octave.
int getWindowSize() const noexcept
T getRawHz() const noexcept
bool isTracking() const noexcept
T getConfidence() const noexcept
void setConfidence(T threshold) noexcept
Confidence threshold [0, 1] below which readings are ignored (default 0.85).
T getMaxHz() const noexcept
Returns the upper bound of the accepted pitch range in Hz.
void processBlock(AudioBufferView< const T > buffer) noexcept
Feeds a block (any channel count); channels are averaged to mono.
void prepare(const AudioSpec &spec, int windowSize=0)
Prepares the follower and the wrapped detector.
void pushSamples(std::span< const T > samples) noexcept
Feeds mono samples directly (alternative to processBlock).
void setGlide(T msPerOctave) noexcept
Glide time in milliseconds per octave (default 60). 0 disables smoothing (the output snaps to each ac...
T getMinHz() const noexcept
Returns the lower bound of the accepted pitch range in Hz.
T getConfidenceThreshold() const noexcept
Returns the confidence gating threshold [0, 1].
void setRange(T minHz, T maxHz) noexcept
Accepted pitch range in Hz (default 60 to 1200). Non-finite values are ignored (the old max() passed ...
T getSmoothedHz() const noexcept
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
int maxBlockSize
Maximum number of samples per processing block.
Definition AudioSpec.h:53
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:45