From 802e46f5bbdc81343da08ef134506f360346def0 Mon Sep 17 00:00:00 2001 From: Me Here Date: Wed, 10 Jun 2026 20:04:59 -0500 Subject: [PATCH] pm-daisy: wire Pod controls (encoder/buttons/knobs/RGB LEDs) The Daisy Pod hardware arrived (Seed 1.2 / PCM3060). Build the controls phase on top of the audio spike: encoder turn = tempo, push = play/pause, buttons step the program list, knob 1 = volume, RGB LED 1 = beat colored by dynamic, RGB LED 2 = transport. Boot still plays the spike groove so the spike's decision criteria stay observable. - pm-synth: add Player::{position,seek,set_volume,last_level} + Synth:: set_master, with host tests (tests/player.rs). Live tempo/program changes rebuild the Player in thread context and swap it in under a short critical section, preserving loop phase (seek) on tempo changes. - pm-daisy: SysTick 1 kHz millis tick, non-blocking beat flash, and PLAYING/VOLUME/LAST_LEVEL atomics the audio IRQ honors/publishes. - New modules controls.rs (buttons/encoder/pots, libDaisy debounce + quadrature decode, 1 kHz poll), leds.rs (active-low RGB + boot self-test), programs.rs (spike groove at index 0 + pm-grid grooves). - Pin map verified against libDaisy daisy_pod.cpp + the daisy crate's pins.rs. Builds clean for all three Seed revisions; 91 KB/128 KB flash. Co-Authored-By: Claude Fable 5 --- docs/daisy-spike.md | 12 +- rust/pm-daisy/README.md | 29 ++++- rust/pm-daisy/src/controls.rs | 198 ++++++++++++++++++++++++++++++++ rust/pm-daisy/src/leds.rs | 103 +++++++++++++++++ rust/pm-daisy/src/main.rs | 205 ++++++++++++++++++++++++++++------ rust/pm-daisy/src/programs.rs | 25 +++++ rust/pm-synth/src/lib.rs | 56 +++++++++- rust/pm-synth/tests/player.rs | 86 ++++++++++++++ 8 files changed, 673 insertions(+), 41 deletions(-) create mode 100644 rust/pm-daisy/src/controls.rs create mode 100644 rust/pm-daisy/src/leds.rs create mode 100644 rust/pm-daisy/src/programs.rs create mode 100644 rust/pm-synth/tests/player.rs diff --git a/docs/daisy-spike.md b/docs/daisy-spike.md index 1340932..a03471e 100644 --- a/docs/daisy-spike.md +++ b/docs/daisy-spike.md @@ -2,11 +2,17 @@ **Status:** **code complete, host-verified, awaiting hardware.** The firmware crate [`rust/pm-daisy`](../rust/pm-daisy/) builds for the target and the shared engine is proven on the -host (see "What's built" below). Hardware was purchased — flash + listen when it arrives. +host (see "What's built" below). Hardware has arrived (a Daisy Seed **1.2 / Seed2 DFM**, PCM3060 +codec — build with `./build.sh seed_1_2`) — flash + listen, then append the verdict below. **Target hardware: Daisy *Pod*** (a Daisy Seed on a dev board with audio jack, buttons, knobs, -encoder, RGB LEDs). The Seed is the brain; this spike uses its audio out + the Seed USER LED. The -Pod's extra controls are a documented next step (`rust/pm-daisy/README.md`). +encoder, RGB LEDs). The Seed is the brain; the spike itself uses its audio out + the Seed USER LED. + +> **Follow-on (code-complete, awaiting the same hardware bring-up):** the Pod's extra controls — 2 +> buttons, encoder + push, 2 knobs, 2 RGB LEDs — are now wired (`rust/pm-daisy/src/controls.rs` + +> `leds.rs`, mapped in `programs.rs`): encoder = tempo / play-pause, buttons = program step, knob 1 = +> volume, RGB LEDs = beat-by-dynamic + transport. Boot still plays the spike groove, so the criteria +> below are unaffected. Pin map + control table in [`rust/pm-daisy/README.md`](../rust/pm-daisy/README.md). **Decision it informs:** is the [Electrosmith Daisy](https://electro-smith.com/products/pod) platform (STM32H750, Cortex-M7 @ 480 MHz + onboard 24-bit audio codec) the right home for the audio diff --git a/rust/pm-daisy/README.md b/rust/pm-daisy/README.md index ee58d59..eeddd0a 100644 --- a/rust/pm-daisy/README.md +++ b/rust/pm-daisy/README.md @@ -57,8 +57,27 @@ the **Daisy Bootloader** to the 8 MB QSPI instead: dfu-util -a 0 -s 0x90040000:leave -D pm-daisy.bin -d ,0483:df11 ``` -## Beyond the spike (Pod extras, not yet wired) -The Pod adds 2 buttons, 2 knobs, an encoder, and 2 RGB LEDs on Seed GPIO/ADC pins. Obvious next -steps once audio is confirmed: knob → tempo, button → start/stop, RGB LED → beat/downbeat color. -Wiring those needs the Pod pin map from Electrosmith's pinout (verify before assigning pins). This -spike deliberately uses only the always-present Seed USER LED to avoid guessing the Pod pinout. +## Pod controls +The Pod's buttons, encoder, knobs, and RGB LEDs are wired up (in `src/controls.rs` + `src/leds.rs`). +Boot still plays the spike groove (program index 0), so the original spike criteria stay observable. + +| Control | Action | +|---|---| +| Encoder turn | tempo ±1 BPM per detent (clamped 5–300) | +| Encoder push | play / pause (ignored if you turned while holding it) | +| Button 1 / Button 2 | previous / next program (`src/programs.rs`, wraps) | +| Knob 1 | master volume (read at boot — no startup jump) | +| Knob 2 | reserved (read but unused) | +| RGB LED 1 | beat flash, colored by dynamic: accent = yellow, normal = cyan, ghost = magenta | +| RGB LED 2 | transport: green = playing, red = paused | +| Seed USER LED | unchanged spike beat flash | + +At boot both RGB LEDs cycle R → G → B (a ~0.45 s self-test) so a miswired or dead leg is obvious +before audio starts. A live tempo or program change rebuilds the engine in the main loop (not the +audio IRQ) and swaps it in under a short critical section, preserving loop phase on tempo changes. + +**Pin map** (verified against libDaisy `daisy_pod.cpp`, cross-checked with the `daisy` crate's +`pins.rs`): buttons D27/D28 (PG9/PA2), encoder A/B/click D26/D25/D13 (PD11/PA0/PB6), knobs D21/D15 +(PC4/PC0 → ADC1), LED 1 D20/D19/D18 (PC1/PA6/PA7), LED 2 D17/D24/D23 (PB1/PA1/PA4). The RGB LEDs are +common-anode (active-low). If the encoder feels reversed or double-steps, adjust the decode in +`Pod::poll` (detents-per-quadrature-cycle varies by encoder). diff --git a/rust/pm-daisy/src/controls.rs b/rust/pm-daisy/src/controls.rs new file mode 100644 index 0000000..1157d3d --- /dev/null +++ b/rust/pm-daisy/src/controls.rs @@ -0,0 +1,198 @@ +//! Daisy Pod input: 2 buttons, a rotary encoder with push, and 2 pots — plus the 2 RGB LEDs (in +//! [`crate::leds`]). The `daisy` crate is Seed-level only (no Pod helpers), so we hand-roll these +//! over the raw `stm32h7xx-hal` pins the audio path left free, porting libDaisy's debounce/encoder +//! logic. Polled at ~1 kHz from the main loop (no interrupts), matching the sibling firmwares. +//! +//! Pin map — verified against libDaisy `daisy_pod.cpp`, cross-checked with the `daisy` crate's +//! `pins.rs` (Seed `Dxx` → STM pin → `daisy` `Gpio` field): +//! +//! | Control | Seed | STM | `gpio.PIN_*` | +//! |----------------|------|------|--------------| +//! | Button 1 | D27 | PG9 | PIN_27 (pull-up, active-low) | +//! | Button 2 | D28 | PA2 | PIN_28 (pull-up, active-low) | +//! | Encoder A/B | D26/D25 | PD11/PA0 | PIN_26/PIN_25 (pull-up) | +//! | Encoder click | D13 | PB6 | PIN_13 (pull-up, active-low) | +//! | Pot 1 / Pot 2 | D21/D15 | PC4/PC0 | PIN_21/PIN_15 (ADC1 ch4 / ch10) | +//! | LED 1 R/G/B | D20/D19/D18 | PC1/PA6/PA7 | PIN_20/19/18 | +//! | LED 2 R/G/B | D17/D24/D23 | PB1/PA1/PA4 | PIN_17/24/23 | + +use stm32h7xx_hal as hal; + +use hal::gpio::gpioa::{PA0, PA2}; +use hal::gpio::gpiob::PB6; +use hal::gpio::gpioc::{PC0, PC4}; +use hal::gpio::gpiod::PD11; +use hal::gpio::gpiog::PG9; +use hal::gpio::{Analog, Input}; +use hal::pac::ADC1; +use hal::prelude::*; // OneShot::read, RateExtU32 (`4.MHz()`) +use hal::rcc::{rec, CoreClocks}; + +use crate::leds::Leds; + +type Adc1 = hal::adc::Adc; + +/// What a single `poll()` observed. All fields are deltas/edges since the previous poll, so the main +/// loop just applies whatever is set. +#[derive(Default)] +pub struct Events { + /// Net BPM change from the encoder this tick (±1 per detent). + pub bpm_delta: i32, + /// Program step from the buttons (−1 prev / +1 next). + pub prog_delta: i32, + /// Encoder click released as a clean press (no turn while held) → toggle play/pause. + pub toggle_play: bool, + /// New master volume from pot 1, only when it moved past the hysteresis band. + pub volume: Option, +} + +/// All Pod controls plus the two RGB LEDs. +pub struct Pod { + pub leds: Leds, + + adc1: Adc1, + pot1: PC4, + pot2: PC0, + + btn1: PG9, + btn2: PA2, + enc_a: PD11, + enc_b: PA0, + enc_sw: PB6, + + // Debounce / edge state (shift registers of recent reads). + btn1_hist: u8, + btn2_hist: u8, + a_hist: u8, + b_hist: u8, + sw_hist: u8, + sw_pressed: bool, + turned_while_pressed: bool, + + // Pot 1 smoothing, in normalized [0, 1]. + vol_smoothed: f32, + vol_emitted: f32, +} + +impl Pod { + /// Claim the Pod's GPIO/ADC pins and bring up ADC1. Borrows `syst` to clock the ADC's boot + /// calibration, then returns it so the caller can use it for the SysTick millis tick. + pub fn new( + gpio: daisy::pins::Gpio, + adc1: ADC1, + adc_prec: rec::Adc12, + clocks: CoreClocks, + syst: cortex_m::peripheral::SYST, + ) -> (Self, cortex_m::peripheral::SYST) { + // ADC1 calibration needs a blocking delay; build one from SYST, then hand SYST back. + let mut delay = hal::delay::Delay::new(syst, clocks); + let mut adc1 = hal::adc::Adc::adc1(adc1, 4.MHz(), &mut delay, adc_prec, &clocks).enable(); + adc1.set_resolution(hal::adc::Resolution::SixteenBit); + let syst = delay.free(); + + let leds = Leds::new( + [ + gpio.PIN_20.into_push_pull_output().erase(), // LED1 R (PC1) + gpio.PIN_19.into_push_pull_output().erase(), // LED1 G (PA6) + gpio.PIN_18.into_push_pull_output().erase(), // LED1 B (PA7) + ], + [ + gpio.PIN_17.into_push_pull_output().erase(), // LED2 R (PB1) + gpio.PIN_24.into_push_pull_output().erase(), // LED2 G (PA1) + gpio.PIN_23.into_push_pull_output().erase(), // LED2 B (PA4) + ], + ); + + let pod = Pod { + leds, + adc1, + pot1: gpio.PIN_21.into_analog(), + pot2: gpio.PIN_15.into_analog(), + btn1: gpio.PIN_27.into_pull_up_input(), + btn2: gpio.PIN_28.into_pull_up_input(), + enc_a: gpio.PIN_26.into_pull_up_input(), + enc_b: gpio.PIN_25.into_pull_up_input(), + enc_sw: gpio.PIN_13.into_pull_up_input(), + btn1_hist: 0, + btn2_hist: 0, + a_hist: 0, + b_hist: 0, + sw_hist: 0, + sw_pressed: false, + turned_while_pressed: false, + vol_smoothed: 0.0, + vol_emitted: -1.0, // force a first emit + }; + (pod, syst) + } + + /// Read pot 1 a few times to settle the smoother, and return its volume (squared taper). Called + /// once before audio starts so the opening level matches the knob with no jump. + pub fn volume(&mut self) -> f32 { + let mut s = 0.0; + for _ in 0..8 { + let v: u32 = self.adc1.read(&mut self.pot1).unwrap(); + s = v as f32 / 65_535.0; + } + self.vol_smoothed = s; + self.vol_emitted = s; + (s * s).clamp(0.0, 1.0) + } + + /// Poll all controls once (call at ~1 kHz). Returns the edges/deltas seen this tick. + pub fn poll(&mut self) -> Events { + let mut ev = Events::default(); + + // --- Encoder turn (libDaisy quadrature decode: one increment per detent). --- + self.a_hist = (self.a_hist << 1) | self.enc_a.is_high() as u8; + self.b_hist = (self.b_hist << 1) | self.enc_b.is_high() as u8; + let inc: i32 = if (self.a_hist & 0x03) == 0x02 && (self.b_hist & 0x03) == 0x00 { + 1 + } else if (self.b_hist & 0x03) == 0x02 && (self.a_hist & 0x03) == 0x00 { + -1 + } else { + 0 + }; + ev.bpm_delta = inc; + + // --- Encoder click (active-low): toggle play on a clean release (no turn while held). --- + self.sw_hist = (self.sw_hist << 1) | self.enc_sw.is_low() as u8; + let pressed_now = (self.sw_hist & 0b111) == 0b111; + let released_now = (self.sw_hist & 0b111) == 0b000; + if pressed_now && !self.sw_pressed { + self.sw_pressed = true; + self.turned_while_pressed = false; + } + if self.sw_pressed && inc != 0 { + self.turned_while_pressed = true; + } + if self.sw_pressed && released_now { + self.sw_pressed = false; + if !self.turned_while_pressed { + ev.toggle_play = true; + } + } + + // --- Buttons (active-low): fire once when the debounce settles to "pressed". --- + self.btn1_hist = (self.btn1_hist << 1) | self.btn1.is_low() as u8; + self.btn2_hist = (self.btn2_hist << 1) | self.btn2.is_low() as u8; + if (self.btn1_hist & 0x0f) == 0b0111 { + ev.prog_delta -= 1; // Button 1 = previous + } + if (self.btn2_hist & 0x0f) == 0b0111 { + ev.prog_delta += 1; // Button 2 = next + } + + // --- Pot 1 → volume (EMA + hysteresis). Pot 2 is read but reserved for future use. --- + let raw1: u32 = self.adc1.read(&mut self.pot1).unwrap(); + let _raw2: u32 = self.adc1.read(&mut self.pot2).unwrap(); + let norm1 = raw1 as f32 / 65_535.0; + self.vol_smoothed += (norm1 - self.vol_smoothed) * 0.1; + if (self.vol_smoothed - self.vol_emitted).abs() > 0.01 { + self.vol_emitted = self.vol_smoothed; + ev.volume = Some((self.vol_smoothed * self.vol_smoothed).clamp(0.0, 1.0)); + } + + ev + } +} diff --git a/rust/pm-daisy/src/leds.rs b/rust/pm-daisy/src/leds.rs new file mode 100644 index 0000000..3fa808e --- /dev/null +++ b/rust/pm-daisy/src/leds.rs @@ -0,0 +1,103 @@ +//! The Daisy Pod's two RGB LEDs. They are common-anode (active-LOW: drive a leg pin LOW to light +//! it), as confirmed by libDaisy's `DaisyPod::InitLeds` invert flag. We drive them plain on/off +//! (libDaisy itself does this — "LEDs are just going to be on/off for now"), giving the seven +//! saturated colors used below; that's enough to read beat dynamics and transport at a glance. +//! +//! Pin map (libDaisy `daisy_pod.cpp`, cross-checked with the `daisy` crate's `pins.rs`): +//! LED 1: R=D20/PC1 G=D19/PA6 B=D18/PA7 LED 2: R=D17/PB1 G=D24/PA1 B=D23/PA4 +//! Pins are type-erased so both LEDs share one struct despite living on different GPIO ports. + +use stm32h7xx_hal as hal; + +use hal::gpio::{ErasedPin, Output, PushPull}; + +/// An RGB color as (red, green, blue) on/off legs. +pub type Color = (bool, bool, bool); + +pub const OFF: Color = (false, false, false); +pub const GREEN: Color = (false, true, false); +pub const RED: Color = (true, false, false); +pub const BLUE: Color = (false, false, true); +pub const YELLOW: Color = (true, true, false); // accent beat +pub const CYAN: Color = (false, true, true); // normal beat +pub const MAGENTA: Color = (true, false, true); // ghost beat + +/// Color for a click level (2 = accent, 1 = normal, 3 = ghost), matching the pico edition's beat +/// coloring as closely as on/off RGB allows. +fn level_color(level: u8) -> Color { + match level { + 2 => YELLOW, + 3 => MAGENTA, + _ => CYAN, // normal (1) and anything else + } +} + +/// One common-anode RGB LED: three type-erased push-pull output pins, [R, G, B], active-low. +struct RgbLed { + legs: [ErasedPin>; 3], +} + +impl RgbLed { + fn new(r: ErasedPin>, g: ErasedPin>, b: ErasedPin>) -> Self { + let mut led = RgbLed { legs: [r, g, b] }; + led.set(OFF); + led + } + + fn set(&mut self, color: Color) { + let on = [color.0, color.1, color.2]; + for (leg, &lit) in self.legs.iter_mut().zip(on.iter()) { + // Active-low: LOW lights the leg, HIGH turns it off. + if lit { + leg.set_low(); + } else { + leg.set_high(); + } + } + } +} + +/// Both Pod RGB LEDs: LED 1 shows the beat (colored by dynamic), LED 2 shows transport state. +pub struct Leds { + beat: RgbLed, + transport: RgbLed, +} + +impl Leds { + pub fn new( + beat: [ErasedPin>; 3], + transport: [ErasedPin>; 3], + ) -> Self { + let [b0, b1, b2] = beat; + let [t0, t1, t2] = transport; + Leds { beat: RgbLed::new(b0, b1, b2), transport: RgbLed::new(t0, t1, t2) } + } + + /// Light each leg of both LEDs in turn (R, G, B) so a miswired or dead channel is obvious before + /// audio starts. Runs once at boot, blocking; `cpu_hz` sizes the ~150 ms per-step delay. + pub fn self_test(&mut self, cpu_hz: u32) { + let step = cpu_hz / 1000 * 150; // ~150 ms in CPU cycles + for color in [RED, GREEN, BLUE] { + self.beat.set(color); + self.transport.set(color); + cortex_m::asm::delay(step); + } + self.beat.set(OFF); + self.transport.set(OFF); + } + + /// Light the beat LED for a click of the given level. + pub fn beat_on(&mut self, level: u8) { + self.beat.set(level_color(level)); + } + + /// Turn the beat LED off (end of the flash window). + pub fn beat_off(&mut self) { + self.beat.set(OFF); + } + + /// Show transport state on LED 2: green = playing, red = paused. + pub fn set_transport(&mut self, playing: bool) { + self.transport.set(if playing { GREEN } else { RED }); + } +} diff --git a/rust/pm-daisy/src/main.rs b/rust/pm-daisy/src/main.rs index 11964ad..ddcacdd 100644 --- a/rust/pm-daisy/src/main.rs +++ b/rust/pm-daisy/src/main.rs @@ -1,12 +1,16 @@ -//! PM Daisy-Pod spike — play the PolyMeter click engine on real hardware. +//! PM Daisy-Pod — play the PolyMeter click engine on real hardware, with Pod controls. //! //! Drives the shared [`pm_synth::Player`] (the SAME sequencer the host `synthrender` renders to //! `pm-daisy-preview.wav`) from the Daisy Seed's SAI audio DMA interrupt, out the Daisy Pod's audio -//! jack. The engine is mono; both stereo channels get the same sample. The Seed's USER LED flashes -//! on every click as a beat indicator. +//! jack. The engine is mono; both stereo channels get the same sample. //! -//! This is a digital/DSP-home experiment (see `docs/daisy-spike.md`), not the heirloom analog path. -//! The audio loop structure follows the `daisy` crate's `examples/audio.rs`. +//! Beyond the original spike (see `docs/daisy-spike.md`) this wires the Pod's controls: the encoder +//! sets tempo (turn) and play/pause (push), the two buttons step through a program list, knob 1 is +//! the master volume, and the two RGB LEDs show the beat (colored by dynamic) and transport state. +//! The Seed's USER LED keeps its spike beat flash so the original criteria stay observable. +//! +//! This is a digital/DSP-home experiment, not the heirloom analog path. The audio loop structure +//! follows the `daisy` crate's `examples/audio.rs`. //! //! Build/flash: see `build.sh` and `README.md`. The board REVISION feature (Cargo.toml) must match //! your Seed's codec or you get silence. @@ -16,13 +20,17 @@ extern crate alloc; +mod controls; +mod leds; +mod programs; + use core::cell::RefCell; use core::mem::MaybeUninit; -use core::sync::atomic::{AtomicU32, Ordering}; +use core::sync::atomic::{AtomicBool, AtomicU32, AtomicU8, Ordering}; -use cortex_m::asm; use cortex_m::interrupt::Mutex; -use cortex_m_rt::entry; +use cortex_m::peripheral::syst::SystClkSource; +use cortex_m_rt::{entry, exception}; use defmt_rtt as _; use panic_probe as _; @@ -31,24 +39,45 @@ use hal::pac::{self, interrupt}; use stm32h7xx_hal as hal; use daisy::audio; -use pm_synth::{Player, SPIKE_BARS, SPIKE_PROGRAM}; +use pm_synth::Player; #[global_allocator] static HEAP: Heap = Heap::empty(); /// The running groove and the audio peripheral live in globals so the DMA audio interrupt can reach -/// them. Both are installed once in `main` (inside a critical section) before audio starts. +/// them. Both are installed once in `main` (inside a critical section) before audio starts; the main +/// loop swaps the [`Player`] under the same critical section to change tempo/program live. static PLAYER: Mutex>> = Mutex::new(RefCell::new(None)); static AUDIO_INTERFACE: Mutex>> = Mutex::new(RefCell::new(None)); /// Total clicks fired, published from the audio IRQ. The main loop watches it to flash the beat LED. static FIRED: AtomicU32 = AtomicU32::new(0); +/// Level of the last click (2/1/3), published from the audio IRQ to color the beat LED. +static LAST_LEVEL: AtomicU8 = AtomicU8::new(0); +/// Transport: the audio IRQ feeds silence (and does not advance the engine) when this is false. +static PLAYING: AtomicBool = AtomicBool::new(true); +/// Master volume as `f32` bits, read once per audio block by the IRQ. Set before audio starts. +static VOLUME_BITS: AtomicU32 = AtomicU32::new(0); + +/// Free-running millisecond counter, incremented by the SysTick exception. The main loop uses it to +/// time the non-blocking beat flash and to gate control polling to ~1 kHz. +static MILLIS: AtomicU32 = AtomicU32::new(0); + +#[exception] +fn SysTick() { + MILLIS.fetch_add(1, Ordering::Relaxed); +} + +fn millis() -> u32 { + MILLIS.load(Ordering::Relaxed) +} #[entry] fn main() -> ! { // Heap first: `Player::new` parses the program into Vec/String, and each `trigger()` allocates a - // voice. 64 KB lives comfortably in the 128 KB DTCM. (Allocating inside the audio IRQ is a known - // real-time smell — fine for the spike; see docs/daisy-spike.md "Decision criteria".) + // voice. 64 KB lives comfortably in the 128 KB DTCM. Tempo/program changes rebuild the Player in + // THIS thread context (not the audio IRQ), so the only allocation left in the IRQ is per-voice + // `trigger()` (the known real-time smell tracked in docs/daisy-spike.md). { const HEAP_SIZE: usize = 64 * 1024; static mut HEAP_MEM: [MaybeUninit; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE]; @@ -70,10 +99,26 @@ fn main() -> ! { let mut led_user = daisy::board_split_leds!(pins).USER; let audio_interface = daisy::board_split_audio!(ccdr, pins); - // Build the groove from the shared spike pattern. - let track = track_format::parse(SPIKE_PROGRAM); - let player = Player::new(&track, SPIKE_BARS); - defmt::info!("pm-daisy: playing `{}` ({=i64} bars), heap {=usize} B free", SPIKE_PROGRAM, SPIKE_BARS, HEAP.free()); + // Pod controls (buttons, encoder, pots, RGB LEDs) on the Seed GPIO/ADC pins the audio path left + // free. Pin map verified against libDaisy's daisy_pod.cpp — see controls.rs / leds.rs. `Pod::new` + // borrows SYST for the ADC's boot calibration and hands it back for the SysTick tick below. + let cpu_hz = ccdr.clocks.sys_ck().to_Hz(); + let (mut pod, syst) = controls::Pod::new(pins.GPIO, dp.ADC1, ccdr.peripheral.ADC12, ccdr.clocks, cp.SYST); + pod.leds.self_test(cpu_hz); // light each RGB leg once so a miswire is obvious before audio starts + + // Knob 1 sets the master volume; read it once *before* audio starts so there is no startup jump + // (pots are absolute — the knob position simply IS the volume). + let vol = pod.volume(); + VOLUME_BITS.store(vol.to_bits(), Ordering::Relaxed); + pod.leds.set_transport(true); // LED 2 green = playing + + // Build the first groove (index 0 is the spike pattern, so boot behavior is unchanged). + let (mut prog_idx, prog) = (0usize, programs::PROGRAMS[0]); + let mut track = track_format::parse(prog.1); + let mut bars = play_bars(&track); + let mut player = Player::new(&track, bars); + player.set_volume(vol); + defmt::info!("pm-daisy: '{}' ({=i64} bars), vol {=f32}, heap {=usize} B free", prog.0, bars, vol, HEAP.free()); // Start audio and hand the peripheral + player to the interrupt. let audio_interface = audio_interface.spawn().unwrap(); @@ -82,22 +127,114 @@ fn main() -> ! { AUDIO_INTERFACE.borrow(cs).replace(Some(audio_interface)); }); - // Beat LED: a ~25 ms flash whenever the click count advances. Audio is fully interrupt-driven, - // so blocking the main loop with `asm::delay` is harmless. - let ms = ccdr.clocks.sys_ck().to_Hz() / 1000; - let mut last = 0u32; - loop { - let fired = FIRED.load(Ordering::Relaxed); - if fired != last { - last = fired; - led_user.set_high(); - asm::delay(ms * 25); - led_user.set_low(); - } - asm::delay(ms); // ~1 ms poll cadence + // SysTick as a 1 kHz millis tick. Started AFTER the ADC calibration that borrowed SYST in + // `Pod::new` (which returned it), so the counter and the calibration delay never fight over it. + let mut syst = syst; + syst.set_clock_source(SystClkSource::Core); + syst.set_reload(cpu_hz / 1000 - 1); + syst.clear_current(); + syst.enable_counter(); + syst.enable_interrupt(); + + led_loop(&mut led_user, &mut pod, &mut track, &mut bars, &mut prog_idx) +} + +/// Bars to loop a parsed track: its own `bars` if set, else 4 (the spike default). +fn play_bars(track: &track_format::Track) -> i64 { + if track.bars > 0 { + track.bars + } else { + 4 } } +/// Main control + indicator loop. Audio is fully interrupt-driven; here we poll the Pod controls at +/// ~1 kHz, apply tempo/program/volume/transport changes, and drive the LEDs. +fn led_loop( + led_user: &mut daisy::led::LedUser, + pod: &mut controls::Pod, + track: &mut track_format::Track, + bars: &mut i64, + prog_idx: &mut usize, +) -> ! { + let mut last_fired = 0u32; + let mut flash_off_at = 0u32; // millis deadline for ending the beat flash; 0 = LED off + let mut last_poll = 0u32; + + loop { + let now = millis(); + + // Beat flash (USER LED + RGB LED 1), driven by the click counter the IRQ publishes. + let fired = FIRED.load(Ordering::Relaxed); + if fired != last_fired { + last_fired = fired; + let _ = led_user.set_high(); + pod.leds.beat_on(LAST_LEVEL.load(Ordering::Relaxed)); + flash_off_at = now.wrapping_add(25); + } + if flash_off_at != 0 && now.wrapping_sub(flash_off_at) < 0x8000_0000 && now >= flash_off_at { + let _ = led_user.set_low(); + pod.leds.beat_off(); + flash_off_at = 0; + } + + // Poll controls once per millisecond. + if now != last_poll { + last_poll = now; + let ev = pod.poll(); + + // Volume: knob 1, applied continuously via the atomic the IRQ reads (no rebuild). + if let Some(v) = ev.volume { + VOLUME_BITS.store(v.to_bits(), Ordering::Relaxed); + } + + // Transport: encoder push toggles play/pause. + if ev.toggle_play { + let now_playing = !PLAYING.load(Ordering::Relaxed); + PLAYING.store(now_playing, Ordering::Relaxed); + pod.leds.set_transport(now_playing); + defmt::info!("transport: {}", if now_playing { "play" } else { "pause" }); + } + + // Program change: buttons step through PROGRAMS, then rebuild. + if ev.prog_delta != 0 { + let n = programs::PROGRAMS.len() as i32; + *prog_idx = (((*prog_idx as i32 + ev.prog_delta) % n + n) % n) as usize; + *track = track_format::parse(programs::PROGRAMS[*prog_idx].1); + *bars = play_bars(track); + rebuild_player(track, *bars, true); + defmt::info!("program {=usize}: '{}' @ {=i64} bpm", *prog_idx, programs::PROGRAMS[*prog_idx].0, track.bpm); + } + + // Tempo: encoder turn nudges BPM, then rebuild preserving loop phase. + if ev.bpm_delta != 0 { + track.bpm = (track.bpm + ev.bpm_delta as i64).clamp(5, 300); + rebuild_player(track, *bars, false); + defmt::info!("bpm {=i64}", track.bpm); + } + } + } +} + +/// Rebuild the [`Player`] for a new tempo/program and swap it into the audio IRQ's slot. The build +/// (and the old Player's drop) happen here in thread context; the critical section holds only the +/// phase read + pointer swap, so it can't stall the DMA callback. When `reset_phase` is false the +/// new Player is seeked to the old one's loop position so a tempo change doesn't jump to bar 1. +fn rebuild_player(track: &track_format::Track, bars: i64, reset_phase: bool) { + let mut newp = Player::new(track, bars); + newp.set_volume(f32::from_bits(VOLUME_BITS.load(Ordering::Relaxed))); + let old = cortex_m::interrupt::free(|cs| { + let mut slot = PLAYER.borrow(cs).borrow_mut(); + if !reset_phase { + if let Some(cur) = slot.as_ref() { + newp.seek(cur.position()); + } + } + slot.replace(newp) + }); + drop(old); // free the old Player's Vec/String outside the critical section +} + /// Audio is transferred to/from the codec periodically over DMA. When a transfer completes, the /// DMA1 Stream 1 interrupt fires asking for the next block — we fill it from the engine. #[interrupt] @@ -106,16 +243,20 @@ fn DMA1_STR1() { let mut ai = AUDIO_INTERFACE.borrow(cs).borrow_mut(); let mut pl = PLAYER.borrow(cs).borrow_mut(); if let (Some(audio_interface), Some(player)) = (ai.as_mut(), pl.as_mut()) { + let playing = PLAYING.load(Ordering::Relaxed); + player.set_volume(f32::from_bits(VOLUME_BITS.load(Ordering::Relaxed))); audio_interface .handle_interrupt_dma1_str1(|audio_buffer| { for frame in audio_buffer { - let s = player.next_sample(); // mono engine sample - *frame = (s, s); // → left + right + // Paused: feed silence and freeze the engine (don't advance position). + let s = if playing { player.next_sample() } else { 0.0 }; + *frame = (s, s); // mono engine sample → left + right } }) .unwrap(); - // Publish the click count for the main-loop beat LED. + // Publish beat state for the main-loop LEDs. FIRED.store(player.fired(), Ordering::Relaxed); + LAST_LEVEL.store(player.last_level(), Ordering::Relaxed); } }); } diff --git a/rust/pm-daisy/src/programs.rs b/rust/pm-daisy/src/programs.rs new file mode 100644 index 0000000..ad2a2ca --- /dev/null +++ b/rust/pm-daisy/src/programs.rs @@ -0,0 +1,25 @@ +//! The built-in program list the two Pod buttons step through. Index 0 is the spike pattern, so +//! boot behavior is identical to the original spike (`docs/daisy-spike.md`); the rest are lifted +//! from the Grid firmware's `STYLES` / `PRACTICE` sets (`rust/pm-grid/src/main.rs`) so the editions +//! share one groove vocabulary. All are plain track-format strings — `track_format::parse` handles +//! them and `pm_synth::Player` schedules them at a fixed tempo (any `rmp`/`tr` directives parse but +//! don't animate here; the polyrhythm and odd-meter grooves are the interesting ones for the Pod). + +/// `(display name, track-format program)`. +pub const PROGRAMS: &[(&str, &str)] = &[ + // Index 0: the spike groove — unchanged boot default (124-BPM 909 four-on-the-floor). + ("Spike 909", pm_synth::SPIKE_PROGRAM), + // Styles. + ("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"), + ("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"), + // Practice — polyrhythms and subdivisions. + ("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"), +]; diff --git a/rust/pm-synth/src/lib.rs b/rust/pm-synth/src/lib.rs index a1892d6..866a77b 100644 --- a/rust/pm-synth/src/lib.rs +++ b/rust/pm-synth/src/lib.rs @@ -230,6 +230,11 @@ impl Synth { pub fn active(&self) -> usize { self.voices.len() } + /// Set the master output gain (volume), applied before the soft limiter. Clamped to a sane + /// range so a runaway control value can't blow up the mix. + pub fn set_master(&mut self, v: f32) { + self.master = v.clamp(0.0, 4.0); + } } // ----- voice recipes (ports of the DRUMS table in engine.js) ----- @@ -389,6 +394,10 @@ pub struct Player { n: u64, ci: usize, fired: u32, + /// Level of the most recently fired click (2 = accent, 1 = normal, 3 = ghost; 0 = none yet). + /// The firmware reads this to color the beat LED. When several clicks land on one sample the + /// loudest wins (accent > normal > ghost). + last_level: u8, } impl Player { @@ -403,18 +412,27 @@ impl Player { .collect(); events.sort_by_key(|e| e.0); let lane_voice = track.lanes.iter().map(|l| String::from(default_kit(&l.sound))).collect(); - Player { synth: Synth::new(), events, lane_voice, loop_samples: to_sample(total_ns).max(1), n: 0, ci: 0, fired: 0 } + Player { synth: Synth::new(), events, lane_voice, loop_samples: to_sample(total_ns).max(1), n: 0, ci: 0, fired: 0, last_level: 0 } } /// One mono sample in [-1, 1]. Call at `SR` (48 kHz). Triggers any clicks due at this sample /// first, so the click's own first sample is included. pub fn next_sample(&mut self) -> f32 { + // Loudest level among clicks landing on this sample (accent > normal > ghost); published to + // `last_level` so the beat LED can color by dynamic. Left unchanged on silent samples. + let mut best: Option = None; while self.ci < self.events.len() && self.events[self.ci].0 <= self.n { let (_, lane, level) = self.events[self.ci]; self.synth.trigger(self.lane_voice[lane].as_str(), level); self.fired = self.fired.wrapping_add(1); + if best.map_or(true, |b| level_rank(level) > level_rank(b)) { + best = Some(level); + } self.ci += 1; } + if let Some(b) = best { + self.last_level = b; + } let s = self.synth.next_sample(); self.n += 1; if self.n >= self.loop_samples { @@ -434,6 +452,42 @@ impl Player { pub fn fired(&self) -> u32 { self.fired } + + /// Position within the loop as a fraction in [0, 1). Used to carry the groove's phase across a + /// [`Player`] rebuild (e.g. a live tempo change) so the pattern doesn't jump back to bar 1. + pub fn position(&self) -> f32 { + self.n as f32 / self.loop_samples as f32 + } + + /// Jump to `fraction` of the way through the loop (clamped to [0, 1)). Resets the click cursor + /// to the first event at or after the new position; ringing voices are not affected (a fresh + /// `Player` has none). Pairs with [`position`](Self::position) for a phase-preserving swap. + pub fn seek(&mut self, fraction: f32) { + let f = fraction.clamp(0.0, 0.999_999); + self.n = (f * self.loop_samples as f32) as u64; + self.ci = self.events.partition_point(|e| e.0 < self.n); + } + + /// Set the master output volume (forwarded to the inner [`Synth`]). + pub fn set_volume(&mut self, v: f32) { + self.synth.set_master(v); + } + + /// Level of the most recently fired click (2 = accent, 1 = normal, 3 = ghost; 0 = none yet). + pub fn last_level(&self) -> u8 { + self.last_level + } +} + +/// Priority of a click level for "loudest wins" when several land on one sample: accent (2) beats +/// normal (1) beats ghost (3); anything else is lowest. +fn level_rank(level: u8) -> u8 { + match level { + 2 => 3, + 1 => 2, + 3 => 1, + _ => 0, + } } /// The groove the Daisy spike plays on boot. Defined here so the firmware and the host preview diff --git a/rust/pm-synth/tests/player.rs b/rust/pm-synth/tests/player.rs new file mode 100644 index 0000000..f35f0b7 --- /dev/null +++ b/rust/pm-synth/tests/player.rs @@ -0,0 +1,86 @@ +//! Host tests for the `Player` controls added for live tempo/volume on the Daisy Pod +//! (`position` / `seek` / `set_volume` / `last_level`). These run on the host (the no_std lib links +//! against the test harness's std allocator, same as `synthrender`), so the device behavior is +//! exercised without hardware. + +use pm_synth::{Player, SPIKE_BARS, SPIKE_PROGRAM}; + +fn spike_player() -> Player { + let track = track_format::parse(SPIKE_PROGRAM); + Player::new(&track, SPIKE_BARS) +} + +#[test] +fn fresh_player_starts_at_zero() { + let p = spike_player(); + assert_eq!(p.position(), 0.0); + assert_eq!(p.last_level(), 0, "no click has fired yet"); +} + +#[test] +fn position_advances_with_playback() { + let mut p = spike_player(); + for _ in 0..48_000 { + p.next_sample(); + } + let pos = p.position(); + assert!(pos > 0.0 && pos < 1.0, "position should be mid-loop, got {pos}"); +} + +#[test] +fn seek_sets_position_and_is_idempotent_with_position() { + let mut p = spike_player(); + p.seek(0.5); + let pos = p.position(); + assert!((pos - 0.5).abs() < 0.01, "seek(0.5) → position {pos}, want ~0.5"); +} + +#[test] +fn seek_clamps_to_loop() { + let mut p = spike_player(); + p.seek(2.0); // out of range + assert!(p.position() < 1.0, "seek past the end must clamp inside the loop"); + p.seek(-1.0); + assert_eq!(p.position(), 0.0, "negative seek clamps to start"); +} + +#[test] +fn seek_cursor_lands_on_next_event() { + // The spike program has a kick on every beat (level 1/X). After seeking just before the loop + // end, advancing should still fire the wrap-around downbeat — i.e. the cursor was repositioned, + // not left dangling past the end. + let mut p = spike_player(); + let before = p.fired(); + p.seek(0.999); + // Run through the loop seam (a chunk longer than the remaining tail). + for _ in 0..48_000 { + p.next_sample(); + } + assert!(p.fired() > before, "clicks should fire after seeking near the end and wrapping"); +} + +#[test] +fn last_level_reports_accent_priority() { + // kick909 is level 1 (X with no explicit accent map → normal), clap on beats 2&4 is X (accent). + // Run a full loop; by the end last_level must have seen at least a normal-level hit. + let mut p = spike_player(); + let track = track_format::parse(SPIKE_PROGRAM); + let mbar = track_format::schedule::master_bar_ns(&track) * SPIKE_BARS; + let samples = (mbar as f64 / 1e9 * pm_synth::SR as f64) as usize; + for _ in 0..samples { + p.next_sample(); + } + assert!(p.last_level() >= 1 && p.last_level() <= 3, "last_level must be a valid click level, got {}", p.last_level()); +} + +#[test] +fn set_volume_does_not_panic_and_silences_at_zero() { + let mut p = spike_player(); + p.set_volume(0.0); + // Drive past the first downbeat; at zero master the output must be silent. + let mut peak = 0.0f32; + for _ in 0..24_000 { + peak = peak.max(p.next_sample().abs()); + } + assert_eq!(peak, 0.0, "volume 0 should produce silence, peaked at {peak}"); +}