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/// A sample-accurate musical timeline driving MIDI clips and automation lanes into a plugin.
98#[derive(Debug, Clone)]
99pub struct Timeline {
100 sample_rate: f64,
101 bpm: f64,
102 sample_clock: u64,
103 clips: Vec<MidiClip>,
104 lanes: Vec<AutomationLane>,
105}
106
107impl Timeline {
108 /// A timeline at `sample_rate` and constant tempo `bpm`. `bpm` must be finite and `> 0`;
109 /// an invalid value falls back to `120.0` so beat↔sample conversion can't produce NaN.
110 pub fn new(sample_rate: f64, bpm: f64) -> Self {
111 let bpm = if bpm.is_finite() && bpm > 0.0 {
112 bpm
113 } else {
114 120.0
115 };
116 Self {
117 sample_rate,
118 bpm,
119 sample_clock: 0,
120 clips: Vec::new(),
121 lanes: Vec::new(),
122 }
123 }
124
125 /// Add a MIDI clip (fluent).
126 pub fn with_clip(mut self, clip: MidiClip) -> Self {
127 self.clips.push(clip);
128 self
129 }
130
131 /// Add an automation lane (fluent).
132 pub fn with_lane(mut self, lane: AutomationLane) -> Self {
133 self.lanes.push(lane);
134 self
135 }
136
137 /// Add a MIDI clip.
138 pub fn add_clip(&mut self, clip: MidiClip) {
139 self.clips.push(clip);
140 }
141
142 /// Add an automation lane.
143 pub fn add_lane(&mut self, lane: AutomationLane) {
144 self.lanes.push(lane);
145 }
146
147 /// The current playhead position in frames since the start.
148 pub fn sample_clock(&self) -> u64 {
149 self.sample_clock
150 }
151
152 /// Move the playhead to `frame` (e.g. to loop or seek). Does not emit events.
153 pub fn seek_frame(&mut self, frame: u64) {
154 self.sample_clock = frame;
155 }
156
157 /// Samples per beat at the current constant tempo.
158 pub fn samples_per_beat(&self) -> f64 {
159 self.sample_rate * 60.0 / self.bpm
160 }
161
162 /// Convert a beat position to an absolute frame index.
163 pub fn beat_to_frame(&self, beat: f64) -> u64 {
164 (beat * self.samples_per_beat()).round().max(0.0) as u64
165 }
166
167 /// Convert an absolute frame index to a beat position.
168 pub fn frame_to_beat(&self, frame: u64) -> f64 {
169 frame as f64 / self.samples_per_beat()
170 }
171
172 /// Collect the events that fall in the next `frames`-sample block as sample offsets, then
173 /// advance the playhead by `frames`. Clip events are windowed by frame index against the
174 /// half-open block `[clock, clock + frames)`; automation lanes emit their per-block points
175 /// (evaluated in the beat domain) tagged with the lane's parameter id.
176 pub fn advance_block(&mut self, frames: usize) -> BlockEvents {
177 let start = self.sample_clock;
178 let end = start + frames as u64;
179 let mut out = BlockEvents::default();
180
181 for clip in &self.clips {
182 for (beat, event) in &clip.events {
183 let frame = self.beat_to_frame(*beat);
184 if frame >= start && frame < end {
185 out.midi.push((*event, (frame - start) as i32));
186 }
187 }
188 }
189 // Deliver scheduled events in time order so a NoteOff never precedes its NoteOn within a
190 // block when two clips overlap.
191 out.midi.sort_by_key(|(_, offset)| *offset);
192
193 if frames > 0 {
194 // Drive points_for_block in the beat domain: passing `samples_per_beat` as the
195 // "sample rate" makes its internal `offset / rate` term read as beats, so a
196 // beat-authored curve is evaluated correctly with sample-accurate offsets.
197 let start_beats = self.frame_to_beat(start);
198 let spb = self.samples_per_beat();
199 for lane in &self.lanes {
200 for (offset, value) in lane.automation.points_for_block(
201 start_beats,
202 frames,
203 spb,
204 lane.points_per_block,
205 ) {
206 out.params.push((lane.param_id, offset, value));
207 }
208 }
209 }
210
211 self.sample_clock = end;
212 out
213 }
214
215 /// Advance one block and drive it into `plugin`: schedule its MIDI and parameter changes at
216 /// their sample offsets, then render `buffers`. The block length is `buffers`' block size.
217 pub fn drive_block(&mut self, plugin: &mut Plugin, buffers: &mut AudioBuffers) -> Result<()> {
218 let frames = buffers.block_size;
219 let events = self.advance_block(frames);
220 for (event, offset) in events.midi {
221 plugin.send_midi_event_at(event, offset)?;
222 }
223 for (id, offset, value) in events.params {
224 plugin.set_parameter_at(id, value, offset)?;
225 }
226 plugin.process_audio(buffers)
227 }
228}
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233 use crate::midi::MidiChannel;
234
235 fn note_on(n: u8) -> MidiEvent {
236 MidiEvent::NoteOn {
237 channel: MidiChannel::Ch1,
238 note: n,
239 velocity: 100,
240 }
241 }
242 fn note_off(n: u8) -> MidiEvent {
243 MidiEvent::NoteOff {
244 channel: MidiChannel::Ch1,
245 note: n,
246 velocity: 0,
247 }
248 }
249
250 #[test]
251 fn beat_frame_round_trip_at_120_and_140_bpm() {
252 // 120 bpm @ 48k: 1 beat = 0.5s = 24000 frames.
253 let t = Timeline::new(48_000.0, 120.0);
254 assert_eq!(t.beat_to_frame(0.0), 0);
255 assert_eq!(t.beat_to_frame(1.0), 24_000);
256 assert_eq!(t.beat_to_frame(0.5), 12_000);
257 assert_eq!(t.frame_to_beat(24_000), 1.0);
258
259 // 140 bpm @ 48k: 1 beat = 60/140 s ≈ 20571.43 frames → rounds to 20571.
260 let t = Timeline::new(48_000.0, 140.0);
261 assert_eq!(t.beat_to_frame(1.0), 20_571);
262 }
263
264 #[test]
265 fn invalid_bpm_falls_back_to_120() {
266 for bad in [0.0, -10.0, f64::NAN, f64::INFINITY] {
267 let t = Timeline::new(48_000.0, bad);
268 assert_eq!(
269 t.beat_to_frame(1.0),
270 24_000,
271 "bpm {bad} should fall back to 120"
272 );
273 }
274 }
275
276 #[test]
277 fn slices_clip_and_lane_into_block_offsets() {
278 // 120 bpm @ 48k. NoteOn @ beat 0 (frame 0); NoteOff @ beat 0.02 (=0.01s=480 frames).
279 let clip = MidiClip::new()
280 .with(0.0, note_on(60))
281 .with(0.02, note_off(60));
282 let lane = AutomationLane::new(
283 7,
284 ParameterAutomation::new()
285 .add_point(0.0, 0.0)
286 .add_point(4.0, 1.0),
287 1,
288 );
289 let mut t = Timeline::new(48_000.0, 120.0)
290 .with_clip(clip)
291 .with_lane(lane);
292
293 // Block 0: [0, 512). NoteOn @ offset 0, NoteOff @ offset 480; one lane point @ offset 0.
294 let b0 = t.advance_block(512);
295 assert_eq!(b0.midi, vec![(note_on(60), 0), (note_off(60), 480)]);
296 assert_eq!(b0.params.len(), 1);
297 assert_eq!(b0.params[0].0, 7);
298 assert_eq!(b0.params[0].1, 0);
299 assert_eq!(t.sample_clock(), 512);
300
301 // Block 1: [512, 1024). No MIDI (both events were before 512); lane still emits a point.
302 let b1 = t.advance_block(512);
303 assert!(b1.midi.is_empty());
304 assert_eq!(b1.params.len(), 1);
305 assert_eq!(t.sample_clock(), 1024);
306 }
307
308 #[test]
309 fn event_on_block_boundary_lands_in_the_next_block() {
310 // An event whose frame index == clock + frames must fall in the NEXT block (the window
311 // is half-open `[clock, clock + frames)`), guarding the off-by-one.
312 // 120 bpm @ 48k: beat 0.0213333.. → frame 512 exactly.
313 let boundary_beat = 512.0 / (48_000.0 * 60.0 / 120.0);
314 let clip = MidiClip::new().with(boundary_beat, note_on(60));
315 let mut t = Timeline::new(48_000.0, 120.0).with_clip(clip);
316
317 let b0 = t.advance_block(512); // [0, 512)
318 assert!(b0.midi.is_empty(), "frame 512 must not be in block [0,512)");
319 let b1 = t.advance_block(512); // [512, 1024)
320 assert_eq!(
321 b1.midi,
322 vec![(note_on(60), 0)],
323 "lands at offset 0 of the next block"
324 );
325 }
326}