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
[`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

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
```
## 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 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
//! `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<RefCell<Option<Player>>> = 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.
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<u8>; 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,20 +127,112 @@ 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;
// 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 {
last = fired;
led_user.set_high();
asm::delay(ms * 25);
led_user.set_low();
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);
}
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
@ -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);
}
});
}

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 {
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<u8> = 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

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