use std::sync::atomic::{AtomicBool, AtomicI64, AtomicU32, Ordering};
const ORD: Ordering = Ordering::Relaxed;
#[derive(Debug)]
pub struct Transport {
playing: AtomicBool,
recording: AtomicBool,
looping: AtomicBool,
metronome: AtomicBool,
position_ticks: AtomicI64,
tempo_centibpm: AtomicU32,
loop_start_ticks: AtomicI64,
loop_end_ticks: AtomicI64,
}
#[derive(Debug, Clone, Copy)]
pub struct TransportSnapshot {
pub playing: bool,
pub recording: bool,
pub looping: bool,
pub metronome: bool,
pub position_ticks: i64,
pub tempo_bpm: f64,
pub loop_start_ticks: i64,
pub loop_end_ticks: i64,
}
impl Transport {
pub const PPQ: i64 = 960;
pub fn new(bpm: f64) -> Self {
Self {
playing: AtomicBool::new(false),
recording: AtomicBool::new(false),
looping: AtomicBool::new(false),
metronome: AtomicBool::new(false),
position_ticks: AtomicI64::new(0),
tempo_centibpm: AtomicU32::new((bpm * 100.0) as u32),
loop_start_ticks: AtomicI64::new(0),
loop_end_ticks: AtomicI64::new(Self::PPQ * 4 * 4), }
}
pub fn play(&self) {
self.playing.store(true, ORD);
}
pub fn pause(&self) {
self.playing.store(false, ORD);
}
pub fn stop(&self) {
self.playing.store(false, ORD);
self.position_ticks.store(0, ORD);
}
pub fn toggle_record(&self) {
self.recording.fetch_xor(true, ORD);
}
pub fn toggle_loop(&self) {
self.looping.fetch_xor(true, ORD);
}
pub fn toggle_metronome(&self) {
self.metronome.fetch_xor(true, ORD);
}
pub fn is_metronome_on(&self) -> bool {
self.metronome.load(ORD)
}
pub fn set_tempo(&self, bpm: f64) {
self.tempo_centibpm.store((bpm * 100.0) as u32, ORD);
}
pub fn set_position(&self, ticks: i64) {
self.position_ticks.store(ticks, ORD);
}
pub fn set_loop_range(&self, start_ticks: i64, end_ticks: i64) {
self.loop_start_ticks.store(start_ticks, ORD);
self.loop_end_ticks.store(end_ticks, ORD);
}
pub fn set_loop_bars(&self, start_bar: u32, end_bar: u32) {
let ticks_per_bar = Self::PPQ * 4; self.set_loop_range(
(start_bar.saturating_sub(1) as i64) * ticks_per_bar,
(end_bar as i64) * ticks_per_bar,
);
}
pub fn loop_start(&self) -> i64 { self.loop_start_ticks.load(ORD) }
pub fn loop_end(&self) -> i64 { self.loop_end_ticks.load(ORD) }
pub fn start_loop_record(&self) {
self.looping.store(true, ORD);
self.position_ticks.store(self.loop_start_ticks.load(ORD), ORD);
self.recording.store(true, ORD);
self.playing.store(true, ORD);
}
pub fn stop_loop_record(&self) {
self.recording.store(false, ORD);
self.playing.store(false, ORD);
}
pub fn is_playing(&self) -> bool {
self.playing.load(ORD)
}
pub fn is_recording(&self) -> bool {
self.recording.load(ORD)
}
pub fn is_looping(&self) -> bool {
self.looping.load(ORD)
}
pub fn position_ticks(&self) -> i64 {
self.position_ticks.load(ORD)
}
pub fn tempo_bpm(&self) -> f64 {
self.tempo_centibpm.load(ORD) as f64 / 100.0
}
pub fn advance(&self, num_samples: u32, sample_rate: u32) {
if !self.is_playing() {
return;
}
let bpm = self.tempo_bpm();
let ticks_per_sample = (bpm * Self::PPQ as f64) / (60.0 * sample_rate as f64);
let delta = (num_samples as f64 * ticks_per_sample) as i64;
let mut new_pos = self.position_ticks.load(ORD) + delta;
if self.is_looping() {
let loop_end = self.loop_end_ticks.load(ORD);
let loop_start = self.loop_start_ticks.load(ORD);
if new_pos >= loop_end && loop_end > loop_start {
new_pos = loop_start + (new_pos - loop_end) % (loop_end - loop_start);
}
}
self.position_ticks.store(new_pos, ORD);
}
pub fn snapshot(&self) -> TransportSnapshot {
TransportSnapshot {
playing: self.playing.load(ORD),
recording: self.recording.load(ORD),
looping: self.looping.load(ORD),
metronome: self.metronome.load(ORD),
position_ticks: self.position_ticks.load(ORD),
tempo_bpm: self.tempo_bpm(),
loop_start_ticks: self.loop_start_ticks.load(ORD),
loop_end_ticks: self.loop_end_ticks.load(ORD),
}
}
}
impl Default for Transport {
fn default() -> Self {
Self::new(120.0)
}
}
pub fn ticks_to_position_string(ticks: i64, ppq: i64) -> String {
let ticks_per_beat = ppq;
let ticks_per_bar = ppq * 4;
let bar = ticks / ticks_per_bar + 1;
let beat = (ticks % ticks_per_bar) / ticks_per_beat + 1;
let tick = ticks % ticks_per_beat;
format!("{bar}.{beat}.{tick:03}")
}
pub fn ticks_to_samples(ticks: i64, bpm: f64, sample_rate: f64) -> i64 {
let seconds = ticks as f64 * 60.0 / (bpm * Transport::PPQ as f64);
(seconds * sample_rate) as i64
}
pub fn samples_to_ticks(samples: i64, bpm: f64, sample_rate: f64) -> i64 {
let seconds = samples as f64 / sample_rate;
(seconds * bpm * Transport::PPQ as f64 / 60.0) as i64
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn transport_starts_stopped() {
let t = Transport::default();
assert!(!t.is_playing());
assert!(!t.is_recording());
assert_eq!(t.position_ticks(), 0);
}
#[test]
fn play_pause_stop() {
let t = Transport::default();
t.play();
assert!(t.is_playing());
t.pause();
assert!(!t.is_playing());
t.set_position(1000);
t.play();
t.pause();
assert_eq!(t.position_ticks(), 1000);
t.stop();
assert!(!t.is_playing());
assert_eq!(t.position_ticks(), 0); }
#[test]
fn tempo_set_and_read() {
let t = Transport::new(140.0);
assert!((t.tempo_bpm() - 140.0).abs() < 0.01);
t.set_tempo(95.5);
assert!((t.tempo_bpm() - 95.5).abs() < 0.01);
}
#[test]
fn advance_moves_position() {
let t = Transport::new(120.0);
t.play();
t.advance(44100, 44100); let pos = t.position_ticks();
assert!(
(pos - 1920).abs() <= 1,
"Expected ~1920 ticks after 1s at 120bpm, got {pos}"
);
}
#[test]
fn advance_does_nothing_when_stopped() {
let t = Transport::new(120.0);
t.advance(44100, 44100);
assert_eq!(t.position_ticks(), 0);
}
#[test]
fn loop_wraps_position() {
let t = Transport::new(120.0);
t.set_loop_range(0, 1920); t.toggle_loop();
t.play();
t.advance(44100 * 3, 44100);
let pos = t.position_ticks();
assert!(
pos < 1920,
"Position should have wrapped within loop, got {pos}"
);
}
#[test]
fn position_string_formatting() {
assert_eq!(ticks_to_position_string(0, 960), "1.1.000");
assert_eq!(ticks_to_position_string(960, 960), "1.2.000");
assert_eq!(ticks_to_position_string(3840, 960), "2.1.000");
assert_eq!(ticks_to_position_string(4000, 960), "2.1.160");
}
#[test]
fn tick_sample_conversion_round_trip() {
let bpm = 120.0;
let sr = 44100.0;
for tick in [0, 480, 960, 1920, 3840, 96000] {
let samples = ticks_to_samples(tick, bpm, sr);
let back = samples_to_ticks(samples, bpm, sr);
assert!(
(back - tick).abs() <= 1,
"Round trip failed: {tick} → {samples} → {back}"
);
}
}
#[test]
fn snapshot_reflects_current_state() {
let t = Transport::new(130.0);
t.play();
t.toggle_record();
t.set_position(500);
let snap = t.snapshot();
assert!(snap.playing);
assert!(snap.recording);
assert_eq!(snap.position_ticks, 500);
assert!((snap.tempo_bpm - 130.0).abs() < 0.01);
}
#[test]
fn loop_wraps_at_boundary() {
let t = Transport::new(120.0);
t.set_loop_range(0, 7680); t.toggle_loop();
t.play();
let mut wrapped = false;
for _ in 0..800 {
let before = t.position_ticks();
t.advance(256, 44100);
if t.position_ticks() < before {
wrapped = true;
break;
}
}
assert!(wrapped, "Loop should have wrapped. pos={}", t.position_ticks());
}
}