DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
OfflineStereoGenerator.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
24#include "../Analysis/OfflineEnergyAnalyzer.h"
25#if DSPARK_HAS_OFFLINE
26#include "../Core/TruePeakDetector.h"
27#include "../Core/detail/OfflineStereo.h"
28#include <algorithm>
29#include <cmath>
30#include <cstddef>
31#include <cstdint>
32#include <limits>
33#include <memory>
34#include <optional>
35#include <span>
36#include <utility>
37
38namespace dspark
39{
41template <FloatType T> class OfflineStereoGenerator final
42{
43 public:
45 struct Options
46 {
47 float width = 0;
49 float lowCutHz = 0;
50 bool duplicateMono = false;
51 std::span<const OfflineRegion> exclusions{};
52 };
53 enum class Reason : std::uint8_t
54 {
55 Ready,
60 };
61 struct Report
62 {
64 float width = 0;
66 double inputSamplePeakDb = -std::numeric_limits<double>::infinity();
67 double outputSamplePeakDb = -std::numeric_limits<double>::infinity();
68 double outputTruePeakDb = -std::numeric_limits<double>::infinity();
70 bool duplicatedMono = false;
71 bool usedDeltaCache = false;
72 bool peaksMeasured = false;
73 };
74
76 class Plan final
77 {
78 public:
79 Plan() = default;
80 Plan(Plan &&) noexcept = default;
81 Plan &operator=(Plan &&) noexcept = default;
82 [[nodiscard]] bool isValid() const noexcept
83 {
84 return state_ != nullptr;
85 }
86 [[nodiscard]] OfflineAudioSpec getSpec() const noexcept
87 {
88 return state_ ? state_->spec : OfflineAudioSpec{};
89 }
90 [[nodiscard]] OfflineAudioSpec getOutputSpec() const noexcept
91 {
92 return state_ ? state_->outputSpec : OfflineAudioSpec{};
93 }
94 [[nodiscard]] Report getReport() const noexcept
95 {
96 return state_ ? state_->report : Report{};
97 }
98 [[nodiscard]] std::size_t retainedBytes() const noexcept
99 {
100 return state_ ? sizeof(State) + state_->exclusions.retainedBytes() : 0;
101 }
103 [[nodiscard]] std::span<const OfflineRegion> exclusions() const noexcept
104 {
105 return state_ ? state_->exclusions.view() : std::span<const OfflineRegion>{};
106 }
107
108 private:
110 struct State
111 {
112 OfflineAudioSpec spec, outputSpec;
113 OfflineFingerprint fingerprint;
114 detail::OfflineExclusions exclusions;
115 Report report;
116 double inputPeak = 0, featherFrames = 1;
117 bool active = false;
118 };
119 std::unique_ptr<State> state_;
120 };
121
128 class DeltaCache final
129 {
130 public:
131 DeltaCache() = default;
132 DeltaCache(DeltaCache &&) noexcept = default;
133 DeltaCache &operator=(DeltaCache &&) noexcept = default;
134 [[nodiscard]] bool isValid() const noexcept
135 {
136 return state_ != nullptr;
137 }
138 [[nodiscard]] OfflineAudioSpec getSourceSpec() const noexcept
139 {
140 return state_ ? state_->sourceSpec : OfflineAudioSpec{};
141 }
142 [[nodiscard]] OfflineAudioSpec getSpec() const noexcept
143 {
144 return state_ ? state_->deltaSpec : OfflineAudioSpec{};
145 }
146 [[nodiscard]] GenerationOptions getGenerationOptions() const noexcept
147 {
148 return state_ ? state_->generation : GenerationOptions{};
149 }
150 [[nodiscard]] std::size_t retainedBytes() const noexcept
151 {
152 return state_ ? sizeof(State) : 0;
153 }
154
155 private:
157 struct State
158 {
159 OfflineAudioSpec sourceSpec, deltaSpec;
160 OfflineFingerprint sourceFingerprint, deltaFingerprint;
161 GenerationOptions generation;
162 double inputPeak = 0, deltaPeak = 0;
163 int latency = 0;
164 };
165 std::unique_ptr<State> state_;
166 };
168 {
171 std::size_t memoryBytes = 0;
172 [[nodiscard]] bool succeeded() const noexcept
173 {
174 return offlineSucceeded(status);
175 }
176 };
177 struct Result
178 {
181 std::size_t memoryBytes = 0;
182 [[nodiscard]] bool succeeded() const noexcept
183 {
184 return offlineSucceeded(status);
185 }
186 };
188 {
191 std::size_t memoryBytes = 0;
192 [[nodiscard]] bool succeeded() const noexcept
193 {
194 return offlineSucceeded(status);
195 }
196 };
197
199 [[nodiscard]] PlanResult analyze(OfflineAudioSource<T> &source, const Options &options = {},
200 const OfflineJobOptions &jobOptions = {}) const
201 {
202 PlanResult result;
203 try
204 {
205 const auto spec = source.getSpec();
206 validate(spec, options);
207 detail::OfflineSession job(jobOptions);
208 job.checkpoint(OfflinePhase::Analyze, 0, spec.frames);
209 detail::OfflineBlock<T> block(job, spec, std::min(4096, jobOptions.blockFrames));
210 const auto summary = detail::offlineScanSource(
211 source, spec, block, job, OfflinePhase::Analyze, [](std::int64_t, double) {});
212 result.plan = createPlan(source, spec, summary.fingerprint, summary.peak, options, job);
213 result.status =
214 result.plan.state_->active ? OfflineStatus::Success : OfflineStatus::NoChange;
215 result.memoryBytes = job.bytes();
216 }
217 catch (...)
218 {
219 result.status = detail::offlineExceptionStatus();
220 }
221 return result;
222 }
223
226 const typename OfflineEnergyAnalyzer<T>::Analysis &analysis,
227 const Options &options = {},
228 const OfflineJobOptions &jobOptions = {}) const
229 {
230 PlanResult result;
231 try
232 {
233 if (!analysis.isValid())
235 detail::OfflineSession job(jobOptions);
236 result.plan = createPlan(source, analysis.getSpec(), analysis.fingerprint(),
237 analysis.samplePeak(), options, job);
238 result.status =
239 result.plan.state_->active ? OfflineStatus::Success : OfflineStatus::NoChange;
240 result.memoryBytes = job.bytes();
241 }
242 catch (...)
243 {
244 result.status = detail::offlineExceptionStatus();
245 }
246 return result;
247 }
248
250 [[nodiscard]] PlanResult replan(OfflineAudioSource<T> &source, const Plan &previous,
251 const Options &options,
252 const OfflineJobOptions &jobOptions = {}) const
253 {
254 PlanResult result;
255 try
256 {
257 if (!previous.isValid())
259 detail::OfflineSession job(jobOptions);
260 const auto &old = *previous.state_;
261 result.plan =
262 createPlan(source, old.spec, old.fingerprint, old.inputPeak, options, job);
263 result.status =
264 result.plan.state_->active ? OfflineStatus::Success : OfflineStatus::NoChange;
265 result.memoryBytes = job.bytes();
266 }
267 catch (...)
268 {
269 result.status = detail::offlineExceptionStatus();
270 }
271 return result;
272 }
273
275 [[nodiscard]] Result render(OfflineAudioSource<T> &source, const Plan &plan,
277 const OfflineJobOptions &jobOptions = {}) const
278 {
279 return renderImpl(source, plan, sink, nullptr, nullptr, jobOptions);
280 }
281
287 [[nodiscard]] CacheResult buildDeltaCache(OfflineAudioSource<T> &source, const Plan &plan,
289 const OfflineJobOptions &jobOptions = {}) const
290 {
291 CacheResult result;
293 try
294 {
295 if (!plan.isValid())
297 const auto &state = *plan.state_;
298 if (source.getSpec() != state.spec)
300 detail::OfflineSession job(jobOptions);
301 job.checkpoint(OfflinePhase::Verify, 0, state.spec.frames);
302 job.charge(sizeof(typename DeltaCache::State));
303 DeltaCache candidate;
304 candidate.state_ = std::make_unique<typename DeltaCache::State>();
305 auto &cache = *candidate.state_;
306 cache.sourceSpec = state.spec;
307 cache.deltaSpec = state.spec;
308 cache.deltaSpec.channels = 1;
309 cache.sourceFingerprint = state.fingerprint;
310 cache.generation = state.report.generation;
311 cache.inputPeak = state.inputPeak;
312 detail::OfflineStereoStream<T> stream(source, state.spec, state.fingerprint,
313 state.inputPeak, cache.generation,
314 state.inputPeak > 0, job, jobOptions.blockFrames);
315 cache.latency = stream.latency();
316 result.memoryBytes = job.bytes();
317 transaction.sink = &sink;
318 detail::offlineCallSink([&] { return sink.begin(cache.deltaSpec); });
319 stream.run([&](std::int64_t first, AudioBufferView<T>, std::span<const double> delta) {
320 for (const double sample : delta)
321 {
322 if (!std::isfinite(sample))
324 detail::offlineHash(cache.deltaFingerprint, sample);
325 cache.deltaPeak = std::max(cache.deltaPeak, std::abs(sample));
326 }
327 const double *channels[]{delta.data()};
329 return sink.write(first, {channels, 1, static_cast<int>(delta.size())});
330 });
331 });
332 job.checkpoint(OfflinePhase::Render, state.spec.frames, state.spec.frames);
333 if (source.getSpec() != state.spec)
335 detail::offlineCallSink([&] { return sink.commit(); });
336 transaction.sink = nullptr;
337 result.status = cache.deltaPeak > 0 ? OfflineStatus::Success : OfflineStatus::NoChange;
338 result.cache = std::move(candidate);
339 }
340 catch (...)
341 {
342 result.status = detail::offlineExceptionStatus();
343 }
344 return result;
345 }
346
351 [[nodiscard]] Result renderCached(OfflineAudioSource<T> &source, const Plan &plan,
352 OfflineAudioSource<double> &delta, const DeltaCache &cache,
354 const OfflineJobOptions &jobOptions = {}) const
355 {
356 return renderImpl(source, plan, sink, &delta, &cache, jobOptions);
357 }
358
360 template <int MaxChannels>
361 [[nodiscard]] Result run(const AudioBuffer<T, MaxChannels> &input,
362 AudioBuffer<T, MaxChannels> &output, double sampleRate,
363 const Options &options = {}, const OfflineJobOptions &job = {}) const
364 {
365 return detail::offlineRun(*this, input, output, sampleRate, options, job);
366 }
367
368 private:
369 static void validate(OfflineAudioSpec spec, const Options &options)
370 {
372 if (spec.channels == 1 && !options.duplicateMono)
374 if (!std::isfinite(options.width) || (options.width < 0) || (options.width > 1) ||
375 StereoGenerator<double>::getPrepareMemoryBound(
376 {spec.sampleRate, 256, 2}, {options.oversamplingFactor, options.lowCutHz}) == 0)
378 }
379
380 static Plan createPlan(OfflineAudioSource<T> &source, OfflineAudioSpec spec,
381 OfflineFingerprint fingerprint, double peak, const Options &options,
382 detail::OfflineSession &job)
383 {
384 validate(spec, options);
385 if (source.getSpec() != spec)
387 job.checkpoint(OfflinePhase::Plan, 0, spec.frames);
388 job.charge(sizeof(typename Plan::State));
389 Plan plan;
390 plan.state_ = std::make_unique<typename Plan::State>();
391 auto &state = *plan.state_;
392 state.spec = spec;
393 state.outputSpec = spec;
394 state.outputSpec.channels = 2;
395 state.fingerprint = fingerprint;
396 state.inputPeak = peak;
397 state.featherFrames = std::max(1., std::round(.005 * spec.sampleRate));
398 state.exclusions.assign(job, options.exclusions, spec.frames);
399 auto &report = state.report;
400 report.width = options.width;
401 report.generation = {options.oversamplingFactor, options.lowCutHz};
402 report.inputSamplePeakDb = gainToDecibels(peak, -std::numeric_limits<double>::infinity());
403 report.duplicatedMono = spec.channels == 1;
404 const bool excluded = state.exclusions.covers(spec.frames);
405 state.active = options.width > 0 && peak > 0 && !excluded;
406 report.reason = options.width == 0 ? Reason::ZeroWidth
407 : peak == 0 ? Reason::NoSignal
408 : excluded ? Reason::AllExcluded
410 job.checkpoint(OfflinePhase::Plan, spec.frames, spec.frames);
411 if (source.getSpec() != spec)
413 return plan;
414 }
415
416 static Result renderImpl(OfflineAudioSource<T> &source, const Plan &plan,
417 OfflineAudioSink<T> &sink, OfflineAudioSource<double> *deltaSource,
418 const DeltaCache *certificate, const OfflineJobOptions &options)
419 {
420 Result result;
421 detail::OfflineSinkTransaction<T> transaction;
422 try
423 {
424 if (!plan.isValid() || (certificate && !certificate->isValid()))
426 const auto &state = *plan.state_;
427 const auto &spec = state.spec;
428 if (source.getSpec() != spec)
430 const auto *cache = certificate ? certificate->state_.get() : nullptr;
431 if (cache &&
432 (cache->sourceSpec != spec || cache->sourceFingerprint != state.fingerprint ||
433 cache->inputPeak != state.inputPeak ||
434 cache->generation != state.report.generation ||
435 deltaSource->getSpec() != cache->deltaSpec))
437 result.report = state.report;
438 auto &report = result.report;
439 report.usedDeltaCache = cache != nullptr;
440 detail::OfflineSession job(options);
441 job.checkpoint(OfflinePhase::Verify, 0, spec.frames);
442 const int limit = std::min(256, options.blockFrames);
443 detail::OfflineBlock<T> output(job, state.outputSpec, limit);
444 std::optional<detail::OfflineStereoStream<T>> stream;
445 std::optional<detail::OfflineBlock<T>> cacheOriginal;
446 std::optional<detail::OfflineBlock<double>> cacheDelta;
447 if (cache)
448 {
449 cacheOriginal.emplace(job, spec, limit);
450 cacheDelta.emplace(job, cache->deltaSpec, limit);
451 report.compensatedLatencyFrames = cache->latency;
452 }
453 else
454 {
455 stream.emplace(source, spec, state.fingerprint, state.inputPeak, report.generation,
456 state.active, job, limit);
457 report.compensatedLatencyFrames = stream->latency();
458 }
459 result.memoryBytes = job.bytes();
460 TruePeakDetector<double, 2> detector;
461 const double scale = state.inputPeak > 0 ? state.inputPeak : 1;
462 double peak = 0, truePeak = 0;
463 transaction.sink = &sink;
464 detail::offlineCallSink([&] { return sink.begin(state.outputSpec); });
465 const auto compose = [&](std::int64_t first, AudioBufferView<T> raw,
466 std::span<const double> delta) {
467 const int count = raw.getNumSamples();
468 auto out = output.view(count);
469 for (int i = 0; i < count; ++i)
470 {
471 const double mask =
472 state.active ? 1 - state.exclusions.apply(first + i, 0, state.featherFrames)
473 : 0;
474 const double change = mask == 0 ? 0 : delta[i] * report.width * mask;
475 for (int c = 0; c < 2; ++c)
476 {
477 const T original = raw.getChannel(c % spec.channels)[i];
478 T value = original;
479 if (change != 0)
480 {
481 const double processed =
482 static_cast<double>(original) + (c == 0 ? change : -change);
483 if (!std::isfinite(processed) ||
484 std::abs(processed) > std::numeric_limits<T>::max())
486 value = static_cast<T>(processed);
487 }
488 out.getChannel(c)[i] = value;
489 const double normalized = static_cast<double>(value) / scale;
490 peak = std::max(peak, std::abs(normalized));
491 truePeak = std::max(truePeak, detector.processSample(normalized, c));
492 }
493 }
494 detail::offlineCallSink([&] { return sink.write(first, out); });
495 };
496 if (stream)
497 stream->run(compose);
498 else
499 {
500 OfflineFingerprint originalFingerprint, deltaFingerprint;
501 job.checkpoint(OfflinePhase::Render, 0, spec.frames);
502 for (std::int64_t first = 0; first < spec.frames;)
503 {
504 const int count = static_cast<int>(
505 std::min<std::int64_t>(cacheOriginal->frames(), spec.frames - first));
506 auto original = cacheOriginal->view(count);
507 auto delta = cacheDelta->view(count);
508 detail::offlineRead(source, spec, first, original);
509 detail::offlineRead(*deltaSource, cache->deltaSpec, first, delta);
510 for (int i = 0; i < count; ++i)
511 {
512 for (int c = 0; c < spec.channels; ++c)
513 {
514 const T sample = original.getChannel(c)[i];
515 if (!std::isfinite(sample))
517 if (std::abs(static_cast<double>(sample)) > state.inputPeak)
519 detail::offlineHash(originalFingerprint, sample);
520 }
521 const double sample = delta.getChannel(0)[i];
522 if (!std::isfinite(sample) || std::abs(sample) > cache->deltaPeak)
524 detail::offlineHash(deltaFingerprint, sample);
525 }
526 compose(first, original,
527 {delta.getChannel(0), static_cast<std::size_t>(count)});
528 first += count;
529 job.checkpoint(OfflinePhase::Render, first, spec.frames);
530 }
531 if (originalFingerprint != state.fingerprint ||
532 deltaFingerprint != cache->deltaFingerprint || source.getSpec() != spec ||
533 deltaSource->getSpec() != cache->deltaSpec)
535 }
536 for (int i = 0; i < TruePeakDetector<double, 2>::getTaps() - 1; ++i)
537 for (int c = 0; c < 2; ++c)
538 truePeak = std::max(truePeak, detector.processSample(0, c));
539 if (!std::isfinite(peak) || !std::isfinite(truePeak))
541 constexpr double silence = -std::numeric_limits<double>::infinity();
542 report.outputSamplePeakDb =
543 peak > 0 ? gainToDecibels(peak, silence) + gainToDecibels(scale, silence) : silence;
544 report.outputTruePeakDb =
545 truePeak > 0 ? gainToDecibels(truePeak, silence) + gainToDecibels(scale, silence)
546 : silence;
547 report.peaksMeasured = true;
548 if (state.active)
549 report.reason = Reason::Generated;
550 job.checkpoint(OfflinePhase::Render, spec.frames, spec.frames);
551 if (source.getSpec() != spec || (cache && deltaSource->getSpec() != cache->deltaSpec))
553 detail::offlineCallSink([&] { return sink.commit(); });
554 transaction.sink = nullptr;
555 result.status = state.active ? OfflineStatus::Success : OfflineStatus::NoChange;
556 }
557 catch (...)
558 {
559 result.status = detail::offlineExceptionStatus();
560 }
561 return result;
562 }
563};
564} // namespace dspark
565#endif // DSPARK_HAS_OFFLINE
Owning audio buffer with contiguous, 32-byte aligned storage.
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.
Read-only source-bound energy map; owns bins, never source samples.
bool isValid() const noexcept
False for a failed or moved-from result.
OfflineFingerprint fingerprint() const noexcept
OfflineAudioSpec getSpec() const noexcept
Certificate for caller-owned, mono-double canonical delta storage. Width and exclusions are deliberat...
DeltaCache(DeltaCache &&) noexcept=default
OfflineAudioSpec getSourceSpec() const noexcept
GenerationOptions getGenerationOptions() const noexcept
Owned controls/exclusions bound to a complete source fingerprint.
OfflineAudioSpec getOutputSpec() const noexcept
Plan(Plan &&) noexcept=default
std::size_t retainedBytes() const noexcept
OfflineAudioSpec getSpec() const noexcept
std::span< const OfflineRegion > exclusions() const noexcept
Borrowed regions, valid while the plan remains alive and unmoved.
Offline processed-copy stereo generation with a one-width control.
PlanResult analyze(OfflineAudioSource< T > &source, const Options &options={}, const OfflineJobOptions &jobOptions={}) const
Scans the complete source once for provenance/peak; no feature map is built.
Result render(OfflineAudioSource< T > &source, const Plan &plan, OfflineAudioSink< T > &sink, const OfflineJobOptions &jobOptions={}) const
Generates and composes stereo, verifying rounded output before commit.
PlanResult replan(OfflineAudioSource< T > &source, const Plan &previous, const Options &options, const OfflineJobOptions &jobOptions={}) const
Changes controls/exclusions without another scan; render verifies the source.
Result renderCached(OfflineAudioSource< T > &source, const Plan &plan, OfflineAudioSource< double > &delta, const DeltaCache &cache, OfflineAudioSink< T > &sink, const OfflineJobOptions &jobOptions={}) const
Composes verified source/delta PCM with new width/exclusions in O(frames). Both streams are verified ...
Result run(const AudioBuffer< T, MaxChannels > &input, AudioBuffer< T, MaxChannels > &output, double sampleRate, const Options &options={}, const OfflineJobOptions &job={}) const
Owning render; failure preserves output, including in-place and mono duplication.
StereoGenerator< double >::Options GenerationOptions
CacheResult buildDeltaCache(OfflineAudioSource< T > &source, const Plan &plan, OfflineAudioSink< double > &sink, const OfflineJobOptions &jobOptions={}) const
Writes a canonical unit-width delta to a transactional host sink. Exclusions and requested width do n...
PlanResult makePlan(OfflineAudioSource< T > &source, const typename OfflineEnergyAnalyzer< T >::Analysis &analysis, const Options &options={}, const OfflineJobOptions &jobOptions={}) const
Reuses a complete energy analysis; render still verifies source PCM.
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 offlineCallSink(Function &&function)
void offlineRead(OfflineAudioSource< T > &source, const OfflineAudioSpec &expected, std::int64_t first, AudioBufferView< T > block)
void offlineValidateSpec(const OfflineAudioSpec &spec, int maximumChannels=16)
void offlineHash(OfflineFingerprint &state, T sample) noexcept
OfflineSourceSummary offlineScanSource(OfflineAudioSource< T > &source, const OfflineAudioSpec &spec, OfflineBlock< T > &scratch, const OfflineSession &job, OfflinePhase phase, Consume consume)
OfflineStatus offlineExceptionStatus() noexcept
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.
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.
Resource and cooperative-cancellation controls for one worker operation.
float width
0..1; zero returns exact original PCM, optionally duplicated.
std::span< const OfflineRegion > exclusions
Copied source-relative regions.
int oversamplingFactor
Local color factor: 1, 2, 4, 8 or 16.
bool duplicateMono
Explicit permission to turn mono into two channels.
float lowCutHz
Generated delta only; off or 20..5000 Hz.
int compensatedLatencyFrames
Alignment delay already removed from output/cache.
Configuration applied by prepare(), never by the audio callback.