Skip to main content

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}