DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
OfflineGain.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
10#include "../OfflineProcessing.h"
11#if DSPARK_HAS_OFFLINE
12#include "../TruePeakDetector.h"
13#include "OfflineGainSource.h"
14#include "OfflineWorker.h"
15#include <algorithm>
16#include <array>
17#include <cmath>
18#include <cstring>
19#include <limits>
20#include <memory>
21#include <optional>
22#include <span>
23#include <utility>
24
25namespace dspark::detail
26{
27
29{
31 {
33 double peakGain = 1; // Relative to the complete source peak.
34 };
35 const OfflineExclusions *exclusions = nullptr;
36 double featherFrames = 1;
37 bool calibratePeak = false;
38 std::array<BoundaryTarget, 2> boundaryTargets{};
39};
40
41// Intersection of peak half-planes with the admissible exterior-control box.
42// Clipping preserves the complete feasible polygon, including line/point cases.
43// The nearest point to zero changes only the necessary exterior continuation.
45{
46 public:
47 using Point = std::array<double, 2>;
48 OfflineBoundaryFeasibility(OfflineSession &job, std::uint64_t constraints, Point lower)
49 : capacity_(static_cast<std::size_t>(constraints + 4)),
50 vertices_(job.allocate<Point>(constraints + 4)),
51 scratch_(job.allocate<Point>(constraints + 4))
52 {
53 vertices_[0] = lower;
54 vertices_[1] = {0, lower[1]};
55 vertices_[2] = {0, 0};
56 vertices_[3] = {lower[0], 0};
57 }
58 void constrain(double a, double b, double bound)
59 {
60 if (!std::isfinite(a) || !std::isfinite(b) || !std::isfinite(bound))
62 if (count_ == 0)
63 return;
64 std::size_t next = 0;
65 const auto append = [&](Point point) {
66 if (next && point == scratch_[next - 1])
67 return;
68 if (next == capacity_)
70 scratch_[next++] = point;
71 };
72 auto previous = vertices_[count_ - 1];
73 double pv = a * previous[0] + b * previous[1] - bound;
74 for (std::size_t i = 0; i < count_; ++i)
75 {
76 const auto current = vertices_[i];
77 const double cv = a * current[0] + b * current[1] - bound;
78 if ((pv > 0) != (cv > 0))
79 {
80 const double t = pv / (pv - cv);
81 append({previous[0] + t * (current[0] - previous[0]),
82 previous[1] + t * (current[1] - previous[1])});
83 }
84 if (cv <= 0)
85 append(current);
86 previous = current;
87 pv = cv;
88 }
89 if (next > 1 && scratch_[0] == scratch_[next - 1])
90 --next;
91 count_ = next;
92 vertices_.swap(scratch_);
93 }
94 [[nodiscard]] bool feasible() const noexcept { return count_ != 0; }
95 [[nodiscard]] Point closest() const noexcept
96 {
97 Point best{};
98 double norm = std::numeric_limits<double>::infinity();
99 for (std::size_t i = 0; i < count_; ++i)
100 {
101 const auto a = vertices_[i], b = vertices_[(i + 1) % count_];
102 const double dx = b[0] - a[0], dy = b[1] - a[1];
103 const double denominator = dx * dx + dy * dy;
104 const double t = denominator > 0
105 ? std::clamp(-(a[0] * dx + a[1] * dy) / denominator, 0., 1.) : 0;
106 const Point point{a[0] + t * dx, a[1] + t * dy};
107 const double squared = point[0] * point[0] + point[1] * point[1];
108 if (squared < norm)
109 {
110 norm = squared;
111 best = point;
112 }
113 }
114 return best;
115 }
116 private:
117 std::size_t capacity_, count_ = 4;
118 std::unique_ptr<Point[]> vertices_, scratch_;
119};
120
121// Complete-file product maps are shared by calibration, measurement and output.
122// Constant controls keep exact scalar behavior; zero amount preserves PCM bits.
123template <class Result, FloatType T, class Report, class CursorFactory>
124[[nodiscard]] Result offlineRenderGain(OfflineAudioSource<T> &source, const OfflineAudioSpec &spec,
125 const OfflineFingerprint &expectedFingerprint,
126 double inputPeak, bool valid, bool changed,
127 const Report &report, OfflineAudioSink<T> &sink,
128 const OfflineJobOptions &options, CursorFactory makeCursor,
129 OfflineGainRenderSettings settings = {})
130{
131 Result result;
132 OfflineSinkTransaction<T> transaction;
133 try
134 {
135 if (!valid)
137 if (source.getSpec() != spec)
139 detail::OfflineSession job(options);
140 job.checkpoint(OfflinePhase::Verify, 0, spec.frames);
141 result.report = report;
142 auto cursor = makeCursor(job);
143 using Cursor = decltype(cursor);
144 OfflineGainSource<T, Cursor> cached(source, spec, expectedFingerprint, inputPeak, job,
145 options, std::move(cursor));
146 std::optional<OfflineGainProduct<T, Cursor>> product;
147 std::uint64_t boundaryFrames = 0;
148 for (const auto target : settings.boundaryTargets)
149 {
150 if (target.region.begin < 0 || target.region.end < target.region.begin ||
151 target.region.end > spec.frames || !std::isfinite(target.peakGain) ||
152 !(target.peakGain > 0) || target.peakGain > 1)
154 boundaryFrames += static_cast<std::uint64_t>(target.region.end - target.region.begin);
155 }
156 if (changed && inputPeak > 0 && !cached.constant())
157 product.emplace(cached, job, boundaryFrames != 0);
158 result.report.renderInfo.bandlimited = product.has_value();
159 result.report.renderInfo.boundaryGain = cached.baseline();
160 result.report.renderInfo.leftBoundaryGain = cached.endpoint(0);
161 result.report.renderInfo.rightBoundaryGain = cached.endpoint(1);
162 result.report.renderInfo.minimumControlGain = cached.minimumGain();
163 result.report.renderInfo.maximumControlGain = cached.maximumGain();
164 const int blockSize = cached.block();
165 auto output = job.allocate<T>(static_cast<std::uint64_t>(blockSize) * spec.channels);
166 result.memoryBytes = job.bytes();
167 TruePeakDetector<double, 2> detector;
168 double outputPeak = 0, normalizedTruePeak = 0;
169 const double normalizer = inputPeak > 0 ? inputPeak : 1;
170 const double maximumSample = static_cast<double>(std::numeric_limits<T>::max());
171 const auto runPass = [&](OfflinePhase phase, auto &&consume, bool boundaryOnly = false) {
172 cached.reset();
173 if (product)
174 product->reset();
175 job.checkpoint(phase, 0, spec.frames);
176 for (std::size_t leaf = 0; leaf < cached.leaves(); ++leaf)
177 {
178 const auto first = static_cast<std::int64_t>(leaf) * blockSize;
179 const int frames =
180 static_cast<int>(std::min<std::int64_t>(blockSize, spec.frames - first));
181 if (boundaryOnly && std::none_of(settings.boundaryTargets.begin(),
182 settings.boundaryTargets.end(), [&](const auto &target) {
183 return first < target.region.end && first + frames > target.region.begin;
184 }))
185 continue;
186 const double *delta = product ? product->evaluate(leaf) : nullptr;
187 const T *input = cached.audio(leaf, 0);
188 const double *gains = cached.gain(leaf);
189 consume(first, frames, input, gains, delta);
190 job.checkpoint(phase, first + frames, spec.frames);
191 }
192 cached.verifySpec();
193 };
194 const auto maskAt = [&](std::int64_t frame) {
195 return settings.exclusions
196 ? settings.exclusions->apply(frame, 2, settings.featherFrames) - 1
197 : 1;
198 };
199 const auto constrainedDelta = [](double x, double gain, double delta, double mask) {
200 if (mask == 0)
201 return 0.;
202 const double native = x * (gain - 1);
203 return native + mask * (delta - native);
204 };
205 double scale = 1;
206 if (product && boundaryFrames)
207 {
208 (void)offlineBytes(boundaryFrames, static_cast<std::size_t>(2 * spec.channels) *
210 const auto constraints = boundaryFrames * static_cast<std::uint64_t>(2 * spec.channels);
211 (void)offlineBytes(constraints + 4, sizeof(OfflineBoundaryFeasibility::Point));
212 const std::array<double, 2> lower{
213 settings.boundaryTargets[0].region.end > settings.boundaryTargets[0].region.begin
214 ? -cached.endpoint(0) : 0,
215 settings.boundaryTargets[1].region.end > settings.boundaryTargets[1].region.begin
216 ? -cached.endpoint(1) : 0};
217 OfflineBoundaryFeasibility feasible(job, constraints, lower);
218 runPass(OfflinePhase::Verify, [&](std::int64_t first, int frames, const T *input,
219 const double *gains, const double *delta) {
220 const auto *left = product->boundaryResponse(0);
221 const auto *right = product->boundaryResponse(1);
222 for (const auto target : settings.boundaryTargets)
223 for (auto frame = std::max(first, target.region.begin);
224 frame < std::min(first + frames, target.region.end); ++frame)
225 {
226 const int f = static_cast<int>(frame - first);
227 const double mask = maskAt(frame);
228 const double bound = offlineRepresentablePeakGain<T>(normalizer, target.peakGain);
229 for (int c = 0; c < spec.channels; ++c)
230 {
231 const int k = c * blockSize + f;
232 const double x = static_cast<double>(input[k]) / normalizer;
233 const double y = x + constrainedDelta(x, gains[f], delta[k], mask);
234 // Exclusions and their feathers retain the scalar plan's
235 // admissible level; no target overrides protected PCM.
236 const double limit = std::max(bound, std::abs(x * gains[f]));
237 const double a = mask * left[k], b = mask * right[k];
238 feasible.constrain(a, b, limit - y);
239 feasible.constrain(-a, -b, limit + y);
240 }
241 }
242 }, true);
243 result.report.renderInfo.targetFeasible = feasible.feasible();
244 if (!feasible.feasible())
246 const auto delta = feasible.closest();
247 auto &info = result.report.renderInfo;
248 info.leftBoundaryGain = std::clamp(cached.endpoint(0) + delta[0], 0., cached.endpoint(0));
249 info.rightBoundaryGain = std::clamp(cached.endpoint(1) + delta[1], 0., cached.endpoint(1));
250 info.boundaryCalibrated = delta[0] != 0 || delta[1] != 0;
251 product->setEndpoints(info.leftBoundaryGain, info.rightBoundaryGain);
252 }
253 // The finite Hilbert row sum is below 2*(1+log(N))/pi. Bound each term
254 // of the product identity, including the affine control extension.
255 // A factor of two reserves numerical error. Ordinary PCM then avoids a
256 // redundant complete-source representability pass; every final sample
257 // is still checked before publication. Exact-PCM feathering is convex
258 // between the scalar and bandlimited deltas and respects the same bound.
259 const double rowSum = 2 * (1 + std::log(static_cast<double>(spec.frames))) / pi<double>;
260 const double controlRange = std::max(std::abs(cached.minimumGain() - cached.baseline()),
261 std::abs(cached.maximumGain() - cached.baseline()));
262 // Each endpoint commutator has a diagonal <=1/8 and two Cauchy
263 // terms. Centering its digamma weights bounds them by 1+log(N).
264 const double endpointBound = .125 + 2 * (1 + std::log(static_cast<double>(spec.frames))) *
265 rowSum / pi<double>;
266 const double endpointRange =
267 std::abs(result.report.renderInfo.leftBoundaryGain - cached.baseline()) +
268 std::abs(result.report.renderInfo.rightBoundaryGain - cached.baseline());
269 const double deltaBound = std::abs(cached.baseline() - 1) +
270 (.75 + 3 * rowSum * rowSum) * controlRange +
271 endpointBound * endpointRange;
272 const bool couldOverflow = normalizer >= maximumSample / (2 * (1 + deltaBound));
273 if (product && (settings.calibratePeak || couldOverflow))
274 {
275 double lo = 0;
276 double hi = settings.calibratePeak && cached.minimumGain() < 1
277 ? 1 / (1 - cached.minimumGain())
278 : 1;
279 const double bound =
280 settings.calibratePeak
281 ? std::nextafter(cached.scalarPeak(), std::numeric_limits<double>::infinity())
282 : maximumSample / normalizer;
283 // Reserve roundoff in the affine sum and final rescaling. Original
284 // finite PCM remains admissible, including an exact TYPE_MAX sample.
285 const double safeBound =
286 settings.calibratePeak
287 ? bound
288 : bound * (1 - 8 * std::numeric_limits<double>::epsilon());
289 bool feasible = true;
290 runPass(OfflinePhase::Verify, [&](std::int64_t first, int frames, const T *input,
291 const double *gains, const double *delta) {
292 for (int f = 0; f < frames; ++f)
293 {
294 const double mask = maskAt(first + f);
295 for (int c = 0; c < spec.channels; ++c)
296 {
297 const int k = c * blockSize + f;
298 const double x = static_cast<double>(input[k]) / normalizer;
299 const double d = constrainedDelta(x, gains[f], delta[k], mask);
300 if (!std::isfinite(d))
302 if (d == 0)
303 feasible = feasible && std::abs(x) <= bound;
304 else
305 {
306 const double limit = settings.calibratePeak
307 ? bound
308 : std::max(safeBound, std::abs(x));
309 double a = (-limit - x) / d, b = (limit - x) / d;
310 if (a > b)
311 std::swap(a, b);
312 lo = std::max(lo, a);
313 hi = std::min(hi, b);
314 }
315 }
316 }
317 });
318 result.report.renderInfo.targetFeasible = feasible && lo <= hi;
319 if (!result.report.renderInfo.targetFeasible)
321 scale = std::clamp(1., lo, hi);
322 if (!settings.calibratePeak && scale < 1 && scale > 0)
323 scale = std::nextafter(scale, 0.);
324 result.report.renderInfo.deltaScale = scale;
325 result.report.renderInfo.representabilityLimited = !settings.calibratePeak && scale < 1;
326 }
327 result.report.renderInfo.minimumControlGain =
328 std::max(0., 1 + scale * (cached.minimumGain() - 1));
329 result.report.renderInfo.maximumControlGain =
330 std::max(0., 1 + scale * (cached.maximumGain() - 1));
331 // Source and target calibration are complete. Measure the actual rounded
332 // PCM while writing provisional blocks; any later failure aborts them.
333 // Publication happens only after source checks and the meter tail finish.
334 transaction.sink = &sink;
335 offlineCallSink([&] { return sink.begin(spec); });
336 runPass(OfflinePhase::Render, [&](std::int64_t first, int frames, const T *input,
337 const double *gains, const double *delta) {
338 for (int f = 0; f < frames; ++f)
339 {
340 const double gain = gains[f];
341 const double mask = maskAt(first + f);
342 for (int c = 0; c < spec.channels; ++c)
343 {
344 const int k = c * blockSize + f;
345 const T x = input[k];
346 T y = x;
347 if (delta && mask != 0 && scale != 0)
348 {
349 const double xn = static_cast<double>(x) / normalizer;
350 const double d = constrainedDelta(xn, gain, delta[k], mask);
351 const double value = (xn + scale * d) * normalizer;
352 if (!std::isfinite(value) || std::abs(value) > maximumSample)
354 y = static_cast<T>(value);
355 }
356 else if (!delta && gain != 1)
357 {
358 const double value = static_cast<double>(x) * gain;
359 if (!std::isfinite(value) || std::abs(value) > maximumSample)
361 y = static_cast<T>(value);
362 }
363 if (!std::isfinite(y))
365 outputPeak = std::max(outputPeak, std::abs(static_cast<double>(y)));
366 normalizedTruePeak = std::max(
367 normalizedTruePeak,
368 detector.processSample(static_cast<double>(y) / normalizer, c));
369 output[k] = y;
370 }
371 }
372 for (int offset = 0; offset < frames;)
373 {
374 const int count = std::min(options.blockFrames, frames - offset);
375 std::array<const T *, 2> channels{};
376 for (int c = 0; c < spec.channels; ++c)
377 channels[c] = output.get() + c * blockSize + offset;
378 offlineCallSink([&] {
379 return sink.write(first + offset, AudioBufferView<const T>(
380 channels.data(), spec.channels, count));
381 });
382 offset += count;
383 }
384 });
385 for (int i = 0; i < TruePeakDetector<double, 2>::getTaps() - 1; ++i)
386 for (int c = 0; c < spec.channels; ++c)
387 normalizedTruePeak = std::max(normalizedTruePeak, detector.processSample(0, c));
388 const double silence = -std::numeric_limits<double>::infinity();
389 result.report.outputSamplePeakDb = gainToDecibels(outputPeak, silence);
390 result.report.outputTruePeakDb =
391 normalizedTruePeak > 0
392 ? gainToDecibels(normalizedTruePeak, silence) + gainToDecibels(normalizer, silence)
393 : silence;
394 result.report.peaksMeasured = true;
395 job.checkpoint(OfflinePhase::Render, spec.frames, spec.frames);
396 offlineCallSink([&] { return sink.commit(); });
397 transaction.sink = nullptr;
398 result.memoryBytes = job.bytes();
399 result.status = changed && scale != 0 ? OfflineStatus::Success : OfflineStatus::NoChange;
400 }
401 catch (...)
402 {
403 result.status = detail::offlineExceptionStatus();
404 }
405 return result;
406}
407
408} // namespace dspark::detail
409#endif // DSPARK_HAS_OFFLINE
Transactional worker sink for arbitrarily long offline output.
virtual bool begin(const OfflineAudioSpec &spec)=0
virtual bool commit()=0
virtual bool write(std::int64_t first, AudioBufferView< const T > block)=0
Rewindable, complete-file source with int64 positions and bounded blocks.
virtual OfflineAudioSpec getSpec() const noexcept=0
Returns format and provenance by value.
void constrain(double a, double b, double bound)
Definition OfflineGain.h:58
OfflineBoundaryFeasibility(OfflineSession &job, std::uint64_t constraints, Point lower)
Definition OfflineGain.h:48
void offlineFail(OfflineStatus status)
void offlineCallSink(Function &&function)
std::size_t offlineBytes(std::uint64_t count, std::size_t size)
Result offlineRenderGain(OfflineAudioSource< T > &source, const OfflineAudioSpec &spec, const OfflineFingerprint &expectedFingerprint, double inputPeak, bool valid, bool changed, const Report &report, OfflineAudioSink< T > &sink, const OfflineJobOptions &options, CursorFactory makeCursor, OfflineGainRenderSettings settings={})
OfflineStatus offlineExceptionStatus() noexcept
T gainToDecibels(T gain, T minusInfinityDb=T(-100)) noexcept
Converts a linear gain value to decibels.
Definition DspMath.h:89
OfflinePhase
Current kind of work; completed/total restart for each pass, and a phase may recur.
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.
Source-frame interval [begin,end), relative to the start of the source.
std::array< BoundaryTarget, 2 > boundaryTargets
Definition OfflineGain.h:38
const OfflineExclusions * exclusions
Definition OfflineGain.h:35