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        }
273    }
274
275    /// Iterate per-frame and hand a fixed-size `(input, output)`
276    /// stack-array pair to `tick`. Sized at the type level by const
277    /// generic `N`, which must equal [`Self::channels`].
278    ///
279    /// `io()` / `io_pair()` give a per-channel slice view, which is
280    /// the right shape for "process channel `ch` in isolation"
281    /// loops. But libraries that expect a per-frame `(in: &[S],
282    /// out: &mut [S])` callback - `fundsp::AudioUnit::tick`,
283    /// `nih_plug`'s frame iterators, custom per-sample DSP nodes -
284    /// can't take that shape directly without either copying inputs
285    /// into a scratch first (heap allocation on the audio thread)
286    /// or fighting the borrow checker over two simultaneous `&mut`
287    /// borrows of the buffer.
288    ///
289    /// This helper does the per-frame transpose in-place against a
290    /// stack-allocated `[S; N]` pair, calls `tick` `num_samples()`
291    /// times, and writes back. No heap, no borrow gymnastics at the
292    /// call site:
293    ///
294    /// ```ignore
295    /// // Stereo plugin delegating per-frame DSP to fundsp:
296    /// buffer.for_each_frame::<2, _>(|frame_in, frame_out| {
297    ///     self.graph.tick(frame_in, frame_out);
298    /// });
299    /// ```
300    ///
301    /// `&[S; N]` deref-coerces to `&[S]` at the call site, so
302    /// callers can pass the arrays straight to slice-taking APIs
303    /// like fundsp's `tick`.
304    ///
305    /// # Panics
306    ///
307    /// Debug builds panic if `N != self.channels()`. Release builds
308    /// rely on the same precondition without checking; reading past
309    /// the actual channel count would index out of bounds anyway.
310    pub fn for_each_frame<const N: usize, F>(&mut self, mut tick: F)
311    where
312        F: FnMut(&[S; N], &mut [S; N]),
313    {
314        debug_assert_eq!(
315            N,
316            self.channels(),
317            "for_each_frame::<{N}> requires the buffer to have exactly {N} channels"
318        );
319        for ch in 0..N {
320            self.debug_assert_not_in_place(ch);
321        }
322        let mut frame_in = [S::default(); N];
323        let mut frame_out = [S::default(); N];
324        let end = self.offset + self.num_samples;
325        for i in self.offset..end {
326            for (ch, slot) in frame_in.iter_mut().enumerate() {
327                *slot = self.inputs[ch][i];
328            }
329            tick(&frame_in, &mut frame_out);
330            for (ch, sample) in frame_out.iter().enumerate() {
331                self.outputs[ch][i] = *sample;
332            }
333        }
334    }
335
336    /// Like [`Self::for_each_frame`] but for a DSP whose frame shape is a
337    /// fixed `(IN, OUT)` that need not match the bus width. Input slot `k`
338    /// reads bus input channel `k`, repeating the last available channel
339    /// when the bus has fewer than `IN` inputs - so a mono source fans into
340    /// both inputs of a stereo graph. Output slot `k` writes bus output
341    /// channel `k` while `k < num_output_channels`; frame outputs past the
342    /// bus width are dropped.
343    ///
344    /// This lets a plugin built around a fixed-shape DSP (a fundsp
345    /// `reverb_stereo`, a dasp graph) run on any declared bus layout -
346    /// `(2, 2)` stereo and `(1, 2)` mono-in/stereo-out alike - through one
347    /// `for_each_frame_io::<2, 2>` call, with no per-width branch. A bus
348    /// with no inputs (an instrument) feeds silence.
349    pub fn for_each_frame_io<const IN: usize, const OUT: usize, F>(&mut self, mut tick: F)
350    where
351        F: FnMut(&[S; IN], &mut [S; OUT]),
352    {
353        let num_in = self.inputs.len();
354        let num_out = self.outputs.len();
355        for ch in 0..num_in.min(IN) {
356            self.debug_assert_not_in_place(ch);
357        }
358        let mut frame_in = [S::default(); IN];
359        let mut frame_out = [S::default(); OUT];
360        let end = self.offset + self.num_samples;
361        for i in self.offset..end {
362            if num_in > 0 {
363                for (k, slot) in frame_in.iter_mut().enumerate() {
364                    *slot = self.inputs[k.min(num_in - 1)][i];
365                }
366            }
367            tick(&frame_in, &mut frame_out);
368            for (k, sample) in frame_out.iter().enumerate().take(num_out) {
369                self.outputs[k][i] = *sample;
370            }
371        }
372    }
373
374    /// [`Self::for_each_frame_io`] specialized to a stereo `(2, 2)` DSP -
375    /// the common case (a `reverb_stereo`, a stereo filter block). Runs the
376    /// 2-in/2-out `tick` over any declared bus: a mono source fans into
377    /// both inputs, a stereo bus maps 1:1, so a stereo effect needs no
378    /// per-width branch and no turbofish.
379    pub fn for_each_stereo_frame<F>(&mut self, tick: F)
380    where
381        F: FnMut(&[S; 2], &mut [S; 2]),
382    {
383        self.for_each_frame_io::<2, 2, F>(tick);
384    }
385
386    /// Peak absolute value across an output channel, returned as `f32`
387    /// because meters / UI display always work in `f32` regardless of
388    /// the plugin's internal precision.
389    ///
390    /// Short-circuits and returns `f32::NAN` on the **first** NaN
391    /// sample seen, so meters can flag runaway plugins instead of
392    /// silently reporting "peaks within range" while NaN poison
393    /// spreads downstream.
394    #[must_use]
395    pub fn output_peak(&self, ch: usize) -> f32 {
396        let end = self.offset + self.num_samples;
397        let mut peak = 0.0f32;
398        for &b in &self.outputs[ch][self.offset..end] {
399            let v = b.to_f32();
400            if v.is_nan() {
401                return f32::NAN;
402            }
403            let abs = v.abs();
404            if abs > peak {
405                peak = abs;
406            }
407        }
408        peak
409    }
410
411    /// Return a sub-block view covering samples `start..start+len`.
412    ///
413    /// The returned buffer borrows `self` exclusively - you cannot use
414    /// the original buffer while the slice is alive.
415    ///
416    /// # Panics
417    /// Panics if `start + len > self.num_samples()`.
418    pub fn slice(&mut self, start: usize, len: usize) -> AudioBuffer<'_, S> {
419        assert!(
420            start + len <= self.num_samples,
421            "slice({start}, {len}) out of bounds for buffer of {} samples",
422            self.num_samples,
423        );
424        let new_offset = self.offset + start;
425        // SAFETY: We construct an AudioBuffer<'a, S> and transmute to AudioBuffer<'_, S>.
426        // These have identical memory layout (lifetimes are erased at runtime).
427        // This is sound because:
428        // 1. &mut self prevents the caller from using self while the slice exists
429        // 2. The underlying channel memory lives for 'a which outlives '_
430        // 3. Bounds are checked by the assert above
431        let self_ptr: *mut Self = self;
432        unsafe {
433            let s = &mut *self_ptr;
434            std::mem::transmute::<AudioBuffer<'a, S>, AudioBuffer<'_, S>>(AudioBuffer {
435                inputs: s.inputs,
436                outputs: &mut *s.outputs,
437                in_place_mask: s.in_place_mask,
438                offset: new_offset,
439                num_samples: len,
440            })
441        }
442    }
443}
444
445/// One yielded chunk from [`AudioBuffer::chunks_mut`].
446///
447/// `Full` is the SIMD-friendly path: `inp` and `out` are stack
448/// arrays of exactly `N` elements, ready to feed `truce-simd`'s
449/// block ops. `Tail` is the trailing fragment when `num_samples()`
450/// isn't a multiple of `N`; fall back to a scalar loop.
451pub enum ChunkItem<'b, S: Sample, const N: usize> {
452    /// Full N-sample chunk. The `&[S; N]` / `&mut [S; N]` are the
453    /// shape `truce-simd` ops are written against - no slice
454    /// length check at the call site.
455    Full {
456        /// Channel index this chunk belongs to.
457        ch: usize,
458        /// Sample offset within the audio block this chunk starts
459        /// at. Use this when indexing into a precomputed envelope
460        /// array - `chunks_mut` iterates channel-major, so the
461        /// envelope (typically read once per audio block via
462        /// `read_into(&mut env[..num_samples])`) is shared across all
463        /// channel passes.
464        sample: usize,
465        /// Read-only N-sample input slice.
466        inp: &'b [S; N],
467        /// Mutable N-sample output slice.
468        out: &'b mut [S; N],
469    },
470    /// Trailing chunk when `num_samples()` isn't a multiple of `N`.
471    /// Length is in `(0, N)`. Fall back to scalar processing.
472    Tail {
473        /// Channel index this chunk belongs to.
474        ch: usize,
475        /// Sample offset within the audio block this chunk starts at.
476        sample: usize,
477        /// Read-only tail input slice; length < N.
478        inp: &'b [S],
479        /// Mutable tail output slice; length < N.
480        out: &'b mut [S],
481    },
482}
483
484/// Lending iterator returned by [`AudioBuffer::chunks_mut`].
485///
486/// Does not implement [`Iterator`] because each yielded
487/// [`ChunkItem`] borrows from the iterator itself - the standard
488/// "GATs would help here" pattern. Drive it with `while let
489/// Some(chunk) = chunks.next()` instead. See
490/// [`AudioBuffer::chunks_mut`] for a worked example.
491pub struct ChunksMut<'b, 'a, S: Sample, const N: usize> {
492    buffer: &'b mut AudioBuffer<'a, S>,
493    /// Current channel being walked.
494    ch: usize,
495    /// Position within the current channel, relative to
496    /// `buffer.offset`. Advances by N each Full chunk, then jumps
497    /// to `num_samples` for the Tail (or directly past it when
498    /// `num_samples` is a multiple of N).
499    pos: usize,
500}
501
502impl<S: Sample, const N: usize> ChunksMut<'_, '_, S, N> {
503    /// Yield the next chunk, or `None` when every channel has been
504    /// fully walked.
505    ///
506    /// Method-on-self rather than `Iterator::next` because each
507    /// yielded [`ChunkItem`] borrows from `self`; GATs would be
508    /// needed to express that through the `Iterator` trait.
509    #[allow(clippy::should_implement_trait, clippy::missing_panics_doc)]
510    pub fn next(&mut self) -> Option<ChunkItem<'_, S, N>> {
511        loop {
512            if self.ch >= self.buffer.outputs.len() {
513                return None;
514            }
515            let ns = self.buffer.num_samples;
516            if self.pos >= ns {
517                self.ch += 1;
518                self.pos = 0;
519                continue;
520            }
521            let abs_start = self.buffer.offset + self.pos;
522            let remaining = ns - self.pos;
523            let take = remaining.min(N);
524            let abs_end = abs_start + take;
525            let ch = self.ch;
526            let sample = self.pos;
527
528            self.buffer.debug_assert_not_in_place(ch);
529            let inp_slice = &self.buffer.inputs[ch][abs_start..abs_end];
530            let out_slice: &mut [S] = &mut self.buffer.outputs[ch][abs_start..abs_end];
531
532            self.pos += take;
533
534            // Full vs Tail by length: full chunks convert to `&[S;
535            // N]` / `&mut [S; N]` for the SIMD-friendly path; tails
536            // fall back to slice form.
537            return Some(if take == N {
538                ChunkItem::Full {
539                    ch,
540                    sample,
541                    // Length-checked above; `try_into` here is a
542                    // free reinterpret.
543                    inp: inp_slice.try_into().expect("len == N by construction"),
544                    out: out_slice.try_into().expect("len == N by construction"),
545                }
546            } else {
547                ChunkItem::Tail {
548                    ch,
549                    sample,
550                    inp: inp_slice,
551                    out: out_slice,
552                }
553            });
554        }
555    }
556}
557
558/// Scratch space for [`RawBufferScratch::build`].
559///
560/// Callers allocate this on the stack and pass it to `build`. The
561/// buffer borrows the slices stored here, so this struct must outlive
562/// the returned `AudioBuffer`.
563///
564/// Generic over the plugin's sample type `S`. When the host buffer
565/// matches `S`, slices point into host memory (zero-copy). When the
566/// host buffer is a different precision, the input is widened/narrowed
567/// into per-channel scratch; the output is rendered into scratch and
568/// the wrapper copies + casts it back to the host buffer at the end
569/// of the block via [`Self::finish_widening`].
570pub struct RawBufferScratch<S: Sample = f32> {
571    pub input_slices: Vec<&'static [S]>,
572    pub output_slices: Vec<&'static mut [S]>,
573    /// Per-channel input copies. Used (a) when the host passes the
574    /// same buffer for input and output (in-place processing - VST3
575    /// spec allows this and several real DAWs use it for effects),
576    /// or (b) when the host buffer precision differs from `S` and
577    /// we widen/narrow on the way in. In either case the slice the
578    /// plugin sees points into the matching slot here.
579    input_copies: Vec<Vec<S>>,
580    /// Per-channel output scratch. Populated by [`Self::build`] when
581    /// the host buffer precision differs from `S` (the wrapper copies +
582    /// casts these back via [`Self::finish_widening`]), and reused as
583    /// write-discard scratch for an unconnected (null) output channel.
584    output_buffers: Vec<Vec<S>>,
585    /// Shared read-only silence handed to the plugin for an unconnected
586    /// (null) input channel - an unrouted sidechain, or an LV2 port the
587    /// host never connected. The plugin negotiated the channel, so it
588    /// must read block-length silence, never the out-of-range empty
589    /// slice a raw null would otherwise produce. Never written.
590    silence: Vec<S>,
591}
592
593impl<S: Sample> RawBufferScratch<S> {
594    /// Build an `AudioBuffer<S>` from raw host pointers of wire
595    /// precision `H` - `f32` in the common case (CLAP, LV2, AAX
596    /// always; VST3/VST2/AU 32-bit mode), `f64` when the host
597    /// negotiated a double-precision wire (VST3 `kSample64`, VST2
598    /// `processDoubleReplacing`).
599    ///
600    /// When `S = H`, slices point directly into host memory (modulo
601    /// in-place input copying). Otherwise every channel is converted
602    /// into per-channel scratch and the wrapper must call
603    /// [`Self::finish_widening`] at the end of the block to copy the
604    /// rendered samples back to the host's output pointers.
605    ///
606    /// # Safety
607    /// - `inputs` must point to `num_in` valid `*const H` pointers
608    ///   (each non-null pointer must address at least `num_frames`
609    ///   readable samples; a null pointer marks an unconnected channel
610    ///   and reads back as block-length silence).
611    /// - `outputs` must point to `num_out` valid `*mut H` pointers
612    ///   (each non-null pointer must address at least `num_frames`
613    ///   writable samples; a null pointer marks an unconnected channel
614    ///   whose writes are discarded).
615    /// - The pointed-to memory must remain valid for the lifetime of
616    ///   the returned `AudioBuffer`.
617    pub unsafe fn build<H: Sample>(
618        &mut self,
619        inputs: *const *const H,
620        outputs: *mut *mut H,
621        num_in: u32,
622        num_out: u32,
623        num_frames: u32,
624        supports_in_place: bool,
625    ) -> AudioBuffer<'_, S> {
626        // SAFETY: forwarded - caller's contract is the same.
627        unsafe {
628            self.build_inner(
629                inputs,
630                outputs,
631                num_in,
632                num_out,
633                num_frames,
634                supports_in_place,
635            )
636        }
637    }
638
639    /// Copy + convert the rendered `S` output back to the host's `H`
640    /// output pointers. No-op when `S = H` (the slices the plugin
641    /// wrote already point directly at host memory).
642    ///
643    /// # Safety
644    /// `outputs` and `num_out` / `num_frames` must match the values
645    /// passed to the prior [`Self::build`] call on this scratch.
646    pub unsafe fn finish_widening<H: Sample>(
647        &self,
648        outputs: *mut *mut H,
649        num_out: u32,
650        num_frames: u32,
651    ) {
652        // Same precision: the plugin wrote straight into host memory.
653        if S::IS_F64 == H::IS_F64 {
654            return;
655        }
656        unsafe {
657            let nf = num_frames as usize;
658            for ch in 0..(num_out as usize) {
659                let ptr = *outputs.add(ch);
660                if ptr.is_null() {
661                    continue;
662                }
663                let host = std::slice::from_raw_parts_mut(ptr, nf);
664                let plugin_out = &self.output_buffers[ch];
665                for (h, &p) in host.iter_mut().zip(plugin_out.iter()) {
666                    *h = H::from_f64(p.to_f64());
667                }
668            }
669        }
670    }
671
672    unsafe fn build_inner<'a, H: Sample>(
673        &'a mut self,
674        inputs: *const *const H,
675        outputs: *mut *mut H,
676        num_in: u32,
677        num_out: u32,
678        num_frames: u32,
679        supports_in_place: bool,
680    ) -> AudioBuffer<'a, S> {
681        const MAX_CHANNELS_TRACKED: usize = 64;
682        // Whether the plugin's chosen precision matches the host's.
683        // When matched, we zero-copy host pointers into the slice
684        // arrays; when not, we convert through input_copies and
685        // output_buffers. The traits are sealed at f32/f64, so equal
686        // IS_F64 flags mean S and H are the same type.
687        let same_precision = S::IS_F64 == H::IS_F64;
688
689        unsafe {
690            let nf = num_frames as usize;
691            let num_out_u = num_out as usize;
692            let num_in_u = num_in as usize;
693            debug_assert!(
694                num_out_u <= MAX_CHANNELS_TRACKED,
695                "RawBufferScratch::build: alias detection only covers up to {MAX_CHANNELS_TRACKED} \
696                 output channels; got {num_out_u}. Channels beyond the cap won't be \
697                 detected as aliased.",
698            );
699            let out_ptrs: [Option<*mut H>; MAX_CHANNELS_TRACKED] = std::array::from_fn(|ch| {
700                if ch < num_out_u {
701                    let p = *outputs.add(ch);
702                    if p.is_null() { None } else { Some(p) }
703                } else {
704                    None
705                }
706            });
707            let aliases_any_output = |in_ptr: *const H| -> bool {
708                let in_start = in_ptr as usize;
709                let in_end = in_start + nf * std::mem::size_of::<H>();
710                out_ptrs
711                    .iter()
712                    .take(num_out_u.min(MAX_CHANNELS_TRACKED))
713                    .any(|o| {
714                        o.is_some_and(|op| {
715                            let o_start = op as usize;
716                            let o_end = o_start + nf * std::mem::size_of::<H>();
717                            !(in_end <= o_start || o_end <= in_start)
718                        })
719                    })
720            };
721
722            // Grow per-channel scratch slots if the bus widened or
723            // we're widening precision and need every channel copied.
724            // `output_buffers` grows unconditionally now: an unconnected
725            // output channel discards its writes into this scratch even in
726            // the same-precision path.
727            while self.input_copies.len() < num_in_u {
728                self.input_copies.push(Vec::new());
729            }
730            while self.output_buffers.len() < num_out_u {
731                self.output_buffers.push(Vec::new());
732            }
733            // Block-length silence for any unconnected input channel.
734            if self.silence.len() < nf {
735                self.silence.resize(nf, S::default());
736            }
737            let silence_ptr = self.silence.as_ptr();
738
739            self.input_slices.clear();
740            self.input_slices.reserve(num_in_u);
741            let mut in_place_mask: u64 = 0;
742            for ch in 0..num_in_u {
743                let ptr = *inputs.add(ch);
744                let slice: &[S] = if ptr.is_null() {
745                    // Unconnected channel (unrouted sidechain, unbound LV2
746                    // port). The plugin negotiated it, so hand it
747                    // block-length silence, not an out-of-range empty slice.
748                    std::slice::from_raw_parts(silence_ptr, nf)
749                } else if aliases_any_output(ptr) {
750                    if ch < 64 {
751                        in_place_mask |= 1 << ch;
752                    }
753                    if supports_in_place {
754                        // Plugin opted in: hand it nothing through input(ch);
755                        // it reads+writes the shared buffer via in_out_mut.
756                        // Same-precision reinterprets the host buffer directly
757                        // (true zero-copy); a precision-converting wire can't,
758                        // so the output loop seeds its conversion scratch with
759                        // the converted input below - keeping in_out_mut's
760                        // "reads as the input value" contract on every wire,
761                        // and input(ch) empty for the documented is_in_place
762                        // branch regardless of precision.
763                        &[]
764                    } else {
765                        // Snapshot the input (converting precision if
766                        // needed) before the plugin overwrites the
767                        // shared buffer. Routing through f64 is
768                        // lossless in the widening direction.
769                        let host = std::slice::from_raw_parts(ptr, nf);
770                        let copy = &mut self.input_copies[ch];
771                        copy.clear();
772                        copy.reserve(nf);
773                        for &h in host {
774                            copy.push(S::from_f64(h.to_f64()));
775                        }
776                        let p = copy.as_ptr();
777                        let l = copy.len();
778                        // SAFETY: `copy` lives as long as `self`, which
779                        // outlives the returned `AudioBuffer<'a>`.
780                        std::slice::from_raw_parts(p, l)
781                    }
782                } else if same_precision {
783                    // SAFETY: same-precision branch - host pointer is
784                    // already `*const S` modulo runtime type identity;
785                    // the cast reinterprets `*const H` as `*const S`.
786                    let raw = ptr.cast::<S>();
787                    std::slice::from_raw_parts(raw, nf)
788                } else {
789                    // Different precision, no aliasing: convert into
790                    // scratch (f64 round-trip, lossless when widening).
791                    let host = std::slice::from_raw_parts(ptr, nf);
792                    let copy = &mut self.input_copies[ch];
793                    copy.clear();
794                    copy.reserve(nf);
795                    for &h in host {
796                        copy.push(S::from_f64(h.to_f64()));
797                    }
798                    let p = copy.as_ptr();
799                    let l = copy.len();
800                    std::slice::from_raw_parts(p, l)
801                };
802                self.input_slices.push(slice);
803            }
804
805            self.output_slices.clear();
806            self.output_slices.reserve(num_out_u);
807            for ch in 0..num_out_u {
808                let ptr = *outputs.add(ch);
809                let slice: &mut [S] = if ptr.is_null() {
810                    // Unconnected output channel: give the plugin a
811                    // block-length discard buffer to write into rather than
812                    // an empty slice it would index out of range.
813                    // `finish_widening` skips it (null host pointer), so
814                    // nothing is copied back.
815                    let buf = &mut self.output_buffers[ch];
816                    buf.clear();
817                    buf.resize(nf, S::default());
818                    let p = buf.as_mut_ptr();
819                    let l = buf.len();
820                    std::slice::from_raw_parts_mut(p, l)
821                } else if same_precision {
822                    // SAFETY: same-precision branch - host pointer is
823                    // already `*mut S` modulo runtime type identity.
824                    let raw = ptr.cast::<S>();
825                    std::slice::from_raw_parts_mut(raw, nf)
826                } else {
827                    // Different precision: render into per-channel
828                    // scratch; finish_widening copies+converts back.
829                    let buf = &mut self.output_buffers[ch];
830                    buf.clear();
831                    buf.resize(nf, S::default());
832                    // For an opted-in in-place channel, `input(ch)` is the
833                    // empty sentinel, so `in_out_mut(ch)` (this scratch) is the
834                    // plugin's only view of its data. The host output pointer
835                    // aliases the input and still holds the input samples at
836                    // build time, so seed the scratch with the converted input
837                    // - otherwise a `supports_in_place` plugin on a converting
838                    // wire would read (and emit) silence. Same-precision needs
839                    // no seed: it reinterprets the host buffer directly above.
840                    if supports_in_place && ch < 64 && (in_place_mask >> ch) & 1 == 1 {
841                        let host = std::slice::from_raw_parts(ptr.cast_const(), nf);
842                        for (dst, &h) in buf.iter_mut().zip(host) {
843                            *dst = S::from_f64(h.to_f64());
844                        }
845                    }
846                    let p = buf.as_mut_ptr();
847                    let l = buf.len();
848                    std::slice::from_raw_parts_mut(p, l)
849                };
850                self.output_slices.push(slice);
851            }
852
853            // SAFETY: Same transmute pattern as AudioBuffer::slice().
854            // RawBufferScratch stores 'static slices but we return AudioBuffer<'a>.
855            let self_ptr: *mut Self = self;
856            let s = &mut *self_ptr;
857            let mut buf = std::mem::transmute::<AudioBuffer<'static, S>, AudioBuffer<'a, S>>(
858                AudioBuffer::from_slices(&s.input_slices, &mut s.output_slices, nf),
859            );
860            buf.set_in_place_mask(in_place_mask);
861            buf
862        }
863    }
864
865    /// Pre-allocate the per-channel scratch vectors so `build` runs
866    /// allocation-free for buses up to `num_in` × `num_out` channels
867    /// and blocks up to `max_frames`. Idempotent and growth-only.
868    pub fn ensure_capacity(&mut self, num_in: usize, num_out: usize, max_frames: usize) {
869        if self.input_slices.capacity() < num_in {
870            self.input_slices
871                .reserve_exact(num_in - self.input_slices.capacity());
872        }
873        if self.output_slices.capacity() < num_out {
874            self.output_slices
875                .reserve_exact(num_out - self.output_slices.capacity());
876        }
877        while self.input_copies.len() < num_in {
878            self.input_copies.push(Vec::with_capacity(max_frames));
879        }
880        for buf in &mut self.input_copies {
881            if buf.capacity() < max_frames {
882                buf.reserve_exact(max_frames - buf.capacity());
883            }
884        }
885        while self.output_buffers.len() < num_out {
886            self.output_buffers.push(Vec::with_capacity(max_frames));
887        }
888        for buf in &mut self.output_buffers {
889            if buf.capacity() < max_frames {
890                buf.reserve_exact(max_frames - buf.capacity());
891            }
892        }
893        // Shared silence for unconnected input channels, kept block-sized
894        // and zeroed so `build` never allocates it on the audio thread.
895        if self.silence.len() < max_frames {
896            self.silence.resize(max_frames, S::default());
897        }
898    }
899}
900
901impl<S: Sample> Default for RawBufferScratch<S> {
902    fn default() -> Self {
903        Self {
904            input_slices: Vec::with_capacity(2),
905            output_slices: Vec::with_capacity(2),
906            input_copies: Vec::with_capacity(2),
907            output_buffers: Vec::with_capacity(2),
908            silence: Vec::new(),
909        }
910    }
911}
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916
917    /// Drive one block through `build` / `finish_widening` with
918    /// plugin precision `S` on host wire `H`: the plugin doubles a
919    /// `[1, 2, 3, 4]` input ramp into the output.
920    fn double_one_block<S: Sample, H: Sample>() -> Vec<H> {
921        let input: Vec<H> = (1..=4).map(|v| H::from_f64(f64::from(v))).collect();
922        let mut output: Vec<H> = vec![H::default(); 4];
923        let in_ptrs = [input.as_ptr()];
924        let mut out_ptrs = [output.as_mut_ptr()];
925        let mut scratch = RawBufferScratch::<S>::default();
926        // SAFETY: both pointers address 4 valid samples that outlive
927        // the buffer; the finish call reuses the same layout.
928        unsafe {
929            let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, false);
930            for i in 0..4 {
931                let v = buf.input(0)[i];
932                buf.output(0)[i] = v + v;
933            }
934            scratch.finish_widening(out_ptrs.as_mut_ptr(), 1, 4);
935        }
936        output
937    }
938
939    fn assert_doubled<H: Sample>(output: &[H]) {
940        let got: Vec<f64> = output.iter().map(|v| v.to_f64()).collect();
941        assert_eq!(got, vec![2.0, 4.0, 6.0, 8.0]);
942    }
943
944    // Passthrough, so the outputs are bit-identical to the input - exact
945    // float equality is the contract being checked.
946    #[allow(clippy::float_cmp)]
947    #[test]
948    fn for_each_frame_io_fans_mono_input_to_a_stereo_graph() {
949        // Mono-in (1) / stereo-out (2) bus fed through a 2-in/2-out identity
950        // "graph": the single input must fan into both frame slots, so both
951        // outputs receive the mono signal, with no per-width branch.
952        let input: [f32; 3] = [0.1, 0.2, 0.3];
953        let mut out_l = [0.0f32; 3];
954        let mut out_r = [0.0f32; 3];
955        let inputs: [&[f32]; 1] = [&input];
956        let mut outputs: [&mut [f32]; 2] = [&mut out_l, &mut out_r];
957        let mut buf = AudioBuffer::<f32>::from_slices_checked(&inputs, &mut outputs, 3);
958
959        buf.for_each_frame_io::<2, 2, _>(|frame_in, frame_out| {
960            // Identity graph: both channels pass through.
961            frame_out[0] = frame_in[0];
962            frame_out[1] = frame_in[1];
963        });
964
965        // frame_in[1] repeated the last (only) input channel, so both
966        // outputs equal the mono input.
967        assert_eq!(out_l, input);
968        assert_eq!(out_r, input);
969    }
970
971    #[test]
972    fn f32_wire_f32_plugin_zero_copy() {
973        assert_doubled(&double_one_block::<f32, f32>());
974    }
975
976    #[test]
977    fn f32_wire_f64_plugin_widens() {
978        assert_doubled(&double_one_block::<f64, f32>());
979    }
980
981    #[test]
982    fn f64_wire_f64_plugin_zero_copy() {
983        assert_doubled(&double_one_block::<f64, f64>());
984    }
985
986    #[test]
987    fn f64_wire_f32_plugin_narrows() {
988        assert_doubled(&double_one_block::<f32, f64>());
989    }
990
991    #[test]
992    #[allow(clippy::float_cmp)]
993    fn f64_wire_in_place_snapshots_input() {
994        // Host hands the same f64 buffer for input and output; the
995        // input reads must see the pre-write values.
996        let mut io: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0];
997        let in_ptrs = [io.as_ptr()];
998        let mut out_ptrs = [io.as_mut_ptr()];
999        let mut scratch = RawBufferScratch::<f64>::default();
1000        // SAFETY: the aliased pointer addresses 4 valid samples that
1001        // outlive the buffer.
1002        unsafe {
1003            let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, false);
1004            assert!(buf.is_in_place(0));
1005            for i in 0..4 {
1006                let v = buf.input(0)[i];
1007                buf.output(0)[i] = v * 10.0;
1008            }
1009        }
1010        assert_eq!(io, vec![10.0, 20.0, 30.0, 40.0]);
1011    }
1012
1013    #[test]
1014    #[allow(clippy::float_cmp)]
1015    fn in_place_true_path_hands_shared_buffer() {
1016        // `supports_in_place = true`: the host aliases in/out, so the wrapper
1017        // skips the copy. `input(ch)` is empty and the plugin reads+writes
1018        // the shared buffer through `in_out_mut`. This is the zero-copy path
1019        // the `f64_wire_in_place_snapshots_input` test (opting out) never
1020        // exercises - and the one that used to panic at construction (debug)
1021        // or in `input()` (release).
1022        let mut io: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
1023        let in_ptrs = [io.as_ptr()];
1024        let mut out_ptrs = [io.as_mut_ptr()];
1025        let mut scratch = RawBufferScratch::<f32>::default();
1026        // SAFETY: the aliased pointer addresses 4 valid samples that outlive
1027        // the buffer.
1028        unsafe {
1029            let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, true);
1030            assert!(buf.is_in_place(0));
1031            assert!(buf.input(0).is_empty(), "in-place input(ch) is empty");
1032            let io_ch = buf.in_out_mut(0);
1033            assert_eq!(io_ch.len(), 4);
1034            for s in io_ch.iter_mut() {
1035                *s *= 10.0; // read the current (input) value, write in place
1036            }
1037        }
1038        assert_eq!(io, vec![10.0, 20.0, 30.0, 40.0]);
1039    }
1040
1041    #[test]
1042    #[allow(clippy::float_cmp)]
1043    fn in_place_true_path_cross_precision_seeds_input() {
1044        // The documented in-place contract on a precision-converting wire: an
1045        // f64 plugin (S) on an f32 host (H), aliased, supports_in_place = true.
1046        // The `is_in_place` + `in_out_mut` branch must read the INPUT (not the
1047        // zeroed conversion scratch) and write correct output back to the f32
1048        // host. Before the fix this emitted silence in every f32-wire host.
1049        let mut io: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
1050        let in_ptrs = [io.as_ptr()];
1051        let mut out_ptrs = [io.as_mut_ptr()];
1052        let mut scratch = RawBufferScratch::<f64>::default();
1053        // SAFETY: the aliased f32 pointer addresses 4 valid samples that
1054        // outlive the buffer; `finish_widening` reuses the same layout.
1055        unsafe {
1056            {
1057                // H = f32 (host pointers), S = f64 (scratch): cross-precision.
1058                let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, true);
1059                assert!(buf.is_in_place(0));
1060                assert!(
1061                    buf.input(0).is_empty(),
1062                    "in-place input(ch) is empty on any wire"
1063                );
1064                let io_ch = buf.in_out_mut(0);
1065                assert_eq!(
1066                    io_ch.to_vec(),
1067                    vec![1.0, 2.0, 3.0, 4.0],
1068                    "in_out_mut reads the converted input, not zeros"
1069                );
1070                for s in io_ch.iter_mut() {
1071                    *s *= 10.0;
1072                }
1073            }
1074            // Narrow the f64 scratch the plugin wrote back to the f32 host.
1075            scratch.finish_widening(out_ptrs.as_mut_ptr(), 1, 4);
1076        }
1077        assert_eq!(io, vec![10.0, 20.0, 30.0, 40.0]);
1078    }
1079
1080    /// The disjoint `(input, output)` accessors can't represent an in-place
1081    /// channel, so they debug-assert with a clear message instead of the
1082    /// opaque out-of-range panic the empty input slice would otherwise
1083    /// produce. Gated on `debug_assertions`: the guard is compiled out in
1084    /// release, so this only runs (and only should panic) in debug.
1085    #[cfg(debug_assertions)]
1086    #[test]
1087    #[should_panic(expected = "in-place")]
1088    fn io_on_zero_copy_in_place_channel_debug_asserts() {
1089        let mut io: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
1090        let in_ptrs = [io.as_ptr()];
1091        let mut out_ptrs = [io.as_mut_ptr()];
1092        let mut scratch = RawBufferScratch::<f32>::default();
1093        // SAFETY: the aliased pointer addresses 4 valid samples that outlive
1094        // the buffer.
1095        unsafe {
1096            // supports_in_place = true -> zero-copy, empty input slice.
1097            let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, true);
1098            assert!(buf.is_in_place(0));
1099            // Should fire the guard, not index the empty input slice.
1100            let _ = buf.io(0);
1101        }
1102    }
1103
1104    /// The guard must NOT fire on the copy path: a host-aliased channel with
1105    /// `supports_in_place = false` reports `is_in_place`, but the wrapper
1106    /// snapshotted its input into a full slice, so `io()` works. A guard
1107    /// keyed on `is_in_place` would false-positive here and break every
1108    /// normal plugin that uses `io()` in an aliasing host (e.g. AU, which
1109    /// advertises in-place unconditionally).
1110    #[test]
1111    #[allow(clippy::float_cmp)]
1112    fn io_on_copy_path_aliased_channel_is_fine() {
1113        let mut io: Vec<f32> = vec![1.0, 2.0, 3.0, 4.0];
1114        let in_ptrs = [io.as_ptr()];
1115        let mut out_ptrs = [io.as_mut_ptr()];
1116        let mut scratch = RawBufferScratch::<f32>::default();
1117        // SAFETY: the aliased pointer addresses 4 valid samples that outlive
1118        // the buffer.
1119        unsafe {
1120            // supports_in_place = false -> copy path, full readable input.
1121            let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, false);
1122            assert!(buf.is_in_place(0), "still reports host aliasing");
1123            let (inp, out) = buf.io(0); // must not panic
1124            assert_eq!(inp, &[1.0, 2.0, 3.0, 4.0]);
1125            for (o, &i) in out.iter_mut().zip(inp) {
1126                *o = i * 2.0;
1127            }
1128        }
1129        assert_eq!(io, vec![2.0, 4.0, 6.0, 8.0]);
1130    }
1131
1132    /// A disconnected sidechain reaches the plugin as block-sized silence,
1133    /// not a null pointer - the uniform "declared width always; missing
1134    /// buses read as silence" contract every format upholds. The VST3 shim
1135    /// substitutes silence for any missing or null input channel (a
1136    /// deactivated bus arrives with null channel buffers, or a trailing bus
1137    /// is dropped from `ProcessData`), and the AAX shim feeds silence for an
1138    /// unpatched sidechain, so the flat channel array always carries the
1139    /// negotiated width. This pins the downstream contract: such a channel
1140    /// is a readable, zeroed, block-length slice, not the out-of-range
1141    /// empty slice a raw null would produce.
1142    #[test]
1143    fn silence_substituted_sidechain_channels_are_full_length_zeros() {
1144        let nf = 512usize;
1145        let main_l = vec![0.5f32; nf];
1146        let main_r = vec![0.5f32; nf];
1147        // What the shim now hands us for a disconnected stereo sidechain:
1148        // a block-sized zeroed buffer per channel (shared read-only).
1149        let silence = vec![0.0f32; nf];
1150        let mut out_l = vec![0.0f32; nf];
1151        let mut out_r = vec![0.0f32; nf];
1152
1153        let in_ptrs = [
1154            main_l.as_ptr(),
1155            main_r.as_ptr(),
1156            silence.as_ptr(),
1157            silence.as_ptr(),
1158        ];
1159        let mut out_ptrs = [out_l.as_mut_ptr(), out_r.as_mut_ptr()];
1160        let mut scratch = RawBufferScratch::<f32>::default();
1161        // SAFETY: every pointer addresses `nf` valid samples that outlive
1162        // the buffer; `silence` backs both deactivated-bus channels.
1163        unsafe {
1164            #[allow(clippy::cast_possible_truncation)]
1165            let buf = scratch.build(
1166                in_ptrs.as_ptr(),
1167                out_ptrs.as_mut_ptr(),
1168                4,
1169                2,
1170                nf as u32,
1171                false,
1172            );
1173            assert_eq!(buf.num_input_channels(), 4);
1174            // The reads that panicked when the sidechain arrived as a
1175            // null/empty slice now return block-length silence.
1176            assert_eq!(buf.input(2).len(), nf);
1177            assert_eq!(buf.input(3).len(), nf);
1178            assert!(buf.input(2).iter().all(|&s| s == 0.0));
1179            assert!(buf.input(3).iter().all(|&s| s == 0.0));
1180        }
1181    }
1182
1183    /// A raw null channel pointer is handled at the `build` layer itself:
1184    /// a null input reads as block-length silence and a null output absorbs
1185    /// the plugin's writes into discard scratch - so a wrapper that hands
1186    /// `build` a null (a CLAP/VST2/LV2 port the host left unconnected) can
1187    /// never produce the out-of-range empty slice that used to panic.
1188    #[test]
1189    fn raw_null_channels_read_silence_and_discard_writes() {
1190        let nf = 512usize;
1191        let main_l = vec![0.5f32; nf];
1192        let main_r = vec![0.5f32; nf];
1193        let mut out_l = vec![0.0f32; nf];
1194        // Input channels 2/3 and output channel 1 arrive unconnected.
1195        let in_ptrs = [
1196            main_l.as_ptr(),
1197            main_r.as_ptr(),
1198            std::ptr::null(),
1199            std::ptr::null(),
1200        ];
1201        let mut out_ptrs = [out_l.as_mut_ptr(), std::ptr::null_mut()];
1202        let mut scratch = RawBufferScratch::<f32>::default();
1203        // SAFETY: the non-null pointers address `nf` valid samples; the
1204        // null channels are the unconnected-port shape under test.
1205        unsafe {
1206            #[allow(clippy::cast_possible_truncation)]
1207            let mut buf = scratch.build(
1208                in_ptrs.as_ptr(),
1209                out_ptrs.as_mut_ptr(),
1210                4,
1211                2,
1212                nf as u32,
1213                false,
1214            );
1215            assert_eq!(buf.num_input_channels(), 4);
1216            assert_eq!(buf.num_output_channels(), 2);
1217            // Null input channels read as full-length silence.
1218            assert_eq!(buf.input(2).len(), nf);
1219            assert!(buf.input(3).iter().all(|&s| s == 0.0));
1220            // The null output channel is a full-length discard buffer: the
1221            // plugin can write it without an out-of-range panic.
1222            assert_eq!(buf.output(1).len(), nf);
1223            for s in buf.output(1) {
1224                *s = 1.0;
1225            }
1226        }
1227    }
1228}