DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
OfflineClipProjection.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
12#include "OfflineSinc.h"
13#if DSPARK_HAS_OFFLINE
14#include "../FIRFilter.h"
15#include "ContinuousClip.h"
16#include <algorithm>
17#include <array>
18#include <atomic>
19#include <cmath>
20#include <cstdint>
21#include <limits>
22#include <memory>
23#include <numbers>
24#include <optional>
25#include <span>
26#include <utility>
27#include <vector>
28
30{
33
34enum class Exterior
35{
36 Linear,
38};
39
40// Shared reconstructed clipping stage. Both branches use the same interpolation,
41// B-spline moments and compensation. The source/projection boundary policy is
42// supplied by BoundedClip; this stage alone does not perform sample-rate conversion.
43template <ClipperCurve C> class ReconstructedStage final
44{
45 static constexpr int degree = 11;
48 FIRFilter<double> eq_, linear_;
49 int order_, filterSize_;
50
51 public:
53 : order_(factor <= 2 ? 18 : 10), filterSize_(2 * order_ + degree + 4)
54 {
55 const int tapsSize = 2 * order_ + 1, linearSize = degree + 4;
56 job.charge(static_cast<std::size_t>(6 * (order_ + 1) + (order_ + 2) * tapsSize +
57 linearSize + filterSize_) *
58 sizeof(double) +
59 static_cast<std::size_t>(order_ + 24) * 64 +
60 static_cast<std::size_t>(tapsSize + filterSize_) *
61 (sizeof(std::atomic<double>) + 4 * sizeof(double)) +
62 2 * sizeof(int));
63 const auto taps = continuous_clip::inverseBoxPower(order_),
64 linear = residual_.linearKernel();
65 std::vector<double> h(taps.size() + linear.size() - 1);
66 for (std::size_t i = 0; i < taps.size(); ++i)
67 for (std::size_t j = 0; j < linear.size(); ++j)
68 h[i + j] += taps[i] * linear[j];
69 eq_.prepare(tapsSize, 1);
70 eq_.setCoefficients(taps);
71 linear_.prepare(filterSize_, 1);
72 linear_.setCoefficients(h);
73 }
74 void reset(double ceiling) noexcept
75 {
76 residual_.reset(ceiling);
77 direct_.reset(ceiling);
78 eq_.reset();
79 linear_.reset();
80 }
81 [[nodiscard]] int filterSize() const noexcept
82 {
83 return filterSize_;
84 }
85 [[nodiscard]] int delay() const noexcept
86 {
87 return (degree + 1) / 2 + 1 + order_;
88 }
89 void process(double *nonlinear, double *reference, int count, Exterior exterior)
90 {
91 for (int i = 0; i < count; ++i)
92 {
93 nonlinear[i] = exterior == Exterior::Matched ? direct_.process(nonlinear[i])
94 : residual_.process(nonlinear[i]);
95 if (!std::isfinite(nonlinear[i]))
97 }
98 double *a[]{nonlinear}, *b[]{reference};
99 eq_.processBlock({a, 1, count});
100 linear_.processBlock({b, 1, count});
101 }
102};
103
104// The only duration-sized storage is the existing
105// coarse Cauchy tree. Upconverted PCM and shaped PCM use three-leaf caches.
106template <ClipperCurve C, class Reader> class BoundedClip
107{
108 static constexpr double slope = clipperSmallSignalSlope<C, double>();
109 OfflineSession &job_;
110 Reader source_;
111 std::int64_t frames_, pad_, extent_;
112 int factor_, block_, highBlock_, delay_ = 0, left_ = 0, filterSize_ = 0;
113 double ceiling_;
114 Exterior exterior_;
116 struct PaddedReader
117 {
118 BoundedClip *owner;
119 bool difference;
120 void operator()(std::int64_t first, int count, double *out)
121 {
122 owner->readPadded(first, count, out, difference);
123 }
124 } raw_{this, false}, difference_{this, true};
125 std::optional<OfflineHilbertMap> rawMap_, differenceMap_;
126 std::array<std::optional<SincPhase>, 16> phases_;
127 OfflineScratchArray<double> native_, up_, shaped_, scratchA_, scratchB_, phaseWork_, output_;
128 std::array<std::int64_t, 3> nativeLeaf_{-1, -1, -1};
129 std::array<std::int64_t, 3> upLeaf_{-1, -1, -1}, shapedLeaf_{-1, -1, -1};
131 std::uint64_t evaluations_ = 0;
132 struct ProjectedReader
133 {
134 BoundedClip *owner;
135 void operator()(std::int64_t first, int count, double *out)
136 {
137 owner->projectedSource(first, count, out);
138 }
139 };
140 std::optional<SincProjection<ProjectedReader>> projection_;
141
142 static std::int64_t checkedExtent(std::int64_t frames, std::int64_t pad, int factor, int block)
143 {
144 if (frames < 1 || pad < 64 || factor < 1 || factor > 16 || (factor & (factor - 1)) ||
145 block < 128 || block > 16384 || block * factor > 65536 || (block & (block - 1)))
147 constexpr auto maximum = std::numeric_limits<std::int64_t>::max();
148 if (pad > (maximum / factor - frames) / 2 || frames > maximum / factor)
150 return frames + 2 * pad;
151 }
152 void readPadded(std::int64_t first, int count, double *out, bool difference)
153 {
154 while (count > 0)
155 {
156 const auto leaf = first / block_;
157 const int offset = static_cast<int>(first % block_),
158 length = std::min(count, block_ - offset);
159 const auto slot = static_cast<std::size_t>(leaf % 3);
160 auto *data = native_.get() + slot * 2 * block_;
161 if (nativeLeaf_[slot] != leaf)
162 {
163 std::fill_n(data, 2 * block_, 0.);
164 const auto origin = leaf * block_;
165 const auto begin = std::max(origin, pad_),
166 end = std::min(origin + block_, pad_ + frames_);
167 if (begin < end)
168 {
169 const int at = static_cast<int>(begin - origin),
170 n = static_cast<int>(end - begin);
171 for (int done = 0; done < n;)
172 {
173 const int chunk = std::min(n - done, 4096);
174 source_(begin - pad_ + done, chunk, data + at + done);
175 done += chunk;
176 }
177 for (int i = at; i < at + n; ++i)
178 {
179 if (!std::isfinite(data[i]))
181 const double shaped = clipperShape<C>(data[i], ceiling_);
182 data[block_ + i] =
183 exterior_ == Exterior::Matched ? -shaped : slope * data[i] - shaped;
184 }
185 }
186 nativeLeaf_[slot] = leaf;
187 }
188 std::copy_n(data + (difference ? block_ : 0) + offset, length, out);
189 first += length;
190 count -= length;
191 out += length;
192 }
193 }
194 double *upsampled(std::int64_t leaf)
195 {
196 const auto slot = static_cast<std::size_t>(leaf % 3);
197 auto *data = up_.get() + slot * 2 * highBlock_;
198 if (upLeaf_[slot] == leaf)
199 return data;
200 for (int p = 0; p < factor_; ++p)
201 {
202 phases_[p]->evaluate(*rawMap_, static_cast<std::size_t>(leaf), raw_, phaseWork_.get());
203 for (int i = 0; i < block_; ++i)
204 data[i * factor_ + p] = phaseWork_[i];
205 phases_[p]->evaluate(*differenceMap_, static_cast<std::size_t>(leaf), difference_,
206 phaseWork_.get());
207 for (int i = 0; i < block_; ++i)
208 data[highBlock_ + i * factor_ + p] = phaseWork_[i];
209 }
210 upLeaf_[slot] = leaf;
211 return data;
212 }
213 void highInput(std::int64_t first, int count, double *a, double *b)
214 {
215 const auto highExtent = extent_ * factor_;
216 while (count > 0)
217 {
218 if (first < 0)
219 {
220 const int n = static_cast<int>(std::min<std::int64_t>(-first, count));
221 std::fill_n(a, n, 0.);
222 std::fill_n(b, n, 0.);
223 first += n;
224 count -= n;
225 a += n;
226 b += n;
227 continue;
228 }
229 if (first >= highExtent)
230 {
231 std::fill_n(a, count, 0.);
232 std::fill_n(b, count, 0.);
233 break;
234 }
235 const auto leaf = first / highBlock_;
236 const int offset = static_cast<int>(first % highBlock_);
237 const int n = static_cast<int>(
238 std::min<std::int64_t>(std::min(highBlock_ - offset, count), highExtent - first));
239 const auto *data = upsampled(leaf);
240 std::copy_n(data + offset, n, a);
241 std::copy_n(data + highBlock_ + offset, n, b);
242 first += n;
243 count -= n;
244 a += n;
245 b += n;
246 }
247 }
248 const double *stage(std::int64_t leaf)
249 {
250 const auto slot = static_cast<std::size_t>(leaf % 3);
251 auto *data = shaped_.get() + slot * 2 * highBlock_;
252 if (shapedLeaf_[slot] == leaf)
253 return data;
254 const auto first = leaf * highBlock_;
255 const int length = highBlock_ + filterSize_ - 1;
256 highInput(first - left_, length, scratchA_.get(), scratchB_.get());
257 stage_.reset(ceiling_);
258 stage_.process(scratchA_.get(), scratchB_.get(), length, exterior_);
259 std::copy_n(scratchA_.get() + filterSize_ - 1, highBlock_, data);
260 std::copy_n(scratchB_.get() + filterSize_ - 1, highBlock_, data + highBlock_);
261 shapedLeaf_[slot] = leaf;
262 ++evaluations_;
263 return data;
264 }
265 void projectedSource(std::int64_t first, int count, double *out)
266 {
267 while (count > 0)
268 {
269 const auto leaf = first / highBlock_;
270 const int offset = static_cast<int>(first % highBlock_);
271 const int n = std::min(highBlock_ - offset, count);
272 const auto *data = stage(leaf);
273 for (int i = 0; i < n; ++i)
274 out[i] = data[offset + i] +
275 (exterior_ == Exterior::Matched ? data[highBlock_ + offset + i] : 0.);
276 first += n;
277 count -= n;
278 out += n;
279 }
280 }
281
282 public:
283 BoundedClip(OfflineSession &job, Reader reader, std::int64_t frames, std::int64_t pad,
284 int factor, double ceiling, Exterior exterior, int block = 512)
285 : job_(job), source_(std::move(reader)), frames_(frames), pad_(pad),
286 extent_(checkedExtent(frames, pad, factor, block)), factor_(factor), block_(block),
287 highBlock_(block * factor), ceiling_(ceiling), exterior_(exterior),
288 work_(job, extent_, block, OfflineProductWorkspace::NearFields::External),
289 stage_(job, factor)
290 {
291 if (!(ceiling > 0) || !std::isfinite(ceiling))
293 native_ = job.allocateScratch<double>(6 * static_cast<std::uint64_t>(block_));
294 rawMap_.emplace(work_, raw_);
295 differenceMap_.emplace(work_, difference_);
296 for (int p = 0; p < factor; ++p)
297 phases_[p].emplace(work_, double(p) / factor);
298 filterSize_ = stage_.filterSize();
299 delay_ = stage_.delay();
300 left_ = filterSize_ - 1 - delay_;
301 up_ = job.allocateScratch<double>(6 * static_cast<std::uint64_t>(highBlock_));
302 shaped_ = job.allocateScratch<double>(6 * static_cast<std::uint64_t>(highBlock_));
303 scratchA_ = job.allocateScratch<double>(static_cast<std::uint64_t>(highBlock_) + filterSize_ - 1);
304 scratchB_ = job.allocateScratch<double>(static_cast<std::uint64_t>(highBlock_) + filterSize_ - 1);
305 phaseWork_ = job.allocateScratch<double>(block_);
306 output_ = job.allocateScratch<double>(block_);
307 }
308 BoundedClip(const BoundedClip &) = delete;
310 [[nodiscard]] bool hasGeometry(std::int64_t pad, int block) const noexcept
311 {
312 return pad_ == pad && block_ == block;
313 }
314 void reset(double ceiling, Exterior exterior)
315 {
316 if (!(ceiling > 0) || !std::isfinite(ceiling))
318 if (ceiling == ceiling_ && exterior == exterior_)
319 return;
320 ceiling_ = ceiling;
321 exterior_ = exterior;
322 nativeLeaf_.fill(-1);
323 upLeaf_.fill(-1);
324 shapedLeaf_.fill(-1);
325 differenceMap_->rebuild(work_, difference_);
326 if (projection_)
327 projection_->rebuild();
328 }
329 [[nodiscard]] std::uint64_t evaluations() const
330 {
331 return evaluations_;
332 }
334 {
335 if (!projection_)
336 projection_.emplace(job_, extent_ * factor_, factor_, ProjectedReader{this},
337 highBlock_);
338 }
339 [[nodiscard]] std::int64_t firstLeaf() const
340 {
341 return pad_ / block_;
342 }
343 [[nodiscard]] std::int64_t endLeaf() const
344 {
345 return (pad_ + frames_ - 1) / block_ + 1;
346 }
347 [[nodiscard]] std::int64_t leafOrigin(std::int64_t leaf) const
348 {
349 return std::max(leaf * block_, pad_) - pad_;
350 }
351 [[nodiscard]] std::span<const double> get(std::int64_t leaf)
352 {
353 if (leaf < firstLeaf() || leaf >= endLeaf())
356 const auto values = projection_->get(static_cast<std::size_t>(leaf));
357 const auto first = leaf * block_, begin = std::max(first, pad_),
358 end = std::min(first + static_cast<int>(values.size()), pad_ + frames_);
359 const auto *filtered = stage(leaf), *raw = upsampled(leaf);
360 const int offset = static_cast<int>(begin - first), length = static_cast<int>(end - begin);
361 for (int i = 0; i < length; ++i)
362 {
363 const int at = offset + i;
364 output_[i] = clipperShape<C>(raw[at * factor_], ceiling_) + values[at] +
365 (exterior_ == Exterior::Linear ? filtered[highBlock_ + at * factor_] : 0.);
366 if (!std::isfinite(output_[i]))
368 }
369 return {output_.get(), static_cast<std::size_t>(length)};
370 }
371 template <class Consumer> void render(Consumer consume)
372 {
374 for (auto leaf = firstLeaf(); leaf < endLeaf(); ++leaf)
375 {
376 const auto data = get(leaf);
377 consume(leafOrigin(leaf), static_cast<int>(data.size()), data.data());
378 }
379 }
380};
381// Outward-rounded positive arithmetic and signed prefix intervals. Abel
382// summation bounds a finite alternating Cauchy sum by (|total|+max|prefix|)/d.
383// This often avoids the duration-dependent sum-of-magnitudes bound, while
384// retaining that bound as the better fallback for a Nyquist-alternating input.
385inline double upper(double value)
386{
387 return std::nextafter(value, std::numeric_limits<double>::infinity());
388}
390{
391 double low_ = 0, high_ = 0, mass_ = 0, prefix_ = 0;
392 std::int64_t next_ = 0;
393
394 public:
395 void append(double x)
396 {
397 if (!std::isfinite(x))
399 const double a = next_ % 2 ? -x : x;
400 low_ = std::nextafter(low_ + a, -std::numeric_limits<double>::infinity());
401 high_ = upper(high_ + a);
402 mass_ = upper(mass_ + std::abs(x));
403 prefix_ = std::max(prefix_, std::max(std::abs(low_), std::abs(high_)));
404 ++next_;
405 }
406 [[nodiscard]] double cauchyEnvelope() const
407 {
408 const double total = std::max(std::abs(low_), std::abs(high_));
409 const double bound = std::min(mass_, upper(total + prefix_));
410 return upper(bound / std::nextafter(std::numbers::pi, 0.));
411 }
412 [[nodiscard]] double mass() const
413 {
414 return mass_;
415 }
416};
418{
419 std::int64_t padding = 0;
421 double bound = 0;
422};
423
424template <ClipperCurve C> class TailPolicy
425{
426 double l_ = 0, e_ = 0, radius_ = 0, cubic_ = 0, shapeMaximum_ = 1, shapeGain_ = 1;
427 static constexpr int degree = 11;
428
429 public:
430 explicit TailPolicy(int factor)
431 {
432 if (factor < 1 || factor > 16 || (factor & (factor - 1)))
434 const int order = factor <= 2 ? 18 : 10;
436 l_ = upper(interval.reconstructionBound() *
437 (1 + 256 * std::numeric_limits<double>::epsilon()));
438 for (double v : continuous_clip::inverseBoxPower(order))
439 e_ = upper(e_ + std::abs(v));
440 radius_ = double(degree + 4 + 2 * order + 2) / factor + 2;
441 if constexpr (C == ClipperCurve::Tanh)
442 cubic_ = upper(1. / 3);
443 if constexpr (C == ClipperCurve::Sine)
444 {
445 for (int i = 1; i < 5; ++i)
446 cubic_ = upper(cubic_ + std::abs(fastSinPolynomial<double>[i]));
447 shapeGain_ = shapeMaximum_ = 0;
448 const auto lower = [](double x)
449 { return std::nextafter(x, -std::numeric_limits<double>::infinity()); };
450 for (int part = 0; part < 256; ++part)
451 {
452 const double low = std::max(0., lower(halfPi<double> * part / 256)),
453 high = upper(halfPi<double> * (part + 1) / 256);
454 const double squareLow = std::max(0., lower(low * low)),
455 squareHigh = upper(high * high);
456 double a = fastSinPolynomial<double>[4], b = a;
457 for (int i = 3; i >= 0; --i)
458 {
459 const std::array<double, 4> products{a * squareLow, a * squareHigh,
460 b * squareLow, b * squareHigh};
461 a = lower(lower(*std::min_element(products.begin(), products.end())) +
462 fastSinPolynomial<double>[i]);
463 b = upper(upper(*std::max_element(products.begin(), products.end())) +
464 fastSinPolynomial<double>[i]);
465 }
466 const double gain = std::max(std::abs(a), std::abs(b));
467 shapeGain_ = std::max(shapeGain_, gain);
468 shapeMaximum_ = std::max(shapeMaximum_, upper(gain * high));
469 }
470 }
471 }
472 // Analytic omitted-tail bounds. Roundoff/continuous-kernel errors are
473 // separately tested; they are not silently included in this tail budget.
474 TailPlan choose(double c, const TailCertificate &source, const TailCertificate &shape,
475 double tolerance = 1e-10, std::int64_t maximumPadding = 1ll << 26) const
476 {
477 if (!(c > 0) || !std::isfinite(c) || !(tolerance > 0) || !std::isfinite(tolerance))
479 const double a = upper(l_ * source.cauchyEnvelope()),
480 af = upper(l_ * shape.cauchyEnvelope());
481 for (std::int64_t pad = 64; pad <= maximumPadding; pad *= 2)
482 {
483 const double p = static_cast<double>(pad) - radius_;
484 if (p <= 0)
485 continue;
486 const double amplitude = upper(a / p);
487 double bound = std::numeric_limits<double>::infinity();
488 if constexpr (C == ClipperCurve::Hard || C == ClipperCurve::GoldenRatio)
489 {
490 const double threshold = C == ClipperCurve::Hard
491 ? c
492 : clipperLinearLimits<ClipperCurve::GoldenRatio>(c)[1];
493 if (amplitude < threshold)
494 bound = 0;
495 }
496 else if constexpr (C == ClipperCurve::Tanh || C == ClipperCurve::Sine)
497 {
498 if (amplitude <= c)
499 {
500 const double ratio = upper(amplitude / c);
501 // Integral of d^-4; factor4 includes both ends and the
502 // altered boundary strip of the finite evaluation domain.
503 bound =
504 upper(upper(upper(4 * e_ * cubic_ / (3 * std::numbers::pi)) * amplitude) *
505 upper(ratio * ratio));
506 }
507 }
508 if (bound <= tolerance)
509 return {pad, Exterior::Linear, bound};
510 const double shapeA = upper(a * shapeGain_), limit = upper(c * shapeMaximum_);
511 double shaped = upper(shapeA / p);
512 if (shaped > limit)
513 {
514 const double logarithm = std::log(shapeA) - std::log(limit) - std::log(p);
515 shaped = upper(limit * (1 + logarithm + 1e-12 * (1 + std::abs(logarithm))));
516 }
517 const double matched =
518 upper(upper(4 * e_ / std::numbers::pi) * upper(shaped + upper(af / p)));
519 if (matched <= tolerance)
520 return {pad, Exterior::Matched, matched};
521 if (pad > maximumPadding / 2)
522 break;
523 }
525 }
526};
527} // namespace dspark::detail::offline_clip
528#endif // DSPARK_HAS_OFFLINE
FIR filter using direct-form convolution with a mirrored delay line.
Definition FIRFilter.h:364
void reset() noexcept
Resets all delay lines to zero, clearing the filter's memory.
Definition FIRFilter.h:452
void processBlock(AudioBufferView< T > buffer) noexcept
Processes a full audio buffer in-place.
Definition FIRFilter.h:466
void prepare(int maxTaps, int numChannels)
Pre-allocates memory and initializes the delay lines.
Definition FIRFilter.h:376
void setCoefficients(std::span< const T > coeffs) noexcept
Sets the filter coefficients asynchronously.
Definition FIRFilter.h:418
void charge(std::size_t bytes)
OfflineScratchArray< U > allocateScratch(std::uint64_t count)
Streaming cubic B-spline moments of a reconstructed clipping residual.
double process(double x) noexcept
Processes one high-rate sample, returning its integrated residual.
std::vector< double > linearKernel() const
Constructs the matching linear FIR during setup.
void reset(double ceiling) noexcept
Resets reconstruction history and sets the positive finite ceiling.
BoundedClip(const BoundedClip &)=delete
BoundedClip(OfflineSession &job, Reader reader, std::int64_t frames, std::int64_t pad, int factor, double ceiling, Exterior exterior, int block=512)
std::int64_t leafOrigin(std::int64_t leaf) const
bool hasGeometry(std::int64_t pad, int block) const noexcept
std::span< const double > get(std::int64_t leaf)
void reset(double ceiling, Exterior exterior)
BoundedClip & operator=(const BoundedClip &)=delete
void process(double *nonlinear, double *reference, int count, Exterior exterior)
TailPlan choose(double c, const TailCertificate &source, const TailCertificate &shape, double tolerance=1e-10, std::int64_t maximumPadding=1ll<< 26) const
std::vector< double > inverseBoxPower(int order)
void offlineFail(OfflineStatus status)
std::unique_ptr< T[], OfflineScratchDeleter< T > > OfflineScratchArray