DSPark 1.8.0
Header-only C++20 DSP for real-time and offline audio
Loading...
Searching...
No Matches
OfflineProduct.h
1// DSPark - Professional Audio DSP Framework
2// Copyright (c) 2026 Cristian Moresi - MIT License
3
4#pragma once
5
20#include "../OfflineProcessing.h"
21#if DSPARK_HAS_OFFLINE
22#include "OfflineConvolutionWindow.h"
23#include "../Hilbert.h"
24#include "../SimdOps.h"
25#include <algorithm>
26#include <array>
27#include <cmath>
28#include <cstdint>
29#include <memory>
30#include <numbers>
31#include <optional>
32#include <vector>
33
34namespace dspark::detail
35{
36
38{
39 public:
40 static constexpr int order = 24;
41 using Coefficients = std::array<double, 2 * order>;
42 using Matrix = std::array<std::array<double, order>, order>;
43 struct Level
44 {
45 std::size_t count = 0, offset = 0;
46 };
47 enum class NearFields { Hilbert, External };
48
51 : job_(job), frames_(frames), block_(block)
52 {
53 // A wider internal grid reduces coarse-map memory for upsampled offline
54 // sources. The external read contract remains capped at 4096 frames.
55 if (frames < 1 || block < 128 || block > 65536 || (block & (block - 1)) != 0)
58 const auto leaves = static_cast<std::uint64_t>(frames / block + (frames % block != 0));
59 // Check the full tree before narrowing or making a source-sized request.
60 (void)offlineBytes(2 * leaves + 64, sizeof(Coefficients));
61 leaves_ = static_cast<std::size_t>(leaves);
62 std::size_t nodes = 0;
63 for (auto count = leaves_;; count = count / 2 + count % 2)
64 {
65 levels_[levelCount_++] = {count, nodes};
66 nodes += count;
67 if (count == 1)
68 break;
69 }
70 moments_ = job.allocateScratch<Coefficients>(nodes);
71 local_ = job.allocateScratch<Coefficients>(leaves_);
72 matrices_ = job.allocateScratch<Matrix>(8);
73 weights_ = job.allocateScratch<double>(static_cast<std::uint64_t>(block) * order);
74 impulse_ = job.allocateScratch<double>(4 * static_cast<std::uint64_t>(block) - 1);
75 input_ = job.allocateScratch<double>(block);
76 buildGeometry();
77 // Existing product workers reserve both fields before opening a sink.
78 // A caller supplying other near-field kernels can explicitly omit them.
79 if (nearFields == NearFields::Hilbert)
80 {
81 prepareNear(near_);
82 prepareNear(outerNear_);
83 }
84 }
85
86 [[nodiscard]] int block() const noexcept { return block_; }
87 [[nodiscard]] std::int64_t frames() const noexcept { return frames_; }
88 [[nodiscard]] std::size_t leaves() const noexcept { return leaves_; }
89 [[nodiscard]] OfflineSession &job() noexcept { return job_; }
90
92 {
93 if (cauchyNear_)
94 return;
95 for (int i = 0; i < 4 * block_ - 1; ++i)
96 {
97 const int lag = i - (2 * block_ - 1);
98 impulse_[i] = lag == 0 ? 0 : 1 / (std::numbers::pi * lag);
99 }
100 prepareNear(cauchyNear_);
101 }
102
103 template <class Reader>
104 void read(Reader &source, std::int64_t first, int count, double *out) const
105 {
106 std::fill_n(out, count, 0.0);
107 if (first < 0)
108 {
109 const int skip = static_cast<int>(std::min<std::int64_t>(-first, count));
110 out += skip;
111 count -= skip;
112 first += skip;
113 }
114 if (first >= frames_ || count == 0)
115 return;
116 count = static_cast<int>(std::min<std::int64_t>(count, frames_ - first));
117 while (count > 0)
118 {
119 const int length = std::min({count, block_, 4096});
120 source(first, length, out);
121 for (int i = 0; i < length; ++i)
122 if (!std::isfinite(out[i]))
124 first += length;
125 out += length;
126 count -= length;
127 }
128 }
129
130 private:
131 friend class OfflineHilbertMap;
132 using NearField = std::optional<OfflineConvolutionWindow>;
133
134 void buildGeometry()
135 {
136 for (int i = 0; i < block_; ++i)
137 {
138 const double u = (i - (block_ - 1) * .5) / (block_ * .5);
139 double power = 1;
140 for (int p = 0; p < order; ++p)
141 {
142 weights_[static_cast<std::size_t>(p) * block_ + i] = power;
143 power *= u;
144 }
145 }
146 for (int p = 0; p < order; ++p)
147 {
148 double choose = 1;
149 for (int q = 0; q <= p; ++q)
150 {
151 const double right = std::ldexp(choose, -p);
152 const double left = (p - q) % 2 == 0 ? right : -right;
153 matrices_[0][p][q] = matrices_[2][q][p] = left;
154 matrices_[1][p][q] = matrices_[3][q][p] = right;
155 choose *= static_cast<double>(p - q) / (q + 1);
156 }
157 }
158 constexpr std::array<int, 4> offsets{-3, -2, 2, 3};
159 for (int k = 0; k < 4; ++k)
160 {
161 const double d = offsets[k], r = .5 / d;
162 for (int p = 0; p < order; ++p)
163 {
164 double choose = 1;
165 for (int q = 0; q < order; ++q)
166 {
167 if (q > 0)
168 choose *= static_cast<double>(p + q) / q;
169 matrices_[4 + k][p][q] = (p % 2 == 0 ? 1. : -1.) * choose *
170 std::pow(r, p + q) / (std::numbers::pi * d);
171 }
172 }
173 }
174 for (int i = 0; i < 4 * block_ - 1; ++i)
175 impulse_[i] = .5 * hilbertIdealImpulse(i - (2 * block_ - 1));
176 }
177
178 void prepareNear(NearField &field)
179 {
180 field.emplace(job_, block_, [this](int lag) { return impulse_[lag + 2 * block_ - 1]; });
181 }
182
183 template <class Reader>
184 const double *nearField(std::size_t leaf, Reader &source, bool outer, bool cauchy = false,
185 bool alternating = false)
186 {
187 if (cauchy)
189 auto &field = cauchy ? cauchyNear_ : outer ? outerNear_ : near_;
190 if (!field)
191 {
192 // Fractional-phase users supply their own near-field kernel and
193 // need no Hilbert convolvers. Construct only the requested field.
194 for (int i = 0; i < 4 * block_ - 1; ++i)
195 impulse_[i] = .5 * hilbertIdealImpulse(i - (2 * block_ - 1));
196 prepareNear(field);
197 }
198 auto *input = field->input();
199 read(source, (static_cast<std::int64_t>(leaf) - 1) * block_, 3 * block_, input);
200 if (alternating)
201 for (int i = 1; i < 3 * block_; i += 2)
202 input[i] = -input[i];
203 return field->process();
204 }
205
206 OfflineSession &job_;
207 std::int64_t frames_;
208 int block_;
209 std::size_t leaves_ = 0, levelCount_ = 0;
210 std::array<Level, 64> levels_{};
211 OfflineScratchArray<Coefficients> moments_, local_;
212 OfflineScratchArray<Matrix> matrices_;
213 OfflineScratchArray<double> weights_, impulse_, input_;
214 NearField near_, outerNear_, cauchyNear_;
215};
216
217// For h[k]=1/(pi*k) on odd k and zero on even k, evaluate h*x without
218// truncating its long tail. Exact neighbouring-block Core convolution and
219// dyadic multipole/local expansions cover disjoint source regions. The two
220// source parities have distinct moments; only opposite parity contributes.
222{
223 public:
224 template <class Reader>
226 : local_(work.job_.allocateScratch<OfflineProductWorkspace::Coefficients>(work.leaves_)),
227 leaves_(work.leaves_), block_(work.block_)
228 {
229 rebuild(work, source);
230 }
231
232 // Reuse all geometry and storage when a worker updates its source function.
233 // Existing maps sharing this workspace keep their own independent fields.
234 template <class Reader>
235 void rebuild(OfflineProductWorkspace &work, Reader &source)
236 {
237 if (work.leaves_ != leaves_ || work.block_ != block_)
239 constexpr int order = OfflineProductWorkspace::order;
240 using Coefficients = OfflineProductWorkspace::Coefficients;
241 const auto last = work.levels_[work.levelCount_ - 1];
242 std::fill_n(work.moments_.get(), last.offset + last.count, Coefficients{});
243 work.job_.checkpoint(OfflinePhase::Plan, 0, work.frames_);
244 for (std::size_t leaf = 0; leaf < leaves_; ++leaf)
245 {
246 const auto first = static_cast<std::int64_t>(leaf) * block_;
247 work.read(source, first, block_, work.input_.get());
248 auto &dst = work.moments_[leaf];
249 for (int i = 0; i < block_; ++i)
250 for (int p = 0; p < order; ++p)
251 dst[static_cast<std::size_t>((i % 2) * order + p)] +=
252 work.input_[i] * work.weights_[static_cast<std::size_t>(p) * block_ + i];
254 first + std::min<std::int64_t>(block_, work.frames_ - first),
255 work.frames_);
256 }
257 for (std::size_t level = 1; level < work.levelCount_; ++level)
258 {
259 const auto current = work.levels_[level], previous = work.levels_[level - 1];
260 for (std::size_t parent = 0; parent < current.count; ++parent)
261 {
262 auto &dst = work.moments_[current.offset + parent];
263 for (int side = 0; side < 2; ++side)
264 {
265 const auto child = 2 * parent + side;
266 if (child >= previous.count)
267 continue;
268 const auto &src = work.moments_[previous.offset + child];
269 add(dst, src, work.matrices_[side], 1);
270 }
271 if ((parent & 255) == 0)
272 work.job_.checkpoint(OfflinePhase::Plan, work.frames_, work.frames_);
273 }
274 }
275 auto *local = local_.get(), *children = work.local_.get();
276 *local = Coefficients{};
277 for (std::size_t remaining = work.levelCount_; remaining > 0; --remaining)
278 {
279 const auto level = remaining - 1;
280 const auto current = work.levels_[level];
281 const double width = std::ldexp(static_cast<double>(block_), static_cast<int>(level));
282 for (std::size_t target = 0; target < current.count; ++target)
283 {
284 const auto first = static_cast<std::int64_t>(2 * (target / 2)) - 2;
285 for (auto other = first; other < first + 6; ++other)
286 {
287 if (other < 0 || static_cast<std::uint64_t>(other) >= current.count)
288 continue;
289 const auto offset = static_cast<std::int64_t>(target) - other;
290 if (std::abs(offset) <= 1)
291 continue;
292 const int kernel = offset == -3 ? 4 : offset == -2 ? 5 : offset == 2 ? 6 : 7;
293 add(local[target], work.moments_[current.offset + static_cast<std::size_t>(other)],
294 work.matrices_[kernel], 1 / width);
295 }
296 if ((target & 255) == 0)
297 work.job_.checkpoint(OfflinePhase::Plan, work.frames_, work.frames_);
298 }
299 if (level == 0)
300 break;
301 for (std::size_t child = 0; child < work.levels_[level - 1].count; ++child)
302 {
303 children[child] = Coefficients{};
304 add(children[child], local[child / 2], work.matrices_[2 + child % 2], 1);
305 }
306 std::swap(local, children);
307 }
308 if (local != local_.get())
309 local_.swap(work.local_);
310 }
311
312 template <class Reader>
313 void evaluate(OfflineProductWorkspace &work, std::size_t leaf, Reader &source, double *output,
314 bool outer = false) const
315 {
316 evaluateKernel(work, leaf, source, output, outer, false, false);
317 }
318
319 // The same parity moments also represent 1/(pi*k) for every nonzero k.
320 // Alternating input needs only a sign on the odd source moments; the source
321 // reader remains unchanged. This reuses the audio map for endpoint sums.
322 template <class Reader>
323 void evaluateCauchy(OfflineProductWorkspace &work, std::size_t leaf, Reader &source,
324 double *output, bool alternating = false) const
325 {
326 evaluateKernel(work, leaf, source, output, false, true, alternating);
327 }
328
329 // Evaluate only the non-neighbouring source contribution at n+shift.
330 // The caller supplies the matching three-leaf near field. Fractional
331 // sinc phases can therefore reuse this tree without truncating its tail.
332 void evaluateCauchyFar(std::size_t leaf, double shift, double *output,
333 bool alternating = false) const
334 {
335 if (leaf >= leaves_ || !std::isfinite(shift) || std::abs(shift) > 1)
337 constexpr int order = OfflineProductWorkspace::order;
338 const auto *coefficients = local_[leaf].data();
339 for (int i = 0; i < block_; ++i)
340 {
341 const double u = (i + shift - (block_ - 1) * .5) / (block_ * .5);
342 double even = coefficients[order - 1], odd = coefficients[2 * order - 1];
343 for (int p = order - 2; p >= 0; --p)
344 {
345 even = even * u + coefficients[p];
346 odd = odd * u + coefficients[order + p];
347 }
348 output[i] = alternating ? even - odd : even + odd;
349 }
350 }
351
352 private:
353 template <class Reader>
354 void evaluateKernel(OfflineProductWorkspace &work, std::size_t leaf, Reader &source,
355 double *output, bool outer, bool cauchy, bool alternating) const
356 {
357 if (leaf >= leaves_ || work.leaves_ != leaves_ || work.block_ != block_)
359 constexpr int order = OfflineProductWorkspace::order;
360 const auto *coefficients = local_[leaf].data();
361 const auto *close = work.nearField(leaf, source, outer, cauchy, alternating);
362 for (int i = 0; i < block_; ++i)
363 {
364 const auto *c = coefficients + (1 - i % 2) * order;
365 const double u = (i - (block_ - 1) * .5) / (block_ * .5);
366 double distant = c[order - 1];
367 for (int p = order - 2; p >= 0; --p)
368 distant = distant * u + c[p];
369 if (cauchy)
370 {
371 const auto *same = coefficients + (i % 2) * order;
372 double other = same[order - 1];
373 for (int p = order - 2; p >= 0; --p)
374 other = other * u + same[p];
375 distant = alternating ? (i % 2 ? distant - other : other - distant) : distant + other;
376 }
377 output[i] = close[i] + distant;
378 }
379 }
380
381 static void add(OfflineProductWorkspace::Coefficients &dst,
383 const OfflineProductWorkspace::Matrix &matrix, double scale) noexcept
384 {
385 constexpr int order = OfflineProductWorkspace::order;
386 for (int parity = 0; parity < 2; ++parity)
387 for (int p = 0; p < order; ++p)
388 dst[static_cast<std::size_t>(parity * order + p)] +=
389 simd::dotProduct(matrix[p].data(), src.data() + parity * order, order) * scale;
390 }
391 OfflineScratchArray<OfflineProductWorkspace::Coefficients> local_;
392 std::size_t leaves_;
393 int block_;
394};
395
396template <class LeftReader, class RightReader>
398{
399 public:
400 OfflineProductCombination(OfflineProductWorkspace &work, LeftReader &left, RightReader &right,
401 const OfflineHilbertMap &aLeft, const OfflineHilbertMap &aRight)
402 : work_(work), left_(left), right_(right), aLeft_(aLeft), aRight_(aRight),
403 samples_(work.job().allocateScratch<double>(15 * static_cast<std::uint64_t>(work.block())))
404 {
405 leaves_.fill(-1);
406 }
407
408 void reset() noexcept { leaves_.fill(-1); }
409
410 // Three neighbouring leaves are the full near-field stencil. Entries also
411 // retain operands and their transforms for the final product identity.
412 [[nodiscard]] double *get(std::size_t leaf)
413 {
414 const int block = work_.block();
415 const auto slot = leaf % 3;
416 auto *data = samples_.get() + slot * 5 * block;
417 if (leaves_[slot] != static_cast<std::int64_t>(leaf))
418 {
419 const auto first = static_cast<std::int64_t>(leaf) * block;
420 work_.read(left_, first, block, data);
421 work_.read(right_, first, block, data + block);
422 aLeft_.evaluate(work_, leaf, left_, data + 2 * block);
423 aRight_.evaluate(work_, leaf, right_, data + 3 * block);
424 for (int i = 0; i < block; ++i)
425 data[4 * block + i] =
426 data[block + i] * data[2 * block + i] + data[i] * data[3 * block + i];
427 leaves_[slot] = static_cast<std::int64_t>(leaf);
428 }
429 return data;
430 }
431 void operator()(std::int64_t first, int count, double *output)
432 {
433 while (count > 0)
434 {
435 const auto leaf = static_cast<std::size_t>(first / work_.block());
436 const int offset = static_cast<int>(first % work_.block());
437 const int length = std::min(work_.block() - offset, count);
438 std::copy_n(get(leaf) + 4 * work_.block() + offset, length, output);
439 first += length;
440 output += length;
441 count -= length;
442 }
443 }
444
445 private:
447 LeftReader &left_;
448 RightReader &right_;
449 const OfflineHilbertMap &aLeft_, &aRight_;
451 std::array<std::int64_t, 3> leaves_{};
452};
453
454// Integrating three cardinal sinc functions gives 3/4 when all indices match,
455// zero for other equal-parity triples, and 1/(pi^2*(k-a)*(k-b)) when k is the
456// index of opposite parity. Grouping these terms yields
457// P(x,g) = .75*x*g + A(x)*A(g) - A(g*A(x) + x*A(g)).
458// No audio oversampling, circular wrapping or whole-source PCM buffer is used.
459template <class LeftReader, class RightReader, class Consumer>
460inline void offlineBandlimitedProduct(OfflineSession &job, std::int64_t frames, LeftReader left,
461 RightReader right, Consumer consume, int block = 0)
462{
463 if (block == 0)
464 {
465 block = 128;
466 while (block < frames && block < 4096)
467 block *= 2;
468 }
469 OfflineProductWorkspace work(job, frames, block);
470 OfflineHilbertMap aLeft(work, left), aRight(work, right);
471 OfflineProductCombination combination(work, left, right, aLeft, aRight);
472 OfflineHilbertMap aCombined(work, combination);
473 auto output = job.allocateScratch<double>(block);
474 job.checkpoint(OfflinePhase::Render, 0, frames);
475 for (std::size_t leaf = 0; leaf < work.leaves(); ++leaf)
476 {
477 aCombined.evaluate(work, leaf, combination, output.get(), true);
478 const auto *data = combination.get(leaf);
479 const auto first = static_cast<std::int64_t>(leaf) * block;
480 const int count = static_cast<int>(std::min<std::int64_t>(block, frames - first));
481 for (int i = 0; i < count; ++i)
482 {
483 output[i] = .75 * data[i] * data[block + i] +
484 data[2 * block + i] * data[3 * block + i] - output[i];
485 if (!std::isfinite(output[i]))
487 }
488 consume(first, count, output.get());
489 job.checkpoint(OfflinePhase::Render, first + count, frames);
490 }
491}
492
493} // namespace dspark::detail
494#endif // DSPARK_HAS_OFFLINE
OfflineHilbertMap(OfflineProductWorkspace &work, Reader &source)
void evaluateCauchy(OfflineProductWorkspace &work, std::size_t leaf, Reader &source, double *output, bool alternating=false) const
void evaluate(OfflineProductWorkspace &work, std::size_t leaf, Reader &source, double *output, bool outer=false) const
void evaluateCauchyFar(std::size_t leaf, double shift, double *output, bool alternating=false) const
void rebuild(OfflineProductWorkspace &work, Reader &source)
OfflineProductCombination(OfflineProductWorkspace &work, LeftReader &left, RightReader &right, const OfflineHilbertMap &aLeft, const OfflineHilbertMap &aRight)
void operator()(std::int64_t first, int count, double *output)
OfflineProductWorkspace(OfflineSession &job, std::int64_t frames, int block=4096, NearFields nearFields=NearFields::Hilbert)
std::array< std::array< double, order >, order > Matrix
std::array< double, 2 *order > Coefficients
OfflineSession & job() noexcept
std::int64_t frames() const noexcept
std::size_t leaves() const noexcept
void read(Reader &source, std::int64_t first, int count, double *out) const
OfflineScratchArray< U > allocateScratch(std::uint64_t count)
void checkpoint(OfflinePhase phase, std::int64_t completed, std::int64_t total) const
void offlineFail(OfflineStatus status)
double hilbertIdealImpulse(std::int64_t index) noexcept
Definition Hilbert.h:41
void offlineBandlimitedProduct(OfflineSession &job, std::int64_t frames, LeftReader left, RightReader right, Consumer consume, int block=0)
std::unique_ptr< T[], OfflineScratchDeleter< T > > OfflineScratchArray
std::size_t offlineBytes(std::uint64_t count, std::size_t size)
float dotProduct(const float *DSPARK_RESTRICT a, const float *DSPARK_RESTRICT b, int count) noexcept
Computes the dot product of two arrays.
Definition SimdOps.h:419