Skip to main content

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}