metronome/rust/pm-grid/src/main.rs
Me Here bdf69cfd30 pm-grid: start main.rs modularization (extract fonts module)
Move the 3x5 LED font (DIGITS, glyph, build_name_cols) into src/fonts.rs.
Pure code move, compiler-verified identical behavior; main.rs 1835 -> ~1770 lines.
First step of the recommended main.rs split; further extraction (FAT/MSC storage,
views) to continue incrementally as those areas are touched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 11:24:09 -05:00

1762 lines
65 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! VARASYS PolyMeter — PM_G-1 "Grid" firmware (Rust edition).
//!
//! Target: a *plain* Raspberry Pi Pico (RP2040, Cortex-M0+) wearing the Pimoroni Pico Scroll Pack
//! (PIM545): a 17x7 single-colour white LED matrix on an IS31FL3731 (I2C @ 0x74) + 4 buttons
//! (A/B/X/Y). No speaker, no touch. This is the Rust sibling of `pico-scroll/app.py` and the UI
//! prototype for the eventual `pm-grid` board (see docs/rust-port.md).
//!
//! Scope of this milestone (LED-first, like pm-kit's bring-up): the IS31FL3731 driver, the
//! polymeter scheduler (driven by the shared `track-format` crate — the cross-impl contract),
//! 4-button input, three LED views (Ticker / Grid / Pendulum), the built-in set lists, and
//! per-track ramp + gap-trainer. Audio is over USB-MIDI on this board, which — like pm-kit — is
//! the NEXT milestone (along with live-sync SysEx, firmware push, practice log). No speaker here.
//!
//! Pins (Pimoroni Pico Scroll Pack, verified against pico-scroll/app.py):
//! I2C0 SDA=GP4 SCL=GP5 (IS31FL3731 @ 0x74; relies on the RP2040's INTERNAL pull-ups)
//! Buttons (active-low): A=GP12 B=GP13 X=GP14 Y=GP15
#![no_std]
#![no_main]
extern crate alloc;
mod fonts;
use fonts::{build_name_cols, DIGITS};
use alloc::collections::VecDeque;
use alloc::string::{String, ToString};
use alloc::vec::Vec;
use embedded_alloc::LlffHeap as Heap;
use cortex_m::delay::Delay;
use defmt::info;
use defmt_rtt as _; // global defmt logger over RTT (read by `probe-rs run`)
use embedded_hal::digital::InputPin;
use embedded_hal::i2c::I2c;
use panic_probe as _; // prints the panic over defmt, then halts
use rp2040_hal as hal;
use rp2040_hal::fugit::RateExtU32;
use rp2040_hal::Clock;
use track_format::End;
use usb_device::prelude::*;
use usb_device::bus::UsbBusAllocator;
use usbd_midi::UsbMidiClass;
use usbd_storage::subclass::scsi::{Scsi, ScsiCommand};
use usbd_storage::subclass::Command;
use usbd_storage::transport::bbb::{BulkOnly, BulkOnlyError};
use usbd_storage::transport::TransportError;
#[global_allocator]
static HEAP: Heap = Heap::empty();
/// Second-stage bootloader for the Pico's W25Q080-style QSPI flash (placed at flash start).
#[link_section = ".boot2"]
#[used]
pub static BOOT2_FIRMWARE: [u8; 256] = rp2040_boot2::BOOT_LOADER_W25Q080;
// ---- USB Mass Storage: a 1 MB FAT volume in the upper flash (the host formats it on first use) ----
const FS_BLOCK_SIZE: u32 = 4096;
const FS_BLOCKS: u32 = 256; // 256 × 4 KB = 1 MB
const FS_LEN: usize = (FS_BLOCK_SIZE * FS_BLOCKS) as usize;
const MSC_PACKET: u16 = 64;
const MSC_MAX_LUN: u8 = 0;
/// The filesystem flash region (see memory.x `.filesystem`, NOLOAD). We never read it *through* this
/// static (it would const-fold to 0); reads go via raw pointers and writes via `rp2040-flash`.
#[link_section = ".filesystem"]
#[used]
static FILESYSTEM: [u8; FS_LEN] = [0u8; FS_LEN];
/// Bulk-Only-Transport scratch buffer for usbd-storage (one block).
static mut MSC_BUF: core::mem::MaybeUninit<[u8; FS_BLOCK_SIZE as usize]> =
core::mem::MaybeUninit::uninit();
/// A freshly-formatted, empty FAT12 volume labelled "PM_G-1" (boot sector + 2 FATs + root dir, the
/// first 7 × 4 KB sectors made by `mkfs.fat -F12 -S4096 -n PM_G-1`). Written to flash when the drive
/// isn't already ours, so the host shows it as "PM_G-1" instead of a leftover CircuitPython volume.
static FAT_TEMPLATE: &[u8] = include_bytes!("fat_template.bin");
// ============================== ON-DEVICE FAT (read the drive) ==============================
// Read-only access to the Mass Storage FAT volume so the device can load programs.json (user set
// lists). Writes go through MSC (the host); here we only read + (re)format to set the label.
#[derive(Debug)]
struct FsErr;
impl fatfs::IoError for FsErr {
fn is_interrupted(&self) -> bool {
false
}
fn new_unexpected_eof_error() -> Self {
FsErr
}
fn new_write_zero_error() -> Self {
FsErr
}
}
/// A byte cursor over the flash `.filesystem` region. Reads hit flash; writes are discarded (we only
/// read on-device — formatting is a separate bulk flash write below).
struct FlashIo {
pos: u64,
}
impl FlashIo {
fn new() -> Self {
FlashIo { pos: 0 }
}
}
impl fatfs::IoBase for FlashIo {
type Error = FsErr;
}
impl fatfs::Read for FlashIo {
fn read(&mut self, buf: &mut [u8]) -> Result<usize, FsErr> {
let p = self.pos as usize;
if p >= FS_LEN {
return Ok(0);
}
let n = buf.len().min(FS_LEN - p);
// black_box the base so the compiler can't const-fold the (zero-init, NOLOAD) static to 0
let base = core::hint::black_box(FILESYSTEM.as_ptr());
unsafe { core::ptr::copy_nonoverlapping(base.add(p), buf.as_mut_ptr(), n) };
self.pos += n as u64;
Ok(n)
}
}
impl fatfs::Write for FlashIo {
fn write(&mut self, buf: &[u8]) -> Result<usize, FsErr> {
self.pos += buf.len() as u64; // discard (on-device is read-only)
Ok(buf.len())
}
fn flush(&mut self) -> Result<(), FsErr> {
Ok(())
}
}
impl fatfs::Seek for FlashIo {
fn seek(&mut self, from: fatfs::SeekFrom) -> Result<u64, FsErr> {
let np: i64 = match from {
fatfs::SeekFrom::Start(o) => o as i64,
fatfs::SeekFrom::End(o) => FS_LEN as i64 + o,
fatfs::SeekFrom::Current(o) => self.pos as i64 + o,
};
if np < 0 {
return Err(FsErr);
}
self.pos = np as u64;
Ok(self.pos)
}
}
/// Write the blank PM_G-1 template over the metadata sectors (erases the leftover volume → empty).
/// One 4 KB sector per call — exactly the proven MSC write pattern (a multi-sector call is riskier).
fn format_pmg1() {
let base = FILESYSTEM.as_ptr() as u32 & 0x00ff_ffff & !0xfff; // flash offset 0x100000
let sz = FS_BLOCK_SIZE as usize;
let sectors = FAT_TEMPLATE.len() / sz;
for s in 0..sectors {
let mut sector = [0u8; FS_BLOCK_SIZE as usize];
sector.copy_from_slice(&FAT_TEMPLATE[s * sz..(s + 1) * sz]);
let faddr = base + (s as u32) * FS_BLOCK_SIZE;
cortex_m::interrupt::free(|_| unsafe {
rp2040_flash::flash::flash_range_erase_and_program(faddr, &sector, false);
});
}
}
/// Read programs.json (if present) into user set lists. READ-ONLY — never writes the FAT, so it's
/// safe to call at runtime even while the host has the drive mounted.
fn read_programs_json() -> Vec<SetList> {
let io = FlashIo::new();
let mut out = Vec::new();
if let Ok(fs) = fatfs::FileSystem::new(io, fatfs::FsOptions::new()) {
if let Ok(mut f) = fs.root_dir().open_file("programs.json") {
let mut data = Vec::new();
let mut tmp = [0u8; 256];
loop {
match fatfs::Read::read(&mut f, &mut tmp) {
Ok(0) => break,
Ok(n) => data.extend_from_slice(&tmp[..n]),
_ => break,
}
if data.len() > 64 * 1024 {
break; // sanity cap
}
}
if let Ok(s) = core::str::from_utf8(&data) {
out = parse_setlists(s);
}
info!("fat: programs.json -> {} user set list(s)", out.len());
}
}
out
}
/// Boot path: if the drive isn't a "PM_G-1"-labelled FAT, (re)format it, then read programs.json.
/// The format flash-write only happens here (at boot, before USB) — runtime re-reads never write.
fn read_user_setlists() -> Vec<SetList> {
let is_ours = {
let io = FlashIo::new();
match fatfs::FileSystem::new(io, fatfs::FsOptions::new()) {
Ok(fs) => fs
.read_volume_label_from_root_dir()
.ok()
.flatten()
.as_deref()
== Some("PM_G-1"),
Err(_) => false,
}
};
if !is_ours {
info!("fat: (re)formatting drive as PM_G-1");
format_pmg1();
}
read_programs_json()
}
/// Build the runtime set-list table: built-ins (static → owned) followed by the drive's set lists.
fn build_setlists(user: Vec<SetList>) -> Vec<SetList> {
let mut v: Vec<SetList> = SETLISTS
.iter()
.map(|(title, items)| SetList {
title: String::from(*title),
items: items.iter().map(|(n, p)| (String::from(*n), String::from(*p))).collect(),
})
.collect();
v.extend(user);
v.retain(|s| !s.items.is_empty()); // never keep an empty set list (would `% 0` in load/next)
v
}
/// Read a JSON string starting at the opening quote `b[start]`; returns (value, index-after-close).
fn json_str(b: &[u8], start: usize) -> Option<(String, usize)> {
let mut s = String::new();
let mut i = start + 1;
while i < b.len() {
match b[i] {
b'\\' => {
i += 1;
let e = *b.get(i)?;
s.push(match e {
b'n' => '\n',
b't' => '\t',
b'r' => '\r',
_ => e as char,
});
i += 1;
}
b'"' => return Some((s, i + 1)),
c => {
s.push(c as char);
i += 1;
}
}
}
None
}
/// Tolerant extractor for the editor's programs.json: walks `"title"`/`"name"`/`"prog"` string
/// fields in document order. Each `title` starts a set list; each `name`+`prog` appends an item.
fn parse_setlists(json: &str) -> Vec<SetList> {
let b = json.as_bytes();
let mut out: Vec<SetList> = Vec::new();
let mut pending_name: Option<String> = None;
let mut i = 0;
while i < b.len() {
if b[i] != b'"' {
i += 1;
continue;
}
let (key, after_key) = match json_str(b, i) {
Some(kv) => kv,
None => break,
};
// is this string a key (followed by ':' then a string value)?
let mut j = after_key;
while j < b.len() && (b[j] as char).is_ascii_whitespace() {
j += 1;
}
if j >= b.len() || b[j] != b':' {
i = after_key;
continue;
}
j += 1;
while j < b.len() && (b[j] as char).is_ascii_whitespace() {
j += 1;
}
if j >= b.len() || b[j] != b'"' {
i = j;
continue;
}
let (val, after_val) = match json_str(b, j) {
Some(kv) => kv,
None => break,
};
match key.as_str() {
"title" => out.push(SetList { title: val, items: Vec::new() }),
"name" => pending_name = Some(val),
"prog" => {
let name = pending_name.take().unwrap_or_else(|| String::from("?"));
if out.is_empty() {
out.push(SetList { title: String::from("My set list"), items: Vec::new() });
}
out.last_mut().unwrap().items.push((name, val));
}
_ => {}
}
i = after_val;
}
out.retain(|s| !s.items.is_empty());
out
}
const XTAL_FREQ_HZ: u32 = 12_000_000;
const MATRIX_ADDR: u8 = 0x74;
const DEVICE_ID: &str = "G"; // reported on the SysEx version query (0x02→0x03)
const APP_VERSION: &str = "0.1.0";
// Brightness ladder (matches pico-scroll/app.py BRIGHTNESS=160 with the same scaling).
const BRIGHTNESS: u8 = 160; // accent
const NAME_BRIGHT: u8 = 120; // ticker name pixels
// ============================== IS31FL3731 DRIVER (bulk framebuffer) ==============================
// Faithful port of pico-scroll/app.py's `Matrix`: keep a 144-byte PWM framebuffer and push the
// WHOLE thing in one I2C block write per frame (per-pixel I2C is far too slow to animate). The
// Scroll Pack wires the 17x7 matrix with the Scroll pHAT HD pixel map.
fn pixel_addr(x: i32, y: i32) -> usize {
let (x, y) = if x <= 8 { (8 - x, 6 - y) } else { (x - 8, y - 8) };
(x * 16 + y) as usize
}
struct Matrix<I> {
i2c: I,
fb: [u8; 145], // fb[0] = COLOR register offset (0x24); fb[1..] = 144 PWM bytes
}
impl<I: I2c> Matrix<I> {
fn new(i2c: I, delay: &mut Delay) -> Self {
let mut m = Matrix { i2c, fb: [0u8; 145] };
m.fb[0] = 0x24;
// --- config (mirrors pico-scroll/app.py Matrix.__init__) ---
let _ = m.i2c.write(MATRIX_ADDR, &[0xFD, 0x0B]); // select Function (config) bank
let _ = m.i2c.write(MATRIX_ADDR, &[0x0A, 0x00]); // software shutdown while configuring
let mut cfg = [0u8; 14];
cfg[0] = 0x00; // clear config regs 0x00..0x0C (Picture Mode, frame 0, audiosync off)
let _ = m.i2c.write(MATRIX_ADDR, &cfg);
let _ = m.i2c.write(MATRIX_ADDR, &[0xFD, 0x00]); // select frame 0
let mut led_ctrl = [0xFFu8; 19];
led_ctrl[0] = 0x00; // LED-control regs 0x00..0x11 -> enable every LED
let _ = m.i2c.write(MATRIX_ADDR, &led_ctrl);
let _ = m.i2c.write(MATRIX_ADDR, &[0xFD, 0x0B]);
let _ = m.i2c.write(MATRIX_ADDR, &[0x0A, 0x01]); // normal operation
let _ = m.i2c.write(MATRIX_ADDR, &[0xFD, 0x00]); // frame 0 for all PWM writes
delay.delay_ms(1);
m.show();
m
}
fn clear(&mut self) {
for b in self.fb[1..].iter_mut() {
*b = 0;
}
}
fn fill(&mut self, v: u8) {
for b in self.fb[1..].iter_mut() {
*b = v;
}
}
fn get(&self, x: i32, y: i32) -> u8 {
if (0..17).contains(&x) && (0..7).contains(&y) {
self.fb[1 + pixel_addr(x, y)]
} else {
0
}
}
fn set(&mut self, x: i32, y: i32, v: u8) {
if (0..17).contains(&x) && (0..7).contains(&y) {
self.fb[1 + pixel_addr(x, y)] = v;
}
}
/// Brightest-wins set (matches the views' `if val > m.get(...)` guard).
fn set_max(&mut self, x: i32, y: i32, v: u8) {
if v > self.get(x, y) {
self.set(x, y, v);
}
}
fn show(&mut self) {
let _ = self.i2c.write(MATRIX_ADDR, &self.fb);
}
}
// ============================== BUILT-IN SET LISTS (same as Kit/Explorer) ==============================
type Item = (&'static str, &'static str);
type BuiltinSet = (&'static str, &'static [Item]);
/// A runtime set list (built-ins converted to owned + any loaded from the drive's programs.json).
struct SetList {
#[allow(dead_code)] // parsed from programs.json; not shown yet (Ticker shows the track name)
title: String,
items: Vec<(String, String)>, // (name, program-string)
}
static STYLES: &[Item] = &[
("Four-on-the-floor", "t120;kick:4;snare:4=.x.x;hatClosed:4/2"),
("Swing ride", "t150;ride:4/2s;kick:4=X..x;snare:4=.x.x"),
("Purdie half-time shuffle", "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"),
("Samba (2/4)", "t104;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."),
("Nanigo (6/8 bembe)", "t130;cowbell:4/3=X.xx.x.xx.x.;kick:4/3=X.....X.....;hatClosed:4/3=..x..x..x..x"),
("6/8 groove", "t100;kick:3+3=x..x..;snare:3+3=...x..;hatClosed:3+3/2"),
("7/8 (2+2+3)", "t130;kick:2+2+3=x..x..x;hatClosed:2+2+3/2"),
("5/4 (3+2)", "t112;kick:3+2=x..x.;snare:3+2=..x..;hatClosed:3+2/2"),
];
static PRACTICE: &[Item] = &[
("5 over 4 polyrhythm", "t100;kick:4;claves:5~"),
("3 over 2 hemiola", "t96;woodblock:2;cowbell:3~"),
("2 & 4 & 3 over one bar", "t100;kick:3;cowbell:2~;claves:4~"),
("Triplet hats", "t100;kick:4;snare:4=.x.x;hatClosed:4/3"),
("Tempo builder 80 up", "t80;woodblock:4;rmp80/4/4"),
("Gap trainer (play 2 / rest 2)", "t100;kick:4;hatClosed:4/2;tr2/2"),
];
static SONG: &[Item] = &[
("Intro - hats & kick", "t88;b4;kick:4=X.x.;hatClosed:4/2=gggggggg"),
("Groove in - backbeat", "t88;b4;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2"),
("Half-time shuffle", "t92;b4;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x"),
("Build - ramp 92-120", "t92;b4;rmp92/4/2;kick:4;snare:4=.X.X;hatClosed:4/2"),
("Four-on-the-floor (909)", "t124;b4;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X"),
("Samba break (2/4)", "t116;b4;tomLow:2/4=x...X...;hatClosed:2/4;woodblock:2/4=X.xx.xX."),
("Peak - 16ths", "t132;b4;kick:4=X..x;snare:4=.X.X;hatClosed:4/4"),
("Outro - ramp down", "t132;b4;rmp132/-7/1;kick:4=X..x;hatClosed:4/2=gggggggg"),
];
static SETLISTS: &[BuiltinSet] = &[("Styles", STYLES), ("Practice", PRACTICE), ("Song", SONG)];
// ============================== APP STATE ==============================
const NS_PER_MIN: i64 = 60_000_000_000;
#[derive(Clone, Copy, PartialEq)]
enum View {
Ticker,
Grid,
Pendulum,
}
/// A next track preloaded (parsed + durations computed) so the playback-flow advance is seamless.
struct Pending {
track: track_format::Track,
durs: Vec<Vec<i64>>,
item: usize,
name: String,
name_cols: Vec<u8>,
}
struct App {
setlists: Vec<SetList>, // built-ins (owned) + any loaded from the drive
// current program
track: track_format::Track,
name: String,
name_cols: Vec<u8>, // 3x5 column slices of the (uppercased) name + trailing gap
sl: usize, // set-list index
item: usize, // item within the set list
// tempo + scheduler
tempo: i64,
ramp_base: i64,
durs: Vec<Vec<i64>>, // per-lane per-step durations (ns)
next: Vec<i64>, // per-lane next fire time (ns)
step: Vec<i32>, // per-lane current step (-1 = not started)
m_steps: i64,
lastbar: i64,
muted: bool, // gap-trainer mute
playing: bool,
// view / animation
view: View,
scroll_off: i32,
scroll_total: i32,
beatflash: u8,
beatflash_off: i64,
bpm_flash_off: i64, // while >0 and active, Grid/Pendulum briefly show the Ticker so nudges are visible
full_flash_off: i64, // strobe the WHOLE matrix bright on the downbeat ("the 1")
// --- USB-MIDI TX queue (notes + SysEx, drained one-per-poll) + live-sync state ---
tx_q: VecDeque<[u8; 4]>, // outgoing USB-MIDI event packets
sx_buf: Vec<u8>, // incoming SysEx assembler (between 0xF0 and 0xF7)
sx_on: bool,
sync_origin: String, // our session id; we ignore frames stamped with it (echo guard)
sync_seq: u32,
sync_armed: bool, // a peer (the editor) is connected
sync_applying: bool, // suppress re-broadcast while applying a received frame
sync_hb_next: i64, // next FULL heartbeat time (ns)
// --- playback flow (rep/end auto-advance) ---
pending: Option<Pending>, // next track, preloaded one bar early
advance: bool, // set at a seam boundary → do_advance() after the lane loop
seam_ns: i64, // the exact bar-boundary time the next track starts at
continue_on: bool, // "continuous" mode: treat a bars-track with no end= as end=next
// --- MIDI clock out (24 PPQN, so a DAW can slave to the Grid) ---
clock_out: bool,
clock_next: i64,
// --- MIDI clock in (slave the Grid's tempo to an external 24-PPQN clock) ---
clock_in: bool,
clock_in_last: i64, // timestamp of the last F8 (ns); 0 = waiting for the first
clock_in_avg: i64, // EMA of the F8 interval (ns)
slaved: bool, // currently following an external clock
}
fn master_bar_ns(track: &track_format::Track, tempo: i64) -> i64 {
let beat = NS_PER_MIN / tempo.max(1);
let m = &track.lanes[0];
let beats = (m.levels.len().max(1) / m.sub.max(1) as usize) as i64;
beat * beats.max(1)
}
const SCROLL_GAP: i32 = 13; // blank columns between repeats (>= name region width) for a clean loop
impl App {
fn new(now_ns: i64, user: Vec<SetList>) -> Self {
let setlists = build_setlists(user);
let prog = setlists[0].items[0].1.clone();
let track = track_format::parse(&prog);
let tempo = track.bpm;
let mut app = App {
setlists,
track,
name: String::new(),
name_cols: Vec::new(),
sl: 0,
item: 0,
tempo,
ramp_base: tempo,
durs: Vec::new(),
next: Vec::new(),
step: Vec::new(),
m_steps: 0,
lastbar: -1,
muted: false,
playing: false,
view: View::Ticker,
scroll_off: 0,
scroll_total: 1,
beatflash: 0,
beatflash_off: 0,
bpm_flash_off: 0,
full_flash_off: 0,
tx_q: VecDeque::new(),
sx_buf: Vec::new(),
sx_on: false,
// origin derived from the boot timer (distinct from the editor's "e…"/"d…" ids)
sync_origin: alloc::format!("g{:06x}", (now_ns / 1000) as u32 & 0xff_ffff),
sync_seq: 0,
sync_armed: false,
sync_applying: false,
sync_hb_next: 0,
pending: None,
advance: false,
seam_ns: 0,
continue_on: false,
clock_out: true,
clock_next: 0,
clock_in: true,
clock_in_last: 0,
clock_in_avg: 0,
slaved: false,
};
app.load(0, 0, now_ns);
app
}
fn rebuild_durs(&mut self) {
let mbar = master_bar_ns(&self.track, self.tempo);
self.durs = self
.track
.lanes
.iter()
.map(|l| track_format::schedule::lane_durs(l, self.tempo, mbar))
.collect();
}
fn reset_clock(&mut self, now_ns: i64) {
let n = self.track.lanes.len();
self.next = alloc::vec![now_ns; n];
self.step = alloc::vec![-1i32; n];
self.m_steps = 0;
self.lastbar = -1;
self.clock_next = now_ns;
}
fn load(&mut self, sl: usize, item: usize, now_ns: i64) {
self.sl = sl % self.setlists.len();
let n = self.setlists[self.sl].items.len();
self.item = item % n;
let (name, prog) = self.setlists[self.sl].items[self.item].clone();
self.track = track_format::parse(&prog);
self.name = name;
self.name_cols = build_name_cols(&self.name);
self.scroll_total = self.name_cols.len() as i32 + SCROLL_GAP;
self.scroll_off = 0;
self.tempo = self.track.bpm;
self.ramp_base = self.tempo;
self.muted = false;
self.pending = None;
self.advance = false;
self.rebuild_durs();
self.reset_clock(now_ns);
}
/// Live re-read: replace the user set lists from a fresh programs.json without disturbing the
/// currently-playing track (just makes the new lists reachable via B-hold). Clamps indices.
fn reload_user(&mut self, user: Vec<SetList>) {
self.setlists = build_setlists(user);
if self.sl >= self.setlists.len() {
self.sl = 0;
}
if self.item >= self.setlists[self.sl].items.len() {
self.item = 0;
}
}
fn next_track(&mut self, now_ns: i64) {
let n = self.setlists[self.sl].items.len();
self.load(self.sl, (self.item + 1) % n, now_ns);
let sel = alloc::format!("sel={}/{}", self.sl, self.item);
self.sync_broadcast(&sel);
}
fn next_setlist(&mut self, now_ns: i64) {
self.load((self.sl + 1) % self.setlists.len(), 0, now_ns);
let sel = alloc::format!("sel={}/{}", self.sl, self.item);
self.sync_broadcast(&sel);
}
fn set_bpm(&mut self, v: i64, now_ns: i64) {
let v = v.clamp(5, 300);
if v != self.tempo {
self.tempo = v;
self.rebuild_durs();
self.bpm_flash_off = now_ns + 700_000_000;
let evt = alloc::format!("bpm={}", v);
self.sync_broadcast(&evt);
}
}
fn toggle(&mut self, now_ns: i64) {
self.playing = !self.playing;
if self.playing {
self.reset_clock(now_ns);
if self.clock_out {
self.tx_q.push_back([0x0F, 0xFA, 0, 0]); // MIDI Start
}
} else if self.clock_out {
self.tx_q.push_back([0x0F, 0xFC, 0, 0]); // MIDI Stop
}
self.sync_broadcast(if self.playing { "play" } else { "stop" });
}
fn cycle_view(&mut self) {
self.view = match self.view {
View::Ticker => View::Grid,
View::Grid => View::Pendulum,
View::Pendulum => View::Ticker,
};
}
/// Ramp + gap-trainer + playback-flow at a master-bar boundary. Returns the new tempo if the
/// ramp changed it (applied by the caller after the lane loop, so we never mutate `durs` mid-tick).
fn on_new_bar(&mut self, bar: i64) -> Option<i64> {
// playback flow: rep/end auto-advance (total = bars * rep cycles)
if let Some((total, action)) = self.end_plan() {
let goto_off = match action {
End::Goto(o) => Some(o),
End::Stop => None,
};
// preload the next track one bar early so the swap is seamless
if let Some(o) = goto_off {
if self.pending.is_none() && bar == total - 1 {
let tgt = self.goto_target(o);
self.prepare_next(tgt);
}
}
if bar > 0 && bar == total {
match goto_off {
None => {
self.playing = false;
self.sync_broadcast("stop");
}
Some(o) => {
if self.pending.is_none() {
let tgt = self.goto_target(o);
self.prepare_next(tgt);
}
if self.pending.is_some() {
self.seam_ns = self.next[0]; // the master lane's current bar boundary
self.advance = true;
}
}
}
}
}
// gap-trainer mute
if let Some(t) = &self.track.trainer {
let cyc = t.play + t.mute;
self.muted = cyc > 0 && (bar % cyc) >= t.play;
}
// tempo ramp (suppressed while slaved to an external clock — it owns the tempo)
if let (false, Some(r)) = (self.slaved, &self.track.ramp) {
let steps0 = self.track.lanes[0].levels.len().max(1) as i64;
let bar_pos = self.m_steps / steps0;
let seg_bar = if self.track.bars > 0 {
bar_pos % self.track.bars
} else {
bar_pos
};
let new = (self.ramp_base + seg_bar / r.every.max(1) * r.amt).clamp(5, 300);
if new != self.tempo {
return Some(new);
}
}
None
}
/// Advance the per-lane step clocks up to `now_ns`. Sets `beatflash` for the loudest hit and
/// queues a USB-MIDI note-on (ch10) per lane hit onto `tx_q` (drained by the main loop).
fn tick(&mut self, now_ns: i64) {
// drop the external-clock lock if ticks stopped arriving (>1s gap)
if self.slaved && now_ns - self.clock_in_last > 1_000_000_000 {
self.slaved = false;
}
if !self.playing {
return;
}
let nlanes = self.track.lanes.len();
let mut fired_best = 0u8;
let mut pending_tempo: Option<i64> = None;
for li in 0..nlanes {
if self.advance {
break;
}
let steps = self.durs[li].len().max(1) as i32;
while now_ns >= self.next[li] {
self.step[li] = (self.step[li] + 1) % steps;
if li == 0 {
if self.step[li] == 0 {
// the downbeat — strobe the entire matrix bright
self.full_flash_off = now_ns + 80_000_000;
}
self.m_steps += 1;
let bar = (self.m_steps - 1) / steps as i64;
if bar != self.lastbar {
self.lastbar = bar;
if let Some(t) = self.on_new_bar(bar) {
pending_tempo = Some(t);
}
if self.advance {
break; // seam reached — stop advancing this lane; do_advance() below
}
}
}
let s = self.step[li] as usize;
let lvl = if self.track.lanes[li].mute {
0
} else {
self.track.lanes[li].levels[s]
};
if lvl > 0 && !self.muted {
let note = gm_note(self.track.lanes[li].sound.as_str());
if self.tx_q.len() < 128 {
self.tx_q.push_back([0x09, 0x99, note, midi_vel(lvl)]); // note-on, ch10
}
if prio(lvl) > prio(fired_best) {
fired_best = lvl;
}
}
self.next[li] += self.durs[li][s].max(1);
}
}
if fired_best > 0 {
self.beatflash = fired_best;
self.beatflash_off = now_ns + 70_000_000;
}
if self.advance {
self.advance = false;
self.do_advance(); // swap to the preloaded next track at the seam
} else if let Some(t) = pending_tempo {
// tempo change keeps step counts identical (only durations scale) → safe to swap durs
self.tempo = t;
self.rebuild_durs();
}
// MIDI clock out: emit 24-PPQN F8 ticks against the wall clock (not while we're the slave)
if self.clock_out && !self.slaved {
let tick_ns = ((NS_PER_MIN / self.tempo.max(1)) / 24).max(1);
while now_ns >= self.clock_next {
if self.tx_q.len() < 200 {
self.tx_q.push_back([0x0F, 0xF8, 0, 0]); // MIDI Clock
}
self.clock_next += tick_ns;
}
}
}
}
// ============================== LIVE-SYNC (USB-MIDI SysEx; docs/livesync-protocol.md) ==============================
impl App {
/// Feed one received MIDI byte: assembles SysEx (0xF0..0xF7) and handles realtime clock
/// (0xF8 tick, 0xFA/0xFB start, 0xFC stop). Called for the data bytes of incoming USB-MIDI packets.
fn feed_midi(&mut self, b: u8, now_ns: i64) {
match b {
0xF0 => {
self.sx_buf.clear();
self.sx_on = true;
}
0xF7 => {
if self.sx_on {
self.handle_sysex(now_ns);
}
self.sx_on = false;
}
0xF8 => {
if self.clock_in {
self.slave_tick(now_ns);
}
}
0xFA | 0xFB => {
if self.clock_in {
self.slave_start(now_ns);
}
}
0xFC => {
if self.clock_in {
self.slave_stop();
}
}
_ if b >= 0xF8 => {} // other realtime (active sensing etc.)
_ => {
if self.sx_on && self.sx_buf.len() < 2048 {
self.sx_buf.push(b);
}
}
}
}
/// One external clock tick (0xF8): track the EMA interval and derive tempo (24 PPQN).
fn slave_tick(&mut self, now_ns: i64) {
if self.clock_in_last == 0 {
self.clock_in_last = now_ns;
self.slaved = true;
return;
}
let interval = now_ns - self.clock_in_last;
self.clock_in_last = now_ns;
if !(8_300_000..=500_000_000).contains(&interval) {
return; // out of 5..300 BPM range → ignore (jitter/glitch)
}
self.clock_in_avg = if self.clock_in_avg == 0 {
interval
} else {
(self.clock_in_avg * 7 + interval) / 8
};
let new_bpm = (NS_PER_MIN / (self.clock_in_avg * 24)).clamp(5, 300);
if new_bpm != self.tempo {
self.tempo = new_bpm;
self.rebuild_durs();
}
self.slaved = true;
}
fn slave_start(&mut self, now_ns: i64) {
if !self.playing {
self.playing = true;
self.reset_clock(now_ns);
}
self.clock_in_last = 0;
self.clock_in_avg = 0;
}
fn slave_stop(&mut self) {
self.playing = false;
self.clock_in_last = 0;
self.clock_in_avg = 0;
self.slaved = false;
}
/// Build `F0 7D <op> <text(ASCII-clamped)> F7`, packetize into 4-byte USB-MIDI events, queue them.
fn sx_send(&mut self, op: u8, text: &str) {
// Bound the TX queue: if the host stopped draining MIDI-IN (e.g. editor closed without a BYE
// while sync_armed), drop the message rather than grow the heap forever. The 5 s heartbeat
// re-syncs once the host returns.
if self.tx_q.len() > 256 {
return;
}
let mut f: Vec<u8> = Vec::with_capacity(text.len() + 4);
f.push(0xF0);
f.push(0x7D);
f.push(op);
for &c in text.as_bytes() {
f.push(if c < 0x80 { c } else { 0x3F });
}
f.push(0xF7);
let mut i = 0;
while f.len() - i > 3 {
self.tx_q.push_back([0x04, f[i], f[i + 1], f[i + 2]]); // SysEx start/continue
i += 3;
}
match f.len() - i {
1 => self.tx_q.push_back([0x05, f[i], 0, 0]),
2 => self.tx_q.push_back([0x06, f[i], f[i + 1], 0]),
3 => self.tx_q.push_back([0x07, f[i], f[i + 1], f[i + 2]]),
_ => {}
}
}
fn handle_sysex(&mut self, now_ns: i64) {
if self.sx_buf.len() < 2 || self.sx_buf[0] != 0x7D {
return;
}
let cmd = self.sx_buf[1];
if cmd == 0x02 {
// version query → reply 0x03 "G;<version>"
let reply = alloc::format!("{};{}", DEVICE_ID, APP_VERSION);
info!("sysex: version query → {}", reply.as_str());
self.sx_send(0x03, &reply);
return;
}
if !(0x40..=0x43).contains(&cmd) {
return;
}
// payload text (printable ASCII only)
let text: String = self.sx_buf[2..]
.iter()
.cloned()
.filter(|&b| (0x20..0x7F).contains(&b))
.map(|b| b as char)
.collect();
let origin = text.splitn(2, ';').next().unwrap_or("");
if origin == self.sync_origin.as_str() {
return; // ignore our own echo
}
self.sync_armed = true;
match cmd {
0x40 => {
info!("sysex: HELLO → FULL");
self.sync_broadcast_full(now_ns);
}
0x43 => {
info!("sysex: BYE");
self.sync_armed = false;
}
0x41 => {
// origin;seq;running;sl;item;patch (patch may contain ';' → splitn 6)
let mut it = text.splitn(6, ';');
let _ = it.next(); // origin
let _ = it.next(); // seq
let running = it.next() == Some("1");
let _ = it.next(); // sl
let _ = it.next(); // item
if let Some(patch) = it.next() {
let patch = patch.to_string();
info!("sysex: FULL running={} ({} chars)", running, patch.len());
self.apply_full(running, &patch, now_ns);
}
}
0x42 => {
// origin;seq;evt
let mut it = text.splitn(3, ';');
let _ = it.next();
let _ = it.next();
if let Some(evt) = it.next() {
let evt = evt.to_string();
info!("sysex: DELTA {}", evt.as_str());
self.apply_delta(&evt, now_ns);
}
}
_ => {}
}
}
/// DELTA broadcast (transport/tempo/track changes from on-device input).
fn sync_broadcast(&mut self, evt: &str) {
if !self.sync_armed || self.sync_applying {
return;
}
let text = alloc::format!("{};{};{}", self.sync_origin, self.sync_seq, evt);
self.sync_seq = self.sync_seq.wrapping_add(1);
self.sx_send(0x42, &text);
}
/// FULL broadcast: our whole program + transport (HELLO reply + ~5s heartbeat).
fn sync_broadcast_full(&mut self, now_ns: i64) {
if !self.sync_armed {
return;
}
self.track.bpm = self.tempo; // reflect the live tempo in the serialized patch
let patch = track_format::serialize(&self.track);
let text = alloc::format!(
"{};{};{};{};{};{}",
self.sync_origin,
self.sync_seq,
if self.playing { 1 } else { 0 },
self.sl,
self.item,
patch
);
self.sync_seq = self.sync_seq.wrapping_add(1);
self.sx_send(0x41, &text);
self.sync_hb_next = now_ns + 5_000_000_000;
}
fn apply_full(&mut self, running: bool, patch: &str, now_ns: i64) {
self.sync_applying = true;
self.track = track_format::parse(patch);
self.tempo = self.track.bpm;
self.ramp_base = self.tempo;
self.muted = false;
self.rebuild_durs();
self.reset_clock(now_ns);
self.playing = running;
self.sync_applying = false;
}
fn apply_delta(&mut self, evt: &str, now_ns: i64) {
self.sync_applying = true;
let (key, val) = evt.split_once('=').unwrap_or((evt, ""));
match key {
"play" => {
if !self.playing {
self.playing = true;
self.reset_clock(now_ns);
if self.clock_out {
self.tx_q.push_back([0x0F, 0xFA, 0, 0]); // MIDI Start
}
}
}
"stop" => {
if self.playing && self.clock_out {
self.tx_q.push_back([0x0F, 0xFC, 0, 0]); // MIDI Stop
}
self.playing = false;
}
"bpm" => {
if let Ok(v) = val.parse::<i64>() {
self.set_bpm(v, now_ns);
}
}
"sel" => {
let mut p = val.split('/');
if let (Some(a), Some(b)) = (p.next(), p.next()) {
if let (Ok(sl), Ok(item)) = (a.parse::<usize>(), b.parse::<usize>()) {
if sl < self.setlists.len() {
self.load(sl, item, now_ns);
}
}
}
}
"beat" => {
// li/s/lvl — single-cell level edit
let mut p = val.split('/');
if let (Some(a), Some(b), Some(c)) = (p.next(), p.next(), p.next()) {
if let (Ok(li), Ok(s), Ok(lvl)) =
(a.parse::<usize>(), b.parse::<usize>(), c.parse::<u8>())
{
if li < self.track.lanes.len() && s < self.track.lanes[li].levels.len() {
self.track.lanes[li].levels[s] = lvl & 3;
}
}
}
}
_ => {} // structural lane= edits arrive as a fresh FULL; nothing to do here
}
self.sync_applying = false;
}
// ---------- playback flow (rep/end auto-advance) ----------
/// `(total_bars, action)`: the end-action fires after `bars * rep` cycles. `None` = loop forever.
fn end_plan(&self) -> Option<(i64, End)> {
let end = match &self.track.end {
Some(e) => e.clone(),
None => {
if self.continue_on && self.track.bars > 0 {
End::Goto(1)
} else {
return None;
}
}
};
let cyc = if self.track.bars > 0 { self.track.bars } else { 1 };
let reps = self.track.rep.unwrap_or(1).max(1);
Some((cyc * reps, end))
}
/// Resolve a relative track offset into a set-list item index (clamps below 0, wraps above).
fn goto_target(&self, offset: i64) -> usize {
let n = self.setlists[self.sl].items.len() as i64;
let t = self.item as i64 + offset;
(if t < 0 {
0
} else if t >= n {
t % n
} else {
t
}) as usize
}
/// Preload `target` (parse + per-lane durations) so `do_advance` is just a pointer swap.
fn prepare_next(&mut self, target: usize) {
if target == self.item {
return;
}
let (name, prog) = self.setlists[self.sl].items[target].clone();
let track = track_format::parse(&prog);
let mbar = master_bar_ns(&track, track.bpm);
let durs = track
.lanes
.iter()
.map(|l| track_format::schedule::lane_durs(l, track.bpm, mbar))
.collect();
let nm = String::from(name);
let name_cols = build_name_cols(&nm);
self.pending = Some(Pending { track, durs, item: target, name: nm, name_cols });
}
/// Swap to the preloaded next track at the seam time (gapless: every lane restarts at `seam_ns`).
fn do_advance(&mut self) {
if let Some(p) = self.pending.take() {
let seam = self.seam_ns;
self.track = p.track;
self.tempo = self.track.bpm;
self.durs = p.durs;
self.item = p.item;
self.name = p.name;
self.name_cols = p.name_cols;
self.scroll_total = self.name_cols.len() as i32 + SCROLL_GAP;
self.ramp_base = self.tempo;
self.muted = false;
self.m_steps = 0;
self.lastbar = -1;
let n = self.track.lanes.len();
self.next = alloc::vec![seam; n];
self.step = alloc::vec![-1i32; n];
self.clock_next = seam;
let sel = alloc::format!("sel={}/{}", self.sl, self.item);
self.sync_broadcast(&sel);
}
}
}
fn prio(level: u8) -> u8 {
match level {
2 => 3, // accent
1 => 2, // normal
3 => 1, // ghost
_ => 0,
}
}
fn lvl_bright(lvl: u8) -> u8 {
match lvl {
2 => BRIGHTNESS,
1 => (BRIGHTNESS / 4).max(8),
3 => (BRIGHTNESS / 16).max(3),
_ => 0,
}
}
/// Sound name → General MIDI drum note (channel 10). Ports pico-scroll/app.py's SOUND_GM.
fn gm_note(sound: &str) -> u8 {
match sound {
"kick" | "kick808" | "kick909" => 36,
"snare" | "snare808" | "snare909" => 38,
"clap" | "clap808" | "clap909" => 39,
"rim" => 37,
"hatClosed" | "hat808" | "hat909" => 42,
"hatOpen" | "openHat808" => 46,
"ride" | "ride909" => 51,
"crash" | "crash909" => 49,
"tomLow" => 41,
"tom808" | "tomMid" => 45,
"tomHigh" => 48,
"tambourine" => 54,
"cowbell" | "cowbell808" => 56,
"woodblock" | "jamblock" => 76,
"claves" => 75,
_ => 37, // beep / unknown → GM_DEFAULT
}
}
/// Level → MIDI velocity (accent / normal / ghost). Ports pico-scroll/app.py's MIDI_VEL.
fn midi_vel(level: u8) -> u8 {
match level {
2 => 120,
1 => 90,
3 => 45,
_ => 90,
}
}
// ============================== RENDERING ==============================
fn render<I: I2c>(m: &mut Matrix<I>, app: &App, now_ns: i64) {
// downbeat strobe: the whole matrix at full brightness on "the 1"
if now_ns < app.full_flash_off {
m.fill(255);
m.show();
return;
}
m.clear();
// a tempo nudge briefly forces the Ticker (so X/Y is visible from Grid/Pendulum)
let view = if app.view != View::Ticker && now_ns < app.bpm_flash_off {
View::Ticker
} else {
app.view
};
match view {
View::Ticker => draw_ticker(m, app),
View::Grid => draw_grid(m, app),
View::Pendulum => draw_pendulum(m, app, now_ns),
}
m.show();
}
/// Ticker: a beat strip runs along the top row (cols 0..=10); the track name infinite-scrolls below
/// it (cols 0..=10, rows 2..=6); BPM is pinned to the right rotated 90° CCW — a vertical "hundreds
/// dot-bar" in col 11 (one dot per 100) plus the last two digits rotated into cols 12..=16 (tens at
/// the bottom, units on top; reads bottom→top).
fn draw_ticker<I: I2c>(m: &mut Matrix<I>, app: &App) {
// top-row beat strip (cols 0..=10): faint marks at each beat (every `sub` steps), a bright
// playhead at the master lane's current step — spread across the full name-region width.
if let Some(master) = app.track.lanes.first() {
let steps = master.levels.len().max(1) as i32;
let sub = master.sub.max(1) as i32;
for s in 0..steps {
if s % sub == 0 {
m.set_max(s * 11 / steps, 0, 24); // beat tick
}
}
if app.playing && app.step[0] >= 0 {
m.set(app.step[0] * 11 / steps, 0, 255); // live playhead
}
}
// scrolling name on rows 2..=6 (shifted down one to clear the beat strip)
let total = app.scroll_total.max(1);
for sx in 0..=10i32 {
let i = ((app.scroll_off + sx) % total + total) % total;
let colbits = if (i as usize) < app.name_cols.len() {
app.name_cols[i as usize]
} else {
0
};
for r in 0..5i32 {
if colbits & (1 << r) != 0 {
m.set(sx, 2 + r, NAME_BRIGHT);
}
}
}
// hundreds dot-bar in col 11 (1 dot per 100; nothing under 100)
let hundreds = (app.tempo / 100) as i32;
for i in 0..hundreds {
m.set(11, 6 - 2 * i, BRIGHTNESS);
}
// last two digits, rotated 90° CCW into cols 12..=16
let two = (app.tempo % 100) as usize;
draw_rot_digit(m, two / 10, 12, 4); // tens → bottom cell (rows 4..=6)
draw_rot_digit(m, two % 10, 12, 0); // units → top cell (rows 0..=2)
}
/// Draw a 3x5 digit rotated 90° CCW at cell origin (cx, cy): occupies 5 cols (cx..=cx+4) x 3 rows
/// (cy..=cy+2). Source pixel (col c, row r) maps to (cx + r, cy + (2 - c)).
fn draw_rot_digit<I: I2c>(m: &mut Matrix<I>, d: usize, cx: i32, cy: i32) {
let g = DIGITS[d];
for c in 0..3i32 {
for r in 0..5i32 {
if g[r as usize] & (1 << (2 - c)) != 0 {
m.set(cx + r, cy + (2 - c), BRIGHTNESS);
}
}
}
}
/// Grid: lanes are rows (centred), steps are columns (centred), brightness encodes accent/normal/
/// ghost, a bright playhead column tracks the beat. (Port of pico-scroll `_render_grid`.)
fn draw_grid<I: I2c>(m: &mut Matrix<I>, app: &App) {
let lanes = &app.track.lanes;
let n = lanes.len().min(7);
if n == 0 {
return;
}
let y0 = ((7 - n) / 2) as i32;
for li in 0..n {
let l = &lanes[li];
let steps = l.levels.len().max(1) as i32;
let y = y0 + li as i32;
let lit = if app.playing { app.step[li] } else { -1 };
let off = if steps <= 17 { (17 - steps) / 2 } else { 0 };
for s in 0..steps {
let col = if steps <= 17 { s + off } else { s * 17 / steps };
let lvl = if l.mute { 0 } else { l.levels[s as usize] };
let val = if s == lit {
if lvl > 0 {
255
} else {
70
}
} else {
lvl_bright(lvl)
};
if val > 0 {
m.set_max(col, y, val);
}
}
}
}
/// Pendulum: a metronome arm bounces across the bar; faint beat ticks along the bottom edge.
/// (Port of pico-scroll `_render_pendulum`.)
fn draw_pendulum<I: I2c>(m: &mut Matrix<I>, app: &App, now_ns: i64) {
if app.track.lanes.is_empty() {
return;
}
let l = &app.track.lanes[0];
let steps = l.levels.len().max(1) as i64;
let sub = l.sub.max(1) as i64;
let beats = (steps / sub).max(1);
let frac = if app.playing {
(((app.m_steps - 1).rem_euclid(steps)) as f32) / steps as f32
} else {
0.0
};
let tri = if frac < 0.5 { frac * 2.0 } else { 2.0 * (1.0 - frac) };
let col = (tri * 16.0 + 0.5) as i32;
let flash = if app.beatflash != 0 && now_ns < app.beatflash_off {
app.beatflash
} else {
0
};
let val = if flash == 2 {
255
} else if flash != 0 {
150
} else {
90
};
for y in 0..7 {
m.set(col, y, val);
}
for bi in 0..beats {
let bc = (bi * 17 / beats) as i32;
if m.get(bc, 6) < 24 {
m.set(bc, 6, 24);
}
}
}
/// Boot splash: scroll "PM-G1 GRID" once, right-to-left. Doubles as a liveness + pixel-map check.
/// `poll` is called frequently during the per-frame delay so USB enumeration isn't stalled.
fn splash<I: I2c, P: FnMut()>(m: &mut Matrix<I>, delay: &mut Delay, mut poll: P) {
let cols = build_name_cols("PM-G1 GRID");
let n = cols.len() as i32;
let mut off = -16i32;
while off < n {
m.clear();
for x in 0..17i32 {
let ci = x + off;
if ci >= 0 && ci < n {
let colbits = cols[ci as usize];
for r in 0..5i32 {
if colbits & (1 << r) != 0 {
m.set(x, 1 + r, BRIGHTNESS);
}
}
}
}
m.show();
for _ in 0..30 {
poll();
delay.delay_us(1500); // ~45ms/frame, polling USB every 1.5ms
}
off += 1;
}
}
// ============================== USB MASS STORAGE (SCSI over Bulk-Only) ==============================
// Adapted from the usbd-storage rp2040 example (apohrebniak/usbd-storage). The host sees a 1 MB
// removable drive; we serve blocks from the flash `.filesystem` region and write them with
// rp2040-flash (erase+program a 4 KB sector). The host owns the FAT format.
struct ScsiState {
offset: usize, // bytes transferred so far for the in-progress Read/Write
sense_key: u8,
sense_asc: u8,
dirty: bool, // host wrote a block → the main loop schedules a programs.json re-read
write_buf: [u8; FS_BLOCK_SIZE as usize],
}
impl ScsiState {
fn new() -> Self {
ScsiState {
offset: 0,
sense_key: 0,
sense_asc: 0,
dirty: false,
write_buf: [0u8; FS_BLOCK_SIZE as usize],
}
}
}
fn scsi_command(
mut command: Command<ScsiCommand, Scsi<BulkOnly<hal::usb::UsbBus, &mut [u8]>>>,
st: &mut ScsiState,
) -> Result<(), TransportError<BulkOnlyError>> {
match command.kind {
ScsiCommand::TestUnitReady { .. } => {
command.pass();
}
ScsiCommand::Inquiry { .. } => {
command.try_write_data_all(&[
0x00, 0x80, 0x04, 0x02, 0x20, 0x00, 0x00, 0x00, // std inquiry, removable
b'V', b'A', b'R', b'A', b'S', b'Y', b'S', b' ', // 8-byte vendor id
b'P', b'M', b'_', b'G', b'-', b'1', b' ', b'G', b'r', b'i', b'd', b' ', b' ', b' ',
b' ', b' ', // 16-byte product id
b'0', b'.', b'1', b'0', // 4-byte revision
])?;
command.pass();
}
ScsiCommand::RequestSense { .. } => {
command.try_write_data_all(&[
0x70, 0x00, st.sense_key, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
st.sense_asc, 0x00, 0x00, 0x00, 0x00, 0x00,
])?;
st.sense_key = 0;
st.sense_asc = 0;
command.pass();
}
ScsiCommand::ReadCapacity10 { .. } => {
let mut data = [0u8; 8];
data[0..4].copy_from_slice(&u32::to_be_bytes(FS_BLOCKS - 1));
data[4..8].copy_from_slice(&u32::to_be_bytes(FS_BLOCK_SIZE));
command.try_write_data_all(&data)?;
command.pass();
}
ScsiCommand::ReadCapacity16 { .. } => {
let mut data = [0u8; 16];
data[0..8].copy_from_slice(&u64::to_be_bytes((FS_BLOCKS - 1) as u64));
data[8..12].copy_from_slice(&u32::to_be_bytes(FS_BLOCK_SIZE));
command.try_write_data_all(&data)?;
command.pass();
}
ScsiCommand::ReadFormatCapacities { .. } => {
let mut data = [0u8; 12];
data[0..4].copy_from_slice(&[0x00, 0x00, 0x00, 0x08]);
data[4..8].copy_from_slice(&u32::to_be_bytes(FS_BLOCKS));
data[8] = 0x01;
let bl = u32::to_be_bytes(FS_BLOCK_SIZE);
data[9..12].copy_from_slice(&bl[1..4]);
command.try_write_data_all(&data)?;
command.pass();
}
ScsiCommand::Read { lba, len } => {
let len = len as u32;
if st.offset != (len * FS_BLOCK_SIZE) as usize {
let start = (FS_BLOCK_SIZE * lba) as usize + st.offset;
let end = ((FS_BLOCK_SIZE * lba) as usize + (FS_BLOCK_SIZE * len) as usize).min(FS_LEN);
let start = start.min(end);
// raw-pointer read of the flash region (the static would const-fold to 0)
let data = unsafe { core::slice::from_raw_parts(FILESYSTEM.as_ptr().add(start), end - start) };
let count = command.write_data(data)?;
st.offset += count;
} else {
command.pass();
st.offset = 0;
}
}
ScsiCommand::Write { lba, len } => {
let len = len as u32;
if st.offset != (len * FS_BLOCK_SIZE) as usize {
loop {
let start = (FS_BLOCK_SIZE * lba) as usize + st.offset;
let block_offset = start % (FS_BLOCK_SIZE as usize);
let count = command.read_data(&mut st.write_buf[block_offset..])?;
st.offset += count;
if count > 0 && (st.offset % (FS_BLOCK_SIZE as usize)) == 0 {
// a full 4 KB block accumulated → erase+program that sector
info!("msc: write block {}", start / FS_BLOCK_SIZE as usize);
st.dirty = true; // drive changed → re-read programs.json once it's idle
let faddr = (FILESYSTEM.as_ptr() as u32 & 0x00ff_ffff) + ((start as u32) & !0xfff);
cortex_m::interrupt::free(|_| unsafe {
rp2040_flash::flash::flash_range_erase_and_program(faddr, &st.write_buf, false);
});
} else {
break;
}
}
if st.offset == (len * FS_BLOCK_SIZE) as usize {
command.pass();
st.offset = 0;
}
} else {
command.pass();
st.offset = 0;
}
}
ScsiCommand::ModeSense6 { .. } => {
command.try_write_data_all(&[0x03, 0x00, 0x00, 0x00])?;
command.pass();
}
ScsiCommand::ModeSense10 { .. } => {
command.try_write_data_all(&[0x00, 0x06, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00])?;
command.pass();
}
ref other => {
info!("msc: unsupported scsi cmd {}", other);
st.sense_key = 0x05; // illegal request
st.sense_asc = 0x20; // invalid command operation code
command.fail();
}
}
Ok(())
}
// ============================== MAIN ==============================
#[rp2040_hal::entry]
fn main() -> ! {
// heap for track-format (Vec/String). The Pico has 264 KB SRAM; 24 KB is plenty for a track.
{
use core::mem::MaybeUninit;
const HEAP_SIZE: usize = 96 * 1024; // fatfs + owned set lists + track parse (Pico has 264 KB)
static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
unsafe { HEAP.init(core::ptr::addr_of_mut!(HEAP_MEM) as usize, HEAP_SIZE) }
}
info!("== pm-grid boot == heap {} free", HEAP.free());
let mut pac = hal::pac::Peripherals::take().unwrap();
let core = hal::pac::CorePeripherals::take().unwrap();
let mut watchdog = hal::Watchdog::new(pac.WATCHDOG);
let clocks = hal::clocks::init_clocks_and_plls(
XTAL_FREQ_HZ,
pac.XOSC,
pac.CLOCKS,
pac.PLL_SYS,
pac.PLL_USB,
&mut pac.RESETS,
&mut watchdog,
)
.ok()
.unwrap();
let mut delay = Delay::new(core.SYST, clocks.system_clock.freq().to_Hz());
let timer = hal::Timer::new(pac.TIMER, &mut pac.RESETS, &clocks);
let sio = hal::Sio::new(pac.SIO);
let pins = hal::gpio::Pins::new(pac.IO_BANK0, pac.PADS_BANK0, sio.gpio_bank0, &mut pac.RESETS);
// I2C0 on GP4/GP5 with the RP2040's INTERNAL pull-ups (the Scroll Pack has no external ones).
let sda: hal::gpio::Pin<_, hal::gpio::FunctionI2C, hal::gpio::PullUp> = pins.gpio4.reconfigure();
let scl: hal::gpio::Pin<_, hal::gpio::FunctionI2C, hal::gpio::PullUp> = pins.gpio5.reconfigure();
let i2c = hal::I2C::i2c0(
pac.I2C0,
sda,
scl,
400.kHz(),
&mut pac.RESETS,
&clocks.system_clock,
);
let mut mtx = Matrix::new(i2c, &mut delay);
// --- USB-MIDI: the Scroll Pack has no speaker, so clicks play through the host (the editor's
// "Device audio"). We send a GM note-on per lane hit on channel 10. ---
let usb_bus = UsbBusAllocator::new(hal::usb::UsbBus::new(
pac.USBCTRL_REGS,
pac.USBCTRL_DPRAM,
clocks.usb_clock,
true,
&mut pac.RESETS,
));
let mut midi = UsbMidiClass::new(&usb_bus, 1, 1).unwrap();
let mut scsi = Scsi::new(&usb_bus, MSC_PACKET, MSC_MAX_LUN, unsafe {
#[allow(static_mut_refs)]
MSC_BUF.assume_init_mut()
}
.as_mut_slice())
.unwrap();
let mut scsi_state = ScsiState::new();
let mut usb_dev = UsbDeviceBuilder::new(&usb_bus, UsbVidPid(0x16c0, 0x5e4))
.strings(&[StringDescriptors::default()
.manufacturer("VARASYS")
.product("PM_G-1 Grid")
.serial_number("PMG1")])
.unwrap()
.device_class(0)
.build();
info!("usb-midi configured (channel 10)");
// boot splash (polls USB throughout so the host can enumerate during the animation)
splash(&mut mtx, &mut delay, || {
usb_dev.poll(&mut [&mut midi, &mut scsi]);
});
// buttons (active-low, internal pull-ups): A=GP12 B=GP13 X=GP14 Y=GP15
let mut btn_a = pins.gpio12.into_pull_up_input();
let mut btn_b = pins.gpio13.into_pull_up_input();
let mut btn_x = pins.gpio14.into_pull_up_input();
let mut btn_y = pins.gpio15.into_pull_up_input();
let now_us = || timer.get_counter().ticks() as i64;
// Read the drive's set lists (and (re)format it to "PM_G-1" if it isn't ours). Done AFTER the
// splash so the screen shows life first and a FAT/flash problem can't leave it black.
info!("fat: reading drive...");
let user_setlists = read_user_setlists();
info!("boot: {} total set list(s)", SETLISTS.len() + user_setlists.len());
let mut app = App::new(now_us() * 1000, user_setlists);
info!("groove: bpm={} lanes={}", app.tempo, app.track.lanes.len());
// input edge/hold state
let (mut pa, mut pb, mut px, mut py) = (false, false, false, false);
let (mut press_a, mut press_b) = (0i64, 0i64);
let (mut held_x, mut held_y) = (0i64, 0i64);
let (mut nextrep_x, mut nextrep_y) = (0i64, 0i64);
let mut last_frame_us = 0i64;
let mut hb_us = 0i64;
let mut rxbuf = [0u8; 64];
let mut fs_write_us = 0i64; // last host drive-write time
let mut fs_pending = false; // a re-read of programs.json is owed once the drive goes idle
loop {
let us = now_us();
let now_ns = us * 1000;
// ---- USB: poll the composite device (MIDI + Mass Storage) ----
if usb_dev.poll(&mut [&mut midi, &mut scsi]) {
// service any pending SCSI command (drive read/write)
let _ = scsi.poll(|cmd| {
let _ = scsi_command(cmd, &mut scsi_state);
});
}
// host wrote the drive → schedule a re-read once it's been idle for a bit
if scsi_state.dirty {
scsi_state.dirty = false;
fs_write_us = us;
fs_pending = true;
}
// re-read programs.json after the drive settles (host done writing) and we're stopped, so a
// dropped file applies without a reboot. Read-only → no FAT-corruption risk.
if fs_pending && !app.playing && us - fs_write_us > 1_500_000 {
fs_pending = false;
let user = read_programs_json();
app.reload_user(user);
info!("fat: re-read drive -> {} set list(s)", app.setlists.len());
}
// ---- drain the MIDI RX endpoint, feeding SysEx (live-sync) bytes ----
while let Ok(n) = midi.read(&mut rxbuf) {
if n == 0 {
break;
}
let mut i = 0;
while i + 4 <= n {
// USB-MIDI event packet: low nibble = Code Index Number (how many data bytes)
match rxbuf[i] & 0x0F {
0x4 | 0x7 => {
app.feed_midi(rxbuf[i + 1], now_ns);
app.feed_midi(rxbuf[i + 2], now_ns);
app.feed_midi(rxbuf[i + 3], now_ns);
}
0x6 => {
app.feed_midi(rxbuf[i + 1], now_ns);
app.feed_midi(rxbuf[i + 2], now_ns);
}
0x5 | 0xF => app.feed_midi(rxbuf[i + 1], now_ns), // single byte (SysEx end / realtime)
_ => {} // channel-voice — ignored
}
i += 4;
}
if n < rxbuf.len() {
break;
}
}
// ---- inputs (active-low) ----
let a = btn_a.is_low().unwrap_or(false);
let b = btn_b.is_low().unwrap_or(false);
let x = btn_x.is_low().unwrap_or(false);
let y = btn_y.is_low().unwrap_or(false);
// A: tap = play/stop, hold (>=600ms) = cycle view
if a && !pa {
press_a = us;
}
if !a && pa {
if us - press_a >= 600_000 {
app.cycle_view();
} else {
app.toggle(now_ns);
}
}
// B: tap = next track, hold (>=600ms) = next set list
if b && !pb {
press_b = us;
}
if !b && pb {
if us - press_b >= 600_000 {
app.next_setlist(now_ns);
} else {
app.next_track(now_ns);
}
}
// X: tempo up (tap +1, auto-repeat; +5 after 1.5s held) [X/Y swapped per hardware layout]
if x && !px {
held_x = us;
nextrep_x = us + 350_000;
app.set_bpm(app.tempo + 1, now_ns);
} else if x && px && us >= nextrep_x {
nextrep_x = us + 120_000;
let d = if us - held_x > 1_500_000 { 5 } else { 1 };
app.set_bpm(app.tempo + d, now_ns);
}
// Y: tempo down
if y && !py {
held_y = us;
nextrep_y = us + 350_000;
app.set_bpm(app.tempo - 1, now_ns);
} else if y && py && us >= nextrep_y {
nextrep_y = us + 120_000;
let d = if us - held_y > 1_500_000 { -5 } else { -1 };
app.set_bpm(app.tempo + d, now_ns);
}
pa = a;
pb = b;
px = x;
py = y;
// ---- scheduler: advance clocks (queues note-ons onto app.tx_q) ----
app.tick(now_ns);
// ---- live-sync FULL heartbeat (~5s while a peer is connected) ----
if app.sync_armed && now_ns >= app.sync_hb_next {
app.sync_broadcast_full(now_ns);
}
// drain the USB-MIDI TX queue (notes + SysEx) to the endpoint: send until it's busy
// (WouldBlock), keep the rest for the next poll. This is why chords play in full.
while let Some(&pkt) = app.tx_q.front() {
if midi.send_bytes(pkt).is_ok() {
app.tx_q.pop_front();
} else {
break;
}
}
// ---- ticker scroll advance (~120ms) ----
// (uses the frame clock implicitly; scroll_off wraps mod scroll_total)
let scroll_phase = (us / 120_000) as i32;
app.scroll_off = scroll_phase.rem_euclid(app.scroll_total.max(1));
// ---- render at ~33 fps ----
if us - last_frame_us >= 30_000 {
last_frame_us = us;
render(&mut mtx, &app, now_ns);
}
// heartbeat (~1 Hz) for probe-rs/defmt debugging
if us - hb_us > 1_000_000 {
hb_us = us;
info!(
"alive: playing={} bpm={} step={} usb={} heap={}",
app.playing,
app.tempo,
app.step.first().copied().unwrap_or(-1),
usb_dev.state() as u8,
HEAP.free()
);
}
delay.delay_us(1000); // ~1 kHz loop: tight enough for USB polling + click timing
}
}