DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
OfflineStereoBalance.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
30#include "../Analysis/OfflineEnergyAnalyzer.h"
31#if DSPARK_HAS_OFFLINE
32#include "../Core/detail/BoxAverage.h"
33#include "../Core/detail/OfflineGain.h"
34#include <algorithm>
35#include <cmath>
36#include <cstddef>
37#include <cstdint>
38#include <limits>
39#include <memory>
40#include <numeric>
41#include <optional>
42#include <span>
43#include <utility>
44
45namespace dspark
46{
48template <FloatType T> class OfflineStereoBalance final
49{
50 public:
51 struct Options
52 {
53 double levelingAmount = 0;
54 bool guard = false;
55 std::optional<double> targetSideShare;
57 double guardMarginDb = 3;
58 double maximumBoostDb = 12;
59 double maximumCutDb = 24;
60 double maximumGuardCutDb = 60;
61 std::span<const OfflineRegion> exclusions{};
62 };
63 enum class Reason : std::uint8_t
64 {
67 Silence,
68 NoMid,
69 NoSide,
73 };
74 struct Report
75 {
77 double targetSideShare = std::numeric_limits<double>::quiet_NaN();
78 double inputSideShare = std::numeric_limits<double>::quiet_NaN();
79 double outputSideShare = std::numeric_limits<double>::quiet_NaN();
81 double maximumWindowExcessDb = -std::numeric_limits<double>::infinity();
82 double minimumGainDb = 0, maximumGainDb = 0;
83 double outputSamplePeakDb = -std::numeric_limits<double>::infinity();
84 double outputTruePeakDb = -std::numeric_limits<double>::infinity();
85 std::size_t limitedPoints = 0, guardWindows = 0, violatedWindows = 0;
86 std::int64_t analysisHopFrames = 0, guardWindowFrames = 0;
88 bool peaksMeasured = false, guardMeasured = false, guardTargetMet = false;
90 };
91
92 class Plan final
93 {
94 public:
95 Plan() = default;
96 Plan(Plan &&) noexcept = default;
97 Plan &operator=(Plan &&) noexcept = default;
98 [[nodiscard]] bool isValid() const noexcept
99 {
100 return state_ != nullptr;
101 }
102 [[nodiscard]] OfflineAudioSpec getSpec() const noexcept
103 {
104 return state_ ? state_->spec : OfflineAudioSpec{};
105 }
106 [[nodiscard]] Report getReport() const noexcept
107 {
108 return state_ ? state_->report : Report{};
109 }
110 [[nodiscard]] std::size_t retainedBytes() const noexcept
111 {
112 return state_ ? sizeof(State) + 2 * state_->count * sizeof(double) +
113 state_->exclusions.retainedBytes()
114 : 0;
115 }
116 [[nodiscard]] std::span<const OfflineRegion> exclusions() const noexcept
117 {
118 return state_ ? state_->exclusions.view() : std::span<const OfflineRegion>{};
119 }
122 [[nodiscard]] double gainAt(std::int64_t frame) const noexcept
123 {
124 return state_ ? control(*state_, state_->guard.get(), frame) : 1;
125 }
126
127 private:
129 struct State
130 {
131 OfflineAudioSpec spec;
132 OfflineFingerprint fingerprint, sideFingerprint;
133 Report report;
134 Options options;
135 detail::OfflineExclusions exclusions;
136 std::unique_ptr<double[]> level, guard;
137 std::size_t count = 0, window = 1;
138 std::int64_t hop = 1;
139 double peak = 0, sidePeak = 0, gate = 0, feather = 1;
140 bool changed = false;
141 };
142 std::unique_ptr<State> state_;
143 };
145 {
148 std::size_t memoryBytes = 0;
149 [[nodiscard]] bool succeeded() const noexcept
150 {
151 return offlineSucceeded(status);
152 }
153 };
154 struct Result
155 {
158 std::size_t memoryBytes = 0;
159 [[nodiscard]] bool succeeded() const noexcept
160 {
161 return offlineSucceeded(status);
162 }
163 };
164
167 [[nodiscard]] PlanResult
169 const Options &options = {}, const OfflineJobOptions &jobOptions = {}) const
170 {
171 PlanResult result;
172 try
173 {
174 validate(options);
175 if (!analysis.isValid())
177 detail::OfflineSession job(jobOptions);
178 job.charge(sizeof(State));
179 Plan plan;
180 plan.state_ = std::make_unique<State>();
181 auto &s = *plan.state_;
182 s.spec = analysis.getSpec();
183 s.fingerprint = analysis.fingerprint();
184 s.sideFingerprint = analysis.sideFingerprint();
185 s.peak = analysis.samplePeak();
186 s.sidePeak = analysis.sidePeak();
187 s.hop = analysis.binFrames();
188 s.options = options;
189 s.options.exclusions = {};
190 s.exclusions.assign(job, options.exclusions, s.spec.frames);
191 s.feather = std::max(1., s.spec.sampleRate * .005);
192 const auto bins = analysis.bins();
193 const auto n = bins.size();
194 job.checkpoint(OfflinePhase::Plan, 0, static_cast<std::int64_t>(n));
195 s.count = n + 1;
196 s.level = job.allocate<double>(s.count);
197 s.guard = job.allocate<double>(s.count);
198 const double perSecond = s.spec.sampleRate / static_cast<double>(s.hop);
199 s.window =
200 std::max<std::size_t>(1, static_cast<std::size_t>(std::round(.04 * perSecond)));
201 s.report.analysisHopFrames = s.hop;
202 s.report.guardWindowFrames = s.hop * static_cast<std::int64_t>(s.window);
203 s.report.inputSideShare = share(analysis.midRms(), analysis.sideRms());
204 s.report.targetSideShare = options.targetSideShare.value_or(
205 std::isfinite(s.report.inputSideShare)
206 ? std::clamp(s.report.inputSideShare, .001, .49)
207 : std::numeric_limits<double>::quiet_NaN());
208 const double ratio = std::isfinite(s.report.targetSideShare)
209 ? s.report.targetSideShare / (1 - s.report.targetSideShare)
210 : 0;
211 s.report.guardCeilingRatio = ratio * std::pow(10., options.guardMarginDb / 10);
212 const double scale = s.peak > 0 ? s.peak : 1;
213 const double mr = analysis.midRms() / scale, sr = analysis.sideRms() / scale;
214 const double floor = std::min(1., 1e-7 / scale);
215 s.gate = std::max(floor * floor, (mr * mr + sr * sr) * 1e-6);
216 auto mid = job.allocate<double>(n), side = job.allocate<double>(n);
217 for (std::size_t i = 0; i < n; ++i)
218 {
219 if ((i & 1023) == 0)
220 job.checkpoint(OfflinePhase::Plan, static_cast<std::int64_t>(i),
221 static_cast<std::int64_t>(n));
222 const double m = bins[i].midRms / scale, v = bins[i].sideRms / scale;
223 mid[i] = m * m;
224 side[i] = v * v;
225 }
226 const auto mean = [&](const double *power, std::size_t begin, std::size_t end)
227 {
228 double energy = 0, frames = 0;
229 for (auto i = begin; i < end; ++i)
230 {
231 energy += power[i] * static_cast<double>(bins[i].frames);
232 frames += static_cast<double>(bins[i].frames);
233 }
234 return energy / frames;
235 };
236 const auto radius =
237 std::max<std::size_t>(1, static_cast<std::size_t>(std::round(.2 * perSecond)));
238 for (std::size_t i = 0; i < n; ++i)
239 {
240 if ((i & 1023) == 0)
241 job.checkpoint(OfflinePhase::Plan, static_cast<std::int64_t>(i),
242 static_cast<std::int64_t>(n));
243 const auto begin = i > radius ? i - radius : 0;
244 const auto end = std::min(n, i + radius + 1);
245 const double m = mean(mid.get(), begin, end), v = mean(side.get(), begin, end);
246 if (options.levelingAmount > 0 && m > s.gate && v > s.gate)
247 {
248 const double wanted = .5 * (std::log(ratio) + std::log(m) - std::log(v));
249 const double bounded = std::clamp(wanted, -options.maximumCutDb * logDb,
250 options.maximumBoostDb * logDb);
251 s.report.limitedPoints += wanted != bounded;
252 s.level[i] = options.levelingAmount * bounded;
253 }
254 }
255 auto scratch = job.allocate<double>(s.count);
256 const auto smoothRadius =
257 std::max<std::size_t>(1, static_cast<std::size_t>(std::round(.5 * perSecond)));
258 average(s.level.get(), scratch.get(), n, smoothRadius, job);
259 average(scratch.get(), s.level.get(), n, smoothRadius, job);
260 s.level[n] = s.level[n - 1];
261 if (options.guard)
262 {
263 for (std::size_t i = 0; i < n; ++i)
264 {
265 if ((i & 1023) == 0)
266 job.checkpoint(OfflinePhase::Plan, static_cast<std::int64_t>(i),
267 static_cast<std::int64_t>(n));
268 const auto end = std::min(n, i + s.window);
269 double m = 0, v = 0, frames = 0,
270 maximum = -std::numeric_limits<double>::infinity();
271 for (auto j = i; j < end; ++j)
272 {
273 const double count = static_cast<double>(bins[j].frames);
274 m += mid[j] * count;
275 v += side[j] * count;
276 frames += count;
277 maximum = std::max({maximum, s.level[j], s.level[j + 1]});
278 }
279 if (v > s.gate * frames)
280 {
281 const double safe = m > 0 ? .5 * (std::log(s.report.guardCeilingRatio) +
282 std::log(m) - std::log(v)) -
283 maximum
284 : -std::numeric_limits<double>::infinity();
285 scratch[i] = std::clamp(safe, -options.maximumGuardCutDb * logDb, 0.);
286 }
287 else
288 scratch[i] = 0;
289 }
290 for (std::size_t i = 0; i <= n; ++i)
291 for (auto j = i > s.window ? i - s.window : 0; j <= std::min(i, n - 1); ++j)
292 s.guard[i] = std::min(s.guard[i], scratch[j]);
293 limitSlopes(s, s.guard.get());
294 }
295 s.report.reason = s.peak == 0 ? Reason::Silence
296 : mr * mr <= s.gate ? Reason::NoMid
297 : sr * sr <= s.gate ? Reason::NoSide
298 : s.report.limitedPoints ? Reason::GainLimited
300 if ((!options.guard && options.levelingAmount == 0) ||
301 s.exclusions.covers(s.spec.frames))
302 {
303 std::fill_n(s.level.get(), s.count, 0.);
304 std::fill_n(s.guard.get(), s.count, 0.);
305 s.report.reason =
306 s.exclusions.covers(s.spec.frames) ? Reason::AllExcluded : Reason::Disabled;
307 }
308 // Unity belongs to the source control range when PCM is protected.
309 // Otherwise initialize from a real control, not an unused 0 dB value.
310 s.report.minimumGainDb = s.report.maximumGainDb =
311 s.exclusions.view().empty() ? (s.level[0] + s.guard[0]) / logDb : 0;
312 for (std::size_t i = 0; i < s.count; ++i)
313 {
314 const double db = (s.level[i] + s.guard[i]) / logDb;
315 s.report.minimumGainDb = std::min(s.report.minimumGainDb, db);
316 s.report.maximumGainDb = std::max(s.report.maximumGainDb, db);
317 s.changed = s.changed || db != 0;
318 }
319 job.checkpoint(OfflinePhase::Plan, static_cast<std::int64_t>(n),
320 static_cast<std::int64_t>(n));
321 result.status = s.changed ? OfflineStatus::Success : OfflineStatus::NoChange;
322 result.memoryBytes = job.bytes();
323 result.plan = std::move(plan);
324 }
325 catch (...)
326 {
327 result.status = detail::offlineExceptionStatus();
328 }
329 return result;
330 }
331
334 [[nodiscard]] PlanResult analyze(OfflineAudioSource<T> &source, const Options &options = {},
335 const OfflineJobOptions &job = {}) const
336 {
337 PlanResult result;
338 try
339 {
340 validate(options);
341 auto energy = OfflineEnergyAnalyzer<T>().analyzeMidSide(source, job);
342 if (!energy.succeeded())
343 {
344 result.status = energy.status;
345 return result;
346 }
347 result = makePlan(energy.analysis, options,
348 detail::offlineRemaining(job, energy.analysis.retainedBytes()));
349 result.memoryBytes =
350 std::max(energy.memoryBytes, result.memoryBytes + energy.analysis.retainedBytes());
351 }
352 catch (...)
353 {
354 result.status = detail::offlineExceptionStatus();
355 }
356 return result;
357 }
358
361 [[nodiscard]] Result render(OfflineAudioSource<T> &source, const Plan &plan,
363 const OfflineJobOptions &options = {}) const
364 {
365 Result result;
366 try
367 {
368 if (!plan.state_)
370 const auto &s = *plan.state_;
371 result.report = s.report;
372 if (source.getSpec() != s.spec)
374 detail::OfflineSession job(options);
375 auto guards = job.allocate<double>(s.count);
376 auto corrections = job.allocate<double>(s.count);
377 auto measured = job.allocate<Energy>(s.count - 1);
378 std::copy_n(s.guard.get(), s.count, guards.get());
379 SideSource side(source, s, job, options.blockFrames);
380 StereoSink output(source, s, job, options.blockFrames, measured.get());
381 bool publish = !s.options.guard;
382 for (int pass = 0; pass < 17; ++pass)
383 {
384 output.destination = publish ? &sink : nullptr;
385 output.guardRequired = publish && s.options.guard;
386 auto innerJob = detail::offlineRemaining(options, job.bytes());
387 auto rendered = detail::offlineRenderGain<Result>(
388 side, side.getSpec(), s.sideFingerprint, s.sidePeak, true, s.changed,
389 result.report, output, innerJob, [&](detail::OfflineSession &)
390 { return [&](std::int64_t frame) { return control(s, guards.get(), frame); }; },
391 {&s.exclusions, s.feather, false});
392 result.memoryBytes =
393 std::max(result.memoryBytes, job.bytes() + rendered.memoryBytes);
394 result.report.renderInfo = rendered.report.renderInfo;
395 result.report.minimumGainDb =
396 gainToDecibels(rendered.report.renderInfo.minimumControlGain);
397 result.report.maximumGainDb =
398 gainToDecibels(rendered.report.renderInfo.maximumControlGain);
399 output.copyReport(result.report);
400 if (!rendered.succeeded())
401 {
402 result.status = output.failure != OfflineStatus::Success ? output.failure
403 : side.failure != OfflineStatus::Success ? side.failure
404 : rendered.status;
405 return result;
406 }
407 if (publish)
408 {
409 result.status = rendered.status;
410 return result;
411 }
412 ++result.report.guardCalibrationPasses;
413 if (result.report.guardTargetMet)
414 {
415 publish = true;
416 continue;
417 }
418 if (pass == 15 || !refine(s, measured.get(), guards.get(), corrections.get()))
419 break;
420 }
421 if (result.report.reason != Reason::NoMid)
422 result.report.reason = Reason::GuardUnreachable;
423 result.status = OfflineStatus::TargetUnreachable;
424 }
425 catch (...)
426 {
427 result.status = detail::offlineExceptionStatus();
428 }
429 return result;
430 }
431
434 template <int MaxChannels>
435 [[nodiscard]] Result run(const AudioBuffer<T, MaxChannels> &input,
436 AudioBuffer<T, MaxChannels> &output, double sampleRate,
437 const Options &options = {}, const OfflineJobOptions &job = {}) const
438 {
439 return detail::offlineRun(*this, input, output, sampleRate, options, job);
440 }
441
442 private:
443 using State = typename Plan::State;
444 static constexpr double logDb = .1151292546497022842;
445 struct Energy
446 {
447 double mid = 0, side = 0;
448 std::int64_t frames = 0;
449 };
450 static double share(double midRms, double sideRms) noexcept
451 {
452 const double scale = std::max(midRms, sideRms);
453 if (scale == 0)
454 return std::numeric_limits<double>::quiet_NaN();
455 const double m = midRms / scale, s = sideRms / scale;
456 return s * s / (m * m + s * s);
457 }
458 static void validate(const Options &options)
459 {
460 const auto range = [](double x, double low, double high)
461 { return std::isfinite(x) && x >= low && x <= high; };
462 if (!range(options.levelingAmount, 0, 1) || !range(options.guardMarginDb, 0, 12) ||
463 !range(options.maximumBoostDb, 0, 24) || !range(options.maximumCutDb, 0, 60) ||
464 !range(options.maximumGuardCutDb, 0, 120) ||
465 (options.targetSideShare && !range(*options.targetSideShare, .001, .49)))
467 }
468 static void average(const double *input, double *output, std::size_t count, std::size_t radius,
469 const detail::OfflineSession &job)
470 {
471 std::size_t begin = 0, end = 0;
472 double sum = 0;
473 for (std::size_t i = 0; i < count; ++i)
474 {
475 const auto first = i > radius ? i - radius : 0, last = std::min(count, i + radius + 1);
476 while (end < last)
477 sum = detail::advanceBoxAverage(input[end++], 0., sum, 1.);
478 while (begin < first)
479 sum = detail::advanceBoxAverage(0., input[begin++], sum, 1.);
480 if ((i & 1023) == 0)
481 {
482 job.checkpoint(OfflinePhase::Plan, static_cast<std::int64_t>(i),
483 static_cast<std::int64_t>(count));
484 sum = 0;
485 for (auto j = begin; j < end; ++j)
486 sum += input[j];
487 }
488 output[i] = sum / static_cast<double>(end - begin);
489 }
490 }
491 static void limitSlopes(const State &s, double *guard) noexcept
492 {
493 const double step = static_cast<double>(s.hop) / s.spec.sampleRate;
494 for (std::size_t i = 1; i < s.count; ++i)
495 guard[i] = std::min(guard[i], guard[i - 1] + 20 * logDb * step);
496 for (auto i = s.count - 1; i > 0; --i)
497 guard[i - 1] = std::min(guard[i - 1], guard[i] + 60 * logDb * step);
498 }
499 static double control(const State &s, const double *guard, std::int64_t frame) noexcept
500 {
501 if (frame < 0 || frame >= s.spec.frames)
502 return 1;
503 const auto i = static_cast<std::size_t>(frame / s.hop);
504 const double t = static_cast<double>(frame % s.hop) / static_cast<double>(s.hop);
505 const double logGain = std::lerp(s.level[i] + guard[i], s.level[i + 1] + guard[i + 1], t);
506 return s.exclusions.apply(frame, std::exp(logGain), s.feather);
507 }
508 static Energy window(const State &s, const Energy *bins, std::size_t first) noexcept
509 {
510 Energy result;
511 for (auto j = first; j < std::min(s.count - 1, first + s.window); ++j)
512 {
513 result.mid += bins[j].mid;
514 result.side += bins[j].side;
515 result.frames += bins[j].frames;
516 }
517 return result;
518 }
519 static double excess(const State &s, const Energy &e) noexcept
520 {
521 if (e.side <= s.gate * static_cast<double>(e.frames))
522 return -std::numeric_limits<double>::infinity();
523 return e.mid > 0
524 ? 10 / std::log(10.) *
525 (std::log(e.side) - std::log(e.mid) - std::log(s.report.guardCeilingRatio))
526 : std::numeric_limits<double>::infinity();
527 }
528 static bool refine(const State &s, const Energy *bins, double *guards,
529 double *corrections) noexcept
530 {
531 std::fill_n(corrections, s.count, 0.);
532 for (std::size_t i = 0; i + 1 < s.count; ++i)
533 {
534 const double over = excess(s, window(s, bins, i));
535 if (over <= 1e-9)
536 continue;
537 const double correction =
538 std::isfinite(over) ? (over + .01) * logDb : s.options.maximumGuardCutDb * logDb;
539 for (auto j = i; j <= std::min(s.count - 1, i + s.window); ++j)
540 corrections[j] = std::max(corrections[j], correction);
541 }
542 bool changed = false;
543 for (std::size_t j = 0; j < s.count; ++j)
544 {
545 const double next =
546 std::max(-s.options.maximumGuardCutDb * logDb, guards[j] - corrections[j]);
547 changed = changed || next < guards[j];
548 guards[j] = next;
549 }
550 limitSlopes(s, guards);
551 return changed;
552 }
553
554 class SideSource final : public OfflineAudioSource<double>
555 {
556 public:
557 OfflineStatus failure = OfflineStatus::Success;
558 SideSource(OfflineAudioSource<T> &source, const State &state, detail::OfflineSession &job,
559 int block)
560 : source_(source), s_(state), scratch_(job, state.spec, std::min(4096, block))
561 {
562 }
563 OfflineAudioSpec getSpec() const noexcept override
564 {
565 auto spec = source_.getSpec();
566 spec.channels = 1;
567 return spec;
568 }
569 bool read(std::int64_t first, AudioBufferView<double> output) override
570 {
571 try
572 {
573 for (int offset = 0; offset < output.getNumSamples();)
574 {
575 const int count = std::min(scratch_.frames(), output.getNumSamples() - offset);
576 auto input = scratch_.view(count);
577 detail::offlineRead(source_, s_.spec, first + offset, input);
578 for (int i = 0; i < count; ++i)
579 {
580 const double l = input.getChannel(0)[i], r = input.getChannel(1)[i];
581 if (!std::isfinite(l) || !std::isfinite(r))
582 detail::offlineFail(OfflineStatus::NonFiniteInput);
583 output.getChannel(0)[offset + i] = std::midpoint(l, -r);
584 }
585 offset += count;
586 }
587 return true;
588 }
589 catch (...)
590 {
591 failure = detail::offlineExceptionStatus();
592 return false;
593 }
594 }
595
596 private:
597 OfflineAudioSource<T> &source_;
598 const State &s_;
599 detail::OfflineBlock<T> scratch_;
600 };
601
602 class StereoSink final : public OfflineAudioSink<double>
603 {
604 public:
605 OfflineAudioSink<T> *destination = nullptr;
606 OfflineStatus failure = OfflineStatus::Success;
607 bool guardRequired = false;
608 StereoSink(OfflineAudioSource<T> &source, const State &state, detail::OfflineSession &job,
609 int block, Energy *bins)
610 : source_(source), s_(state), scratch_(job, state.spec, std::min(4096, block)),
611 bins_(bins)
612 {
613 }
614 bool begin(const OfflineAudioSpec &) override
615 {
616 next_ = inBin_ = 0;
617 fingerprint_ = {};
618 mid_.reset();
619 side_.reset();
620 totalMid_.reset();
621 totalSide_.reset();
622 detector_.reset();
623 peak_ = truePeak_ = 0;
624 failure = OfflineStatus::Success;
625 return destination ? destination->begin(s_.spec) : true;
626 }
627 bool write(std::int64_t first, AudioBufferView<const double> side) override
628 {
629 try
630 {
631 if (first != next_)
632 detail::offlineFail(OfflineStatus::InvalidInput);
633 for (int offset = 0; offset < side.getNumSamples();)
634 {
635 const int count = std::min(scratch_.frames(), side.getNumSamples() - offset);
636 auto output = scratch_.view(count);
637 detail::offlineRead(source_, s_.spec, first + offset, output);
638 const double scale = s_.peak > 0 ? s_.peak : 1;
639 for (int i = 0; i < count; ++i)
640 {
641 const double l = output.getChannel(0)[i], r = output.getChannel(1)[i];
642 if (!std::isfinite(l) || !std::isfinite(r))
643 detail::offlineFail(OfflineStatus::NonFiniteInput);
644 detail::offlineHash(fingerprint_, output.getChannel(0)[i]);
645 detail::offlineHash(fingerprint_, output.getChannel(1)[i]);
646 const double delta = side.getChannel(0)[offset + i] - std::midpoint(l, -r);
647 if (delta != 0)
648 {
649 const double a = l + delta, b = r - delta;
650 const double maximum =
651 static_cast<double>(std::numeric_limits<T>::max());
652 if (!std::isfinite(a) || !std::isfinite(b) || std::abs(a) > maximum ||
653 std::abs(b) > maximum)
654 detail::offlineFail(OfflineStatus::NumericalFailure);
655 output.getChannel(0)[i] = static_cast<T>(a);
656 output.getChannel(1)[i] = static_cast<T>(b);
657 }
658 const double a = output.getChannel(0)[i], b = output.getChannel(1)[i];
659 const double m = std::midpoint(a, b) / scale,
660 v = std::midpoint(a, -b) / scale;
661 mid_.add(m);
662 side_.add(v);
663 totalMid_.add(m);
664 totalSide_.add(v);
665 peak_ = std::max({peak_, std::abs(a), std::abs(b)});
666 truePeak_ = std::max({truePeak_, detector_.processSample(a / scale, 0),
667 detector_.processSample(b / scale, 1)});
668 ++inBin_;
669 ++next_;
670 if (inBin_ == s_.hop || next_ == s_.spec.frames)
671 {
672 const double frames = static_cast<double>(inBin_);
673 const double mr = mid_.rms(frames), sr = side_.rms(frames);
674 bins_[static_cast<std::size_t>((next_ - 1) / s_.hop)] = {
675 mr * mr * frames, sr * sr * frames, inBin_};
676 inBin_ = 0;
677 mid_.reset();
678 side_.reset();
679 }
680 }
681 if (destination)
682 {
683 std::array<const T *, 2> channels{output.getChannel(0),
684 output.getChannel(1)};
685 detail::offlineCallSink(
686 [&]
687 {
688 return destination->write(first + offset,
689 {channels.data(), 2, count});
690 });
691 }
692 offset += count;
693 }
694 return true;
695 }
696 catch (...)
697 {
698 failure = detail::offlineExceptionStatus();
699 return false;
700 }
701 }
702 bool commit() override
703 {
704 if (next_ != s_.spec.frames || fingerprint_ != s_.fingerprint ||
705 source_.getSpec() != s_.spec)
706 {
707 failure = OfflineStatus::SourceMismatch;
708 return false;
709 }
710 if (guardRequired)
711 for (std::size_t i = 0; i + 1 < s_.count; ++i)
712 if (excess(s_, window(s_, bins_, i)) > 1e-9)
713 {
714 failure = OfflineStatus::TargetUnreachable;
715 return false;
716 }
717 return destination ? destination->commit() : true;
718 }
719 void abort() noexcept override
720 {
721 if (destination)
722 destination->abort();
723 }
724 void copyReport(Report &report)
725 {
726 report.peaksMeasured = next_ == s_.spec.frames;
727 if (!report.peaksMeasured)
728 return;
729 const double silence = -std::numeric_limits<double>::infinity();
730 for (int c = 0; c < 2; ++c)
731 truePeak_ = std::max(truePeak_, detector_.getTailPeak(c));
732 report.outputSamplePeakDb = gainToDecibels(peak_, silence);
733 report.outputTruePeakDb = truePeak_ > 0
734 ? gainToDecibels(truePeak_, silence) +
735 gainToDecibels(s_.peak > 0 ? s_.peak : 1, silence)
736 : silence;
737 report.outputSideShare = share(totalMid_.rms(static_cast<double>(next_)),
738 totalSide_.rms(static_cast<double>(next_)));
739 report.guardMeasured = s_.options.guard;
740 report.guardWindows = report.violatedWindows = 0;
741 report.maximumWindowExcessDb = silence;
742 if (s_.options.guard)
743 for (std::size_t i = 0; i + 1 < s_.count; ++i)
744 {
745 const auto e = window(s_, bins_, i);
746 if (e.side <= s_.gate * static_cast<double>(e.frames))
747 continue;
748 ++report.guardWindows;
749 const double db = excess(s_, e);
750 report.maximumWindowExcessDb = std::max(report.maximumWindowExcessDb, db);
751 report.violatedWindows += db > 1e-9;
752 }
753 report.guardTargetMet = s_.options.guard && report.violatedWindows == 0;
754 }
755
756 private:
757 OfflineAudioSource<T> &source_;
758 const State &s_;
759 detail::OfflineBlock<T> scratch_;
760 Energy *bins_;
761 OfflineFingerprint fingerprint_;
762 std::int64_t next_ = 0, inBin_ = 0;
763 detail::ScaledSumSquares mid_, side_, totalMid_, totalSide_;
764 TruePeakDetector<double, 2> detector_;
765 double peak_ = 0, truePeak_ = 0;
766 };
767};
768} // namespace dspark
769#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 10 ms stereo energy map, bound to the supplied PCM/clock.
OfflineFingerprint sideFingerprint() const noexcept
Fingerprint of double-precision side samples, for shared gain rendering.
OfflineFingerprint fingerprint() const noexcept
std::span< const MidSideBin > bins() const noexcept
Borrowed bins; valid while this analysis is alive and unmoved.
Plan(Plan &&) noexcept=default
double gainAt(std::int64_t frame) const noexcept
Planned scalar gain; rendering may add reported guard attenuation.
OfflineAudioSpec getSpec() const noexcept
std::size_t retainedBytes() const noexcept
std::span< const OfflineRegion > exclusions() const noexcept
Optional offline balance of an already generated stereo signal.
Result run(const AudioBuffer< T, MaxChannels > &input, AudioBuffer< T, MaxChannels > &output, double sampleRate, const Options &options={}, const OfflineJobOptions &job={}) const
Owning convenience render; output changes only after successful verification.
@ NoSide
Side power is at or below the analysis gate.
@ NoMid
Mid power is at or below the analysis gate.
PlanResult makePlan(const typename OfflineEnergyAnalyzer< T >::MidSideAnalysis &analysis, const Options &options={}, const OfflineJobOptions &jobOptions={}) const
Replans controls from an immutable complete-source mid/side analysis.
PlanResult analyze(OfflineAudioSource< T > &source, const Options &options={}, const OfflineJobOptions &job={}) const
Analyzes the supplied post-generation stereo source and builds its gain plan.
Result render(OfflineAudioSource< T > &source, const Plan &plan, OfflineAudioSink< T > &sink, const OfflineJobOptions &options={}) const
Verifies, optionally calibrates the guard, and publishes transactionally.
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)
OfflineJobOptions offlineRemaining(const OfflineJobOptions &job, std::size_t used)
OfflineStatus offlineExceptionStatus() noexcept
double advanceBoxAverage(double incoming, double outgoing, double &sum, double inverseLength) noexcept
Definition BoxAverage.h:12
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.
@ TargetUnreachable
Exact protected PCM prevents the requested processing target.
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 maximumBoostDb
0..24 dB leveler boost cap.
bool guard
Verify the actual windowed side/mid energy ceiling.
double levelingAmount
0..1 of the measured log-gain correction.
double maximumGuardCutDb
0..120 dB additional guard attenuation cap.
double maximumCutDb
0..60 dB leveler attenuation cap.
double guardMarginDb
0..12 dB above the target side/mid power ratio.
std::span< const OfflineRegion > exclusions
Exact source-relative protected PCM.
double maximumGainDb
Planned bounds; rendered bounds after render.