vst3_host/transport.rs
1//! A sample-accurate musical timeline: schedule MIDI clips and parameter-automation lanes on
2//! a beat grid and drive them into a plugin block by block.
3//!
4//! [`Timeline`] owns a tempo (BPM) and sample rate and a sample clock. Each call to
5//! [`Timeline::advance_block`] returns the events that fall in the next block as
6//! sample-accurate offsets (`(event, offset)` / `(param_id, offset, value)`), then advances the
7//! clock. [`Timeline::drive_block`] is the convenience that pushes those into a [`Plugin`] and
8//! renders one block.
9//!
10//! **Timebase:** clips and lanes are authored in **beats**. Slice 1 uses a single constant
11//! tempo (`bpm`); a varying tempo curve is future work, so beat↔sample conversion here is the
12//! constant-tempo `samples_per_beat = sample_rate * 60 / bpm`.
13//!
14//! ```no_run
15//! use vst3_host::{simple, transport::{Timeline, MidiClip}, midi::{MidiEvent, MidiChannel}};
16//! # fn main() -> vst3_host::Result<()> {
17//! let mut plugin = simple::load_plugin("/path/synth.vst3")?;
18//! plugin.start_processing()?;
19//!
20//! let clip = MidiClip::new()
21//! .with(0.0, MidiEvent::NoteOn { channel: MidiChannel::Ch1, note: 60, velocity: 100 })
22//! .with(2.0, MidiEvent::NoteOff { channel: MidiChannel::Ch1, note: 60, velocity: 0 });
23//! let mut timeline = Timeline::new(48_000.0, 120.0).with_clip(clip);
24//!
25//! let mut buffers = vst3_host::audio::AudioBuffers::new(0, 2, 512, 48_000.0);
26//! for _ in 0..96 {
27//! timeline.drive_block(&mut plugin, &mut buffers)?;
28//! }
29//! # Ok(())
30//! # }
31//! ```
32
33use crate::audio::AudioBuffers;
34use crate::error::Result;
35use crate::midi::MidiEvent;
36use crate::parameters::ParameterAutomation;
37use crate::plugin::Plugin;
38
39/// A clip of MIDI events placed at beat positions on the timeline.
40#[derive(Debug, Clone, Default)]
41pub struct MidiClip {
42 /// `(beat, event)`, not required to be sorted — [`Timeline::advance_block`] windows by frame.
43 events: Vec<(f64, MidiEvent)>,
44}
45
46impl MidiClip {
47 /// An empty clip.
48 pub fn new() -> Self {
49 Self::default()
50 }
51
52 /// Add an event at `beat` (fluent).
53 pub fn with(mut self, beat: f64, event: MidiEvent) -> Self {
54 self.events.push((beat, event));
55 self
56 }
57
58 /// Add an event at `beat`.
59 pub fn add(&mut self, beat: f64, event: MidiEvent) {
60 self.events.push((beat, event));
61 }
62}
63
64/// A parameter-automation lane: a parameter id plus its [`ParameterAutomation`] curve, whose
65/// point times are interpreted in **beats** (so the lane follows the timeline's tempo).
66#[derive(Debug, Clone)]
67pub struct AutomationLane {
68 /// Target parameter id.
69 pub param_id: u32,
70 /// The automation curve; point times are in beats.
71 pub automation: ParameterAutomation,
72 /// How many automation points to emit per block (denser = smoother, more events).
73 pub points_per_block: usize,
74}
75
76impl AutomationLane {
77 /// A lane targeting `param_id` driven by `automation` (point times in beats), emitting
78 /// `points_per_block` points per processed block.
79 pub fn new(param_id: u32, automation: ParameterAutomation, points_per_block: usize) -> Self {
80 Self {
81 param_id,
82 automation,
83 points_per_block,
84 }
85 }
86}
87
88/// The events a single block should deliver, as sample offsets within that block.
89#[derive(Debug, Clone, Default, PartialEq)]
90pub struct BlockEvents {
91 /// MIDI events with their sample offset within the block, in scheduled order.
92 pub midi: Vec<(MidiEvent, i32)>,
93 /// Parameter changes as `(param_id, sample_offset, value)`.
94 pub params: Vec<(u32, i32, f64)>,
95}
96
97/// How many frames [`Plugin::process_audio`] will render for `buffers`: the length of the first
98/// channel buffer (outputs first, then inputs), falling back to `block_size` when the buffers
99/// carry no channels at all. Mirrors the plugin's own derivation so the timeline windows events
100/// over exactly the span the plugin advances.
101fn rendered_frames(buffers: &AudioBuffers) -> usize {
102 buffers
103 .outputs
104 .iter()
105 .chain(buffers.inputs.iter())
106 .map(|channel| channel.len())
107 .next()
108 .unwrap_or(buffers.block_size)
109}
110
111/// A sample-accurate musical timeline driving MIDI clips and automation lanes into a plugin.
112#[derive(Debug, Clone)]
113pub struct Timeline {
114 sample_rate: f64,
115 bpm: f64,
116 sample_clock: u64,
117 clips: Vec<MidiClip>,
118 lanes: Vec<AutomationLane>,
119}
120
121impl Timeline {
122 /// A timeline at `sample_rate` and constant tempo `bpm`. `bpm` must be finite and `> 0`;
123 /// an invalid value falls back to `120.0` so beat↔sample conversion can't produce NaN.
124 pub fn new(sample_rate: f64, bpm: f64) -> Self {
125 let bpm = if bpm.is_finite() && bpm > 0.0 {
126 bpm
127 } else {
128 120.0
129 };
130 Self {
131 sample_rate,
132 bpm,
133 sample_clock: 0,
134 clips: Vec::new(),
135 lanes: Vec::new(),
136 }
137 }
138
139 /// Add a MIDI clip (fluent).
140 pub fn with_clip(mut self, clip: MidiClip) -> Self {
141 self.clips.push(clip);
142 self
143 }
144
145 /// Add an automation lane (fluent).
146 pub fn with_lane(mut self, lane: AutomationLane) -> Self {
147 self.lanes.push(lane);
148 self
149 }
150
151 /// Add a MIDI clip.
152 pub fn add_clip(&mut self, clip: MidiClip) {
153 self.clips.push(clip);
154 }
155
156 /// Add an automation lane.
157 pub fn add_lane(&mut self, lane: AutomationLane) {
158 self.lanes.push(lane);
159 }
160
161 /// The current playhead position in frames since the start.
162 pub fn sample_clock(&self) -> u64 {
163 self.sample_clock
164 }
165
166 /// Move the playhead to `frame` (e.g. to loop or seek). Does not emit events.
167 pub fn seek_frame(&mut self, frame: u64) {
168 self.sample_clock = frame;
169 }
170
171 /// Samples per beat at the current constant tempo.
172 pub fn samples_per_beat(&self) -> f64 {
173 self.sample_rate * 60.0 / self.bpm
174 }
175
176 /// Convert a beat position to an absolute frame index.
177 pub fn beat_to_frame(&self, beat: f64) -> u64 {
178 (beat * self.samples_per_beat()).round().max(0.0) as u64
179 }
180
181 /// Convert an absolute frame index to a beat position.
182 pub fn frame_to_beat(&self, frame: u64) -> f64 {
183 frame as f64 / self.samples_per_beat()
184 }
185
186 /// Collect the events that fall in the next `frames`-sample block as sample offsets, then
187 /// advance the playhead by `frames`. Clip events are windowed by frame index against the
188 /// half-open block `[clock, clock + frames)`; automation lanes emit their per-block points
189 /// (evaluated in the beat domain) tagged with the lane's parameter id.
190 pub fn advance_block(&mut self, frames: usize) -> BlockEvents {
191 let start = self.sample_clock;
192 // Saturating: `seek_frame` is public and takes any `u64`, so a seek near the top of the
193 // range followed by a normal block would otherwise overflow (panic in debug, wrap in
194 // release — wrapping would make the window run backwards and re-fire events).
195 let end = start.saturating_add(frames as u64);
196 let mut out = BlockEvents::default();
197
198 for clip in &self.clips {
199 for (beat, event) in &clip.events {
200 let frame = self.beat_to_frame(*beat);
201 if frame >= start && frame < end {
202 out.midi.push((*event, (frame - start) as i32));
203 }
204 }
205 }
206 // Deliver scheduled events in time order so a NoteOff never precedes its NoteOn within a
207 // block when two clips overlap.
208 out.midi.sort_by_key(|(_, offset)| *offset);
209
210 if frames > 0 {
211 // Drive points_for_block in the beat domain: passing `samples_per_beat` as the
212 // "sample rate" makes its internal `offset / rate` term read as beats, so a
213 // beat-authored curve is evaluated correctly with sample-accurate offsets.
214 let start_beats = self.frame_to_beat(start);
215 let spb = self.samples_per_beat();
216 for lane in &self.lanes {
217 for (offset, value) in lane.automation.points_for_block(
218 start_beats,
219 frames,
220 spb,
221 lane.points_per_block,
222 ) {
223 out.params.push((lane.param_id, offset, value));
224 }
225 }
226 }
227
228 self.sample_clock = end;
229 out
230 }
231
232 /// Advance one block and drive it into `plugin`: schedule its MIDI and parameter changes at
233 /// their sample offsets, then render `buffers`.
234 ///
235 /// The block length is the number of frames [`Plugin::process_audio`] will actually render
236 /// — the length of `buffers`' first channel buffer, not its `block_size` field. The two are
237 /// independent (both are public), and windowing by a different count than the plugin renders
238 /// would slip the timeline clock against the plugin's transport by the difference every block.
239 pub fn drive_block(&mut self, plugin: &mut Plugin, buffers: &mut AudioBuffers) -> Result<()> {
240 let frames = rendered_frames(buffers);
241 let events = self.advance_block(frames);
242 for (event, offset) in events.midi {
243 plugin.send_midi_event_at(event, offset)?;
244 }
245 for (id, offset, value) in events.params {
246 plugin.set_parameter_at(id, value, offset)?;
247 }
248 plugin.process_audio(buffers)
249 }
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255
256 /// `seek_frame` is public and takes any `u64`, so the block window must not overflow: a panic
257 /// in debug, and in release a wrapped `end` that runs the window backwards and re-fires events.
258 #[test]
259 fn advance_block_near_the_end_of_the_clock_does_not_overflow() {
260 let mut t = Timeline::new(48_000.0, 120.0);
261 t.seek_frame(u64::MAX);
262 let _ = t.advance_block(1);
263 let _ = t.advance_block(4096);
264 }
265
266 /// `AudioBuffers`' fields are public, so `block_size` can disagree with the channel buffers
267 /// the plugin actually renders. `drive_block` must window by the latter, or the timeline
268 /// clock and the plugin's transport drift apart by the difference every block.
269 #[test]
270 fn rendered_frames_follows_the_channel_buffers_not_block_size() {
271 let mut buffers = AudioBuffers::new(0, 2, 512, 48_000.0);
272 assert_eq!(rendered_frames(&buffers), 512);
273
274 // A caller (or a resizing bridge) hands over shorter channel buffers than `block_size`.
275 for channel in &mut buffers.outputs {
276 channel.resize(128, 0.0);
277 }
278 assert_eq!(rendered_frames(&buffers), 128);
279
280 // Input-only buffers fall through to the input channels.
281 let mut input_only = AudioBuffers::new(1, 0, 512, 48_000.0);
282 input_only.inputs[0].resize(64, 0.0);
283 assert_eq!(rendered_frames(&input_only), 64);
284
285 // No channels at all: nothing to measure, so `block_size` is the only answer.
286 let empty = AudioBuffers::new(0, 0, 256, 48_000.0);
287 assert_eq!(rendered_frames(&empty), 256);
288 }
289
290 use crate::midi::MidiChannel;
291
292 fn note_on(n: u8) -> MidiEvent {
293 MidiEvent::NoteOn {
294 channel: MidiChannel::Ch1,
295 note: n,
296 velocity: 100,
297 }
298 }
299 fn note_off(n: u8) -> MidiEvent {
300 MidiEvent::NoteOff {
301 channel: MidiChannel::Ch1,
302 note: n,
303 velocity: 0,
304 }
305 }
306
307 #[test]
308 fn beat_frame_round_trip_at_120_and_140_bpm() {
309 // 120 bpm @ 48k: 1 beat = 0.5s = 24000 frames.
310 let t = Timeline::new(48_000.0, 120.0);
311 assert_eq!(t.beat_to_frame(0.0), 0);
312 assert_eq!(t.beat_to_frame(1.0), 24_000);
313 assert_eq!(t.beat_to_frame(0.5), 12_000);
314 assert_eq!(t.frame_to_beat(24_000), 1.0);
315
316 // 140 bpm @ 48k: 1 beat = 60/140 s ≈ 20571.43 frames → rounds to 20571.
317 let t = Timeline::new(48_000.0, 140.0);
318 assert_eq!(t.beat_to_frame(1.0), 20_571);
319 }
320
321 #[test]
322 fn invalid_bpm_falls_back_to_120() {
323 for bad in [0.0, -10.0, f64::NAN, f64::INFINITY] {
324 let t = Timeline::new(48_000.0, bad);
325 assert_eq!(
326 t.beat_to_frame(1.0),
327 24_000,
328 "bpm {bad} should fall back to 120"
329 );
330 }
331 }
332
333 #[test]
334 fn slices_clip_and_lane_into_block_offsets() {
335 // 120 bpm @ 48k. NoteOn @ beat 0 (frame 0); NoteOff @ beat 0.02 (=0.01s=480 frames).
336 let clip = MidiClip::new()
337 .with(0.0, note_on(60))
338 .with(0.02, note_off(60));
339 let lane = AutomationLane::new(
340 7,
341 ParameterAutomation::new()
342 .add_point(0.0, 0.0)
343 .add_point(4.0, 1.0),
344 1,
345 );
346 let mut t = Timeline::new(48_000.0, 120.0)
347 .with_clip(clip)
348 .with_lane(lane);
349
350 // Block 0: [0, 512). NoteOn @ offset 0, NoteOff @ offset 480; one lane point @ offset 0.
351 let b0 = t.advance_block(512);
352 assert_eq!(b0.midi, vec![(note_on(60), 0), (note_off(60), 480)]);
353 assert_eq!(b0.params.len(), 1);
354 assert_eq!(b0.params[0].0, 7);
355 assert_eq!(b0.params[0].1, 0);
356 assert_eq!(t.sample_clock(), 512);
357
358 // Block 1: [512, 1024). No MIDI (both events were before 512); lane still emits a point.
359 let b1 = t.advance_block(512);
360 assert!(b1.midi.is_empty());
361 assert_eq!(b1.params.len(), 1);
362 assert_eq!(t.sample_clock(), 1024);
363 }
364
365 #[test]
366 fn event_on_block_boundary_lands_in_the_next_block() {
367 // An event whose frame index == clock + frames must fall in the NEXT block (the window
368 // is half-open `[clock, clock + frames)`), guarding the off-by-one.
369 // 120 bpm @ 48k: beat 0.0213333.. → frame 512 exactly.
370 let boundary_beat = 512.0 / (48_000.0 * 60.0 / 120.0);
371 let clip = MidiClip::new().with(boundary_beat, note_on(60));
372 let mut t = Timeline::new(48_000.0, 120.0).with_clip(clip);
373
374 let b0 = t.advance_block(512); // [0, 512)
375 assert!(b0.midi.is_empty(), "frame 512 must not be in block [0,512)");
376 let b1 = t.advance_block(512); // [512, 1024)
377 assert_eq!(
378 b1.midi,
379 vec![(note_on(60), 0)],
380 "lands at offset 0 of the next block"
381 );
382 }
383}