DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
BeatTracker.h
1// DSPark -- Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi -- MIT License
3
4#pragma once
5
285#include "../Core/DspMath.h"
286#include "../Core/AudioBuffer.h"
287#include "../Core/AudioSpec.h"
288#include "../Core/detail/AccountedAllocator.h"
289#include "OnsetDetector.h"
290
291#include <algorithm>
292#include <array>
293#include <atomic>
294#include <cmath>
295#include <cstddef>
296#include <cstdint>
297#include <cstring>
298#include <span>
299#include <vector>
300
301namespace dspark {
302
303namespace detail { template <FloatType T> struct OfflineBeatEngine; }
304
316template <FloatType T>
317class BeatTracker final
318{
319 template <typename U>
320 using WorkVector = std::vector<U, detail::AccountedAllocator<U>>;
321 friend struct detail::OfflineBeatEngine<T>;
322
323public:
325 {
326 const detail::AccountedAllocator<double> doubles(&allocationAccount_);
327 for (auto* values : {&bankPeriod_, &bankCosCoef_, &bankSinCoef_, &bankDecay_,
328 &bankPrior_, &zRe_, &zIm_, &zMass_, &bankCoherence_,
329 &bankLevelRatio_, &env_, &envBal_, &local_, &cumScore_,
330 &acf_, &scratchA_, &kernel_, &localPeriod_, &sweepA_,
331 &sweepB_, &candScore_, &candExplains_})
332 *values = WorkVector<double>(doubles);
333 for (auto& values : envReg_) values = WorkVector<double>(doubles);
334 envRef_ = WorkVector<int64_t>(detail::AccountedAllocator<int64_t>(&allocationAccount_));
335 backlink_ = WorkVector<int>(detail::AccountedAllocator<int>(&allocationAccount_));
336 candidates_ = WorkVector<int>(detail::AccountedAllocator<int>(&allocationAccount_));
337 }
338
342 struct Result
343 {
344 T tempoBpm = T(0);
345 T confidence = T(0);
347 std::vector<int64_t> beatSamples;
348 };
349
350 // -- Lifecycle -----------------------------------------------------------
351
365 void prepare(const AudioSpec& spec)
366 {
367 if (!(spec.sampleRate > 0.0) || !std::isfinite(spec.sampleRate))
368 return;
369
370 prepared_.store(false, std::memory_order_relaxed);
371
372 sampleRate_ = spec.sampleRate;
373 onset_.prepare(spec);
374 hop_ = onset_.getHopSize();
375 if (hop_ < 1) hop_ = 1;
376 frameRate_ = sampleRate_ / static_cast<double>(hop_);
377 baselineCoef_ = 1.0 - std::exp(-1.0 / std::max(1.0, kBaselineSeconds * frameRate_));
378
379 latencySamples_.store(static_cast<int64_t>(onset_.getEnvelopeLatencySamples())
380 + static_cast<int64_t>(hop_),
381 std::memory_order_relaxed);
382
383 buildBank();
384 setTempoRange(T(kDefaultMinBpm), T(kDefaultMaxBpm));
385 tightness_.store(kDefaultTightness, std::memory_order_relaxed);
386
387 // Offline scratch that does not depend on the signal length.
388 candidates_.reserve(64);
389
390 resetState();
391 prepared_.store(true, std::memory_order_relaxed);
392 }
393
402 void setTempoRange(T minBpm, T maxBpm) noexcept
403 {
404 if (!std::isfinite(minBpm) || !std::isfinite(maxBpm)) return;
405 if (!(minBpm > T(0)) || !(maxBpm > T(0)) || !(minBpm <= maxBpm)) return;
406 if (bankSize_ < 2) return;
407
408 const double lo = std::clamp(static_cast<double>(minBpm),
409 kBankMinBpm, kBankMaxBpm);
410 const double hi = std::clamp(static_cast<double>(maxBpm),
411 kBankMinBpm, kBankMaxBpm);
412
413 // Bank index runs with PERIOD, so the fastest tempo is the lowest
414 // index: the high BPM bound selects the low index bound.
415 int iLo = bpmToIndex(hi);
416 int iHi = bpmToIndex(lo);
417 iLo = std::clamp(iLo, 0, bankSize_ - 1);
418 iHi = std::clamp(iHi, 0, bankSize_ - 1);
419 if (iLo > iHi) std::swap(iLo, iHi);
420
421 // The two indices are a set with an invariant (lo <= hi), so they are
422 // published as one word: half of one range combined with half of the
423 // next is a range nobody asked for, and it would be searched.
424 activeRange_.store(packRange(iLo, iHi), std::memory_order_relaxed);
425 }
426
508 void setTightness(T alpha) noexcept
509 {
510 if (!std::isfinite(alpha)) return;
511 tightness_.store(std::max(kMinTightness, static_cast<double>(alpha)),
512 std::memory_order_relaxed);
513 }
514
516 [[nodiscard]] T getTightness() const noexcept
517 {
518 return static_cast<T>(tightness_.load(std::memory_order_relaxed));
519 }
520
521 // -- Offline -------------------------------------------------------------
522
535 {
536 if (!prepared_.load(std::memory_order_relaxed)) return {};
537 if (whole.getNumChannels() < 1 || whole.getNumSamples() <= 0) return {};
538 const int n = whole.getNumSamples();
539 beginOffline(n);
540 pushOffline(std::span<const T>(whole.getChannel(0), static_cast<size_t>(n)));
541 return finishOffline();
542 }
543
560 void beginOffline(int64_t expectedSamples = 0)
561 {
562 env_.clear();
563 envRef_.clear();
564 for (auto& r : envReg_) r.clear();
565 offlineOpen_ = false;
566 if (!prepared_.load(std::memory_order_relaxed)) return;
567
568 resetState();
569 if (expectedSamples > 0)
570 {
571 const size_t frames = static_cast<size_t>(expectedSamples / std::max(1, hop_) + 2);
572 env_.reserve(frames);
573 envRef_.reserve(frames);
574 for (auto& r : envReg_) r.reserve(frames);
575 }
576 offlinePushed_ = 0;
577 offlineOpen_ = true;
578 }
579
582 void pushOffline(std::span<const T> samples)
583 {
584 if (!offlineOpen_) return;
585 size_t off = 0;
586 while (off < samples.size())
587 {
588 // Pieces end on the front end's frame boundaries, so each frame's
589 // envelope value is read as the frame is computed.
590 const int64_t since = offlinePushed_ % static_cast<int64_t>(hop_);
591 const size_t take = std::min(samples.size() - off,
592 static_cast<size_t>(static_cast<int64_t>(hop_) - since));
593 onset_.pushSamples(samples.subspan(off, take));
594 offlinePushed_ += static_cast<int64_t>(take);
595 off += take;
596 if (offlinePushed_ % static_cast<int64_t>(hop_) == 0) appendEnvelopeFrame();
597 }
598 }
599
604 {
605 Result out;
606 if (!offlineOpen_) return out;
607 offlineOpen_ = false;
608 onset_.reset();
609
610 return finishEnvelope();
611 }
612
613 // -- Audio path (causal, RT-safe) ---------------------------------------
614
618 {
619 if (!prepared_.load(std::memory_order_relaxed) || in.getNumChannels() < 1)
620 return;
621 const T* ch0 = in.getChannel(0);
622 pushSamples(std::span<const T>(ch0, static_cast<size_t>(in.getNumSamples())));
623 }
624
633 void pushSamples(std::span<const T> samples) noexcept
634 {
635 if (!prepared_.load(std::memory_order_relaxed)) return;
636
637 bool fired = false;
638 size_t off = 0;
639 while (off < samples.size())
640 {
641 const int64_t sinceFrame = samplesPushed_ % static_cast<int64_t>(hop_);
642 const size_t toBoundary = static_cast<size_t>(static_cast<int64_t>(hop_)
643 - sinceFrame);
644 const size_t take = std::min(samples.size() - off, toBoundary);
645
646 onset_.pushSamples(samples.subspan(off, take));
647 samplesPushed_ += static_cast<int64_t>(take);
648 off += take;
649
650 if (static_cast<size_t>(take) == toBoundary)
651 {
652 if (advanceFrame()) fired = true;
653 }
654 }
655
656 // Release, and the matching acquire is in beatNow(). The latch and the
657 // beat's position are two separate words written in this same call,
658 // and a caller that sees the latch set must not then read a position
659 // from an earlier call: on a weakly ordered machine two relaxed loads
660 // of distinct objects may be observed in either order, so without this
661 // pairing a reader could be handed the PREVIOUS beat's position -- an
662 // error of one whole beat period -- while being told a beat just
663 // happened. The store is last on purpose: everything the frame wrote,
664 // the position included, is ordered before it.
665 beatLatched_.store(fired, std::memory_order_release);
666 }
667
668 // -- Readout (lock-free) -------------------------------------------------
669
671 [[nodiscard]] T getRunningTempoBpm() const noexcept
672 {
673 float bpm = 0.0f, conf = 0.0f;
674 unpackTempo(tempoAndConfidence_.load(std::memory_order_relaxed), bpm, conf);
675 return static_cast<T>(bpm);
676 }
677
704 [[nodiscard]] T getConfidence() const noexcept
705 {
706 float bpm = 0.0f, conf = 0.0f;
707 unpackTempo(tempoAndConfidence_.load(std::memory_order_relaxed), bpm, conf);
708 return static_cast<T>(conf);
709 }
710
720 void getTempoAndConfidence(T& bpmOut, T& confidenceOut) const noexcept
721 {
722 float bpm = 0.0f, conf = 0.0f;
723 unpackTempo(tempoAndConfidence_.load(std::memory_order_relaxed), bpm, conf);
724 bpmOut = static_cast<T>(bpm);
725 confidenceOut = static_cast<T>(conf);
726 }
727
744 [[nodiscard]] bool beatNow() const noexcept
745 {
746 return beatLatched_.load(std::memory_order_acquire);
747 }
748
762 [[nodiscard]] int64_t getLastBeatSample() const noexcept
763 {
764 return lastBeatSample_.load(std::memory_order_relaxed);
765 }
766
774 [[nodiscard]] int getLatencySamples() const noexcept
775 {
776 return static_cast<int>(latencySamples_.load(std::memory_order_relaxed));
777 }
778
780 [[nodiscard]] double getFrameRate() const noexcept { return frameRate_; }
781
784 [[nodiscard]] const OnsetDetector<T>& getOnsetDetector() const noexcept
785 {
786 return onset_;
787 }
788
791 void reset() noexcept
792 {
793 offlineOpen_ = false;
794 resetState();
795 }
796
797private:
798 // Shared complete-envelope engine. The legacy sample frontend and the
799 // pooled offline feature path both execute this exact arithmetic.
800 Result finishEnvelope(void* beatContext = nullptr,
801 void (*receiveBeats)(void*, std::span<const int64_t>) = nullptr)
802 {
803 Result out;
804 int iLo = 0, iHi = 0;
805 unpackRange(activeRange_.load(std::memory_order_relaxed), iLo, iHi);
806
807 const int n = static_cast<int>(env_.size());
808 const double maxPeriod = bankPeriod_[static_cast<size_t>(iHi)];
809 if (n < static_cast<int>(kMinAnalysisBeats * maxPeriod))
810 {
811 resetState();
812 return out;
813 }
814
815 conditionEnvelope();
816
817 // Two candidate periods from the tempo-prior-weighted autocorrelation,
818 // at least a quarter octave apart so that the runner-up is a different
819 // metrical reading and not the same peak one lag over.
820 double tau1 = 0.0, tau2 = 0.0;
821 const bool balanced = (envBal_.size() == env_.size());
822 if (balanced) env_.swap(envBal_);
823 pickCandidates(iLo, iHi, tau1, tau2);
824 if (balanced) env_.swap(envBal_);
825 if (!(tau1 > 0.0)) { resetState(); return out; }
826
827 // The ranking above proposes a reading; which multiple of it is the
828 // beat is settled by the level model (see chooseMetricalLevel()). Only
829 // where the signal has a pulse of its own in the range: otherwise the
830 // correlation decided on its own, and so it stays.
831 if (fundamentalFound_) chooseMetricalLevel(iLo, iHi, tau1, tau2);
832
833 // The metrical level is decided against one period for the whole
834 // signal, which is the stable model, and only then is the grid laid
835 // down against a period that is allowed to move.
836 // A period that moves is only worth estimating where there is a pulse
837 // to estimate it from. When the winner does not clear the fundamental
838 // floor -- the caller restricted the range past the real pulse -- a
839 // per-frame estimate over that range is tracking noise, and it drags
840 // the delivered grid with it: measured at 96 BPM on 160 BPM material
841 // searched over 60 to 100, where the answer a listener would give is
842 // the 80 BPM the correlation points at.
843 //
844 // Both grids are built - one against the moving period, one against
845 // the steady one - and the one that explains the envelope better is
846 // delivered. A per-frame period is the right model for a tempo that
847 // moves, and the wrong one for a steady tempo under a syncopated
848 // part: there the resonator sweep is drawn, bar by bar, towards the
849 // syncopation's own period, and the grid it drives falls off the beat
850 // and onto the off-accents. Measured on a limited pop master at 120
851 // BPM over a dotted-eighth guitar, the moving-period grid put 18 of 74
852 // intervals at three quarters or five quarters of a beat; the steady
853 // grid has none, and explains more of the envelope, so it is the one
854 // delivered. On a tempo ramp the steady grid drifts off the beats and
855 // the moving one wins by the same measure.
856 WorkVector<int64_t> win(detail::AccountedAllocator<int64_t>{&allocationAccount_});
857 localPeriod_.clear();
858 WorkVector<int64_t> moving(detail::AccountedAllocator<int64_t>{&allocationAccount_});
859 bool haveMoving = false;
860 if (coherence(tau1) >= kMinFundamentalCoherence)
861 {
862 computeLocalPeriods(tau1);
863 haveMoving = buildGrid(tau1, moving) && moving.size() >= 2;
864 }
865 localPeriod_.clear();
866 bool haveSteady = buildGrid(tau1, win) && win.size() >= 2;
867 // A third reading: the steady period held kStrictTightnessFactor times
868 // more tightly. The tightness in force is tuned for rubato, where it
869 // must let an interval stretch; under a steady beat with a strong
870 // syncopated part the same slack lets single intervals jump onto the
871 // off-accents. Whichever grid explains the envelope best is kept, so
872 // rubato keeps its loose grid and a steady groove gets a steady one.
873 {
874 WorkVector<int64_t> strict(detail::AccountedAllocator<int64_t>{&allocationAccount_});
875 alphaScale_ = kStrictTightnessFactor;
876 const bool haveStrict = buildGrid(tau1, strict) && strict.size() >= 2;
877 alphaScale_ = 1.0;
878 if (haveStrict && (!haveSteady || gridCoherence(strict) > gridCoherence(win)))
879 {
880 win.swap(strict);
881 haveSteady = true;
882 }
883 }
884 if (haveMoving && (!haveSteady
885 || gridCoherence(moving) > kMovingGridMargin * gridCoherence(win)))
886 win.swap(moving);
887 else if (!haveSteady)
888 {
889 resetState();
890 return out;
891 }
892
893 // The delivered tempo is fitted to the delivered grid, not read off
894 // the autocorrelation lag: the grid spans the whole signal, so a
895 // straight-line fit through it resolves the period far below the
896 // frame spacing the lag is quantised to.
897 const double slope = fitBeatSlope(win);
898 if (receiveBeats)
899 receiveBeats(beatContext, {win.data(), win.size()});
900 else
901 out.beatSamples.assign(win.begin(), win.end());
902 out.tempoBpm = static_cast<T>((slope > 0.0) ? 60.0 * sampleRate_ / slope
903 : periodToBpm(tau1));
904 out.secondaryTempoBpm = static_cast<T>((tau2 > 0.0) ? periodToBpm(tau2) : 0.0);
905
906 // Confidence is the coherence of the delivered grid, reduced only if
907 // the alternative explained the signal nearly as well. Coherence alone
908 // answers "does this grid fit"; it cannot answer "and is it the only
909 // grid that does", and on material that genuinely carries two pulses
910 // -- three against two, most obviously -- a tracker that reports one
911 // of them at full confidence is asserting something it has no evidence
912 // for. See kAmbiguityFloor for why the reduction has a floor under it
913 // instead of being applied in proportion.
914 const double base = coherence((slope > 0.0) ? slope / static_cast<double>(hop_)
915 : tau1);
916 out.confidence = static_cast<T>(std::clamp(base * ambiguityDiscount(secondaryShare_),
917 0.0, 1.0));
918
919 resetState();
920 return out;
921 }
922 // -- Constants -----------------------------------------------------------
923
925 static constexpr double kBankMinBpm = 20.0;
926 static constexpr double kBankMaxBpm = 480.0;
935 static constexpr double kHarmonicReach = 5.0;
936 static constexpr int kBinsPerOctave = 128;
937 static constexpr double kDefaultMinBpm = 40.0;
938 static constexpr double kDefaultMaxBpm = 240.0;
939
952 static constexpr int kNumHarmonics = 3;
953 static constexpr double kHarmonicDivisors[kNumHarmonics] = { 2.0, 3.0, 5.0 };
954
959 static constexpr double kIntegrationBeats = 8.0;
960
963 static constexpr double kPriorCentreSeconds = 0.5;
964 static constexpr double kPriorWidthOctaves = 1.4;
965
986 static constexpr double kTactusFastOctaves = 0.45;
987 static constexpr double kTactusSlowOctaves = kPriorWidthOctaves;
988
1013 static constexpr int kLevelCandidates = 3;
1014 static constexpr int kLevelMiddle = 1;
1015 static constexpr size_t kLevelRawFeatures = 18;
1016 static constexpr size_t kLevelShareAtPeriod = 1;
1017 static constexpr size_t kLevelBalancedShareAtPeriod = 2;
1018 static constexpr size_t kLevelShareAtDouble = 7;
1019 static constexpr size_t kLevelShareAtTriple = 14;
1020 static constexpr std::array<double, kLevelCandidates> kLevelMultiples = { 0.5, 1.0, 2.0 };
1021 static constexpr std::array<double, kLevelCandidates> kLevelBias = {
1022 -0.975336646, +0.092188351, +0.883148294 };
1023 static constexpr std::array<double, 2 * kLevelRawFeatures + 1> kLevelWeights = {
1024 // Each reading on its own:
1025 -0.542395096, -0.584902910, +0.498236961,
1026 +2.173179030, +0.290865194, +0.064019696,
1027 -0.056958253, -1.551139582, +2.419398334,
1028 +0.999335380, +1.771192530, +1.715518634,
1029 -0.269329648, -0.242521186, +0.512883972,
1030 +2.417326254, -4.177049390, -0.658688841,
1031 // The same, relative to the proposed reading:
1032 -0.740402303, -0.614477620, +0.536878776,
1033 +2.283943101, +0.321349606, +0.070567309,
1034 -0.060601959, -1.661774996, +2.331723324,
1035 +1.031193638, +1.895745003, +1.908437878,
1036 -0.571387423, -0.456177537, +0.860604058,
1037 +4.361899112, -4.360371230, -0.993521862,
1038 // Log tempo squared: the tapping preference.
1039 -2.979577030 };
1048 static constexpr std::array<double, 4> kTernaryMultiples = {
1049 1.0 / 3.0, 2.0 / 3.0, 1.5, 3.0 };
1050 static constexpr std::array<double, 4> kTernaryBias = {
1051 0.53602663631392855, -0.44218443943636193, -0.98844100123510192,
1052 -4.3780218173667675 };
1053 static constexpr std::array<double, 2 * kLevelRawFeatures + 1> kTernaryWeights = {
1054 0.135530296867787, 1.516476106187614, 0.50512741958750595,
1055 -3.2226909770906635, 0.76647827698580318, -0.5179245237669392,
1056 1.354793380662362, -2.8513739655680381, -2.7957191619709669,
1057 -0.93155463697868401, -0.59124401355810241, 0.57664680014957181,
1058 -0.83331592757939144, -0.92868430654211009, 0.72147135283011565,
1059 1.342529710852844, -0.78142066296951229, 2.2830394447846869,
1060 -0.71810354059356818, 0.89986068541218478, 0.41744186276874551,
1061 1.3685809837441467, 1.8839328472219159, -0.48244306625392142,
1062 0.89516613728223393, -0.60386251174497119, 1.8096407974850166,
1063 2.9213215771564656, 4.4113655210180074, 3.2066836727020585,
1064 2.0230309593047608, 2.7743701263252531, -2.8072203231223085,
1065 -0.60235479209693188, 0.10118126378289685, -2.5817564678948961,
1066 -0.67204847147376856 };
1067
1080 static constexpr double kLevelContrastFloor = 0.1;
1082 static constexpr double kLevelWindowBeats = 8.0;
1083
1086 static constexpr double kDefaultTightness = 25.0;
1087 static constexpr double kMinTightness = 1e-3;
1088
1097 static constexpr double kMinFundamentalCoherence = 0.1;
1098
1112 static constexpr double kAmbiguityFloor = 0.5;
1113
1117 static constexpr double kCandidateSeparationOctaves = 0.25;
1118
1121 static constexpr double kCandidatePeakFloor = 0.25;
1122
1127 static constexpr double kStrictTightnessFactor = 4.0;
1128
1131 static constexpr double kMovingGridMargin = 1.5;
1132
1133 static constexpr int kLocalPeriodSpan = kBinsPerOctave / 6;
1134
1137 static constexpr double kMinAnalysisBeats = 4.0;
1138
1142 static constexpr double kCoherenceWindowBeats = 8.0;
1143
1145 static constexpr double kBaselineSeconds = 1.0;
1146
1149 static constexpr double kLocalScoreSigmaPeriods = 1.0 / 32.0;
1150
1152 static constexpr double kSearchLowFactor = 0.5;
1153 static constexpr double kSearchHighFactor = 2.0;
1154
1160 static constexpr double kSnapFraction = 0.125;
1161
1165 static constexpr double kTrimFraction = 0.5;
1166
1167 static_assert(std::atomic<std::uint64_t>::is_always_lock_free,
1168 "audio-thread stores must not lock");
1169 static_assert(std::atomic<std::uint32_t>::is_always_lock_free,
1170 "audio-thread stores must not lock");
1171 static_assert(std::atomic<std::int64_t>::is_always_lock_free,
1172 "audio-thread stores must not lock");
1173 static_assert(std::atomic<bool>::is_always_lock_free,
1174 "audio-thread stores must not lock");
1175 static_assert(std::atomic<double>::is_always_lock_free,
1176 "audio-thread stores must not lock");
1177
1178 // -- Packing -------------------------------------------------------------
1179
1184 [[nodiscard]] static std::uint64_t packTempo(float bpm, float conf) noexcept
1185 {
1186 std::uint32_t a = 0, b = 0;
1187 std::memcpy(&a, &bpm, sizeof(a));
1188 std::memcpy(&b, &conf, sizeof(b));
1189 return (static_cast<std::uint64_t>(a) << 32) | static_cast<std::uint64_t>(b);
1190 }
1191
1192 static void unpackTempo(std::uint64_t v, float& bpm, float& conf) noexcept
1193 {
1194 const std::uint32_t a = static_cast<std::uint32_t>(v >> 32);
1195 const std::uint32_t b = static_cast<std::uint32_t>(v & 0xFFFFFFFFu);
1196 std::memcpy(&bpm, &a, sizeof(bpm));
1197 std::memcpy(&conf, &b, sizeof(conf));
1198 }
1199
1200 [[nodiscard]] static std::uint32_t packRange(int lo, int hi) noexcept
1201 {
1202 return (static_cast<std::uint32_t>(lo & 0xFFFF) << 16)
1203 | static_cast<std::uint32_t>(hi & 0xFFFF);
1204 }
1205
1206 static void unpackRange(std::uint32_t v, int& lo, int& hi) noexcept
1207 {
1208 lo = static_cast<int>((v >> 16) & 0xFFFFu);
1209 hi = static_cast<int>(v & 0xFFFFu);
1210 }
1211
1212 // -- Bank ----------------------------------------------------------------
1213
1214 [[nodiscard]] double periodToBpm(double periodFrames) const noexcept
1215 {
1216 return (periodFrames > 0.0) ? 60.0 * frameRate_ / periodFrames : 0.0;
1217 }
1218
1219 [[nodiscard]] double bpmToPeriod(double bpm) const noexcept
1220 {
1221 return (bpm > 0.0) ? 60.0 * frameRate_ / bpm : 0.0;
1222 }
1223
1225 [[nodiscard]] int bpmToIndex(double bpm) const noexcept
1226 {
1227 const double p = bpmToPeriod(bpm);
1228 if (!(p > 0.0) || bankSize_ < 1) return 0;
1229 const double x = std::log2(p / bankPeriod_[0])
1230 * static_cast<double>(kBinsPerOctave);
1231 return std::clamp(static_cast<int>(std::lround(x)), 0, bankSize_ - 1);
1232 }
1233
1234 void buildBank()
1235 {
1236 const double pMin = bpmToPeriod(kBankMaxBpm);
1237 const double pMax = bpmToPeriod(kBankMinBpm) * kHarmonicReach;
1238 const double octaves = std::log2(pMax / pMin);
1239 bankSize_ = static_cast<int>(std::lround(octaves
1240 * static_cast<double>(kBinsPerOctave))) + 1;
1241 bankSize_ = std::max(bankSize_, 2 * kBinsPerOctave + 2);
1242
1243 bankPeriod_.assign(static_cast<size_t>(bankSize_), 0.0);
1244 bankCosCoef_.assign(static_cast<size_t>(bankSize_), 0.0);
1245 bankSinCoef_.assign(static_cast<size_t>(bankSize_), 0.0);
1246 bankDecay_.assign(static_cast<size_t>(bankSize_), 0.0);
1247 bankPrior_.assign(static_cast<size_t>(bankSize_), 0.0);
1248 zRe_.assign(static_cast<size_t>(bankSize_), 0.0);
1249 zIm_.assign(static_cast<size_t>(bankSize_), 0.0);
1250 zMass_.assign(static_cast<size_t>(bankSize_), 0.0);
1251 bankCoherence_.assign(static_cast<size_t>(bankSize_), 0.0);
1252
1253 const double centre = kPriorCentreSeconds * frameRate_;
1254 for (int i = 0; i < bankSize_; ++i)
1255 {
1256 const double p = pMin * std::pow(2.0, static_cast<double>(i)
1257 / static_cast<double>(kBinsPerOctave));
1258 bankPeriod_[static_cast<size_t>(i)] = p;
1259
1260 const double w = 2.0 * static_cast<double>(pi<double>) / p;
1261 const double lambda = std::exp(-1.0 / (kIntegrationBeats * p));
1262 bankDecay_[static_cast<size_t>(i)] = lambda;
1263 bankCosCoef_[static_cast<size_t>(i)] = lambda * std::cos(w);
1264 bankSinCoef_[static_cast<size_t>(i)] = lambda * std::sin(w);
1265
1266 const double lg = std::log2(p / centre) / kPriorWidthOctaves;
1267 bankPrior_[static_cast<size_t>(i)] = std::exp(-0.5 * lg * lg);
1268 }
1269
1270 // Index distance to each subharmonic. The bank is geometric, so a
1271 // ratio in period is a constant offset in index; rounding it to the
1272 // nearest bin is worth nothing at all here, because a resonator with a
1273 // memory of eight periods is a hundred times broader than one bin.
1274 for (int k = 0; k < kNumHarmonics; ++k)
1275 harmonicOffset_[k] = static_cast<int>(std::lround(
1276 std::log2(kHarmonicDivisors[k]) * static_cast<double>(kBinsPerOctave)));
1277
1278 // The tactus preference of each subharmonic against its own candidate,
1279 // tabulated here so the per-frame score stays a few multiplies.
1280 bankLevelRatio_.assign(static_cast<size_t>(kNumHarmonics)
1281 * static_cast<size_t>(bankSize_), 1.0);
1282 for (int k = 0; k < kNumHarmonics; ++k)
1283 for (int i = 0; i < bankSize_; ++i)
1284 bankLevelRatio_[static_cast<size_t>(k) * static_cast<size_t>(bankSize_)
1285 + static_cast<size_t>(i)]
1286 = levelRatio(bankPeriod_[static_cast<size_t>(i)], kHarmonicDivisors[k]);
1287 }
1288
1289 // -- Causal path ---------------------------------------------------------
1290
1299 bool advanceFrame() noexcept
1300 {
1301 const typename OnsetDetector<T>::OdfFrame f = onset_.getLastOdfFrame();
1302 ++frameIndex_;
1303
1304 const double raw = (std::isfinite(f.value) && f.value > T(0))
1305 ? static_cast<double>(f.value) : 0.0;
1306 lastFrameRef_ = f.referenceSample;
1307
1308 // Same conditioning the offline path applies, in the only form a
1309 // causal path can have it: a trailing mean instead of a centred one.
1310 // Without it the log-compressed envelope's positive floor counts as
1311 // onset mass that is spread evenly in phase, so the coherence ratio
1312 // reads the noise floor of the material rather than its pulse -- on a
1313 // bed at a twentieth of the click amplitude that alone took the
1314 // published confidence from 0.98 to 0.25 while the tempo stayed
1315 // correct to a tenth of a percent, which is an under-reading a caller
1316 // gating on confidence would act on.
1317 baseline_ += (raw - baseline_) * baselineCoef_;
1318 const double o = std::max(0.0, raw - baseline_);
1319
1320 for (int i = 0; i < bankSize_; ++i)
1321 {
1322 const size_t u = static_cast<size_t>(i);
1323 const double cr = bankCosCoef_[u];
1324 const double ci = bankSinCoef_[u];
1325 const double re = zRe_[u];
1326 const double im = zIm_[u];
1327 zRe_[u] = cr * re - ci * im + o;
1328 zIm_[u] = cr * im + ci * re;
1329 zMass_[u] = bankDecay_[u] * zMass_[u] + o;
1330
1331 const double mass = zMass_[u];
1332 bankCoherence_[u] = (mass > kMassFloor)
1333 ? std::min(1.0, std::sqrt(zRe_[u] * zRe_[u]
1334 + zIm_[u] * zIm_[u]) / mass)
1335 : 0.0;
1336 }
1337
1338 int iLo = 0, iHi = 0;
1339 unpackRange(activeRange_.load(std::memory_order_relaxed), iLo, iHi);
1340 iLo = std::clamp(iLo, 0, bankSize_ - 1);
1341 iHi = std::clamp(iHi, iLo, bankSize_ - 1);
1342
1343 int best = -1;
1344 double bestScore = 0.0;
1345 for (int i = iLo; i <= iHi; ++i)
1346 {
1347 const double s = scoreAt(i);
1348 if (s > bestScore) { bestScore = s; best = i; }
1349 }
1350
1351 if (best < 0)
1352 {
1353 // Nothing in the searched range explains the envelope yet. Keep
1354 // the previous published pair rather than publishing a tempo of
1355 // zero and a confidence of zero, which a caller cannot tell from
1356 // a real reading of silence.
1357 return false;
1358 }
1359
1360 // The runner-up at a different metrical level, and how nearly it
1361 // explained the envelope as well as the winner did. Same discount the
1362 // offline path applies, for the same reason and on the same scale: a
1363 // grid that fits is not the same claim as a grid that is the only one
1364 // that fits, and a caller gating on one number must be told the
1365 // difference.
1366 double runnerUp = 0.0;
1367 const int apart = std::max(1, static_cast<int>(kCandidateSeparationOctaves
1368 * kBinsPerOctave));
1369 for (int i = iLo; i <= iHi; ++i)
1370 {
1371 if (std::abs(i - best) < apart) continue;
1372 runnerUp = std::max(runnerUp, scoreAt(i, false));
1373 }
1374 const double bestExplains = scoreAt(best, false);
1375 const double share = (bestExplains > 0.0)
1376 ? std::clamp(runnerUp / bestExplains, 0.0, 1.0) : 0.0;
1377
1378 // The level is adjudicated with a preference that changes from bin to
1379 // bin, so the bin it selects can sit one bin off the coherence peak of
1380 // the very level it selected. The period and the phase are read from
1381 // the peak itself, which is where the signal actually is: two steps
1382 // are enough, because the two functions never differ by more than the
1383 // preference's slope over one bin, and it costs -0.27% -> -0.14% on
1384 // the worst reported tempo at the top of the default range.
1385 for (int guard = 0; guard < 2; ++guard)
1386 {
1387 if (best > 0 && bankCoherence_[static_cast<size_t>(best - 1)]
1388 > bankCoherence_[static_cast<size_t>(best)]) --best;
1389 else if (best + 1 < bankSize_
1390 && bankCoherence_[static_cast<size_t>(best + 1)]
1391 > bankCoherence_[static_cast<size_t>(best)]) ++best;
1392 else break;
1393 }
1394
1395 const size_t ub = static_cast<size_t>(best);
1396 const double period = refinePeriod(best);
1397 const double conf = std::clamp(bankCoherence_[ub] * ambiguityDiscount(share),
1398 0.0, 1.0);
1399
1400 tempoAndConfidence_.store(packTempo(static_cast<float>(periodToBpm(period)),
1401 static_cast<float>(conf)),
1402 std::memory_order_relaxed);
1403
1404 // Phase. arg(z) advances by one turn per period, so the frames since
1405 // the last pulse are arg(z) scaled by the period; the turn completing
1406 // -- that quantity falling back through zero -- is the beat.
1407 const double w = 2.0 * static_cast<double>(pi<double>) / bankPeriod_[ub];
1408 double sinceBeat = std::atan2(zIm_[ub], zRe_[ub]) / w;
1409 const double p = bankPeriod_[ub];
1410 while (sinceBeat < 0.0) sinceBeat += p;
1411 while (sinceBeat >= p) sinceBeat -= p;
1412
1413 ++framesSinceBeat_;
1414 bool beat = false;
1415 // The turn completes when the frames-since-pulse quantity falls back
1416 // through zero. The refractory is not a smoother: the winning bin can
1417 // step between neighbours from one frame to the next, and the two
1418 // neighbours do not carry exactly the same phase, so without it a
1419 // one-bin step near a turn reports the same beat twice.
1420 if (havePhase_ && (prevSinceBeat_ - sinceBeat) > 0.5 * p
1421 && static_cast<double>(framesSinceBeat_) >= kBeatRefractory * p)
1422 {
1423 const int64_t backFrames = static_cast<int64_t>(std::lround(sinceBeat));
1424 const int64_t ref = lastFrameRef_ - backFrames * static_cast<int64_t>(hop_);
1425 lastBeatSample_.store(ref, std::memory_order_relaxed);
1426 framesSinceBeat_ = 0;
1427 beat = true;
1428 }
1429 prevSinceBeat_ = sinceBeat;
1430 lastBestIndex_ = best;
1431 havePhase_ = true;
1432
1433 return beat;
1434 }
1435
1444 [[nodiscard]] double refinePeriod(int i) const noexcept
1445 {
1446 if (i <= 0 || i >= bankSize_ - 1) return bankPeriod_[static_cast<size_t>(i)];
1447 const double a = scoreAt(i - 1, false);
1448 const double b = scoreAt(i, false);
1449 const double c = scoreAt(i + 1, false);
1450 const double den = a - 2.0 * b + c;
1451 if (!(std::abs(den) > 0.0)) return bankPeriod_[static_cast<size_t>(i)];
1452 double d = 0.5 * (a - c) / den;
1453 d = std::clamp(d, -0.5, 0.5);
1454 return bankPeriod_[static_cast<size_t>(i)]
1455 * std::pow(2.0, d / static_cast<double>(kBinsPerOctave));
1456 }
1457
1472 [[nodiscard]] double scoreAt(int i, bool withPreference = true) const noexcept
1473 {
1474 const size_t u = static_cast<size_t>(i);
1475 double s = bankPrior_[u] * bankCoherence_[u];
1476 for (int k = 0; k < kNumHarmonics; ++k)
1477 {
1478 const int sub = i + harmonicOffset_[k];
1479 if (sub >= bankSize_) continue;
1480 const double r = withPreference
1481 ? bankLevelRatio_[static_cast<size_t>(k) * static_cast<size_t>(bankSize_) + u]
1482 : 1.0;
1483 s *= (1.0 - std::min(1.0, r * bankCoherence_[static_cast<size_t>(sub)]));
1484 }
1485 return s;
1486 }
1487
1508 [[nodiscard]] static double levelPreference(double periodSeconds) noexcept
1509 {
1510 const double lg = std::log2(periodSeconds / kPriorCentreSeconds);
1511 const double w = (lg >= 0.0) ? kTactusSlowOctaves : kTactusFastOctaves;
1512 const double x = lg / w;
1513 return std::exp(-0.5 * x * x);
1514 }
1515
1516 [[nodiscard]] double levelRatio(double periodFrames, double divisor) const noexcept
1517 {
1518 if (!(frameRate_ > 0.0) || !(periodFrames > 0.0)) return 1.0;
1519 const double p = periodFrames / frameRate_;
1520 const double here = levelPreference(p);
1521 if (!(here > 0.0)) return 1.0;
1522 return std::max(1.0, levelPreference(p * divisor) / here);
1523 }
1524
1525 // -- Offline stages ------------------------------------------------------
1526
1531 void appendEnvelopeFrame()
1532 {
1533 const typename OnsetDetector<T>::OdfFrame f = onset_.getLastOdfFrame();
1534 const double v = std::isfinite(f.value) ? static_cast<double>(f.value) : 0.0;
1535 env_.push_back(std::max(0.0, v));
1536 envRef_.push_back(f.referenceSample);
1537 for (int g = 0; g < kRegisters; ++g)
1538 {
1539 const double r = static_cast<double>(f.registers[static_cast<size_t>(g)]);
1540 envReg_[static_cast<size_t>(g)].push_back(std::isfinite(r) ? std::max(0.0, r) : 0.0);
1541 }
1542 }
1543
1555 void conditionEnvelope()
1556 {
1557 const int n = static_cast<int>(env_.size());
1558 if (n < 1) return;
1559
1560 // Register balance. The onset function is a mean over quarter-tone
1561 // bands, and the upper registers own most of them: a kick lights a
1562 // handful of bands below 200 Hz while hats, strums and consonants light
1563 // a hundred. On a dense master the pulse the kick states then barely
1564 // shows in the mean, and the backbeat and syllable rhythm decide the
1565 // metrical level instead. So the level is decided on a second,
1566 // register-balanced envelope: each register conditioned on its own -
1567 // floor removed, scaled to unit spread - and the four summed, so that
1568 // a register carries evidence in proportion to how regular it is, not
1569 // to how many bands it spans. The grid itself is still laid on the
1570 // plain envelope, where every onset keeps its own weight. Measured on a limited pop master at 135
1571 // BPM (kick on every beat, snare on two and four, strummed guitar and
1572 // a syllabic vocal on top), the coherence at half the tempo falls from
1573 // 0.88 of the coherence at the beat to 0.38.
1574 envBal_.clear();
1575 bool haveRegisters = false;
1576 for (const auto& r : envReg_)
1577 if (static_cast<int>(r.size()) == n)
1578 for (const double v : r)
1579 if (v > 0.0) { haveRegisters = true; break; }
1580 if (haveRegisters)
1581 {
1582 // A register counts in proportion to how periodic it is: its
1583 // normalised autocorrelation peak over the searched lags. A
1584 // register holding one isolated event - a tone switching on - or
1585 // unpatterned material has no such peak and adds nothing, where
1586 // plain unit-spread scaling would have amplified it to equal voice.
1587 int iLo = 0, iHi = 0;
1588 unpackRange(activeRange_.load(std::memory_order_relaxed), iLo, iHi);
1589 const int lagLo = std::max(1, static_cast<int>(std::floor(bankPeriod_[static_cast<size_t>(iLo)])));
1590 const int lagHi = std::min(n / 2, static_cast<int>(std::ceil(bankPeriod_[static_cast<size_t>(iHi)])));
1591 envBal_.assign(static_cast<size_t>(n), 0.0);
1592 for (auto& r : envReg_)
1593 {
1594 conditionSeries(r, true);
1595 double zero = 0.0;
1596 for (const double v : r) zero += v * v;
1597 zero /= static_cast<double>(n);
1598 double peak = 0.0;
1599 if (zero > 0.0)
1600 for (int lag = lagLo; lag <= lagHi; ++lag)
1601 {
1602 checkOfflineWork();
1603 double acc = 0.0;
1604 for (int i = 0; i + lag < n; ++i)
1605 acc += r[static_cast<size_t>(i)] * r[static_cast<size_t>(i + lag)];
1606 peak = std::max(peak, acc / static_cast<double>(n - lag) / zero);
1607 }
1608 const double w = std::clamp(peak, 0.0, 1.0);
1609 for (int i = 0; i < n; ++i) envBal_[static_cast<size_t>(i)] += w * r[static_cast<size_t>(i)];
1610 }
1611 conditionSeries(envBal_, false); // already floor-free: rescale only
1612 }
1613 conditionSeries(env_, true);
1614 }
1615
1617 void conditionSeries(WorkVector<double>& e, bool removeFloor)
1618 {
1619 const int n = static_cast<int>(e.size());
1620 if (n < 1) return;
1621
1622 const int half = std::max(1, static_cast<int>(std::lround(0.5 * kBaselineSeconds
1623 * frameRate_)));
1624 scratchA_.assign(static_cast<size_t>(n) + 1, 0.0);
1625 for (int i = 0; i < n; ++i)
1626 scratchA_[static_cast<size_t>(i) + 1] = scratchA_[static_cast<size_t>(i)]
1627 + e[static_cast<size_t>(i)];
1628
1629 double sumSq = 0.0;
1630 for (int i = 0; i < n; ++i)
1631 {
1632 if ((i & 255) == 0) checkOfflineWork();
1633 const int lo = std::max(0, i - half);
1634 const int hi = std::min(n, i + half + 1);
1635 const double mean = (scratchA_[static_cast<size_t>(hi)]
1636 - scratchA_[static_cast<size_t>(lo)])
1637 / static_cast<double>(hi - lo);
1638 const double v = removeFloor ? std::max(0.0, e[static_cast<size_t>(i)] - mean)
1639 : e[static_cast<size_t>(i)];
1640 e[static_cast<size_t>(i)] = v;
1641 sumSq += v * v;
1642 }
1643
1644 const double rms = std::sqrt(sumSq / static_cast<double>(n));
1645 if (rms > 0.0)
1646 {
1647 const double g = 1.0 / rms;
1648 for (int i = 0; i < n; ++i) e[static_cast<size_t>(i)] *= g;
1649 }
1650 }
1651
1662 [[nodiscard]] double coherence(double periodFrames) const
1663 {
1664 return coherence(env_, periodFrames);
1665 }
1666
1667 [[nodiscard]] double coherence(const WorkVector<double>& envelope,
1668 double periodFrames) const
1669 {
1670 const int n = static_cast<int>(envelope.size());
1671 if (n < 2 || !(periodFrames > 1.0)) return 0.0;
1672
1673 const int win = std::max(4, static_cast<int>(std::lround(kCoherenceWindowBeats
1674 * periodFrames)));
1675 if (win > n) return coherenceWindow(envelope, 0, n, periodFrames);
1676
1677 const int step = std::max(1, win / 2);
1678 double acc = 0.0;
1679 int count = 0;
1680 for (int start = 0; start + win <= n; start += step)
1681 {
1682 acc += coherenceWindow(envelope, start, start + win, periodFrames);
1683 ++count;
1684 }
1685 if (count == 0) return coherenceWindow(envelope, 0, n, periodFrames);
1686 return acc / static_cast<double>(count);
1687 }
1688
1689 [[nodiscard]] double coherenceWindow(const WorkVector<double>& envelope,
1690 int from, int to, double periodFrames) const
1691 {
1692 const double w = 2.0 * static_cast<double>(pi<double>) / periodFrames;
1693 double re = 0.0, im = 0.0, mass = 0.0;
1694 for (int i = from; i < to; ++i)
1695 {
1696 if ((i & 4095) == 0) checkOfflineWork();
1697 const double v = envelope[static_cast<size_t>(i)];
1698 const double a = w * static_cast<double>(i);
1699 re += v * std::cos(a);
1700 im -= v * std::sin(a);
1701 mass += v;
1702 }
1703 if (!(mass > kMassFloor)) return 0.0;
1704 return std::min(1.0, std::sqrt(re * re + im * im) / mass);
1705 }
1706
1708 [[nodiscard]] std::array<double, 2> offlineCandidateScores(
1709 const WorkVector<double>& envelope, double period) const
1710 {
1711 const double centre = kPriorCentreSeconds * frameRate_;
1712 const double lg = std::log2(period / centre) / kPriorWidthOctaves;
1713 const double base = std::exp(-0.5 * lg * lg) * coherence(envelope, period);
1714 double adjudicated = base, explains = base;
1715 for (const double multiple : kHarmonicDivisors)
1716 {
1717 const double share = coherence(envelope, multiple * period);
1718 adjudicated *= (1.0 - std::min(1.0, levelRatio(period, multiple) * share));
1719 explains *= (1.0 - std::min(1.0, share));
1720 }
1721 return { adjudicated, explains };
1722 }
1723
1747 void pickCandidates(int iLo, int iHi, double& tau1, double& tau2)
1748 {
1749 tau1 = 0.0;
1750 tau2 = 0.0;
1751 secondaryShare_ = 0.0;
1752 fundamentalFound_ = false;
1753
1754 const int n = static_cast<int>(env_.size());
1755 const int lagLo = std::max(1, static_cast<int>(std::floor(
1756 bankPeriod_[static_cast<size_t>(iLo)])));
1757 const int lagHi = std::min(n / 2, static_cast<int>(std::ceil(
1758 bankPeriod_[static_cast<size_t>(iHi)])));
1759 if (lagHi <= lagLo) return;
1760
1761 // Score one guard lag on either side so a genuine maximum exactly at
1762 // the requested range endpoint can be tested against both neighbors.
1763 // Previously both endpoints were excluded from candidate generation.
1764 acf_.assign(static_cast<size_t>(lagHi + 2), 0.0);
1765 const double centre = kPriorCentreSeconds * frameRate_;
1766 for (int lag = std::max(1, lagLo - 1); lag <= lagHi + 1; ++lag)
1767 {
1768 checkOfflineWork();
1769 double s = 0.0;
1770 const int m = n - lag;
1771 for (int i = 0; i < m; ++i)
1772 s += env_[static_cast<size_t>(i)] * env_[static_cast<size_t>(i + lag)];
1773 s /= static_cast<double>(m);
1774 const double lg = std::log2(static_cast<double>(lag) / centre)
1775 / kPriorWidthOctaves;
1776 acf_[static_cast<size_t>(lag)] = s * std::exp(-0.5 * lg * lg);
1777 }
1778
1779 // Peaks, not merely the argmax: the runner-up must be a peak of its
1780 // own, or it is the same peak read one lag over.
1781 candidates_.clear();
1782 for (int lag = lagLo; lag <= lagHi; ++lag)
1783 {
1784 const double v = acf_[static_cast<size_t>(lag)];
1785 if (v > acf_[static_cast<size_t>(lag - 1)]
1786 && v >= acf_[static_cast<size_t>(lag + 1)] && v > 0.0)
1787 {
1788 // A guard lag helps locate the maximum; it must not introduce
1789 // an out-of-range metrical hypothesis after interpolation.
1790 const double refined = refineLag(lag);
1791 if (refined >= bankPeriod_[static_cast<size_t>(iLo)]
1792 && refined <= bankPeriod_[static_cast<size_t>(iHi)])
1793 candidates_.push_back(lag);
1794 }
1795 }
1796 if (candidates_.empty()) return;
1797
1798 // A candidate is a lag at which the envelope actually repeats. Local
1799 // maxima of the correlation's noise floor between its real peaks - a
1800 // hundredth of their height - are lags at which it does not, and the
1801 // coherence ranking below cannot tell them apart from real pulses on
1802 // dense material, where every coherence is small: measured on a
1803 // limited 92 BPM master, a 141 BPM bump at 0.3% of the tallest peak
1804 // outranked the 92 BPM peak itself. Only peaks within a fixed ratio
1805 // of the tallest are ranked.
1806 {
1807 double top = 0.0;
1808 for (const int lag : candidates_) top = std::max(top, acf_[static_cast<size_t>(lag)]);
1809 size_t w = 0;
1810 for (const int lag : candidates_)
1811 if (acf_[static_cast<size_t>(lag)] >= kCandidatePeakFloor * top)
1812 candidates_[w++] = lag;
1813 candidates_.resize(w);
1814 }
1815
1816 // Rank by the octave discriminator, not by correlation height. Two
1817 // scores per candidate, for the two questions of scoreAt(): the
1818 // adjudicated one decides the level, the explanatory one is what the
1819 // confidence is discounted against.
1820 candScore_.assign(candidates_.size(), 0.0);
1821 candExplains_.assign(candidates_.size(), 0.0);
1822 for (size_t k = 0; k < candidates_.size(); ++k)
1823 {
1824 const double tau = refineLag(candidates_[k]);
1825 const auto scores = offlineCandidateScores(env_, tau);
1826 candScore_[k] = scores[0];
1827 candExplains_[k] = scores[1];
1828 }
1829
1830 size_t k1 = 0;
1831 for (size_t k = 1; k < candidates_.size(); ++k)
1832 if (candScore_[k] > candScore_[k1]) k1 = k;
1833
1834 // The discriminator can only choose between candidates that look like
1835 // a fundamental. When the caller has restricted the range so that the
1836 // signal's own period falls outside it, every candidate left inside is
1837 // one of that period's subharmonics -- and a subharmonic is exactly
1838 // what the coherence term is built to score at nothing, so the ranking
1839 // would be choosing between residues. That is not a failure of the
1840 // caller's request: a listener held to 60..100 BPM on a 160 BPM pulse
1841 // hears 80, and 80 is a peak of the correlation. So when no candidate
1842 // clears the floor, the correlation decides on its own.
1843 bool anyFundamental = false;
1844 for (size_t k = 0; k < candidates_.size(); ++k)
1845 if (coherence(refineLag(candidates_[k])) >= kMinFundamentalCoherence)
1846 { anyFundamental = true; break; }
1847 fundamentalFound_ = anyFundamental;
1848 if (!anyFundamental)
1849 {
1850 k1 = 0;
1851 for (size_t k = 1; k < candidates_.size(); ++k)
1852 if (acf_[static_cast<size_t>(candidates_[k])]
1853 > acf_[static_cast<size_t>(candidates_[k1])]) k1 = k;
1854 for (size_t k = 0; k < candidates_.size(); ++k)
1855 candScore_[k] = acf_[static_cast<size_t>(candidates_[k])];
1856 }
1857 tau1 = refineLag(candidates_[k1]);
1858
1859 // The reported alternative and the ambiguity it implies are two
1860 // different quantities and are taken from the two different scores.
1861 // What gets REPORTED is the level a listener could plausibly have
1862 // tapped instead, so it is the best adjudicated alternative. What
1863 // DISCOUNTS the confidence is the best explanation of the envelope at
1864 // any other level, tapping preference set aside -- on a polyrhythm the
1865 // most competitive explanation is the tatum, which is not a level
1866 // anyone taps, and a confidence blind to it would call the signal
1867 // unambiguous because the reported alternative happens to be modest.
1868 int k2 = -1;
1869 double bestOther = 0.0;
1870 for (size_t k = 0; k < candidates_.size(); ++k)
1871 {
1872 const double sep = std::abs(std::log2(static_cast<double>(candidates_[k])
1873 / static_cast<double>(candidates_[k1])));
1874 if (sep < kCandidateSeparationOctaves) continue;
1875 if (k2 < 0 || candScore_[k] > candScore_[static_cast<size_t>(k2)])
1876 k2 = static_cast<int>(k);
1877 bestOther = std::max(bestOther, candExplains_[k]);
1878 }
1879 if (k2 >= 0)
1880 {
1881 tau2 = refineLag(candidates_[static_cast<size_t>(k2)]);
1882 const double top = candExplains_[k1];
1883 secondaryShare_ = (top > 0.0) ? std::clamp(bestOther / top, 0.0, 1.0) : 0.0;
1884 }
1885 }
1886
1888 [[nodiscard]] double refineLag(int lag) const noexcept
1889 {
1890 if (lag <= 0 || lag + 1 >= static_cast<int>(acf_.size()))
1891 return static_cast<double>(lag);
1892 const double a = acf_[static_cast<size_t>(lag - 1)];
1893 const double b = acf_[static_cast<size_t>(lag)];
1894 const double c = acf_[static_cast<size_t>(lag + 1)];
1895 const double den = a - 2.0 * b + c;
1896 if (!(std::abs(den) > 0.0)) return static_cast<double>(lag);
1897 const double d = std::clamp(0.5 * (a - c) / den, -0.5, 0.5);
1898 return static_cast<double>(lag) + d;
1899 }
1900
1902 [[nodiscard]] std::array<double, kLevelRawFeatures> levelFeatures(
1903 double period, const WorkVector<double>& balanced) const noexcept
1904 {
1905 std::array<double, kLevelRawFeatures> features {};
1906 size_t k = 0;
1907 features[k++] = std::log2(periodToBpm(period) / 120.0);
1908 features[k++] = pulseShare(env_, period);
1909 features[k++] = pulseShare(balanced, period);
1910 for (const auto& r : envReg_) features[k++] = pulseShare(r, period);
1911 features[k++] = pulseShare(env_, 2.0 * period);
1912 for (const auto& r : envReg_) features[k++] = pulseShare(r, 2.0 * period);
1913 features[k++] = repetition(env_, period);
1914 features[k++] = repetition(balanced, period);
1915 features[k++] = pulseShare(env_, 3.0 * period);
1916 features[k++] = pulseShare(envReg_[0], 3.0 * period);
1917 features[k++] = pulseShare(balanced, 2.0 * period);
1918 features[k++] = pulseShare(balanced, 3.0 * period);
1919 return features;
1920 }
1921
1936 void chooseMetricalLevel(int iLo, int iHi, double& tau1, double& tau2)
1937 {
1938 const double pMin = bankPeriod_[static_cast<size_t>(iLo)];
1939 const double pMax = bankPeriod_[static_cast<size_t>(iHi)];
1940 const WorkVector<double>& balanced = (envBal_.size() == env_.size()) ? envBal_ : env_;
1941 std::array<std::array<double, kLevelRawFeatures>, kLevelCandidates> raw {};
1942 for (int c = 0; c < kLevelCandidates; ++c)
1943 raw[static_cast<size_t>(c)] = levelFeatures(
1944 tau1 * kLevelMultiples[static_cast<size_t>(c)], balanced);
1945
1946 const auto& proposed = raw[static_cast<size_t>(kLevelMiddle)];
1947 if (!(proposed[kLevelShareAtPeriod] > 0.0)) return;
1948 const double contrastFloor = kLevelContrastFloor * proposed[kLevelShareAtPeriod];
1949 int best = kLevelMiddle;
1950 if (proposed[kLevelShareAtDouble] >= contrastFloor)
1951 {
1952 best = -1;
1953 double bestScore = 0.0;
1954 for (int c = 0; c < kLevelCandidates; ++c)
1955 {
1956 const double period = tau1 * kLevelMultiples[static_cast<size_t>(c)];
1957 if (c != kLevelMiddle && (period < pMin || period > pMax)) continue;
1958 const auto& f = raw[static_cast<size_t>(c)];
1959 double score = kLevelBias[static_cast<size_t>(c)];
1960 for (size_t j = 0; j < kLevelRawFeatures; ++j)
1961 score += kLevelWeights[j] * f[j]
1962 + kLevelWeights[kLevelRawFeatures + j] * (f[j] - proposed[j]);
1963 score += kLevelWeights[2 * kLevelRawFeatures] * f[0] * f[0];
1964 if (best < 0 || score > bestScore) { best = c; bestScore = score; }
1965 }
1966 }
1967
1968 const double binaryPeriod = tau1 * kLevelMultiples[static_cast<size_t>(best)];
1969 double chosenPeriod = binaryPeriod;
1970 bool ternaryChosen = false;
1971 std::array<double, kTernaryMultiples.size()> supportedPeriods {};
1972 size_t supportedCount = 0;
1973 if (std::max(proposed[kLevelShareAtDouble], proposed[kLevelShareAtTriple]) >= contrastFloor)
1974 {
1975 const auto& reference = raw[static_cast<size_t>(best)];
1976 double bestGain = 0.0;
1977 for (size_t c = 0; c < kTernaryMultiples.size(); ++c)
1978 {
1979 const double period = tau1 * kTernaryMultiples[c];
1980 if (period < pMin || period > pMax) continue;
1981 const auto f = levelFeatures(period, balanced);
1982 if (f[kLevelBalancedShareAtPeriod] < kMinFundamentalCoherence) continue;
1983 supportedPeriods[supportedCount++] = period;
1984 double gain = kTernaryBias[c];
1985 for (size_t j = 0; j < kLevelRawFeatures; ++j)
1986 gain += kTernaryWeights[j] * f[j]
1987 + kTernaryWeights[kLevelRawFeatures + j] * (f[j] - reference[j]);
1988 gain += kTernaryWeights[2 * kLevelRawFeatures] * f[0] * f[0];
1989 if (gain > bestGain)
1990 {
1991 bestGain = gain;
1992 chosenPeriod = period;
1993 ternaryChosen = true;
1994 }
1995 }
1996 }
1997 if (ternaryChosen)
1998 {
1999 tau2 = binaryPeriod;
2000 tau1 = chosenPeriod;
2001 }
2002 else if (best != kLevelMiddle)
2003 {
2004 tau2 = tau1;
2005 tau1 = binaryPeriod;
2006 }
2007 if (ternaryChosen || best != kLevelMiddle)
2008 refreshMetricalAmbiguity(balanced, tau1, tau2,
2009 { supportedPeriods.data(), supportedCount });
2010 }
2011
2013 void refreshMetricalAmbiguity(const WorkVector<double>& envelope,
2014 double chosen, double displaced,
2015 std::span<const double> addedPeriods)
2016 {
2017 const double top = offlineCandidateScores(envelope, chosen)[1];
2018 double bestOther = 0.0;
2019 for (size_t k = 0; k < candidates_.size(); ++k)
2020 if (std::abs(std::log2(refineLag(candidates_[k]) / chosen))
2021 >= kCandidateSeparationOctaves)
2022 bestOther = std::max(bestOther, candExplains_[k]);
2023 const auto consider = [&](double period) {
2024 if (period > 0.0 && std::abs(std::log2(period / chosen))
2025 >= kCandidateSeparationOctaves)
2026 bestOther = std::max(bestOther, offlineCandidateScores(envelope, period)[1]);
2027 };
2028 consider(displaced);
2029 for (const double period : addedPeriods) consider(period);
2030 secondaryShare_ = top > 0.0 ? std::clamp(bestOther / top, 0.0, 1.0)
2031 : (bestOther > 0.0 ? 1.0 : 0.0);
2032 }
2033
2044 [[nodiscard]] static double pulseShare(const WorkVector<double>& e,
2045 double periodFrames) noexcept
2046 {
2047 const int n = static_cast<int>(e.size());
2048 const int win = static_cast<int>(kLevelWindowBeats * periodFrames);
2049 if (win < 4) return 0.0;
2050 const double w = 2.0 * static_cast<double>(pi<double>) / periodFrames;
2051 const double dc = std::cos(w), ds = std::sin(w);
2052 double acc = 0.0, mass = 0.0;
2053 for (int start = 0; start + win <= n; start += win / 2)
2054 {
2055 // The phasor is seeded exactly at each window start and rotated
2056 // within it; over one window the rotation's rounding is far
2057 // below anything the features resolve.
2058 double c = std::cos(w * static_cast<double>(start));
2059 double s = std::sin(w * static_cast<double>(start));
2060 double re = 0.0, im = 0.0, m = 0.0;
2061 for (int i = start; i < start + win; ++i)
2062 {
2063 const double v = std::max(0.0, e[static_cast<size_t>(i)]);
2064 re += v * c;
2065 im += v * s;
2066 m += v;
2067 const double cn = c * dc - s * ds;
2068 s = s * dc + c * ds;
2069 c = cn;
2070 }
2071 if (m > 0.0) { acc += std::sqrt(re * re + im * im); mass += m; }
2072 }
2073 return (mass > 0.0) ? acc / mass : 0.0;
2074 }
2075
2078 [[nodiscard]] static double repetition(const WorkVector<double>& e,
2079 double periodFrames) noexcept
2080 {
2081 const int n = static_cast<int>(e.size());
2082 const int lag = static_cast<int>(std::lround(periodFrames));
2083 if (lag <= 0 || lag >= n) return 0.0;
2084 double s0 = 0.0, s = 0.0;
2085 for (int i = 0; i < n; ++i) s0 += e[static_cast<size_t>(i)] * e[static_cast<size_t>(i)];
2086 for (int i = 0; i + lag < n; ++i)
2087 s += e[static_cast<size_t>(i)] * e[static_cast<size_t>(i + lag)];
2088 return (s0 > 0.0) ? s / s0 * static_cast<double>(n) / static_cast<double>(n - lag)
2089 : 0.0;
2090 }
2091
2111 void computeLocalPeriods(double globalTau)
2112 {
2113 const int n = static_cast<int>(env_.size());
2114 localPeriod_.assign(static_cast<size_t>(n), globalTau);
2115 if (n < 8 || bankSize_ < 2) return;
2116
2117 // Confined twice over: to kLocalPeriodSpan either side of the period
2118 // the global analysis settled on, AND to the range the caller asked to
2119 // be searched. The first keeps a syncopated part from moving the grid
2120 // off the beat; the second is the caller's contract, and a local
2121 // estimate that escaped it would deliver a tempo outside the range
2122 // that was asked for -- measured at 110 BPM on 160 BPM material
2123 // searched over 60 to 100 before this clamp existed.
2124 int aLo = 0, aHi = 0;
2125 unpackRange(activeRange_.load(std::memory_order_relaxed), aLo, aHi);
2126 aLo = std::clamp(aLo, 0, bankSize_ - 1);
2127 aHi = std::clamp(aHi, aLo, bankSize_ - 1);
2128
2129 const int gi = periodToIndex(globalTau);
2130 int lo = std::clamp(gi - kLocalPeriodSpan, aLo, aHi);
2131 int hi = std::clamp(gi + kLocalPeriodSpan, aLo, aHi);
2132 if (hi < lo) std::swap(lo, hi);
2133 if (hi <= lo) return;
2134
2135 sweepA_.assign(static_cast<size_t>(n), globalTau);
2136 sweepB_.assign(static_cast<size_t>(n), globalTau);
2137 sweepPeriods(lo, hi, false, sweepA_);
2138 sweepPeriods(lo, hi, true, sweepB_);
2139
2140 for (int i = 0; i < n; ++i)
2141 {
2142 if ((i & 255) == 0) checkOfflineWork();
2143 const double a = sweepA_[static_cast<size_t>(i)];
2144 const double b = sweepB_[static_cast<size_t>(i)];
2145 localPeriod_[static_cast<size_t>(i)] =
2146 (a > 0.0 && b > 0.0) ? std::sqrt(a * b) : globalTau;
2147 }
2148
2149 // Smooth over a couple of beats: a per-frame winner steps by whole
2150 // bank bins, and a target period that steps is a target period the
2151 // interval cost can be made to chase.
2152 const int half = std::max(1, static_cast<int>(std::lround(globalTau)));
2153 scratchA_.assign(static_cast<size_t>(n) + 1, 0.0);
2154 for (int i = 0; i < n; ++i)
2155 scratchA_[static_cast<size_t>(i) + 1] = scratchA_[static_cast<size_t>(i)]
2156 + std::log(localPeriod_[static_cast<size_t>(i)]);
2157 for (int i = 0; i < n; ++i)
2158 {
2159 if ((i & 255) == 0) checkOfflineWork();
2160 const int a = std::max(0, i - half);
2161 const int b = std::min(n, i + half + 1);
2162 const double m = (scratchA_[static_cast<size_t>(b)]
2163 - scratchA_[static_cast<size_t>(a)])
2164 / static_cast<double>(b - a);
2165 localPeriod_[static_cast<size_t>(i)] = std::exp(m);
2166 }
2167 }
2168
2172 void sweepPeriods(int lo, int hi, bool reverse, WorkVector<double>& out)
2173 {
2174 const int n = static_cast<int>(env_.size());
2175 std::fill(zRe_.begin(), zRe_.end(), 0.0);
2176 std::fill(zIm_.begin(), zIm_.end(), 0.0);
2177 std::fill(zMass_.begin(), zMass_.end(), 0.0);
2178
2179 for (int k = 0; k < n; ++k)
2180 {
2181 if ((k & 255) == 0) checkOfflineWork();
2182 const int i = reverse ? (n - 1 - k) : k;
2183 const double o = env_[static_cast<size_t>(i)];
2184
2185 int best = -1;
2186 double bestValue = 0.0;
2187 for (int b = lo; b <= hi; ++b)
2188 {
2189 const size_t u = static_cast<size_t>(b);
2190 const double re = zRe_[u], im = zIm_[u];
2191 zRe_[u] = bankCosCoef_[u] * re - bankSinCoef_[u] * im + o;
2192 zIm_[u] = bankCosCoef_[u] * im + bankSinCoef_[u] * re;
2193 zMass_[u] = bankDecay_[u] * zMass_[u] + o;
2194 const double mass = zMass_[u];
2195 const double c = (mass > kMassFloor)
2196 ? std::sqrt(zRe_[u] * zRe_[u] + zIm_[u] * zIm_[u]) / mass : 0.0;
2197 bankCoherence_[u] = c;
2198 const double s = bankPrior_[u] * c;
2199 if (s > bestValue) { bestValue = s; best = b; }
2200 }
2201 out[static_cast<size_t>(i)] = (best >= 0)
2202 ? bankPeriod_[static_cast<size_t>(best)] : 0.0;
2203 }
2204 }
2205
2209 [[nodiscard]] static double ambiguityDiscount(double share) noexcept
2210 {
2211 if (!(share > kAmbiguityFloor)) return 1.0;
2212 return std::clamp(1.0 - (share - kAmbiguityFloor) / (1.0 - kAmbiguityFloor),
2213 0.0, 1.0);
2214 }
2215
2217 [[nodiscard]] int periodToIndex(double periodFrames) const noexcept
2218 {
2219 if (!(periodFrames > 0.0) || bankSize_ < 1) return 0;
2220 const double x = std::log2(periodFrames / bankPeriod_[0])
2221 * static_cast<double>(kBinsPerOctave);
2222 return std::clamp(static_cast<int>(std::lround(x)), 0, bankSize_ - 1);
2223 }
2224
2229 bool buildGrid(double periodFrames, WorkVector<int64_t>& gridOut)
2230 {
2231 gridOut.clear();
2232 WorkVector<int> beatFrames(detail::AccountedAllocator<int>{&allocationAccount_});
2233 if (!runDp(periodFrames, beatFrames) || beatFrames.size() < 2) return false;
2234 gridOut.reserve(beatFrames.size());
2235 for (const int f : beatFrames)
2236 gridOut.push_back(beatSample(f));
2237 return true;
2238 }
2239
2253 bool runDp(double periodFrames, WorkVector<int>& beatFrames)
2254 {
2255 beatFrames.clear();
2256 const int n = static_cast<int>(env_.size());
2257 const bool varying = (localPeriod_.size() == env_.size());
2258 const int lo = std::max(1, static_cast<int>(std::lround(kSearchLowFactor
2259 * periodFrames)));
2260 const int hi = std::max(lo + 1, static_cast<int>(std::lround(kSearchHighFactor
2261 * periodFrames)));
2262 if (n <= hi + 2) return false;
2263
2264 buildLocalScore(periodFrames);
2265
2266 const double alpha = tightness_.load(std::memory_order_relaxed) * alphaScale_;
2267 cumScore_.assign(static_cast<size_t>(n), 0.0);
2268 backlink_.assign(static_cast<size_t>(n), -1);
2269
2270 for (int i = 0; i < n; ++i)
2271 {
2272 if ((i & 255) == 0) checkOfflineWork();
2273 const double target = varying ? localPeriod_[static_cast<size_t>(i)]
2274 : periodFrames;
2275 const int dLo = varying
2276 ? std::max(1, static_cast<int>(std::lround(kSearchLowFactor * target)))
2277 : lo;
2278 const int dHiBound = varying
2279 ? std::max(dLo + 1, static_cast<int>(std::lround(kSearchHighFactor
2280 * target)))
2281 : hi;
2282
2283 double best = 0.0;
2284 int bestJ = -1;
2285 const int dHi = std::min(dHiBound, i);
2286 for (int d = dLo; d <= dHi; ++d)
2287 {
2288 const int j = i - d;
2289 const double lr = std::log(static_cast<double>(d) / target);
2290 const double s = cumScore_[static_cast<size_t>(j)] - alpha * lr * lr;
2291 if (bestJ < 0 || s > best) { best = s; bestJ = j; }
2292 }
2293 cumScore_[static_cast<size_t>(i)] = local_[static_cast<size_t>(i)]
2294 + ((bestJ >= 0) ? best : 0.0);
2295 backlink_[static_cast<size_t>(i)] = bestJ;
2296 }
2297
2298 int end = 0;
2299 for (int i = 1; i < n; ++i)
2300 if (cumScore_[static_cast<size_t>(i)] > cumScore_[static_cast<size_t>(end)])
2301 end = i;
2302
2303 for (int i = end; i >= 0; i = backlink_[static_cast<size_t>(i)])
2304 {
2305 beatFrames.push_back(i);
2306 if (backlink_[static_cast<size_t>(i)] < 0) break;
2307 }
2308 std::reverse(beatFrames.begin(), beatFrames.end());
2309
2310 trimBeats(beatFrames);
2311 if (beatFrames.size() < 2) return false;
2312
2313 snapBeats(beatFrames, periodFrames);
2314 extendGrid(beatFrames);
2315 snapBeats(beatFrames, periodFrames);
2316 return beatFrames.size() >= 2;
2317 }
2318
2333 void snapBeats(WorkVector<int>& beatFrames, double periodFrames) const
2334 {
2335 const int n = static_cast<int>(env_.size());
2336 const int win = std::max(1, static_cast<int>(std::lround(periodFrames
2337 * kSnapFraction)));
2338 int previous = -1;
2339 for (size_t k = 0; k < beatFrames.size(); ++k)
2340 {
2341 const int f = beatFrames[k];
2342 int best = f;
2343 double bestValue = local_[static_cast<size_t>(f)];
2344 const int lo = std::max(previous + 1, f - win);
2345 const int hi = std::min(n - 1, f + win);
2346 for (int i = lo; i <= hi; ++i)
2347 {
2348 if (local_[static_cast<size_t>(i)] > bestValue)
2349 {
2350 bestValue = local_[static_cast<size_t>(i)];
2351 best = i;
2352 }
2353 }
2354 beatFrames[k] = best;
2355 previous = best;
2356 }
2357 }
2358
2374 void extendGrid(WorkVector<int>& beatFrames) const
2375 {
2376 if (beatFrames.size() < 3) return;
2377 const int n = static_cast<int>(env_.size());
2378
2379 WorkVector<int> ibi(detail::AccountedAllocator<int>{&allocationAccount_});
2380 ibi.reserve(beatFrames.size());
2381 for (size_t k = 1; k < beatFrames.size(); ++k)
2382 ibi.push_back(beatFrames[k] - beatFrames[k - 1]);
2383 std::sort(ibi.begin(), ibi.end());
2384 const int step = ibi[ibi.size() / 2];
2385 if (step < 2) return;
2386
2387 double mean = 0.0;
2388 for (const int f : beatFrames) mean += local_[static_cast<size_t>(f)];
2389 mean /= static_cast<double>(beatFrames.size());
2390 const double floorValue = kTrimFraction * mean;
2391 const int win = std::max(1, static_cast<int>(std::lround(step * kSnapFraction)));
2392
2393 WorkVector<int> front(detail::AccountedAllocator<int>{&allocationAccount_});
2394 for (int guess = beatFrames.front() - step; guess - win >= 0; guess -= step)
2395 {
2396 int best = -1;
2397 double bestValue = floorValue;
2398 for (int i = std::max(0, guess - win); i <= std::min(n - 1, guess + win); ++i)
2399 if (local_[static_cast<size_t>(i)] > bestValue)
2400 { bestValue = local_[static_cast<size_t>(i)]; best = i; }
2401 if (best < 0) break;
2402 front.push_back(best);
2403 guess = best;
2404 }
2405 std::reverse(front.begin(), front.end());
2406
2407 WorkVector<int> back(detail::AccountedAllocator<int>{&allocationAccount_});
2408 for (int guess = beatFrames.back() + step; guess + win <= n - 1; guess += step)
2409 {
2410 int best = -1;
2411 double bestValue = floorValue;
2412 for (int i = std::max(0, guess - win); i <= std::min(n - 1, guess + win); ++i)
2413 if (local_[static_cast<size_t>(i)] > bestValue)
2414 { bestValue = local_[static_cast<size_t>(i)]; best = i; }
2415 if (best < 0) break;
2416 back.push_back(best);
2417 guess = best;
2418 }
2419
2420 if (front.empty() && back.empty()) return;
2421 WorkVector<int> merged(detail::AccountedAllocator<int>{&allocationAccount_});
2422 merged.reserve(front.size() + beatFrames.size() + back.size());
2423 merged.insert(merged.end(), front.begin(), front.end());
2424 merged.insert(merged.end(), beatFrames.begin(), beatFrames.end());
2425 merged.insert(merged.end(), back.begin(), back.end());
2426 beatFrames.swap(merged);
2427 }
2428
2432 void buildLocalScore(double periodFrames)
2433 {
2434 const int n = static_cast<int>(env_.size());
2435 local_.assign(static_cast<size_t>(n), 0.0);
2436
2437 const double sigma = std::max(0.5, kLocalScoreSigmaPeriods * periodFrames);
2438 const int half = std::max(1, static_cast<int>(std::lround(3.0 * sigma)));
2439 kernel_.assign(static_cast<size_t>(2 * half + 1), 0.0);
2440 double ksum = 0.0;
2441 for (int k = -half; k <= half; ++k)
2442 {
2443 const double x = static_cast<double>(k) / sigma;
2444 const double v = std::exp(-0.5 * x * x);
2445 kernel_[static_cast<size_t>(k + half)] = v;
2446 ksum += v;
2447 }
2448 if (ksum > 0.0)
2449 for (double& v : kernel_) v /= ksum;
2450
2451 for (int i = 0; i < n; ++i)
2452 {
2453 if ((i & 255) == 0) checkOfflineWork();
2454 double acc = 0.0;
2455 const int kLo = std::max(-half, -i);
2456 const int kHi = std::min(half, n - 1 - i);
2457 for (int k = kLo; k <= kHi; ++k)
2458 acc += env_[static_cast<size_t>(i + k)]
2459 * kernel_[static_cast<size_t>(k + half)];
2460 local_[static_cast<size_t>(i)] = acc;
2461 }
2462 }
2463
2467 void trimBeats(WorkVector<int>& beatFrames) const
2468 {
2469 if (beatFrames.size() < 3) return;
2470
2471 double mean = 0.0;
2472 for (const int f : beatFrames) mean += local_[static_cast<size_t>(f)];
2473 mean /= static_cast<double>(beatFrames.size());
2474 const double floorValue = kTrimFraction * mean;
2475
2476 size_t first = 0;
2477 while (first + 2 < beatFrames.size()
2478 && local_[static_cast<size_t>(beatFrames[first])] < floorValue)
2479 ++first;
2480
2481 size_t last = beatFrames.size() - 1;
2482 while (last > first + 1
2483 && local_[static_cast<size_t>(beatFrames[last])] < floorValue)
2484 --last;
2485
2486 if (first > 0 || last + 1 < beatFrames.size())
2487 beatFrames = WorkVector<int>(beatFrames.begin()
2488 + static_cast<std::ptrdiff_t>(first),
2489 beatFrames.begin()
2490 + static_cast<std::ptrdiff_t>(last) + 1,
2491 beatFrames.get_allocator());
2492 }
2493
2519 [[nodiscard]] int64_t beatSample(int f) const noexcept
2520 {
2521 const int n = static_cast<int>(env_.size());
2522 const int64_t base = envRef_[static_cast<size_t>(f)];
2523 if (f <= 0 || f >= n - 1) return base;
2524 const double a = env_[static_cast<size_t>(f - 1)];
2525 const double b = env_[static_cast<size_t>(f)];
2526 const double c = env_[static_cast<size_t>(f + 1)];
2527 const double den = a - 2.0 * b + c;
2528 if (!(den < 0.0)) return base; // not a peak: nothing to interpolate
2529 const double d = std::clamp(0.5 * (a - c) / den, -0.5, 0.5);
2530 return base + static_cast<int64_t>(std::llround(d * static_cast<double>(hop_)));
2531 }
2532
2541 [[nodiscard]] double gridCoherence(const WorkVector<int64_t>& beats) const noexcept
2542 {
2543 if (beats.size() < 2) return 0.0;
2544 // Judged on the register-balanced envelope when there is one, for the
2545 // reason the metrical level is: on a dense mix the plain envelope is
2546 // mostly strums and syllables, and a grid that chases them "explains"
2547 // it better than the grid on the kick does.
2548 const WorkVector<double>& e = (envBal_.size() == env_.size()) ? envBal_ : env_;
2549 double re = 0.0, mass = 0.0;
2550 size_t k = 0;
2551 for (size_t i = 0; i < e.size(); ++i)
2552 {
2553 const int64_t r = envRef_[i];
2554 if (r < beats.front()) continue;
2555 while (k + 1 < beats.size() && beats[k + 1] <= r) ++k;
2556 if (k + 1 >= beats.size()) break;
2557 const double span = static_cast<double>(beats[k + 1] - beats[k]);
2558 if (!(span > 0.0)) continue;
2559 const double phase = static_cast<double>(r - beats[k]) / span;
2560 const double o = e[i];
2561 re += o * std::cos(2.0 * pi<double> * phase);
2562 mass += o;
2563 }
2564 return (mass > kMassFloor) ? re / mass : 0.0;
2565 }
2566
2588 [[nodiscard]] static double fitBeatSlope(const WorkVector<int64_t>& beats)
2589 {
2590 const size_t n = beats.size();
2591 if (n < 2) return 0.0;
2592 WorkVector<double> ibi(detail::AccountedAllocator<double>{beats.get_allocator().account()});
2593 ibi.reserve(n - 1);
2594 for (size_t k = 1; k < n; ++k) ibi.push_back(static_cast<double>(beats[k] - beats[k - 1]));
2595 WorkVector<double> sorted = ibi;
2596 std::nth_element(sorted.begin(), sorted.begin() + static_cast<std::ptrdiff_t>(sorted.size() / 2), sorted.end());
2597 const double med = sorted[sorted.size() / 2];
2598 if (!(med > 0.0)) return 0.0;
2599
2600 // Reuse the median scratch for interval counts. The fractional
2601 // transition ends exactly on a half-beat; its internal indices keep
2602 // their relative timing. No segment of the grid is discarded.
2603 for (size_t j = 0; j < ibi.size(); ++j)
2604 sorted[j] = std::max(1.0, std::round(ibi[j] / med));
2605 const auto stable = [&](size_t j) {
2606 const double r = ibi[j] / med;
2607 return r >= 0.75 && std::abs(r - std::round(r)) < 0.125;
2608 };
2609 for (size_t j = 3; j + 3 < ibi.size(); ++j)
2610 {
2611 if (stable(j) || !stable(j - 1) || !stable(j - 2) || !stable(j - 3))
2612 continue;
2613 for (size_t width = 1; width <= 3; ++width)
2614 {
2615 const size_t end = j + width;
2616 if (end + 3 > ibi.size()
2617 || !stable(end) || !stable(end + 1) || !stable(end + 2))
2618 continue;
2619 double total = 0.0;
2620 for (size_t q = j; q < end; ++q) total += ibi[q] / med;
2621 const double half = std::floor(total) + 0.5;
2622 if (std::abs(total - half) >= 0.125) continue;
2623 for (size_t q = j; q < end; ++q)
2624 sorted[q] = (ibi[q] / med) * half / total;
2625 j = end - 1;
2626 break;
2627 }
2628 }
2629
2630 double sx = 0.0, sy = 0.0, sxx = 0.0, sxy = 0.0, index = 0.0;
2631 for (size_t k = 0; k < n; ++k)
2632 {
2633 if (k > 0) index += sorted[k - 1];
2634 const double y = static_cast<double>(beats[k]);
2635 sx += index; sy += y; sxx += index * index; sxy += index * y;
2636 }
2637 const double dn = static_cast<double>(n);
2638 const double den = dn * sxx - sx * sx;
2639 if (!(std::abs(den) > 0.0)) return 0.0;
2640 return (dn * sxy - sx * sy) / den;
2641 }
2642
2643 // -- State ---------------------------------------------------------------
2644
2645 void resetState() noexcept
2646 {
2647 onset_.reset();
2648 std::fill(zRe_.begin(), zRe_.end(), 0.0);
2649 std::fill(zIm_.begin(), zIm_.end(), 0.0);
2650 std::fill(zMass_.begin(), zMass_.end(), 0.0);
2651 std::fill(bankCoherence_.begin(), bankCoherence_.end(), 0.0);
2652
2653 samplesPushed_ = 0;
2654 frameIndex_ = 0;
2655 lastFrameRef_ = 0;
2656 baseline_ = 0.0;
2657 prevSinceBeat_ = 0.0;
2658 framesSinceBeat_ = 0;
2659 lastBestIndex_ = -1;
2660 havePhase_ = false;
2661
2662 tempoAndConfidence_.store(packTempo(0.0f, 0.0f), std::memory_order_relaxed);
2663 beatLatched_.store(false, std::memory_order_relaxed);
2664 lastBeatSample_.store(-1, std::memory_order_relaxed);
2665 }
2666
2669 static constexpr double kBeatRefractory = 0.5;
2670
2673 static constexpr double kMassFloor = 1e-12;
2674
2675 // -- Members -------------------------------------------------------------
2676
2677 detail::AllocationAccount allocationAccount_;
2678 void* offlineCheckpointContext_ = nullptr;
2679 void (*offlineCheckpoint_)(void*) = nullptr;
2680 void checkOfflineWork() const
2681 {
2682 if (offlineCheckpoint_) offlineCheckpoint_(offlineCheckpointContext_);
2683 }
2684 OnsetDetector<T> onset_;
2685
2686 double sampleRate_ = 44100.0;
2687 double frameRate_ = 200.0;
2688 int hop_ = 221;
2689
2690 // Resonator bank (built at prepare, read on the audio thread).
2691 int bankSize_ = 0;
2692 WorkVector<double> bankPeriod_;
2693 WorkVector<double> bankCosCoef_, bankSinCoef_, bankDecay_, bankPrior_;
2694 int harmonicOffset_[kNumHarmonics] = { 0, 0, 0 };
2695 WorkVector<double> zRe_, zIm_, zMass_, bankCoherence_;
2696 WorkVector<double> bankLevelRatio_;
2697
2698 // Causal stream state (audio thread only).
2699 int64_t samplesPushed_ = 0;
2700 int64_t frameIndex_ = 0;
2701 int64_t lastFrameRef_ = 0;
2702 double baseline_ = 0.0;
2703 double baselineCoef_ = 0.005;
2704 double prevSinceBeat_ = 0.0;
2705 int64_t framesSinceBeat_ = 0;
2706 int lastBestIndex_ = -1;
2707 bool havePhase_ = false;
2708
2709 // Offline scratch (analyze() and the offline session only).
2710 int64_t offlinePushed_ = 0;
2711 bool offlineOpen_ = false;
2712 WorkVector<double> env_;
2713 WorkVector<int64_t> envRef_;
2714 static constexpr int kRegisters = OnsetDetector<T>::kNumRegisters;
2715 static_assert(kRegisters == 4, "the level model (kLevelWeights) reads four register envelopes");
2716 std::array<WorkVector<double>, kRegisters> envReg_;
2717 WorkVector<double> envBal_;
2718 double alphaScale_ = 1.0;
2719 WorkVector<double> local_, cumScore_, acf_, scratchA_, kernel_;
2720 WorkVector<double> localPeriod_, sweepA_, sweepB_;
2721 WorkVector<int> backlink_;
2722 WorkVector<int> candidates_;
2723 WorkVector<double> candScore_, candExplains_;
2724 double secondaryShare_ = 0.0;
2725 bool fundamentalFound_ = false;
2726
2727 // Published state.
2728 std::atomic<bool> prepared_ { false };
2729 std::atomic<std::uint64_t> tempoAndConfidence_ { 0 };
2730 std::atomic<std::uint32_t> activeRange_ { 0 };
2731 std::atomic<double> tightness_ { kDefaultTightness };
2732 std::atomic<bool> beatLatched_ { false };
2733 std::atomic<std::int64_t> lastBeatSample_ { -1 };
2734 std::atomic<std::int64_t> latencySamples_ { 549 };
2735};
2736
2737} // namespace dspark
Non-owning view over audio channel data.
Definition AudioBuffer.h:50
int getNumSamples() const noexcept
Returns the number of samples per channel.
int getNumChannels() const noexcept
Returns the number of channels in this view.
T * getChannel(int ch) const noexcept
Returns a pointer to the sample data for the given channel.
Tempo and beat tracking with an offline grid and a causal readout.
bool beatNow() const noexcept
True if a beat was reported during the most recent call.
void setTempoRange(T minBpm, T maxBpm) noexcept
Restricts the searched tempo range, in BPM. Lock free, no alloc.
void prepare(const AudioSpec &spec)
Allocates all causal state and prepares the shared front end.
void pushOffline(std::span< const T > samples)
Feeds the next piece of an offline session (channel-0 samples). No-op outside one.
T getTightness() const noexcept
Tightness currently in force.
const OnsetDetector< T > & getOnsetDetector() const noexcept
Read-only access to the front end, for callers that also want onsets and would otherwise run a second...
T getConfidence() const noexcept
Coherence of the envelope with the running tempo, in [0,1].
Result analyze(AudioBufferView< const T > whole)
Tracks tempo and beats over a whole mono buffer (channel 0).
void setTightness(T alpha) noexcept
Sets the dynamic-programming tightness (Ellis alpha).
int64_t getLastBeatSample() const noexcept
Sample index the most recent causal beat is attributed to; -1 before the first one.
void reset() noexcept
Clears all streaming state and abandons an open offline session. Not concurrent with pushSamples().
void beginOffline(int64_t expectedSamples=0)
Opens an incremental offline analysis.
Result finishOffline()
Closes the session and tracks tempo and beats over everything pushed, as analyze() does....
void processBlock(AudioBufferView< const T > in) noexcept
Feeds a mono block; reads channel 0 only. Const, never mutated. Lock free and allocation free....
void pushSamples(std::span< const T > samples) noexcept
Feeds a mono stream of samples. Lock free, allocation free.
void getTempoAndConfidence(T &bpmOut, T &confidenceOut) const noexcept
Running tempo and its confidence, from ONE load.
T getRunningTempoBpm() const noexcept
Running tempo estimate in BPM, 0 before the first frame.
int getLatencySamples() const noexcept
Delay between a beat and the call in which it is announced.
double getFrameRate() const noexcept
Analysis frames per second of the shared envelope.
Causal SuperFlux onset detector with lock-free readout.
static constexpr int kNumRegisters
One frame of the onset-strength envelope (the ODF before the peak picker).
Main namespace for the DSPark framework.
Describes the audio environment for a DSP processor.
Definition AudioSpec.h:37
double sampleRate
Sample rate in Hz.
Definition AudioSpec.h:45
What analyze() returns: one tempo, one grid, one number saying how much of the signal that grid expla...
std::vector< int64_t > beatSamples
Beat positions, ascending, in samples.
T secondaryTempoBpm
The competing hypothesis that lost.
T tempoBpm
Delivered tempo, fitted to the grid.
T confidence
Coherence of the envelope with the grid, [0,1].