DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
OnsetFeatures.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
17#include "../../Core/AudioBuffer.h"
18#include "../../Core/DspMath.h"
19#include "../../Core/FFT.h"
20#include "../../Core/WindowFunctions.h"
21#include <algorithm>
22#include <array>
23#include <cmath>
24#include <cstddef>
25#include <memory>
26#include <span>
27#include <vector>
28
29namespace dspark::detail
30{
31
34template <FloatType T> class OnsetPeakPicker final
35{
36 public:
37 struct Windows
38 {
39 std::size_t preMaximum = 0, postMaximum = 0;
40 std::size_t preAverage = 0, postAverage = 0;
41 std::size_t wait = 0;
42 };
49 template <typename Value>
50 [[nodiscard]] bool accept(std::size_t index, std::size_t count, Value value, Windows windows,
51 T delta)
52 {
53 if (index >= count || (hasLast_ && (index <= last_ || index - last_ <= windows.wait)))
54 return false;
55 const T current = value(index);
56 const auto fromMax = index - std::min(index, windows.preMaximum);
57 const auto toMax = index + std::min(count - index - 1, windows.postMaximum);
58 for (auto j = fromMax; j <= toMax; ++j)
59 if (value(j) > current)
60 return false;
61 const auto fromAvg = index - std::min(index, windows.preAverage);
62 const auto toAvg = index + std::min(count - index - 1, windows.postAverage);
63 T sum = T(0);
64 for (auto j = fromAvg; j <= toAvg; ++j)
65 sum += value(j);
66 const T mean = sum / static_cast<T>(toAvg - fromAvg + 1);
67 if (current < mean + delta)
68 return false;
69 last_ = index;
70 hasLast_ = true;
71 return true;
72 }
73
74 private:
75 std::size_t last_ = 0;
76 bool hasLast_ = false;
77};
78
80template <FloatType T> class OnsetSpectrum final
81{
82 public:
83 [[nodiscard]] static int defaultFrameSize(double sampleRate) noexcept
84 {
85 int size = 512;
86 const double target = sampleRate * (2048.0 / 48000.0);
87 while (size < 16384 && static_cast<double>(size) < target)
88 size <<= 1;
89 return size;
90 }
93 [[nodiscard]] static std::size_t preparationBytes(int fftSize) noexcept
94 {
95 // Five frontend arrays: 4*N+4 scalars. FFTReal owns fewer than 4*N
96 // scalars plus its short Stockham pass plan. The 2048-byte allowance
97 // covers cumulative plan-vector growth through N=65536 on the tested
98 // standard libraries, including construction temporaries.
99 return (8 * static_cast<std::size_t>(fftSize) + 4) * sizeof(T) + sizeof(FFTReal<T>) + 2048;
100 }
103 void prepare(int fftSize)
104 {
105 fft_.reset();
106 phaseValid_ = false;
107 fftSize_ = fftSize;
108 window_.assign(static_cast<size_t>(fftSize_), T(0));
109 WindowFunctions<T>::hann(window_.data(), fftSize_, true);
110 time_.assign(static_cast<size_t>(fftSize_), T(0));
111 spectrum_.assign(static_cast<size_t>(fftSize_) + 2, T(0));
112 magnitude_.assign(static_cast<size_t>(fftSize_ / 2 + 1), T(0));
113 phase_.assign(magnitude_.size(), T(0));
114 fft_ = std::make_unique<FFTReal<T>>(static_cast<size_t>(fftSize_));
115 }
122 void compute(const T *input, bool withPhase = true) noexcept
123 {
124 if (!fft_)
125 return;
126 transform(input);
127 for (size_t k = 0; k < magnitude_.size(); ++k)
128 {
129 const T re = spectrum_[2 * k], im = spectrum_[2 * k + 1];
130 magnitude_[k] = binMagnitude(k);
131 if (withPhase)
132 phase_[k] = std::atan2(im, re);
133 }
134 phaseValid_ = withPhase;
135 }
144 [[nodiscard]] bool computePooled(AudioBufferView<const T> frame) noexcept
145 {
146 phaseValid_ = false;
147 if (!fft_ || frame.getNumSamples() != fftSize_ || frame.getNumChannels() < 1 ||
148 frame.getNumChannels() > 2)
149 return false;
150 for (int c = 0; c < frame.getNumChannels(); ++c)
151 if (!frame.getChannel(c))
152 return false;
153 compute(frame.getChannel(0), false);
154 if (frame.getNumChannels() == 2)
155 {
156 transform(frame.getChannel(1));
157 for (size_t k = 0; k < magnitude_.size(); ++k)
158 {
159 const T other = binMagnitude(k);
160 if (magnitude_[k] != other)
161 magnitude_[k] = std::hypot(magnitude_[k], other) * invSqrt2<T>;
162 }
163 }
164 return true;
165 }
168 [[nodiscard]] std::span<T> magnitudes() noexcept
169 {
170 return magnitude_;
171 }
174 [[nodiscard]] std::span<const T> phases() const noexcept
175 {
176 return phaseValid_ ? std::span<const T>(phase_) : std::span<const T>();
177 }
178
179 private:
180 void transform(const T *input) noexcept
181 {
182 for (int k = 0; k < fftSize_; ++k)
183 time_[static_cast<size_t>(k)] = input[k] * window_[static_cast<size_t>(k)];
184 fft_->forward(time_.data(), spectrum_.data());
185 }
186 [[nodiscard]] T binMagnitude(size_t k) const noexcept
187 {
188 const T re = spectrum_[2 * k], im = spectrum_[2 * k + 1];
189 return std::sqrt(re * re + im * im);
190 }
191 int fftSize_ = 0;
192 bool phaseValid_ = false;
193 std::unique_ptr<FFTReal<T>> fft_;
194 std::vector<T> window_, time_, spectrum_, magnitude_, phase_;
195};
196
199template <FloatType T> class OnsetNovelty final
200{
201 public:
202 enum class Method
203 {
206 SuperFlux,
208 };
209 static constexpr int kNumRegisters = 4;
210 struct Frame
211 {
212 T value = T(0);
213 T spectralFlux = T(0);
214 std::array<T, kNumRegisters> registers{};
215 bool valid = false;
216 };
219 [[nodiscard]] static std::size_t preparationBytes(int fftSize) noexcept
220 {
221 return (3 * static_cast<std::size_t>(fftSize) + 4 + 4 * kBandCapacity) * sizeof(T) +
222 5 * kBandCapacity * sizeof(int);
223 }
225 void prepare(double sampleRate, int fftSize, bool boundedWorkspace = false)
226 {
227 prepared_ = false;
228 sampleRate_ = sampleRate;
229 fftSize_ = fftSize;
230 numBins_ = fftSize / 2 + 1;
231 odfScale_ = static_cast<T>(kOdfRefFrame / static_cast<double>(fftSize_));
232 prevPhase_.assign(static_cast<size_t>(numBins_), T(0));
233 prevPhase2_.assign(static_cast<size_t>(numBins_), T(0));
234 prevMag_.assign(static_cast<size_t>(numBins_), T(0));
235 whitenPeak_.assign(static_cast<size_t>(numBins_), T(0));
236 buildFilterBank(boundedWorkspace);
237 bandCur_.assign(static_cast<size_t>(numBands_), T(0));
238 bandPrev_.assign(static_cast<size_t>(numBands_), T(0));
239 bandMaxPrev_.assign(static_cast<size_t>(numBands_), T(0));
240 reset();
241 prepared_ = true;
242 }
245 void reset() noexcept
246 {
247 std::fill(prevMag_.begin(), prevMag_.end(), T(0));
248 std::fill(prevPhase_.begin(), prevPhase_.end(), T(0));
249 std::fill(prevPhase2_.begin(), prevPhase2_.end(), T(0));
250 std::fill(whitenPeak_.begin(), whitenPeak_.end(), T(0));
251 std::fill(bandPrev_.begin(), bandPrev_.end(), T(0));
252 std::fill(bandMaxPrev_.begin(), bandMaxPrev_.end(), T(0));
253 curRegisters_.fill(T(0));
254 phaseHistoryValid_ = true;
255 }
256 [[nodiscard]] int getNumBands() const noexcept
257 {
258 return numBands_;
259 }
267 [[nodiscard]] Frame process(std::span<T> mag, std::span<const T> phase, Method method,
268 bool whiten = false) noexcept
269 {
270 Frame result;
271 if (!prepared_ || mag.size() != static_cast<size_t>(numBins_) ||
272 (!phase.empty() && phase.size() != mag.size()) ||
273 (method == Method::ComplexDomain && (phase.empty() || !phaseHistoryValid_)))
274 return result;
275 if (whiten)
276 applyWhitening(mag);
277 curRegisters_.fill(T(0));
278 switch (method)
279 {
281 result.value = result.spectralFlux = computeSpectralFlux(mag);
282 break;
284 result.value = computeComplexFlux(mag, phase);
285 break;
286 case Method::BothFlux:
287 result.spectralFlux = computeSpectralFlux(mag);
288 [[fallthrough]];
290 result.value = computeSuperFlux(mag, whiten);
291 break;
292 }
293 if (!phase.empty())
294 {
295 std::copy(prevPhase_.begin(), prevPhase_.end(), prevPhase2_.begin());
296 std::copy(phase.begin(), phase.end(), prevPhase_.begin());
297 }
298 else
299 phaseHistoryValid_ = false;
300 std::copy(mag.begin(), mag.end(), prevMag_.begin());
301 result.registers = curRegisters_;
302 result.valid = true;
303 return result;
304 }
305
306 private:
307 T computeSpectralFlux(std::span<const T> mag) const noexcept
308 {
309 T odf = T(0);
310 for (int k = 0; k < numBins_; ++k)
311 {
312 const T d = mag[static_cast<size_t>(k)] - prevMag_[static_cast<size_t>(k)];
313 if (d > T(0))
314 odf += d;
315 }
316 odf /= static_cast<T>(numBins_);
317 return odf;
318 }
319 T computeComplexFlux(std::span<const T> mag, std::span<const T> phase) const noexcept
320 {
321 T odf = T(0);
322 // Rectified complex-domain deviation (Dixon 2006): phase-predict
323 // each bin, sum |X - Xhat| where magnitude increased.
324 for (int k = 0; k < numBins_; ++k)
325 {
326 const T target = princArg(T(2) * prevPhase_[static_cast<size_t>(k)] -
327 prevPhase2_[static_cast<size_t>(k)]);
328 const T pm = prevMag_[static_cast<size_t>(k)];
329 const T cm = mag[static_cast<size_t>(k)];
330 const T re = cm * std::cos(phase[static_cast<size_t>(k)]) - pm * std::cos(target);
331 const T im = cm * std::sin(phase[static_cast<size_t>(k)]) - pm * std::sin(target);
332 if (cm >= pm)
333 odf += std::sqrt(re * re + im * im);
334 }
335 odf /= static_cast<T>(numBins_);
336 return odf;
337 }
338 T computeSuperFlux(std::span<const T> mag, bool whiten) noexcept
339 {
340 T odf = T(0);
341 // Log-filtered magnitude bands, flux to the mu-th previous
342 // frame after a frequency maximum filter on the reference.
343 // The frame-invariant magnitude scale (kOdfRefFrame/fftSize)
344 // applies to the raw spectrum only: adaptive whitening
345 // already divides each bin by its running peak, which
346 // carries the same linear-in-N growth, so above kWhitenFloor
347 // the whitened spectrum is dimensionless and scaling it
348 // again would INVERT the rate dependence instead of removing
349 // it. Below the floor the divisor is the absolute constant
350 // kWhitenFloor, so those bins keep the linear-in-N growth --
351 // the residual rate dependence documented at prepare().
352 filterLogBands(mag, bandCur_, whiten ? T(1) : odfScale_);
353 curRegisters_.fill(T(0));
354 for (int b = 0; b < numBands_; ++b)
355 {
356 const T d = bandCur_[static_cast<size_t>(b)] - bandMaxPrev_[static_cast<size_t>(b)];
357 if (d > T(0))
358 {
359 odf += d;
360 if (b < static_cast<int>(bandRegister_.size()))
361 curRegisters_[static_cast<size_t>(bandRegister_[static_cast<size_t>(b)])] += d;
362 }
363 }
364 odf /= static_cast<T>(numBands_);
365 for (int g = 0; g < kNumRegisters; ++g)
366 if (registerBands_[static_cast<size_t>(g)] > 0)
367 curRegisters_[static_cast<size_t>(g)] /=
368 static_cast<T>(registerBands_[static_cast<size_t>(g)]);
369 // Rotate: previous <- current, and rebuild the max-filtered
370 // reference from the (new) previous frame.
371 bandPrev_ = bandCur_;
372 maxFilterFreq(bandPrev_, bandMaxPrev_);
373 return odf;
374 }
375 void applyWhitening(std::span<T> mag) noexcept
376 {
377 for (int k = 0; k < numBins_; ++k)
378 {
379 T &pk = whitenPeak_[static_cast<size_t>(k)];
380 const T decayed = pk * kWhitenDecay;
381 const T m = mag[static_cast<size_t>(k)];
382 pk = std::max({m, kWhitenFloor, decayed});
383 mag[static_cast<size_t>(k)] = m / pk;
384 }
385 }
386
387 void buildFilterBank(bool boundedWorkspace)
388 {
389 fbStart_.clear();
390 fbWeights_.clear();
391 fbOffset_.clear();
392 bandRegister_.clear();
393 registerBands_.fill(0);
394
395 const double binHz = sampleRate_ / static_cast<double>(fftSize_);
396 const double fMax = std::min(kFMaxHz, sampleRate_ * 0.5 * 0.999);
397
398 // Quarter-tone centre bins, strictly increasing and unique.
399 std::vector<int> centres;
400 if (boundedWorkspace)
401 {
402 // At most 221 quarter-tone centres between 27.5 and 16000 Hz.
403 // Consecutive triangles contain at most N + centres weights.
404 // Reserving these bounds avoids data-dependent vector growth in
405 // worker jobs whose allocation payload is checked before setup.
406 centres.reserve(kBandCapacity);
407 fbStart_.reserve(kBandCapacity);
408 fbOffset_.reserve(kBandCapacity);
409 fbCount_.reserve(kBandCapacity);
410 bandRegister_.reserve(kBandCapacity);
411 fbWeights_.reserve(static_cast<size_t>(fftSize_) + kBandCapacity);
412 }
413 for (int i = 0;; ++i)
414 {
415 const double f = kFMin * std::pow(2.0, static_cast<double>(i) /
416 static_cast<double>(kBandsPerOctave));
417 if (f > fMax)
418 break;
419 int bin = static_cast<int>(std::lround(f / binHz));
420 bin = std::clamp(bin, 0, numBins_ - 1);
421 if (centres.empty() || bin > centres.back())
422 centres.push_back(bin);
423 }
424
425 // Triangular filters over consecutive triples (b[j-1], b[j], b[j+1]).
426 numBands_ = 0;
427 for (size_t j = 1; j + 1 < centres.size(); ++j)
428 {
429 const int lo = centres[j - 1];
430 const int ce = centres[j];
431 const int hi = centres[j + 1];
432 if (!(lo < ce && ce < hi))
433 continue;
434
435 {
436 const double fc = static_cast<double>(ce) * binHz;
437 const int g = fc < 200.0 ? 0 : fc < 800.0 ? 1 : fc < 3200.0 ? 2 : 3;
438 bandRegister_.push_back(g);
439 ++registerBands_[static_cast<size_t>(g)];
440 }
441 fbStart_.push_back(lo);
442 fbOffset_.push_back(static_cast<int>(fbWeights_.size()));
443 for (int k = lo; k <= hi; ++k)
444 {
445 T wv;
446 if (k <= ce)
447 wv = static_cast<T>(static_cast<double>(k - lo) / static_cast<double>(ce - lo));
448 else
449 wv = static_cast<T>(static_cast<double>(hi - k) / static_cast<double>(hi - ce));
450 fbWeights_.push_back(wv);
451 }
452 ++numBands_;
453 }
454 fbCount_.clear();
455 for (int b = 0; b < numBands_; ++b)
456 {
457 const int off = fbOffset_[static_cast<size_t>(b)];
458 const int nextOff = (b + 1 < numBands_) ? fbOffset_[static_cast<size_t>(b + 1)]
459 : static_cast<int>(fbWeights_.size());
460 fbCount_.push_back(nextOff - off);
461 }
462 if (numBands_ < 1)
463 numBands_ = 1; // degenerate guard (tiny fftSize)
464 }
465
466 void filterLogBands(std::span<const T> mag, std::vector<T> &out, T scale) noexcept
467 {
468 for (int b = 0; b < numBands_ && b < static_cast<int>(fbStart_.size()); ++b)
469 {
470 const int start = fbStart_[static_cast<size_t>(b)];
471 const int off = fbOffset_[static_cast<size_t>(b)];
472 const int cnt = fbCount_[static_cast<size_t>(b)];
473 T acc = T(0);
474 for (int i = 0; i < cnt; ++i)
475 {
476 const int k = start + i;
477 if (k >= 0 && k < numBins_)
478 acc += mag[static_cast<size_t>(k)] * fbWeights_[static_cast<size_t>(off + i)];
479 }
480 out[static_cast<size_t>(b)] = std::log10(acc * scale + T(1));
481 }
482 }
483
484 void maxFilterFreq(const std::vector<T> &in, std::vector<T> &out) const noexcept
485 {
486 for (int b = 0; b < numBands_; ++b)
487 {
488 T mx = in[static_cast<size_t>(b)];
489 if (b > 0)
490 mx = std::max(mx, in[static_cast<size_t>(b - 1)]);
491 if (b + 1 < numBands_)
492 mx = std::max(mx, in[static_cast<size_t>(b + 1)]);
493 out[static_cast<size_t>(b)] = mx;
494 }
495 }
496
497 static T princArg(T x) noexcept
498 {
499 // Wrap to (-pi, pi].
500 const T twoPiT = twoPi<T>;
501 T y = x - twoPiT * std::floor(x / twoPiT + T(0.5));
502 return y;
503 }
504
505 static constexpr double kOdfRefFrame = 2048.0;
506 static constexpr double kFMin = 27.5;
507 static constexpr double kFMaxHz = 16000.0;
508 static constexpr int kBandsPerOctave = 24;
509 static constexpr std::size_t kBandCapacity = 256;
510 static constexpr T kWhitenDecay = T(0.9995), kWhitenFloor = T(1e-4);
511 double sampleRate_ = 44100;
512 int fftSize_ = 0, numBins_ = 0, numBands_ = 1;
513 bool phaseHistoryValid_ = true;
514 bool prepared_ = false;
515 T odfScale_ = T(1);
516 std::vector<T> prevPhase_, prevPhase2_, prevMag_, whitenPeak_;
517 std::vector<int> fbStart_, fbOffset_, fbCount_, bandRegister_;
518 std::vector<T> fbWeights_, bandCur_, bandPrev_, bandMaxPrev_;
519 std::array<T, kNumRegisters> curRegisters_{};
520 std::array<int, kNumRegisters> registerBands_{};
521};
522
523} // namespace dspark::detail
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
FFT optimised for real-valued input signals (the common audio case).
Definition FFT.h:452
Shared whitening, spectral/complex flux and SuperFlux feature history.
static std::size_t preparationBytes(int fftSize) noexcept
Setup payload allowance when prepare() reserves the bounded filterbank.
void prepare(double sampleRate, int fftSize, bool boundedWorkspace=false)
Allocates histories and the canonical quarter-tone filterbank.
static constexpr int kNumRegisters
Frame process(std::span< T > mag, std::span< const T > phase, Method method, bool whiten=false) noexcept
Advances one frame; optional whitening modifies the supplied magnitudes. Phase-less frames support Sp...
void reset() noexcept
Clears feature history without allocation or changing the filterbank.
int getNumBands() const noexcept
Allocation-free symmetric novelty peak picker with an explicit frame clock.
bool accept(std::size_t index, std::size_t count, Value value, Windows windows, T delta)
Examines increasing indices in an immutable, finite novelty sequence. The accessor returns T....
Reusable periodic-Hann real spectrum, without feature history.
void prepare(int fftSize)
Allocates a valid power-of-two frame; the caller owns frame timing.
void compute(const T *input, bool withPhase=true) noexcept
Evaluates one contiguous frame, optionally including bin phases. Input is finite and scaled by the ca...
static int defaultFrameSize(double sampleRate) noexcept
static std::size_t preparationBytes(int fftSize) noexcept
Conservative setup payload allowance for a fresh power-of-two instance.
std::span< T > magnitudes() noexcept
Borrowed scratch, valid until the next compute/prepare or destruction.
bool computePooled(AudioBufferView< const T > frame) noexcept
Pools mono/stereo spectral energy without a phase-cancelling downmix. The view must contain exactly o...
std::span< const T > phases() const noexcept
Empty when the last frame did not compute phases. Same scratch lifetime.
static void hann(T *output, int size, bool periodic=true) noexcept
Hann (raised cosine) window.
std::array< T, kNumRegisters > registers
T value
Requested function; SuperFlux when BothFlux is requested.
T spectralFlux
Also returned by BothFlux, from the same spectrum.
std::size_t wait
Selected indices must be more than wait frames apart.