Skip to main content

phosphor_core/
transport.rs

1//! Transport state: play, pause, stop, record, loop.
2//!
3//! The transport is the single source of truth for playback position.
4//! The audio thread reads it via atomics — no locks.
5
6use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
7
8/// Relaxed ordering — sufficient for single-producer (UI) single-consumer (audio)
9/// where we don't need happens-before guarantees across variables.
10const ORD: Ordering = Ordering::Relaxed;
11
12/// Playback state readable from any thread without locking.
13#[derive(Debug)]
14pub struct Transport {
15    playing: AtomicBool,
16    recording: AtomicBool,
17    looping: AtomicBool,
18    metronome: AtomicBool,
19    /// Current position in ticks (960 PPQ).
20    position_ticks: AtomicI64,
21    /// Tempo in BPM × 100 (e.g., 12000 = 120.00 BPM). Integer atomics avoid f64 issues.
22    tempo_centibpm: AtomicU32,
23    /// Loop start in ticks.
24    loop_start_ticks: AtomicI64,
25    /// Loop end in ticks.
26    loop_end_ticks: AtomicI64,
27}
28
29/// Snapshot of transport state for the UI to display. Cheap to copy.
30#[derive(Debug, Clone, Copy)]
31pub struct TransportSnapshot {
32    pub playing: bool,
33    pub recording: bool,
34    pub looping: bool,
35    pub metronome: bool,
36    pub position_ticks: i64,
37    pub tempo_bpm: f64,
38    pub loop_start_ticks: i64,
39    pub loop_end_ticks: i64,
40}
41
42impl Transport {
43    /// Ticks per quarter note.
44    pub const PPQ: i64 = 960;
45
46    pub fn new(bpm: f64) -> Self {
47        Self {
48            playing: AtomicBool::new(false),
49            recording: AtomicBool::new(false),
50            looping: AtomicBool::new(false),
51            metronome: AtomicBool::new(false),
52            position_ticks: AtomicI64::new(0),
53            tempo_centibpm: AtomicU32::new((bpm * 100.0) as u32),
54            loop_start_ticks: AtomicI64::new(0),
55            loop_end_ticks: AtomicI64::new(Self::PPQ * 4 * 4), // default 4 bars
56        }
57    }
58
59    // -- Controls (called from UI thread) --
60
61    pub fn play(&self) {
62        self.playing.store(true, ORD);
63    }
64
65    pub fn pause(&self) {
66        self.playing.store(false, ORD);
67    }
68
69    pub fn stop(&self) {
70        self.playing.store(false, ORD);
71        self.position_ticks.store(0, ORD);
72    }
73
74    pub fn toggle_record(&self) {
75        self.recording.fetch_xor(true, ORD);
76    }
77
78    pub fn toggle_loop(&self) {
79        self.looping.fetch_xor(true, ORD);
80    }
81
82    pub fn toggle_metronome(&self) {
83        self.metronome.fetch_xor(true, ORD);
84    }
85
86    pub fn is_metronome_on(&self) -> bool {
87        self.metronome.load(ORD)
88    }
89
90    pub fn set_tempo(&self, bpm: f64) {
91        self.tempo_centibpm.store((bpm * 100.0) as u32, ORD);
92    }
93
94    pub fn set_position(&self, ticks: i64) {
95        self.position_ticks.store(ticks, ORD);
96    }
97
98    pub fn set_loop_range(&self, start_ticks: i64, end_ticks: i64) {
99        self.loop_start_ticks.store(start_ticks, ORD);
100        self.loop_end_ticks.store(end_ticks, ORD);
101    }
102
103    /// Set loop range by bar numbers (1-based, in 4/4 time).
104    /// E.g., bars 1-4 = ticks 0..3840.
105    pub fn set_loop_bars(&self, start_bar: u32, end_bar: u32) {
106        let ticks_per_bar = Self::PPQ * 4; // 4/4 time
107        self.set_loop_range(
108            (start_bar.saturating_sub(1) as i64) * ticks_per_bar,
109            (end_bar as i64) * ticks_per_bar,
110        );
111    }
112
113    pub fn loop_start(&self) -> i64 { self.loop_start_ticks.load(ORD) }
114    pub fn loop_end(&self) -> i64 { self.loop_end_ticks.load(ORD) }
115
116    /// Start recording within the loop range.
117    /// Sets up loop, rewinds to loop start, enables record + play.
118    pub fn start_loop_record(&self) {
119        self.looping.store(true, ORD);
120        self.position_ticks.store(self.loop_start_ticks.load(ORD), ORD);
121        self.recording.store(true, ORD);
122        self.playing.store(true, ORD);
123    }
124
125    /// Stop loop recording. Disables record, stops playback.
126    pub fn stop_loop_record(&self) {
127        self.recording.store(false, ORD);
128        self.playing.store(false, ORD);
129    }
130
131    // -- Reads (called from audio thread — lock-free) --
132
133    pub fn is_playing(&self) -> bool {
134        self.playing.load(ORD)
135    }
136
137    pub fn is_recording(&self) -> bool {
138        self.recording.load(ORD)
139    }
140
141    pub fn is_looping(&self) -> bool {
142        self.looping.load(ORD)
143    }
144
145    pub fn position_ticks(&self) -> i64 {
146        self.position_ticks.load(ORD)
147    }
148
149    pub fn tempo_bpm(&self) -> f64 {
150        self.tempo_centibpm.load(ORD) as f64 / 100.0
151    }
152
153    /// Advance position by the given number of samples. Handles loop wrapping.
154    /// Called from the audio thread each buffer cycle.
155    pub fn advance(&self, num_samples: u32, sample_rate: u32) {
156        if !self.is_playing() {
157            return;
158        }
159
160        let bpm = self.tempo_bpm();
161        let ticks_per_sample = (bpm * Self::PPQ as f64) / (60.0 * sample_rate as f64);
162        let delta = (num_samples as f64 * ticks_per_sample) as i64;
163
164        let mut new_pos = self.position_ticks.load(ORD) + delta;
165
166        if self.is_looping() {
167            let loop_end = self.loop_end_ticks.load(ORD);
168            let loop_start = self.loop_start_ticks.load(ORD);
169            if new_pos >= loop_end && loop_end > loop_start {
170                new_pos = loop_start + (new_pos - loop_end) % (loop_end - loop_start);
171            }
172        }
173
174        self.position_ticks.store(new_pos, ORD);
175    }
176
177    /// Take a snapshot for the UI to display.
178    pub fn snapshot(&self) -> TransportSnapshot {
179        TransportSnapshot {
180            playing: self.playing.load(ORD),
181            recording: self.recording.load(ORD),
182            looping: self.looping.load(ORD),
183            metronome: self.metronome.load(ORD),
184            position_ticks: self.position_ticks.load(ORD),
185            tempo_bpm: self.tempo_bpm(),
186            loop_start_ticks: self.loop_start_ticks.load(ORD),
187            loop_end_ticks: self.loop_end_ticks.load(ORD),
188        }
189    }
190}
191
192impl Default for Transport {
193    fn default() -> Self {
194        Self::new(120.0)
195    }
196}
197
198/// Convert ticks to bar.beat.tick string (assumes 4/4 time).
199pub fn ticks_to_position_string(ticks: i64, ppq: i64) -> String {
200    let ticks_per_beat = ppq;
201    let ticks_per_bar = ppq * 4; // 4/4 time
202
203    let bar = ticks / ticks_per_bar + 1;
204    let beat = (ticks % ticks_per_bar) / ticks_per_beat + 1;
205    let tick = ticks % ticks_per_beat;
206
207    format!("{bar}.{beat}.{tick:03}")
208}
209
210/// Convert ticks to samples at a given tempo and sample rate.
211pub fn ticks_to_samples(ticks: i64, bpm: f64, sample_rate: f64) -> i64 {
212    let seconds = ticks as f64 * 60.0 / (bpm * Transport::PPQ as f64);
213    (seconds * sample_rate) as i64
214}
215
216/// Convert samples to ticks at a given tempo and sample rate.
217pub fn samples_to_ticks(samples: i64, bpm: f64, sample_rate: f64) -> i64 {
218    let seconds = samples as f64 / sample_rate;
219    (seconds * bpm * Transport::PPQ as f64 / 60.0) as i64
220}
221
222#[cfg(test)]
223mod tests {
224    use super::*;
225
226    #[test]
227    fn transport_starts_stopped() {
228        let t = Transport::default();
229        assert!(!t.is_playing());
230        assert!(!t.is_recording());
231        assert_eq!(t.position_ticks(), 0);
232    }
233
234    #[test]
235    fn play_pause_stop() {
236        let t = Transport::default();
237        t.play();
238        assert!(t.is_playing());
239        t.pause();
240        assert!(!t.is_playing());
241
242        // Pause preserves position
243        t.set_position(1000);
244        t.play();
245        t.pause();
246        assert_eq!(t.position_ticks(), 1000);
247
248        t.stop();
249        assert!(!t.is_playing());
250        assert_eq!(t.position_ticks(), 0); // position reset on stop
251    }
252
253    #[test]
254    fn tempo_set_and_read() {
255        let t = Transport::new(140.0);
256        assert!((t.tempo_bpm() - 140.0).abs() < 0.01);
257        t.set_tempo(95.5);
258        assert!((t.tempo_bpm() - 95.5).abs() < 0.01);
259    }
260
261    #[test]
262    fn advance_moves_position() {
263        let t = Transport::new(120.0);
264        t.play();
265        // At 120 BPM, 960 PPQ, 44100 Hz:
266        // ticks_per_sample = 120 * 960 / (60 * 44100) = 0.04354
267        // 64 samples = ~2.79 ticks
268        t.advance(44100, 44100); // advance 1 second
269        let pos = t.position_ticks();
270        // 1 second at 120 BPM = 2 beats = 1920 ticks
271        assert!(
272            (pos - 1920).abs() <= 1,
273            "Expected ~1920 ticks after 1s at 120bpm, got {pos}"
274        );
275    }
276
277    #[test]
278    fn advance_does_nothing_when_stopped() {
279        let t = Transport::new(120.0);
280        t.advance(44100, 44100);
281        assert_eq!(t.position_ticks(), 0);
282    }
283
284    #[test]
285    fn loop_wraps_position() {
286        let t = Transport::new(120.0);
287        t.set_loop_range(0, 1920); // loop 2 beats
288        t.toggle_loop();
289        t.play();
290
291        // Advance 3 seconds (= 5760 ticks at 120bpm)
292        t.advance(44100 * 3, 44100);
293        let pos = t.position_ticks();
294        // 5760 % 1920 = 0, so should wrap to 0
295        assert!(
296            pos < 1920,
297            "Position should have wrapped within loop, got {pos}"
298        );
299    }
300
301    #[test]
302    fn position_string_formatting() {
303        assert_eq!(ticks_to_position_string(0, 960), "1.1.000");
304        assert_eq!(ticks_to_position_string(960, 960), "1.2.000");
305        assert_eq!(ticks_to_position_string(3840, 960), "2.1.000");
306        assert_eq!(ticks_to_position_string(4000, 960), "2.1.160");
307    }
308
309    #[test]
310    fn tick_sample_conversion_round_trip() {
311        let bpm = 120.0;
312        let sr = 44100.0;
313        for tick in [0, 480, 960, 1920, 3840, 96000] {
314            let samples = ticks_to_samples(tick, bpm, sr);
315            let back = samples_to_ticks(samples, bpm, sr);
316            assert!(
317                (back - tick).abs() <= 1,
318                "Round trip failed: {tick} → {samples} → {back}"
319            );
320        }
321    }
322
323    #[test]
324    fn snapshot_reflects_current_state() {
325        let t = Transport::new(130.0);
326        t.play();
327        t.toggle_record();
328        t.set_position(500);
329        let snap = t.snapshot();
330        assert!(snap.playing);
331        assert!(snap.recording);
332        assert_eq!(snap.position_ticks, 500);
333        assert!((snap.tempo_bpm - 130.0).abs() < 0.01);
334    }
335
336
337    #[test]
338    fn loop_wraps_at_boundary() {
339        let t = Transport::new(120.0);
340        t.set_loop_range(0, 7680); // 2 bars
341        t.toggle_loop();
342        t.play();
343
344        // At 120bpm, 44100Hz, 256 samples/buffer: ~11 ticks/buffer
345        // 7680 / 11 ≈ 698 buffers needed
346        let mut wrapped = false;
347        for _ in 0..800 {
348            let before = t.position_ticks();
349            t.advance(256, 44100);
350            if t.position_ticks() < before {
351                wrapped = true;
352                break;
353            }
354        }
355        assert!(wrapped, "Loop should have wrapped. pos={}", t.position_ticks());
356    }
357}