DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
OfflinePeakCompressor.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
31#include "../Analysis/OfflineTransientAnalyzer.h"
32#if DSPARK_HAS_OFFLINE
33#include "../Analysis/EnvelopeFollower.h"
34#include "../Core/detail/OfflineAttenuation.h"
35#include "../Core/detail/OfflineGain.h"
36#include <algorithm>
37#include <cmath>
38#include <cstddef>
39#include <cstdint>
40#include <limits>
41#include <span>
42#include <utility>
43
44namespace dspark
45{
46
48template <FloatType T> class OfflinePeakCompressor final
49{
50 public:
51 struct Options
52 {
53 double reductionDb = 0;
54 double lookaheadMs = 2;
55 double releaseMs = 80;
56 std::span<const OfflineRegion> exclusions{};
57 };
58 enum class Reason : std::uint8_t
59 {
68 };
69 struct Report
70 {
74 double maximumReductionDb = 0;
75 double lookaheadMs = 0;
76 double releaseMs = 0;
79 double inputSamplePeakDb = -std::numeric_limits<double>::infinity();
80 double outputSamplePeakDb = -std::numeric_limits<double>::infinity();
81 double outputTruePeakDb = -std::numeric_limits<double>::infinity();
82 std::size_t transientHolds = 0, additionalHolds = 0;
83 bool peaksMeasured = false;
85 };
86
88 class Plan final
89 {
90 public:
91 Plan() = default;
92 Plan(Plan &&) noexcept = default;
93 Plan &operator=(Plan &&) noexcept = default;
94 [[nodiscard]] bool isValid() const noexcept
95 {
96 return curve_.isValid();
97 }
98 [[nodiscard]] OfflineAudioSpec getSpec() const noexcept
99 {
100 return spec_;
101 }
102 [[nodiscard]] Report getReport() const noexcept
103 {
104 return report_;
105 }
106 [[nodiscard]] std::size_t retainedBytes() const noexcept
107 {
108 return isValid() ? curve_.retainedBytes() + exclusions_.retainedBytes() : 0;
109 }
111 [[nodiscard]] std::span<const OfflineRegion> exclusions() const noexcept
112 {
113 return isValid() ? exclusions_.view() : std::span<const OfflineRegion>();
114 }
115 [[nodiscard]] double gainAt(std::int64_t frame) const noexcept
116 {
117 if (!isValid() || frame < 0 || frame >= spec_.frames)
118 return 1;
119 return exclusions_.apply(frame, curve_.gainAt(frame),
120 static_cast<double>(attackFrames_));
121 }
122
123 private:
125 OfflineAudioSpec spec_;
126 OfflineFingerprint fingerprint_;
127 Report report_;
128 double inputPeak_ = 0;
129 std::int64_t attackFrames_ = 1;
131 detail::OfflineExclusions exclusions_;
132 };
134 {
137 std::size_t memoryBytes = 0;
138 [[nodiscard]] bool succeeded() const noexcept
139 {
140 return offlineSucceeded(status);
141 }
142 };
143 struct Result
144 {
147 std::size_t memoryBytes = 0;
148 [[nodiscard]] bool succeeded() const noexcept
149 {
150 return offlineSucceeded(status);
151 }
152 };
153
160 [[nodiscard]] PlanResult makePlan(
161 OfflineAudioSource<T> &source,
162 const typename OfflineTransientAnalyzer<T>::Analysis &analysis, const Options &options = {},
163 const OfflineJobOptions &jobOptions = {}) const
164 {
165 PlanResult result;
166 try
167 {
168 validateOptions(options);
169 if (!analysis.isValid() || (options.reductionDb > 0 && !analysis.getOptions().attacks))
171 detail::OfflineSession job(jobOptions);
172 Plan plan;
173 plan.spec_ = analysis.energy().getSpec();
174 if (source.getSpec() != plan.spec_)
176 plan.fingerprint_ = analysis.energy().fingerprint();
177 plan.inputPeak_ = analysis.energy().samplePeak();
178 plan.attackFrames_ =
179 std::max(std::int64_t(1), static_cast<std::int64_t>(std::llround(
180 plan.spec_.sampleRate * options.lookaheadMs * .001)));
181 auto &report = plan.report_;
182 report.requestedReductionDb = options.reductionDb;
183 report.requestedLookaheadMs = options.lookaheadMs;
184 report.lookaheadMs =
185 1000 * static_cast<double>(plan.attackFrames_) / plan.spec_.sampleRate;
186 report.releaseMs = options.releaseMs;
187 report.inputSamplePeakDb =
188 gainToDecibels(plan.inputPeak_, -std::numeric_limits<double>::infinity());
189 job.checkpoint(OfflinePhase::Plan, 0, plan.spec_.frames);
190 plan.exclusions_.assign(job, options.exclusions, plan.spec_.frames);
191 const double releaseFrames = options.releaseMs * .001 * plan.spec_.sampleRate;
192 const bool allExcluded = plan.exclusions_.covers(plan.spec_.frames);
193 if (options.reductionDb == 0 || plan.inputPeak_ == 0 || allExcluded)
194 {
195 report.reason = allExcluded ? Reason::AllExcluded
196 : options.reductionDb == 0 ? Reason::ZeroAmount
198 plan.curve_.prepare(job, 0, plan.attackFrames_, releaseFrames);
199 }
200 else
201 {
202 detail::OfflineBlock<T> scratch(job, plan.spec_, jobOptions.blockFrames);
203 EnvelopeFollower<double, 1> sustain;
204 sustain.prepare({plan.spec_.sampleRate, 1, 1});
205 sustain.setAttack(60);
206 sustain.setRelease(300);
207 double loudestSustain = 0;
208 scan(source, plan, scratch, job, [&](std::int64_t, double magnitude) {
209 loudestSustain = std::max(loudestSustain, sustain.processSample(magnitude));
210 });
211 report.availableHeadroomDb =
212 loudestSustain > 0 ? std::max(0.0, -gainToDecibels(loudestSustain)) : 0;
213 report.effectiveReductionDb =
214 std::min(options.reductionDb, report.availableHeadroomDb);
215 const bool crestLimited = report.availableHeadroomDb < options.reductionDb;
216 const double ceiling = detail::offlineRepresentablePeakGain<T>(
217 plan.inputPeak_, decibelsToGain(-report.effectiveReductionDb));
218 const double representableReduction = -gainToDecibels(ceiling);
219 const bool resolutionLimited =
220 report.effectiveReductionDb - representableReduction > .001;
221 report.effectiveReductionDb = std::max(0.0, representableReduction);
222 if (report.effectiveReductionDb < .001)
223 {
224 report.effectiveReductionDb = 0;
225 report.reason =
227 plan.curve_.prepare(job, 0, plan.attackFrames_, releaseFrames);
228 }
229 else
230 {
231 std::size_t count = 0;
232 const auto countHold = [&](std::int64_t, std::int64_t, double, bool) {
233 if (count == std::numeric_limits<std::size_t>::max())
235 ++count;
236 };
237 buildHolds(source, plan, analysis.attacks(), ceiling, scratch, job, countHold);
238 plan.curve_.prepare(job, count, plan.attackFrames_, releaseFrames);
239 std::size_t index = 0;
240 const auto addHold = [&](std::int64_t begin, std::int64_t end, double peak,
241 bool mapped) {
242 const double gain = ceiling / peak;
243 plan.curve_.set(index++, begin, end, gain);
244 report.maximumReductionDb =
245 std::max(report.maximumReductionDb, -gainToDecibels(gain));
246 if (mapped)
247 ++report.transientHolds;
248 else
249 ++report.additionalHolds;
250 };
251 buildHolds(source, plan, analysis.attacks(), ceiling, scratch, job, addHold);
252 if (index != count)
254 report.reason = resolutionLimited ? Reason::ResolutionLimited
255 : crestLimited ? Reason::CrestLimited
257 }
258 }
259 job.checkpoint(OfflinePhase::Plan, plan.spec_.frames, plan.spec_.frames);
260 result.status =
261 report.maximumReductionDb == 0 ? OfflineStatus::NoChange : OfflineStatus::Success;
262 result.memoryBytes = job.bytes();
263 result.plan = std::move(plan);
264 }
265 catch (...)
266 {
267 result.status = detail::offlineExceptionStatus();
268 }
269 return result;
270 }
271
273 [[nodiscard]] PlanResult analyze(OfflineAudioSource<T> &source, const Options &options = {},
274 const OfflineJobOptions &job = {}) const
275 {
276 PlanResult result;
277 try
278 {
279 validateOptions(options);
280 typename OfflineTransientAnalyzer<T>::Options features;
281 features.attacks = options.reductionDb > 0;
282 features.pulses = false;
283 auto analyzed = OfflineTransientAnalyzer<T>().analyze(source, features, job);
284 if (!analyzed.succeeded())
285 {
286 result.status = analyzed.status;
287 return result;
288 }
289 result = makePlan(source, analyzed.analysis, options,
290 detail::offlineRemaining(job, analyzed.analysis.retainedBytes()));
291 result.memoryBytes = std::max(analyzed.memoryBytes,
292 result.memoryBytes + analyzed.analysis.retainedBytes());
293 }
294 catch (...)
295 {
296 result.status = detail::offlineExceptionStatus();
297 }
298 return result;
299 }
300
302 [[nodiscard]] Result render(OfflineAudioSource<T> &source, const Plan &plan,
303 OfflineAudioSink<T> &sink, const OfflineJobOptions &job = {}) const
304 {
305 const auto attempt = [&](const detail::OfflineAttenuation &curve,
306 std::int64_t attackFrames, const OfflineJobOptions &options) {
307 auto report = plan.report_;
308 report.lookaheadMs = 1000 * static_cast<double>(attackFrames) / plan.spec_.sampleRate;
309 return detail::offlineRenderGain<Result>(
310 source, plan.spec_, plan.fingerprint_, plan.inputPeak_, plan.isValid(),
311 plan.report_.maximumReductionDb != 0, report, sink, options,
312 [&plan, &curve](detail::OfflineSession &session) {
313 return [&plan, cursor = detail::OfflineAttenuation::Cursor(curve, session)](
314 std::int64_t frame) mutable {
315 return plan.exclusions_.apply(frame, cursor(frame),
316 static_cast<double>(plan.attackFrames_));
317 };
318 },
319 {&plan.exclusions_, static_cast<double>(plan.attackFrames_), true});
320 };
321 auto result = attempt(plan.curve_, plan.attackFrames_, job);
322 int attempts = 1;
323 auto memoryBytes = result.memoryBytes;
324 // A fixed processing-delta scale can be infeasible: increasing it can
325 // reduce the transient but deepen an earlier negative ringing peak.
326 // Extend only the source-aligned attack, keeping the holds and release.
327 // Failed target attempts have not begun the sink transaction.
328 if (plan.isValid() && !result.report.renderInfo.targetFeasible)
329 {
330 const auto maximum =
331 static_cast<std::int64_t>(std::llround(.010 * plan.spec_.sampleRate));
332 auto attack = plan.attackFrames_;
333 try
334 {
335 while (attack < maximum && !result.report.renderInfo.targetFeasible)
336 {
337 attack = std::min(maximum, attack * 2);
338 detail::OfflineSession session(job);
339 auto curve = plan.curve_.withAttack(session, attack);
340 result = attempt(curve, attack, detail::offlineRemaining(job, session.bytes()));
341 memoryBytes = std::max(memoryBytes, result.memoryBytes + session.bytes());
342 ++attempts;
343 }
344 }
345 catch (...)
346 {
347 result.status = detail::offlineExceptionStatus();
348 }
349 }
350 result.memoryBytes = memoryBytes;
351 result.report.renderAttempts = attempts;
352 if (result.succeeded() && plan.inputPeak_ > 0)
353 {
354 result.report.achievedReductionDb =
355 std::max(0.0, result.report.inputSamplePeakDb - result.report.outputSamplePeakDb);
356 if (!plan.exclusions_.view().empty() &&
357 result.report.achievedReductionDb + .0001 < result.report.effectiveReductionDb)
358 result.report.reason = Reason::ProtectedPeaks;
359 }
360 return result;
361 }
362
364 template <int MaxChannels>
365 [[nodiscard]] Result run(const AudioBuffer<T, MaxChannels> &input,
366 AudioBuffer<T, MaxChannels> &output, double sampleRate,
367 const Options &options = {}, const OfflineJobOptions &job = {}) const
368 {
369 return detail::offlineRun(*this, input, output, sampleRate, options, job);
370 }
371
372 private:
373 static void validateOptions(const Options &options)
374 {
375 if (!std::isfinite(options.reductionDb) || (options.reductionDb < 0) ||
376 (options.reductionDb > 18) || !std::isfinite(options.lookaheadMs) ||
377 (options.lookaheadMs < .1) || (options.lookaheadMs > 10) ||
378 !std::isfinite(options.releaseMs) || (options.releaseMs < 60) ||
379 (options.releaseMs > 100))
381 }
382 template <class Consume>
383 static void scan(OfflineAudioSource<T> &source, const Plan &plan,
384 detail::OfflineBlock<T> &scratch, const detail::OfflineSession &job,
385 Consume consume)
386 {
387 detail::offlineScanMagnitude(source, plan.spec_, plan.fingerprint_, plan.inputPeak_,
388 scratch, job, [&](std::int64_t frame, double magnitude) {
389 consume(frame, magnitude / plan.inputPeak_);
390 });
391 }
392 template <class Emit>
393 static void buildHolds(OfflineAudioSource<T> &source, const Plan &plan,
394 std::span<const typename OfflineTransientAnalyzer<T>::Event> events,
395 double ceiling, detail::OfflineBlock<T> &scratch,
396 const detail::OfflineSession &job, Emit emit)
397 {
398 std::size_t event = 0;
399 double mappedPeak = 0, extraPeak = 0;
400 std::int64_t extraBegin = 0, extraEnd = 0;
401 // A quiet gap separates otherwise undetected excursions. This avoids
402 // releasing on individual periods of a stationary low-frequency tone.
403 const auto gap = static_cast<std::int64_t>(std::ceil(plan.spec_.sampleRate * .03));
404 const auto flushExtra = [&] {
405 if (extraPeak > 0)
406 emit(extraBegin, extraEnd, extraPeak, false);
407 extraPeak = 0;
408 };
409 scan(source, plan, scratch, job, [&](std::int64_t frame, double magnitude) {
410 while (event < events.size() && frame >= events[event].end)
411 {
412 if (mappedPeak > ceiling)
413 emit(events[event].begin, events[event].end, mappedPeak, true);
414 mappedPeak = 0;
415 ++event;
416 }
417 if (event < events.size() && frame >= events[event].begin)
418 {
419 flushExtra();
420 mappedPeak = std::max(mappedPeak, magnitude);
421 }
422 else
423 {
424 if (extraPeak > 0 && frame - extraEnd >= gap)
425 flushExtra();
426 if (magnitude > ceiling)
427 {
428 if (extraPeak == 0)
429 extraBegin = frame;
430 extraEnd = frame + 1;
431 extraPeak = std::max(extraPeak, magnitude);
432 }
433 }
434 });
435 if (event < events.size() && mappedPeak > ceiling)
436 emit(events[event].begin, events[event].end, mappedPeak, true);
437 flushExtra();
438 }
439};
440} // namespace dspark
441#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.
Immutable source-bound sparse gain plan; positions are int64 source frames.
std::size_t retainedBytes() const noexcept
Plan(Plan &&) noexcept=default
double gainAt(std::int64_t frame) const noexcept
std::span< const OfflineRegion > exclusions() const noexcept
Borrowed regions, valid while the plan remains alive and unmoved.
OfflineAudioSpec getSpec() const noexcept
Complete-file peak compressor with a positive reduction-dB control.
PlanResult makePlan(OfflineAudioSource< T > &source, const typename OfflineTransientAnalyzer< T >::Analysis &analysis, const Options &options={}, const OfflineJobOptions &jobOptions={}) const
Reuses a transient map and measures the actual stage PCM. Three bounded scans measure sustained headr...
PlanResult analyze(OfflineAudioSource< T > &source, const Options &options={}, const OfflineJobOptions &job={}) const
Complete-source analysis and planning; retains only sparse automation.
Result render(OfflineAudioSource< T > &source, const Plan &plan, OfflineAudioSink< T > &sink, const OfflineJobOptions &job={}) const
Verifies and renders transactionally; reports the actual final peak reduction.
Result run(const AudioBuffer< T, MaxChannels > &input, AudioBuffer< T, MaxChannels > &output, double sampleRate, const Options &options={}, const OfflineJobOptions &job={}) const
Owning convenience render; aliases are safe and failures preserve output.
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 > attacks() 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.
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.
double lookaheadMs
Requested anticipation, 0.1..10 ms; render may extend to 10 ms.
std::span< const OfflineRegion > exclusions
Exact PCM; feathering is outside regions.
double reductionDb
Requested whole-file sample-peak reduction, 0..18 dB.
double releaseMs
Amplitude recovery time constant, 60..100 ms.
double lookaheadMs
Actual anticipation after successful render calibration.
double maximumReductionDb
Initial control depth; renderInfo holds calibrated bounds.
double requestedLookaheadMs
Requested anticipation before render calibration.
int renderAttempts
Complete-file calibration attempts; zero in a new plan.