//! 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; 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; #[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; 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 // ============================== FONTS (3x5; bit2 = leftmost column) ============================== // Same bit convention as pico-scroll/app.py: glyph row value's bit (1<<(2-col)) lights that column. const DIGITS: [[u8; 5]; 10] = [ [7, 5, 5, 5, 7], // 0 [2, 6, 2, 2, 7], // 1 [7, 1, 7, 4, 7], // 2 [7, 1, 7, 1, 7], // 3 [5, 5, 7, 1, 1], // 4 [7, 4, 7, 1, 7], // 5 [7, 4, 7, 5, 7], // 6 [7, 1, 2, 2, 2], // 7 [7, 5, 7, 5, 7], // 8 [7, 5, 7, 1, 7], // 9 ]; /// Full 3x5 uppercase glyph for a character (used by the scrolling name + boot splash). /// Unknown characters render blank. Digits reuse `DIGITS`. fn glyph(c: char) -> [u8; 5] { match c { '0'..='9' => DIGITS[c as usize - '0' as usize], 'A' => [2, 5, 7, 5, 5], 'B' => [6, 5, 6, 5, 6], 'C' => [3, 4, 4, 4, 3], 'D' => [6, 5, 5, 5, 6], 'E' => [7, 4, 6, 4, 7], 'F' => [7, 4, 6, 4, 4], 'G' => [7, 4, 5, 5, 7], 'H' => [5, 5, 7, 5, 5], 'I' => [7, 2, 2, 2, 7], 'J' => [1, 1, 1, 5, 2], 'K' => [5, 6, 4, 6, 5], 'L' => [4, 4, 4, 4, 7], 'M' => [5, 7, 7, 5, 5], 'N' => [5, 7, 7, 7, 5], 'O' => [2, 5, 5, 5, 2], 'P' => [7, 5, 7, 4, 4], 'Q' => [2, 5, 5, 6, 3], 'R' => [7, 5, 7, 6, 5], 'S' => [3, 4, 2, 1, 6], 'T' => [7, 2, 2, 2, 2], 'U' => [5, 5, 5, 5, 7], 'V' => [5, 5, 5, 5, 2], 'W' => [5, 5, 7, 7, 5], 'X' => [5, 5, 2, 5, 5], 'Y' => [5, 5, 2, 2, 2], 'Z' => [7, 1, 2, 4, 7], '-' => [0, 0, 7, 0, 0], '/' => [1, 1, 2, 4, 4], '(' => [1, 2, 2, 2, 1], ')' => [4, 2, 2, 2, 4], '.' => [0, 0, 0, 0, 2], '+' => [0, 2, 7, 2, 0], '&' => [2, 5, 2, 5, 3], _ => [0, 0, 0, 0, 0], // space + anything unmapped } } // ============================== 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 { i2c: I, fb: [u8; 145], // fb[0] = COLOR register offset (0x24); fb[1..] = 144 PWM bytes } impl Matrix { 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 SetList = (&'static str, &'static [Item]); 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: &[SetList] = &[("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>, item: usize, name: String, name_cols: Vec, } struct App { // current program track: track_format::Track, name: String, name_cols: Vec, // 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>, // per-lane per-step durations (ns) next: Vec, // per-lane next fire time (ns) step: Vec, // 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, // 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, // 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, } 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) } fn build_name_cols(name: &str) -> Vec { let mut cols = Vec::new(); for ch in name.chars() { let g = glyph(ch.to_ascii_uppercase()); for c in 0..3 { let mut col = 0u8; for r in 0..5 { if g[r] & (1 << (2 - c)) != 0 { col |= 1 << r; } } cols.push(col); } cols.push(0); // 1px gap between glyphs } cols } const SCROLL_GAP: i32 = 13; // blank columns between repeats (>= name region width) for a clean loop impl App { fn new(now_ns: i64) -> Self { let prog = SETLISTS[0].1[0].1; let track = track_format::parse(prog); let tempo = track.bpm; let mut app = App { 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, }; 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 % SETLISTS.len(); let items = SETLISTS[self.sl].1; self.item = item % items.len(); let (name, prog) = items[self.item]; self.track = track_format::parse(prog); self.name = String::from(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); } fn next_track(&mut self, now_ns: i64) { let n = SETLISTS[self.sl].1.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) % 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 { // 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 if let Some(r) = &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) { if !self.playing { return; } let nlanes = self.track.lanes.len(); let mut fired_best = 0u8; let mut pending_tempo: Option = 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 if self.clock_out { 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 into the SysEx assembler (called for the data bytes of incoming /// USB-MIDI packets). On a complete frame (0xF0..0xF7) it dispatches. fn feed_sx(&mut self, b: u8, now_ns: i64) { if b == 0xF0 { self.sx_buf.clear(); self.sx_on = true; } else if b == 0xF7 { if self.sx_on { self.handle_sysex(now_ns); } self.sx_on = false; } else if b >= 0xF8 { // realtime (clock etc.) — ignored for now } else if self.sx_on && self.sx_buf.len() < 2048 { self.sx_buf.push(b); } } /// Build `F0 7D F7`, packetize into 4-byte USB-MIDI events, queue them. fn sx_send(&mut self, op: u8, text: &str) { let mut f: Vec = 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;" 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::() { 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::(), b.parse::()) { if sl < 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::(), b.parse::(), c.parse::()) { 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 = SETLISTS[self.sl].1.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) = SETLISTS[self.sl].1[target]; 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(m: &mut Matrix, 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(m: &mut Matrix, 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(m: &mut Matrix, 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(m: &mut Matrix, 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(m: &mut Matrix, 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(m: &mut Matrix, 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; } } // ============================== 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 = 24 * 1024; static mut HEAP_MEM: [MaybeUninit; 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 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]); }); // 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; let mut app = App::new(now_us() * 1000); 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]; loop { let us = now_us(); let now_ns = us * 1000; // ---- USB: poll, then drain the RX endpoint, feeding SysEx (live-sync) bytes ---- usb_dev.poll(&mut [&mut midi]); 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_sx(rxbuf[i + 1], now_ns); app.feed_sx(rxbuf[i + 2], now_ns); app.feed_sx(rxbuf[i + 3], now_ns); } 0x6 => { app.feed_sx(rxbuf[i + 1], now_ns); app.feed_sx(rxbuf[i + 2], now_ns); } 0x5 => app.feed_sx(rxbuf[i + 1], now_ns), _ => {} // channel-voice / realtime — not SysEx } 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 } }