Ashiedu's picture
Sync unified workbench
0490201 verified

A newer version of the Gradio SDK is available: 6.14.0

Upgrade

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]:

spin_sleep = { version = "*" }

Add to kansas/Cargo.toml [dependencies]:

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

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<Mutex<TransportStatus>>,
    /// f32 BPM stored as bits for lock-free reads from the tick thread.
    atomic_bpm: Arc<AtomicU32>,
    /// Set to true to signal the tick thread to exit.
    stop_flag: Arc<AtomicBool>,
}

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<Mutex<TransportStatus>>,
    atomic_bpm: Arc<AtomicU32>,
    stop_flag: Arc<AtomicBool>,
    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<TransportHandle>,
    app: AppHandle,
) -> Result<TransportStatus, IpcError> {
    Ok(handle.apply(cmd, app))
}

/// [T-003] Get current transport status snapshot.
#[tauri::command]
pub fn transport_get(
    handle: tauri::State<TransportHandle>,
) -> Result<TransportStatus, IpcError> {
    Ok(handle.snapshot())
}

Register Managed State in kansas/src/main.rs

Replace the existing tauri::Builder::default() block:

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

// 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

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

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

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

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

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)