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 runtime transport change applied to the plugin's host `ProcessContext` on the audio
35/// thread, taking effect on the next block. Shared by the lock-free runner and the
36/// mutex-based playback path so both apply transport mutation the same way.
37#[derive(Clone, Copy)]
38pub(crate) enum TransportCommand {
39    /// Set the transport tempo (BPM).
40    Tempo(f64),
41    /// Set the transport time signature (`numerator`, `denominator`).
42    TimeSignature(i32, i32),
43    /// Toggle the transport playing state.
44    Playing(bool),
45}
46
47impl TransportCommand {
48    /// Apply this transport change to the plugin, ignoring errors as the audio thread does for
49    /// all queued control. The value was validated on the control thread before being queued.
50    pub(crate) fn apply(self, plugin: &mut Plugin) {
51        match self {
52            TransportCommand::Tempo(bpm) => {
53                let _ = plugin.set_tempo(bpm);
54            }
55            TransportCommand::TimeSignature(num, den) => {
56                let _ = plugin.set_time_signature(num, den);
57            }
58            TransportCommand::Playing(playing) => {
59                let _ = plugin.set_playing(playing);
60            }
61        }
62    }
63}
64
65/// A control command applied to the plugin on the audio thread.
66enum RtCommand {
67    /// Deliver a MIDI event at `offset` samples into the next block.
68    Midi { event: MidiEvent, offset: i32 },
69    /// Set a normalized parameter value on the next block.
70    Param { id: u32, value: f64 },
71    /// Apply a transport change (tempo / time signature / playing) on the next block.
72    Transport(TransportCommand),
73}
74
75/// Owns a [`Plugin`] on the audio thread and applies queued control commands before each
76/// process block. Pair with an [`RtControl`] (returned from [`Self::new`]) to drive it from
77/// other threads.
78///
79/// # Real-time safety
80///
81/// In steady state [`process`](Self::process) is **allocation-free and `Drop`-free**: once
82/// warmed up it performs no heap allocation, reallocation, or free per block, even while
83/// parameter changes and MIDI (in and out) are flowing. This holds under two conditions:
84///
85/// - **Fixed buffer size** — pass an [`AudioBuffers`] sized to the configured block size and
86///   don't resize it between calls (a smaller block is fine; growth reallocates).
87/// - **In-process** — the runner hosts the plugin in-process; the process-isolation path
88///   marshals audio over IPC and is not allocation-free.
89///
90/// This is verified by `tests/alloc_tests.rs` (a counting global allocator asserts zero
91/// alloc/realloc/free over a steady-state run driving parameters and MIDI). The host cannot
92/// guarantee the *plugin's* own `process()` is allocation-free — that is the plugin's
93/// responsibility; the guarantee is about the host code around it.
94///
95/// It is **not yet fully lock-free**: `process` still takes a few short, uncontended mutexes
96/// per block (the parameter-change and event queues, and the level meter). They are uncontended
97/// while the runner owns the plugin, but a hard-real-time deployment should treat lock removal
98/// as pending work. Output MIDI is already lock-free, though: take a
99/// [`OutputMidiConsumer`](crate::OutputMidiConsumer) via
100/// [`Plugin::output_midi_handle`](crate::Plugin::output_midi_handle) before moving the plugin
101/// into the runner, then drain emitted events from your UI thread while the audio thread pushes.
102pub struct RealtimePluginRunner {
103    plugin: Plugin,
104    rx: Consumer<RtCommand>,
105}
106
107/// A `Send` handle for pushing MIDI and parameter changes to a [`RealtimePluginRunner`]
108/// without locking. Lives on the control thread; the runner lives on the audio thread.
109pub struct RtControl {
110    tx: Producer<RtCommand>,
111    /// Count of commands dropped because the queue was full (observability).
112    dropped: u64,
113}
114
115impl RealtimePluginRunner {
116    /// Build a runner that owns `plugin`, plus the [`RtControl`] handle to drive it.
117    ///
118    /// `command_capacity` is the maximum number of MIDI/parameter commands that can be
119    /// queued between two [`process`](Self::process) calls; pushes beyond it are dropped
120    /// (reported by the `RtControl` methods returning `false`). Size it for your block rate
121    /// and worst-case control burst (e.g. 1024).
122    pub fn new(plugin: Plugin, command_capacity: usize) -> (Self, RtControl) {
123        let (tx, rx) = RingBuffer::new(command_capacity.max(1));
124        (Self { plugin, rx }, RtControl { tx, dropped: 0 })
125    }
126
127    /// Begin processing. Call once before the first [`process`](Self::process).
128    pub fn start(&mut self) -> Result<()> {
129        self.plugin.start_processing()
130    }
131
132    /// Stop processing.
133    pub fn stop(&mut self) -> Result<()> {
134        self.plugin.stop_processing()
135    }
136
137    /// Drain all queued control commands and render one block.
138    ///
139    /// Call this from the audio thread (e.g. inside your device callback). It performs only
140    /// the lock-free queue drain plus the plugin's own processing — it never blocks on a lock
141    /// a control thread could hold.
142    pub fn process(&mut self, buffers: &mut AudioBuffers) -> Result<()> {
143        while let Ok(cmd) = self.rx.pop() {
144            match cmd {
145                RtCommand::Midi { event, offset } => {
146                    let _ = self.plugin.send_midi_event_at(event, offset);
147                }
148                RtCommand::Param { id, value } => {
149                    let _ = self.plugin.set_parameter(id, value);
150                }
151                RtCommand::Transport(change) => {
152                    change.apply(&mut self.plugin);
153                }
154            }
155        }
156        self.plugin.process_audio(buffers)
157    }
158
159    /// Borrow the underlying plugin (e.g. to read parameters or info). Do **not** call this
160    /// from the audio thread while another thread might also touch the plugin.
161    pub fn plugin(&self) -> &Plugin {
162        &self.plugin
163    }
164
165    /// Recover the owned plugin, consuming the runner.
166    pub fn into_plugin(self) -> Plugin {
167        self.plugin
168    }
169}
170
171impl RtControl {
172    /// Queue a MIDI event for the next block (at block start). Returns `false` if the command
173    /// queue is full (the event is dropped rather than blocking the caller).
174    pub fn send_midi(&mut self, event: MidiEvent) -> bool {
175        self.send_midi_at(event, 0)
176    }
177
178    /// Queue a MIDI event scheduled at `sample_offset` samples into the next block, for
179    /// sample-accurate sequencing. A negative offset is floored to `0`; `process()` clamps it
180    /// into the actual (possibly shorter) block. Returns `false` if the queue is full.
181    pub fn send_midi_at(&mut self, event: MidiEvent, sample_offset: i32) -> bool {
182        let ok = self
183            .tx
184            .push(RtCommand::Midi {
185                event,
186                offset: sample_offset.max(0),
187            })
188            .is_ok();
189        self.track(ok)
190    }
191
192    /// Queue a normalized parameter change (`0.0..=1.0`) for the next block. Returns `false`
193    /// if the queue is full.
194    pub fn set_parameter(&mut self, id: u32, value: f64) -> bool {
195        let ok = self.tx.push(RtCommand::Param { id, value }).is_ok();
196        self.track(ok)
197    }
198
199    /// Queue a transport tempo change (BPM) for the next block. `bpm` must be finite and
200    /// greater than `0`; an invalid value is rejected (returns `false`) rather than queued.
201    /// Returns `false` if the queue is full.
202    pub fn set_tempo(&mut self, bpm: f64) -> bool {
203        if !(bpm.is_finite() && bpm > 0.0) {
204            return false;
205        }
206        let ok = self
207            .tx
208            .push(RtCommand::Transport(TransportCommand::Tempo(bpm)))
209            .is_ok();
210        self.track(ok)
211    }
212
213    /// Queue a transport time-signature change for the next block. `denominator` must be one
214    /// of `1, 2, 4, 8, 16` and `numerator` must be positive; an invalid value is rejected
215    /// (returns `false`). Returns `false` if the queue is full.
216    pub fn set_time_signature(&mut self, numerator: i32, denominator: i32) -> bool {
217        if numerator <= 0 || !matches!(denominator, 1 | 2 | 4 | 8 | 16) {
218            return false;
219        }
220        let ok = self
221            .tx
222            .push(RtCommand::Transport(TransportCommand::TimeSignature(
223                numerator,
224                denominator,
225            )))
226            .is_ok();
227        self.track(ok)
228    }
229
230    /// Queue a transport playing-state toggle for the next block. Returns `false` if the queue
231    /// is full.
232    pub fn set_playing(&mut self, playing: bool) -> bool {
233        let ok = self
234            .tx
235            .push(RtCommand::Transport(TransportCommand::Playing(playing)))
236            .is_ok();
237        self.track(ok)
238    }
239
240    /// Total number of commands dropped because the queue was full since this control was
241    /// created. A persistently rising count means the queue capacity is too small for the
242    /// control rate.
243    pub fn dropped_command_count(&self) -> u64 {
244        self.dropped
245    }
246
247    fn track(&mut self, ok: bool) -> bool {
248        if !ok {
249            self.dropped += 1;
250        }
251        ok
252    }
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258    use crate::midi::MidiChannel;
259
260    #[test]
261    fn control_queue_reports_full_without_blocking() {
262        // A tiny capacity makes the drop-on-full behavior observable without a plugin.
263        let (tx, _rx) = RingBuffer::<RtCommand>::new(2);
264        let mut control = RtControl { tx, dropped: 0 };
265        assert!(control.set_parameter(1, 0.5));
266        assert!(control.set_parameter(1, 0.6));
267        // Third push exceeds capacity (nothing has been drained) → dropped, not blocked.
268        assert!(!control.set_parameter(1, 0.7));
269        assert!(!control.send_midi(crate::midi::MidiEvent::NoteOn {
270            channel: crate::midi::MidiChannel::Ch1,
271            note: 60,
272            velocity: 100
273        }));
274        assert_eq!(control.dropped_command_count(), 2);
275    }
276
277    #[test]
278    fn transport_commands_round_trip_through_the_ring() {
279        let (tx, mut rx) = RingBuffer::<RtCommand>::new(8);
280        let mut control = RtControl { tx, dropped: 0 };
281
282        assert!(control.set_tempo(140.0));
283        assert!(control.set_time_signature(7, 8));
284        assert!(control.set_playing(false));
285
286        // The three transport commands arrive in order, carrying their payloads intact.
287        match rx.pop().expect("tempo queued") {
288            RtCommand::Transport(TransportCommand::Tempo(bpm)) => assert_eq!(bpm, 140.0),
289            _ => panic!("expected tempo transport command"),
290        }
291        match rx.pop().expect("time sig queued") {
292            RtCommand::Transport(TransportCommand::TimeSignature(n, d)) => {
293                assert_eq!((n, d), (7, 8))
294            }
295            _ => panic!("expected time-signature transport command"),
296        }
297        match rx.pop().expect("playing queued") {
298            RtCommand::Transport(TransportCommand::Playing(p)) => assert!(!p),
299            _ => panic!("expected playing transport command"),
300        }
301    }
302
303    #[test]
304    fn midi_offset_round_trips_through_the_ring() {
305        let (tx, mut rx) = RingBuffer::<RtCommand>::new(8);
306        let mut control = RtControl { tx, dropped: 0 };
307
308        assert!(control.send_midi_at(
309            MidiEvent::NoteOn {
310                channel: MidiChannel::Ch1,
311                note: 60,
312                velocity: 100,
313            },
314            128,
315        ));
316        // send_midi is the offset-0 convenience.
317        assert!(control.send_midi(MidiEvent::NoteOff {
318            channel: MidiChannel::Ch1,
319            note: 60,
320            velocity: 0,
321        }));
322
323        match rx.pop().expect("scheduled note queued") {
324            RtCommand::Midi { offset, .. } => assert_eq!(offset, 128),
325            _ => panic!("expected a MIDI command"),
326        }
327        match rx.pop().expect("block-start note queued") {
328            RtCommand::Midi { offset, .. } => assert_eq!(offset, 0),
329            _ => panic!("expected a MIDI command"),
330        }
331    }
332
333    #[test]
334    fn invalid_transport_values_are_rejected_not_queued() {
335        let (tx, _rx) = RingBuffer::<RtCommand>::new(8);
336        let mut control = RtControl { tx, dropped: 0 };
337        // Non-positive / non-finite tempo and malformed time signatures never reach the ring.
338        assert!(!control.set_tempo(0.0));
339        assert!(!control.set_tempo(f64::NAN));
340        assert!(!control.set_time_signature(0, 4));
341        assert!(!control.set_time_signature(4, 3));
342        // Rejected on validation, not because the queue was full.
343        assert_eq!(control.dropped_command_count(), 0);
344    }
345}