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 <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-10 20:04:59 -05:00
parent 4e0a68f35b
commit 802e46f5bb
8 changed files with 673 additions and 41 deletions

View file

@ -2,11 +2,17 @@
**Status:** **code complete, host-verified, awaiting hardware.** The firmware crate **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 [`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, **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 encoder, RGB LEDs). The Seed is the brain; the spike itself uses its audio out + the Seed USER LED.
Pod's extra controls are a documented next step (`rust/pm-daisy/README.md`).
> **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) **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 platform (STM32H750, Cortex-M7 @ 480 MHz + onboard 24-bit audio codec) the right home for the audio

View file

@ -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 dfu-util -a 0 -s 0x90040000:leave -D pm-daisy.bin -d ,0483:df11
``` ```
## Beyond the spike (Pod extras, not yet wired) ## Pod controls
The Pod adds 2 buttons, 2 knobs, an encoder, and 2 RGB LEDs on Seed GPIO/ADC pins. Obvious next The Pod's buttons, encoder, knobs, and RGB LEDs are wired up (in `src/controls.rs` + `src/leds.rs`).
steps once audio is confirmed: knob → tempo, button → start/stop, RGB LED → beat/downbeat color. Boot still plays the spike groove (program index 0), so the original spike criteria stay observable.
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. | Control | Action |
|---|---|
| Encoder turn | tempo ±1 BPM per detent (clamped 5300) |
| 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).

View file

@ -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<ADC1, hal::adc::Enabled>;
/// 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<f32>,
}
/// All Pod controls plus the two RGB LEDs.
pub struct Pod {
pub leds: Leds,
adc1: Adc1,
pot1: PC4<Analog>,
pot2: PC0<Analog>,
btn1: PG9<Input>,
btn2: PA2<Input>,
enc_a: PD11<Input>,
enc_b: PA0<Input>,
enc_sw: PB6<Input>,
// 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
}
}

103
rust/pm-daisy/src/leds.rs Normal file
View file

@ -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<Output<PushPull>>; 3],
}
impl RgbLed {
fn new(r: ErasedPin<Output<PushPull>>, g: ErasedPin<Output<PushPull>>, b: ErasedPin<Output<PushPull>>) -> 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<Output<PushPull>>; 3],
transport: [ErasedPin<Output<PushPull>>; 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 });
}
}

View file

@ -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 //! 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 //! `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 //! jack. The engine is mono; both stereo channels get the same sample.
//! on every click as a beat indicator.
//! //!
//! This is a digital/DSP-home experiment (see `docs/daisy-spike.md`), not the heirloom analog path. //! Beyond the original spike (see `docs/daisy-spike.md`) this wires the Pod's controls: the encoder
//! The audio loop structure follows the `daisy` crate's `examples/audio.rs`. //! 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 //! Build/flash: see `build.sh` and `README.md`. The board REVISION feature (Cargo.toml) must match
//! your Seed's codec or you get silence. //! your Seed's codec or you get silence.
@ -16,13 +20,17 @@
extern crate alloc; extern crate alloc;
mod controls;
mod leds;
mod programs;
use core::cell::RefCell; use core::cell::RefCell;
use core::mem::MaybeUninit; 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::interrupt::Mutex;
use cortex_m_rt::entry; use cortex_m::peripheral::syst::SystClkSource;
use cortex_m_rt::{entry, exception};
use defmt_rtt as _; use defmt_rtt as _;
use panic_probe as _; use panic_probe as _;
@ -31,24 +39,45 @@ use hal::pac::{self, interrupt};
use stm32h7xx_hal as hal; use stm32h7xx_hal as hal;
use daisy::audio; use daisy::audio;
use pm_synth::{Player, SPIKE_BARS, SPIKE_PROGRAM}; use pm_synth::Player;
#[global_allocator] #[global_allocator]
static HEAP: Heap = Heap::empty(); static HEAP: Heap = Heap::empty();
/// The running groove and the audio peripheral live in globals so the DMA audio interrupt can reach /// 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<RefCell<Option<Player>>> = Mutex::new(RefCell::new(None)); static PLAYER: Mutex<RefCell<Option<Player>>> = Mutex::new(RefCell::new(None));
static AUDIO_INTERFACE: Mutex<RefCell<Option<audio::Interface>>> = Mutex::new(RefCell::new(None)); static AUDIO_INTERFACE: Mutex<RefCell<Option<audio::Interface>>> = Mutex::new(RefCell::new(None));
/// Total clicks fired, published from the audio IRQ. The main loop watches it to flash the beat LED. /// Total clicks fired, published from the audio IRQ. The main loop watches it to flash the beat LED.
static FIRED: AtomicU32 = AtomicU32::new(0); 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] #[entry]
fn main() -> ! { fn main() -> ! {
// Heap first: `Player::new` parses the program into Vec/String, and each `trigger()` allocates a // 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 // voice. 64 KB lives comfortably in the 128 KB DTCM. Tempo/program changes rebuild the Player in
// real-time smell — fine for the spike; see docs/daisy-spike.md "Decision criteria".) // 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; const HEAP_SIZE: usize = 64 * 1024;
static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE]; static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
@ -70,10 +99,26 @@ fn main() -> ! {
let mut led_user = daisy::board_split_leds!(pins).USER; let mut led_user = daisy::board_split_leds!(pins).USER;
let audio_interface = daisy::board_split_audio!(ccdr, pins); let audio_interface = daisy::board_split_audio!(ccdr, pins);
// Build the groove from the shared spike pattern. // Pod controls (buttons, encoder, pots, RGB LEDs) on the Seed GPIO/ADC pins the audio path left
let track = track_format::parse(SPIKE_PROGRAM); // free. Pin map verified against libDaisy's daisy_pod.cpp — see controls.rs / leds.rs. `Pod::new`
let player = Player::new(&track, SPIKE_BARS); // borrows SYST for the ADC's boot calibration and hands it back for the SysTick tick below.
defmt::info!("pm-daisy: playing `{}` ({=i64} bars), heap {=usize} B free", SPIKE_PROGRAM, SPIKE_BARS, HEAP.free()); 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. // Start audio and hand the peripheral + player to the interrupt.
let audio_interface = audio_interface.spawn().unwrap(); let audio_interface = audio_interface.spawn().unwrap();
@ -82,20 +127,112 @@ fn main() -> ! {
AUDIO_INTERFACE.borrow(cs).replace(Some(audio_interface)); AUDIO_INTERFACE.borrow(cs).replace(Some(audio_interface));
}); });
// Beat LED: a ~25 ms flash whenever the click count advances. Audio is fully interrupt-driven, // SysTick as a 1 kHz millis tick. Started AFTER the ADC calibration that borrowed SYST in
// so blocking the main loop with `asm::delay` is harmless. // `Pod::new` (which returned it), so the counter and the calibration delay never fight over it.
let ms = ccdr.clocks.sys_ck().to_Hz() / 1000; let mut syst = syst;
let mut last = 0u32; 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 { 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); let fired = FIRED.load(Ordering::Relaxed);
if fired != last { if fired != last_fired {
last = fired; last_fired = fired;
led_user.set_high(); let _ = led_user.set_high();
asm::delay(ms * 25); pod.leds.beat_on(LAST_LEVEL.load(Ordering::Relaxed));
led_user.set_low(); flash_off_at = now.wrapping_add(25);
} }
asm::delay(ms); // ~1 ms poll cadence 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 /// Audio is transferred to/from the codec periodically over DMA. When a transfer completes, the
@ -106,16 +243,20 @@ fn DMA1_STR1() {
let mut ai = AUDIO_INTERFACE.borrow(cs).borrow_mut(); let mut ai = AUDIO_INTERFACE.borrow(cs).borrow_mut();
let mut pl = PLAYER.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()) { 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 audio_interface
.handle_interrupt_dma1_str1(|audio_buffer| { .handle_interrupt_dma1_str1(|audio_buffer| {
for frame in audio_buffer { for frame in audio_buffer {
let s = player.next_sample(); // mono engine sample // Paused: feed silence and freeze the engine (don't advance position).
*frame = (s, s); // → left + right let s = if playing { player.next_sample() } else { 0.0 };
*frame = (s, s); // mono engine sample → left + right
} }
}) })
.unwrap(); .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); FIRED.store(player.fired(), Ordering::Relaxed);
LAST_LEVEL.store(player.last_level(), Ordering::Relaxed);
} }
}); });
} }

View file

@ -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"),
];

View file

@ -230,6 +230,11 @@ impl Synth {
pub fn active(&self) -> usize { pub fn active(&self) -> usize {
self.voices.len() 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) ----- // ----- voice recipes (ports of the DRUMS table in engine.js) -----
@ -389,6 +394,10 @@ pub struct Player {
n: u64, n: u64,
ci: usize, ci: usize,
fired: u32, 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 { impl Player {
@ -403,18 +412,27 @@ impl Player {
.collect(); .collect();
events.sort_by_key(|e| e.0); events.sort_by_key(|e| e.0);
let lane_voice = track.lanes.iter().map(|l| String::from(default_kit(&l.sound))).collect(); 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 /// 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. /// first, so the click's own first sample is included.
pub fn next_sample(&mut self) -> f32 { 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<u8> = None;
while self.ci < self.events.len() && self.events[self.ci].0 <= self.n { while self.ci < self.events.len() && self.events[self.ci].0 <= self.n {
let (_, lane, level) = self.events[self.ci]; let (_, lane, level) = self.events[self.ci];
self.synth.trigger(self.lane_voice[lane].as_str(), level); self.synth.trigger(self.lane_voice[lane].as_str(), level);
self.fired = self.fired.wrapping_add(1); 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; self.ci += 1;
} }
if let Some(b) = best {
self.last_level = b;
}
let s = self.synth.next_sample(); let s = self.synth.next_sample();
self.n += 1; self.n += 1;
if self.n >= self.loop_samples { if self.n >= self.loop_samples {
@ -434,6 +452,42 @@ impl Player {
pub fn fired(&self) -> u32 { pub fn fired(&self) -> u32 {
self.fired 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 /// The groove the Daisy spike plays on boot. Defined here so the firmware and the host preview

View file

@ -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}");
}