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}