diff --git a/deploy.sh b/deploy.sh index e146bdc..67d7bcb 100755 --- a/deploy.sh +++ b/deploy.sh @@ -53,6 +53,12 @@ if [[ -f "$SRC_DIR/rust/pm-kit/pm-kit.uf2" ]]; then cp "$SRC_DIR/rust/pm-kit/pm-kit.uf2" "$DEST_DIR/pm-kit.uf2" echo " pm-kit.uf2 ($(stat -c '%s' "$DEST_DIR/pm-kit.uf2") bytes) # Rust RP2350 firmware (alpha live metronome)" fi +# ELF with defmt info — `probe-rs run --chip RP235x pm-kit.elf` flashes over the Debug Probe and +# streams logs/panics. Needed locally (not the uf2) because defmt decodes log strings from the ELF. +if [[ -f "$SRC_DIR/rust/pm-kit/pm-kit.elf" ]]; then + cp "$SRC_DIR/rust/pm-kit/pm-kit.elf" "$DEST_DIR/pm-kit.elf" + echo " pm-kit.elf ($(stat -c '%s' "$DEST_DIR/pm-kit.elf") bytes) # probe-rs flash + defmt RTT logging" +fi cp "$DIST_DIR/pm_k1_circuitpy.zip" "$DEST_DIR/pm_k1_circuitpy.zip"; echo " pm_k1_circuitpy.zip ($(stat -c '%s' "$DEST_DIR/pm_k1_circuitpy.zip") bytes)" # PM_K-1 CircuitPython bundle cp "$DIST_DIR/pico-cp-app.py" "$DEST_DIR/pico-cp-app.py"; echo " pico-cp-app.py ($(stat -c '%s' "$DEST_DIR/pico-cp-app.py") bytes)" # served for version reading + reference cp "$DIST_DIR/pico-cp-app.mpy" "$DEST_DIR/pico-cp-app.mpy"; echo " pico-cp-app.mpy ($(stat -c '%s' "$DEST_DIR/pico-cp-app.mpy") bytes)" # precompiled firmware the editor pushes (base64) diff --git a/rust/Containerfile b/rust/Containerfile index af5dabe..3c8c5d0 100644 --- a/rust/Containerfile +++ b/rust/Containerfile @@ -3,6 +3,8 @@ FROM docker.io/library/rust:1-slim # Cortex-M33 target for the eventual RP2350 firmware. Harmless for the host tests. -RUN rustup target add thumbv8m.main-none-eabihf +RUN rustup target add thumbv8m.main-none-eabihf \ + && rustup component add llvm-tools-preview \ + && cargo install flip-link # stack-overflow-safe linker (overflow faults instead of corrupting statics) WORKDIR /work/rust diff --git a/rust/pm-kit/.cargo/config.toml b/rust/pm-kit/.cargo/config.toml index 910819a..efbae5a 100644 --- a/rust/pm-kit/.cargo/config.toml +++ b/rust/pm-kit/.cargo/config.toml @@ -2,7 +2,16 @@ target = "thumbv8m.main-none-eabihf" [target.thumbv8m.main-none-eabihf] +# flip-link: places the stack BELOW the statics so a stack overflow hits a guard region +# and faults cleanly instead of silently corrupting .bss/.data (e.g. the SPI buffer → black screen). +linker = "flip-link" +# `cargo run` flashes over the Raspberry Pi Debug Probe and streams defmt logs/panics via RTT. +runner = "probe-rs run --chip RP235x" rustflags = [ "-C", "link-arg=--nmagic", "-C", "link-arg=-Tlink.x", + "-C", "link-arg=-Tdefmt.x", # defmt's linker section for log-string interning ] + +[env] +DEFMT_LOG = "debug" diff --git a/rust/pm-kit/.gitignore b/rust/pm-kit/.gitignore index 76f1255..6599781 100644 --- a/rust/pm-kit/.gitignore +++ b/rust/pm-kit/.gitignore @@ -2,3 +2,4 @@ target/ Cargo.lock *.uf2 *.bin +*.elf diff --git a/rust/pm-kit/Cargo.toml b/rust/pm-kit/Cargo.toml index 7db251e..1f4352b 100644 --- a/rust/pm-kit/Cargo.toml +++ b/rust/pm-kit/Cargo.toml @@ -5,9 +5,11 @@ edition = "2021" description = "PM_K-1 firmware (RP2350 / Pico 2). Stage 3 bring-up: boot blink → display → drivers + pm-core." [dependencies] -rp235x-hal = { version = "0.3", features = ["binary-info", "critical-section-impl", "rt"] } +rp235x-hal = { version = "0.3", features = ["binary-info", "critical-section-impl", "rt", "defmt"] } cortex-m-rt = "0.7" -panic-halt = "1" +defmt = "0.3" +defmt-rtt = "0.4" +panic-probe = { version = "0.3", features = ["print-defmt"] } embedded-hal = "1" embedded-hal-0-2 = { package = "embedded-hal", version = "0.2.7" } # ADC OneShot trait (rp235x-hal ADC) embedded-hal-bus = "0.2" diff --git a/rust/pm-kit/build.sh b/rust/pm-kit/build.sh index 2cb19d1..ed4482f 100755 --- a/rust/pm-kit/build.sh +++ b/rust/pm-kit/build.sh @@ -10,7 +10,7 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # rust/pm-kit REPO="$(cd "$DIR/../.." && pwd)" # repo root RUNTIME="${RUNTIME:-podman}" -IMG="pm-rust:1" +IMG="pm-rust:2" "$RUNTIME" run --rm -v "$REPO":/work:Z -w /work/rust/pm-kit "$IMG" bash -c ' set -e diff --git a/rust/pm-kit/src/main.rs b/rust/pm-kit/src/main.rs index 8cbaec0..7f0c241 100644 --- a/rust/pm-kit/src/main.rs +++ b/rust/pm-kit/src/main.rs @@ -23,7 +23,9 @@ use mipidsi::interface::{Interface, SpiInterface}; use mipidsi::models::ST7796; use mipidsi::options::{ColorInversion, ColorOrder, Orientation}; use mipidsi::Builder; -use panic_halt as _; +use defmt::info; +use defmt_rtt as _; // global defmt logger over RTT (read by `probe-rs run`) +use panic_probe as _; // panic handler that prints the message + location over defmt, then halts use rp235x_hal as hal; use hal::fugit::RateExtU32; use hal::Clock; @@ -57,12 +59,13 @@ impl OutputPin for NoCs { #[hal::entry] fn main() -> ! { // heap for track parsing (track-format uses alloc). RP2350 has 520 KB SRAM. + const HEAP_SIZE: usize = 96 * 1024; { use core::mem::MaybeUninit; - const HEAP_SIZE: usize = 16 * 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-kit boot == heap {}KB, {} free", HEAP_SIZE / 1024, HEAP.free()); let mut pac = hal::pac::Peripherals::take().unwrap(); let mut watchdog = hal::Watchdog::new(pac.WATCHDOG); @@ -149,24 +152,161 @@ fn main() -> ! { .orientation(Orientation::new().flip_horizontal()) .init(&mut timer) .unwrap(); + info!("display init ok, {} free", HEAP.free()); - // ---- MINIMAL ISOLATION ---- - // Heap is initialised above. Exercise the allocator once (parse), then just draw the - // confirmed-working pattern in a loop. No inputs/audio/clock. If the screen shows blue + - // corners → heap + parse + display are all fine and the bug is in the metronome loop logic. - // If still black → the heap/parse path breaks the display. - // (parse removed for this test — heap init only) + // ---- inputs + speaker ---- + let mut btn_a = pins.gpio15.into_pull_up_input(); // A = play/stop + let mut btn_b = pins.gpio14.into_pull_up_input(); // B = grid/notation view + let mut adc = hal::adc::Adc::new(pac.ADC, &mut pac.RESETS); + let mut joy_x = hal::adc::AdcPin::new(pins.gpio26).unwrap(); + let mut joy_y = hal::adc::AdcPin::new(pins.gpio27).unwrap(); + let pwm_slices = hal::pwm::Slices::new(pac.PWM, &mut pac.RESETS); + let mut spk = pwm_slices.pwm6; // GP13 = PWM slice 6, channel B + spk.set_div_int(125); + spk.set_top(600); // ~2 kHz click + spk.enable(); + spk.channel_b.output_to(pins.gpio13); + + // ---- built-in grooves ---- + const GROOVES: [&str; 4] = [ + "t120;kick:4=X.x.;snare:4=.X.X;hatClosed:4/2=xxxxxxxx", + "t92;kick:4/3=X....x...x..;snare:4/3=..gg.gX.gg.g;hatClosed:4/3=X.xX.xX.xX.x", + "t140;kick:4=X..x;snare:4=.X.X;hatClosed:4/4=xxxxxxxxxxxxxxxx", + "t100;kick:4=Xxxx;claves:5=Xxxxx~", + ]; + const NAMES: [&str; 4] = ["Four on the floor", "Half-time shuffle", "Driving 16ths", "5 over 4"]; + + let mut idx = 0usize; + let mut track = track_format::parse(GROOVES[idx]); + info!("parsed groove 0: bpm={} lanes={}, {} free", track.bpm, track.lanes.len(), HEAP.free()); + let mut tempo: i64 = track.bpm; + let mut playing = true; + let mut notation = false; + + let mut bar_start = timer.get_counter().ticks(); + let mut last_step = usize::MAX; + let mut click_off_us: u64 = 0; + let (mut pa, mut pb) = (false, false); + let mut joy_zone = 0i8; + let mut full_redraw = true; + let mut last_draw_us = 0u64; let mut hb = false; let mut hb_us = 0u64; + let mut last_draw_ok = false; led.set_low().unwrap(); + info!("entering main loop"); + loop { - pm_ui::draw_ui(&mut display).ok(); let now = timer.get_counter().ticks(); + + // ---- inputs ---- + let a = btn_a.is_low().unwrap_or(false); + let b = btn_b.is_low().unwrap_or(false); + if a && !pa { + playing = !playing; + if playing { + bar_start = now; + last_step = usize::MAX; + } + full_redraw = true; + } + if b && !pb { + notation = !notation; + full_redraw = true; + } + pa = a; + pb = b; + + let jx = adc.read(&mut joy_x).unwrap_or(2048); + let jy = adc.read(&mut joy_y).unwrap_or(2048); + // rotate the joystick reading 90° CCW for control + let rx = jy as i32; + let ry = 4095 - jx as i32; + let zone: i8 = if ry > 3200 { 1 } else if ry < 900 { 2 } else if rx > 3200 { 3 } else if rx < 900 { 4 } else { 0 }; + if zone != 0 && joy_zone == 0 { + match zone { + 1 => tempo = (tempo + 4).min(300), // up → tempo+ + 2 => tempo = (tempo - 4).max(30), // down → tempo- + 3 => { + idx = (idx + 1) % GROOVES.len(); // right → next groove + track = track_format::parse(GROOVES[idx]); + tempo = track.bpm; + bar_start = now; + last_step = usize::MAX; + } + 4 => { + idx = (idx + GROOVES.len() - 1) % GROOVES.len(); // left → prev + track = track_format::parse(GROOVES[idx]); + tempo = track.bpm; + bar_start = now; + last_step = usize::MAX; + } + _ => {} + } + full_redraw = true; + } + joy_zone = zone; + + // ---- clock ---- + let master = &track.lanes[0]; + let msteps = master.levels.len().max(1) as u64; + let beats = master.groups.iter().map(|&g| g as u64).sum::().max(1); + let bar_us = 60_000_000u64 * beats / tempo.max(1) as u64; + let step_us = (bar_us / msteps).max(1); + let elapsed = now.wrapping_sub(bar_start) % bar_us; + let cur_step = (elapsed / step_us) as usize; + let phase = elapsed as f32 / bar_us as f32; + + // ---- audio: click on a new step that has a hit ---- + if playing && cur_step != last_step { + let lvl = master.levels[cur_step.min(msteps as usize - 1)]; + if lvl > 0 { + let _ = spk.channel_b.set_duty_cycle(if lvl == 2 { 380 } else { 240 }); + click_off_us = now + if lvl == 2 { 28_000 } else { 14_000 }; + } + last_step = cur_step; + } + if now >= click_off_us { + let _ = spk.channel_b.set_duty_cycle(0); + } + + // ---- draw: on change, and periodically (so a draw lost right after init reappears, and + // the playhead advances). ~7 fps; partial/playhead-only redraw is the next optimization. ---- + if full_redraw || now.wrapping_sub(last_draw_us) > 140_000 { + let lanes: Vec = track + .lanes + .iter() + .map(|l| pm_ui::LaneView { + name: &l.sound, + levels: &l.levels, + beats: l.groups.iter().map(|&g| g as u32).sum::().min(255) as u8, + poly: l.poly, + muted: l.mute, + }) + .collect(); + let screen = pm_ui::Screen { name: NAMES[idx], bpm: tempo, playing, phase, lanes: &lanes }; + last_draw_ok = if notation { + pm_ui::draw_notation(&mut display, &screen).is_ok() + } else { + pm_ui::draw_metronome(&mut display, &screen).is_ok() + }; + full_redraw = false; + last_draw_us = now; + } + + // heartbeat LED + log (~2 Hz) — confirms the loop is alive and the last draw returned Ok if now.wrapping_sub(hb_us) > 500_000 { hb = !hb; let _ = if hb { led.set_high() } else { led.set_low() }; + info!("alive: idx={} step={} playing={} draw_ok={} free={}", idx, cur_step, playing, last_draw_ok, HEAP.free()); hb_us = now; } - timer.delay_ms(50); + timer.delay_ms(8); } } + +/// picotool metadata (visible via `picotool info`). +#[link_section = ".bi_entries"] +#[used] +pub static PICOTOOL_ENTRIES: [hal::binary_info::EntryAddr; 1] = + [hal::binary_info::rp_program_name!(c"pm-kit display")]; diff --git a/rust/run.sh b/rust/run.sh index 43a4ea5..f5e1fd4 100755 --- a/rust/run.sh +++ b/rust/run.sh @@ -8,7 +8,7 @@ # Override the runtime with RUNTIME=docker ./run.sh ... set -euo pipefail -IMG="pm-rust:1" +IMG="pm-rust:2" RUST_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # rust/ REPO_DIR="$(cd "$RUST_DIR/.." && pwd)" # repo root RUNTIME="${RUNTIME:-podman}"