Pulp 1.88.0
Cross-platform audio plugin framework
Loading...
Searching...
No Matches
zero_latency_convolver.hpp
Go to the documentation of this file.
1#pragma once
2
6
8#include <pulp/simd/simd.hpp>
9
10namespace pulp::signal {
11
18template <typename SampleType = float>
20public:
21 // ── Lifecycle ────────────────────────────────────────────────────────────
22
25 void prepare(double sample_rate, int max_block, int channels) {
26 sample_rate_ = (std::isfinite(sample_rate) && sample_rate > 0.0) ? sample_rate
27 : 48000.0;
28 max_block_ = std::max(1, max_block);
29 channels_ = std::clamp(channels, 1, 2);
30 head_len_ = std::clamp(round_up_pow2(max_block_), kHeadLenMin, kHeadLenMax);
31
32 predelay_len_ = static_cast<int>(std::ceil(units::ms_to_samples(
33 kPredelayMsMax, sample_rate_))) + max_block_ + 1;
34 predelay_.assign(2, std::vector<SampleType>(static_cast<std::size_t>(predelay_len_),
35 SampleType{0}));
36 predelay_write_ = 0;
37 wet_scratch_.assign(2, std::vector<SampleType>(
38 static_cast<std::size_t>(std::max(max_block_, kHeadLenMax)),
39 SampleType{0}));
40 // A chunk never exceeds max_block_ or the head length, so the head's
41 // linear window is at most head_len_ - 1 + max(max_block_, head_len_).
42 head_window_.assign(
43 2, std::vector<SampleType>(
44 static_cast<std::size_t>(head_len_ - 1 + std::max(max_block_, head_len_)),
45 SampleType{0}));
46 head_out_.assign(static_cast<std::size_t>(std::max(max_block_, head_len_)), SampleType{0});
47 hp_state_.assign(2, OnePole{});
48 lp_state_.assign(2, OnePole{});
49 update_send_eq();
50 set_predelay_ms(predelay_ms_);
51 is_prepared_ = true;
52 }
53
63 bool load_impulse_response(const SampleType* const* ir_channels,
64 int ir_channel_count,
65 int ir_length,
66 double ir_sample_rate) {
67 if (!is_prepared_) return false;
68 if (ir_channels == nullptr || ir_length <= 0) return false;
69 if (ir_channel_count != 1 && ir_channel_count != 2 && ir_channel_count != 4)
70 return false;
71 if (!valid_resample_geometry(ir_length, ir_sample_rate, sample_rate_,
72 resample_taps_))
73 return false;
74 for (int c = 0; c < ir_channel_count; ++c) {
75 if (ir_channels[c] == nullptr) return false;
76 for (int i = 0; i < ir_length; ++i)
77 if (!std::isfinite(static_cast<double>(ir_channels[c][i]))) return false;
78 }
79
80 // 1. Channel map into double working buffers. The whole ingest runs in
81 // double so the prepared IR is not shaped by the sample type.
82 std::vector<std::vector<double>> work(static_cast<std::size_t>(ir_channel_count));
83 for (int c = 0; c < ir_channel_count; ++c) {
84 auto& dst = work[static_cast<std::size_t>(c)];
85 dst.resize(static_cast<std::size_t>(ir_length));
86 for (int i = 0; i < ir_length; ++i)
87 dst[static_cast<std::size_t>(i)] = static_cast<double>(ir_channels[c][i]);
88 }
89
90 // 2. Resample, preserving broadband energy by measurement.
91 if (ir_sample_rate > 0.0 && std::abs(ir_sample_rate - sample_rate_) > 1e-9)
92 for (auto& channel : work)
93 channel = resample_energy_preserving(channel, ir_sample_rate, sample_rate_);
94
95 // 3. DC removal — measurement DC would otherwise integrate in the wet
96 // sum. Per channel, since the offset is a property of the capture.
97 for (auto& channel : work) {
98 if (channel.empty()) continue;
99 double mean = 0.0;
100 for (double v : channel) mean += v;
101 mean /= static_cast<double>(channel.size());
102 for (double& v : channel) v -= mean;
103 }
104
105 // 4. Schroeder tail trim + raised-cosine fade-out.
106 trim_tail(work);
107
108 // 5. Amplitude normalization.
109 normalize(work, ir_channel_count);
110
111 // 6/7. Publish: taps, L1 norms, partition schedule, spectra.
112 publish(work, ir_channel_count);
113 return true;
114 }
115
116 // ── Parameters (RT-safe; no allocation) ──────────────────────────────────
117
118 void set_ir_gain_db(double db) {
119 if (!std::isfinite(db)) return;
120 ir_gain_db_ = std::clamp(db, kIrGainDbMin, kIrGainDbMax);
121 ir_gain_lin_ = units::db_to_linear(ir_gain_db_);
122 }
123 void set_predelay_ms(double ms) {
124 if (!std::isfinite(ms)) return;
125 predelay_ms_ = std::clamp(ms, kPredelayMsMin, kPredelayMsMax);
126 const int limit = std::max(0, predelay_len_ - max_block_ - 1);
127 predelay_samples_ = std::clamp(
128 static_cast<int>(std::lround(units::ms_to_samples(predelay_ms_, sample_rate_))),
129 0, limit);
130 }
131 void set_true_stereo(bool on) {
132 true_stereo_ = on;
133 refresh_active_cells();
134 }
135 void set_wet_percent(double percent) {
136 if (std::isfinite(percent)) wet_gain_ = std::clamp(percent, 0.0, 100.0) / 100.0;
137 }
138 void set_dry_percent(double percent) {
139 if (std::isfinite(percent)) dry_gain_ = std::clamp(percent, 0.0, 100.0) / 100.0;
140 }
141 void set_width_percent(double percent) {
142 if (!std::isfinite(percent)) return;
143 width_ = std::clamp(percent, kWidthPercentMin, kWidthPercentMax) / 100.0;
144 }
145 void set_lowcut_hz(double hz) {
146 if (!std::isfinite(hz)) return;
147 lowcut_hz_ = std::clamp(hz, kLowcutHzMin, kLowcutHzMax);
148 update_send_eq();
149 }
150 void set_highcut_hz(double hz) {
151 if (!std::isfinite(hz)) return;
152 highcut_hz_ = std::clamp(hz, kHighcutHzMin, kHighcutHzMax);
153 update_send_eq();
154 }
155
158 void set_normalize_mode(IrNormalizeMode mode) { normalize_mode_ = mode; }
159 void set_tail_trim_db(double db) {
160 if (!std::isfinite(db)) return;
161 tail_trim_db_ = std::clamp(db, kTailTrimDbMin, kTailTrimDbMax);
162 }
163 void set_tail_fade_ms(double ms) {
164 if (!std::isfinite(ms)) return;
165 tail_fade_ms_ = std::clamp(ms, kTailFadeMsMin, kTailFadeMsMax);
166 }
168 resample_taps_ = std::clamp(taps, kResampTapsPerPhaseMin, kResampTapsPerPhaseMax);
169 }
170
171 // ── Audio ────────────────────────────────────────────────────────────────
172
174 void process(const SampleType* const* in, SampleType* const* out, int n) {
175 ScopedFlushDenormals flush_denormals;
176 block_cost_ = 0;
177 if (n <= 0) return;
178
179 if (!loaded_) {
180 // No IR is a wire scaled by the dry control only. Passing the input
181 // through the WET path would be a convolution with an implied
182 // delta, which is audibly indistinguishable from a working engine
183 // and would hide a failed load.
184 for (int c = 0; c < channels_; ++c)
185 for (int i = 0; i < n; ++i)
186 out[c][i] = static_cast<SampleType>(dry_gain_) * finite_sample(in[c][i]);
187 return;
188 }
189
190 int done = 0;
191 while (done < n) {
192 // Every group period is a power-of-two multiple of the smallest, so
193 // all scheduling boundaries lie on the L_min grid: clipping the
194 // chunk to that grid means a chunk can never straddle a boundary.
195 const int to_grid =
196 smallest_period_ -
197 static_cast<int>(counter_ % static_cast<std::uint64_t>(smallest_period_));
198 const int chunk = std::min(n - done, to_grid);
199
200 render_chunk(in, done, chunk);
201 mix_chunk(in, out, done, chunk);
202
203 counter_ += static_cast<std::uint64_t>(chunk);
204 advance_groups();
205 done += chunk;
206 }
207 }
208
210 void reset() {
211 for (auto& ring : history_) std::fill(ring.begin(), ring.end(), SampleType{0});
212 history_write_ = 0;
213 counter_ = 0;
214 for (auto& g : groups_) g.reset();
215 for (auto& ring : predelay_) std::fill(ring.begin(), ring.end(), SampleType{0});
216 predelay_write_ = 0;
217 for (auto& s : hp_state_) s = OnePole{};
218 for (auto& s : lp_state_) s = OnePole{};
219 block_cost_ = 0;
220 }
221
225 int latency_samples() const { return 0; }
226
237 double worst_case_gain() const { return ir_gain_lin_ * l1_norm(); }
238
240 double l1_norm() const {
241 if (!loaded_) return 0.0;
242 double worst = 0.0;
243 for (int dst = 0; dst < 2; ++dst) {
244 double sum = 0.0;
245 for (int cell = 0; cell < cell_count_; ++cell) {
246 const Cell& k = cells_[static_cast<std::size_t>(cell)];
247 if (cell_active(cell) && k.dst == dst) sum += k.l1;
248 }
249 worst = std::max(worst, sum);
250 }
251 return worst;
252 }
253
254 // ── Introspection (tests and the catalog node read these) ────────────────
255
256 bool is_loaded() const { return loaded_; }
257 int head_length() const { return head_len_; }
258 int num_levels() const { return static_cast<int>(groups_.size()); }
259 int level_block_length(int level) const {
260 return groups_[static_cast<std::size_t>(level)].block_len;
261 }
262 int level_ir_start(int level) const {
263 return groups_[static_cast<std::size_t>(level)].ir_start;
264 }
265 int level_partitions(int level) const {
266 return groups_[static_cast<std::size_t>(level)].num_partitions;
267 }
268
271 int level_margin(int level) const {
272 const auto& g = groups_[static_cast<std::size_t>(level)];
273 return g.ir_start - g.block_len + 1;
274 }
275 int prepared_ir_length() const { return prepared_len_; }
276 int prepared_ir_channels() const { return ir_channels_; }
277 const SampleType* prepared_ir(int channel) const {
278 return prepared_[static_cast<std::size_t>(channel)].data();
279 }
280
282 long long last_block_cost() const { return block_cost_; }
283
284 double sample_rate() const { return sample_rate_; }
285 IrNormalizeMode normalize_mode() const { return normalize_mode_; }
286 double tail_trim_db() const { return tail_trim_db_; }
287 double tail_fade_ms() const { return tail_fade_ms_; }
288 double predelay_ms() const { return predelay_ms_; }
289 int predelay_samples() const { return predelay_samples_; }
290
291private:
292 static SampleType finite_sample(SampleType sample) noexcept {
293 return std::isfinite(static_cast<double>(sample)) ? sample : SampleType{0};
294 }
295
296 using Group = detail::ZeroLatencyConvolverGroup<SampleType>;
297 using Cell = detail::ZeroLatencyConvolverCell<SampleType>;
298 using OnePole = detail::ZeroLatencyConvolverOnePole;
299
300 // ── Chunk rendering ──────────────────────────────────────────────────────
301
302 void render_chunk(const SampleType* const* in, int offset, int chunk) {
303 const std::size_t mask = history_mask_;
304
305 // Send EQ into the input-history rings. The head and every partition
306 // read the same rings, so the EQ is applied exactly once.
307 for (int c = 0; c < channels_; ++c) {
308 SampleType* ring = history_[static_cast<std::size_t>(c)].data();
309 auto& hp = hp_state_[static_cast<std::size_t>(c)];
310 auto& lp = lp_state_[static_cast<std::size_t>(c)];
311 for (int i = 0; i < chunk; ++i) {
312 double x = static_cast<double>(finite_sample(in[c][offset + i]));
313 if (hp_active_) {
314 hp.z = snap_to_zero(hp_coef_ * (hp.z + x - hp.prev_x));
315 hp.prev_x = x;
316 x = hp.z;
317 }
318 if (lp_active_) {
319 lp.z = snap_to_zero(lp.z + lp_coef_ * (x - lp.z));
320 x = lp.z;
321 }
322 ring[(history_write_ + static_cast<std::size_t>(i)) & mask] =
323 static_cast<SampleType>(x);
324 }
325 }
326 history_write_ = (history_write_ + static_cast<std::size_t>(chunk)) & mask;
327
328 for (int c = 0; c < 2; ++c)
329 std::fill_n(wet_scratch_[static_cast<std::size_t>(c)].begin(), chunk, SampleType{0});
330
331 // Direct-form head: the whole zero-latency claim lives here. Every tap
332 // reads a sample at or before the current one, so the current block's
333 // near-field is complete the moment the block arrives. The window
334 // x[n - head_len + 1 .. n] for the chunk's outputs is made linear
335 // (pointed at in place, or unwrapped once per source channel when it
336 // straddles the ring's end) so each cell is one correlate() call.
337 const std::size_t taps = static_cast<std::size_t>(head_len_);
338 const std::size_t span = taps - 1 + static_cast<std::size_t>(chunk);
339 const std::size_t head_base = (history_write_ - static_cast<std::size_t>(chunk)) & mask;
340 const std::size_t window_start = (head_base - (taps - 1)) & mask;
341 const SampleType* window[2] = {nullptr, nullptr};
342 for (int cell = 0; cell < cell_count_; ++cell) {
343 if (!cell_active(cell)) continue;
344 const Cell& k = cells_[static_cast<std::size_t>(cell)];
345 const auto src = static_cast<std::size_t>(k.src);
346 if (window[src] == nullptr) {
347 const SampleType* ring = history_[src].data();
348 const std::size_t ring_len = mask + 1;
349 if (window_start + span <= ring_len) {
350 window[src] = ring + window_start;
351 } else {
352 SampleType* linear = head_window_[src].data();
353 const std::size_t first = ring_len - window_start;
354 std::copy_n(ring + window_start, first, linear);
355 std::copy_n(ring, span - first, linear + first);
356 window[src] = linear;
357 }
358 }
359 SampleType* wet = wet_scratch_[static_cast<std::size_t>(k.dst)].data();
360 pulp::simd::correlate(window[src], k.head_reversed.data(), head_out_.data(),
361 static_cast<std::size_t>(chunk), taps);
362 pulp::simd::add(wet, head_out_.data(), wet, static_cast<std::size_t>(chunk));
363 }
364 block_cost_ += static_cast<long long>(active_cells_) * head_len_ * chunk * 2;
365
366 // FFT partitions: each group emits the block of output it finished one
367 // period ago. `phase` is the position inside the current period, and by
368 // construction the chunk cannot straddle a period boundary.
369 for (auto& g : groups_) {
370 const int phase =
371 static_cast<int>(counter_ % static_cast<std::uint64_t>(g.block_len));
372 for (int cell = 0; cell < cell_count_; ++cell) {
373 if (!cell_active(cell)) continue;
374 const Cell& k = cells_[static_cast<std::size_t>(cell)];
375 const SampleType* src = g.emit[static_cast<std::size_t>(cell)].data() + phase;
376 SampleType* wet = wet_scratch_[static_cast<std::size_t>(k.dst)].data();
377 for (int i = 0; i < chunk; ++i) wet[i] += src[i];
378 }
379 }
380 block_cost_ += static_cast<long long>(groups_.size()) * active_cells_ * chunk;
381 }
382
383 void mix_chunk(const SampleType* const* in, SampleType* const* out, int offset, int chunk) {
384 const SampleType gain = static_cast<SampleType>(wet_gain_ * ir_gain_lin_);
385 const SampleType dry = static_cast<SampleType>(dry_gain_);
386 const SampleType width = static_cast<SampleType>(width_);
387
388 for (int i = 0; i < chunk; ++i) {
389 // Predelay on the wet path only. The engine is LTI, so delaying the
390 // send and delaying the wet return are the same filter; the return
391 // side costs one ring per audio channel instead of one per IR cell.
392 SampleType w[2] = {SampleType{0}, SampleType{0}};
393 const int write = (predelay_write_ + i) % predelay_len_;
394 const int read = (write - predelay_samples_ + predelay_len_) % predelay_len_;
395 for (int c = 0; c < channels_; ++c) {
396 auto& ring = predelay_[static_cast<std::size_t>(c)];
397 ring[static_cast<std::size_t>(write)] =
398 wet_scratch_[static_cast<std::size_t>(c)][static_cast<std::size_t>(i)];
399 w[c] = ring[static_cast<std::size_t>(read)];
400 }
401 if (channels_ == 2 && width != SampleType{1}) {
402 const SampleType mid = (w[0] + w[1]) * SampleType{0.5};
403 const SampleType side = (w[0] - w[1]) * SampleType{0.5} * width;
404 w[0] = mid + side;
405 w[1] = mid - side;
406 }
407 for (int c = 0; c < channels_; ++c)
408 out[c][offset + i] = dry * finite_sample(in[c][offset + i]) + gain * w[c];
409 }
410 predelay_write_ = (predelay_write_ + chunk) % predelay_len_;
411 }
412
413 // ── Group scheduling ─────────────────────────────────────────────────────
414
415 void advance_groups() {
416 for (auto& g : groups_) {
417 const int period = g.block_len;
418 const int phase = static_cast<int>(counter_ % static_cast<std::uint64_t>(period));
419 // `counter_` has already advanced past this chunk, so phase == 0
420 // means the chunk ended exactly on a period boundary.
421 const int elapsed = phase == 0 ? period : phase;
422
423 if (g.armed) {
424 // Grant work in proportion to how far through the window we
425 // are. At the window's end the grant is exactly `total_cost`,
426 // so the transform completes without a catch-up branch: a late
427 // host block cannot make the next one do double work.
428 const long target =
429 static_cast<long>((static_cast<long long>(g.total_cost) * elapsed) / period);
430 const long long previous_cost = block_cost_;
431 run_group_work(g, target - g.granted);
432 // An indivisible work item can leave credit unused. Carry it
433 // into the next slice instead of deferring it to the boundary.
434 g.granted += static_cast<long>(block_cost_ - previous_cost);
435 }
436
437 if (phase != 0) continue;
438
439 // Period boundary. Close the period out before publishing: the
440 // proportional grant above can land exactly on the last item of the
441 // last job, in which case the work is finished but the job walker
442 // has not yet stepped past the final cell to say so. This call is
443 // free when the period is complete. The one-unit minimum lets
444 // the walker publish an exactly completed job; any real work is
445 // limited to the period's remaining credit.
446 if (g.armed)
447 run_group_work(g, std::max(1L, g.total_cost - g.granted));
448
449 if (g.has_result) {
450 for (int cell = 0; cell < cell_count_; ++cell)
451 g.emit[static_cast<std::size_t>(cell)].swap(
452 g.ready[static_cast<std::size_t>(cell)]);
453 g.has_result = false;
454 } else {
455 for (auto& e : g.emit) std::fill(e.begin(), e.end(), SampleType{0});
456 }
457 arm_group(g);
458 }
459 }
460
467 void arm_group(Group& g) {
468 g.slot = static_cast<int>((counter_ / static_cast<std::uint64_t>(g.block_len)) %
469 static_cast<std::uint64_t>(g.num_partitions));
470 g.frame_base = (history_write_ - static_cast<std::size_t>(g.fft_size)) & history_mask_;
471 g.granted = 0;
472 g.job = 0;
473 g.job_step = 0;
474 g.job_cursor = 0;
475 g.fft.cancel();
476 g.armed = true;
477 }
478
485 void run_group_work(Group& g, long budget) {
486 if (!g.armed || budget <= 0) return;
487 const int np = g.num_partitions;
488 const int K = g.fft_size;
489 const int L = g.block_len;
490 const int half = K / 2; // Nyquist bin
491 const bool packed_stereo = channels_ == 2 && (cell_count_ == 2 || cell_count_ == 4);
492 const int output_jobs = packed_stereo ? (cell_count_ == 2 ? 1 : 2) : cell_count_;
493 long spent = 0;
494 const auto finish = [&]() noexcept { block_cost_ += spent; };
495
496 // A fused stereo group uses one complex FFT for z = xL + i*xR. The
497 // packed frame lives in accum[0], which is otherwise only an output
498 // accumulator and is therefore already allocated for this group.
499 // Gather in natural order (the in-place forward cursor performs the
500 // bit-reversal), then recover the independent lower-half spectra.
501 if (packed_stereo && g.job == 0) {
502 auto& packed = g.accum[0];
503 if (g.job_step == 0) {
504 g.job_step = 1;
505 g.job_cursor = 0;
506 }
507 if (g.job_step == 1) {
509 const int take =
510 static_cast<int>(std::min<long>((budget - spent) / unit, K - g.job_cursor));
511 if (take <= 0) {
512 finish();
513 return;
514 }
515 const auto* left = history_[0].data();
516 const auto* right = history_[1].data();
517 for (int i = g.job_cursor; i < g.job_cursor + take; ++i) {
518 const std::size_t index =
519 (g.frame_base + static_cast<std::size_t>(i)) & history_mask_;
520 packed[static_cast<std::size_t>(i)] = {left[index], right[index]};
521 }
522 g.job_cursor += take;
523 spent += static_cast<long>(take) * unit;
524 if (g.job_cursor >= K) {
525 g.fft.begin_forward_in_place();
526 g.job_step = 2;
527 g.job_cursor = 0;
528 }
529 if (spent >= budget || g.job_step == 1) {
530 finish();
531 return;
532 }
533 }
534 if (g.job_step == 2) {
535 spent += g.fft.advance(g.plan, packed.data(), budget - spent);
536 if (!g.fft.done()) {
537 finish();
538 return;
539 }
540 g.job_step = 3;
541 g.job_cursor = 0;
542 }
543 if (g.job_step == 3) {
544 constexpr long unpack_unit = 8;
545 const int take = static_cast<int>(
546 std::min<long>((budget - spent) / unpack_unit, half + 1 - g.job_cursor));
547 if (take <= 0) {
548 finish();
549 return;
550 }
551 auto& left = g.spectra[static_cast<std::size_t>(g.slot)];
552 auto& right = g.spectra[static_cast<std::size_t>(np + g.slot)];
553 for (int i = g.job_cursor; i < g.job_cursor + take; ++i) {
554 if (i == 0 || i == half) {
555 left[static_cast<std::size_t>(i)] = {
556 packed[static_cast<std::size_t>(i)].real(), 0};
557 right[static_cast<std::size_t>(i)] = {
558 packed[static_cast<std::size_t>(i)].imag(), 0};
559 } else {
560 const auto z = packed[static_cast<std::size_t>(i)];
561 const auto mirror = std::conj(packed[static_cast<std::size_t>(K - i)]);
562 const auto sum = z + mirror;
563 const auto diff = z - mirror;
564 left[static_cast<std::size_t>(i)] = sum * SampleType{0.5};
565 right[static_cast<std::size_t>(i)] = {diff.imag() * SampleType{0.5},
566 -diff.real() * SampleType{0.5}};
567 }
568 }
569 g.job_cursor += take;
570 spent += static_cast<long>(take) * unpack_unit;
571 if (g.job_cursor >= half + 1) {
572 g.job = channels_;
573 g.job_step = 0;
574 g.job_cursor = 0;
575 }
576 if (spent >= budget || g.job_step == 3) {
577 finish();
578 return;
579 }
580 }
581 }
582
583 while (spent < budget) {
584 if (g.job < channels_) {
585 // ── forward transform of channel g.job into its FDL slot ──
586 auto& spec = g.spectra[static_cast<std::size_t>(g.job * np + g.slot)];
587 if (g.fft.done() && g.job_step == 0) {
588 g.fft.begin_forward_from_ring(history_[static_cast<std::size_t>(g.job)].data(),
589 g.frame_base, history_mask_);
590 g.job_step = 1;
591 }
592 spent += g.fft.advance(g.plan, spec.data(), budget - spent);
593 if (!g.fft.done()) break;
594 ++g.job;
595 g.job_step = 0;
596 g.job_cursor = 0;
597 continue;
598 }
599
600 const int output_job = g.job - channels_;
601 if (output_job >= output_jobs) {
602 g.armed = false;
603 g.has_result = true;
604 break;
605 }
606 // A destination's diagonal accumulator holds the packed inverse:
607 // real output is diagonal, imaginary output is crossfeed. Extract
608 // both into separate cell buffers so render_chunk can gate them.
609 const int cell =
610 packed_stereo ? (cell_count_ == 2 ? 0 : (output_job == 0 ? 0 : 3)) : output_job;
611 if (!cell_active(cell)) {
612 std::fill(g.ready[static_cast<std::size_t>(cell)].begin(),
613 g.ready[static_cast<std::size_t>(cell)].end(), SampleType{0});
614 ++g.job;
615 g.job_step = 0;
616 g.job_cursor = 0;
617 continue;
618 }
619
620 auto& acc = g.accum[static_cast<std::size_t>(cell)];
621 if (g.job_step == 0) {
622 // ── spectral multiply-accumulate over the FDL ──
623 // Partition p covers IR delays [2L + pL, 3L + pL) and consumes
624 // the frame from p periods ago, so both partitions land on the
625 // SAME output range and share one inverse transform. Input and
626 // IR spectra are both Hermitian, so only bins [0, K/2] are
627 // independent; the mirror step below reconstructs the rest.
628 const long unit =
629 detail::SlicedFftPlanT<SampleType>::kMacCost * np * (packed_stereo ? 2 : 1);
630 const int remaining = half + 1 - g.job_cursor;
631 const int take = static_cast<int>(
632 std::min<long>((budget - spent) / unit, remaining));
633 if (take <= 0) break;
634 if (!packed_stereo) {
635 for (int p = 0; p < np; ++p) {
636 const int slot = (g.slot - p + np) % np;
637 const Cell& source = cells_[static_cast<std::size_t>(cell)];
638 const std::complex<SampleType>* x =
639 g.spectra[static_cast<std::size_t>(source.src * np + slot)].data();
640 const std::complex<SampleType>* h =
641 g.ir_spectra[static_cast<std::size_t>(cell * np + p)].data();
642 std::complex<SampleType>* a = acc.data();
643 if (p == 0)
644 for (int i = g.job_cursor; i < g.job_cursor + take; ++i)
645 a[i] = x[i] * h[i];
646 else
647 for (int i = g.job_cursor; i < g.job_cursor + take; ++i)
648 a[i] += x[i] * h[i];
649 }
650 } else {
651 // Compute the cross tail even while its render gate is off.
652 // An enable during partial work then exposes complete state,
653 // rather than a spectrum assembled under mixed gate values.
654 const int cross_cell = cell_count_ == 2 ? 1 : (output_job == 0 ? 2 : 1);
655 auto& cross_acc = g.accum[static_cast<std::size_t>(cross_cell)];
656 for (int i = g.job_cursor; i < g.job_cursor + take; ++i) {
657 acc[static_cast<std::size_t>(i)] = {};
658 cross_acc[static_cast<std::size_t>(i)] = {};
659 }
660 for (int source_cell = 0; source_cell < cell_count_; ++source_cell) {
661 const Cell& source = cells_[static_cast<std::size_t>(source_cell)];
662 if (cell_count_ == 4 && source.dst != output_job)
663 continue;
664 for (int p = 0; p < np; ++p) {
665 const int slot = (g.slot - p + np) % np;
666 const std::complex<SampleType>* x =
667 g.spectra[static_cast<std::size_t>(source.src * np + slot)].data();
668 const std::complex<SampleType>* h =
669 g.ir_spectra[static_cast<std::size_t>(source_cell * np + p)].data();
670 auto* a = acc.data();
671 auto* b = cross_acc.data();
672 for (int i = g.job_cursor; i < g.job_cursor + take; ++i) {
673 if (source_cell == cross_cell)
674 b[i] += x[i] * h[i];
675 else
676 a[i] += x[i] * h[i];
677 }
678 }
679 }
680 }
681 g.job_cursor += take;
682 spent += static_cast<long>(take) * unit;
683 if (g.job_cursor > half) {
684 g.job_step = 1;
685 g.job_cursor = packed_stereo ? 0 : half + 1;
686 }
687 continue;
688 }
689
690 if (g.job_step == 1) {
691 // ── Hermitian mirror onto the upper half ──
692 const int mirror_end = packed_stereo ? half + 1 : K;
693 const long mirror_unit = packed_stereo ? 2L : 1L;
694 const int take = static_cast<int>(
695 std::min<long>((budget - spent) / mirror_unit, mirror_end - g.job_cursor));
696 if (take <= 0) break;
697 std::complex<SampleType>* a = acc.data();
698 if (packed_stereo) {
699 const int cross_cell = cell_count_ == 2 ? 1 : (output_job == 0 ? 2 : 1);
700 const auto* b = g.accum[static_cast<std::size_t>(cross_cell)].data();
701 for (int i = g.job_cursor; i < g.job_cursor + take; ++i) {
702 const auto av = a[i];
703 const auto bv = b[i];
704 if (i == 0 || i == half)
705 a[i] = {av.real(), bv.real()};
706 else
707 a[i] = {av.real() - bv.imag(), av.imag() + bv.real()};
708 if (i > 0 && i < half)
709 a[K - i] = {av.real() + bv.imag(), -av.imag() + bv.real()};
710 }
711 } else {
712 for (int i = g.job_cursor; i < g.job_cursor + take; ++i)
713 a[i] = std::conj(a[K - i]);
714 }
715 g.job_cursor += take;
716 spent += static_cast<long>(take) * mirror_unit;
717 if (g.job_cursor >= mirror_end) {
718 g.job_step = 2;
719 g.job_cursor = 0;
720 g.fft.begin_inverse_in_place();
721 }
722 continue;
723 }
724
725 if (g.job_step == 2) {
726 spent += g.fft.advance(g.plan, acc.data(), budget - spent);
727 if (!g.fft.done()) break;
728 g.job_step = 3;
729 g.job_cursor = 0;
730 continue;
731 }
732
733 // ── extract: overlap-save keeps the LAST L samples ──
734 const long extract_unit = packed_stereo ? 2L : 1L;
735 const int take =
736 static_cast<int>(std::min<long>((budget - spent) / extract_unit, L - g.job_cursor));
737 if (take <= 0) break;
738 SampleType* dst = g.ready[static_cast<std::size_t>(cell)].data();
739 for (int i = g.job_cursor; i < g.job_cursor + take; ++i)
740 dst[i] = acc[static_cast<std::size_t>(L + i)].real();
741 if (packed_stereo) {
742 const int cross_cell = cell_count_ == 2 ? 1 : (output_job == 0 ? 2 : 1);
743 SampleType* cross = g.ready[static_cast<std::size_t>(cross_cell)].data();
744 for (int i = g.job_cursor; i < g.job_cursor + take; ++i)
745 cross[i] = acc[static_cast<std::size_t>(L + i)].imag();
746 }
747 g.job_cursor += take;
748 spent += static_cast<long>(take) * extract_unit;
749 if (g.job_cursor >= L) {
750 ++g.job;
751 g.job_step = 0;
752 g.job_cursor = 0;
753 }
754 }
755 block_cost_ += spent;
756 }
757
758 // ── IR preparation ───────────────────────────────────────────────────────
759
773 std::vector<double> resample_energy_preserving(const std::vector<double>& src,
774 double src_rate,
775 double dst_rate) const {
776 if (src.empty()) return src;
777 constexpr double pi = 3.14159265358979323846;
778 const double ratio = dst_rate / src_rate;
779 const int src_len = static_cast<int>(src.size());
780 const int dst_len = std::max(1, static_cast<int>(std::ceil(src_len * ratio)));
781
782 // Anti-image / anti-alias cutoff, normalised to the SOURCE Nyquist.
783 const double fc = std::min(1.0, ratio);
784 // Kernel half-width in source samples. Decimation widens the kernel so
785 // the tap count per output sample stays at the design value.
786 const double half = 0.5 * resample_taps_ / fc;
787 const int reach = static_cast<int>(std::ceil(half)) + 1;
788 const double norm = bessel_i0(kResampKaiserBeta);
789
790 std::vector<double> dst(static_cast<std::size_t>(dst_len), 0.0);
791 for (int j = 0; j < dst_len; ++j) {
792 const double centre = static_cast<double>(j) / ratio;
793 const int i0 = static_cast<int>(std::floor(centre));
794 double sum = 0.0;
795 for (int i = i0 - reach; i <= i0 + reach; ++i) {
796 if (i < 0 || i >= src_len) continue;
797 const double x = centre - static_cast<double>(i);
798 if (std::abs(x) > half) continue;
799 const double t = x / half;
800 const double w =
801 bessel_i0(kResampKaiserBeta * std::sqrt(std::max(0.0, 1.0 - t * t))) / norm;
802 const double s = std::abs(x) < 1e-12 ? fc : std::sin(pi * fc * x) / (pi * x);
803 sum += src[static_cast<std::size_t>(i)] * s * w;
804 }
805 dst[static_cast<std::size_t>(j)] = sum;
806 }
807
808 double e_before = 0.0, e_after = 0.0;
809 for (double v : src) e_before += v * v;
810 for (double v : dst) e_after += v * v;
811 if (e_after > kSilenceEpsilon && e_before > kSilenceEpsilon) {
812 const double correction = std::sqrt(e_before / e_after);
813 for (double& v : dst) v *= correction;
814 }
815 return dst;
816 }
817
819 void trim_tail(std::vector<std::vector<double>>& work) const {
820 constexpr double pi = 3.14159265358979323846;
821 if (work.empty() || work[0].empty()) return;
822 const std::size_t len = work[0].size();
823
824 // Backward-integrated energy summed over channels: the decay of the
825 // measurement as a whole, so channels are never trimmed to different
826 // lengths (that would smear the image at the truncation point).
827 std::vector<double> schroeder(len + 1, 0.0);
828 for (std::size_t i = len; i-- > 0;) {
829 double e = 0.0;
830 for (const auto& ch : work) e += ch[i] * ch[i];
831 schroeder[i] = schroeder[i + 1] + e;
832 }
833 if (schroeder[0] <= kSilenceEpsilon) return;
834
835 const double floor_lin = std::pow(10.0, tail_trim_db_ / 10.0) * schroeder[0];
836 std::size_t keep = len;
837 for (std::size_t i = 0; i < len; ++i)
838 if (schroeder[i] < floor_lin) { keep = i; break; }
839 keep = std::max<std::size_t>(keep, 1);
840
841 const auto fade_target = static_cast<std::size_t>(
842 std::max<long>(1, std::lround(units::ms_to_samples(tail_fade_ms_, sample_rate_))));
843 const std::size_t fade = std::min(keep, fade_target);
844 for (auto& ch : work) {
845 ch.resize(keep);
846 for (std::size_t i = 0; i < fade; ++i) {
847 // Raised cosine from just under 1 down to 0 over the final
848 // `fade` samples, so the last kept sample is exactly zero.
849 const double t = static_cast<double>(i + 1) / static_cast<double>(fade);
850 ch[keep - fade + i] *= 0.5 * (1.0 + std::cos(pi * t));
851 }
852 }
853 }
854
855 void normalize(std::vector<std::vector<double>>& work, int ir_channel_count) const {
856 if (normalize_mode_ == IrNormalizeMode::none) return;
857 double scale = 1.0;
858 if (normalize_mode_ == IrNormalizeMode::peak) {
859 double peak = 0.0;
860 for (const auto& ch : work)
861 for (double v : ch) peak = std::max(peak, std::abs(v));
862 if (peak > kSilenceEpsilon) scale = 1.0 / peak;
863 } else {
864 // Energy per OUTPUT ear, so a stereo or true-stereo IR keeps its
865 // balance: ONE scalar for all channels, sized by the loudest ear.
866 double worst = 0.0;
867 if (ir_channel_count == 1)
868 worst = energy(work[0]);
869 else if (ir_channel_count == 2)
870 worst = std::max(energy(work[0]), energy(work[1]));
871 else
872 worst = std::max(energy(work[0]) + energy(work[2]),
873 energy(work[1]) + energy(work[3]));
874 if (worst > kSilenceEpsilon) scale = 1.0 / std::sqrt(worst);
875 }
876 for (auto& ch : work)
877 for (double& v : ch) v *= scale;
878 }
879
880 static double energy(const std::vector<double>& v) {
881 double e = 0.0;
882 for (double x : v) e += x * x;
883 return e;
884 }
885
886 void publish(const std::vector<std::vector<double>>& work, int ir_channel_count) {
887 ir_channels_ = ir_channel_count;
888 prepared_len_ = static_cast<int>(work[0].size());
889
890 prepared_.assign(static_cast<std::size_t>(ir_channel_count), {});
891 for (int c = 0; c < ir_channel_count; ++c) {
892 auto& dst = prepared_[static_cast<std::size_t>(c)];
893 dst.resize(static_cast<std::size_t>(prepared_len_));
894 for (int i = 0; i < prepared_len_; ++i)
895 dst[static_cast<std::size_t>(i)] = static_cast<SampleType>(
896 work[static_cast<std::size_t>(c)][static_cast<std::size_t>(i)]);
897 }
898
899 build_cells(work, ir_channel_count);
900
901 // Groups tile [N0, N0 * 2^P). Group g has L = (N0/2) * 2^g and covers
902 // [2L, 4L) = [N0 * 2^g, N0 * 2^(g+1)).
903 groups_.clear();
904 smallest_period_ = head_len_;
905 if (prepared_len_ > head_len_) {
906 smallest_period_ = head_len_ / 2;
907 for (int L = head_len_ / 2; 2 * L < prepared_len_; L *= 2)
908 groups_.push_back(build_group(L, work));
909 }
910
911 // Input history ring: the largest group's frame (2L) must survive the
912 // L samples of its compute window, so 3L is the floor; the next power
913 // of two gives masking for free and covers the head as well.
914 int ring = std::max(2 * head_len_, 4 * max_block_);
915 for (const auto& g : groups_) ring = std::max(ring, 4 * g.block_len);
916 ring = round_up_pow2(ring);
917 history_.assign(2, std::vector<SampleType>(static_cast<std::size_t>(ring), SampleType{0}));
918 history_mask_ = static_cast<std::size_t>(ring) - 1;
919
920 loaded_ = true;
921 reset();
922 }
923
924 void build_cells(const std::vector<std::vector<double>>& work, int ir_channel_count) {
925 cells_.clear();
926 auto add = [&](int src, int dst, int ir_channel) {
927 Cell c;
928 c.src = src;
929 c.dst = dst;
930 c.ir_channel = ir_channel;
931 const auto& h = work[static_cast<std::size_t>(ir_channel)];
932 for (double v : h) c.l1 += std::abs(v);
933 c.head.assign(static_cast<std::size_t>(head_len_), SampleType{0});
934 const int n = std::min<int>(head_len_, static_cast<int>(h.size()));
935 for (int i = 0; i < n; ++i)
936 c.head[static_cast<std::size_t>(i)] =
937 static_cast<SampleType>(h[static_cast<std::size_t>(i)]);
938 c.head_reversed.assign(c.head.rbegin(), c.head.rend());
939 cells_.push_back(std::move(c));
940 };
941
942 if (ir_channel_count == 1) {
943 add(0, 0, 0);
944 if (channels_ == 2) add(1, 1, 0);
945 } else if (ir_channel_count == 2) {
946 add(0, 0, 0);
947 if (channels_ == 2) add(1, 1, 1);
948 } else {
949 // [LL, LR, RL, RR]: cell (src, dst) reads IR channel 2*src + dst.
950 add(0, 0, 0);
951 add(0, 1, 1);
952 if (channels_ == 2) {
953 add(1, 0, 2);
954 add(1, 1, 3);
955 }
956 }
957 cell_count_ = static_cast<int>(cells_.size());
958 refresh_active_cells();
959 }
960
965 bool cell_active(int cell) const {
966 const Cell& k = cells_[static_cast<std::size_t>(cell)];
967 return k.src == k.dst || true_stereo_;
968 }
969
970 void refresh_active_cells() {
971 active_cells_ = 0;
972 for (int c = 0; c < cell_count_; ++c)
973 if (cell_active(c)) ++active_cells_;
974 }
975
976 Group build_group(int L, const std::vector<std::vector<double>>& work) {
977 Group g;
978 g.block_len = L;
979 g.fft_size = 2 * L;
980 g.ir_start = 2 * L;
981 g.num_partitions = (3 * L < prepared_len_) ? 2 : 1;
982 g.plan.prepare(g.fft_size);
983
984 const int np = g.num_partitions;
985 const int K = g.fft_size;
986 g.spectra.assign(static_cast<std::size_t>(2 * np),
987 std::vector<std::complex<SampleType>>(static_cast<std::size_t>(K)));
988 g.ir_spectra.assign(static_cast<std::size_t>(cell_count_ * np),
989 std::vector<std::complex<SampleType>>(static_cast<std::size_t>(K)));
990 g.accum.assign(static_cast<std::size_t>(cell_count_),
991 std::vector<std::complex<SampleType>>(static_cast<std::size_t>(K)));
992 g.emit.assign(static_cast<std::size_t>(cell_count_),
993 std::vector<SampleType>(static_cast<std::size_t>(L), SampleType{0}));
994 g.ready.assign(static_cast<std::size_t>(cell_count_),
995 std::vector<SampleType>(static_cast<std::size_t>(L), SampleType{0}));
996
997 std::vector<double> segment(static_cast<std::size_t>(L), 0.0);
998 for (int cell = 0; cell < cell_count_; ++cell) {
999 const auto& h =
1000 work[static_cast<std::size_t>(cells_[static_cast<std::size_t>(cell)].ir_channel)];
1001 for (int p = 0; p < np; ++p) {
1002 const int start = g.ir_start + p * L;
1003 std::fill(segment.begin(), segment.end(), 0.0);
1004 for (int i = 0; i < L; ++i) {
1005 const int idx = start + i;
1006 if (idx < static_cast<int>(h.size()))
1007 segment[static_cast<std::size_t>(i)] = h[static_cast<std::size_t>(idx)];
1008 }
1009 g.plan.transform_real_padded(
1010 segment.data(), L, g.ir_spectra[static_cast<std::size_t>(cell * np + p)].data());
1011 }
1012 }
1013
1014 // Metered cost of one period: one forward per audio channel and one
1015 // spectral MAC per cell. For a four-cell stereo IR, pack two real
1016 // outputs into one complex inverse per destination; packing and both
1017 // output extractions are included in the distributed work budget.
1018 const bool packed_stereo = channels_ == 2 && (cell_count_ == 2 || cell_count_ == 4);
1019 if (packed_stereo) {
1020 constexpr long packed_unpack_cost = 8;
1021 g.total_cost =
1022 2L * static_cast<long>(K) * detail::SlicedFftPlanT<SampleType>::kGatherCost +
1023 g.plan.forward_cost() + static_cast<long>(K / 2 + 1) * packed_unpack_cost;
1024 } else {
1025 g.total_cost = static_cast<long>(channels_) * g.plan.forward_cost();
1026 }
1027 g.total_cost +=
1028 static_cast<long>(cell_count_) *
1029 (static_cast<long>(K / 2 + 1) * detail::SlicedFftPlanT<SampleType>::kMacCost * np);
1030 const int output_jobs = packed_stereo ? (cell_count_ == 2 ? 1 : 2) : cell_count_;
1031 const long mirror_cost = packed_stereo ? 2L * (K / 2 + 1) : K / 2 - 1;
1032 const long extract_cost = packed_stereo ? 2L * L : L;
1033 g.total_cost +=
1034 static_cast<long>(output_jobs) * (mirror_cost + g.plan.inverse_cost() + extract_cost);
1035 return g;
1036 }
1037
1038 // ── Send EQ ──────────────────────────────────────────────────────────────
1039
1040 void update_send_eq() {
1041 // Endpoint == bypass; see the parameter declaration above.
1042 hp_active_ = lowcut_hz_ > kLowcutHzMin;
1043 lp_active_ = highcut_hz_ < kHighcutHzMax;
1044 constexpr double two_pi = 6.283185307179586476925286766559;
1045 const double hp_w = two_pi * lowcut_hz_ / sample_rate_;
1046 hp_coef_ = 1.0 / (1.0 + hp_w);
1047 const double lp_w = two_pi * highcut_hz_ / sample_rate_;
1048 lp_coef_ = std::clamp(lp_w / (1.0 + lp_w), 0.0, 1.0);
1049 }
1050
1051 static int round_up_pow2(int v) {
1052 if (v <= 1) return 1;
1053 int p = 1;
1054 while (p < v) p <<= 1;
1055 return p;
1056 }
1057
1058 // ── State ────────────────────────────────────────────────────────────────
1059 double sample_rate_ = 48000.0;
1060 int max_block_ = 512;
1061 int channels_ = 2;
1062 int head_len_ = 128;
1063 bool is_prepared_ = false;
1064 bool loaded_ = false;
1065
1066 std::vector<std::vector<SampleType>> history_; // per audio channel
1067 std::size_t history_mask_ = 0;
1068 std::size_t history_write_ = 0;
1069 std::uint64_t counter_ = 0;
1070 int smallest_period_ = 1;
1071
1072 std::vector<Group> groups_;
1073 std::vector<Cell> cells_;
1074 int cell_count_ = 0;
1075 int active_cells_ = 0;
1076
1077 std::vector<std::vector<SampleType>> prepared_;
1078 int prepared_len_ = 0;
1079 int ir_channels_ = 0;
1080
1081 std::vector<std::vector<SampleType>> wet_scratch_;
1082 // Unwrapped head windows (one per source channel) and one head output
1083 // block, sized in prepare() so render_chunk never allocates.
1084 std::vector<std::vector<SampleType>> head_window_;
1085 std::vector<SampleType> head_out_;
1086 std::vector<std::vector<SampleType>> predelay_;
1087 int predelay_len_ = 1;
1088 int predelay_write_ = 0;
1089 int predelay_samples_ = 0;
1090
1091 std::vector<OnePole> hp_state_;
1092 std::vector<OnePole> lp_state_;
1093 bool hp_active_ = false;
1094 bool lp_active_ = false;
1095 double hp_coef_ = 1.0;
1096 double lp_coef_ = 1.0;
1097
1098 IrNormalizeMode normalize_mode_ = IrNormalizeMode::energy;
1099 double tail_trim_db_ = kTailTrimDbDefault;
1100 double tail_fade_ms_ = kTailFadeMsDefault;
1101 int resample_taps_ = kResampTapsPerPhaseDefault;
1102
1103 double ir_gain_db_ = kIrGainDbDefault;
1104 double ir_gain_lin_ = 1.0;
1105 double predelay_ms_ = kPredelayMsDefault;
1106 double wet_gain_ = kWetPercentDefault / 100.0;
1107 double dry_gain_ = kDryPercentDefault / 100.0;
1108 double width_ = kWidthPercentDefault / 100.0;
1109 double lowcut_hz_ = kLowcutHzDefault;
1110 double highcut_hz_ = kHighcutHzDefault;
1111 bool true_stereo_ = false;
1112
1113 long long block_cost_ = 0;
1114};
1115
1118
1134
1135} // namespace pulp::signal
Definition scoped_flush_denormals.hpp:106
See the file doc block for the schedule and its lineage.
Definition zero_latency_convolver.hpp:19
void set_tail_fade_ms(double ms)
Definition zero_latency_convolver.hpp:163
int prepared_ir_channels() const
Definition zero_latency_convolver.hpp:276
void set_resample_taps_per_phase(int taps)
Definition zero_latency_convolver.hpp:167
void set_predelay_ms(double ms)
Definition zero_latency_convolver.hpp:123
int prepared_ir_length() const
Definition zero_latency_convolver.hpp:275
void set_lowcut_hz(double hz)
Definition zero_latency_convolver.hpp:145
IrNormalizeMode normalize_mode() const
Definition zero_latency_convolver.hpp:285
int level_partitions(int level) const
Definition zero_latency_convolver.hpp:265
int latency_samples() const
The headline invariant.
Definition zero_latency_convolver.hpp:225
const SampleType * prepared_ir(int channel) const
Definition zero_latency_convolver.hpp:277
void set_true_stereo(bool on)
Definition zero_latency_convolver.hpp:131
double worst_case_gain() const
ir_gain_linear * ||h||_1, the exact l-infinity operator norm of the wet path: |y[n]| <= ||h||_1 * max...
Definition zero_latency_convolver.hpp:237
void set_highcut_hz(double hz)
Definition zero_latency_convolver.hpp:150
double l1_norm() const
||h||_1 under the current channel routing, without the IR trim.
Definition zero_latency_convolver.hpp:240
int num_levels() const
Definition zero_latency_convolver.hpp:258
double tail_trim_db() const
Definition zero_latency_convolver.hpp:286
double predelay_ms() const
Definition zero_latency_convolver.hpp:288
void process(const SampleType *const *in, SampleType *const *out, int n)
Convolve n <= max_block samples. Allocation-free and lock-free.
Definition zero_latency_convolver.hpp:174
int head_length() const
Definition zero_latency_convolver.hpp:257
void set_ir_gain_db(double db)
Definition zero_latency_convolver.hpp:118
void set_normalize_mode(IrNormalizeMode mode)
Load-time policy knobs.
Definition zero_latency_convolver.hpp:158
bool load_impulse_response(const SampleType *const *ir_channels, int ir_channel_count, int ir_length, double ir_sample_rate)
Ingest an impulse response.
Definition zero_latency_convolver.hpp:63
bool is_loaded() const
Definition zero_latency_convolver.hpp:256
void set_width_percent(double percent)
Definition zero_latency_convolver.hpp:141
int predelay_samples() const
Definition zero_latency_convolver.hpp:289
void reset()
Drop all convolution history. Allocation-free.
Definition zero_latency_convolver.hpp:210
double sample_rate() const
Definition zero_latency_convolver.hpp:284
int level_ir_start(int level) const
Definition zero_latency_convolver.hpp:262
void set_wet_percent(double percent)
Definition zero_latency_convolver.hpp:135
int level_block_length(int level) const
Definition zero_latency_convolver.hpp:259
void prepare(double sample_rate, int max_block, int channels)
Size the engine.
Definition zero_latency_convolver.hpp:25
int level_margin(int level) const
Scheduling margin of level i, in samples: the time between its frame closing and the first output it ...
Definition zero_latency_convolver.hpp:271
void set_tail_trim_db(double db)
Definition zero_latency_convolver.hpp:159
double tail_fade_ms() const
Definition zero_latency_convolver.hpp:287
long long last_block_cost() const
Estimated real flops performed by the most recent process() call.
Definition zero_latency_convolver.hpp:282
void set_dry_percent(double percent)
Definition zero_latency_convolver.hpp:138
static constexpr long kMacCost
Definition zero_latency_convolver_support.hpp:192
static constexpr long kGatherCost
Definition zero_latency_convolver_support.hpp:188
@ mirror
Definition canvas.hpp:169
@ b
Definition pitch.hpp:31
@ g
Definition pitch.hpp:27
@ index
Definition gpu_pass.hpp:140
@ ring
Definition string.hpp:18
@ width
Definition config.hpp:225
constexpr double pi
Definition fir_design.hpp:164
std::uint64_t read(std::span< const std::uint8_t > b, std::size_t p, unsigned n) noexcept
Definition spectral_frame_codec.hpp:111
void write(std::uint8_t *b, std::size_t p, std::uint64_t v, unsigned n) noexcept
Definition spectral_frame_codec.hpp:117
T ms_to_samples(T ms, T sample_rate)
Milliseconds → samples at sample_rate Hz.
Definition units.hpp:147
@ half
Definition units.hpp:290
T db_to_linear(T db)
Amplitude dB → linear gain. 0 dB → 1, −6.0206 dB → 0.5.
Definition units.hpp:71
Definition additive_bank.hpp:218
@ first
Definition dither.hpp:23
ZeroLatencyConvolverT< double > ZeroLatencyConvolver64
Definition zero_latency_convolver.hpp:1117
@ right
Definition multi_channel_meter.hpp:67
@ left
Definition multi_channel_meter.hpp:66
@ peak
Sample peak. Literal, fastest-responding.
Definition feedforward_compressor.hpp:119
@ c
Definition spectrum_trace.hpp:35
IrNormalizeMode
IR amplitude policy applied at load, after resampling and tail trim.
Definition zero_latency_convolver_support.hpp:157
@ energy
sum h^2 = 1 per output ear (default; convolution reverb).
Definition zero_latency_convolver_support.hpp:160
@ none
File passes through untouched (match / linear-phase EQ kernels).
Definition zero_latency_convolver_support.hpp:158
@ peak
max|h| = 1 (cabinet / mic-response IRs).
Definition zero_latency_convolver_support.hpp:159
float bessel_i0(float x)
Modified Bessel function of the first kind, order 0 Used in Kaiser window design.
Definition special_functions.hpp:25
T snap_to_zero(T x)
Returns 0 if |x| < snap_threshold, else x.
Definition denormal.hpp:45
@ ratio
base_frequency_hz * ratio.
Definition fm_operator_engine.hpp:20
ZeroLatencyConvolverT< float > ZeroLatencyConvolver
Definition zero_latency_convolver.hpp:1116
@ a
Definition formant_filter_bank.hpp:20
@ i
Definition formant_filter_bank.hpp:20
@ e
Definition formant_filter_bank.hpp:20
@ linear
2-point. Cheapest; audible imaging when pitching up.
Definition granular.hpp:133
float sum(const float *data, std::size_t count) noexcept
void scale(const float *a, float scalar, float *dst, std::size_t count) noexcept
void add(const float *a, const float *b, float *dst, std::size_t count) noexcept
@ s
Definition input_events.hpp:41
@ h
Definition input_events.hpp:39
@ j
Definition input_events.hpp:40
@ n
Definition input_events.hpp:40
@ p
Definition input_events.hpp:40
@ k
Definition input_events.hpp:40
@ x
Definition input_events.hpp:41
@ v
Definition input_events.hpp:41
@ t
Definition input_events.hpp:41
@ w
Definition input_events.hpp:41
@ z
Definition input_events.hpp:42
@ start
Definition geometry.hpp:370
Zero-latency non-uniform partitioned convolution engine.
Definition zero_latency_convolver_support.hpp:422
static constexpr double kWidthPercentMax
Definition zero_latency_convolver_support.hpp:465
static constexpr double kWidthPercentMin
Definition zero_latency_convolver_support.hpp:464
static constexpr double kResampKaiserBeta
House Kaiser window shape, fixed by the series contract's oversampling / resampling law — not a tunab...
Definition zero_latency_convolver_support.hpp:522
static constexpr double kPredelayMsMin
Definition zero_latency_convolver_support.hpp:441
static constexpr double kTailTrimDbMin
Definition zero_latency_convolver_support.hpp:470
static constexpr int kResampTapsPerPhaseMax
Definition zero_latency_convolver_support.hpp:485
static constexpr double kHighcutHzDefault
Definition zero_latency_convolver_support.hpp:458
static constexpr double kTailFadeMsDefault
Raised-cosine fade applied to the last part of the kept IR so the truncation cannot click.
Definition zero_latency_convolver_support.hpp:475
static constexpr double kDryPercentDefault
Definition zero_latency_convolver_support.hpp:446
static constexpr double kTailTrimDbDefault
IR truncation floor on the Schroeder backward-energy curve.
Definition zero_latency_convolver_support.hpp:469
static constexpr double kLowcutHzMin
Definition zero_latency_convolver_support.hpp:456
static constexpr int kHeadLenMax
Definition zero_latency_convolver_support.hpp:431
static constexpr double kIrGainDbDefault
IR trim on the convolved path. [dp, default 0 dB, range -24..+24 dB].
Definition zero_latency_convolver_support.hpp:434
static constexpr double kTailTrimDbMax
Definition zero_latency_convolver_support.hpp:471
static constexpr double kHighcutHzMin
Definition zero_latency_convolver_support.hpp:459
static constexpr int kResampTapsPerPhaseMin
Definition zero_latency_convolver_support.hpp:484
static constexpr int kResampTapsPerPhaseDefault
Resampler kernel width, in taps per polyphase branch.
Definition zero_latency_convolver_support.hpp:483
static constexpr double kSilenceEpsilon
Below this, energies are treated as zero.
Definition zero_latency_convolver_support.hpp:536
static constexpr double kIrGainDbMin
Definition zero_latency_convolver_support.hpp:435
static constexpr double kPredelayMsMax
Definition zero_latency_convolver_support.hpp:442
static constexpr double kHighcutHzMax
Definition zero_latency_convolver_support.hpp:460
static constexpr double kLowcutHzMax
Definition zero_latency_convolver_support.hpp:457
static constexpr double kTailFadeMsMin
Definition zero_latency_convolver_support.hpp:476
static constexpr double kLowcutHzDefault
Send-EQ corners.
Definition zero_latency_convolver_support.hpp:455
static bool valid_resample_geometry(int source_length, double source_rate, double destination_rate, int taps_per_phase) noexcept
Proves every resampler geometry value is finite and representable before the ingest path casts it to ...
Definition zero_latency_convolver_support.hpp:491
static constexpr double kWidthPercentDefault
Mid/side width of the wet output. [dp, default 100 %, range 0..200 %].
Definition zero_latency_convolver_support.hpp:463
static constexpr double kIrGainDbMax
Definition zero_latency_convolver_support.hpp:436
static constexpr int kHeadLenMin
Definition zero_latency_convolver_support.hpp:430
static constexpr double kTailFadeMsMax
Definition zero_latency_convolver_support.hpp:477
static constexpr double kPredelayMsDefault
Pre-IR delay of the wet path.
Definition zero_latency_convolver_support.hpp:440
static constexpr double kWetPercentDefault
Wet / dry mix, in percent. [dp, defaults 100 % / 0 %, range 0..100 %].
Definition zero_latency_convolver_support.hpp:445