Skip to main content

vst3_host/
realtime.rs

1//! Lock-free real-time plugin runner.
2//!
3//! [`Vst3Host::play`](crate::Vst3Host::play) / [`simple::play`](crate::simple::play) are the
4//! friendly path: they wrap the plugin in an `Arc<Mutex<Plugin>>` and the audio callback
5//! locks it. That's correctness-first but not hard-real-time — a control-thread call can
6//! contend with the audio thread for the lock.
7//!
8//! [`RealtimePluginRunner`] is the serious path *alongside* it. The runner **owns** the
9//! plugin on the audio thread; control commands (MIDI, parameter changes) are delivered over
10//! a lock-free SPSC ring and applied at the start of each block. The audio callback never
11//! takes a lock a control thread could be holding, so it can't be blocked by `set_parameter`
12//! or `send_midi`.
13//!
14//! ```no_run
15//! use vst3_host::{simple, realtime::RealtimePluginRunner, midi::MidiChannel, audio::AudioBuffers};
16//! # fn main() -> vst3_host::Result<()> {
17//! let plugin = simple::load_plugin("/path/synth.vst3")?;
18//! let (mut runner, mut control) = RealtimePluginRunner::new(plugin, 1024);
19//! runner.start()?;
20//!
21//! // From any thread: queue control changes without locking the audio thread.
22//! control.send_midi(vst3_host::midi::MidiEvent::NoteOn { channel: MidiChannel::Ch1, note: 60, velocity: 100 });
23//!
24//! // On the audio thread (e.g. your device callback): drain commands + render, no locks.
25//! let mut buffers = AudioBuffers::new(0, 2, 512, 48_000.0);
26//! runner.process(&mut buffers)?;
27//! # Ok(())
28//! # }
29//! ```
30
31use crate::{audio::AudioBuffers, error::Result, midi::MidiEvent, plugin::Plugin};
32use rtrb::{Consumer, Producer, RingBuffer};
33
34/// A control command applied to the plugin on the audio thread.
35enum RtCommand {
36    /// Deliver a MIDI event on the next block.
37    Midi(MidiEvent),
38    /// Set a normalized parameter value on the next block.
39    Param { id: u32, value: f64 },
40}
41
42/// Owns a [`Plugin`] on the audio thread and applies queued control commands before each
43/// process block, with no locking on the audio path. Pair with an [`RtControl`] (returned
44/// from [`Self::new`]) to drive it from other threads.
45pub struct RealtimePluginRunner {
46    plugin: Plugin,
47    rx: Consumer<RtCommand>,
48}
49
50/// A `Send` handle for pushing MIDI and parameter changes to a [`RealtimePluginRunner`]
51/// without locking. Lives on the control thread; the runner lives on the audio thread.
52pub struct RtControl {
53    tx: Producer<RtCommand>,
54    /// Count of commands dropped because the queue was full (observability).
55    dropped: u64,
56}
57
58impl RealtimePluginRunner {
59    /// Build a runner that owns `plugin`, plus the [`RtControl`] handle to drive it.
60    ///
61    /// `command_capacity` is the maximum number of MIDI/parameter commands that can be
62    /// queued between two [`process`](Self::process) calls; pushes beyond it are dropped
63    /// (reported by the `RtControl` methods returning `false`). Size it for your block rate
64    /// and worst-case control burst (e.g. 1024).
65    pub fn new(plugin: Plugin, command_capacity: usize) -> (Self, RtControl) {
66        let (tx, rx) = RingBuffer::new(command_capacity.max(1));
67        (Self { plugin, rx }, RtControl { tx, dropped: 0 })
68    }
69
70    /// Begin processing. Call once before the first [`process`](Self::process).
71    pub fn start(&mut self) -> Result<()> {
72        self.plugin.start_processing()
73    }
74
75    /// Stop processing.
76    pub fn stop(&mut self) -> Result<()> {
77        self.plugin.stop_processing()
78    }
79
80    /// Drain all queued control commands and render one block.
81    ///
82    /// Call this from the audio thread (e.g. inside your device callback). It performs only
83    /// the lock-free queue drain plus the plugin's own processing — it never blocks on a lock
84    /// a control thread could hold.
85    pub fn process(&mut self, buffers: &mut AudioBuffers) -> Result<()> {
86        while let Ok(cmd) = self.rx.pop() {
87            match cmd {
88                RtCommand::Midi(event) => {
89                    let _ = self.plugin.send_midi_event(event);
90                }
91                RtCommand::Param { id, value } => {
92                    let _ = self.plugin.set_parameter(id, value);
93                }
94            }
95        }
96        self.plugin.process_audio(buffers)
97    }
98
99    /// Borrow the underlying plugin (e.g. to read parameters or info). Do **not** call this
100    /// from the audio thread while another thread might also touch the plugin.
101    pub fn plugin(&self) -> &Plugin {
102        &self.plugin
103    }
104
105    /// Recover the owned plugin, consuming the runner.
106    pub fn into_plugin(self) -> Plugin {
107        self.plugin
108    }
109}
110
111impl RtControl {
112    /// Queue a MIDI event for the next block. Returns `false` if the command queue is full
113    /// (the event is dropped rather than blocking the caller).
114    pub fn send_midi(&mut self, event: MidiEvent) -> bool {
115        let ok = self.tx.push(RtCommand::Midi(event)).is_ok();
116        self.track(ok)
117    }
118
119    /// Queue a normalized parameter change (`0.0..=1.0`) for the next block. Returns `false`
120    /// if the queue is full.
121    pub fn set_parameter(&mut self, id: u32, value: f64) -> bool {
122        let ok = self.tx.push(RtCommand::Param { id, value }).is_ok();
123        self.track(ok)
124    }
125
126    /// Total number of commands dropped because the queue was full since this control was
127    /// created. A persistently rising count means the queue capacity is too small for the
128    /// control rate.
129    pub fn dropped_command_count(&self) -> u64 {
130        self.dropped
131    }
132
133    fn track(&mut self, ok: bool) -> bool {
134        if !ok {
135            self.dropped += 1;
136        }
137        ok
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144
145    #[test]
146    fn control_queue_reports_full_without_blocking() {
147        // A tiny capacity makes the drop-on-full behavior observable without a plugin.
148        let (tx, _rx) = RingBuffer::<RtCommand>::new(2);
149        let mut control = RtControl { tx, dropped: 0 };
150        assert!(control.set_parameter(1, 0.5));
151        assert!(control.set_parameter(1, 0.6));
152        // Third push exceeds capacity (nothing has been drained) → dropped, not blocked.
153        assert!(!control.set_parameter(1, 0.7));
154        assert!(!control.send_midi(crate::midi::MidiEvent::NoteOn {
155            channel: crate::midi::MidiChannel::Ch1,
156            note: 60,
157            velocity: 100
158        }));
159        assert_eq!(control.dropped_command_count(), 2);
160    }
161}