Skip to main content

vst3_host/
playback.rs

1//! Batteries-included audio playback: drive a [`Plugin`] from an [`AudioBackend`].
2//!
3//! This is the glue that turns a loaded plugin into sound. [`play_with_backend`]
4//! opens the backend's default output device and pumps the plugin's
5//! [`Plugin::process_audio`] from the device callback, returning an [`AudioHandle`]
6//! that keeps the stream alive and lets you keep controlling the plugin (send MIDI,
7//! change parameters) while it plays.
8//!
9//! For the common case, prefer [`crate::simple::play`] or [`crate::Vst3Host::play`].
10
11use std::sync::atomic::{AtomicU32, Ordering};
12use std::sync::{Arc, Mutex, MutexGuard};
13
14use rtrb::{Consumer, Producer, RingBuffer};
15
16use crate::{
17    audio::{AudioBackend, AudioBuffers, AudioConfig, AudioLevels, AudioStream, ChannelLevel},
18    error::{Error, Result},
19    midi::MidiEvent,
20    plugin::Plugin,
21    realtime::{RealtimePluginRunner, RtControl},
22};
23
24/// Capacity of each lock-free side-channel ring between the UI/control thread and the audio
25/// callback. Sized for a worst-case control burst and several frames of output MIDI / GUI
26/// parameter changes; pushes beyond it are dropped rather than blocking.
27const SIDE_CHANNEL_CAPACITY: usize = 4096;
28
29/// A control command queued by a UI/control thread and applied on the audio thread (inside the
30/// callback, under the plugin lock it already holds) at the start of the next block.
31enum HybridCommand {
32    Midi(MidiEvent),
33    Param { id: u32, value: f64 },
34    Panic,
35}
36
37/// Peak amplitude of one channel buffer, sanitizing non-finite samples to 0.
38fn channel_peak(buf: &[f32]) -> f32 {
39    buf.iter()
40        .map(|&x| if x.is_finite() { x.abs() } else { 0.0 })
41        .fold(0.0_f32, f32::max)
42}
43
44/// The audio-thread half of the lock-free side channels. Moved into the device callback; it
45/// drains queued control before processing and publishes feedback (peaks, output MIDI, GUI
46/// parameter changes) after. The control/feedback rings and the level atomics are lock-free;
47/// the only lock the callback takes is the plugin mutex it already needs (plus, via
48/// `get_parameter_changes`, the plugin's tiny internal component-handler mutex that its editor
49/// briefly touches on `performEdit` — bounded, not the UI-thread audio mutex).
50struct AudioSideChannels {
51    control_rx: Consumer<HybridCommand>,
52    out_midi_tx: Producer<MidiEvent>,
53    param_tx: Producer<(u32, f64)>,
54    /// One `AtomicU32` per output channel holding the max per-block peak (f32 bits) since the
55    /// UI last read it. Peaks are non-negative, so `fetch_max` on the bit pattern is a valid
56    /// float max.
57    levels: Arc<[AtomicU32]>,
58}
59
60impl AudioSideChannels {
61    /// Apply all queued control commands to the plugin. The caller already holds the lock.
62    fn apply_control(&mut self, plugin: &mut Plugin) {
63        while let Ok(cmd) = self.control_rx.pop() {
64            match cmd {
65                HybridCommand::Midi(event) => {
66                    let _ = plugin.send_midi_event(event);
67                }
68                HybridCommand::Param { id, value } => {
69                    let _ = plugin.set_parameter(id, value);
70                }
71                HybridCommand::Panic => {
72                    let _ = plugin.midi_panic();
73                }
74            }
75        }
76    }
77
78    /// Publish per-channel output peaks into the atomics. Only meaningful after a successful
79    /// render, so the caller gates this on `process_audio` succeeding.
80    fn publish_levels(&mut self, outputs: &[Vec<f32>]) {
81        for (ch, atomic) in self.levels.iter().enumerate() {
82            let peak = outputs.get(ch).map(|b| channel_peak(b)).unwrap_or(0.0);
83            atomic.fetch_max(peak.to_bits(), Ordering::Relaxed);
84        }
85    }
86
87    /// Forward the plugin's drained output MIDI and editor parameter changes into their rings
88    /// (drop-on-full). Called every block **regardless of processing state**: the editor can
89    /// still emit parameter changes (and a plugin its output MIDI) while processing is stopped,
90    /// and the UI must stay in sync.
91    fn publish_feedback(&mut self, plugin: &Plugin) {
92        for event in plugin.take_output_midi() {
93            let _ = self.out_midi_tx.push(event);
94        }
95        for change in plugin.get_parameter_changes() {
96            let _ = self.param_tx.push(change);
97        }
98    }
99}
100
101/// The UI-thread half of the side channels, stored in [`AudioHandle`]. The rtrb endpoints need
102/// `&mut` for push/pop, so they live behind `Mutex` to expose `&self` methods; this mutex is
103/// only ever touched by the UI/control thread, never the audio callback.
104struct UiSideChannels {
105    control_tx: Mutex<Producer<HybridCommand>>,
106    out_midi_rx: Mutex<Consumer<MidiEvent>>,
107    param_rx: Mutex<Consumer<(u32, f64)>>,
108    levels: Arc<[AtomicU32]>,
109}
110
111/// Build a fresh set of side channels for `channels` output channels, returning the audio-side
112/// half (move into the callback) and the UI-side half (store in the handle).
113fn make_side_channels(channels: usize) -> (AudioSideChannels, UiSideChannels) {
114    let (control_tx, control_rx) = RingBuffer::<HybridCommand>::new(SIDE_CHANNEL_CAPACITY);
115    let (out_midi_tx, out_midi_rx) = RingBuffer::<MidiEvent>::new(SIDE_CHANNEL_CAPACITY);
116    let (param_tx, param_rx) = RingBuffer::<(u32, f64)>::new(SIDE_CHANNEL_CAPACITY);
117    let levels: Arc<[AtomicU32]> = (0..channels).map(|_| AtomicU32::new(0)).collect();
118
119    let audio = AudioSideChannels {
120        control_rx,
121        out_midi_tx,
122        param_tx,
123        levels: Arc::clone(&levels),
124    };
125    let ui = UiSideChannels {
126        control_tx: Mutex::new(control_tx),
127        out_midi_rx: Mutex::new(out_midi_rx),
128        param_rx: Mutex::new(param_rx),
129        levels,
130    };
131    (audio, ui)
132}
133
134/// A running audio stream driving a [`Plugin`].
135///
136/// Dropping the handle stops playback (the underlying device stream is released).
137/// While it lives, the plugin keeps running on the audio thread; use [`Self::lock`]
138/// to send MIDI or change parameters from your control thread.
139pub struct AudioHandle {
140    // Boxed as a trait object so `AudioHandle` is not generic over the backend.
141    // Kept solely to hold the stream open — dropping it stops audio.
142    _stream: Box<dyn AudioStream>,
143    // The capture stream for the duplex (effect-hosting) path; `None` for output-only play.
144    // Kept alive alongside `_stream`.
145    _input_stream: Option<Box<dyn AudioStream>>,
146    plugin: Arc<Mutex<Plugin>>,
147    // Lock-free side channels to/from the audio callback. Used for the hot path (control +
148    // per-frame feedback) so a UI thread never contends with the audio thread for the lock.
149    ui: UiSideChannels,
150}
151
152impl AudioHandle {
153    /// Lock the running plugin to send MIDI, change parameters, etc.
154    ///
155    /// Recovers automatically if the audio thread previously panicked while holding
156    /// the lock (poisoned mutex), so control calls keep working.
157    pub fn lock(&self) -> MutexGuard<'_, Plugin> {
158        self.plugin
159            .lock()
160            .unwrap_or_else(|poisoned| poisoned.into_inner())
161    }
162
163    /// Try to lock the plugin without blocking, returning `None` if the audio
164    /// callback currently holds the lock (it is held for the duration of each
165    /// `process_audio` call).
166    ///
167    /// Use this on a UI/render thread for best-effort, per-frame reads (VU
168    /// meters, output-MIDI drain, parameter sync): skipping a frame when the
169    /// audio thread is mid-block is invisible, and it keeps the UI thread from
170    /// stalling on the (unfair) mutex — which otherwise shows up as input lag.
171    pub fn try_lock(&self) -> Option<MutexGuard<'_, Plugin>> {
172        match self.plugin.try_lock() {
173            Ok(guard) => Some(guard),
174            Err(std::sync::TryLockError::Poisoned(p)) => Some(p.into_inner()),
175            Err(std::sync::TryLockError::WouldBlock) => None,
176        }
177    }
178
179    /// Queue a MIDI event for the plugin without locking the audio thread.
180    ///
181    /// The event is pushed onto a lock-free ring and applied at the start of the next audio
182    /// block. Prefer this over `lock().send_midi_event(..)` on a UI thread — it never blocks
183    /// on the audio mutex. Returns `false` if the ring is full (the event is dropped).
184    pub fn send_midi(&self, event: MidiEvent) -> bool {
185        self.ui
186            .control_tx
187            .lock()
188            .map(|mut tx| tx.push(HybridCommand::Midi(event)).is_ok())
189            .unwrap_or(false)
190    }
191
192    /// Queue a normalized parameter change (`0.0..=1.0`) without locking the audio thread.
193    /// Applied at the start of the next block. Returns `false` if the ring is full.
194    pub fn set_parameter(&self, id: u32, value: f64) -> bool {
195        self.ui
196            .control_tx
197            .lock()
198            .map(|mut tx| tx.push(HybridCommand::Param { id, value }).is_ok())
199            .unwrap_or(false)
200    }
201
202    /// Queue an all-notes-off "panic" (CC 123/120/121 on every channel) without locking the
203    /// audio thread. Returns `false` if the ring is full.
204    pub fn midi_panic(&self) -> bool {
205        self.ui
206            .control_tx
207            .lock()
208            .map(|mut tx| tx.push(HybridCommand::Panic).is_ok())
209            .unwrap_or(false)
210    }
211
212    /// Read the latest per-channel output peak levels without locking the audio thread.
213    ///
214    /// Each channel reports the maximum peak observed since the previous call (the read resets
215    /// the accumulator), so polling at UI frame rate never misses a transient between frames.
216    /// `rms` is not tracked on this path and is reported as 0; `peak_hold` mirrors `peak`
217    /// (drive your own ballistics, e.g. [`crate::audio::PeakMeter`], from the peak).
218    pub fn output_levels(&self) -> AudioLevels {
219        let channels = self
220            .ui
221            .levels
222            .iter()
223            .map(|atomic| {
224                let peak = f32::from_bits(atomic.swap(0, Ordering::Relaxed));
225                ChannelLevel {
226                    peak,
227                    rms: 0.0,
228                    peak_hold: peak,
229                }
230            })
231            .collect();
232        AudioLevels { channels }
233    }
234
235    /// Drain MIDI the plugin emitted during processing (arpeggiators, MPE, …) without locking
236    /// the audio thread. Returns the events queued since the last call.
237    pub fn drain_output_midi(&self) -> Vec<MidiEvent> {
238        let mut out = Vec::new();
239        if let Ok(mut rx) = self.ui.out_midi_rx.lock() {
240            while let Ok(event) = rx.pop() {
241                out.push(event);
242            }
243        }
244        out
245    }
246
247    /// Drain parameter changes the plugin made through its own editor without locking the
248    /// audio thread. Returns `(id, normalized_value)` pairs queued since the last call.
249    pub fn drain_parameter_changes(&self) -> Vec<(u32, f64)> {
250        let mut out = Vec::new();
251        if let Ok(mut rx) = self.ui.param_rx.lock() {
252            while let Ok(change) = rx.pop() {
253                out.push(change);
254            }
255        }
256        out
257    }
258
259    /// A shared handle to the plugin, e.g. to move into another thread.
260    pub fn plugin(&self) -> Arc<Mutex<Plugin>> {
261        Arc::clone(&self.plugin)
262    }
263
264    /// Stop playback now (equivalent to dropping the handle).
265    pub fn stop(self) {}
266}
267
268/// Interleave per-channel plugin output into a device's interleaved buffer.
269///
270/// `out` is laid out as `[frame0_ch0, frame0_ch1, ..., frame1_ch0, ...]` with
271/// `out.len() == frames * channels`. Channels the plugin didn't produce are left
272/// untouched (callers should pre-fill `out` with silence); plugin channels beyond
273/// `channels` are ignored.
274pub(crate) fn interleave_outputs(outputs: &[Vec<f32>], out: &mut [f32], channels: usize) {
275    if channels == 0 {
276        return;
277    }
278    let frames = out.len() / channels;
279    for ch in 0..channels.min(outputs.len()) {
280        let src = &outputs[ch];
281        for frame in 0..frames.min(src.len()) {
282            out[frame * channels + ch] = src[frame];
283        }
284    }
285}
286
287/// Resize a scratch buffer's output channels to exactly `frames`, clearing them.
288fn prepare_scratch(scratch: &mut AudioBuffers, frames: usize) {
289    for ch in &mut scratch.outputs {
290        if ch.len() != frames {
291            ch.resize(frames, 0.0);
292        }
293        ch.fill(0.0);
294    }
295    for ch in &mut scratch.inputs {
296        if ch.len() != frames {
297            ch.resize(frames, 0.0);
298        }
299        ch.fill(0.0);
300    }
301    scratch.block_size = frames;
302}
303
304/// Start streaming `plugin` through `backend`'s default output device.
305///
306/// The plugin is moved behind a shared lock so it can keep being controlled while
307/// the audio thread pulls blocks. Playback starts immediately and continues until
308/// the returned [`AudioHandle`] is dropped.
309///
310/// `config.output_channels` and `config.sample_rate` define the stream; the device
311/// callback may request varying block sizes, which the bridge accommodates.
312pub fn play_with_backend<B: AudioBackend>(
313    backend: &B,
314    plugin: Plugin,
315    config: AudioConfig,
316) -> Result<AudioHandle> {
317    let device = backend
318        .default_output_device()
319        .ok_or_else(|| Error::AudioBackendError("No default output device available".into()))?;
320
321    let channels = config.output_channels;
322    let sample_rate = config.sample_rate;
323
324    let plugin = Arc::new(Mutex::new(plugin));
325    // Ensure the plugin is armed before the first callback fires.
326    plugin
327        .lock()
328        .unwrap_or_else(|p| p.into_inner())
329        .start_processing()?;
330
331    let plugin_cb = Arc::clone(&plugin);
332    // Lock-free side channels: UI control in, feedback (peaks / output MIDI / param changes) out.
333    let (mut side, ui) = make_side_channels(channels);
334    // Reusable scratch buffer so the steady-state callback does not allocate.
335    let mut scratch = AudioBuffers::new(0, channels, config.block_size, sample_rate);
336
337    let data_cb = Box::new(move |data: &mut [f32]| {
338        // Start from silence so unproduced channels/frames are quiet.
339        data.fill(0.0);
340        if channels == 0 {
341            return;
342        }
343        let frames = data.len() / channels;
344        prepare_scratch(&mut scratch, frames);
345
346        // Recover from poison so queued control keeps flowing even after an audio-thread panic
347        // (matches AudioHandle::lock): the callback re-attempts processing rather than going
348        // permanently silent.
349        let mut p = match plugin_cb.lock() {
350            Ok(guard) => guard,
351            Err(poisoned) => poisoned.into_inner(),
352        };
353        // Apply queued control before rendering; render; then forward feedback. Levels need a
354        // successful render, but MIDI/param feedback is published even when stopped so the UI
355        // stays in sync.
356        side.apply_control(&mut p);
357        if p.process_audio(&mut scratch).is_ok() {
358            interleave_outputs(&scratch.outputs, data, channels);
359            side.publish_levels(&scratch.outputs);
360        }
361        side.publish_feedback(&p);
362    });
363
364    let err_cb = Box::new(|e: B::Error| {
365        log::error!("audio stream error: {}", e);
366    });
367
368    let stream = backend
369        .create_output_stream(&device, config, data_cb, err_cb)
370        .map_err(|e| Error::AudioBackendError(format!("Failed to create output stream: {}", e)))?;
371
372    stream
373        .play()
374        .map_err(|e| Error::AudioBackendError(format!("Failed to start stream: {}", e)))?;
375
376    Ok(AudioHandle {
377        _stream: Box::new(stream),
378        _input_stream: None,
379        plugin,
380        ui,
381    })
382}
383
384/// Drive a plugin with **live audio input** (effect hosting): capture from the default input
385/// device, process it through the plugin, and play the result on the default output device.
386///
387/// cpal has no true duplex stream, so this opens a separate input and output stream bridged
388/// by a lock-free ring: the input callback pushes captured frames, the output callback pops
389/// them into the plugin's input buffers, processes, and writes the output. `config`'s
390/// `input_channels`/`output_channels`/`sample_rate` define the streams. Like
391/// [`play_with_backend`], control the plugin via the returned [`AudioHandle`].
392///
393/// Note: the two device clocks are independent; this uses a small bridge buffer and tolerates
394/// drift by dropping/zero-filling at the edges. Suitable for monitoring/auditioning effects.
395pub fn play_with_input_backend<B: AudioBackend>(
396    backend: &B,
397    plugin: Plugin,
398    config: AudioConfig,
399) -> Result<AudioHandle> {
400    let in_device = backend
401        .default_input_device()
402        .ok_or_else(|| Error::AudioBackendError("No default input device available".into()))?;
403    let out_device = backend
404        .default_output_device()
405        .ok_or_else(|| Error::AudioBackendError("No default output device available".into()))?;
406
407    let in_channels = config.input_channels.max(1);
408    let out_channels = config.output_channels;
409    let sample_rate = config.sample_rate;
410
411    let plugin = Arc::new(Mutex::new(plugin));
412    plugin
413        .lock()
414        .unwrap_or_else(|p| p.into_inner())
415        .start_processing()?;
416
417    // SPSC bridge: input callback (producer) -> output callback (consumer). Hold a few
418    // blocks of interleaved input so the independent device clocks don't starve immediately.
419    let ring_cap = (config.block_size * in_channels * 8).max(2048);
420    let (mut producer, mut consumer) = rtrb::RingBuffer::<f32>::new(ring_cap);
421
422    let in_data_cb = Box::new(move |data: &[f32]| {
423        // Drop on full (output side fell behind) rather than block the capture callback.
424        for &s in data {
425            let _ = producer.push(s);
426        }
427    });
428    let in_err_cb = Box::new(|e: B::Error| log::error!("input stream error: {}", e));
429    let input_stream = backend
430        .create_input_stream(&in_device, config, in_data_cb, in_err_cb)
431        .map_err(|e| Error::AudioBackendError(format!("Failed to create input stream: {}", e)))?;
432
433    let plugin_cb = Arc::clone(&plugin);
434    // Lock-free side channels (same as the output-only path) so effect hosting is also
435    // controllable without locking the audio thread.
436    let (mut side, ui) = make_side_channels(out_channels);
437    let mut scratch = AudioBuffers::new(in_channels, out_channels, config.block_size, sample_rate);
438    let out_data_cb = Box::new(move |data: &mut [f32]| {
439        data.fill(0.0);
440        if out_channels == 0 {
441            return;
442        }
443        let frames = data.len() / out_channels;
444        prepare_scratch(&mut scratch, frames);
445        // Deinterleave captured input from the ring into the plugin's input buffers
446        // (interleaved frame-major order matches the input callback's push order).
447        for f in 0..frames {
448            for ch in scratch.inputs.iter_mut() {
449                ch[f] = consumer.pop().unwrap_or(0.0);
450            }
451        }
452        let mut p = match plugin_cb.lock() {
453            Ok(guard) => guard,
454            Err(poisoned) => poisoned.into_inner(),
455        };
456        side.apply_control(&mut p);
457        if p.process_audio(&mut scratch).is_ok() {
458            interleave_outputs(&scratch.outputs, data, out_channels);
459            side.publish_levels(&scratch.outputs);
460        }
461        side.publish_feedback(&p);
462    });
463    let out_err_cb = Box::new(|e: B::Error| log::error!("output stream error: {}", e));
464    let output_stream = backend
465        .create_output_stream(&out_device, config, out_data_cb, out_err_cb)
466        .map_err(|e| Error::AudioBackendError(format!("Failed to create output stream: {}", e)))?;
467
468    input_stream
469        .play()
470        .map_err(|e| Error::AudioBackendError(format!("Failed to start input stream: {}", e)))?;
471    output_stream
472        .play()
473        .map_err(|e| Error::AudioBackendError(format!("Failed to start output stream: {}", e)))?;
474
475    Ok(AudioHandle {
476        _stream: Box::new(output_stream),
477        _input_stream: Some(Box::new(input_stream)),
478        plugin,
479        ui,
480    })
481}
482
483/// A running real-time audio stream (the [`RealtimePluginRunner`] variant of
484/// [`AudioHandle`]). Holds the device stream open and exposes the lock-free [`RtControl`];
485/// dropping it stops playback.
486pub struct RtAudioHandle {
487    _stream: Box<dyn AudioStream>,
488    control: RtControl,
489}
490
491impl RtAudioHandle {
492    /// The lock-free control handle — queue MIDI and parameter changes without locking the
493    /// audio thread.
494    pub fn control(&mut self) -> &mut RtControl {
495        &mut self.control
496    }
497
498    /// Stop playback now (equivalent to dropping the handle).
499    pub fn stop(self) {}
500}
501
502/// Like [`play_with_backend`], but drives the plugin through a [`RealtimePluginRunner`] so the
503/// audio callback takes **no lock** — control changes flow over a lock-free queue. Returns an
504/// [`RtAudioHandle`] that keeps the stream alive and exposes the [`RtControl`].
505///
506/// `command_capacity` bounds how many MIDI/parameter commands can queue between callbacks.
507pub fn play_realtime_with_backend<B: AudioBackend>(
508    backend: &B,
509    plugin: Plugin,
510    config: AudioConfig,
511    command_capacity: usize,
512) -> Result<RtAudioHandle> {
513    let device = backend
514        .default_output_device()
515        .ok_or_else(|| Error::AudioBackendError("No default output device available".into()))?;
516
517    let channels = config.output_channels;
518    let sample_rate = config.sample_rate;
519
520    let (mut runner, control) = RealtimePluginRunner::new(plugin, command_capacity);
521    runner.start()?;
522
523    // Reusable scratch buffer so the steady-state callback does not allocate.
524    let mut scratch = AudioBuffers::new(0, channels, config.block_size, sample_rate);
525
526    let data_cb = Box::new(move |data: &mut [f32]| {
527        data.fill(0.0);
528        if channels == 0 {
529            return;
530        }
531        let frames = data.len() / channels;
532        prepare_scratch(&mut scratch, frames);
533
534        // No lock: the runner owns the plugin and drains its command queue here.
535        if runner.process(&mut scratch).is_ok() {
536            interleave_outputs(&scratch.outputs, data, channels);
537        }
538    });
539
540    let err_cb = Box::new(|e: B::Error| {
541        log::error!("audio stream error: {}", e);
542    });
543
544    let stream = backend
545        .create_output_stream(&device, config, data_cb, err_cb)
546        .map_err(|e| Error::AudioBackendError(format!("Failed to create output stream: {}", e)))?;
547
548    stream
549        .play()
550        .map_err(|e| Error::AudioBackendError(format!("Failed to start stream: {}", e)))?;
551
552    Ok(RtAudioHandle {
553        _stream: Box::new(stream),
554        control,
555    })
556}
557
558#[cfg(test)]
559mod tests {
560    use super::*;
561
562    #[test]
563    fn interleaves_two_channels() {
564        // outputs[ch][frame]
565        let outputs = vec![vec![1.0, 2.0, 3.0], vec![-1.0, -2.0, -3.0]];
566        let mut out = vec![0.0; 6]; // 3 frames * 2 channels
567        interleave_outputs(&outputs, &mut out, 2);
568        assert_eq!(out, vec![1.0, -1.0, 2.0, -2.0, 3.0, -3.0]);
569    }
570
571    #[test]
572    fn channel_peak_is_max_abs_and_sanitizes_non_finite() {
573        assert_eq!(channel_peak(&[0.1, -0.5, 0.3]), 0.5);
574        assert_eq!(channel_peak(&[]), 0.0);
575        // NaN / inf are treated as 0 so they never poison the meter or the atomic.
576        assert_eq!(channel_peak(&[f32::NAN, 0.2, f32::INFINITY]), 0.2);
577    }
578
579    #[test]
580    fn nonneg_f32_bits_are_monotonic_so_fetch_max_is_float_max() {
581        // The level atomics rely on this: for non-negative finite floats, a < b implies
582        // a.to_bits() < b.to_bits(), so AtomicU32::fetch_max on the bit pattern is a float max.
583        let peaks = [0.0_f32, 1e-6, 0.01, 0.25, 0.5, 0.999, 1.0];
584        for w in peaks.windows(2) {
585            assert!(w[0].to_bits() < w[1].to_bits(), "{} vs {}", w[0], w[1]);
586        }
587    }
588
589    #[test]
590    fn ignores_extra_plugin_channels() {
591        // Plugin produced 3 channels but the device only has 2.
592        let outputs = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![9.0, 9.0]];
593        let mut out = vec![0.0; 4];
594        interleave_outputs(&outputs, &mut out, 2);
595        assert_eq!(out, vec![1.0, 3.0, 2.0, 4.0]);
596    }
597
598    #[test]
599    fn leaves_missing_channels_as_silence() {
600        // Device wants 2 channels but plugin produced only 1 (mono).
601        let outputs = vec![vec![0.5, 0.6]];
602        let mut out = vec![0.0; 4];
603        interleave_outputs(&outputs, &mut out, 2);
604        // ch1 stays at the pre-filled silence.
605        assert_eq!(out, vec![0.5, 0.0, 0.6, 0.0]);
606    }
607
608    #[test]
609    fn zero_channels_is_a_noop() {
610        let outputs = vec![vec![1.0, 2.0]];
611        let mut out = vec![7.0, 7.0];
612        interleave_outputs(&outputs, &mut out, 0);
613        assert_eq!(out, vec![7.0, 7.0]);
614    }
615
616    #[test]
617    fn prepare_scratch_resizes_and_clears() {
618        let mut scratch = AudioBuffers::new(1, 2, 4, 48000.0);
619        scratch.outputs[0][0] = 9.0;
620        prepare_scratch(&mut scratch, 8);
621        assert_eq!(scratch.block_size, 8);
622        assert!(scratch.outputs.iter().all(|c| c.len() == 8));
623        assert!(scratch.inputs.iter().all(|c| c.len() == 8));
624        assert!(scratch.outputs.iter().flatten().all(|&s| s == 0.0));
625    }
626}