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}