DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
OfflineLeveler.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
35#include "../Analysis/OfflineEnergyAnalyzer.h"
36#include "../Core/OfflineProcessing.h"
37
38#if DSPARK_HAS_OFFLINE
39
40#include "../Core/detail/OfflineGain.h"
41
42#include <algorithm>
43#include <cmath>
44#include <cstddef>
45#include <cstdint>
46#include <limits>
47#include <memory>
48#include <span>
49#include <utility>
50
51namespace dspark
52{
53
55template <FloatType T> class OfflineLeveler final
56{
57 public:
59 struct Options
60 {
61 double amount = 0;
62 double speed = 0.5;
63 double maximumBoostDb = 24;
64 std::span<const OfflineRegion> exclusions;
65 };
66
68 enum class Reason : std::uint8_t
69 {
70 Leveled,
78 };
79
81 struct Report
82 {
84 double referenceRmsDb = -std::numeric_limits<double>::infinity();
85 double minimumGainDb = 0;
86 double maximumGainDb = 0;
87 double outputSamplePeakDb = -std::numeric_limits<double>::infinity();
88 double outputTruePeakDb = -std::numeric_limits<double>::infinity();
89 std::size_t controlPoints = 0;
90 std::size_t boundaries = 0;
91 std::size_t limitedPoints = 0;
93 bool peaksMeasured = false;
95 };
96
98 class Plan final
99 {
100 public:
101 Plan() = default;
102 Plan(Plan &&) noexcept = default;
103 Plan &operator=(Plan &&) noexcept = default;
104 [[nodiscard]] bool isValid() const noexcept
105 {
106 return gains_ != nullptr;
107 }
108 [[nodiscard]] OfflineAudioSpec getSpec() const noexcept
109 {
110 return spec_;
111 }
112 [[nodiscard]] Report getReport() const noexcept
113 {
114 return report_;
115 }
116 [[nodiscard]] std::size_t controlPoints() const noexcept
117 {
118 return isValid() ? count_ : 0;
119 }
120 [[nodiscard]] std::size_t retainedBytes() const noexcept
121 {
122 return isValid() ? count_ * sizeof(double) + exclusions_.retainedBytes() : 0;
123 }
125 [[nodiscard]] std::span<const OfflineRegion> exclusions() const noexcept
126 {
127 return isValid() ? exclusions_.view() : std::span<const OfflineRegion>();
128 }
130 [[nodiscard]] double gainAt(std::int64_t frame) const noexcept
131 {
132 if (!isValid() || frame < 0 || frame >= spec_.frames)
133 return 1;
134 const auto index = static_cast<std::size_t>(frame / hop_);
135 const double fraction = static_cast<double>(frame % hop_) / static_cast<double>(hop_);
136 const double gain = std::lerp(gains_[index], gains_[index + 1], fraction);
137 return exclusions_.apply(frame, gain, featherFrames_);
138 }
139
140 private:
141 friend class OfflineLeveler;
142 OfflineAudioSpec spec_;
143 OfflineFingerprint fingerprint_;
144 Report report_;
145 std::int64_t hop_ = 1;
146 std::size_t count_ = 0;
147 double featherFrames_ = 1, inputPeak_ = 0;
148 std::unique_ptr<double[]> gains_;
149 detail::OfflineExclusions exclusions_;
150 };
151
154 {
157 std::size_t memoryBytes = 0;
158 [[nodiscard]] bool succeeded() const noexcept
159 {
160 return offlineSucceeded(status);
161 }
162 };
163
165 struct Result
166 {
169 std::size_t memoryBytes = 0;
170 [[nodiscard]] bool succeeded() const noexcept
171 {
172 return offlineSucceeded(status);
173 }
174 };
175
177 [[nodiscard]] PlanResult makePlan(const typename OfflineEnergyAnalyzer<T>::Analysis &analysis,
178 const Options &options = {},
179 const OfflineJobOptions &jobOptions = {}) const
180 {
181 PlanResult result;
182 try
183 {
184 validateOptions(options);
185 if (!analysis.isValid())
187 detail::OfflineSession job(jobOptions);
188 Plan plan;
189 plan.spec_ = analysis.getSpec();
190 detail::offlineValidateSpec(plan.spec_, 2);
191 plan.fingerprint_ = analysis.fingerprint();
192 plan.hop_ = analysis.binFrames();
193 plan.inputPeak_ = analysis.samplePeak();
194 const auto bins = analysis.bins();
195 const auto n = bins.size();
196 const auto total = static_cast<std::int64_t>(n + 1) * 3;
197 job.checkpoint(OfflinePhase::Plan, 0, total);
198 plan.count_ = n + 1;
199 plan.gains_ = job.allocate<double>(n + 1);
200 std::fill_n(plan.gains_.get(), n + 1, 1.0);
201 plan.exclusions_.assign(job, options.exclusions, plan.spec_.frames);
202 plan.report_.controlPoints = n + 1;
203 auto powers = job.allocate<double>(n);
204 double scale = 0;
205 for (std::size_t i = 0; i < n; ++i)
206 {
207 powers[i] = bins[i].rms;
208 scale = std::max(scale, bins[i].rms);
209 }
210 const auto quantile = (n - 1) / 10 * 9 + (n - 1) % 10 * 9 / 10;
211 std::nth_element(powers.get(), powers.get() + quantile, powers.get() + n);
212 const double floorDb = std::max(-65.0, gainToDecibels(powers[quantile], -6400.0) - 35);
213 bool active = false;
214 for (const auto bin : bins)
215 active = active || gainToDecibels(bin.rms, -6400.0) > floorDb;
216
217 const bool allExcluded = plan.exclusions_.covers(plan.spec_.frames);
218 if (!active || options.amount == 0 || options.maximumBoostDb == 0 || allExcluded)
219 {
220 plan.report_.reason = allExcluded ? Reason::AllExcluded
221 : !active ? Reason::NoActivity
222 : options.amount == 0 ? Reason::ZeroAmount
224 job.checkpoint(OfflinePhase::Plan, total, total);
225 result.status = OfflineStatus::NoChange;
226 result.memoryBytes = job.bytes();
227 result.plan = std::move(plan);
228 return result;
229 }
230 for (std::size_t i = 0; i < n; ++i)
231 {
232 const double ratio = bins[i].rms / scale;
233 powers[i] = ratio * ratio;
234 }
235 const double binsPerSecond = plan.spec_.sampleRate / static_cast<double>(plan.hop_);
236 const auto macro = std::min(
237 n,
238 std::max<std::size_t>(1, static_cast<std::size_t>(std::round(3 * binsPerSecond))));
239 const auto context =
240 std::max<std::size_t>(2, static_cast<std::size_t>(std::round(binsPerSecond)));
241 // Local sums avoid subtracting two large cumulative sums after a loud
242 // section. At 100 ms resolution all analysis windows have bounded size.
243 const auto meanPower = [&](std::size_t from, std::size_t to) {
244 double energy = 0, frames = 0;
245 for (auto j = from; j < to; ++j)
246 {
247 energy += powers[j] * static_cast<double>(bins[j].frames);
248 frames += static_cast<double>(bins[j].frames);
249 }
250 return energy / frames;
251 };
252 const auto meanDb = [&](std::size_t from, std::size_t to) {
253 double localScale = 0, energy = 0, frames = 0;
254 for (auto j = from; j < to; ++j)
255 localScale = std::max(localScale, bins[j].rms);
256 if (localScale == 0)
257 return -6400.0;
258 for (auto j = from; j < to; ++j)
259 {
260 const double r = bins[j].rms / localScale;
261 energy += r * r * static_cast<double>(bins[j].frames);
262 frames += static_cast<double>(bins[j].frames);
263 }
264 return gainToDecibels(localScale * std::sqrt(std::min(1.0, energy / frames)),
265 -6400.0);
266 };
267 double targetPower = 0;
268 for (std::size_t i = 0; i <= n - macro; ++i)
269 targetPower = std::max(targetPower, meanPower(i, i + macro));
270 const double targetDb =
271 gainToDecibels(scale * std::sqrt(std::min(1.0, targetPower)), -6400.0);
272 plan.report_.referenceRmsDb = targetDb;
273 auto novelty = job.allocate<double>(n + 1);
274 auto edges = job.allocate<std::size_t>(n + 2);
275 std::size_t edgeCount = 1;
276 if (n >= 2 * context)
277 for (auto i = context; i <= n - context; ++i)
278 {
279 const auto half = context / 2;
280 const double left = meanDb(i - context, i), right = meanDb(i, i + context);
281 if (left > floorDb + 6 && right > floorDb + 6 && std::abs(right - left) >= 3 &&
282 std::abs(meanDb(i - context, i - half) - meanDb(i - half, i)) < 1.5 &&
283 std::abs(meanDb(i, i + half) - meanDb(i + half, i + context)) < 1.5)
284 novelty[i] = std::abs(right - left);
285 }
286 if (n >= 2 * context)
287 for (auto i = context; i <= n - context; ++i)
288 {
289 if (novelty[i] == 0)
290 continue;
291 bool maximum = true;
292 for (auto j = std::max(context, i - context);
293 j <= std::min(n - context, i + context); ++j)
294 if (novelty[j] > novelty[i] || (novelty[j] == novelty[i] && j < i))
295 {
296 maximum = false;
297 break;
298 }
299 if (maximum && i - edges[edgeCount - 1] >= context * 2)
300 edges[edgeCount++] = i;
301 }
302 edges[edgeCount++] = n;
303 plan.report_.boundaries = edgeCount - 2;
304 const auto radius = std::max<std::size_t>(
305 1, static_cast<std::size_t>(
306 std::round(3 * std::pow(1.0 / 3, options.speed) * binsPerSecond)));
307 const auto smoothing = std::max<std::size_t>(
308 1, static_cast<std::size_t>(
309 std::round(0.6 * std::pow(1.0 / 6, options.speed) * binsPerSecond)));
310 plan.featherFrames_ =
311 std::max(plan.spec_.sampleRate * 0.1,
312 static_cast<double>(smoothing) * static_cast<double>(plan.hop_));
313 auto desired = job.allocate<double>(n + 1);
314 std::size_t region = 0;
315 for (std::size_t i = 0; i <= n; ++i)
316 {
317 if ((i & 1023) == 0)
318 job.checkpoint(OfflinePhase::Plan, static_cast<std::int64_t>(i), total);
319 while (region + 2 < edgeCount && i >= edges[region + 1])
320 ++region;
321 auto from = std::max(edges[region], i > radius ? i - radius : 0);
322 auto to = std::min(edges[region + 1], i + radius);
323 if (to <= from)
324 {
325 from = std::min(i, n - 1);
326 to = from + 1;
327 }
328 const double correction = std::max(0.0, targetDb - meanDb(from, to));
329 double activity = std::clamp(
330 (gainToDecibels(bins[std::min(i, n - 1)].rms, -6400.0) - floorDb) / 6, 0.0,
331 1.0);
332 activity = activity * activity * (3 - 2 * activity);
333 if (correction > options.maximumBoostDb && activity > 0.99)
334 ++plan.report_.limitedPoints;
335 desired[i] =
336 options.amount * std::min(options.maximumBoostDb, correction) * activity;
337 }
338 const auto smooth = [n, smoothing](const double *values, std::size_t i) {
339 double sum = 0, weights = 0;
340 const auto radiusSigned = static_cast<std::int64_t>(smoothing);
341 for (auto j = -radiusSigned; j <= radiusSigned; ++j)
342 {
343 const double w =
344 static_cast<double>(smoothing + 1) - static_cast<double>(std::abs(j));
345 const auto index = static_cast<std::size_t>(
346 std::clamp(static_cast<std::int64_t>(i) + j, std::int64_t(0),
347 static_cast<std::int64_t>(n)));
348 sum += w * values[index];
349 weights += w;
350 }
351 return sum / weights;
352 };
353 for (std::size_t i = 0; i <= n; ++i)
354 plan.gains_[i] = decibelsToGain(std::min(desired[i], smooth(desired.get(), i)));
355 const double maxSample = static_cast<double>(std::numeric_limits<T>::max());
356 const double maxGain = decibelsToGain(options.maximumBoostDb);
357 for (std::size_t i = 0; i < n; ++i)
358 if (bins[i].peak > maxSample / maxGain)
359 {
360 const double bound =
361 std::max(1.0, std::nextafter(maxSample / bins[i].peak, 0.0));
362 if (std::max(plan.gains_[i], plan.gains_[i + 1]) > bound)
363 ++plan.report_.representabilityLimitedBins;
364 plan.gains_[i] = std::min(plan.gains_[i], bound);
365 plan.gains_[i + 1] = std::min(plan.gains_[i + 1], bound);
366 }
367 // Bound each grid window using the maximum endpoint gain of each bin.
368 // Scaling only the extra gain preserves source dynamics and monotonic
369 // validity of all previously bounded windows.
370 for (std::size_t start = 0; start <= n - macro; ++start)
371 {
372 if ((start & 1023) == 0)
373 job.checkpoint(OfflinePhase::Plan, static_cast<std::int64_t>(n + 1 + start),
374 total);
375 const auto end = start + macro;
376 double a = 0, b = 0, c = 0, frames = 0;
377 for (auto j = start; j < end; ++j)
378 {
379 const double energy = powers[j] * static_cast<double>(bins[j].frames);
380 const double extra = std::max(plan.gains_[j], plan.gains_[j + 1]) - 1;
381 a += energy * extra * extra;
382 b += 2 * energy * extra;
383 c += energy;
384 frames += static_cast<double>(bins[j].frames);
385 }
386 const double budget = std::max(0.0, targetPower * frames - c);
387 if (a + b > budget && a + b > 0)
388 {
389 const double factor = std::clamp(
390 a == 0 ? budget / b : 2 * budget / (b + std::sqrt(b * b + 4 * a * budget)),
391 0.0, 1.0);
392 for (auto j = start; j <= end; ++j)
393 plan.gains_[j] = 1 + (plan.gains_[j] - 1) * factor;
394 }
395 }
396 auto lower = job.allocate<double>(n + 1);
397 for (std::size_t i = 0; i <= n; ++i)
398 lower[i] = std::min(
399 {plan.gains_[i], plan.gains_[i ? i - 1 : 0], plan.gains_[std::min(n, i + 1)]});
400 double minimum = std::numeric_limits<double>::infinity(), maximum = 1;
401 for (std::size_t i = 0; i <= n; ++i)
402 {
403 if ((i & 1023) == 0)
404 job.checkpoint(OfflinePhase::Plan, static_cast<std::int64_t>(2 * (n + 1) + i),
405 total);
406 plan.gains_[i] = std::max(1.0, std::min(plan.gains_[i], smooth(lower.get(), i)));
407 minimum = std::min(minimum, plan.gains_[i]);
408 maximum = std::max(maximum, plan.gains_[i]);
409 }
410 if (!plan.exclusions_.view().empty())
411 minimum = 1;
412 plan.report_.minimumGainDb = gainToDecibels(minimum);
413 plan.report_.maximumGainDb = gainToDecibels(maximum);
414 plan.report_.reason = plan.report_.representabilityLimitedBins
416 : maximum == 1 ? Reason::NoCorrection
417 : plan.report_.limitedPoints ? Reason::BoostLimited
419 job.checkpoint(OfflinePhase::Plan, total, total);
420 result.status = maximum == 1 ? OfflineStatus::NoChange : OfflineStatus::Success;
421 result.memoryBytes = job.bytes();
422 result.plan = std::move(plan);
423 }
424 catch (...)
425 {
426 result.status = detail::offlineExceptionStatus();
427 }
428 return result;
429 }
430
432 [[nodiscard]] PlanResult analyze(OfflineAudioSource<T> &source, const Options &options = {},
433 const OfflineJobOptions &job = {}) const
434 {
435 PlanResult result;
436 try
437 {
438 validateOptions(options);
439 auto energy = OfflineEnergyAnalyzer<T>().analyze(source, job);
440 if (!energy.succeeded())
441 {
442 result.status = energy.status;
443 return result;
444 }
445 result = makePlan(energy.analysis, options,
446 detail::offlineRemaining(job, energy.analysis.retainedBytes()));
447 result.memoryBytes =
448 std::max(energy.memoryBytes, result.memoryBytes + energy.analysis.retainedBytes());
449 }
450 catch (...)
451 {
452 result.status = detail::offlineExceptionStatus();
453 }
454 return result;
455 }
456
465 [[nodiscard]] Result render(OfflineAudioSource<T> &source, const Plan &plan,
467 const OfflineJobOptions &options = {}) const
468 {
469 auto result = detail::offlineRenderGain<Result>(
470 source, plan.spec_, plan.fingerprint_, plan.inputPeak_, plan.isValid(),
471 plan.report_.maximumGainDb != 0, plan.report_, sink, options,
472 [&plan](detail::OfflineSession &) {
473 return [&plan](std::int64_t frame) { return plan.gainAt(frame); };
474 }, {&plan.exclusions_, plan.featherFrames_, false});
475 if (result.succeeded() && result.report.renderInfo.representabilityLimited)
476 {
478 const auto adjusted = [&](double db) {
479 return gainToDecibels(1 + result.report.renderInfo.deltaScale *
480 (decibelsToGain(db) - 1));
481 };
482 result.report.minimumGainDb = adjusted(result.report.minimumGainDb);
483 result.report.maximumGainDb = adjusted(result.report.maximumGainDb);
484 }
485 return result;
486 }
487
494 template <int MaxChannels>
495 [[nodiscard]] Result run(const AudioBuffer<T, MaxChannels> &input,
496 AudioBuffer<T, MaxChannels> &output, double sampleRate,
497 const Options &options = {}, const OfflineJobOptions &job = {}) const
498 {
499 return detail::offlineRun(*this, input, output, sampleRate, options, job);
500 }
501
502 private:
503 static void validateOptions(const Options &options)
504 {
505 if (!std::isfinite(options.amount) || (options.amount < 0) || (options.amount > 1) ||
506 !std::isfinite(options.speed) || (options.speed < 0) || (options.speed > 1) ||
507 !std::isfinite(options.maximumBoostDb) || (options.maximumBoostDb < 0) ||
508 (options.maximumBoostDb > 24))
510 }
511};
512
513} // namespace dspark
514
515#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.
Read-only source-bound energy map; owns bins, never source samples.
bool isValid() const noexcept
False for a failed or moved-from result.
std::span< const Bin > bins() const noexcept
Returned spans remain valid only while this analysis is alive and unmoved.
OfflineFingerprint fingerprint() const noexcept
OfflineAudioSpec getSpec() const noexcept
Immutable source-bound linear-amplitude automation, with int64 lookup.
std::size_t retainedBytes() const noexcept
OfflineAudioSpec getSpec() const noexcept
double gainAt(std::int64_t frame) const noexcept
Gain at a source-relative frame. Outside the source or on an invalid plan: 1.
Plan(Plan &&) noexcept=default
bool isValid() const noexcept
std::span< const OfflineRegion > exclusions() const noexcept
Borrowed read-only regions; invalidated by destruction/move of the plan.
std::size_t controlPoints() const noexcept
Report getReport() const noexcept
Complete-file automatic upward leveling, with reusable immutable plans.
PlanResult makePlan(const typename OfflineEnergyAnalyzer< T >::Analysis &analysis, const Options &options={}, const OfflineJobOptions &jobOptions={}) const
Builds/rebuilds a plan from reusable energy features without reading PCM.
PlanResult analyze(OfflineAudioSource< T > &source, const Options &options={}, const OfflineJobOptions &job={}) const
Complete analysis plus planning; only the compact gain plan is retained.
Result run(const AudioBuffer< T, MaxChannels > &input, AudioBuffer< T, MaxChannels > &output, double sampleRate, const Options &options={}, const OfflineJobOptions &job={}) const
Convenience complete-file owning render; input and output may be the same buffer.
Result render(OfflineAudioSource< T > &source, const Plan &plan, OfflineAudioSink< T > &sink, const OfflineJobOptions &options={}) const
Verifies a source-bound plan, then renders transactionally to a sink.
Reason
Why the computed plan changed, limited, or left the source alone.
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 offlineValidateSpec(const OfflineAudioSpec &spec, int maximumChannels=16)
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.
Principal amount plus optional expert controls and protected regions.
std::span< const OfflineRegion > exclusions
Copied and canonicalized into the plan.
double speed
0..1: 6..2 s context, 1.2..0.2 s transitions.
double amount
0..1, fraction of the measured level difference in dB.
double maximumBoostDb
Finite 0..24 dB cap; no downward correction.
Completed plan or explicit failure, never an old/stale plan.
Plan range and final render peaks. Silence peaks are -infinity dB.
OfflineGainRenderInfo renderInfo
std::size_t representabilityLimitedBins
Correction reduced to keep PCM finite.
Render result, measurements, and the operation's requested dynamic payload.
bool succeeded() const noexcept