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, TransportCommand},
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 { event: MidiEvent, offset: i32 },
33    Param { id: u32, value: f64 },
34    Transport(TransportCommand),
35    Panic,
36}
37
38/// Peak amplitude of one channel buffer, sanitizing non-finite samples to 0.
39fn channel_peak(buf: &[f32]) -> f32 {
40    buf.iter()
41        .map(|&x| if x.is_finite() { x.abs() } else { 0.0 })
42        .fold(0.0_f32, f32::max)
43}
44
45/// The audio-thread half of the lock-free side channels. Moved into the device callback; it
46/// drains queued control before processing and publishes feedback (peaks, output MIDI, GUI
47/// parameter changes) after. The control/feedback rings and the level atomics are lock-free;
48/// the only lock the callback takes is the plugin mutex it already needs (plus, via
49/// `get_parameter_changes`, the plugin's tiny internal component-handler mutex that its editor
50/// briefly touches on `performEdit` — bounded, not the UI-thread audio mutex).
51struct AudioSideChannels {
52    control_rx: Consumer<HybridCommand>,
53    out_midi_tx: Producer<MidiEvent>,
54    param_tx: Producer<(u32, f64)>,
55    /// One `AtomicU32` per output channel holding the max per-block peak (f32 bits) since the
56    /// UI last read it. Peaks are non-negative, so `fetch_max` on the bit pattern is a valid
57    /// float max.
58    levels: Arc<[AtomicU32]>,
59}
60
61impl AudioSideChannels {
62    /// Apply queued control commands to the plugin. The caller already holds the lock.
63    ///
64    /// Drains at most one ring's worth per block: a control thread pushing in a tight loop
65    /// refills the ring as fast as this drains it, so an unbounded loop would pin the audio
66    /// callback indefinitely. Anything still queued is applied on the next block.
67    fn apply_control(&mut self, plugin: &mut Plugin) {
68        crate::realtime::drain_commands(&mut self.control_rx, |command| match command {
69            HybridCommand::Midi { event, offset } => {
70                let _ = plugin.send_midi_event_at(event, offset);
71            }
72            HybridCommand::Param { id, value } => {
73                let _ = plugin.queue_processor_parameter_at(id, value, 0);
74            }
75            HybridCommand::Transport(change) => {
76                change.apply(plugin);
77            }
78            HybridCommand::Panic => {
79                let _ = plugin.midi_panic();
80            }
81        });
82    }
83
84    /// Publish per-channel output peaks into the atomics. Only meaningful after a successful
85    /// render, so the caller gates this on `process_audio` succeeding.
86    fn publish_levels(&mut self, outputs: &[Vec<f32>]) {
87        for (ch, atomic) in self.levels.iter().enumerate() {
88            let peak = outputs.get(ch).map(|b| channel_peak(b)).unwrap_or(0.0);
89            atomic.fetch_max(peak.to_bits(), Ordering::Relaxed);
90        }
91    }
92
93    /// Forward the plugin's drained output MIDI and editor parameter changes into their rings
94    /// (drop-on-full). Called every block **regardless of processing state**: the editor can
95    /// still emit parameter changes (and a plugin its output MIDI) while processing is stopped,
96    /// and the UI must stay in sync.
97    fn publish_feedback(&mut self, plugin: &Plugin) {
98        for event in plugin.take_output_midi() {
99            let _ = self.out_midi_tx.push(event);
100        }
101        for change in plugin.get_parameter_changes() {
102            let _ = self.param_tx.push(change);
103        }
104    }
105}
106
107/// The UI-thread half of the side channels, stored in [`AudioHandle`]. The rtrb endpoints need
108/// `&mut` for push/pop, so they live behind `Mutex` to expose `&self` methods; this mutex is
109/// only ever touched by the UI/control thread, never the audio callback.
110struct UiSideChannels {
111    // Shared (`Arc`) so a `Send` [`MidiSink`] can be cloned out and moved to another thread (e.g.
112    // a MIDI-input callback) while the non-`Send` `AudioHandle` stays put.
113    control_tx: Arc<Mutex<Producer<HybridCommand>>>,
114    out_midi_rx: Mutex<Consumer<MidiEvent>>,
115    param_rx: Mutex<Consumer<(u32, f64)>>,
116    levels: Arc<[AtomicU32]>,
117}
118
119/// Push a control command onto the shared lock-free ring, never blocking. Returns `false` if
120/// the ring is full (the command is dropped) or if the ring mutex was poisoned.
121///
122/// The mutex here is only ever contended between control threads — the audio callback holds
123/// the consumer end and never touches it.
124fn queue_command(tx: &Mutex<Producer<HybridCommand>>, command: HybridCommand) -> bool {
125    tx.lock()
126        .map(|mut tx| tx.push(command).is_ok())
127        .unwrap_or(false)
128}
129
130/// Build a fresh set of side channels for `channels` output channels, returning the audio-side
131/// half (move into the callback) and the UI-side half (store in the handle).
132fn make_side_channels(channels: usize) -> (AudioSideChannels, UiSideChannels) {
133    let (control_tx, control_rx) = RingBuffer::<HybridCommand>::new(SIDE_CHANNEL_CAPACITY);
134    let (out_midi_tx, out_midi_rx) = RingBuffer::<MidiEvent>::new(SIDE_CHANNEL_CAPACITY);
135    let (param_tx, param_rx) = RingBuffer::<(u32, f64)>::new(SIDE_CHANNEL_CAPACITY);
136    let levels: Arc<[AtomicU32]> = (0..channels).map(|_| AtomicU32::new(0)).collect();
137
138    let audio = AudioSideChannels {
139        control_rx,
140        out_midi_tx,
141        param_tx,
142        levels: Arc::clone(&levels),
143    };
144    let ui = UiSideChannels {
145        control_tx: Arc::new(Mutex::new(control_tx)),
146        out_midi_rx: Mutex::new(out_midi_rx),
147        param_rx: Mutex::new(param_rx),
148        levels,
149    };
150    (audio, ui)
151}
152
153/// A running audio stream driving a [`Plugin`].
154///
155/// Dropping the handle stops playback (the underlying device stream is released).
156/// While it lives, the plugin keeps running on the audio thread; use [`Self::lock`]
157/// to send MIDI or change parameters from your control thread.
158///
159/// # Thread affinity
160///
161/// `AudioHandle` owns the device stream, which backends make thread-affine (cpal's `Stream`
162/// is `!Send` for exactly this reason: open, control and *drop* must happen on one thread).
163/// So the handle is `!Send` and has to stay on the thread that started playback:
164///
165/// ```compile_fail
166/// fn assert_send<T: Send>() {}
167/// assert_send::<vst3_host::AudioHandle>(); // AudioHandle is deliberately not Send
168/// ```
169///
170/// To drive the plugin from another thread, move a [`MidiSink`] ([`Self::midi_sink`]) or the
171/// shared `Arc<Mutex<Plugin>>` ([`Self::plugin`]) there instead — both are `Send`.
172pub struct AudioHandle {
173    // Boxed as a trait object so `AudioHandle` is not generic over the backend.
174    // Kept solely to hold the stream open — dropping it stops audio.
175    _stream: Box<dyn AudioStream>,
176    // The capture stream for the duplex (effect-hosting) path; `None` for output-only play.
177    // Kept alive alongside `_stream`.
178    _input_stream: Option<Box<dyn AudioStream>>,
179    plugin: Arc<Mutex<Plugin>>,
180    // Lock-free side channels to/from the audio callback. Used for the hot path (control +
181    // per-frame feedback) so a UI thread never contends with the audio thread for the lock.
182    ui: UiSideChannels,
183}
184
185/// A cheap, cloneable, `Send` handle for queuing MIDI to a running plugin from another thread.
186///
187/// Obtained from [`AudioHandle::midi_sink`]. It holds only the (shared) lock-free command ring,
188/// not the device stream, so unlike [`AudioHandle`] it is `Send` and can be moved into a
189/// background thread or a MIDI input callback. Cloning is cheap (an `Arc` bump).
190///
191/// ```
192/// fn assert_send<T: Send>() {}
193/// assert_send::<vst3_host::MidiSink>();
194/// ```
195#[derive(Clone)]
196pub struct MidiSink {
197    control_tx: Arc<Mutex<Producer<HybridCommand>>>,
198}
199
200impl MidiSink {
201    /// Queue a MIDI event for the plugin, applied at the start of the next audio block.
202    ///
203    /// Lock-free and non-blocking (the same path as [`AudioHandle::send_midi`]). Returns `false`
204    /// if the command ring is full (the event is dropped).
205    pub fn send_midi(&self, event: MidiEvent) -> bool {
206        self.send_midi_at(event, 0)
207    }
208
209    /// Queue a MIDI event scheduled at `sample_offset` samples into the next block, for
210    /// sample-accurate sequencing. A negative offset is floored to `0`. Returns `false` if the
211    /// ring is full.
212    pub fn send_midi_at(&self, event: MidiEvent, sample_offset: i32) -> bool {
213        queue_command(
214            &self.control_tx,
215            HybridCommand::Midi {
216                event,
217                offset: sample_offset.max(0),
218            },
219        )
220    }
221}
222
223impl AudioHandle {
224    /// Lock the running plugin to send MIDI, change parameters, etc.
225    ///
226    /// Recovers automatically if the audio thread previously panicked while holding
227    /// the lock (poisoned mutex), so control calls keep working.
228    pub fn lock(&self) -> MutexGuard<'_, Plugin> {
229        self.plugin
230            .lock()
231            .unwrap_or_else(|poisoned| poisoned.into_inner())
232    }
233
234    /// Try to lock the plugin without blocking, returning `None` if the audio
235    /// callback currently holds the lock (it is held for the duration of each
236    /// `process_audio` call).
237    ///
238    /// Use this on a UI/render thread for best-effort, per-frame reads (VU
239    /// meters, output-MIDI drain, parameter sync): skipping a frame when the
240    /// audio thread is mid-block is invisible, and it keeps the UI thread from
241    /// stalling on the (unfair) mutex — which otherwise shows up as input lag.
242    pub fn try_lock(&self) -> Option<MutexGuard<'_, Plugin>> {
243        match self.plugin.try_lock() {
244            Ok(guard) => Some(guard),
245            Err(std::sync::TryLockError::Poisoned(p)) => Some(p.into_inner()),
246            Err(std::sync::TryLockError::WouldBlock) => None,
247        }
248    }
249
250    /// Queue a MIDI event for the plugin without locking the audio thread.
251    ///
252    /// The event is pushed onto a lock-free ring and applied at the start of the next audio
253    /// block. Prefer this over `lock().send_midi_event(..)` on a UI thread — it never blocks
254    /// on the audio mutex. Returns `false` if the ring is full (the event is dropped).
255    pub fn send_midi(&self, event: MidiEvent) -> bool {
256        self.send_midi_at(event, 0)
257    }
258
259    /// Queue a MIDI event scheduled at `sample_offset` samples into the next block, for
260    /// sample-accurate sequencing, without locking the audio thread. A negative offset is
261    /// floored to `0`. Returns `false` if the ring is full.
262    pub fn send_midi_at(&self, event: MidiEvent, sample_offset: i32) -> bool {
263        queue_command(
264            &self.ui.control_tx,
265            HybridCommand::Midi {
266                event,
267                offset: sample_offset.max(0),
268            },
269        )
270    }
271
272    /// Obtain a [`MidiSink`]: a cheap, cloneable, `Send` handle that can queue MIDI to this
273    /// running plugin from another thread.
274    ///
275    /// Unlike [`AudioHandle`] itself (which is not `Send`, as it owns the device stream), the
276    /// sink can be moved into a background thread or callback — e.g. a MIDI input device
277    /// callback (see [`crate::midi_input`]). It shares the same lock-free command ring as
278    /// [`Self::send_midi`].
279    pub fn midi_sink(&self) -> MidiSink {
280        MidiSink {
281            control_tx: Arc::clone(&self.ui.control_tx),
282        }
283    }
284
285    /// Queue a normalized parameter change without locking the audio thread; applied at the
286    /// start of the next block. `value` must be finite and within `0.0..=1.0` — an invalid
287    /// value is rejected here (returns `false`) rather than queued, so the caller learns about
288    /// it instead of the audio thread silently discarding it. Returns `false` if the ring is
289    /// full.
290    ///
291    /// # The editor catches up later
292    ///
293    /// The audio thread applies the value to the plugin's DSP, but `IEditController` belongs to
294    /// the main-thread domain, so the plugin's *own editor* (and
295    /// [`Plugin::get_parameter`](crate::Plugin::get_parameter),
296    /// [`format_parameter`](crate::Plugin::format_parameter) and saved state) is updated from
297    /// the control thread instead. That happens the next time the control thread touches the
298    /// plugin — reading a parameter, draining
299    /// [`Plugin::get_parameter_changes`](crate::Plugin::get_parameter_changes), or calling
300    /// [`Plugin::service_host_requests`](crate::Plugin::service_host_requests). A host that
301    /// polls the plugin every UI frame (the usual editor loop) never notices the gap; a host
302    /// that never calls back in will see a stale editor. The queue is bounded and drops its
303    /// oldest entry when full, so the newest value for a parameter always wins.
304    pub fn set_parameter(&self, id: u32, value: f64) -> bool {
305        crate::realtime::is_normalized(value)
306            && queue_command(&self.ui.control_tx, HybridCommand::Param { id, value })
307    }
308
309    /// Queue a transport tempo change (BPM) without locking the audio thread; applied at the
310    /// start of the next block. `bpm` must be finite and greater than `0` (an invalid value is
311    /// rejected, returning `false`). Returns `false` if the ring is full.
312    pub fn set_tempo(&self, bpm: f64) -> bool {
313        if !(bpm.is_finite() && bpm > 0.0) {
314            return false;
315        }
316        queue_command(
317            &self.ui.control_tx,
318            HybridCommand::Transport(TransportCommand::Tempo(bpm)),
319        )
320    }
321
322    /// Queue a transport time-signature change without locking the audio thread; applied at the
323    /// start of the next block. `denominator` must be one of `1, 2, 4, 8, 16` and `numerator`
324    /// must be positive (an invalid value is rejected, returning `false`). Returns `false` if
325    /// the ring is full.
326    pub fn set_time_signature(&self, numerator: i32, denominator: i32) -> bool {
327        if numerator <= 0 || !matches!(denominator, 1 | 2 | 4 | 8 | 16) {
328            return false;
329        }
330        queue_command(
331            &self.ui.control_tx,
332            HybridCommand::Transport(TransportCommand::TimeSignature(numerator, denominator)),
333        )
334    }
335
336    /// Queue a transport playing-state toggle without locking the audio thread; applied at the
337    /// start of the next block. Returns `false` if the ring is full.
338    pub fn set_playing(&self, playing: bool) -> bool {
339        queue_command(
340            &self.ui.control_tx,
341            HybridCommand::Transport(TransportCommand::Playing(playing)),
342        )
343    }
344
345    /// Queue an all-notes-off "panic" (CC 123/120/121 on every channel) without locking the
346    /// audio thread. Returns `false` if the ring is full.
347    pub fn midi_panic(&self) -> bool {
348        queue_command(&self.ui.control_tx, HybridCommand::Panic)
349    }
350
351    /// Read the latest per-channel output peak levels without locking the audio thread.
352    ///
353    /// Each channel reports the maximum peak observed since the previous call (the read resets
354    /// the accumulator), so polling at UI frame rate never misses a transient between frames.
355    /// `rms` is not tracked on this path and is reported as 0; `peak_hold` mirrors `peak`
356    /// (drive your own ballistics, e.g. [`crate::audio::PeakMeter`], from the peak).
357    pub fn output_levels(&self) -> AudioLevels {
358        let channels = self
359            .ui
360            .levels
361            .iter()
362            .map(|atomic| {
363                let peak = f32::from_bits(atomic.swap(0, Ordering::Relaxed));
364                ChannelLevel {
365                    peak,
366                    rms: 0.0,
367                    peak_hold: peak,
368                }
369            })
370            .collect();
371        AudioLevels { channels }
372    }
373
374    /// Drain MIDI the plugin emitted during processing (arpeggiators, MPE, …) without locking
375    /// the audio thread. Returns the events queued since the last call.
376    pub fn drain_output_midi(&self) -> Vec<MidiEvent> {
377        let mut out = Vec::new();
378        if let Ok(mut rx) = self.ui.out_midi_rx.lock() {
379            while let Ok(event) = rx.pop() {
380                out.push(event);
381            }
382        }
383        out
384    }
385
386    /// Drain parameter changes the plugin made through its own editor without locking the
387    /// audio thread. Returns `(id, normalized_value)` pairs queued since the last call.
388    pub fn drain_parameter_changes(&self) -> Vec<(u32, f64)> {
389        let mut out = Vec::new();
390        if let Ok(mut rx) = self.ui.param_rx.lock() {
391            while let Ok(change) = rx.pop() {
392                out.push(change);
393            }
394        }
395        out
396    }
397
398    /// A shared handle to the plugin, e.g. to move into another thread.
399    pub fn plugin(&self) -> Arc<Mutex<Plugin>> {
400        Arc::clone(&self.plugin)
401    }
402
403    /// Stop playback now (equivalent to dropping the handle).
404    pub fn stop(self) {}
405}
406
407/// Interleave per-channel plugin output into a device's interleaved buffer.
408///
409/// `out` is laid out as `[frame0_ch0, frame0_ch1, ..., frame1_ch0, ...]` with
410/// `out.len() == frames * channels`. Channels the plugin didn't produce are left
411/// untouched (callers should pre-fill `out` with silence); plugin channels beyond
412/// `channels` are ignored.
413pub(crate) fn interleave_outputs(outputs: &[Vec<f32>], out: &mut [f32], channels: usize) {
414    if channels == 0 {
415        return;
416    }
417    let frames = out.len() / channels;
418    for ch in 0..channels.min(outputs.len()) {
419        let src = &outputs[ch];
420        for frame in 0..frames.min(src.len()) {
421            out[frame * channels + ch] = src[frame];
422        }
423    }
424}
425
426/// Push interleaved capture frames onto the duplex bridge ring, **whole frames only**.
427///
428/// The ring carries interleaved samples but its meaning is frame-major: sample *n* belongs to
429/// channel `n % channels`. Dropping an individual sample when the ring is full would shift
430/// every later sample by one channel — stereo would come out L/R-swapped for the rest of the
431/// stream, with nothing to resync it. So a frame is either pushed in full or dropped in full.
432///
433/// Returns the number of frames pushed. `data` is interleaved; a trailing partial frame (a
434/// device handing over a non-multiple of `channels`) is ignored.
435fn push_capture_frames(producer: &mut Producer<f32>, data: &[f32], channels: usize) -> usize {
436    if channels == 0 {
437        return 0;
438    }
439    // `slots()` is a conservative estimate (never an over-count), so every push below fits.
440    let room_frames = producer.slots() / channels;
441    let mut pushed = 0;
442    for frame in data.chunks_exact(channels).take(room_frames) {
443        for &sample in frame {
444            let _ = producer.push(sample);
445        }
446        pushed += 1;
447    }
448    pushed
449}
450
451/// Pop whole interleaved frames off the duplex bridge ring into per-channel buffers.
452///
453/// Mirrors [`push_capture_frames`]: consuming a partial frame (the producer may be mid-frame
454/// when this runs) would leave the ring's head on a non-channel-0 sample and permanently swap
455/// the channel assignment. Frames beyond what the ring can supply are left untouched — the
456/// caller has already cleared the buffers, so an underrun reads as silence.
457///
458/// Returns the number of frames written.
459fn pop_capture_frames(
460    consumer: &mut Consumer<f32>,
461    inputs: &mut [Vec<f32>],
462    frames: usize,
463) -> usize {
464    let channels = inputs.len();
465    if channels == 0 {
466        return 0;
467    }
468    let available = (consumer.slots() / channels).min(frames);
469    for f in 0..available {
470        for ch in inputs.iter_mut() {
471            let sample = consumer.pop().unwrap_or(0.0);
472            if let Some(slot) = ch.get_mut(f) {
473                *slot = sample;
474            }
475        }
476    }
477    available
478}
479
480/// Capacity, in interleaved samples, of the duplex input→output bridge ring: about eight
481/// device blocks of headroom so the two independent device clocks don't starve each other.
482///
483/// Sized in **frames** and multiplied up, so the capacity is always a whole number of frames —
484/// one that wasn't would strand a partial frame at the wrap point, exactly the split the
485/// push/pop pair goes out of its way to avoid. Bounded at both ends: a floor so a tiny block
486/// size still buys real headroom, and a ceiling so an absurd configured block size can't ask
487/// for a multi-gigabyte allocation.
488fn bridge_ring_capacity(block_size: usize, channels: usize) -> usize {
489    const MIN_BRIDGE_FRAMES: usize = 1024;
490    const MAX_BRIDGE_FRAMES: usize = 1 << 18; // ~5.5 s at 48 kHz
491    let frames = block_size
492        .saturating_mul(8)
493        .clamp(MIN_BRIDGE_FRAMES, MAX_BRIDGE_FRAMES);
494    frames.saturating_mul(channels.max(1))
495}
496
497/// Resize a scratch buffer's output channels to exactly `frames`, clearing them.
498fn prepare_scratch(scratch: &mut AudioBuffers, frames: usize) {
499    for ch in &mut scratch.outputs {
500        if ch.len() != frames {
501            ch.resize(frames, 0.0);
502        }
503        ch.fill(0.0);
504    }
505    for ch in &mut scratch.inputs {
506        if ch.len() != frames {
507            ch.resize(frames, 0.0);
508        }
509        ch.fill(0.0);
510    }
511    scratch.block_size = frames;
512}
513
514/// Start streaming `plugin` through `backend`'s default output device.
515///
516/// The plugin is moved behind a shared lock so it can keep being controlled while
517/// the audio thread pulls blocks. Playback starts immediately and continues until
518/// the returned [`AudioHandle`] is dropped.
519///
520/// `config.output_channels` and `config.sample_rate` define the stream; the device
521/// callback may request varying block sizes, which the bridge accommodates.
522pub fn play_with_backend<B: AudioBackend>(
523    backend: &B,
524    plugin: Plugin,
525    config: AudioConfig,
526) -> Result<AudioHandle> {
527    let device = backend
528        .default_output_device()
529        .ok_or_else(|| Error::AudioBackendError("No default output device available".into()))?;
530
531    let channels = config.output_channels;
532    let sample_rate = config.sample_rate;
533
534    let plugin = Arc::new(Mutex::new(plugin));
535    // Ensure the plugin is armed before the first callback fires.
536    plugin
537        .lock()
538        .unwrap_or_else(|p| p.into_inner())
539        .start_processing()?;
540
541    let plugin_cb = Arc::clone(&plugin);
542    // Lock-free side channels: UI control in, feedback (peaks / output MIDI / param changes) out.
543    let (mut side, ui) = make_side_channels(channels);
544    // Reusable scratch buffer so the steady-state callback does not allocate.
545    let mut scratch = AudioBuffers::new(0, channels, config.block_size, sample_rate);
546
547    let data_cb = Box::new(move |data: &mut [f32]| {
548        // Start from silence so unproduced channels/frames are quiet.
549        data.fill(0.0);
550        if channels == 0 {
551            return;
552        }
553        let frames = data.len() / channels;
554        prepare_scratch(&mut scratch, frames);
555
556        // Recover from poison so queued control keeps flowing even after an audio-thread panic
557        // (matches AudioHandle::lock): the callback re-attempts processing rather than going
558        // permanently silent.
559        let mut p = match plugin_cb.lock() {
560            Ok(guard) => guard,
561            Err(poisoned) => poisoned.into_inner(),
562        };
563        // Apply queued control before rendering; render; then forward feedback. Levels need a
564        // successful render, but MIDI/param feedback is published even when stopped so the UI
565        // stays in sync.
566        side.apply_control(&mut p);
567        if p.process_audio(&mut scratch).is_ok() {
568            interleave_outputs(&scratch.outputs, data, channels);
569            side.publish_levels(&scratch.outputs);
570        }
571        side.publish_feedback(&p);
572    });
573
574    let err_cb = Box::new(|e: B::Error| {
575        log::error!("audio stream error: {}", e);
576    });
577
578    let stream = backend
579        .create_output_stream(&device, config, data_cb, err_cb)
580        .map_err(|e| Error::AudioBackendError(format!("Failed to create output stream: {}", e)))?;
581
582    stream
583        .play()
584        .map_err(|e| Error::AudioBackendError(format!("Failed to start stream: {}", e)))?;
585
586    Ok(AudioHandle {
587        _stream: Box::new(stream),
588        _input_stream: None,
589        plugin,
590        ui,
591    })
592}
593
594/// Drive a plugin with **live audio input** (effect hosting): capture from the default input
595/// device, process it through the plugin, and play the result on the default output device.
596///
597/// cpal has no true duplex stream, so this opens a separate input and output stream bridged
598/// by a lock-free ring: the input callback pushes captured frames, the output callback pops
599/// them into the plugin's input buffers, processes, and writes the output. `config`'s
600/// `input_channels`/`output_channels`/`sample_rate` define the streams. Like
601/// [`play_with_backend`], control the plugin via the returned [`AudioHandle`].
602///
603/// Note: the two device clocks are independent; this uses a small bridge buffer and tolerates
604/// drift by dropping/zero-filling at the edges. Suitable for monitoring/auditioning effects.
605pub fn play_with_input_backend<B: AudioBackend>(
606    backend: &B,
607    plugin: Plugin,
608    config: AudioConfig,
609) -> Result<AudioHandle> {
610    let in_device = backend
611        .default_input_device()
612        .ok_or_else(|| Error::AudioBackendError("No default input device available".into()))?;
613    let out_device = backend
614        .default_output_device()
615        .ok_or_else(|| Error::AudioBackendError("No default output device available".into()))?;
616
617    let in_channels = config.input_channels.max(1);
618    let out_channels = config.output_channels;
619    let sample_rate = config.sample_rate;
620
621    let plugin = Arc::new(Mutex::new(plugin));
622    plugin
623        .lock()
624        .unwrap_or_else(|p| p.into_inner())
625        .start_processing()?;
626
627    // SPSC bridge: input callback (producer) -> output callback (consumer). Hold a few
628    // blocks of interleaved input so the independent device clocks don't starve immediately.
629    let (mut producer, mut consumer) =
630        rtrb::RingBuffer::<f32>::new(bridge_ring_capacity(config.block_size, in_channels));
631
632    let in_data_cb = Box::new(move |data: &[f32]| {
633        // Drop on full (output side fell behind) rather than block the capture callback —
634        // whole frames at a time, so overflow never shifts the interleave boundary.
635        push_capture_frames(&mut producer, data, in_channels);
636    });
637    let in_err_cb = Box::new(|e: B::Error| log::error!("input stream error: {}", e));
638    let input_stream = backend
639        .create_input_stream(&in_device, config, in_data_cb, in_err_cb)
640        .map_err(|e| Error::AudioBackendError(format!("Failed to create input stream: {}", e)))?;
641
642    let plugin_cb = Arc::clone(&plugin);
643    // Lock-free side channels (same as the output-only path) so effect hosting is also
644    // controllable without locking the audio thread.
645    let (mut side, ui) = make_side_channels(out_channels);
646    let mut scratch = AudioBuffers::new(in_channels, out_channels, config.block_size, sample_rate);
647    let out_data_cb = Box::new(move |data: &mut [f32]| {
648        data.fill(0.0);
649        if out_channels == 0 {
650            return;
651        }
652        let frames = data.len() / out_channels;
653        prepare_scratch(&mut scratch, frames);
654        // Deinterleave captured input from the ring into the plugin's input buffers
655        // (interleaved frame-major order matches the input callback's push order). Frames the
656        // ring can't supply stay at the silence `prepare_scratch` just wrote.
657        pop_capture_frames(&mut consumer, &mut scratch.inputs, frames);
658        let mut p = match plugin_cb.lock() {
659            Ok(guard) => guard,
660            Err(poisoned) => poisoned.into_inner(),
661        };
662        side.apply_control(&mut p);
663        if p.process_audio(&mut scratch).is_ok() {
664            interleave_outputs(&scratch.outputs, data, out_channels);
665            side.publish_levels(&scratch.outputs);
666        }
667        side.publish_feedback(&p);
668    });
669    let out_err_cb = Box::new(|e: B::Error| log::error!("output stream error: {}", e));
670    let output_stream = backend
671        .create_output_stream(&out_device, config, out_data_cb, out_err_cb)
672        .map_err(|e| Error::AudioBackendError(format!("Failed to create output stream: {}", e)))?;
673
674    input_stream
675        .play()
676        .map_err(|e| Error::AudioBackendError(format!("Failed to start input stream: {}", e)))?;
677    output_stream
678        .play()
679        .map_err(|e| Error::AudioBackendError(format!("Failed to start output stream: {}", e)))?;
680
681    Ok(AudioHandle {
682        _stream: Box::new(output_stream),
683        _input_stream: Some(Box::new(input_stream)),
684        plugin,
685        ui,
686    })
687}
688
689/// A running real-time audio stream (the [`RealtimePluginRunner`] variant of
690/// [`AudioHandle`]). Holds the device stream open and exposes the lock-free [`RtControl`];
691/// dropping it stops playback.
692///
693/// Like [`AudioHandle`], it owns the thread-affine device stream and is therefore `!Send`.
694/// Build the runner and its [`RtControl`] yourself ([`RealtimePluginRunner::new`]) if you need
695/// the control half on a different thread than the stream.
696pub struct RtAudioHandle {
697    _stream: Box<dyn AudioStream>,
698    control: RtControl,
699}
700
701impl RtAudioHandle {
702    /// The lock-free control handle — queue MIDI and parameter changes without locking the
703    /// audio thread.
704    pub fn control(&mut self) -> &mut RtControl {
705        &mut self.control
706    }
707
708    /// Stop playback now (equivalent to dropping the handle).
709    pub fn stop(self) {}
710}
711
712/// Like [`play_with_backend`], but drives the plugin through a [`RealtimePluginRunner`] so the
713/// audio callback takes **no lock** — control changes flow over a lock-free queue. Returns an
714/// [`RtAudioHandle`] that keeps the stream alive and exposes the [`RtControl`].
715///
716/// `command_capacity` bounds how many MIDI/parameter commands can queue between callbacks.
717pub fn play_realtime_with_backend<B: AudioBackend>(
718    backend: &B,
719    plugin: Plugin,
720    config: AudioConfig,
721    command_capacity: usize,
722) -> Result<RtAudioHandle> {
723    let device = backend
724        .default_output_device()
725        .ok_or_else(|| Error::AudioBackendError("No default output device available".into()))?;
726
727    let channels = config.output_channels;
728    let sample_rate = config.sample_rate;
729
730    let (mut runner, control) = RealtimePluginRunner::new(plugin, command_capacity);
731    runner.start()?;
732
733    // Reusable scratch buffer so the steady-state callback does not allocate.
734    let mut scratch = AudioBuffers::new(0, channels, config.block_size, sample_rate);
735
736    let data_cb = Box::new(move |data: &mut [f32]| {
737        data.fill(0.0);
738        if channels == 0 {
739            return;
740        }
741        let frames = data.len() / channels;
742        prepare_scratch(&mut scratch, frames);
743
744        // No lock: the runner owns the plugin and drains its command queue here.
745        if runner.process(&mut scratch).is_ok() {
746            interleave_outputs(&scratch.outputs, data, channels);
747        }
748    });
749
750    let err_cb = Box::new(|e: B::Error| {
751        log::error!("audio stream error: {}", e);
752    });
753
754    let stream = backend
755        .create_output_stream(&device, config, data_cb, err_cb)
756        .map_err(|e| Error::AudioBackendError(format!("Failed to create output stream: {}", e)))?;
757
758    stream
759        .play()
760        .map_err(|e| Error::AudioBackendError(format!("Failed to start stream: {}", e)))?;
761
762    Ok(RtAudioHandle {
763        _stream: Box::new(stream),
764        control,
765    })
766}
767
768#[cfg(test)]
769mod tests {
770    use super::*;
771
772    #[test]
773    fn interleaves_two_channels() {
774        // outputs[ch][frame]
775        let outputs = vec![vec![1.0, 2.0, 3.0], vec![-1.0, -2.0, -3.0]];
776        let mut out = vec![0.0; 6]; // 3 frames * 2 channels
777        interleave_outputs(&outputs, &mut out, 2);
778        assert_eq!(out, vec![1.0, -1.0, 2.0, -2.0, 3.0, -3.0]);
779    }
780
781    #[test]
782    fn hybrid_midi_carries_sample_offset() {
783        // The control ring (and apply_control) must preserve the scheduled offset so the mutex
784        // playback path schedules MIDI sample-accurately, like the lock-free path.
785        let (mut tx, mut rx) = RingBuffer::<HybridCommand>::new(4);
786        tx.push(HybridCommand::Midi {
787            event: MidiEvent::NoteOn {
788                channel: crate::midi::MidiChannel::Ch1,
789                note: 60,
790                velocity: 100,
791            },
792            offset: 200,
793        })
794        .expect("queue scheduled note");
795        match rx.pop().expect("note queued") {
796            HybridCommand::Midi { offset, .. } => assert_eq!(offset, 200),
797            _ => panic!("expected a MIDI command"),
798        }
799    }
800
801    #[test]
802    fn channel_peak_is_max_abs_and_sanitizes_non_finite() {
803        assert_eq!(channel_peak(&[0.1, -0.5, 0.3]), 0.5);
804        assert_eq!(channel_peak(&[]), 0.0);
805        // NaN / inf are treated as 0 so they never poison the meter or the atomic.
806        assert_eq!(channel_peak(&[f32::NAN, 0.2, f32::INFINITY]), 0.2);
807    }
808
809    #[test]
810    fn nonneg_f32_bits_are_monotonic_so_fetch_max_is_float_max() {
811        // The level atomics rely on this: for non-negative finite floats, a < b implies
812        // a.to_bits() < b.to_bits(), so AtomicU32::fetch_max on the bit pattern is a float max.
813        let peaks = [0.0_f32, 1e-6, 0.01, 0.25, 0.5, 0.999, 1.0];
814        for w in peaks.windows(2) {
815            assert!(w[0].to_bits() < w[1].to_bits(), "{} vs {}", w[0], w[1]);
816        }
817    }
818
819    #[test]
820    fn ignores_extra_plugin_channels() {
821        // Plugin produced 3 channels but the device only has 2.
822        let outputs = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![9.0, 9.0]];
823        let mut out = vec![0.0; 4];
824        interleave_outputs(&outputs, &mut out, 2);
825        assert_eq!(out, vec![1.0, 3.0, 2.0, 4.0]);
826    }
827
828    #[test]
829    fn leaves_missing_channels_as_silence() {
830        // Device wants 2 channels but plugin produced only 1 (mono).
831        let outputs = vec![vec![0.5, 0.6]];
832        let mut out = vec![0.0; 4];
833        interleave_outputs(&outputs, &mut out, 2);
834        // ch1 stays at the pre-filled silence.
835        assert_eq!(out, vec![0.5, 0.0, 0.6, 0.0]);
836    }
837
838    #[test]
839    fn zero_channels_is_a_noop() {
840        let outputs = vec![vec![1.0, 2.0]];
841        let mut out = vec![7.0, 7.0];
842        interleave_outputs(&outputs, &mut out, 0);
843        assert_eq!(out, vec![7.0, 7.0]);
844    }
845
846    /// Push more than the ring holds, then drain it: every frame that survived must still be a
847    /// whole frame. Dropping a single *sample* on overflow shifts the interleave boundary, and
848    /// the ring never resyncs — stereo comes out L/R-swapped for the rest of the stream.
849    #[test]
850    fn bridge_overflow_drops_whole_frames_never_splits_one() {
851        const CH: usize = 2;
852        let (mut producer, mut consumer) = RingBuffer::<f32>::new(4 * CH);
853
854        // 8 stereo frames into a 4-frame ring: frame f is (f, -f) so a swap is visible.
855        let data: Vec<f32> = (0..8).flat_map(|f| [f as f32, -(f as f32)]).collect();
856        let pushed = push_capture_frames(&mut producer, &data, CH);
857        assert_eq!(pushed, 4, "only the frames that fit are pushed");
858
859        let mut inputs = vec![vec![0.0f32; 8], vec![0.0f32; 8]];
860        let popped = pop_capture_frames(&mut consumer, &mut inputs, 8);
861        assert_eq!(popped, 4);
862        // Left channel holds the non-negative half of each frame, right the negative half —
863        // i.e. no frame was split. Frames past the underrun stay at the pre-cleared silence.
864        assert_eq!(inputs[0], vec![0.0, 1.0, 2.0, 3.0, 0.0, 0.0, 0.0, 0.0]);
865        assert_eq!(inputs[1], vec![-0.0, -1.0, -2.0, -3.0, 0.0, 0.0, 0.0, 0.0]);
866    }
867
868    /// After an overflow the bridge must still be frame-aligned for every later block: this is
869    /// the permanent-channel-swap regression, observed across successive push/pop rounds.
870    #[test]
871    fn bridge_stays_frame_aligned_across_overflow_rounds() {
872        const CH: usize = 2;
873        let (mut producer, mut consumer) = RingBuffer::<f32>::new(4 * CH);
874
875        for round in 0..5 {
876            // Over-produce every round so the ring is permanently in overflow.
877            let base = round * 100;
878            let data: Vec<f32> = (0..6)
879                .flat_map(|f| [(base + f) as f32, -((base + f) as f32)])
880                .collect();
881            push_capture_frames(&mut producer, &data, CH);
882
883            let mut inputs = vec![vec![0.0f32; 3], vec![0.0f32; 3]];
884            let popped = pop_capture_frames(&mut consumer, &mut inputs, 3);
885            for f in 0..popped {
886                assert_eq!(
887                    inputs[1][f], -inputs[0][f],
888                    "round {round} frame {f} lost channel alignment: {:?}",
889                    inputs
890                );
891            }
892        }
893    }
894
895    /// A partial trailing frame (a device handing over a non-multiple of the channel count) is
896    /// ignored rather than half-pushed, for the same alignment reason.
897    #[test]
898    fn bridge_ignores_a_partial_trailing_frame() {
899        const CH: usize = 3;
900        let (mut producer, mut consumer) = RingBuffer::<f32>::new(8 * CH);
901        // Two whole frames plus two stray samples.
902        let data = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
903        assert_eq!(push_capture_frames(&mut producer, &data, CH), 2);
904        assert_eq!(consumer.slots() % CH, 0, "ring holds whole frames only");
905
906        let mut inputs = vec![vec![0.0f32; 2], vec![0.0f32; 2], vec![0.0f32; 2]];
907        assert_eq!(pop_capture_frames(&mut consumer, &mut inputs, 2), 2);
908        assert_eq!(inputs[0], vec![1.0, 4.0]);
909        assert_eq!(inputs[1], vec![2.0, 5.0]);
910        assert_eq!(inputs[2], vec![3.0, 6.0]);
911    }
912
913    /// The consumer must not take a partial frame either: with an odd number of samples parked
914    /// in the ring (the producer caught mid-frame), popping sample-by-sample would leave the
915    /// head on a channel-1 sample and swap every later frame.
916    #[test]
917    fn bridge_consumer_leaves_an_incomplete_frame_alone() {
918        const CH: usize = 2;
919        let (mut producer, mut consumer) = RingBuffer::<f32>::new(4 * CH);
920        // Simulate the producer being pre-empted between the two samples of a frame.
921        producer.push(1.0).expect("room for the first sample");
922
923        let mut inputs = vec![vec![0.0f32; 2], vec![0.0f32; 2]];
924        assert_eq!(pop_capture_frames(&mut consumer, &mut inputs, 2), 0);
925        assert_eq!(consumer.slots(), 1, "the half frame is still queued");
926
927        // Once the frame is complete it is consumed as a unit, channel 0 first.
928        producer.push(-1.0).expect("room for the second sample");
929        assert_eq!(pop_capture_frames(&mut consumer, &mut inputs, 2), 1);
930        assert_eq!(inputs[0][0], 1.0);
931        assert_eq!(inputs[1][0], -1.0);
932    }
933
934    /// The bridge ring must hold a whole number of frames, or a partial frame is stranded at
935    /// the wrap point. `(block_size * in_channels * 8).max(2048)` did not: with 3 channels and
936    /// a small block it clamped to 2048, which is not a multiple of 3.
937    #[test]
938    fn bridge_capacity_is_a_whole_number_of_frames() {
939        for block_size in [0usize, 1, 32, 64, 128, 512, 1024, usize::MAX] {
940            for channels in 1..=8usize {
941                let cap = bridge_ring_capacity(block_size, channels);
942                assert_eq!(
943                    cap % channels,
944                    0,
945                    "block {block_size} x {channels} ch: capacity {cap} splits a frame"
946                );
947                assert!(
948                    cap >= channels,
949                    "block {block_size} x {channels} ch: capacity {cap} holds no frame"
950                );
951            }
952        }
953        // The old expression, for contrast: `(32 * 3 * 8).max(2048)` clamps up to 2048 samples,
954        // which across 3 channels is 682.67 frames — the ring wraps mid-frame.
955        assert_ne!(2048 % 3, 0);
956    }
957
958    /// A zero channel count would make the frame arithmetic divide by zero.
959    #[test]
960    fn bridge_capacity_survives_zero_channels() {
961        assert!(bridge_ring_capacity(512, 0) > 0);
962    }
963
964    /// `queue_command` is the single push path behind every `AudioHandle` / `MidiSink` control
965    /// method: never blocking, dropping on a full ring and reporting that as `false`.
966    #[test]
967    fn queue_command_drops_on_a_full_ring() {
968        let (tx, mut rx) = RingBuffer::<HybridCommand>::new(2);
969        let tx = Mutex::new(tx);
970        assert!(queue_command(&tx, HybridCommand::Panic));
971        assert!(queue_command(&tx, HybridCommand::Panic));
972        assert!(!queue_command(&tx, HybridCommand::Panic), "ring is full");
973        assert_eq!(rx.slots(), 2);
974        assert!(rx.pop().is_ok());
975    }
976
977    #[test]
978    fn prepare_scratch_resizes_and_clears() {
979        let mut scratch = AudioBuffers::new(1, 2, 4, 48000.0);
980        scratch.outputs[0][0] = 9.0;
981        prepare_scratch(&mut scratch, 8);
982        assert_eq!(scratch.block_size, 8);
983        assert!(scratch.outputs.iter().all(|c| c.len() == 8));
984        assert!(scratch.inputs.iter().all(|c| c.len() == 8));
985        assert!(scratch.outputs.iter().flatten().all(|&s| s == 0.0));
986    }
987}