Playback flow (rep/end), ported from pico-scroll: - At each master-bar boundary, after bars*rep cycles the end-action fires: end=stop stops; end=next / end=+N advances through the set list. - The next track is preloaded one bar early (parsed + per-lane durs) into a pending slot, then swapped at the exact seam (master lane bar boundary; all lanes restart there) for a gapless handoff. load()/manual nav clears pending. MIDI clock out (default on, so a DAW can slave to the Grid): - 24-PPQN 0xF8 against the wall clock + 0xFA/0xFC Start/Stop on play/stop (button or live-sync). Queued on tx_q as CIN 0xF single-byte packets. Deferred items needing persistent storage (no CIRCUITPY drive in the Rust build, needs a flash KV layer - separate milestone): practice log, settings.json, SLSYNC/LOGSYNC. Also deferred: MIDI clock in, optional piezo. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1306 lines
46 KiB
Rust
1306 lines
46 KiB
Rust
//! 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<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 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<Vec<i64>>,
|
|
item: usize,
|
|
name: String,
|
|
name_cols: Vec<u8>,
|
|
}
|
|
|
|
struct App {
|
|
// 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,
|
|
}
|
|
|
|
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<u8> {
|
|
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<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
|
|
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<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
|
|
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 <op> <text(ASCII-clamped)> F7`, packetize into 4-byte USB-MIDI events, queue them.
|
|
fn sx_send(&mut self, op: u8, text: &str) {
|
|
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 < 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 = 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<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;
|
|
}
|
|
}
|
|
|
|
// ============================== 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<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 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
|
|
}
|
|
}
|