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}