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 all queued control commands to the plugin. The caller already holds the lock.
63    fn apply_control(&mut self, plugin: &mut Plugin) {
64        while let Ok(cmd) = self.control_rx.pop() {
65            match cmd {
66                HybridCommand::Midi { event, offset } => {
67                    let _ = plugin.send_midi_event_at(event, offset);
68                }
69                HybridCommand::Param { id, value } => {
70                    let _ = plugin.set_parameter(id, value);
71                }
72                HybridCommand::Transport(change) => {
73                    change.apply(plugin);
74                }
75                HybridCommand::Panic => {
76                    let _ = plugin.midi_panic();
77                }
78            }
79        }
80    }
81
82    /// Publish per-channel output peaks into the atomics. Only meaningful after a successful
83    /// render, so the caller gates this on `process_audio` succeeding.
84    fn publish_levels(&mut self, outputs: &[Vec<f32>]) {
85        for (ch, atomic) in self.levels.iter().enumerate() {
86            let peak = outputs.get(ch).map(|b| channel_peak(b)).unwrap_or(0.0);
87            atomic.fetch_max(peak.to_bits(), Ordering::Relaxed);
88        }
89    }
90
91    /// Forward the plugin's drained output MIDI and editor parameter changes into their rings
92    /// (drop-on-full). Called every block **regardless of processing state**: the editor can
93    /// still emit parameter changes (and a plugin its output MIDI) while processing is stopped,
94    /// and the UI must stay in sync.
95    fn publish_feedback(&mut self, plugin: &Plugin) {
96        for event in plugin.take_output_midi() {
97            let _ = self.out_midi_tx.push(event);
98        }
99        for change in plugin.get_parameter_changes() {
100            let _ = self.param_tx.push(change);
101        }
102    }
103}
104
105/// The UI-thread half of the side channels, stored in [`AudioHandle`]. The rtrb endpoints need
106/// `&mut` for push/pop, so they live behind `Mutex` to expose `&self` methods; this mutex is
107/// only ever touched by the UI/control thread, never the audio callback.
108struct UiSideChannels {
109    // Shared (`Arc`) so a `Send` [`MidiSink`] can be cloned out and moved to another thread (e.g.
110    // a MIDI-input callback) while the non-`Send` `AudioHandle` stays put.
111    control_tx: Arc<Mutex<Producer<HybridCommand>>>,
112    out_midi_rx: Mutex<Consumer<MidiEvent>>,
113    param_rx: Mutex<Consumer<(u32, f64)>>,
114    levels: Arc<[AtomicU32]>,
115}
116
117/// Build a fresh set of side channels for `channels` output channels, returning the audio-side
118/// half (move into the callback) and the UI-side half (store in the handle).
119fn make_side_channels(channels: usize) -> (AudioSideChannels, UiSideChannels) {
120    let (control_tx, control_rx) = RingBuffer::<HybridCommand>::new(SIDE_CHANNEL_CAPACITY);
121    let (out_midi_tx, out_midi_rx) = RingBuffer::<MidiEvent>::new(SIDE_CHANNEL_CAPACITY);
122    let (param_tx, param_rx) = RingBuffer::<(u32, f64)>::new(SIDE_CHANNEL_CAPACITY);
123    let levels: Arc<[AtomicU32]> = (0..channels).map(|_| AtomicU32::new(0)).collect();
124
125    let audio = AudioSideChannels {
126        control_rx,
127        out_midi_tx,
128        param_tx,
129        levels: Arc::clone(&levels),
130    };
131    let ui = UiSideChannels {
132        control_tx: Arc::new(Mutex::new(control_tx)),
133        out_midi_rx: Mutex::new(out_midi_rx),
134        param_rx: Mutex::new(param_rx),
135        levels,
136    };
137    (audio, ui)
138}
139
140/// A running audio stream driving a [`Plugin`].
141///
142/// Dropping the handle stops playback (the underlying device stream is released).
143/// While it lives, the plugin keeps running on the audio thread; use [`Self::lock`]
144/// to send MIDI or change parameters from your control thread.
145pub struct AudioHandle {
146    // Boxed as a trait object so `AudioHandle` is not generic over the backend.
147    // Kept solely to hold the stream open — dropping it stops audio.
148    _stream: Box<dyn AudioStream>,
149    // The capture stream for the duplex (effect-hosting) path; `None` for output-only play.
150    // Kept alive alongside `_stream`.
151    _input_stream: Option<Box<dyn AudioStream>>,
152    plugin: Arc<Mutex<Plugin>>,
153    // Lock-free side channels to/from the audio callback. Used for the hot path (control +
154    // per-frame feedback) so a UI thread never contends with the audio thread for the lock.
155    ui: UiSideChannels,
156}
157
158/// A cheap, cloneable, `Send` handle for queuing MIDI to a running plugin from another thread.
159///
160/// Obtained from [`AudioHandle::midi_sink`]. It holds only the (shared) lock-free command ring,
161/// not the device stream, so unlike [`AudioHandle`] it is `Send` and can be moved into a
162/// background thread or a MIDI input callback. Cloning is cheap (an `Arc` bump).
163#[derive(Clone)]
164pub struct MidiSink {
165    control_tx: Arc<Mutex<Producer<HybridCommand>>>,
166}
167
168impl MidiSink {
169    /// Queue a MIDI event for the plugin, applied at the start of the next audio block.
170    ///
171    /// Lock-free and non-blocking (the same path as [`AudioHandle::send_midi`]). Returns `false`
172    /// if the command ring is full (the event is dropped).
173    pub fn send_midi(&self, event: MidiEvent) -> bool {
174        self.send_midi_at(event, 0)
175    }
176
177    /// Queue a MIDI event scheduled at `sample_offset` samples into the next block, for
178    /// sample-accurate sequencing. A negative offset is floored to `0`. Returns `false` if the
179    /// ring is full.
180    pub fn send_midi_at(&self, event: MidiEvent, sample_offset: i32) -> bool {
181        self.control_tx
182            .lock()
183            .map(|mut tx| {
184                tx.push(HybridCommand::Midi {
185                    event,
186                    offset: sample_offset.max(0),
187                })
188                .is_ok()
189            })
190            .unwrap_or(false)
191    }
192}
193
194impl AudioHandle {
195    /// Lock the running plugin to send MIDI, change parameters, etc.
196    ///
197    /// Recovers automatically if the audio thread previously panicked while holding
198    /// the lock (poisoned mutex), so control calls keep working.
199    pub fn lock(&self) -> MutexGuard<'_, Plugin> {
200        self.plugin
201            .lock()
202            .unwrap_or_else(|poisoned| poisoned.into_inner())
203    }
204
205    /// Try to lock the plugin without blocking, returning `None` if the audio
206    /// callback currently holds the lock (it is held for the duration of each
207    /// `process_audio` call).
208    ///
209    /// Use this on a UI/render thread for best-effort, per-frame reads (VU
210    /// meters, output-MIDI drain, parameter sync): skipping a frame when the
211    /// audio thread is mid-block is invisible, and it keeps the UI thread from
212    /// stalling on the (unfair) mutex — which otherwise shows up as input lag.
213    pub fn try_lock(&self) -> Option<MutexGuard<'_, Plugin>> {
214        match self.plugin.try_lock() {
215            Ok(guard) => Some(guard),
216            Err(std::sync::TryLockError::Poisoned(p)) => Some(p.into_inner()),
217            Err(std::sync::TryLockError::WouldBlock) => None,
218        }
219    }
220
221    /// Queue a MIDI event for the plugin without locking the audio thread.
222    ///
223    /// The event is pushed onto a lock-free ring and applied at the start of the next audio
224    /// block. Prefer this over `lock().send_midi_event(..)` on a UI thread — it never blocks
225    /// on the audio mutex. Returns `false` if the ring is full (the event is dropped).
226    pub fn send_midi(&self, event: MidiEvent) -> bool {
227        self.send_midi_at(event, 0)
228    }
229
230    /// Queue a MIDI event scheduled at `sample_offset` samples into the next block, for
231    /// sample-accurate sequencing, without locking the audio thread. A negative offset is
232    /// floored to `0`. Returns `false` if the ring is full.
233    pub fn send_midi_at(&self, event: MidiEvent, sample_offset: i32) -> bool {
234        self.ui
235            .control_tx
236            .lock()
237            .map(|mut tx| {
238                tx.push(HybridCommand::Midi {
239                    event,
240                    offset: sample_offset.max(0),
241                })
242                .is_ok()
243            })
244            .unwrap_or(false)
245    }
246
247    /// Obtain a [`MidiSink`]: a cheap, cloneable, `Send` handle that can queue MIDI to this
248    /// running plugin from another thread.
249    ///
250    /// Unlike [`AudioHandle`] itself (which is not `Send`, as it owns the device stream), the
251    /// sink can be moved into a background thread or callback — e.g. a MIDI input device
252    /// callback (see [`crate::midi_input`]). It shares the same lock-free command ring as
253    /// [`Self::send_midi`].
254    pub fn midi_sink(&self) -> MidiSink {
255        MidiSink {
256            control_tx: Arc::clone(&self.ui.control_tx),
257        }
258    }
259
260    /// Queue a normalized parameter change (`0.0..=1.0`) without locking the audio thread.
261    /// Applied at the start of the next block. Returns `false` if the ring is full.
262    pub fn set_parameter(&self, id: u32, value: f64) -> bool {
263        self.ui
264            .control_tx
265            .lock()
266            .map(|mut tx| tx.push(HybridCommand::Param { id, value }).is_ok())
267            .unwrap_or(false)
268    }
269
270    /// Queue a transport tempo change (BPM) without locking the audio thread; applied at the
271    /// start of the next block. `bpm` must be finite and greater than `0` (an invalid value is
272    /// rejected, returning `false`). Returns `false` if the ring is full.
273    pub fn set_tempo(&self, bpm: f64) -> bool {
274        if !(bpm.is_finite() && bpm > 0.0) {
275            return false;
276        }
277        self.ui
278            .control_tx
279            .lock()
280            .map(|mut tx| {
281                tx.push(HybridCommand::Transport(TransportCommand::Tempo(bpm)))
282                    .is_ok()
283            })
284            .unwrap_or(false)
285    }
286
287    /// Queue a transport time-signature change without locking the audio thread; applied at the
288    /// start of the next block. `denominator` must be one of `1, 2, 4, 8, 16` and `numerator`
289    /// must be positive (an invalid value is rejected, returning `false`). Returns `false` if
290    /// the ring is full.
291    pub fn set_time_signature(&self, numerator: i32, denominator: i32) -> bool {
292        if numerator <= 0 || !matches!(denominator, 1 | 2 | 4 | 8 | 16) {
293            return false;
294        }
295        self.ui
296            .control_tx
297            .lock()
298            .map(|mut tx| {
299                tx.push(HybridCommand::Transport(TransportCommand::TimeSignature(
300                    numerator,
301                    denominator,
302                )))
303                .is_ok()
304            })
305            .unwrap_or(false)
306    }
307
308    /// Queue a transport playing-state toggle without locking the audio thread; applied at the
309    /// start of the next block. Returns `false` if the ring is full.
310    pub fn set_playing(&self, playing: bool) -> bool {
311        self.ui
312            .control_tx
313            .lock()
314            .map(|mut tx| {
315                tx.push(HybridCommand::Transport(TransportCommand::Playing(playing)))
316                    .is_ok()
317            })
318            .unwrap_or(false)
319    }
320
321    /// Queue an all-notes-off "panic" (CC 123/120/121 on every channel) without locking the
322    /// audio thread. Returns `false` if the ring is full.
323    pub fn midi_panic(&self) -> bool {
324        self.ui
325            .control_tx
326            .lock()
327            .map(|mut tx| tx.push(HybridCommand::Panic).is_ok())
328            .unwrap_or(false)
329    }
330
331    /// Read the latest per-channel output peak levels without locking the audio thread.
332    ///
333    /// Each channel reports the maximum peak observed since the previous call (the read resets
334    /// the accumulator), so polling at UI frame rate never misses a transient between frames.
335    /// `rms` is not tracked on this path and is reported as 0; `peak_hold` mirrors `peak`
336    /// (drive your own ballistics, e.g. [`crate::audio::PeakMeter`], from the peak).
337    pub fn output_levels(&self) -> AudioLevels {
338        let channels = self
339            .ui
340            .levels
341            .iter()
342            .map(|atomic| {
343                let peak = f32::from_bits(atomic.swap(0, Ordering::Relaxed));
344                ChannelLevel {
345                    peak,
346                    rms: 0.0,
347                    peak_hold: peak,
348                }
349            })
350            .collect();
351        AudioLevels { channels }
352    }
353
354    /// Drain MIDI the plugin emitted during processing (arpeggiators, MPE, …) without locking
355    /// the audio thread. Returns the events queued since the last call.
356    pub fn drain_output_midi(&self) -> Vec<MidiEvent> {
357        let mut out = Vec::new();
358        if let Ok(mut rx) = self.ui.out_midi_rx.lock() {
359            while let Ok(event) = rx.pop() {
360                out.push(event);
361            }
362        }
363        out
364    }
365
366    /// Drain parameter changes the plugin made through its own editor without locking the
367    /// audio thread. Returns `(id, normalized_value)` pairs queued since the last call.
368    pub fn drain_parameter_changes(&self) -> Vec<(u32, f64)> {
369        let mut out = Vec::new();
370        if let Ok(mut rx) = self.ui.param_rx.lock() {
371            while let Ok(change) = rx.pop() {
372                out.push(change);
373            }
374        }
375        out
376    }
377
378    /// A shared handle to the plugin, e.g. to move into another thread.
379    pub fn plugin(&self) -> Arc<Mutex<Plugin>> {
380        Arc::clone(&self.plugin)
381    }
382
383    /// Stop playback now (equivalent to dropping the handle).
384    pub fn stop(self) {}
385}
386
387/// Interleave per-channel plugin output into a device's interleaved buffer.
388///
389/// `out` is laid out as `[frame0_ch0, frame0_ch1, ..., frame1_ch0, ...]` with
390/// `out.len() == frames * channels`. Channels the plugin didn't produce are left
391/// untouched (callers should pre-fill `out` with silence); plugin channels beyond
392/// `channels` are ignored.
393pub(crate) fn interleave_outputs(outputs: &[Vec<f32>], out: &mut [f32], channels: usize) {
394    if channels == 0 {
395        return;
396    }
397    let frames = out.len() / channels;
398    for ch in 0..channels.min(outputs.len()) {
399        let src = &outputs[ch];
400        for frame in 0..frames.min(src.len()) {
401            out[frame * channels + ch] = src[frame];
402        }
403    }
404}
405
406/// Resize a scratch buffer's output channels to exactly `frames`, clearing them.
407fn prepare_scratch(scratch: &mut AudioBuffers, frames: usize) {
408    for ch in &mut scratch.outputs {
409        if ch.len() != frames {
410            ch.resize(frames, 0.0);
411        }
412        ch.fill(0.0);
413    }
414    for ch in &mut scratch.inputs {
415        if ch.len() != frames {
416            ch.resize(frames, 0.0);
417        }
418        ch.fill(0.0);
419    }
420    scratch.block_size = frames;
421}
422
423/// Start streaming `plugin` through `backend`'s default output device.
424///
425/// The plugin is moved behind a shared lock so it can keep being controlled while
426/// the audio thread pulls blocks. Playback starts immediately and continues until
427/// the returned [`AudioHandle`] is dropped.
428///
429/// `config.output_channels` and `config.sample_rate` define the stream; the device
430/// callback may request varying block sizes, which the bridge accommodates.
431pub fn play_with_backend<B: AudioBackend>(
432    backend: &B,
433    plugin: Plugin,
434    config: AudioConfig,
435) -> Result<AudioHandle> {
436    let device = backend
437        .default_output_device()
438        .ok_or_else(|| Error::AudioBackendError("No default output device available".into()))?;
439
440    let channels = config.output_channels;
441    let sample_rate = config.sample_rate;
442
443    let plugin = Arc::new(Mutex::new(plugin));
444    // Ensure the plugin is armed before the first callback fires.
445    plugin
446        .lock()
447        .unwrap_or_else(|p| p.into_inner())
448        .start_processing()?;
449
450    let plugin_cb = Arc::clone(&plugin);
451    // Lock-free side channels: UI control in, feedback (peaks / output MIDI / param changes) out.
452    let (mut side, ui) = make_side_channels(channels);
453    // Reusable scratch buffer so the steady-state callback does not allocate.
454    let mut scratch = AudioBuffers::new(0, channels, config.block_size, sample_rate);
455
456    let data_cb = Box::new(move |data: &mut [f32]| {
457        // Start from silence so unproduced channels/frames are quiet.
458        data.fill(0.0);
459        if channels == 0 {
460            return;
461        }
462        let frames = data.len() / channels;
463        prepare_scratch(&mut scratch, frames);
464
465        // Recover from poison so queued control keeps flowing even after an audio-thread panic
466        // (matches AudioHandle::lock): the callback re-attempts processing rather than going
467        // permanently silent.
468        let mut p = match plugin_cb.lock() {
469            Ok(guard) => guard,
470            Err(poisoned) => poisoned.into_inner(),
471        };
472        // Apply queued control before rendering; render; then forward feedback. Levels need a
473        // successful render, but MIDI/param feedback is published even when stopped so the UI
474        // stays in sync.
475        side.apply_control(&mut p);
476        if p.process_audio(&mut scratch).is_ok() {
477            interleave_outputs(&scratch.outputs, data, channels);
478            side.publish_levels(&scratch.outputs);
479        }
480        side.publish_feedback(&p);
481    });
482
483    let err_cb = Box::new(|e: B::Error| {
484        log::error!("audio stream error: {}", e);
485    });
486
487    let stream = backend
488        .create_output_stream(&device, config, data_cb, err_cb)
489        .map_err(|e| Error::AudioBackendError(format!("Failed to create output stream: {}", e)))?;
490
491    stream
492        .play()
493        .map_err(|e| Error::AudioBackendError(format!("Failed to start stream: {}", e)))?;
494
495    Ok(AudioHandle {
496        _stream: Box::new(stream),
497        _input_stream: None,
498        plugin,
499        ui,
500    })
501}
502
503/// Drive a plugin with **live audio input** (effect hosting): capture from the default input
504/// device, process it through the plugin, and play the result on the default output device.
505///
506/// cpal has no true duplex stream, so this opens a separate input and output stream bridged
507/// by a lock-free ring: the input callback pushes captured frames, the output callback pops
508/// them into the plugin's input buffers, processes, and writes the output. `config`'s
509/// `input_channels`/`output_channels`/`sample_rate` define the streams. Like
510/// [`play_with_backend`], control the plugin via the returned [`AudioHandle`].
511///
512/// Note: the two device clocks are independent; this uses a small bridge buffer and tolerates
513/// drift by dropping/zero-filling at the edges. Suitable for monitoring/auditioning effects.
514pub fn play_with_input_backend<B: AudioBackend>(
515    backend: &B,
516    plugin: Plugin,
517    config: AudioConfig,
518) -> Result<AudioHandle> {
519    let in_device = backend
520        .default_input_device()
521        .ok_or_else(|| Error::AudioBackendError("No default input device available".into()))?;
522    let out_device = backend
523        .default_output_device()
524        .ok_or_else(|| Error::AudioBackendError("No default output device available".into()))?;
525
526    let in_channels = config.input_channels.max(1);
527    let out_channels = config.output_channels;
528    let sample_rate = config.sample_rate;
529
530    let plugin = Arc::new(Mutex::new(plugin));
531    plugin
532        .lock()
533        .unwrap_or_else(|p| p.into_inner())
534        .start_processing()?;
535
536    // SPSC bridge: input callback (producer) -> output callback (consumer). Hold a few
537    // blocks of interleaved input so the independent device clocks don't starve immediately.
538    let ring_cap = (config.block_size * in_channels * 8).max(2048);
539    let (mut producer, mut consumer) = rtrb::RingBuffer::<f32>::new(ring_cap);
540
541    let in_data_cb = Box::new(move |data: &[f32]| {
542        // Drop on full (output side fell behind) rather than block the capture callback.
543        for &s in data {
544            let _ = producer.push(s);
545        }
546    });
547    let in_err_cb = Box::new(|e: B::Error| log::error!("input stream error: {}", e));
548    let input_stream = backend
549        .create_input_stream(&in_device, config, in_data_cb, in_err_cb)
550        .map_err(|e| Error::AudioBackendError(format!("Failed to create input stream: {}", e)))?;
551
552    let plugin_cb = Arc::clone(&plugin);
553    // Lock-free side channels (same as the output-only path) so effect hosting is also
554    // controllable without locking the audio thread.
555    let (mut side, ui) = make_side_channels(out_channels);
556    let mut scratch = AudioBuffers::new(in_channels, out_channels, config.block_size, sample_rate);
557    let out_data_cb = Box::new(move |data: &mut [f32]| {
558        data.fill(0.0);
559        if out_channels == 0 {
560            return;
561        }
562        let frames = data.len() / out_channels;
563        prepare_scratch(&mut scratch, frames);
564        // Deinterleave captured input from the ring into the plugin's input buffers
565        // (interleaved frame-major order matches the input callback's push order).
566        for f in 0..frames {
567            for ch in scratch.inputs.iter_mut() {
568                ch[f] = consumer.pop().unwrap_or(0.0);
569            }
570        }
571        let mut p = match plugin_cb.lock() {
572            Ok(guard) => guard,
573            Err(poisoned) => poisoned.into_inner(),
574        };
575        side.apply_control(&mut p);
576        if p.process_audio(&mut scratch).is_ok() {
577            interleave_outputs(&scratch.outputs, data, out_channels);
578            side.publish_levels(&scratch.outputs);
579        }
580        side.publish_feedback(&p);
581    });
582    let out_err_cb = Box::new(|e: B::Error| log::error!("output stream error: {}", e));
583    let output_stream = backend
584        .create_output_stream(&out_device, config, out_data_cb, out_err_cb)
585        .map_err(|e| Error::AudioBackendError(format!("Failed to create output stream: {}", e)))?;
586
587    input_stream
588        .play()
589        .map_err(|e| Error::AudioBackendError(format!("Failed to start input stream: {}", e)))?;
590    output_stream
591        .play()
592        .map_err(|e| Error::AudioBackendError(format!("Failed to start output stream: {}", e)))?;
593
594    Ok(AudioHandle {
595        _stream: Box::new(output_stream),
596        _input_stream: Some(Box::new(input_stream)),
597        plugin,
598        ui,
599    })
600}
601
602/// A running real-time audio stream (the [`RealtimePluginRunner`] variant of
603/// [`AudioHandle`]). Holds the device stream open and exposes the lock-free [`RtControl`];
604/// dropping it stops playback.
605pub struct RtAudioHandle {
606    _stream: Box<dyn AudioStream>,
607    control: RtControl,
608}
609
610impl RtAudioHandle {
611    /// The lock-free control handle — queue MIDI and parameter changes without locking the
612    /// audio thread.
613    pub fn control(&mut self) -> &mut RtControl {
614        &mut self.control
615    }
616
617    /// Stop playback now (equivalent to dropping the handle).
618    pub fn stop(self) {}
619}
620
621/// Like [`play_with_backend`], but drives the plugin through a [`RealtimePluginRunner`] so the
622/// audio callback takes **no lock** — control changes flow over a lock-free queue. Returns an
623/// [`RtAudioHandle`] that keeps the stream alive and exposes the [`RtControl`].
624///
625/// `command_capacity` bounds how many MIDI/parameter commands can queue between callbacks.
626pub fn play_realtime_with_backend<B: AudioBackend>(
627    backend: &B,
628    plugin: Plugin,
629    config: AudioConfig,
630    command_capacity: usize,
631) -> Result<RtAudioHandle> {
632    let device = backend
633        .default_output_device()
634        .ok_or_else(|| Error::AudioBackendError("No default output device available".into()))?;
635
636    let channels = config.output_channels;
637    let sample_rate = config.sample_rate;
638
639    let (mut runner, control) = RealtimePluginRunner::new(plugin, command_capacity);
640    runner.start()?;
641
642    // Reusable scratch buffer so the steady-state callback does not allocate.
643    let mut scratch = AudioBuffers::new(0, channels, config.block_size, sample_rate);
644
645    let data_cb = Box::new(move |data: &mut [f32]| {
646        data.fill(0.0);
647        if channels == 0 {
648            return;
649        }
650        let frames = data.len() / channels;
651        prepare_scratch(&mut scratch, frames);
652
653        // No lock: the runner owns the plugin and drains its command queue here.
654        if runner.process(&mut scratch).is_ok() {
655            interleave_outputs(&scratch.outputs, data, channels);
656        }
657    });
658
659    let err_cb = Box::new(|e: B::Error| {
660        log::error!("audio stream error: {}", e);
661    });
662
663    let stream = backend
664        .create_output_stream(&device, config, data_cb, err_cb)
665        .map_err(|e| Error::AudioBackendError(format!("Failed to create output stream: {}", e)))?;
666
667    stream
668        .play()
669        .map_err(|e| Error::AudioBackendError(format!("Failed to start stream: {}", e)))?;
670
671    Ok(RtAudioHandle {
672        _stream: Box::new(stream),
673        control,
674    })
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680
681    #[test]
682    fn interleaves_two_channels() {
683        // outputs[ch][frame]
684        let outputs = vec![vec![1.0, 2.0, 3.0], vec![-1.0, -2.0, -3.0]];
685        let mut out = vec![0.0; 6]; // 3 frames * 2 channels
686        interleave_outputs(&outputs, &mut out, 2);
687        assert_eq!(out, vec![1.0, -1.0, 2.0, -2.0, 3.0, -3.0]);
688    }
689
690    #[test]
691    fn hybrid_midi_carries_sample_offset() {
692        // The control ring (and apply_control) must preserve the scheduled offset so the mutex
693        // playback path schedules MIDI sample-accurately, like the lock-free path.
694        let (mut tx, mut rx) = RingBuffer::<HybridCommand>::new(4);
695        tx.push(HybridCommand::Midi {
696            event: MidiEvent::NoteOn {
697                channel: crate::midi::MidiChannel::Ch1,
698                note: 60,
699                velocity: 100,
700            },
701            offset: 200,
702        })
703        .expect("queue scheduled note");
704        match rx.pop().expect("note queued") {
705            HybridCommand::Midi { offset, .. } => assert_eq!(offset, 200),
706            _ => panic!("expected a MIDI command"),
707        }
708    }
709
710    #[test]
711    fn channel_peak_is_max_abs_and_sanitizes_non_finite() {
712        assert_eq!(channel_peak(&[0.1, -0.5, 0.3]), 0.5);
713        assert_eq!(channel_peak(&[]), 0.0);
714        // NaN / inf are treated as 0 so they never poison the meter or the atomic.
715        assert_eq!(channel_peak(&[f32::NAN, 0.2, f32::INFINITY]), 0.2);
716    }
717
718    #[test]
719    fn nonneg_f32_bits_are_monotonic_so_fetch_max_is_float_max() {
720        // The level atomics rely on this: for non-negative finite floats, a < b implies
721        // a.to_bits() < b.to_bits(), so AtomicU32::fetch_max on the bit pattern is a float max.
722        let peaks = [0.0_f32, 1e-6, 0.01, 0.25, 0.5, 0.999, 1.0];
723        for w in peaks.windows(2) {
724            assert!(w[0].to_bits() < w[1].to_bits(), "{} vs {}", w[0], w[1]);
725        }
726    }
727
728    #[test]
729    fn ignores_extra_plugin_channels() {
730        // Plugin produced 3 channels but the device only has 2.
731        let outputs = vec![vec![1.0, 2.0], vec![3.0, 4.0], vec![9.0, 9.0]];
732        let mut out = vec![0.0; 4];
733        interleave_outputs(&outputs, &mut out, 2);
734        assert_eq!(out, vec![1.0, 3.0, 2.0, 4.0]);
735    }
736
737    #[test]
738    fn leaves_missing_channels_as_silence() {
739        // Device wants 2 channels but plugin produced only 1 (mono).
740        let outputs = vec![vec![0.5, 0.6]];
741        let mut out = vec![0.0; 4];
742        interleave_outputs(&outputs, &mut out, 2);
743        // ch1 stays at the pre-filled silence.
744        assert_eq!(out, vec![0.5, 0.0, 0.6, 0.0]);
745    }
746
747    #[test]
748    fn zero_channels_is_a_noop() {
749        let outputs = vec![vec![1.0, 2.0]];
750        let mut out = vec![7.0, 7.0];
751        interleave_outputs(&outputs, &mut out, 0);
752        assert_eq!(out, vec![7.0, 7.0]);
753    }
754
755    #[test]
756    fn prepare_scratch_resizes_and_clears() {
757        let mut scratch = AudioBuffers::new(1, 2, 4, 48000.0);
758        scratch.outputs[0][0] = 9.0;
759        prepare_scratch(&mut scratch, 8);
760        assert_eq!(scratch.block_size, 8);
761        assert!(scratch.outputs.iter().all(|c| c.len() == 8));
762        assert!(scratch.inputs.iter().all(|c| c.len() == 8));
763        assert!(scratch.outputs.iter().flatten().all(|&s| s == 0.0));
764    }
765}