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}