DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
OfflineBeatCompressor.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
26#include "../Analysis/OfflineTempoAnalyzer.h"
27#if DSPARK_HAS_OFFLINE
28#include "../Core/detail/OfflineAttenuation.h"
29#include "../Core/detail/OfflineGain.h"
30#include <algorithm>
31#include <cmath>
32#include <cstddef>
33#include <cstdint>
34#include <limits>
35#include <memory>
36#include <span>
37#include <utility>
38
39namespace dspark
40{
42template <FloatType T> class OfflineBeatCompressor final
43{
44 public:
45 enum class Note : std::uint8_t { Eighth, Quarter, Half, Whole };
46 enum class TempoSource : std::uint8_t { Local, Manual, Fallback };
47 struct Options
48 {
49 double reductionDb = 0;
51 double manualBpm = 0;
53 std::span<const OfflineRegion> exclusions{};
54 };
55 struct Pulse
56 {
57 std::int64_t begin = 0, end = 0;
58 double peakDb = -std::numeric_limits<double>::infinity();
59 double reductionDb = 0, releaseMs = 250;
61 };
62 enum class Reason : std::uint8_t
63 {
65 };
66 struct Report
67 {
70 double referencePeakDb = -std::numeric_limits<double>::infinity();
71 double tempoBpm = 0, tempoConfidence = 0;
72 double inputSamplePeakDb = -std::numeric_limits<double>::infinity();
73 double outputSamplePeakDb = -std::numeric_limits<double>::infinity();
74 double outputTruePeakDb = -std::numeric_limits<double>::infinity();
75 std::size_t pulses = 0, reducedPulses = 0, fallbackPulses = 0;
76 bool peaksMeasured = false;
78 };
79 class Plan final
80 {
81 public:
82 Plan() = default;
83 Plan(Plan &&) noexcept = default;
84 Plan &operator=(Plan &&) noexcept = default;
85 [[nodiscard]] bool isValid() const noexcept { return curve_.isValid(); }
86 [[nodiscard]] OfflineAudioSpec getSpec() const noexcept { return spec_; }
87 [[nodiscard]] Report getReport() const noexcept { return report_; }
88 [[nodiscard]] std::span<const Pulse> pulses() const noexcept
89 {
90 return isValid() ? std::span<const Pulse>(pulses_.get(), report_.pulses)
91 : std::span<const Pulse>();
92 }
93 [[nodiscard]] std::span<const OfflineRegion> exclusions() const noexcept
94 {
95 return isValid() ? exclusions_.view() : std::span<const OfflineRegion>();
96 }
97 [[nodiscard]] std::size_t retainedBytes() const noexcept
98 {
99 return isValid() ? curve_.retainedBytes() + exclusions_.retainedBytes() +
100 report_.pulses * sizeof(Pulse) : 0;
101 }
102 [[nodiscard]] double gainAt(std::int64_t frame) const noexcept
103 {
104 return !isValid() || frame < 0 || frame >= spec_.frames
105 ? 1 : exclusions_.apply(frame, curve_.gainAt(frame), featherFrames_);
106 }
107 private:
109 OfflineAudioSpec spec_;
110 OfflineFingerprint fingerprint_;
111 Report report_;
112 double inputPeak_ = 0, featherFrames_ = 1;
114 detail::OfflineExclusions exclusions_;
115 std::unique_ptr<Pulse[]> pulses_;
116 };
118 {
121 std::size_t memoryBytes = 0;
122 [[nodiscard]] bool succeeded() const noexcept { return offlineSucceeded(status); }
123 };
124 struct Result
125 {
128 std::size_t memoryBytes = 0;
129 [[nodiscard]] bool succeeded() const noexcept { return offlineSucceeded(status); }
130 };
131
137 [[nodiscard]] PlanResult makePlan(
138 OfflineAudioSource<T> &source,
139 const typename OfflineTransientAnalyzer<T>::Analysis &analysis,
140 const typename OfflineTempoAnalyzer<T>::Analysis &tempo, const Options &options = {},
141 const OfflineJobOptions &jobOptions = {}) const
142 {
143 PlanResult result;
144 try
145 {
146 validate(options);
147 if (!analysis.isValid() || (options.reductionDb > 0 && !analysis.getOptions().pulses) ||
148 (options.reductionDb > 0 && options.manualBpm == 0 && !tempo.isValid()))
150 detail::OfflineSession job(jobOptions);
151 Plan plan;
152 plan.spec_ = analysis.energy().getSpec();
153 plan.fingerprint_ = analysis.energy().fingerprint();
154 if (source.getSpec() != plan.spec_ ||
155 (tempo.isValid() && (tempo.getSpec() != plan.spec_ ||
156 tempo.fingerprint() != plan.fingerprint_)))
158 plan.inputPeak_ = analysis.energy().samplePeak();
159 const auto attack = std::max(std::int64_t(1),
160 static_cast<std::int64_t>(std::llround(.002 * plan.spec_.sampleRate)));
161 const auto preroll = static_cast<std::int64_t>(std::llround(.004 * plan.spec_.sampleRate));
162 plan.featherFrames_ = static_cast<double>(attack + preroll);
163 auto &report = plan.report_;
164 report.requestedReductionDb = options.reductionDb;
165 report.inputSamplePeakDb = gainToDecibels(plan.inputPeak_, silence);
166 report.tempoBpm = options.manualBpm > 0 ? options.manualBpm : tempo.getReport().tempoBpm;
167 report.tempoConfidence = tempo.isValid() ? tempo.getReport().confidence : 0;
168 job.checkpoint(OfflinePhase::Plan, 0, plan.spec_.frames);
169 plan.exclusions_.assign(job, options.exclusions, plan.spec_.frames);
170 const auto events = analysis.pulses();
171 const bool excluded = plan.exclusions_.covers(plan.spec_.frames);
172 const bool active = options.reductionDb > 0 && plan.inputPeak_ > 0 &&
173 !events.empty() && !excluded;
174 plan.curve_.prepare(job, active ? events.size() : 0, attack,
175 .25 * plan.spec_.sampleRate, active);
176 if (!active)
177 report.reason = excluded ? Reason::AllExcluded
178 : options.reductionDb == 0 ? Reason::ZeroAmount
179 : plan.inputPeak_ == 0 ? Reason::NoSignal : Reason::NoPulses;
180 else
181 {
182 report.pulses = events.size();
183 plan.pulses_ = job.template allocate<Pulse>(events.size());
184 auto peaks = job.template allocate<double>(events.size());
185 detail::OfflineBlock<T> scratch(job, plan.spec_, jobOptions.blockFrames);
186 const auto window = std::max(std::int64_t(1),
187 static_cast<std::int64_t>(std::llround(.030 * plan.spec_.sampleRate)));
188 std::size_t first = 0, next = 0;
189 detail::offlineScanMagnitude(source, plan.spec_, plan.fingerprint_, plan.inputPeak_,
190 scratch, job, [&](std::int64_t frame, double magnitude) {
191 while (next < events.size() && events[next].begin <= frame)
192 ++next;
193 while (first < next && frame - events[first].begin >= window)
194 ++first;
195 for (auto i = first; i < next; ++i)
196 peaks[i] = std::max(peaks[i], magnitude);
197 });
198 for (std::size_t i = 0; i < events.size(); ++i)
199 plan.pulses_[i].peakDb = gainToDecibels(peaks[i], silence);
200 std::nth_element(peaks.get(), peaks.get() + events.size() / 2,
201 peaks.get() + events.size());
202 report.referencePeakDb = gainToDecibels(peaks[events.size() / 2], silence);
203 for (std::size_t i = 0; i < events.size(); ++i)
204 {
205 if ((i & 1023) == 0)
206 job.checkpoint(OfflinePhase::Plan, 0, plan.spec_.frames);
207 auto &pulse = plan.pulses_[i];
208 pulse.begin = events[i].begin;
209 pulse.end = events[i].end;
210 const double excess = pulse.peakDb > report.referencePeakDb
211 ? pulse.peakDb - report.referencePeakDb : 0;
212 pulse.reductionDb = std::min(options.reductionDb, excess);
213 double bpm = options.manualBpm;
214 if (bpm > 0)
215 pulse.tempoSource = TempoSource::Manual;
216 else
217 {
218 bpm = pulseTempo(tempo, pulse.begin);
219 if (bpm > 0)
220 pulse.tempoSource = TempoSource::Local;
221 }
222 pulse.releaseMs = bpm > 0 ? noteBeats(options.releaseNote) * 60000 / bpm : 250;
223 report.fallbackPulses += bpm > 0 ? 0 : 1;
224 report.reducedPulses += pulse.reductionDb > 0 ? 1 : 0;
225 report.maximumExcessDb = std::max(report.maximumExcessDb, excess);
226 report.maximumReductionDb = std::max(report.maximumReductionDb, pulse.reductionDb);
227 plan.curve_.set(i, std::max(std::int64_t(0), pulse.begin - preroll), pulse.end,
228 decibelsToGain(-pulse.reductionDb),
229 pulse.releaseMs * .001 * plan.spec_.sampleRate);
230 }
231 report.reason = report.reducedPulses ? Reason::Compressed : Reason::EvenPulses;
232 }
233 job.checkpoint(OfflinePhase::Plan, plan.spec_.frames, plan.spec_.frames);
234 result.status = report.maximumReductionDb > 0 ? OfflineStatus::Success : OfflineStatus::NoChange;
235 result.memoryBytes = job.bytes();
236 result.plan = std::move(plan);
237 }
238 catch (...) { result.status = detail::offlineExceptionStatus(); }
239 return result;
240 }
241
243 [[nodiscard]] PlanResult makePlan(
244 OfflineAudioSource<T> &source,
245 const typename OfflineTransientAnalyzer<T>::Analysis &analysis, const Options &options = {},
246 const OfflineJobOptions &job = {}) const
247 {
248 PlanResult result;
249 try
250 {
251 validate(options);
252 if (options.manualBpm > 0 || options.reductionDb == 0)
253 return makePlan(source, analysis, {}, options, job);
254 auto tempo = OfflineTempoAnalyzer<T>().analyze(analysis, options.tempo, job);
255 if (!tempo.succeeded())
256 detail::offlineFail(tempo.status);
257 result = makePlan(source, analysis, tempo.analysis, options,
258 detail::offlineRemaining(job, tempo.memoryBytes));
259 result.memoryBytes += tempo.memoryBytes;
260 }
261 catch (...) { result.status = detail::offlineExceptionStatus(); }
262 return result;
263 }
264
266 [[nodiscard]] PlanResult analyze(OfflineAudioSource<T> &source, const Options &options = {},
267 const OfflineJobOptions &job = {}) const
268 {
269 PlanResult result;
270 try
271 {
272 validate(options);
273 auto analyzed = OfflineTransientAnalyzer<T>().analyze(
274 source, {false, options.reductionDb > 0,
275 options.reductionDb > 0 && options.manualBpm == 0}, job);
276 if (!analyzed.succeeded())
277 detail::offlineFail(analyzed.status);
278 result = makePlan(source, analyzed.analysis, options,
279 detail::offlineRemaining(job, analyzed.memoryBytes));
280 result.memoryBytes += analyzed.memoryBytes;
281 }
282 catch (...) { result.status = detail::offlineExceptionStatus(); }
283 return result;
284 }
285
287 [[nodiscard]] Result render(OfflineAudioSource<T> &source, const Plan &plan,
288 OfflineAudioSink<T> &sink, const OfflineJobOptions &job = {}) const
289 {
290 detail::OfflineGainRenderSettings settings{&plan.exclusions_, plan.featherFrames_, false};
291 if (plan.isValid() && !plan.pulses().empty())
292 {
293 const auto window = std::max(std::int64_t(1),
294 static_cast<std::int64_t>(std::llround(.030 * plan.spec_.sampleRate)));
295 const std::array<const Pulse *, 2> edge{&plan.pulses().front(), &plan.pulses().back()};
296 for (int side = 0; side < 2; ++side)
297 {
298 const auto &pulse = *edge[side];
299 const bool touches = side == 0
300 ? pulse.begin <= plan.featherFrames_ : pulse.end == plan.spec_.frames;
301 if (pulse.reductionDb > 0 && touches)
302 {
303 const auto end = pulse.begin + std::min(window, plan.spec_.frames - pulse.begin);
304 settings.boundaryTargets[side] = {
305 {pulse.begin, end}, decibelsToGain(pulse.peakDb -
306 plan.report_.inputSamplePeakDb - pulse.reductionDb)};
307 }
308 }
309 }
310 return detail::offlineRenderGain<Result>(
311 source, plan.spec_, plan.fingerprint_, plan.inputPeak_, plan.isValid(),
312 plan.report_.maximumReductionDb > 0, plan.report_, sink, job,
313 [&plan](detail::OfflineSession &session) {
314 return [&plan, cursor = detail::OfflineAttenuation::Cursor(plan.curve_, session)]
315 (std::int64_t frame) mutable {
316 return plan.exclusions_.apply(frame, cursor(frame), plan.featherFrames_);
317 };
318 }, settings);
319 }
321 template <int MaxChannels>
322 [[nodiscard]] Result run(const AudioBuffer<T, MaxChannels> &input,
323 AudioBuffer<T, MaxChannels> &output, double sampleRate,
324 const Options &options = {}, const OfflineJobOptions &job = {}) const
325 {
326 return detail::offlineRun(*this, input, output, sampleRate, options, job);
327 }
328 private:
329 static constexpr double silence = -std::numeric_limits<double>::infinity();
330 static double noteBeats(Note note) noexcept
331 {
332 switch (note)
333 {
334 case Note::Eighth: return .5;
335 case Note::Quarter: return 1;
336 case Note::Half: return 2;
337 case Note::Whole: return 4;
338 }
339 return 0;
340 }
341 static void validate(const Options &options)
342 {
343 if (!std::isfinite(options.reductionDb) || (options.reductionDb < 0) || (options.reductionDb > 18) ||
344 !std::isfinite(options.manualBpm) || (options.manualBpm != 0 &&
345 ((options.manualBpm < 20) || (options.manualBpm > 480))) || noteBeats(options.releaseNote) == 0 ||
346 !std::isfinite(options.tempo.minimumBpm) || !std::isfinite(options.tempo.maximumBpm) ||
347 (options.tempo.minimumBpm < 20) || (options.tempo.maximumBpm > 480) ||
348 options.tempo.minimumBpm > options.tempo.maximumBpm ||
349 !std::isfinite(options.tempo.minimumConfidence) || (options.tempo.minimumConfidence < 0) ||
350 (options.tempo.minimumConfidence > 1))
352 }
353 static double pulseTempo(const typename OfflineTempoAnalyzer<T>::Analysis &tempo,
354 std::int64_t frame) noexcept
355 {
356 const double local = tempo.tempoAt(frame);
357 if (local > 0)
358 return local;
359 const auto beats = tempo.beats();
360 if (beats.size() < 2)
361 return 0;
362 const auto next = std::lower_bound(beats.begin(), beats.end(), frame,
363 [](const auto &beat, auto p) { return beat.frame < p; });
364 const double tolerance = .05 * tempo.getSpec().sampleRate;
365 const auto withinTolerance = [&](auto beat) {
366 return std::abs(static_cast<double>(beat->frame - frame)) <= tolerance;
367 };
368 if (next != beats.end() && withinTolerance(next) && next->reliable)
369 return next->nextIntervalBpm;
370 if (next != beats.begin())
371 {
372 const auto previous = next - 1;
373 if (previous != beats.begin() && withinTolerance(previous) && (previous - 1)->reliable)
374 return (previous - 1)->nextIntervalBpm;
375 if (next != beats.end() && withinTolerance(next) && previous->reliable)
376 return previous->nextIntervalBpm;
377 }
378 // A boundary pulse may precede the first or follow the last grid point
379 // by one measured quarter note. Never extrapolate through an interior gap.
380 const auto &edge = frame < beats.front().frame ? beats.front() : beats[beats.size() - 2];
381 const auto boundary = frame < beats.front().frame ? beats.front().frame : beats.back().frame;
382 if ((frame < beats.front().frame || frame >= beats.back().frame) && edge.reliable)
383 {
384 const double distance = std::abs(static_cast<double>(frame - boundary));
385 const double period = 60 * tempo.getSpec().sampleRate / edge.nextIntervalBpm;
386 if (distance <= tolerance || std::abs(distance - period) <= tolerance)
387 return edge.nextIntervalBpm;
388 }
389 return 0;
390 }
391};
392} // namespace dspark
393#endif // DSPARK_HAS_OFFLINE
Owning audio buffer with contiguous, 32-byte aligned storage.
Transactional worker sink for arbitrarily long offline output.
Rewindable, complete-file source with int64 positions and bounded blocks.
virtual OfflineAudioSpec getSpec() const noexcept=0
Returns format and provenance by value.
std::span< const Pulse > pulses() const noexcept
std::span< const OfflineRegion > exclusions() const noexcept
OfflineAudioSpec getSpec() const noexcept
Plan(Plan &&) noexcept=default
double gainAt(std::int64_t frame) const noexcept
std::size_t retainedBytes() const noexcept
Complete-source pulse compression with one positive reduction-dB control.
Result run(const AudioBuffer< T, MaxChannels > &input, AudioBuffer< T, MaxChannels > &output, double sampleRate, const Options &options={}, const OfflineJobOptions &job={}) const
Owning convenience operation; aliases are safe and failure preserves output.
PlanResult makePlan(OfflineAudioSource< T > &source, const typename OfflineTransientAnalyzer< T >::Analysis &analysis, const typename OfflineTempoAnalyzer< T >::Analysis &tempo, const Options &options={}, const OfflineJobOptions &jobOptions={}) const
Reuses source-bound pulse and tempo maps; one verified PCM scan. A valid tempo map must match the sou...
PlanResult makePlan(OfflineAudioSource< T > &source, const typename OfflineTransientAnalyzer< T >::Analysis &analysis, const Options &options={}, const OfflineJobOptions &job={}) const
Reuses retained features to build tempo; manual BPM skips the tempo engine.
Result render(OfflineAudioSource< T > &source, const Plan &plan, OfflineAudioSink< T > &sink, const OfflineJobOptions &job={}) const
Verified transactional render through the shared bandlimited gain operator.
PlanResult analyze(OfflineAudioSource< T > &source, const Options &options={}, const OfflineJobOptions &job={}) const
Complete-source automatic analysis and planning, with no retained audio.
OfflineAudioSpec getSpec() const noexcept
OfflineFingerprint fingerprint() const noexcept
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 Event > pulses() const noexcept
std::size_t retainedBytes() const noexcept
double gainAt(std::int64_t frame) const noexcept
std::size_t retainedBytes() const noexcept
std::span< const OfflineRegion > view() const noexcept
double apply(std::int64_t frame, double gain, double featherFrames) const noexcept
void offlineFail(OfflineStatus status)
Processor::Result offlineRun(const Processor &processor, const AudioBuffer< T, MaxChannels > &input, AudioBuffer< T, MaxChannels > &output, double sampleRate, const Options &options, const OfflineJobOptions &job)
void offlineScanMagnitude(OfflineAudioSource< T > &source, const OfflineAudioSpec &spec, const OfflineFingerprint &expectedFingerprint, double inputPeak, OfflineBlock< T > &scratch, const OfflineSession &job, Consume consume)
OfflineJobOptions offlineRemaining(const OfflineJobOptions &job, std::size_t used)
OfflineStatus offlineExceptionStatus() noexcept
Main namespace for the DSPark framework.
bool offlineSucceeded(OfflineStatus status) noexcept
True for successful processing, including an explicit identity result.
T decibelsToGain(T dB, T minusInfinityDb=T(-100)) noexcept
Converts a value in decibels to linear gain.
Definition DspMath.h:74
OfflineStatus
Explicit completion or no-publication outcome of an offline job.
T gainToDecibels(T gain, T minusInfinityDb=T(-100)) noexcept
Converts a linear gain value to decibels.
Definition DspMath.h:89
Immutable source format and host-provided content/timeline identity.
OfflineTempoAnalyzer< T >::Options tempo
Used when constructing a tempo map.
double manualBpm
Zero selects analysis; an override accepts 20..480 BPM.
std::span< const OfflineRegion > exclusions
double reductionDb
Maximum event attenuation, 0..18 dB.
std::int64_t end
Original source event, before anticipation.
Noncryptographic PCM fingerprint, stable across block divisions.
Measured-render control adjustments; no clipping or master gain trim.
Resource and cooperative-cancellation controls for one worker operation.