truce_core/buffer.rs
1use truce_params::sample::Sample;
2
3/// Non-interleaved audio buffer. Borrows host memory through the
4/// format wrapper.
5///
6/// Generic over the sample type `S` (the plugin's chosen precision,
7/// `f32` or `f64`). The format wrapper bridges between host-buffer
8/// precision and `S` at the block boundary - see
9/// [`RawBufferScratch::build`]. Plugin code under
10/// `use truce::prelude::*;` (f32) or `use truce::prelude64::*;` (f64)
11/// sees `AudioBuffer<S>` with `S` already picked.
12///
13/// **In-place I/O.** Some hosts (Reaper, pluginval) pass the same
14/// buffer for both input and output of a given channel. By default
15/// the wrapper copies the aliased inputs into per-channel scratch so
16/// `input(ch)` and `output(ch)` are disjoint `&[S]` / `&mut [S]` -
17/// no plugin code change required. Plugins that opt into
18/// `Plugin::supports_in_place() = true` skip the copy and must use
19/// [`Self::in_out_mut`] for channels where [`Self::is_in_place`]
20/// returns `true`.
21pub struct AudioBuffer<'a, S: Sample = f32> {
22 inputs: &'a [&'a [S]],
23 outputs: &'a mut [&'a mut [S]],
24 /// Bit `ch` is set when `inputs[ch]` and `outputs[ch]` point to
25 /// the same host memory. Channels ≥ 64 are always reported as
26 /// non-aliased - formats with that many channels are exotic
27 /// enough to be a follow-up.
28 in_place_mask: u64,
29 offset: usize,
30 num_samples: usize,
31}
32
33impl<'a, S: Sample> AudioBuffer<'a, S> {
34 /// Safe wrapper around [`Self::from_slices`] for callers that hold their
35 /// own owned `Vec<Vec<S>>` (e.g. `truce-driver`'s test harness).
36 /// Forwards to the unsafe constructor - the borrow checker proves
37 /// the lifetime invariants the `unsafe fn` requires when both
38 /// slice arrays and the buffer itself live in the same scope.
39 /// `num_samples > slice length` still asserts in debug builds.
40 pub fn from_slices_checked(
41 inputs: &'a [&'a [S]],
42 outputs: &'a mut [&'a mut [S]],
43 num_samples: usize,
44 ) -> Self {
45 // SAFETY: caller hands us references that the borrow checker
46 // already proved valid for `'a`; the debug-mode assertions
47 // inside `from_slices` cover the `num_samples` bound.
48 unsafe { Self::from_slices(inputs, outputs, num_samples) }
49 }
50
51 /// Create a buffer from pre-split channel slices.
52 /// Used by format wrappers after converting from host-specific buffer types.
53 ///
54 /// # Safety
55 /// The caller must ensure the slices are valid for the lifetime `'a`
56 /// and that `num_samples` does not exceed any slice's length.
57 ///
58 /// # Panics
59 ///
60 /// In debug builds only, panics if any input channel aliases an
61 /// output channel or `num_samples` exceeds the length of any
62 /// input/output slice. Release builds skip these checks (they're
63 /// safety preconditions, not runtime invariants).
64 pub unsafe fn from_slices(
65 inputs: &'a [&'a [S]],
66 outputs: &'a mut [&'a mut [S]],
67 num_samples: usize,
68 ) -> Self {
69 #[cfg(debug_assertions)]
70 {
71 // Verify no input channel aliases any output channel.
72 for (i, inp) in inputs.iter().enumerate() {
73 let i_start = inp.as_ptr() as usize;
74 let i_end = i_start + std::mem::size_of_val(*inp);
75 for (o, out) in outputs.iter().enumerate() {
76 let o_start = out.as_ptr() as usize;
77 let o_end = o_start + std::mem::size_of_val(*out);
78 assert!(
79 i_end <= o_start || o_end <= i_start,
80 "AudioBuffer: input channel {i} and output channel {o} alias \
81 - pass disjoint slices or use RawBufferScratch::build which \
82 handles aliasing automatically",
83 );
84 }
85 }
86 // Verify num_samples doesn't exceed any slice length. An empty
87 // input slice is an in-place channel (`supports_in_place`): the
88 // plugin reads+writes via `in_out_mut`, so there's no separate
89 // input to bound-check. Any non-empty slice must cover the block.
90 for (i, inp) in inputs.iter().enumerate() {
91 assert!(
92 inp.is_empty() || num_samples <= inp.len(),
93 "AudioBuffer: num_samples ({num_samples}) exceeds input channel {i} length ({})",
94 inp.len(),
95 );
96 }
97 for (o, out) in outputs.iter().enumerate() {
98 assert!(
99 num_samples <= out.len(),
100 "AudioBuffer: num_samples ({num_samples}) exceeds output channel {o} length ({})",
101 out.len(),
102 );
103 }
104 }
105 AudioBuffer {
106 inputs,
107 outputs,
108 in_place_mask: 0,
109 offset: 0,
110 num_samples,
111 }
112 }
113
114 /// Set the in-place mask. Called by format wrappers (or
115 /// `RawBufferScratch::build`) after construction once they've
116 /// determined which channels alias on the host side.
117 #[inline]
118 pub fn set_in_place_mask(&mut self, mask: u64) {
119 self.in_place_mask = mask;
120 }
121
122 /// `true` when the host passes a single buffer for both input and
123 /// output of `ch` (in-place I/O). Use [`Self::in_out_mut`] to read
124 /// and write that buffer directly when this returns `true`.
125 #[must_use]
126 pub fn is_in_place(&self, ch: usize) -> bool {
127 ch < 64 && (self.in_place_mask >> ch) & 1 == 1
128 }
129
130 /// Read+write slice for an in-place channel. Each sample reads as the
131 /// input value before the plugin overwrites it: on a same-precision wire
132 /// this is the host's shared in/out buffer directly (zero-copy); on a
133 /// precision-converting wire it's a per-channel scratch seeded with the
134 /// converted input and narrowed back to the host on output. Either way
135 /// the contract - `is_in_place(ch)` true, `input(ch)` empty, data reached
136 /// through here - holds.
137 ///
138 /// Only meaningful when [`Self::is_in_place`] returns `true`. On a
139 /// non-in-place channel this returns the output slice with no
140 /// input data in it; reading is allowed but produces uninitialized
141 /// host-buffer contents.
142 pub fn in_out_mut(&mut self, ch: usize) -> &mut [S] {
143 let end = self.offset + self.num_samples;
144 &mut self.outputs[ch][self.offset..end]
145 }
146
147 /// Debug guard for the accessors that hand out a disjoint `(&[S], &mut
148 /// [S])` for a channel (`io` / `io_pair` / `for_each_frame` /
149 /// `for_each_frame_io` / `chunks_mut`). Those shapes can't represent a
150 /// zero-copy in-place channel: its input and output are the same memory,
151 /// so a live `&` input would alias the `&mut` output - which is why such
152 /// a channel's input slice is the empty sentinel. Use [`Self::in_out_mut`]
153 /// there instead.
154 ///
155 /// Keys on the empty input slice, **not** [`Self::is_in_place`]: the copy
156 /// path also reports `is_in_place` (the host aliased, but the wrapper
157 /// snapshotted the input), yet its input is a full, readable slice these
158 /// accessors handle fine. Compiled out in release, where indexing the
159 /// empty slice then panics out of range (caught by the process firewall).
160 #[inline]
161 fn debug_assert_not_in_place(&self, ch: usize) {
162 debug_assert!(
163 self.num_samples == 0
164 || ch >= self.inputs.len()
165 || self.inputs[ch].len() >= self.offset + self.num_samples,
166 "AudioBuffer: channel {ch} is a zero-copy in-place channel (host \
167 aliases its input and output, so its input slice is empty); a \
168 disjoint (input, output) accessor can't represent it - use \
169 in_out_mut({ch})"
170 );
171 }
172
173 #[must_use]
174 pub fn num_samples(&self) -> usize {
175 self.num_samples
176 }
177
178 #[must_use]
179 pub fn num_input_channels(&self) -> usize {
180 self.inputs.len()
181 }
182
183 #[must_use]
184 pub fn num_output_channels(&self) -> usize {
185 self.outputs.len()
186 }
187
188 #[must_use]
189 pub fn input(&self, channel: usize) -> &[S] {
190 let s = self.inputs[channel];
191 // An empty backing slice marks an in-place channel: the host aliases
192 // it and the plugin opted into `supports_in_place`, so it reads and
193 // writes the shared buffer through `in_out_mut`. A real input slice
194 // would alias the output. Return the empty slice as-is; slicing
195 // `[offset..end]` would be out of range.
196 if s.is_empty() {
197 return s;
198 }
199 let end = self.offset + self.num_samples;
200 &s[self.offset..end]
201 }
202
203 pub fn output(&mut self, channel: usize) -> &mut [S] {
204 let end = self.offset + self.num_samples;
205 &mut self.outputs[channel][self.offset..end]
206 }
207
208 /// Number of channels (min of input and output).
209 #[must_use]
210 pub fn channels(&self) -> usize {
211 self.inputs.len().min(self.outputs.len())
212 }
213
214 /// Get a disjoint `(input, output)` pair for a channel. NOT for
215 /// in-place (host-aliased) channels: their input and output are the
216 /// same memory, which this shape can't represent - use
217 /// [`Self::in_out_mut`] there.
218 pub fn io_pair(&mut self, in_ch: usize, out_ch: usize) -> (&[S], &mut [S]) {
219 self.debug_assert_not_in_place(in_ch);
220 let end = self.offset + self.num_samples;
221 let input = &self.inputs[in_ch][self.offset..end];
222 let output = &mut self.outputs[out_ch][self.offset..end];
223 (input, output)
224 }
225
226 /// Get an input/output pair for the same channel index. Shorthand for `io_pair(ch, ch)`.
227 pub fn io(&mut self, ch: usize) -> (&[S], &mut [S]) {
228 self.io_pair(ch, ch)
229 }
230
231 /// Iterate per-channel, in fixed-size `N`-sample chunks. The
232 /// last chunk of each channel may be shorter than `N`; it's
233 /// yielded as a [`ChunkItem::Tail`] with the actual remaining
234 /// length, and the caller falls back to scalar for it. Full
235 /// `N`-sample chunks arrive as [`ChunkItem::Full`] carrying
236 /// `&[S; N]` / `&mut [S; N]` stack arrays - exactly the shape
237 /// the per-op SIMD primitives in `truce-simd` expect.
238 ///
239 /// Iteration order is channel-major: all chunks of channel 0,
240 /// then all chunks of channel 1, etc. Matches the natural
241 /// orientation for per-channel state (biquad coefficients,
242 /// per-channel meters) and lets the caller read its smoothed
243 /// params once per chunk instead of once per sample.
244 ///
245 /// The returned object is a "lending iterator" - it doesn't
246 /// implement [`Iterator`] because each yielded item borrows
247 /// from the iterator itself. Use `while let Some(chunk) = …
248 /// .next()`:
249 ///
250 /// ```ignore
251 /// let mut chunks = buffer.chunks_mut::<32>();
252 /// while let Some(chunk) = chunks.next() {
253 /// match chunk {
254 /// ChunkItem::Full { ch, inp, out, .. } => {
255 /// // SIMD-friendly path, inp / out are &[f32; 32]
256 /// }
257 /// ChunkItem::Tail { ch, inp, out, .. } => {
258 /// // scalar fallback for the trailing samples
259 /// }
260 /// }
261 /// }
262 /// ```
263 ///
264 /// Const-generic `N` is the chunk size; pick it to match the
265 /// SIMD width × unroll factor for your inner op (32 / 64 are
266 /// good defaults for current Apple Silicon + `x86_64`).
267 pub fn chunks_mut<const N: usize>(&mut self) -> ChunksMut<'_, 'a, S, N> {
268 ChunksMut {
269 buffer: self,
270 ch: 0,
271 pos: 0,
272 silence: [S::default(); N],
273 }
274 }
275
276 /// Iterate per-frame and hand a fixed-size `(input, output)`
277 /// stack-array pair to `tick`. Sized at the type level by const
278 /// generic `N`, which must equal [`Self::channels`].
279 ///
280 /// `io()` / `io_pair()` give a per-channel slice view, which is
281 /// the right shape for "process channel `ch` in isolation"
282 /// loops. But libraries that expect a per-frame `(in: &[S],
283 /// out: &mut [S])` callback - `fundsp::AudioUnit::tick`,
284 /// `nih_plug`'s frame iterators, custom per-sample DSP nodes -
285 /// can't take that shape directly without either copying inputs
286 /// into a scratch first (heap allocation on the audio thread)
287 /// or fighting the borrow checker over two simultaneous `&mut`
288 /// borrows of the buffer.
289 ///
290 /// This helper does the per-frame transpose in-place against a
291 /// stack-allocated `[S; N]` pair, calls `tick` `num_samples()`
292 /// times, and writes back. No heap, no borrow gymnastics at the
293 /// call site:
294 ///
295 /// ```ignore
296 /// // Stereo plugin delegating per-frame DSP to fundsp:
297 /// buffer.for_each_frame::<2, _>(|frame_in, frame_out| {
298 /// self.graph.tick(frame_in, frame_out);
299 /// });
300 /// ```
301 ///
302 /// `&[S; N]` deref-coerces to `&[S]` at the call site, so
303 /// callers can pass the arrays straight to slice-taking APIs
304 /// like fundsp's `tick`.
305 ///
306 /// # Panics
307 ///
308 /// Debug builds panic if `N != self.channels()`. Release builds
309 /// rely on the same precondition without checking; reading past
310 /// the actual channel count would index out of bounds anyway.
311 pub fn for_each_frame<const N: usize, F>(&mut self, mut tick: F)
312 where
313 F: FnMut(&[S; N], &mut [S; N]),
314 {
315 debug_assert_eq!(
316 N,
317 self.channels(),
318 "for_each_frame::<{N}> requires the buffer to have exactly {N} channels"
319 );
320 for ch in 0..N {
321 self.debug_assert_not_in_place(ch);
322 }
323 let mut frame_in = [S::default(); N];
324 let mut frame_out = [S::default(); N];
325 let end = self.offset + self.num_samples;
326 for i in self.offset..end {
327 for (ch, slot) in frame_in.iter_mut().enumerate() {
328 *slot = self.inputs[ch][i];
329 }
330 tick(&frame_in, &mut frame_out);
331 for (ch, sample) in frame_out.iter().enumerate() {
332 self.outputs[ch][i] = *sample;
333 }
334 }
335 }
336
337 /// Like [`Self::for_each_frame`] but for a DSP whose frame shape is a
338 /// fixed `(IN, OUT)` that need not match the bus width. Input slot `k`
339 /// reads bus input channel `k`, repeating the last available channel
340 /// when the bus has fewer than `IN` inputs - so a mono source fans into
341 /// both inputs of a stereo graph. Output slot `k` writes bus output
342 /// channel `k` while `k < num_output_channels`; frame outputs past the
343 /// bus width are dropped.
344 ///
345 /// This lets a plugin built around a fixed-shape DSP (a fundsp
346 /// `reverb_stereo`, a dasp graph) run on any declared bus layout -
347 /// `(2, 2)` stereo and `(1, 2)` mono-in/stereo-out alike - through one
348 /// `for_each_frame_io::<2, 2>` call, with no per-width branch. A bus
349 /// with no inputs (an instrument) feeds silence.
350 pub fn for_each_frame_io<const IN: usize, const OUT: usize, F>(&mut self, mut tick: F)
351 where
352 F: FnMut(&[S; IN], &mut [S; OUT]),
353 {
354 let num_in = self.inputs.len();
355 let num_out = self.outputs.len();
356 for ch in 0..num_in.min(IN) {
357 self.debug_assert_not_in_place(ch);
358 }
359 let mut frame_in = [S::default(); IN];
360 let mut frame_out = [S::default(); OUT];
361 let end = self.offset + self.num_samples;
362 for i in self.offset..end {
363 if num_in > 0 {
364 for (k, slot) in frame_in.iter_mut().enumerate() {
365 *slot = self.inputs[k.min(num_in - 1)][i];
366 }
367 }
368 tick(&frame_in, &mut frame_out);
369 for (k, sample) in frame_out.iter().enumerate().take(num_out) {
370 self.outputs[k][i] = *sample;
371 }
372 }
373 }
374
375 /// [`Self::for_each_frame_io`] specialized to a stereo `(2, 2)` DSP -
376 /// the common case (a `reverb_stereo`, a stereo filter block). Runs the
377 /// 2-in/2-out `tick` over any declared bus: a mono source fans into
378 /// both inputs, a stereo bus maps 1:1, so a stereo effect needs no
379 /// per-width branch and no turbofish.
380 pub fn for_each_stereo_frame<F>(&mut self, tick: F)
381 where
382 F: FnMut(&[S; 2], &mut [S; 2]),
383 {
384 self.for_each_frame_io::<2, 2, F>(tick);
385 }
386
387 /// Peak absolute value across an output channel, returned as `f32`
388 /// because meters / UI display always work in `f32` regardless of
389 /// the plugin's internal precision.
390 ///
391 /// Short-circuits and returns `f32::NAN` on the **first** NaN
392 /// sample seen, so meters can flag runaway plugins instead of
393 /// silently reporting "peaks within range" while NaN poison
394 /// spreads downstream.
395 #[must_use]
396 pub fn output_peak(&self, ch: usize) -> f32 {
397 let end = self.offset + self.num_samples;
398 let mut peak = 0.0f32;
399 for &b in &self.outputs[ch][self.offset..end] {
400 let v = b.to_f32();
401 if v.is_nan() {
402 return f32::NAN;
403 }
404 let abs = v.abs();
405 if abs > peak {
406 peak = abs;
407 }
408 }
409 peak
410 }
411
412 /// Return a sub-block view covering samples `start..start+len`.
413 ///
414 /// The returned buffer borrows `self` exclusively - you cannot use
415 /// the original buffer while the slice is alive.
416 ///
417 /// # Panics
418 /// Panics if `start + len > self.num_samples()`.
419 pub fn slice(&mut self, start: usize, len: usize) -> AudioBuffer<'_, S> {
420 assert!(
421 start + len <= self.num_samples,
422 "slice({start}, {len}) out of bounds for buffer of {} samples",
423 self.num_samples,
424 );
425 let new_offset = self.offset + start;
426 // SAFETY: We construct an AudioBuffer<'a, S> and transmute to AudioBuffer<'_, S>.
427 // These have identical memory layout (lifetimes are erased at runtime).
428 // This is sound because:
429 // 1. &mut self prevents the caller from using self while the slice exists
430 // 2. The underlying channel memory lives for 'a which outlives '_
431 // 3. Bounds are checked by the assert above
432 let self_ptr: *mut Self = self;
433 unsafe {
434 let s = &mut *self_ptr;
435 std::mem::transmute::<AudioBuffer<'a, S>, AudioBuffer<'_, S>>(AudioBuffer {
436 inputs: s.inputs,
437 outputs: &mut *s.outputs,
438 in_place_mask: s.in_place_mask,
439 offset: new_offset,
440 num_samples: len,
441 })
442 }
443 }
444}
445
446/// One yielded chunk from [`AudioBuffer::chunks_mut`].
447///
448/// `Full` is the SIMD-friendly path: `inp` and `out` are stack
449/// arrays of exactly `N` elements, ready to feed `truce-simd`'s
450/// block ops. `Tail` is the trailing fragment when `num_samples()`
451/// isn't a multiple of `N`; fall back to a scalar loop.
452pub enum ChunkItem<'b, S: Sample, const N: usize> {
453 /// Full N-sample chunk. The `&[S; N]` / `&mut [S; N]` are the
454 /// shape `truce-simd` ops are written against - no slice
455 /// length check at the call site.
456 Full {
457 /// Channel index this chunk belongs to.
458 ch: usize,
459 /// Sample offset within the audio block this chunk starts
460 /// at. Use this when indexing into a precomputed envelope
461 /// array - `chunks_mut` iterates channel-major, so the
462 /// envelope (typically read once per audio block via
463 /// `read_into(&mut env[..num_samples])`) is shared across all
464 /// channel passes.
465 sample: usize,
466 /// Read-only N-sample input slice.
467 inp: &'b [S; N],
468 /// Mutable N-sample output slice.
469 out: &'b mut [S; N],
470 },
471 /// Trailing chunk when `num_samples()` isn't a multiple of `N`.
472 /// Length is in `(0, N)`. Fall back to scalar processing.
473 Tail {
474 /// Channel index this chunk belongs to.
475 ch: usize,
476 /// Sample offset within the audio block this chunk starts at.
477 sample: usize,
478 /// Read-only tail input slice; length < N.
479 inp: &'b [S],
480 /// Mutable tail output slice; length < N.
481 out: &'b mut [S],
482 },
483}
484
485/// Lending iterator returned by [`AudioBuffer::chunks_mut`].
486///
487/// Does not implement [`Iterator`] because each yielded
488/// [`ChunkItem`] borrows from the iterator itself - the standard
489/// "GATs would help here" pattern. Drive it with `while let
490/// Some(chunk) = chunks.next()` instead. See
491/// [`AudioBuffer::chunks_mut`] for a worked example.
492pub struct ChunksMut<'b, 'a, S: Sample, const N: usize> {
493 buffer: &'b mut AudioBuffer<'a, S>,
494 /// Current channel being walked.
495 ch: usize,
496 /// Position within the current channel, relative to
497 /// `buffer.offset`. Advances by N each Full chunk, then jumps
498 /// to `num_samples` for the Tail (or directly past it when
499 /// `num_samples` is a multiple of N).
500 pos: usize,
501 /// Zero-filled read source for output channels that have no matching
502 /// input (an instrument's 0-in/N-out, or any asymmetric bus where
503 /// `inputs.len() < outputs.len()`). Mirrors the block-length silence
504 /// `input(ch)` hands out for an unconnected bus, kept on the iterator
505 /// so `inp` can borrow it without allocating.
506 silence: [S; N],
507}
508
509impl<S: Sample, const N: usize> ChunksMut<'_, '_, S, N> {
510 /// Yield the next chunk, or `None` when every channel has been
511 /// fully walked.
512 ///
513 /// Method-on-self rather than `Iterator::next` because each
514 /// yielded [`ChunkItem`] borrows from `self`; GATs would be
515 /// needed to express that through the `Iterator` trait.
516 #[allow(clippy::should_implement_trait, clippy::missing_panics_doc)]
517 pub fn next(&mut self) -> Option<ChunkItem<'_, S, N>> {
518 loop {
519 if self.ch >= self.buffer.outputs.len() {
520 return None;
521 }
522 let ns = self.buffer.num_samples;
523 if self.pos >= ns {
524 self.ch += 1;
525 self.pos = 0;
526 continue;
527 }
528 let abs_start = self.buffer.offset + self.pos;
529 let remaining = ns - self.pos;
530 let take = remaining.min(N);
531 let abs_end = abs_start + take;
532 let ch = self.ch;
533 let sample = self.pos;
534
535 // An output channel past the last input (an instrument, or a
536 // mono-in/stereo-out effect) reads as silence, matching the
537 // unconnected-bus contract `input(ch)` upholds. Indexing
538 // `inputs[ch]` here would panic - the bug this guards.
539 let inp_slice: &[S] = if ch < self.buffer.inputs.len() {
540 self.buffer.debug_assert_not_in_place(ch);
541 &self.buffer.inputs[ch][abs_start..abs_end]
542 } else {
543 &self.silence[..take]
544 };
545 let out_slice: &mut [S] = &mut self.buffer.outputs[ch][abs_start..abs_end];
546
547 self.pos += take;
548
549 // Full vs Tail by length: full chunks convert to `&[S;
550 // N]` / `&mut [S; N]` for the SIMD-friendly path; tails
551 // fall back to slice form.
552 return Some(if take == N {
553 ChunkItem::Full {
554 ch,
555 sample,
556 // Length-checked above; `try_into` here is a
557 // free reinterpret.
558 inp: inp_slice.try_into().expect("len == N by construction"),
559 out: out_slice.try_into().expect("len == N by construction"),
560 }
561 } else {
562 ChunkItem::Tail {
563 ch,
564 sample,
565 inp: inp_slice,
566 out: out_slice,
567 }
568 });
569 }
570 }
571}
572
573/// Scratch space for [`RawBufferScratch::build`].
574///
575/// Callers allocate this on the stack and pass it to `build`. The
576/// buffer borrows the slices stored here, so this struct must outlive
577/// the returned `AudioBuffer`.
578///
579/// Generic over the plugin's sample type `S`. When the host buffer
580/// matches `S`, slices point into host memory (zero-copy). When the
581/// host buffer is a different precision, the input is widened/narrowed
582/// into per-channel scratch; the output is rendered into scratch and
583/// the wrapper copies + casts it back to the host buffer at the end
584/// of the block via [`Self::finish_widening`].
585pub struct RawBufferScratch<S: Sample = f32> {
586 pub input_slices: Vec<&'static [S]>,
587 pub output_slices: Vec<&'static mut [S]>,
588 /// Per-channel input copies. Used (a) when the host passes the
589 /// same buffer for input and output (in-place processing - VST3
590 /// spec allows this and several real DAWs use it for effects),
591 /// or (b) when the host buffer precision differs from `S` and
592 /// we widen/narrow on the way in. In either case the slice the
593 /// plugin sees points into the matching slot here.
594 input_copies: Vec<Vec<S>>,
595 /// Per-channel output scratch. Populated by [`Self::build`] when
596 /// the host buffer precision differs from `S` (the wrapper copies +
597 /// casts these back via [`Self::finish_widening`]), and reused as
598 /// write-discard scratch for an unconnected (null) output channel.
599 output_buffers: Vec<Vec<S>>,
600 /// Shared read-only silence handed to the plugin for an unconnected
601 /// (null) input channel - an unrouted sidechain, or an LV2 port the
602 /// host never connected. The plugin negotiated the channel, so it
603 /// must read block-length silence, never the out-of-range empty
604 /// slice a raw null would otherwise produce. Never written.
605 silence: Vec<S>,
606}
607
608impl<S: Sample> RawBufferScratch<S> {
609 /// Build an `AudioBuffer<S>` from raw host pointers of wire
610 /// precision `H` - `f32` in the common case (CLAP, LV2, AAX
611 /// always; VST3/VST2/AU 32-bit mode), `f64` when the host
612 /// negotiated a double-precision wire (VST3 `kSample64`, VST2
613 /// `processDoubleReplacing`).
614 ///
615 /// When `S = H`, slices point directly into host memory (modulo
616 /// in-place input copying). Otherwise every channel is converted
617 /// into per-channel scratch and the wrapper must call
618 /// [`Self::finish_widening`] at the end of the block to copy the
619 /// rendered samples back to the host's output pointers.
620 ///
621 /// # Safety
622 /// - `inputs` must point to `num_in` valid `*const H` pointers
623 /// (each non-null pointer must address at least `num_frames`
624 /// readable samples; a null pointer marks an unconnected channel
625 /// and reads back as block-length silence).
626 /// - `outputs` must point to `num_out` valid `*mut H` pointers
627 /// (each non-null pointer must address at least `num_frames`
628 /// writable samples; a null pointer marks an unconnected channel
629 /// whose writes are discarded).
630 /// - The pointed-to memory must remain valid for the lifetime of
631 /// the returned `AudioBuffer`.
632 pub unsafe fn build<H: Sample>(
633 &mut self,
634 inputs: *const *const H,
635 outputs: *mut *mut H,
636 num_in: u32,
637 num_out: u32,
638 num_frames: u32,
639 supports_in_place: bool,
640 ) -> AudioBuffer<'_, S> {
641 // SAFETY: forwarded - caller's contract is the same.
642 unsafe {
643 self.build_inner(
644 inputs,
645 outputs,
646 num_in,
647 num_out,
648 num_frames,
649 supports_in_place,
650 )
651 }
652 }
653
654 /// Copy + convert the rendered `S` output back to the host's `H`
655 /// output pointers. No-op when `S = H` (the slices the plugin
656 /// wrote already point directly at host memory).
657 ///
658 /// # Safety
659 /// `outputs` and `num_out` / `num_frames` must match the values
660 /// passed to the prior [`Self::build`] call on this scratch.
661 pub unsafe fn finish_widening<H: Sample>(
662 &self,
663 outputs: *mut *mut H,
664 num_out: u32,
665 num_frames: u32,
666 ) {
667 // Same precision: the plugin wrote straight into host memory.
668 if S::IS_F64 == H::IS_F64 {
669 return;
670 }
671 unsafe {
672 let nf = num_frames as usize;
673 for ch in 0..(num_out as usize) {
674 let ptr = *outputs.add(ch);
675 if ptr.is_null() {
676 continue;
677 }
678 let host = std::slice::from_raw_parts_mut(ptr, nf);
679 let plugin_out = &self.output_buffers[ch];
680 for (h, &p) in host.iter_mut().zip(plugin_out.iter()) {
681 *h = H::from_f64(p.to_f64());
682 }
683 }
684 }
685 }
686
687 unsafe fn build_inner<'a, H: Sample>(
688 &'a mut self,
689 inputs: *const *const H,
690 outputs: *mut *mut H,
691 num_in: u32,
692 num_out: u32,
693 num_frames: u32,
694 supports_in_place: bool,
695 ) -> AudioBuffer<'a, S> {
696 const MAX_CHANNELS_TRACKED: usize = 64;
697 // Whether the plugin's chosen precision matches the host's.
698 // When matched, we zero-copy host pointers into the slice
699 // arrays; when not, we convert through input_copies and
700 // output_buffers. The traits are sealed at f32/f64, so equal
701 // IS_F64 flags mean S and H are the same type.
702 let same_precision = S::IS_F64 == H::IS_F64;
703
704 unsafe {
705 let nf = num_frames as usize;
706 let num_out_u = num_out as usize;
707 let num_in_u = num_in as usize;
708 debug_assert!(
709 num_out_u <= MAX_CHANNELS_TRACKED,
710 "RawBufferScratch::build: alias detection only covers up to {MAX_CHANNELS_TRACKED} \
711 output channels; got {num_out_u}. Channels beyond the cap won't be \
712 detected as aliased.",
713 );
714 let out_ptrs: [Option<*mut H>; MAX_CHANNELS_TRACKED] = std::array::from_fn(|ch| {
715 if ch < num_out_u {
716 let p = *outputs.add(ch);
717 if p.is_null() { None } else { Some(p) }
718 } else {
719 None
720 }
721 });
722 let aliases_any_output = |in_ptr: *const H| -> bool {
723 let in_start = in_ptr as usize;
724 let in_end = in_start + nf * std::mem::size_of::<H>();
725 out_ptrs
726 .iter()
727 .take(num_out_u.min(MAX_CHANNELS_TRACKED))
728 .any(|o| {
729 o.is_some_and(|op| {
730 let o_start = op as usize;
731 let o_end = o_start + nf * std::mem::size_of::<H>();
732 !(in_end <= o_start || o_end <= in_start)
733 })
734 })
735 };
736
737 // Grow per-channel scratch slots if the bus widened or
738 // we're widening precision and need every channel copied.
739 // `output_buffers` grows unconditionally now: an unconnected
740 // output channel discards its writes into this scratch even in
741 // the same-precision path.
742 while self.input_copies.len() < num_in_u {
743 self.input_copies.push(Vec::new());
744 }
745 while self.output_buffers.len() < num_out_u {
746 self.output_buffers.push(Vec::new());
747 }
748 // Block-length silence for any unconnected input channel.
749 if self.silence.len() < nf {
750 self.silence.resize(nf, S::default());
751 }
752 let silence_ptr = self.silence.as_ptr();
753
754 self.input_slices.clear();
755 self.input_slices.reserve(num_in_u);
756 let mut in_place_mask: u64 = 0;
757 for ch in 0..num_in_u {
758 let ptr = *inputs.add(ch);
759 let slice: &[S] = if ptr.is_null() {
760 // Unconnected channel (unrouted sidechain, unbound LV2
761 // port). The plugin negotiated it, so hand it
762 // block-length silence, not an out-of-range empty slice.
763 std::slice::from_raw_parts(silence_ptr, nf)
764 } else if aliases_any_output(ptr) {
765 if ch < 64 {
766 in_place_mask |= 1 << ch;
767 }
768 if supports_in_place {
769 // Plugin opted in: hand it nothing through input(ch);
770 // it reads+writes the shared buffer via in_out_mut.
771 // Same-precision reinterprets the host buffer directly
772 // (true zero-copy); a precision-converting wire can't,
773 // so the output loop seeds its conversion scratch with
774 // the converted input below - keeping in_out_mut's
775 // "reads as the input value" contract on every wire,
776 // and input(ch) empty for the documented is_in_place
777 // branch regardless of precision.
778 &[]
779 } else {
780 // Snapshot the input (converting precision if
781 // needed) before the plugin overwrites the
782 // shared buffer. Routing through f64 is
783 // lossless in the widening direction.
784 let host = std::slice::from_raw_parts(ptr, nf);
785 let copy = &mut self.input_copies[ch];
786 copy.clear();
787 copy.reserve(nf);
788 for &h in host {
789 copy.push(S::from_f64(h.to_f64()));
790 }
791 let p = copy.as_ptr();
792 let l = copy.len();
793 // SAFETY: `copy` lives as long as `self`, which
794 // outlives the returned `AudioBuffer<'a>`.
795 std::slice::from_raw_parts(p, l)
796 }
797 } else if same_precision {
798 // SAFETY: same-precision branch - host pointer is
799 // already `*const S` modulo runtime type identity;
800 // the cast reinterprets `*const H` as `*const S`.
801 let raw = ptr.cast::<S>();
802 std::slice::from_raw_parts(raw, nf)
803 } else {
804 // Different precision, no aliasing: convert into
805 // scratch (f64 round-trip, lossless when widening).
806 let host = std::slice::from_raw_parts(ptr, nf);
807 let copy = &mut self.input_copies[ch];
808 copy.clear();
809 copy.reserve(nf);
810 for &h in host {
811 copy.push(S::from_f64(h.to_f64()));
812 }
813 let p = copy.as_ptr();
814 let l = copy.len();
815 std::slice::from_raw_parts(p, l)
816 };
817 self.input_slices.push(slice);
818 }
819
820 self.output_slices.clear();
821 self.output_slices.reserve(num_out_u);
822 for ch in 0..num_out_u {
823 let ptr = *outputs.add(ch);
824 let slice: &mut [S] = if ptr.is_null() {
825 // Unconnected output channel: give the plugin a
826 // block-length discard buffer to write into rather than
827 // an empty slice it would index out of range.
828 // `finish_widening` skips it (null host pointer), so
829 // nothing is copied back.
830 let buf = &mut self.output_buffers[ch];
831 buf.clear();
832 buf.resize(nf, S::default());
833 let p = buf.as_mut_ptr();
834 let l = buf.len();
835 std::slice::from_raw_parts_mut(p, l)
836 } else if same_precision {
837 // SAFETY: same-precision branch - host pointer is
838 // already `*mut S` modulo runtime type identity.
839 let raw = ptr.cast::<S>();
840 std::slice::from_raw_parts_mut(raw, nf)
841 } else {
842 // Different precision: render into per-channel
843 // scratch; finish_widening copies+converts back.
844 let buf = &mut self.output_buffers[ch];
845 buf.clear();
846 buf.resize(nf, S::default());
847 // For an opted-in in-place channel, `input(ch)` is the
848 // empty sentinel, so `in_out_mut(ch)` (this scratch) is the
849 // plugin's only view of its data. The host output pointer
850 // aliases the input and still holds the input samples at
851 // build time, so seed the scratch with the converted input
852 // - otherwise a `supports_in_place` plugin on a converting
853 // wire would read (and emit) silence. Same-precision needs
854 // no seed: it reinterprets the host buffer directly above.
855 if supports_in_place && ch < 64 && (in_place_mask >> ch) & 1 == 1 {
856 let host = std::slice::from_raw_parts(ptr.cast_const(), nf);
857 for (dst, &h) in buf.iter_mut().zip(host) {
858 *dst = S::from_f64(h.to_f64());
859 }
860 }
861 let p = buf.as_mut_ptr();
862 let l = buf.len();
863 std::slice::from_raw_parts_mut(p, l)
864 };
865 self.output_slices.push(slice);
866 }
867
868 // SAFETY: Same transmute pattern as AudioBuffer::slice().
869 // RawBufferScratch stores 'static slices but we return AudioBuffer<'a>.
870 let self_ptr: *mut Self = self;
871 let s = &mut *self_ptr;
872 let mut buf = std::mem::transmute::<AudioBuffer<'static, S>, AudioBuffer<'a, S>>(
873 AudioBuffer::from_slices(&s.input_slices, &mut s.output_slices, nf),
874 );
875 buf.set_in_place_mask(in_place_mask);
876 buf
877 }
878 }
879
880 /// Pre-allocate the per-channel scratch vectors so `build` runs
881 /// allocation-free for buses up to `num_in` × `num_out` channels
882 /// and blocks up to `max_frames`. Idempotent and growth-only.
883 pub fn ensure_capacity(&mut self, num_in: usize, num_out: usize, max_frames: usize) {
884 // `reserve_exact(n)` guarantees `capacity >= len() + n`, so the
885 // amount to reserve is measured from `len()`, not `capacity()`:
886 // with `len() < capacity()` (e.g. a fresh Default vec, or one
887 // cleared between blocks) subtracting `capacity()` under-reserves
888 // and leaves `build` to allocate on the audio thread.
889 if self.input_slices.capacity() < num_in {
890 self.input_slices
891 .reserve_exact(num_in - self.input_slices.len());
892 }
893 if self.output_slices.capacity() < num_out {
894 self.output_slices
895 .reserve_exact(num_out - self.output_slices.len());
896 }
897 while self.input_copies.len() < num_in {
898 self.input_copies.push(Vec::with_capacity(max_frames));
899 }
900 for buf in &mut self.input_copies {
901 if buf.capacity() < max_frames {
902 buf.reserve_exact(max_frames - buf.len());
903 }
904 }
905 while self.output_buffers.len() < num_out {
906 self.output_buffers.push(Vec::with_capacity(max_frames));
907 }
908 for buf in &mut self.output_buffers {
909 if buf.capacity() < max_frames {
910 buf.reserve_exact(max_frames - buf.len());
911 }
912 }
913 // Shared silence for unconnected input channels, kept block-sized
914 // and zeroed so `build` never allocates it on the audio thread.
915 if self.silence.len() < max_frames {
916 self.silence.resize(max_frames, S::default());
917 }
918 }
919}
920
921impl<S: Sample> Default for RawBufferScratch<S> {
922 fn default() -> Self {
923 Self {
924 input_slices: Vec::with_capacity(2),
925 output_slices: Vec::with_capacity(2),
926 input_copies: Vec::with_capacity(2),
927 output_buffers: Vec::with_capacity(2),
928 silence: Vec::new(),
929 }
930 }
931}
932
933#[cfg(test)]
934mod tests {
935 use super::*;
936
937 /// Drive one block through `build` / `finish_widening` with
938 /// plugin precision `S` on host wire `H`: the plugin doubles a
939 /// `[1, 2, 3, 4]` input ramp into the output.
940 fn double_one_block<S: Sample, H: Sample>() -> Vec<H> {
941 let input: Vec<H> = (1..=4).map(|v| H::from_f64(f64::from(v))).collect();
942 let mut output: Vec<H> = vec![H::default(); 4];
943 let in_ptrs = [input.as_ptr()];
944 let mut out_ptrs = [output.as_mut_ptr()];
945 let mut scratch = RawBufferScratch::<S>::default();
946 // SAFETY: both pointers address 4 valid samples that outlive
947 // the buffer; the finish call reuses the same layout.
948 unsafe {
949 let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, false);
950 for i in 0..4 {
951 let v = buf.input(0)[i];
952 buf.output(0)[i] = v + v;
953 }
954 scratch.finish_widening(out_ptrs.as_mut_ptr(), 1, 4);
955 }
956 output
957 }
958
959 fn assert_doubled<H: Sample>(output: &[H]) {
960 let got: Vec<f64> = output.iter().map(|v| v.to_f64()).collect();
961 assert_eq!(got, vec![2.0, 4.0, 6.0, 8.0]);
962 }
963
964 // Passthrough, so the outputs are bit-identical to the input - exact
965 // float equality is the contract being checked.
966 #[allow(clippy::float_cmp)]
967 #[test]
968 fn for_each_frame_io_fans_mono_input_to_a_stereo_graph() {
969 // Mono-in (1) / stereo-out (2) bus fed through a 2-in/2-out identity
970 // "graph": the single input must fan into both frame slots, so both
971 // outputs receive the mono signal, with no per-width branch.
972 let input: [f32; 3] = [0.1, 0.2, 0.3];
973 let mut out_l = [0.0f32; 3];
974 let mut out_r = [0.0f32; 3];
975 let inputs: [&[f32]; 1] = [&input];
976 let mut outputs: [&mut [f32]; 2] = [&mut out_l, &mut out_r];
977 let mut buf = AudioBuffer::<f32>::from_slices_checked(&inputs, &mut outputs, 3);
978
979 buf.for_each_frame_io::<2, 2, _>(|frame_in, frame_out| {
980 // Identity graph: both channels pass through.
981 frame_out[0] = frame_in[0];
982 frame_out[1] = frame_in[1];
983 });
984
985 // frame_in[1] repeated the last (only) input channel, so both
986 // outputs equal the mono input.
987 assert_eq!(out_l, input);
988 assert_eq!(out_r, input);
989 }
990
991 #[test]
992 fn f32_wire_f32_plugin_zero_copy() {
993 assert_doubled(&double_one_block::<f32, f32>());
994 }
995
996 #[test]
997 fn f32_wire_f64_plugin_widens() {
998 assert_doubled(&double_one_block::<f64, f32>());
999 }
1000
1001 #[test]
1002 fn f64_wire_f64_plugin_zero_copy() {
1003 assert_doubled(&double_one_block::<f64, f64>());
1004 }
1005
1006 #[test]
1007 fn f64_wire_f32_plugin_narrows() {
1008 assert_doubled(&double_one_block::<f32, f64>());
1009 }
1010
1011 #[test]
1012 #[allow(clippy::float_cmp)]
1013 fn f64_wire_in_place_snapshots_input() {
1014 // Host hands the same f64 buffer for input and output; the
1015 // input reads must see the pre-write values.
1016 let mut io: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0];
1017 let in_ptrs = [io.as_ptr()];
1018 let mut out_ptrs = [io.as_mut_ptr()];
1019 let mut scratch = RawBufferScratch::<f64>::default();
1020 // SAFETY: the aliased pointer addresses 4 valid samples that
1021 // outlive the buffer.
1022 unsafe {
1023 let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, false);
1024 assert!(buf.is_in_place(0));
1025 for i in 0..4 {
1026 let v = buf.input(0)[i];
1027 buf.output(0)[i] = v * 10.0;
1028 }
1029 }
1030 assert_eq!(io, vec![10.0, 20.0, 30.0, 40.0]);
1031 }
1032
1033 #[test]
1034 #[allow(clippy::float_cmp)]
1035 fn in_place_true_path_hands_shared_buffer() {
1036 // `supports_in_place = true`: the host aliases in/out, so the wrapper
1037 // skips the copy. `input(ch)` is empty and the plugin reads+writes
1038 // the shared buffer through `in_out_mut`. This is the zero-copy path
1039 // the `f64_wire_in_place_snapshots_input` test (opting out) never
1040 // exercises - and the one that used to panic at construction (debug)
1041 // or in `input()` (release).
1042 let mut io: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
1043 let in_ptrs = [io.as_ptr()];
1044 let mut out_ptrs = [io.as_mut_ptr()];
1045 let mut scratch = RawBufferScratch::<f32>::default();
1046 // SAFETY: the aliased pointer addresses 4 valid samples that outlive
1047 // the buffer.
1048 unsafe {
1049 let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, true);
1050 assert!(buf.is_in_place(0));
1051 assert!(buf.input(0).is_empty(), "in-place input(ch) is empty");
1052 let io_ch = buf.in_out_mut(0);
1053 assert_eq!(io_ch.len(), 4);
1054 for s in io_ch.iter_mut() {
1055 *s *= 10.0; // read the current (input) value, write in place
1056 }
1057 }
1058 assert_eq!(io, vec![10.0, 20.0, 30.0, 40.0]);
1059 }
1060
1061 #[test]
1062 #[allow(clippy::float_cmp)]
1063 fn in_place_true_path_cross_precision_seeds_input() {
1064 // The documented in-place contract on a precision-converting wire: an
1065 // f64 plugin (S) on an f32 host (H), aliased, supports_in_place = true.
1066 // The `is_in_place` + `in_out_mut` branch must read the INPUT (not the
1067 // zeroed conversion scratch) and write correct output back to the f32
1068 // host. Before the fix this emitted silence in every f32-wire host.
1069 let mut io: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
1070 let in_ptrs = [io.as_ptr()];
1071 let mut out_ptrs = [io.as_mut_ptr()];
1072 let mut scratch = RawBufferScratch::<f64>::default();
1073 // SAFETY: the aliased f32 pointer addresses 4 valid samples that
1074 // outlive the buffer; `finish_widening` reuses the same layout.
1075 unsafe {
1076 {
1077 // H = f32 (host pointers), S = f64 (scratch): cross-precision.
1078 let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, true);
1079 assert!(buf.is_in_place(0));
1080 assert!(
1081 buf.input(0).is_empty(),
1082 "in-place input(ch) is empty on any wire"
1083 );
1084 let io_ch = buf.in_out_mut(0);
1085 assert_eq!(
1086 io_ch.to_vec(),
1087 vec![1.0, 2.0, 3.0, 4.0],
1088 "in_out_mut reads the converted input, not zeros"
1089 );
1090 for s in io_ch.iter_mut() {
1091 *s *= 10.0;
1092 }
1093 }
1094 // Narrow the f64 scratch the plugin wrote back to the f32 host.
1095 scratch.finish_widening(out_ptrs.as_mut_ptr(), 1, 4);
1096 }
1097 assert_eq!(io, vec![10.0, 20.0, 30.0, 40.0]);
1098 }
1099
1100 /// The disjoint `(input, output)` accessors can't represent an in-place
1101 /// channel, so they debug-assert with a clear message instead of the
1102 /// opaque out-of-range panic the empty input slice would otherwise
1103 /// produce. Gated on `debug_assertions`: the guard is compiled out in
1104 /// release, so this only runs (and only should panic) in debug.
1105 #[cfg(debug_assertions)]
1106 #[test]
1107 #[should_panic(expected = "in-place")]
1108 fn io_on_zero_copy_in_place_channel_debug_asserts() {
1109 let mut io: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
1110 let in_ptrs = [io.as_ptr()];
1111 let mut out_ptrs = [io.as_mut_ptr()];
1112 let mut scratch = RawBufferScratch::<f32>::default();
1113 // SAFETY: the aliased pointer addresses 4 valid samples that outlive
1114 // the buffer.
1115 unsafe {
1116 // supports_in_place = true -> zero-copy, empty input slice.
1117 let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, true);
1118 assert!(buf.is_in_place(0));
1119 // Should fire the guard, not index the empty input slice.
1120 let _ = buf.io(0);
1121 }
1122 }
1123
1124 /// The guard must NOT fire on the copy path: a host-aliased channel with
1125 /// `supports_in_place = false` reports `is_in_place`, but the wrapper
1126 /// snapshotted its input into a full slice, so `io()` works. A guard
1127 /// keyed on `is_in_place` would false-positive here and break every
1128 /// normal plugin that uses `io()` in an aliasing host (e.g. AU, which
1129 /// advertises in-place unconditionally).
1130 #[test]
1131 #[allow(clippy::float_cmp)]
1132 fn io_on_copy_path_aliased_channel_is_fine() {
1133 let mut io: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
1134 let in_ptrs = [io.as_ptr()];
1135 let mut out_ptrs = [io.as_mut_ptr()];
1136 let mut scratch = RawBufferScratch::<f32>::default();
1137 // SAFETY: the aliased pointer addresses 4 valid samples that outlive
1138 // the buffer.
1139 unsafe {
1140 // supports_in_place = false -> copy path, full readable input.
1141 let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, false);
1142 assert!(buf.is_in_place(0), "still reports host aliasing");
1143 let (inp, out) = buf.io(0); // must not panic
1144 assert_eq!(inp, &[1.0, 2.0, 3.0, 4.0]);
1145 for (o, &i) in out.iter_mut().zip(inp) {
1146 *o = i * 2.0;
1147 }
1148 }
1149 assert_eq!(io, vec![2.0, 4.0, 6.0, 8.0]);
1150 }
1151
1152 /// A disconnected sidechain reaches the plugin as block-sized silence,
1153 /// not a null pointer - the uniform "declared width always; missing
1154 /// buses read as silence" contract every format upholds. The VST3 shim
1155 /// substitutes silence for any missing or null input channel (a
1156 /// deactivated bus arrives with null channel buffers, or a trailing bus
1157 /// is dropped from `ProcessData`), and the AAX shim feeds silence for an
1158 /// unpatched sidechain, so the flat channel array always carries the
1159 /// negotiated width. This pins the downstream contract: such a channel
1160 /// is a readable, zeroed, block-length slice, not the out-of-range
1161 /// empty slice a raw null would produce.
1162 #[test]
1163 fn silence_substituted_sidechain_channels_are_full_length_zeros() {
1164 let nf = 512usize;
1165 let main_l = vec![0.5f32; nf];
1166 let main_r = vec![0.5f32; nf];
1167 // What the shim now hands us for a disconnected stereo sidechain:
1168 // a block-sized zeroed buffer per channel (shared read-only).
1169 let silence = vec![0.0f32; nf];
1170 let mut out_l = vec![0.0f32; nf];
1171 let mut out_r = vec![0.0f32; nf];
1172
1173 let in_ptrs = [
1174 main_l.as_ptr(),
1175 main_r.as_ptr(),
1176 silence.as_ptr(),
1177 silence.as_ptr(),
1178 ];
1179 let mut out_ptrs = [out_l.as_mut_ptr(), out_r.as_mut_ptr()];
1180 let mut scratch = RawBufferScratch::<f32>::default();
1181 // SAFETY: every pointer addresses `nf` valid samples that outlive
1182 // the buffer; `silence` backs both deactivated-bus channels.
1183 unsafe {
1184 #[allow(clippy::cast_possible_truncation)]
1185 let buf = scratch.build(
1186 in_ptrs.as_ptr(),
1187 out_ptrs.as_mut_ptr(),
1188 4,
1189 2,
1190 nf as u32,
1191 false,
1192 );
1193 assert_eq!(buf.num_input_channels(), 4);
1194 // The reads that panicked when the sidechain arrived as a
1195 // null/empty slice now return block-length silence.
1196 assert_eq!(buf.input(2).len(), nf);
1197 assert_eq!(buf.input(3).len(), nf);
1198 assert!(buf.input(2).iter().all(|&s| s == 0.0));
1199 assert!(buf.input(3).iter().all(|&s| s == 0.0));
1200 }
1201 }
1202
1203 /// A raw null channel pointer is handled at the `build` layer itself:
1204 /// a null input reads as block-length silence and a null output absorbs
1205 /// the plugin's writes into discard scratch - so a wrapper that hands
1206 /// `build` a null (a CLAP/VST2/LV2 port the host left unconnected) can
1207 /// never produce the out-of-range empty slice that used to panic.
1208 #[test]
1209 fn raw_null_channels_read_silence_and_discard_writes() {
1210 let nf = 512usize;
1211 let main_l = vec![0.5f32; nf];
1212 let main_r = vec![0.5f32; nf];
1213 let mut out_l = vec![0.0f32; nf];
1214 // Input channels 2/3 and output channel 1 arrive unconnected.
1215 let in_ptrs = [
1216 main_l.as_ptr(),
1217 main_r.as_ptr(),
1218 std::ptr::null(),
1219 std::ptr::null(),
1220 ];
1221 let mut out_ptrs = [out_l.as_mut_ptr(), std::ptr::null_mut()];
1222 let mut scratch = RawBufferScratch::<f32>::default();
1223 // SAFETY: the non-null pointers address `nf` valid samples; the
1224 // null channels are the unconnected-port shape under test.
1225 unsafe {
1226 #[allow(clippy::cast_possible_truncation)]
1227 let mut buf = scratch.build(
1228 in_ptrs.as_ptr(),
1229 out_ptrs.as_mut_ptr(),
1230 4,
1231 2,
1232 nf as u32,
1233 false,
1234 );
1235 assert_eq!(buf.num_input_channels(), 4);
1236 assert_eq!(buf.num_output_channels(), 2);
1237 // Null input channels read as full-length silence.
1238 assert_eq!(buf.input(2).len(), nf);
1239 assert!(buf.input(3).iter().all(|&s| s == 0.0));
1240 // The null output channel is a full-length discard buffer: the
1241 // plugin can write it without an out-of-range panic.
1242 assert_eq!(buf.output(1).len(), nf);
1243 for s in buf.output(1) {
1244 *s = 1.0;
1245 }
1246 }
1247 }
1248
1249 /// `ensure_capacity` must reach `capacity() >= target` even when the
1250 /// vecs start with `len() < capacity()`. `reserve_exact` is relative
1251 /// to `len()`, so reserving `target - capacity()` under-reserves and
1252 /// leaves `build` to malloc on the audio thread. A fresh Default
1253 /// scratch (len 0, capacity 2) is the trigger for buses wider than
1254 /// two channels - the repo's stereo-main + stereo-sidechain shape.
1255 #[test]
1256 fn ensure_capacity_reaches_target_when_len_below_capacity() {
1257 let (num_in, num_out, max_frames) = (4usize, 4usize, 512usize);
1258 let mut scratch = RawBufferScratch::<f32>::default();
1259 // Default seeds capacity 2 at len 0 - the len < capacity case.
1260 assert!(scratch.input_slices.capacity() < num_in);
1261
1262 scratch.ensure_capacity(num_in, num_out, max_frames);
1263
1264 assert!(scratch.input_slices.capacity() >= num_in);
1265 assert!(scratch.output_slices.capacity() >= num_out);
1266 assert_eq!(scratch.input_copies.len(), num_in);
1267 assert_eq!(scratch.output_buffers.len(), num_out);
1268 for buf in &scratch.input_copies {
1269 assert!(buf.capacity() >= max_frames);
1270 }
1271 for buf in &scratch.output_buffers {
1272 assert!(buf.capacity() >= max_frames);
1273 }
1274
1275 // Re-activation with a larger block, with an inner scratch buffer
1276 // carrying leftover len from a prior block (len < new target) -
1277 // the same relative-to-len reservation bug applies to the copies.
1278 scratch.input_copies[0].resize(200, 0.0);
1279 let bigger = 1024;
1280 scratch.ensure_capacity(num_in, num_out, bigger);
1281 for buf in &scratch.input_copies {
1282 assert!(buf.capacity() >= bigger);
1283 }
1284 for buf in &scratch.output_buffers {
1285 assert!(buf.capacity() >= bigger);
1286 }
1287 }
1288
1289 /// An instrument (0-in / N-out) must walk every output channel through
1290 /// `chunks_mut` without panicking - `inputs.len() < outputs.len()`
1291 /// previously indexed `inputs[ch]` out of range. Missing inputs read
1292 /// as silence, mirroring the unconnected-bus `input(ch)` contract.
1293 #[allow(clippy::float_cmp)]
1294 #[test]
1295 fn chunks_mut_instrument_zero_in_reads_silence_and_writes_all_outputs() {
1296 // num_samples 6 with N 4 gives one Full (4) + one Tail (2) per
1297 // channel, exercising both variants on the missing-input path.
1298 let nf = 6;
1299 let mut o0 = vec![9.0f32; nf];
1300 let mut o1 = vec![9.0f32; nf];
1301 let inputs: [&[f32]; 0] = [];
1302 let mut outputs: [&mut [f32]; 2] = [&mut o0, &mut o1];
1303 let mut buf = AudioBuffer::<f32>::from_slices_checked(&inputs, &mut outputs, nf);
1304
1305 let mut visited = Vec::new();
1306 let mut chunks = buf.chunks_mut::<4>();
1307 while let Some(chunk) = chunks.next() {
1308 match chunk {
1309 ChunkItem::Full { ch, inp, out, .. } => {
1310 assert!(inp.iter().all(|&s| s == 0.0), "missing input reads silence");
1311 out.fill(1.0);
1312 visited.push(ch);
1313 }
1314 ChunkItem::Tail { ch, inp, out, .. } => {
1315 assert!(
1316 inp.iter().all(|&s| s == 0.0),
1317 "missing input tail is silence"
1318 );
1319 out.fill(1.0);
1320 visited.push(ch);
1321 }
1322 }
1323 }
1324
1325 assert_eq!(
1326 visited,
1327 vec![0, 0, 1, 1],
1328 "both output channels fully walked"
1329 );
1330 assert!(o0.iter().all(|&s| s == 1.0), "channel 0 was written");
1331 assert!(o1.iter().all(|&s| s == 1.0), "channel 1 was written");
1332 }
1333
1334 /// A mono-in / stereo-out effect: channel 0 sees the real input,
1335 /// channel 1 (no matching input) reads silence rather than panicking.
1336 #[allow(clippy::float_cmp)]
1337 #[test]
1338 fn chunks_mut_mono_in_stereo_out_feeds_silence_for_extra_output() {
1339 let nf = 8;
1340 let input = vec![0.5f32; nf];
1341 let mut o0 = vec![0.0f32; nf];
1342 let mut o1 = vec![0.0f32; nf];
1343 let inputs: [&[f32]; 1] = [&input];
1344 let mut outputs: [&mut [f32]; 2] = [&mut o0, &mut o1];
1345 let mut buf = AudioBuffer::<f32>::from_slices_checked(&inputs, &mut outputs, nf);
1346
1347 let mut chunks = buf.chunks_mut::<4>();
1348 while let Some(chunk) = chunks.next() {
1349 if let ChunkItem::Full { ch, inp, out, .. } = chunk {
1350 let expected = if ch == 0 { 0.5 } else { 0.0 };
1351 assert!(
1352 inp.iter().all(|&s| s == expected),
1353 "channel {ch} input mismatch",
1354 );
1355 out.copy_from_slice(inp);
1356 }
1357 }
1358
1359 assert!(o0.iter().all(|&s| s == 0.5), "input channel passed through");
1360 assert!(o1.iter().all(|&s| s == 0.0), "silent channel stayed silent");
1361 }
1362}