DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
OfflineTempoAnalyzer.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
21#include "OfflineTransientAnalyzer.h"
22#if DSPARK_HAS_OFFLINE
23#include "detail/OfflineBeatEngine.h"
24#include <algorithm>
25#include <array>
26#include <cmath>
27#include <cstddef>
28#include <cstdint>
29#include <memory>
30#include <span>
31#include <utility>
32
33namespace dspark
34{
35
37template <FloatType T> class OfflineTempoAnalyzer final
38{
39 public:
40 static constexpr std::uint32_t algorithmRevision = 2;
41 struct Options
42 {
43 double minimumBpm = 40, maximumBpm = 240;
44 double minimumConfidence = 0.25;
45 };
46 struct Beat
47 {
48 std::int64_t frame = 0;
49 double nextIntervalBpm = 0;
50 bool reliable = false;
51 };
52 enum class Reason : std::uint8_t { Tracked, Uncertain, NoRhythm, NoSignal };
53 struct Report
54 {
57 std::size_t reliableIntervals = 0;
59 };
60 class Analysis final
61 {
62 public:
63 Analysis() = default;
64 Analysis(Analysis &&) noexcept = default;
65 Analysis &operator=(Analysis &&) noexcept = default;
66 [[nodiscard]] bool isValid() const noexcept { return beats_ != nullptr; }
67 [[nodiscard]] OfflineAudioSpec getSpec() const noexcept { return spec_; }
68 [[nodiscard]] OfflineFingerprint fingerprint() const noexcept { return fingerprint_; }
69 [[nodiscard]] Options getOptions() const noexcept { return options_; }
70 [[nodiscard]] Report getReport() const noexcept { return report_; }
71 [[nodiscard]] std::span<const Beat> beats() const noexcept
72 {
73 return isValid() ? std::span<const Beat>(beats_.get(), count_)
74 : std::span<const Beat>();
75 }
77 [[nodiscard]] double tempoAt(std::int64_t frame) const noexcept
78 {
79 const auto map = beats();
80 if (map.size() < 2 || frame < map.front().frame || frame >= map.back().frame)
81 return 0;
82 auto next = std::upper_bound(map.begin(), map.end(), frame,
83 [](std::int64_t p, const Beat &b) { return p < b.frame; });
84 const auto &beat = *(next - 1);
85 return beat.reliable ? beat.nextIntervalBpm : 0;
86 }
87 [[nodiscard]] std::size_t retainedBytes() const noexcept
88 {
89 return isValid() ? std::max(std::size_t(1), count_) * sizeof(Beat) : 0;
90 }
91
92 private:
94 OfflineAudioSpec spec_;
95 OfflineFingerprint fingerprint_;
96 Options options_;
97 Report report_;
98 std::unique_ptr<Beat[]> beats_;
99 std::size_t count_ = 0;
100 };
101 struct Result
102 {
105 std::size_t memoryBytes = 0;
106 [[nodiscard]] bool succeeded() const noexcept { return offlineSucceeded(status); }
107 };
108
110 [[nodiscard]] Result
111 analyze(const typename OfflineTransientAnalyzer<T>::Analysis &features, Options options = {},
112 const OfflineJobOptions &jobOptions = {}) const
113 {
114 Result result;
115 try
116 {
117 validate(options);
118 detail::OfflineSession job(jobOptions);
119 if (!features.isValid() || !features.getOptions().retainFeatures ||
120 !features.getOptions().pulses)
122 Analysis candidate;
123 candidate.spec_ = features.energy().getSpec();
124 candidate.fingerprint_ = features.energy().fingerprint();
125 candidate.options_ = options;
126 const auto frames = features.features();
127 const auto tracked = detail::OfflineBeatEngine<T>::analyze(
128 candidate.spec_.sampleRate, features.hopFrames(), frames.size(),
129 [&](std::size_t i) {
130 typename OnsetDetector<T>::OdfFrame frame;
131 frame.value = frames[i].attack;
132 frame.registers = frames[i].registers;
133 frame.referenceSample = features.featureCenter(i);
134 return frame;
135 },
136 options.minimumBpm, options.maximumBpm, 25, job);
137 candidate.report_.tempoBpm = static_cast<double>(tracked.tempoBpm);
138 candidate.report_.secondaryTempoBpm = static_cast<double>(tracked.secondaryTempoBpm);
139 candidate.report_.confidence = static_cast<double>(tracked.confidence);
140 for (const auto frame : tracked.beats())
141 if (frame >= 0 && frame < candidate.spec_.frames)
142 ++candidate.count_;
143 candidate.beats_ = job.template allocate<Beat>(std::max(std::size_t(1), candidate.count_));
144 std::size_t index = 0;
145 for (const auto frame : tracked.beats())
146 if (frame >= 0 && frame < candidate.spec_.frames)
147 candidate.beats_[index++].frame = frame;
148 for (std::size_t i = 0; i + 1 < candidate.count_; ++i)
149 {
150 auto &beat = candidate.beats_[i];
151 const auto next = candidate.beats_[i + 1].frame;
152 if (next <= beat.frame)
154 const double interval = static_cast<double>(next - beat.frame);
155 std::array<double, 3> local{interval, interval, interval};
156 if (i)
157 local[0] = static_cast<double>(beat.frame - candidate.beats_[i - 1].frame);
158 if (i + 2 < candidate.count_)
159 local[2] = static_cast<double>(candidate.beats_[i + 2].frame - next);
160 std::sort(local.begin(), local.end());
161 const double quarter = local[1];
162 beat.nextIntervalBpm = 60 * candidate.spec_.sampleRate / quarter;
163 const double tolerance = std::min(0.05 * candidate.spec_.sampleRate, 0.125 * interval);
164 // The search range constrains nominal metrical candidates,
165 // not individual intervals of the delivered expressive grid.
166 // Local timing must not spuriously lose confidence at a range
167 // endpoint, or be clamped to a tempo the grid did not produce.
168 beat.reliable = candidate.report_.confidence >= options.minimumConfidence &&
169 std::abs(interval - quarter) <= 2 * tolerance &&
170 anchored(features.pulses(), beat.frame, tolerance) &&
171 anchored(features.pulses(), next, tolerance);
172 candidate.report_.reliableIntervals += beat.reliable ? 1 : 0;
173 }
174 confirmLocalIntervals(features, candidate, job);
175 candidate.report_.reason =
176 features.energy().samplePeak() == 0 ? Reason::NoSignal
177 : candidate.count_ < 2 ? Reason::NoRhythm
178 : candidate.report_.reliableIntervals ? Reason::Tracked : Reason::Uncertain;
179 job.checkpoint(OfflinePhase::Analyze, candidate.spec_.frames, candidate.spec_.frames);
180 result.memoryBytes = job.bytes();
181 result.analysis = std::move(candidate);
182 result.status = OfflineStatus::Success;
183 }
184 catch (...)
185 {
186 result.status = detail::offlineExceptionStatus();
187 }
188 return result;
189 }
190
192 [[nodiscard]] Result analyze(OfflineAudioSource<T> &source, Options options = {},
193 const OfflineJobOptions &jobOptions = {}) const
194 {
195 Result result;
196 try
197 {
198 validate(options);
199 auto features = OfflineTransientAnalyzer<T>().analyze(source, {false, true, true},
200 jobOptions);
201 if (!features.succeeded())
202 detail::offlineFail(features.status);
203 result = analyze(features.analysis, options,
204 detail::offlineRemaining(jobOptions, features.memoryBytes));
205 if (!result.succeeded())
206 return result;
207 if (source.getSpec() != result.analysis.getSpec())
209 result.memoryBytes += features.memoryBytes;
210 }
211 catch (...)
212 {
213 result = Result{};
214 result.status = detail::offlineExceptionStatus();
215 }
216 return result;
217 }
218
219 private:
220 static void confirmLocalIntervals(
221 const typename OfflineTransientAnalyzer<T>::Analysis &features, Analysis &candidate,
222 detail::OfflineSession &job)
223 {
224 if (candidate.count_ < 2 || candidate.report_.confidence >= candidate.options_.minimumConfidence)
225 return;
226 const auto frames = features.features();
227 const auto window = static_cast<std::size_t>(std::ceil(
228 std::max(8., 240 / candidate.options_.minimumBpm) * candidate.spec_.sampleRate /
229 features.hopFrames()));
230 if (frames.size() <= window)
231 return;
232 detail::OfflineBeatMemory workspace(job);
233 for (std::size_t begin = 0;;)
234 {
235 const auto local = detail::OfflineBeatEngine<T>::analyze(
236 candidate.spec_.sampleRate, features.hopFrames(), window,
237 [&](std::size_t i) {
238 typename OnsetDetector<T>::OdfFrame frame;
239 frame.value = frames[begin + i].attack;
240 frame.registers = frames[begin + i].registers;
241 frame.referenceSample = features.featureCenter(begin + i);
242 return frame;
243 }, candidate.options_.minimumBpm, candidate.options_.maximumBpm, 25, job, &workspace);
244 ++candidate.report_.localWindows;
245 const auto grid = local.beats();
246 if (local.confidence >= candidate.options_.minimumConfidence && grid.size() >= 2)
247 {
248 const auto first = std::lower_bound(candidate.beats_.get(),
249 candidate.beats_.get() + candidate.count_, grid.front(),
250 [](const Beat &beat, auto p) { return beat.frame < p; });
251 auto index = static_cast<std::size_t>(first - candidate.beats_.get());
252 if (index)
253 --index;
254 for (; index + 1 < candidate.count_ && candidate.beats_[index].frame <= grid.back(); ++index)
255 {
256 auto &beat = candidate.beats_[index];
257 if (beat.reliable)
258 continue;
259 const auto end = candidate.beats_[index + 1].frame;
260 const double interval = static_cast<double>(end - beat.frame);
261 const double quarter = 60 * candidate.spec_.sampleRate / beat.nextIntervalBpm;
262 const double tolerance = std::min(.05 * candidate.spec_.sampleRate, .125 * interval);
263 if (std::abs(interval - quarter) > 2 * tolerance ||
264 !anchored(features.pulses(), beat.frame, tolerance) ||
265 !anchored(features.pulses(), end, tolerance))
266 continue;
267 auto position = std::lower_bound(grid.begin(), grid.end(), beat.frame);
268 if (position == grid.end() || (position != grid.begin() &&
269 beat.frame - *(position - 1) < *position - beat.frame))
270 --position;
271 if (position + 1 == grid.end() ||
272 std::abs(static_cast<double>(*position - beat.frame)) > tolerance ||
273 std::abs(static_cast<double>(*(position + 1) - end)) > tolerance)
274 continue;
275 beat.reliable = true;
276 ++candidate.report_.reliableIntervals;
277 ++candidate.report_.locallyConfirmedIntervals;
278 }
279 }
280 if (begin + window == frames.size())
281 break;
282 begin = std::min(begin + window / 2, frames.size() - window);
283 }
284 }
285 static void validate(Options options)
286 {
287 if (!std::isfinite(options.minimumBpm) || !std::isfinite(options.maximumBpm) ||
288 (options.minimumBpm < 20) || (options.maximumBpm > 480) ||
289 options.minimumBpm > options.maximumBpm || !std::isfinite(options.minimumConfidence) ||
290 (options.minimumConfidence < 0) || (options.minimumConfidence > 1))
292 }
293 [[nodiscard]] static bool
294 anchored(std::span<const typename OfflineTransientAnalyzer<T>::Event> pulses,
295 std::int64_t frame, double tolerance) noexcept
296 {
297 const auto next = std::lower_bound(pulses.begin(), pulses.end(), frame,
298 [](const auto &event, auto p) { return event.begin < p; });
299 return (next != pulses.end() && static_cast<double>(next->begin - frame) <= tolerance) ||
300 (next != pulses.begin() && static_cast<double>(frame - (next - 1)->begin) <= tolerance);
301 }
302};
303
304} // namespace dspark
305#endif
Rewindable, complete-file source with int64 positions and bounded blocks.
virtual OfflineAudioSpec getSpec() const noexcept=0
Returns format and provenance by value.
double tempoAt(std::int64_t frame) const noexcept
Reliable local tempo, or zero outside an anchored interval.
OfflineAudioSpec getSpec() const noexcept
std::span< const Beat > beats() const noexcept
Analysis(Analysis &&) noexcept=default
OfflineFingerprint fingerprint() const noexcept
std::size_t retainedBytes() const noexcept
Complete-source tempo map for automatic offline processors.
Result analyze(OfflineAudioSource< T > &source, Options options={}, const OfflineJobOptions &jobOptions={}) const
Builds shared features then tempo; two bounded source scans, no retained PCM.
Result analyze(const typename OfflineTransientAnalyzer< T >::Analysis &features, Options options={}, const OfflineJobOptions &jobOptions={}) const
Reuses immutable features; retainFeatures and pulses must be enabled.
static constexpr std::uint32_t algorithmRevision
Read-only analysis with source provenance; no owned audio samples. Threading: energy() is an owner-th...
const OfflineEnergyAnalyzer< T >::Analysis & energy() const noexcept
Borrows the energy map as an owner-thread reference view. Valid while this analysis remains alive and...
std::span< const FeatureFrame > features() const noexcept
std::span< const Event > pulses() const noexcept
void offlineFail(OfflineStatus status)
OfflineMemoryPool OfflineBeatMemory
OfflineJobOptions offlineRemaining(const OfflineJobOptions &job, std::size_t used)
OfflineStatus offlineExceptionStatus() noexcept
constexpr int interval(const std::array< int, 7 > &deg, int degIdx, int skip) noexcept
Calculate semitone interval skipping 'skip' degrees in the scale.
Main namespace for the DSPark framework.
bool offlineSucceeded(OfflineStatus status) noexcept
True for successful processing, including an explicit identity result.
OfflineStatus
Explicit completion or no-publication outcome of an offline job.
Immutable source format and host-provided content/timeline identity.
Noncryptographic PCM fingerprint, stable across block divisions.
Resource and cooperative-cancellation controls for one worker operation.
double nextIntervalBpm
Three-interval median tempo; zero on the last point.
std::int64_t frame
Source-relative grid position, not timelineOrigin + frame.
bool reliable
Global/local confidence, consistent interval and pulse anchors.
double minimumConfidence
Explicit ambiguity gate in [0,1].
double maximumBpm
Search range within 20..480 BPM.
static Result analyze(double sampleRate, int hop, std::size_t count, FrameAt frameAt, double minimumBpm, double maximumBpm, double tightness, OfflineSession &job, std::pmr::memory_resource *workspace=nullptr)