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:
parent
4e0a68f35b
commit
802e46f5bb
8 changed files with 673 additions and 41 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 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).
|
||||||
|
|
|
||||||
198
rust/pm-daisy/src/controls.rs
Normal file
198
rust/pm-daisy/src/controls.rs
Normal 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
103
rust/pm-daisy/src/leds.rs
Normal 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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,22 +127,114 @@ 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);
|
||||||
loop {
|
syst.set_reload(cpu_hz / 1000 - 1);
|
||||||
let fired = FIRED.load(Ordering::Relaxed);
|
syst.clear_current();
|
||||||
if fired != last {
|
syst.enable_counter();
|
||||||
last = fired;
|
syst.enable_interrupt();
|
||||||
led_user.set_high();
|
|
||||||
asm::delay(ms * 25);
|
led_loop(&mut led_user, &mut pod, &mut track, &mut bars, &mut prog_idx)
|
||||||
led_user.set_low();
|
}
|
||||||
}
|
|
||||||
asm::delay(ms); // ~1 ms poll cadence
|
/// 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
|
/// 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.
|
/// DMA1 Stream 1 interrupt fires asking for the next block — we fill it from the engine.
|
||||||
#[interrupt]
|
#[interrupt]
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
25
rust/pm-daisy/src/programs.rs
Normal file
25
rust/pm-daisy/src/programs.rs
Normal 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"),
|
||||||
|
];
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
86
rust/pm-synth/tests/player.rs
Normal file
86
rust/pm-synth/tests/player.rs
Normal 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}");
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue