# T-003: Transport State Machine — BPM, Bar/Beat/Tick Clock, Event Stream **Type:** Task **Phase:** 0 — Foundation **Autonomy:** `agent:autonomous` — Self-contained Rust state machine with no external deps beyond `spin_sleep`. No human review needed before merge. **Stack:** `stack:rust` **Version:** v0.1 **Iteration:** iter-1 **Effort:** S (half-day) --- > ⚠️ **Agent Scope:** Implement the transport clock only. This task replaces the T-002 stubs for `transport_set` and `transport_get`. It does **not** touch audio I/O (T-004), MIDI output (T-068), or any model inference. The only output of this task is `transport_tick` events arriving at the correct BPM and a working `transport_get` IPC command. --- ## Context The transport clock is the timing backbone for Perf RNN note scheduling, Magenta RT chunk boundaries, DDSP frame sync, DualSense beat haptics, and the Conductor Agent's downbeat invocation. Every one of those subsystems listens to `transport_tick` events. If ticks drift, the entire generation pipeline drifts. This must be right before anything rhythmic is built on top of it. **96 PPQN** (pulses per quarter note) is the MIDI standard resolution. At 128 BPM: ``` tick_interval = 60_000_000 μs / 128 BPM / 96 PPQN = 4882.8 μs ≈ 4.88 ms/tick ``` Windows default timer resolution is 15.6 ms — far too coarse for 4.88 ms ticks. This task uses the `spin_sleep` crate which calibrates a native sleep and spins for the tail, achieving <100 μs accuracy without burning a full CPU core. --- ## Prerequisites - [ ] T-001 merged — `kansas/` workspace exists - [ ] T-002 merged — `TransportSetCmd`, `TransportStatus`, `TransportTickEvent`, `TransportState`, `IpcError` all exist in `kansas/src/ipc/types.rs` - [ ] `cargo check --workspace` passes before starting --- ## New Workspace Dependency Add to the root `Cargo.toml` `[workspace.dependencies]`: ```toml spin_sleep = { version = "*" } ``` Add to `kansas/Cargo.toml` `[dependencies]`: ```toml spin_sleep = { workspace = true } ``` --- ## Acceptance Criteria ### Correctness - [ ] At 120 BPM, `transport_tick` events arrive at 192 ticks/sec ±2% (measured over 5 seconds) - [ ] At 60 BPM, events arrive at 96 ticks/sec ±2% - [ ] `is_downbeat` is `true` exactly when `beat == 1 && tick == 0`, false otherwise - [ ] `beat` is always 1-indexed and wraps at `time_sig_numerator` - [ ] `bar` increments by 1 each time beat wraps from `time_sig_numerator` back to 1 - [ ] BPM change mid-play via `transport_set` takes effect within one tick period — no skip, no gap, no duplicate tick ### State Transitions - [ ] `Stopped → Playing`: tick thread spawns, bar/beat/tick reset to 1/1/0, `elapsed_ms` reset to 0 - [ ] `Playing → Paused`: tick thread stops, bar/beat/tick position frozen, `elapsed_ms` frozen - [ ] `Paused → Playing`: tick thread respawns, resumes from frozen position, `elapsed_ms` continues - [ ] `Playing → Stopped`: tick thread stops, position resets to 1/1/0, `elapsed_ms` resets to 0 - [ ] `Paused → Stopped`: position resets - [ ] Calling `transport_set` with only `bpm` (no state change) updates BPM without interrupting playback - [ ] Calling `transport_set` with only `time_sig_numerator` updates time sig without interrupting playback ### Resource Safety - [ ] No thread leak — every spawned tick thread is stopped before a new one starts - [ ] No `unwrap()` anywhere in `transport.rs` - [ ] `cargo clippy --workspace -- -D warnings` passes ### IPC - [ ] `transport_get` returns the live snapshot including current `bar`, `beat`, `tick`, `elapsed_ms` - [ ] `transport_set` returns the updated `TransportStatus` after applying the change --- ## Files Changed ``` kansas/src/ ├── main.rs ← add TransportHandle to tauri::Builder::manage() ├── commands/ │ └── transport.rs ← REPLACE stub with full implementation (this task) └── (no new files needed) ``` --- ## Implementation ### `kansas/src/commands/transport.rs` ```rust use std::{ sync::{ atomic::{AtomicBool, AtomicU32, Ordering}, Arc, Mutex, }, thread, time::{Duration, Instant}, }; use tauri::AppHandle; use crate::ipc::{ error::IpcError, types::{TransportSetCmd, TransportState, TransportStatus, TransportTickEvent}, }; /// Pulses per quarter note — MIDI standard, do not change. const PPQN: u32 = 96; /// Shared handle registered as Tauri managed state. /// Clone-safe: Arc internals mean cloning is cheap. #[derive(Clone)] pub struct TransportHandle { /// Live status snapshot — updated by both the tick thread and IPC commands. status: Arc>, /// f32 BPM stored as bits for lock-free reads from the tick thread. atomic_bpm: Arc, /// Set to true to signal the tick thread to exit. stop_flag: Arc, } impl TransportHandle { pub fn new() -> Self { let initial = TransportStatus { state: TransportState::Stopped, bpm: 128.0, time_sig_numerator: 4, time_sig_denominator: 4, bar: 1, beat: 1, tick: 0, elapsed_ms: 0, }; Self { atomic_bpm: Arc::new(AtomicU32::new(initial.bpm.to_bits())), status: Arc::new(Mutex::new(initial)), stop_flag: Arc::new(AtomicBool::new(false)), } } /// Apply a TransportSetCmd, manage thread lifecycle, return updated snapshot. pub fn apply(&self, cmd: TransportSetCmd, app: AppHandle) -> TransportStatus { let mut s = self.status.lock().unwrap(); // Apply scalar field changes if let Some(bpm) = cmd.bpm { let clamped = bpm.clamp(20.0, 300.0); s.bpm = clamped; // Update atomic so running tick thread picks up new BPM immediately self.atomic_bpm.store(clamped.to_bits(), Ordering::Relaxed); } if let Some(n) = cmd.time_sig_numerator { s.time_sig_numerator = n.max(1); } if let Some(d) = cmd.time_sig_denominator { s.time_sig_denominator = d; } // Apply state transition if let Some(new_state) = cmd.state { match (&s.state, &new_state) { // Already in this state — no-op (TransportState::Playing, TransportState::Playing) | (TransportState::Paused, TransportState::Paused) | (TransportState::Stopped, TransportState::Stopped) => {} // Start playing (from stopped or paused) (_, TransportState::Playing) => { if matches!(s.state, TransportState::Stopped) { s.bar = 1; s.beat = 1; s.tick = 0; s.elapsed_ms = 0; } s.state = TransportState::Playing; self.stop_flag.store(false, Ordering::Relaxed); self.spawn_tick_thread(app, s.time_sig_numerator); } // Pause — freeze position (TransportState::Playing, TransportState::Paused) => { self.stop_flag.store(true, Ordering::Relaxed); s.state = TransportState::Paused; } // Stop — reset position (_, TransportState::Stopped) => { self.stop_flag.store(true, Ordering::Relaxed); s.state = TransportState::Stopped; s.bar = 1; s.beat = 1; s.tick = 0; s.elapsed_ms = 0; } // Resume from paused (TransportState::Paused, TransportState::Playing) => { s.state = TransportState::Playing; self.stop_flag.store(false, Ordering::Relaxed); self.spawn_tick_thread(app, s.time_sig_numerator); } } } s.clone() } pub fn snapshot(&self) -> TransportStatus { self.status.lock().unwrap().clone() } fn spawn_tick_thread(&self, app: AppHandle, time_sig_numerator: u8) { // Ensure any previous thread has time to observe the stop flag. // A brief yield is sufficient since stop_flag was already set true // before calling spawn_tick_thread for any restart scenario. thread::yield_now(); let status = Arc::clone(&self.status); let atomic_bpm = Arc::clone(&self.atomic_bpm); let stop_flag = Arc::clone(&self.stop_flag); thread::Builder::new() .name("synesthesia-transport".into()) .spawn(move || { tick_loop(app, status, atomic_bpm, stop_flag, time_sig_numerator); }) .expect("failed to spawn transport tick thread"); } } // ─── Tick Loop ──────────────────────────────────────────────────────────────── fn tick_loop( app: AppHandle, status: Arc>, atomic_bpm: Arc, stop_flag: Arc, time_sig_numerator: u8, ) { let start_wall = Instant::now(); let mut next_tick_at = start_wall; loop { if stop_flag.load(Ordering::Relaxed) { break; } let now = Instant::now(); if now >= next_tick_at { // ── Fire tick ──────────────────────────────────────────────────── let event = { let mut s = status.lock().unwrap(); s.elapsed_ms = start_wall.elapsed().as_millis() as u64; let is_downbeat = s.beat == 1 && s.tick == 0; let event = TransportTickEvent { bar: s.bar, beat: s.beat, tick: s.tick, bpm: s.bpm, elapsed_ms: s.elapsed_ms, is_downbeat, }; // Advance counters s.tick += 1; if s.tick >= PPQN { s.tick = 0; s.beat += 1; if s.beat > s.time_sig_numerator as u32 { s.beat = 1; s.bar += 1; } } event }; app.emit("transport_tick", &event).ok(); // ── Schedule next tick ─────────────────────────────────────────── // Read BPM lock-free — picks up any mid-play BPM changes immediately. let bpm = f32::from_bits(atomic_bpm.load(Ordering::Relaxed)); let tick_micros = (60_000_000.0 / bpm / PPQN as f32) as u64; next_tick_at += Duration::from_micros(tick_micros); // If we've fallen more than one tick behind (CPU spike, debugger pause), // skip ahead rather than firing a burst of catch-up ticks. let now2 = Instant::now(); if now2 > next_tick_at + Duration::from_micros(tick_micros) { next_tick_at = now2 + Duration::from_micros(tick_micros); } } else { // ── Precise sleep until next tick ──────────────────────────────── let remaining = next_tick_at - now; spin_sleep::sleep(remaining); } } } // ─── IPC Commands ───────────────────────────────────────────────────────────── /// [T-003] Set transport state (play/pause/stop), BPM, or time signature. /// All fields optional — send only what you want to change. #[tauri::command] pub fn transport_set( cmd: TransportSetCmd, handle: tauri::State, app: AppHandle, ) -> Result { Ok(handle.apply(cmd, app)) } /// [T-003] Get current transport status snapshot. #[tauri::command] pub fn transport_get( handle: tauri::State, ) -> Result { Ok(handle.snapshot()) } ``` --- ## Register Managed State in `kansas/src/main.rs` Replace the existing `tauri::Builder::default()` block: ```rust use commands::transport::TransportHandle; fn main() { tauri::Builder::default() .manage(TransportHandle::new()) .invoke_handler(tauri::generate_handler![ commands::ping::ping, commands::transport::transport_set, commands::transport::transport_get, // T-004+ commands registered here as they are implemented ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } ``` --- ## Implementation Notes ### Why `spin_sleep` and not `std::thread::sleep` `std::thread::sleep` on Windows has a default resolution of ~15.6 ms (the OS timer interrupt period). At 128 BPM a tick fires every 4.88 ms — a single sleep call would already be 3× overshot. `spin_sleep` calls `Sleep(1)` with `timeBeginPeriod(1)` under the hood to get ~1 ms native sleep, then spins the final sub-millisecond with `Instant::now()` polling. Result: <100 μs jitter without pinning a core. ### Why `AtomicU32` for BPM The tick thread must read BPM every ~5 ms without taking the `status` mutex (which may be briefly held by the IPC command handler). Storing the f32 as `u32` bits in an `AtomicU32` gives a lock-free single-word read. The value is always a valid f32 because `to_bits`/`from_bits` is a bitwise reinterpret, not a conversion — no NaN injection possible from a clamped BPM value. ### Why the skip-ahead guard If a debugger breakpoint or GC spike delays the tick thread by more than one tick period, naively catching up would fire a burst of ticks in rapid succession — breaking any RNN that expects 5ms spacing. The guard detects lag > 1 tick and resets `next_tick_at` to `now + 1 tick`, sacrificing timing continuity for musical continuity. ### Time signature changes mid-play If `time_sig_numerator` changes mid-play (e.g. 4/4 → 3/4), the change is reflected immediately in the `status` mutex. The tick thread reads `s.time_sig_numerator` on every beat wrap, so the new meter takes effect at the next beat boundary — no mid-beat meter glitch. --- ## Testing ### Automated - [ ] `cargo check --workspace` — zero errors - [ ] `cargo clippy --workspace -- -D warnings` — zero warnings ### Manual **1. Tick rate accuracy** ```javascript // DevTools console — run after transport starts playing let count = 0; const start = Date.now(); const unlisten = await window.__TAURI__.event.listen('transport_tick', () => count++); await new Promise(r => setTimeout(r, 5000)); unlisten(); const rate = count / 5; console.log(`Rate: ${rate} ticks/sec (expected: ${128 * 96 / 60} at 128 BPM)`); // Expected: ~204.8 ticks/sec ±2% → 200.7–208.9 ``` **2. Downbeat flag** ```javascript await window.__TAURI__.event.listen('transport_tick', (e) => { if (e.payload.isDownbeat) { console.log(`Downbeat at bar ${e.payload.bar}`); } }); // Should log once per bar (every 4 beats × 96 ticks = 384 ticks) ``` **3. BPM change mid-play** ```javascript await window.__TAURI__.core.invoke('transport_set', { cmd: { state: { kind: 'playing' } } }); await new Promise(r => setTimeout(r, 2000)); await window.__TAURI__.core.invoke('transport_set', { cmd: { bpm: 200 } }); // Tick rate should increase immediately — no gap or burst in DevTools event log ``` **4. Pause/resume position preservation** ```javascript await window.__TAURI__.core.invoke('transport_set', { cmd: { state: { kind: 'playing' } } }); await new Promise(r => setTimeout(r, 3000)); const before = await window.__TAURI__.core.invoke('transport_get'); await window.__TAURI__.core.invoke('transport_set', { cmd: { state: { kind: 'paused' } } }); const paused = await window.__TAURI__.core.invoke('transport_get'); console.assert(paused.bar === before.bar, 'bar preserved on pause'); console.assert(paused.beat === before.beat, 'beat preserved on pause'); // Wait 2s — elapsed_ms should NOT change while paused await new Promise(r => setTimeout(r, 2000)); const stillPaused = await window.__TAURI__.core.invoke('transport_get'); console.assert(stillPaused.elapsedMs === paused.elapsedMs, 'elapsed frozen while paused'); ``` **5. Stop resets position** ```javascript await window.__TAURI__.core.invoke('transport_set', { cmd: { state: { kind: 'stopped' } } }); const stopped = await window.__TAURI__.core.invoke('transport_get'); console.assert(stopped.bar === 1, 'bar reset'); console.assert(stopped.beat === 1, 'beat reset'); console.assert(stopped.tick === 0, 'tick reset'); console.assert(stopped.elapsedMs === 0, 'elapsed reset'); ``` --- ## Iteration Protocol > If any acceptance criterion cannot be met: do **not** close. Add a comment: > ``` > ## Blocker — [date] > Criterion: [paste verbatim] > Root cause: [what happened] > Attempted: [what you tried] > Decision needed: [what the human must decide] > ``` > Then set `status:roadmap`. --- ## GitHub CLI ```bash gh issue create \ --title "T-003: Transport state machine — BPM, bar/beat/tick clock, event stream" \ --label "type:task,stack:rust,agent:autonomous,priority:critical,status:ready,day:1" \ --body-file T-003.md ``` --- **Parent:** GENESIS **Blocks:** T-063 (Perf RNN tick sync), T-081 (Magenta RT chunk boundary), T-072 (DualSense beat haptics), T-002b (Conductor Agent downbeat invocation) **Blocked By:** T-001, T-002 **Version:** v0.1 · **Iteration:** iter-1 · **Effort:** S (half-day)