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}