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.
87            for (i, inp) in inputs.iter().enumerate() {
88                assert!(
89                    num_samples <= inp.len(),
90                    "AudioBuffer: num_samples ({num_samples}) exceeds input channel {i} length ({})",
91                    inp.len(),
92                );
93            }
94            for (o, out) in outputs.iter().enumerate() {
95                assert!(
96                    num_samples <= out.len(),
97                    "AudioBuffer: num_samples ({num_samples}) exceeds output channel {o} length ({})",
98                    out.len(),
99                );
100            }
101        }
102        AudioBuffer {
103            inputs,
104            outputs,
105            in_place_mask: 0,
106            offset: 0,
107            num_samples,
108        }
109    }
110
111    /// Set the in-place mask. Called by format wrappers (or
112    /// `RawBufferScratch::build`) after construction once they've
113    /// determined which channels alias on the host side.
114    #[inline]
115    pub fn set_in_place_mask(&mut self, mask: u64) {
116        self.in_place_mask = mask;
117    }
118
119    /// `true` when the host passes a single buffer for both input and
120    /// output of `ch` (in-place I/O). Use [`Self::in_out_mut`] to read
121    /// and write that buffer directly when this returns `true`.
122    #[must_use]
123    pub fn is_in_place(&self, ch: usize) -> bool {
124        ch < 64 && (self.in_place_mask >> ch) & 1 == 1
125    }
126
127    /// Read+write slice for an in-place channel - the same memory the
128    /// host gave us for both input and output. Each sample reads as
129    /// the input value before the plugin overwrites it.
130    ///
131    /// Only meaningful when [`Self::is_in_place`] returns `true`. On a
132    /// non-in-place channel this returns the output slice with no
133    /// input data in it; reading is allowed but produces uninitialized
134    /// host-buffer contents.
135    pub fn in_out_mut(&mut self, ch: usize) -> &mut [S] {
136        let end = self.offset + self.num_samples;
137        &mut self.outputs[ch][self.offset..end]
138    }
139
140    #[must_use]
141    pub fn num_samples(&self) -> usize {
142        self.num_samples
143    }
144
145    #[must_use]
146    pub fn num_input_channels(&self) -> usize {
147        self.inputs.len()
148    }
149
150    #[must_use]
151    pub fn num_output_channels(&self) -> usize {
152        self.outputs.len()
153    }
154
155    #[must_use]
156    pub fn input(&self, channel: usize) -> &[S] {
157        let end = self.offset + self.num_samples;
158        &self.inputs[channel][self.offset..end]
159    }
160
161    pub fn output(&mut self, channel: usize) -> &mut [S] {
162        let end = self.offset + self.num_samples;
163        &mut self.outputs[channel][self.offset..end]
164    }
165
166    /// Number of channels (min of input and output).
167    #[must_use]
168    pub fn channels(&self) -> usize {
169        self.inputs.len().min(self.outputs.len())
170    }
171
172    /// Get an input/output pair for a channel. Useful for in-place processing.
173    pub fn io_pair(&mut self, in_ch: usize, out_ch: usize) -> (&[S], &mut [S]) {
174        let end = self.offset + self.num_samples;
175        let input = &self.inputs[in_ch][self.offset..end];
176        let output = &mut self.outputs[out_ch][self.offset..end];
177        (input, output)
178    }
179
180    /// Get an input/output pair for the same channel index. Shorthand for `io_pair(ch, ch)`.
181    pub fn io(&mut self, ch: usize) -> (&[S], &mut [S]) {
182        self.io_pair(ch, ch)
183    }
184
185    /// Iterate per-channel, in fixed-size `N`-sample chunks. The
186    /// last chunk of each channel may be shorter than `N`; it's
187    /// yielded as a [`ChunkItem::Tail`] with the actual remaining
188    /// length, and the caller falls back to scalar for it. Full
189    /// `N`-sample chunks arrive as [`ChunkItem::Full`] carrying
190    /// `&[S; N]` / `&mut [S; N]` stack arrays - exactly the shape
191    /// the per-op SIMD primitives in `truce-simd` expect.
192    ///
193    /// Iteration order is channel-major: all chunks of channel 0,
194    /// then all chunks of channel 1, etc. Matches the natural
195    /// orientation for per-channel state (biquad coefficients,
196    /// per-channel meters) and lets the caller read its smoothed
197    /// params once per chunk instead of once per sample.
198    ///
199    /// The returned object is a "lending iterator" - it doesn't
200    /// implement [`Iterator`] because each yielded item borrows
201    /// from the iterator itself. Use `while let Some(chunk) = …
202    /// .next()`:
203    ///
204    /// ```ignore
205    /// let mut chunks = buffer.chunks_mut::<32>();
206    /// while let Some(chunk) = chunks.next() {
207    ///     match chunk {
208    ///         ChunkItem::Full { ch, inp, out } => {
209    ///             // SIMD-friendly path, inp / out are &[f32; 32]
210    ///         }
211    ///         ChunkItem::Tail { ch, inp, out } => {
212    ///             // scalar fallback for the trailing samples
213    ///         }
214    ///     }
215    /// }
216    /// ```
217    ///
218    /// Const-generic `N` is the chunk size; pick it to match the
219    /// SIMD width × unroll factor for your inner op (32 / 64 are
220    /// good defaults for current Apple Silicon + `x86_64`).
221    pub fn chunks_mut<const N: usize>(&mut self) -> ChunksMut<'_, 'a, S, N> {
222        ChunksMut {
223            buffer: self,
224            ch: 0,
225            pos: 0,
226        }
227    }
228
229    /// Iterate per-frame and hand a fixed-size `(input, output)`
230    /// stack-array pair to `tick`. Sized at the type level by const
231    /// generic `N`, which must equal [`Self::channels`].
232    ///
233    /// `io()` / `io_pair()` give a per-channel slice view, which is
234    /// the right shape for "process channel `ch` in isolation"
235    /// loops. But libraries that expect a per-frame `(in: &[S],
236    /// out: &mut [S])` callback - `fundsp::AudioUnit::tick`,
237    /// `nih_plug`'s frame iterators, custom per-sample DSP nodes -
238    /// can't take that shape directly without either copying inputs
239    /// into a scratch first (heap allocation on the audio thread)
240    /// or fighting the borrow checker over two simultaneous `&mut`
241    /// borrows of the buffer.
242    ///
243    /// This helper does the per-frame transpose in-place against a
244    /// stack-allocated `[S; N]` pair, calls `tick` `num_samples()`
245    /// times, and writes back. No heap, no borrow gymnastics at the
246    /// call site:
247    ///
248    /// ```ignore
249    /// // Stereo plugin delegating per-frame DSP to fundsp:
250    /// buffer.for_each_frame::<2, _>(|frame_in, frame_out| {
251    ///     self.graph.tick(frame_in, frame_out);
252    /// });
253    /// ```
254    ///
255    /// `&[S; N]` deref-coerces to `&[S]` at the call site, so
256    /// callers can pass the arrays straight to slice-taking APIs
257    /// like fundsp's `tick`.
258    ///
259    /// # Panics
260    ///
261    /// Debug builds panic if `N != self.channels()`. Release builds
262    /// rely on the same precondition without checking; reading past
263    /// the actual channel count would index out of bounds anyway.
264    pub fn for_each_frame<const N: usize, F>(&mut self, mut tick: F)
265    where
266        F: FnMut(&[S; N], &mut [S; N]),
267    {
268        debug_assert_eq!(
269            N,
270            self.channels(),
271            "for_each_frame::<{N}> requires the buffer to have exactly {N} channels"
272        );
273        let mut frame_in = [S::default(); N];
274        let mut frame_out = [S::default(); N];
275        let end = self.offset + self.num_samples;
276        for i in self.offset..end {
277            for (ch, slot) in frame_in.iter_mut().enumerate() {
278                *slot = self.inputs[ch][i];
279            }
280            tick(&frame_in, &mut frame_out);
281            for (ch, sample) in frame_out.iter().enumerate() {
282                self.outputs[ch][i] = *sample;
283            }
284        }
285    }
286
287    /// Like [`Self::for_each_frame`] but for a DSP whose frame shape is a
288    /// fixed `(IN, OUT)` that need not match the bus width. Input slot `k`
289    /// reads bus input channel `k`, repeating the last available channel
290    /// when the bus has fewer than `IN` inputs - so a mono source fans into
291    /// both inputs of a stereo graph. Output slot `k` writes bus output
292    /// channel `k` while `k < num_output_channels`; frame outputs past the
293    /// bus width are dropped.
294    ///
295    /// This lets a plugin built around a fixed-shape DSP (a fundsp
296    /// `reverb_stereo`, a dasp graph) run on any declared bus layout -
297    /// `(2, 2)` stereo and `(1, 2)` mono-in/stereo-out alike - through one
298    /// `for_each_frame_io::<2, 2>` call, with no per-width branch. A bus
299    /// with no inputs (an instrument) feeds silence.
300    pub fn for_each_frame_io<const IN: usize, const OUT: usize, F>(&mut self, mut tick: F)
301    where
302        F: FnMut(&[S; IN], &mut [S; OUT]),
303    {
304        let num_in = self.inputs.len();
305        let num_out = self.outputs.len();
306        let mut frame_in = [S::default(); IN];
307        let mut frame_out = [S::default(); OUT];
308        let end = self.offset + self.num_samples;
309        for i in self.offset..end {
310            if num_in > 0 {
311                for (k, slot) in frame_in.iter_mut().enumerate() {
312                    *slot = self.inputs[k.min(num_in - 1)][i];
313                }
314            }
315            tick(&frame_in, &mut frame_out);
316            for (k, sample) in frame_out.iter().enumerate().take(num_out) {
317                self.outputs[k][i] = *sample;
318            }
319        }
320    }
321
322    /// [`Self::for_each_frame_io`] specialized to a stereo `(2, 2)` DSP -
323    /// the common case (a `reverb_stereo`, a stereo filter block). Runs the
324    /// 2-in/2-out `tick` over any declared bus: a mono source fans into
325    /// both inputs, a stereo bus maps 1:1, so a stereo effect needs no
326    /// per-width branch and no turbofish.
327    pub fn for_each_stereo_frame<F>(&mut self, tick: F)
328    where
329        F: FnMut(&[S; 2], &mut [S; 2]),
330    {
331        self.for_each_frame_io::<2, 2, F>(tick);
332    }
333
334    /// Peak absolute value across an output channel, returned as `f32`
335    /// because meters / UI display always work in `f32` regardless of
336    /// the plugin's internal precision.
337    ///
338    /// Short-circuits and returns `f32::NAN` on the **first** NaN
339    /// sample seen, so meters can flag runaway plugins instead of
340    /// silently reporting "peaks within range" while NaN poison
341    /// spreads downstream.
342    #[must_use]
343    pub fn output_peak(&self, ch: usize) -> f32 {
344        let end = self.offset + self.num_samples;
345        let mut peak = 0.0f32;
346        for &b in &self.outputs[ch][self.offset..end] {
347            let v = b.to_f32();
348            if v.is_nan() {
349                return f32::NAN;
350            }
351            let abs = v.abs();
352            if abs > peak {
353                peak = abs;
354            }
355        }
356        peak
357    }
358
359    /// Return a sub-block view covering samples `start..start+len`.
360    ///
361    /// The returned buffer borrows `self` exclusively - you cannot use
362    /// the original buffer while the slice is alive.
363    ///
364    /// # Panics
365    /// Panics if `start + len > self.num_samples()`.
366    pub fn slice(&mut self, start: usize, len: usize) -> AudioBuffer<'_, S> {
367        assert!(
368            start + len <= self.num_samples,
369            "slice({start}, {len}) out of bounds for buffer of {} samples",
370            self.num_samples,
371        );
372        let new_offset = self.offset + start;
373        // SAFETY: We construct an AudioBuffer<'a, S> and transmute to AudioBuffer<'_, S>.
374        // These have identical memory layout (lifetimes are erased at runtime).
375        // This is sound because:
376        // 1. &mut self prevents the caller from using self while the slice exists
377        // 2. The underlying channel memory lives for 'a which outlives '_
378        // 3. Bounds are checked by the assert above
379        let self_ptr: *mut Self = self;
380        unsafe {
381            let s = &mut *self_ptr;
382            std::mem::transmute::<AudioBuffer<'a, S>, AudioBuffer<'_, S>>(AudioBuffer {
383                inputs: s.inputs,
384                outputs: &mut *s.outputs,
385                in_place_mask: s.in_place_mask,
386                offset: new_offset,
387                num_samples: len,
388            })
389        }
390    }
391}
392
393/// One yielded chunk from [`AudioBuffer::chunks_mut`].
394///
395/// `Full` is the SIMD-friendly path: `inp` and `out` are stack
396/// arrays of exactly `N` elements, ready to feed `truce-simd`'s
397/// block ops. `Tail` is the trailing fragment when `num_samples()`
398/// isn't a multiple of `N`; fall back to a scalar loop.
399pub enum ChunkItem<'b, S: Sample, const N: usize> {
400    /// Full N-sample chunk. The `&[S; N]` / `&mut [S; N]` are the
401    /// shape `truce-simd` ops are written against - no slice
402    /// length check at the call site.
403    Full {
404        /// Channel index this chunk belongs to.
405        ch: usize,
406        /// Sample offset within the audio block this chunk starts
407        /// at. Use this when indexing into a precomputed envelope
408        /// array - `chunks_mut` iterates channel-major, so the
409        /// envelope (typically read once per audio block via
410        /// `read_into(&mut env[..num_samples])`) is shared across all
411        /// channel passes.
412        sample: usize,
413        /// Read-only N-sample input slice.
414        inp: &'b [S; N],
415        /// Mutable N-sample output slice.
416        out: &'b mut [S; N],
417    },
418    /// Trailing chunk when `num_samples()` isn't a multiple of `N`.
419    /// Length is in `(0, N)`. Fall back to scalar processing.
420    Tail {
421        /// Channel index this chunk belongs to.
422        ch: usize,
423        /// Sample offset within the audio block this chunk starts at.
424        sample: usize,
425        /// Read-only tail input slice; length < N.
426        inp: &'b [S],
427        /// Mutable tail output slice; length < N.
428        out: &'b mut [S],
429    },
430}
431
432/// Lending iterator returned by [`AudioBuffer::chunks_mut`].
433///
434/// Does not implement [`Iterator`] because each yielded
435/// [`ChunkItem`] borrows from the iterator itself - the standard
436/// "GATs would help here" pattern. Drive it with `while let
437/// Some(chunk) = chunks.next()` instead. See
438/// [`AudioBuffer::chunks_mut`] for a worked example.
439pub struct ChunksMut<'b, 'a, S: Sample, const N: usize> {
440    buffer: &'b mut AudioBuffer<'a, S>,
441    /// Current channel being walked.
442    ch: usize,
443    /// Position within the current channel, relative to
444    /// `buffer.offset`. Advances by N each Full chunk, then jumps
445    /// to `num_samples` for the Tail (or directly past it when
446    /// `num_samples` is a multiple of N).
447    pos: usize,
448}
449
450impl<S: Sample, const N: usize> ChunksMut<'_, '_, S, N> {
451    /// Yield the next chunk, or `None` when every channel has been
452    /// fully walked.
453    ///
454    /// Method-on-self rather than `Iterator::next` because each
455    /// yielded [`ChunkItem`] borrows from `self`; GATs would be
456    /// needed to express that through the `Iterator` trait.
457    #[allow(clippy::should_implement_trait, clippy::missing_panics_doc)]
458    pub fn next(&mut self) -> Option<ChunkItem<'_, S, N>> {
459        loop {
460            if self.ch >= self.buffer.outputs.len() {
461                return None;
462            }
463            let ns = self.buffer.num_samples;
464            if self.pos >= ns {
465                self.ch += 1;
466                self.pos = 0;
467                continue;
468            }
469            let abs_start = self.buffer.offset + self.pos;
470            let remaining = ns - self.pos;
471            let take = remaining.min(N);
472            let abs_end = abs_start + take;
473            let ch = self.ch;
474            let sample = self.pos;
475
476            let inp_slice = &self.buffer.inputs[ch][abs_start..abs_end];
477            let out_slice: &mut [S] = &mut self.buffer.outputs[ch][abs_start..abs_end];
478
479            self.pos += take;
480
481            // Full vs Tail by length: full chunks convert to `&[S;
482            // N]` / `&mut [S; N]` for the SIMD-friendly path; tails
483            // fall back to slice form.
484            return Some(if take == N {
485                ChunkItem::Full {
486                    ch,
487                    sample,
488                    // Length-checked above; `try_into` here is a
489                    // free reinterpret.
490                    inp: inp_slice.try_into().expect("len == N by construction"),
491                    out: out_slice.try_into().expect("len == N by construction"),
492                }
493            } else {
494                ChunkItem::Tail {
495                    ch,
496                    sample,
497                    inp: inp_slice,
498                    out: out_slice,
499                }
500            });
501        }
502    }
503}
504
505/// Scratch space for [`RawBufferScratch::build`].
506///
507/// Callers allocate this on the stack and pass it to `build`. The
508/// buffer borrows the slices stored here, so this struct must outlive
509/// the returned `AudioBuffer`.
510///
511/// Generic over the plugin's sample type `S`. When the host buffer
512/// matches `S`, slices point into host memory (zero-copy). When the
513/// host buffer is a different precision, the input is widened/narrowed
514/// into per-channel scratch; the output is rendered into scratch and
515/// the wrapper copies + casts it back to the host buffer at the end
516/// of the block via [`Self::finish_widening`].
517pub struct RawBufferScratch<S: Sample = f32> {
518    pub input_slices: Vec<&'static [S]>,
519    pub output_slices: Vec<&'static mut [S]>,
520    /// Per-channel input copies. Used (a) when the host passes the
521    /// same buffer for input and output (in-place processing - VST3
522    /// spec allows this and several real DAWs use it for effects),
523    /// or (b) when the host buffer precision differs from `S` and
524    /// we widen/narrow on the way in. In either case the slice the
525    /// plugin sees points into the matching slot here.
526    input_copies: Vec<Vec<S>>,
527    /// Per-channel output scratch. Populated by [`Self::build`] when
528    /// the host buffer precision differs from `S` (the wrapper copies +
529    /// casts these back via [`Self::finish_widening`]), and reused as
530    /// write-discard scratch for an unconnected (null) output channel.
531    output_buffers: Vec<Vec<S>>,
532    /// Shared read-only silence handed to the plugin for an unconnected
533    /// (null) input channel - an unrouted sidechain, or an LV2 port the
534    /// host never connected. The plugin negotiated the channel, so it
535    /// must read block-length silence, never the out-of-range empty
536    /// slice a raw null would otherwise produce. Never written.
537    silence: Vec<S>,
538}
539
540impl<S: Sample> RawBufferScratch<S> {
541    /// Build an `AudioBuffer<S>` from raw host pointers of wire
542    /// precision `H` - `f32` in the common case (CLAP, LV2, AAX
543    /// always; VST3/VST2/AU 32-bit mode), `f64` when the host
544    /// negotiated a double-precision wire (VST3 `kSample64`, VST2
545    /// `processDoubleReplacing`).
546    ///
547    /// When `S = H`, slices point directly into host memory (modulo
548    /// in-place input copying). Otherwise every channel is converted
549    /// into per-channel scratch and the wrapper must call
550    /// [`Self::finish_widening`] at the end of the block to copy the
551    /// rendered samples back to the host's output pointers.
552    ///
553    /// # Safety
554    /// - `inputs` must point to `num_in` valid `*const H` pointers
555    ///   (each non-null pointer must address at least `num_frames`
556    ///   readable samples; a null pointer marks an unconnected channel
557    ///   and reads back as block-length silence).
558    /// - `outputs` must point to `num_out` valid `*mut H` pointers
559    ///   (each non-null pointer must address at least `num_frames`
560    ///   writable samples; a null pointer marks an unconnected channel
561    ///   whose writes are discarded).
562    /// - The pointed-to memory must remain valid for the lifetime of
563    ///   the returned `AudioBuffer`.
564    pub unsafe fn build<H: Sample>(
565        &mut self,
566        inputs: *const *const H,
567        outputs: *mut *mut H,
568        num_in: u32,
569        num_out: u32,
570        num_frames: u32,
571        supports_in_place: bool,
572    ) -> AudioBuffer<'_, S> {
573        // SAFETY: forwarded - caller's contract is the same.
574        unsafe {
575            self.build_inner(
576                inputs,
577                outputs,
578                num_in,
579                num_out,
580                num_frames,
581                supports_in_place,
582            )
583        }
584    }
585
586    /// Copy + convert the rendered `S` output back to the host's `H`
587    /// output pointers. No-op when `S = H` (the slices the plugin
588    /// wrote already point directly at host memory).
589    ///
590    /// # Safety
591    /// `outputs` and `num_out` / `num_frames` must match the values
592    /// passed to the prior [`Self::build`] call on this scratch.
593    pub unsafe fn finish_widening<H: Sample>(
594        &self,
595        outputs: *mut *mut H,
596        num_out: u32,
597        num_frames: u32,
598    ) {
599        // Same precision: the plugin wrote straight into host memory.
600        if S::IS_F64 == H::IS_F64 {
601            return;
602        }
603        unsafe {
604            let nf = num_frames as usize;
605            for ch in 0..(num_out as usize) {
606                let ptr = *outputs.add(ch);
607                if ptr.is_null() {
608                    continue;
609                }
610                let host = std::slice::from_raw_parts_mut(ptr, nf);
611                let plugin_out = &self.output_buffers[ch];
612                for (h, &p) in host.iter_mut().zip(plugin_out.iter()) {
613                    *h = H::from_f64(p.to_f64());
614                }
615            }
616        }
617    }
618
619    unsafe fn build_inner<'a, H: Sample>(
620        &'a mut self,
621        inputs: *const *const H,
622        outputs: *mut *mut H,
623        num_in: u32,
624        num_out: u32,
625        num_frames: u32,
626        supports_in_place: bool,
627    ) -> AudioBuffer<'a, S> {
628        const MAX_CHANNELS_TRACKED: usize = 64;
629        // Whether the plugin's chosen precision matches the host's.
630        // When matched, we zero-copy host pointers into the slice
631        // arrays; when not, we convert through input_copies and
632        // output_buffers. The traits are sealed at f32/f64, so equal
633        // IS_F64 flags mean S and H are the same type.
634        let same_precision = S::IS_F64 == H::IS_F64;
635
636        unsafe {
637            let nf = num_frames as usize;
638            let num_out_u = num_out as usize;
639            let num_in_u = num_in as usize;
640            debug_assert!(
641                num_out_u <= MAX_CHANNELS_TRACKED,
642                "RawBufferScratch::build: alias detection only covers up to {MAX_CHANNELS_TRACKED} \
643                 output channels; got {num_out_u}. Channels beyond the cap won't be \
644                 detected as aliased.",
645            );
646            let out_ptrs: [Option<*mut H>; MAX_CHANNELS_TRACKED] = std::array::from_fn(|ch| {
647                if ch < num_out_u {
648                    let p = *outputs.add(ch);
649                    if p.is_null() { None } else { Some(p) }
650                } else {
651                    None
652                }
653            });
654            let aliases_any_output = |in_ptr: *const H| -> bool {
655                let in_start = in_ptr as usize;
656                let in_end = in_start + nf * std::mem::size_of::<H>();
657                out_ptrs
658                    .iter()
659                    .take(num_out_u.min(MAX_CHANNELS_TRACKED))
660                    .any(|o| {
661                        o.is_some_and(|op| {
662                            let o_start = op as usize;
663                            let o_end = o_start + nf * std::mem::size_of::<H>();
664                            !(in_end <= o_start || o_end <= in_start)
665                        })
666                    })
667            };
668
669            // Grow per-channel scratch slots if the bus widened or
670            // we're widening precision and need every channel copied.
671            // `output_buffers` grows unconditionally now: an unconnected
672            // output channel discards its writes into this scratch even in
673            // the same-precision path.
674            while self.input_copies.len() < num_in_u {
675                self.input_copies.push(Vec::new());
676            }
677            while self.output_buffers.len() < num_out_u {
678                self.output_buffers.push(Vec::new());
679            }
680            // Block-length silence for any unconnected input channel.
681            if self.silence.len() < nf {
682                self.silence.resize(nf, S::default());
683            }
684            let silence_ptr = self.silence.as_ptr();
685
686            self.input_slices.clear();
687            self.input_slices.reserve(num_in_u);
688            let mut in_place_mask: u64 = 0;
689            for ch in 0..num_in_u {
690                let ptr = *inputs.add(ch);
691                let slice: &[S] = if ptr.is_null() {
692                    // Unconnected channel (unrouted sidechain, unbound LV2
693                    // port). The plugin negotiated it, so hand it
694                    // block-length silence, not an out-of-range empty slice.
695                    std::slice::from_raw_parts(silence_ptr, nf)
696                } else if aliases_any_output(ptr) {
697                    if ch < 64 {
698                        in_place_mask |= 1 << ch;
699                    }
700                    if supports_in_place && same_precision {
701                        // Plugin opted in: hand it nothing through
702                        // input(ch); it must read+write via in_out_mut.
703                        // Only supported in the same-precision case;
704                        // the cross-precision path always copies.
705                        &[]
706                    } else {
707                        // Snapshot the input (converting precision if
708                        // needed) before the plugin overwrites the
709                        // shared buffer. Routing through f64 is
710                        // lossless in the widening direction.
711                        let host = std::slice::from_raw_parts(ptr, nf);
712                        let copy = &mut self.input_copies[ch];
713                        copy.clear();
714                        copy.reserve(nf);
715                        for &h in host {
716                            copy.push(S::from_f64(h.to_f64()));
717                        }
718                        let p = copy.as_ptr();
719                        let l = copy.len();
720                        // SAFETY: `copy` lives as long as `self`, which
721                        // outlives the returned `AudioBuffer<'a>`.
722                        std::slice::from_raw_parts(p, l)
723                    }
724                } else if same_precision {
725                    // SAFETY: same-precision branch - host pointer is
726                    // already `*const S` modulo runtime type identity;
727                    // the cast reinterprets `*const H` as `*const S`.
728                    let raw = ptr.cast::<S>();
729                    std::slice::from_raw_parts(raw, nf)
730                } else {
731                    // Different precision, no aliasing: convert into
732                    // scratch (f64 round-trip, lossless when widening).
733                    let host = std::slice::from_raw_parts(ptr, nf);
734                    let copy = &mut self.input_copies[ch];
735                    copy.clear();
736                    copy.reserve(nf);
737                    for &h in host {
738                        copy.push(S::from_f64(h.to_f64()));
739                    }
740                    let p = copy.as_ptr();
741                    let l = copy.len();
742                    std::slice::from_raw_parts(p, l)
743                };
744                self.input_slices.push(slice);
745            }
746
747            self.output_slices.clear();
748            self.output_slices.reserve(num_out_u);
749            for ch in 0..num_out_u {
750                let ptr = *outputs.add(ch);
751                let slice: &mut [S] = if ptr.is_null() {
752                    // Unconnected output channel: give the plugin a
753                    // block-length discard buffer to write into rather than
754                    // an empty slice it would index out of range.
755                    // `finish_widening` skips it (null host pointer), so
756                    // nothing is copied back.
757                    let buf = &mut self.output_buffers[ch];
758                    buf.clear();
759                    buf.resize(nf, S::default());
760                    let p = buf.as_mut_ptr();
761                    let l = buf.len();
762                    std::slice::from_raw_parts_mut(p, l)
763                } else if same_precision {
764                    // SAFETY: same-precision branch - host pointer is
765                    // already `*mut S` modulo runtime type identity.
766                    let raw = ptr.cast::<S>();
767                    std::slice::from_raw_parts_mut(raw, nf)
768                } else {
769                    // Different precision: render into per-channel
770                    // scratch; finish_widening copies+converts back.
771                    let buf = &mut self.output_buffers[ch];
772                    buf.clear();
773                    buf.resize(nf, S::default());
774                    let p = buf.as_mut_ptr();
775                    let l = buf.len();
776                    std::slice::from_raw_parts_mut(p, l)
777                };
778                self.output_slices.push(slice);
779            }
780
781            // SAFETY: Same transmute pattern as AudioBuffer::slice().
782            // RawBufferScratch stores 'static slices but we return AudioBuffer<'a>.
783            let self_ptr: *mut Self = self;
784            let s = &mut *self_ptr;
785            let mut buf = std::mem::transmute::<AudioBuffer<'static, S>, AudioBuffer<'a, S>>(
786                AudioBuffer::from_slices(&s.input_slices, &mut s.output_slices, nf),
787            );
788            buf.set_in_place_mask(in_place_mask);
789            buf
790        }
791    }
792
793    /// Pre-allocate the per-channel scratch vectors so `build` runs
794    /// allocation-free for buses up to `num_in` × `num_out` channels
795    /// and blocks up to `max_frames`. Idempotent and growth-only.
796    pub fn ensure_capacity(&mut self, num_in: usize, num_out: usize, max_frames: usize) {
797        if self.input_slices.capacity() < num_in {
798            self.input_slices
799                .reserve_exact(num_in - self.input_slices.capacity());
800        }
801        if self.output_slices.capacity() < num_out {
802            self.output_slices
803                .reserve_exact(num_out - self.output_slices.capacity());
804        }
805        while self.input_copies.len() < num_in {
806            self.input_copies.push(Vec::with_capacity(max_frames));
807        }
808        for buf in &mut self.input_copies {
809            if buf.capacity() < max_frames {
810                buf.reserve_exact(max_frames - buf.capacity());
811            }
812        }
813        while self.output_buffers.len() < num_out {
814            self.output_buffers.push(Vec::with_capacity(max_frames));
815        }
816        for buf in &mut self.output_buffers {
817            if buf.capacity() < max_frames {
818                buf.reserve_exact(max_frames - buf.capacity());
819            }
820        }
821        // Shared silence for unconnected input channels, kept block-sized
822        // and zeroed so `build` never allocates it on the audio thread.
823        if self.silence.len() < max_frames {
824            self.silence.resize(max_frames, S::default());
825        }
826    }
827}
828
829impl<S: Sample> Default for RawBufferScratch<S> {
830    fn default() -> Self {
831        Self {
832            input_slices: Vec::with_capacity(2),
833            output_slices: Vec::with_capacity(2),
834            input_copies: Vec::with_capacity(2),
835            output_buffers: Vec::with_capacity(2),
836            silence: Vec::new(),
837        }
838    }
839}
840
841#[cfg(test)]
842mod tests {
843    use super::*;
844
845    /// Drive one block through `build` / `finish_widening` with
846    /// plugin precision `S` on host wire `H`: the plugin doubles a
847    /// `[1, 2, 3, 4]` input ramp into the output.
848    fn double_one_block<S: Sample, H: Sample>() -> Vec<H> {
849        let input: Vec<H> = (1..=4).map(|v| H::from_f64(f64::from(v))).collect();
850        let mut output: Vec<H> = vec![H::default(); 4];
851        let in_ptrs = [input.as_ptr()];
852        let mut out_ptrs = [output.as_mut_ptr()];
853        let mut scratch = RawBufferScratch::<S>::default();
854        // SAFETY: both pointers address 4 valid samples that outlive
855        // the buffer; the finish call reuses the same layout.
856        unsafe {
857            let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, false);
858            for i in 0..4 {
859                let v = buf.input(0)[i];
860                buf.output(0)[i] = v + v;
861            }
862            scratch.finish_widening(out_ptrs.as_mut_ptr(), 1, 4);
863        }
864        output
865    }
866
867    fn assert_doubled<H: Sample>(output: &[H]) {
868        let got: Vec<f64> = output.iter().map(|v| v.to_f64()).collect();
869        assert_eq!(got, vec![2.0, 4.0, 6.0, 8.0]);
870    }
871
872    // Passthrough, so the outputs are bit-identical to the input - exact
873    // float equality is the contract being checked.
874    #[allow(clippy::float_cmp)]
875    #[test]
876    fn for_each_frame_io_fans_mono_input_to_a_stereo_graph() {
877        // Mono-in (1) / stereo-out (2) bus fed through a 2-in/2-out identity
878        // "graph": the single input must fan into both frame slots, so both
879        // outputs receive the mono signal, with no per-width branch.
880        let input: [f32; 3] = [0.1, 0.2, 0.3];
881        let mut out_l = [0.0f32; 3];
882        let mut out_r = [0.0f32; 3];
883        let inputs: [&[f32]; 1] = [&input];
884        let mut outputs: [&mut [f32]; 2] = [&mut out_l, &mut out_r];
885        let mut buf = AudioBuffer::<f32>::from_slices_checked(&inputs, &mut outputs, 3);
886
887        buf.for_each_frame_io::<2, 2, _>(|frame_in, frame_out| {
888            // Identity graph: both channels pass through.
889            frame_out[0] = frame_in[0];
890            frame_out[1] = frame_in[1];
891        });
892
893        // frame_in[1] repeated the last (only) input channel, so both
894        // outputs equal the mono input.
895        assert_eq!(out_l, input);
896        assert_eq!(out_r, input);
897    }
898
899    #[test]
900    fn f32_wire_f32_plugin_zero_copy() {
901        assert_doubled(&double_one_block::<f32, f32>());
902    }
903
904    #[test]
905    fn f32_wire_f64_plugin_widens() {
906        assert_doubled(&double_one_block::<f64, f32>());
907    }
908
909    #[test]
910    fn f64_wire_f64_plugin_zero_copy() {
911        assert_doubled(&double_one_block::<f64, f64>());
912    }
913
914    #[test]
915    fn f64_wire_f32_plugin_narrows() {
916        assert_doubled(&double_one_block::<f32, f64>());
917    }
918
919    #[test]
920    #[allow(clippy::float_cmp)]
921    fn f64_wire_in_place_snapshots_input() {
922        // Host hands the same f64 buffer for input and output; the
923        // input reads must see the pre-write values.
924        let mut io: Vec<f64> = vec![1.0, 2.0, 3.0, 4.0];
925        let in_ptrs = [io.as_ptr()];
926        let mut out_ptrs = [io.as_mut_ptr()];
927        let mut scratch = RawBufferScratch::<f64>::default();
928        // SAFETY: the aliased pointer addresses 4 valid samples that
929        // outlive the buffer.
930        unsafe {
931            let mut buf = scratch.build(in_ptrs.as_ptr(), out_ptrs.as_mut_ptr(), 1, 1, 4, false);
932            assert!(buf.is_in_place(0));
933            for i in 0..4 {
934                let v = buf.input(0)[i];
935                buf.output(0)[i] = v * 10.0;
936            }
937        }
938        assert_eq!(io, vec![10.0, 20.0, 30.0, 40.0]);
939    }
940
941    /// A disconnected sidechain reaches the plugin as block-sized silence,
942    /// not a null pointer - the uniform "declared width always; missing
943    /// buses read as silence" contract every format upholds. The VST3 shim
944    /// substitutes silence for any missing or null input channel (a
945    /// deactivated bus arrives with null channel buffers, or a trailing bus
946    /// is dropped from `ProcessData`), and the AAX shim feeds silence for an
947    /// unpatched sidechain, so the flat channel array always carries the
948    /// negotiated width. This pins the downstream contract: such a channel
949    /// is a readable, zeroed, block-length slice, not the out-of-range
950    /// empty slice a raw null would produce.
951    #[test]
952    fn silence_substituted_sidechain_channels_are_full_length_zeros() {
953        let nf = 512usize;
954        let main_l = vec![0.5f32; nf];
955        let main_r = vec![0.5f32; nf];
956        // What the shim now hands us for a disconnected stereo sidechain:
957        // a block-sized zeroed buffer per channel (shared read-only).
958        let silence = vec![0.0f32; nf];
959        let mut out_l = vec![0.0f32; nf];
960        let mut out_r = vec![0.0f32; nf];
961
962        let in_ptrs = [
963            main_l.as_ptr(),
964            main_r.as_ptr(),
965            silence.as_ptr(),
966            silence.as_ptr(),
967        ];
968        let mut out_ptrs = [out_l.as_mut_ptr(), out_r.as_mut_ptr()];
969        let mut scratch = RawBufferScratch::<f32>::default();
970        // SAFETY: every pointer addresses `nf` valid samples that outlive
971        // the buffer; `silence` backs both deactivated-bus channels.
972        unsafe {
973            #[allow(clippy::cast_possible_truncation)]
974            let buf = scratch.build(
975                in_ptrs.as_ptr(),
976                out_ptrs.as_mut_ptr(),
977                4,
978                2,
979                nf as u32,
980                false,
981            );
982            assert_eq!(buf.num_input_channels(), 4);
983            // The reads that panicked when the sidechain arrived as a
984            // null/empty slice now return block-length silence.
985            assert_eq!(buf.input(2).len(), nf);
986            assert_eq!(buf.input(3).len(), nf);
987            assert!(buf.input(2).iter().all(|&s| s == 0.0));
988            assert!(buf.input(3).iter().all(|&s| s == 0.0));
989        }
990    }
991
992    /// A raw null channel pointer is handled at the `build` layer itself:
993    /// a null input reads as block-length silence and a null output absorbs
994    /// the plugin's writes into discard scratch - so a wrapper that hands
995    /// `build` a null (a CLAP/VST2/LV2 port the host left unconnected) can
996    /// never produce the out-of-range empty slice that used to panic.
997    #[test]
998    fn raw_null_channels_read_silence_and_discard_writes() {
999        let nf = 512usize;
1000        let main_l = vec![0.5f32; nf];
1001        let main_r = vec![0.5f32; nf];
1002        let mut out_l = vec![0.0f32; nf];
1003        // Input channels 2/3 and output channel 1 arrive unconnected.
1004        let in_ptrs = [
1005            main_l.as_ptr(),
1006            main_r.as_ptr(),
1007            std::ptr::null(),
1008            std::ptr::null(),
1009        ];
1010        let mut out_ptrs = [out_l.as_mut_ptr(), std::ptr::null_mut()];
1011        let mut scratch = RawBufferScratch::<f32>::default();
1012        // SAFETY: the non-null pointers address `nf` valid samples; the
1013        // null channels are the unconnected-port shape under test.
1014        unsafe {
1015            #[allow(clippy::cast_possible_truncation)]
1016            let mut buf = scratch.build(
1017                in_ptrs.as_ptr(),
1018                out_ptrs.as_mut_ptr(),
1019                4,
1020                2,
1021                nf as u32,
1022                false,
1023            );
1024            assert_eq!(buf.num_input_channels(), 4);
1025            assert_eq!(buf.num_output_channels(), 2);
1026            // Null input channels read as full-length silence.
1027            assert_eq!(buf.input(2).len(), nf);
1028            assert!(buf.input(3).iter().all(|&s| s == 0.0));
1029            // The null output channel is a full-length discard buffer: the
1030            // plugin can write it without an out-of-range panic.
1031            assert_eq!(buf.output(1).len(), nf);
1032            for s in buf.output(1) {
1033                *s = 1.0;
1034            }
1035        }
1036    }
1037}