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
|
||||
[`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
|
||||
|
|
|
|||
|
|
@ -57,8 +57,27 @@ the **Daisy Bootloader** to the 8 MB QSPI instead:
|
|||
dfu-util -a 0 -s 0x90040000:leave -D pm-daisy.bin -d ,0483:df11
|
||||
```
|
||||
|
||||
## Beyond the spike (Pod extras, not yet wired)
|
||||
The Pod adds 2 buttons, 2 knobs, an encoder, and 2 RGB LEDs on Seed GPIO/ADC pins. Obvious next
|
||||
steps once audio is confirmed: knob → tempo, button → start/stop, RGB LED → beat/downbeat color.
|
||||
Wiring those needs the Pod pin map from Electrosmith's pinout (verify before assigning pins). This
|
||||
spike deliberately uses only the always-present Seed USER LED to avoid guessing the Pod pinout.
|
||||
## Pod controls
|
||||
The Pod's buttons, encoder, knobs, and RGB LEDs are wired up (in `src/controls.rs` + `src/leds.rs`).
|
||||
Boot still plays the spike groove (program index 0), so the original spike criteria stay observable.
|
||||
|
||||
| Control | Action |
|
||||
|---|---|
|
||||
| Encoder turn | tempo ±1 BPM per detent (clamped 5–300) |
|
||||
| Encoder push | play / pause (ignored if you turned while holding it) |
|
||||
| Button 1 / Button 2 | previous / next program (`src/programs.rs`, wraps) |
|
||||
| Knob 1 | master volume (read at boot — no startup jump) |
|
||||
| Knob 2 | reserved (read but unused) |
|
||||
| RGB LED 1 | beat flash, colored by dynamic: accent = yellow, normal = cyan, ghost = magenta |
|
||||
| RGB LED 2 | transport: green = playing, red = paused |
|
||||
| Seed USER LED | unchanged spike beat flash |
|
||||
|
||||
At boot both RGB LEDs cycle R → G → B (a ~0.45 s self-test) so a miswired or dead leg is obvious
|
||||
before audio starts. A live tempo or program change rebuilds the engine in the main loop (not the
|
||||
audio IRQ) and swaps it in under a short critical section, preserving loop phase on tempo changes.
|
||||
|
||||
**Pin map** (verified against libDaisy `daisy_pod.cpp`, cross-checked with the `daisy` crate's
|
||||
`pins.rs`): buttons D27/D28 (PG9/PA2), encoder A/B/click D26/D25/D13 (PD11/PA0/PB6), knobs D21/D15
|
||||
(PC4/PC0 → ADC1), LED 1 D20/D19/D18 (PC1/PA6/PA7), LED 2 D17/D24/D23 (PB1/PA1/PA4). The RGB LEDs are
|
||||
common-anode (active-low). If the encoder feels reversed or double-steps, adjust the decode in
|
||||
`Pod::poll` (detents-per-quadrature-cycle varies by encoder).
|
||||
|
|
|
|||
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
|
||||
//! `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,22 +127,114 @@ fn main() -> ! {
|
|||
AUDIO_INTERFACE.borrow(cs).replace(Some(audio_interface));
|
||||
});
|
||||
|
||||
// Beat LED: a ~25 ms flash whenever the click count advances. Audio is fully interrupt-driven,
|
||||
// so blocking the main loop with `asm::delay` is harmless.
|
||||
let ms = ccdr.clocks.sys_ck().to_Hz() / 1000;
|
||||
let mut last = 0u32;
|
||||
loop {
|
||||
let fired = FIRED.load(Ordering::Relaxed);
|
||||
if fired != last {
|
||||
last = fired;
|
||||
led_user.set_high();
|
||||
asm::delay(ms * 25);
|
||||
led_user.set_low();
|
||||
}
|
||||
asm::delay(ms); // ~1 ms poll cadence
|
||||
// SysTick as a 1 kHz millis tick. Started AFTER the ADC calibration that borrowed SYST in
|
||||
// `Pod::new` (which returned it), so the counter and the calibration delay never fight over it.
|
||||
let mut syst = syst;
|
||||
syst.set_clock_source(SystClkSource::Core);
|
||||
syst.set_reload(cpu_hz / 1000 - 1);
|
||||
syst.clear_current();
|
||||
syst.enable_counter();
|
||||
syst.enable_interrupt();
|
||||
|
||||
led_loop(&mut led_user, &mut pod, &mut track, &mut bars, &mut prog_idx)
|
||||
}
|
||||
|
||||
/// Bars to loop a parsed track: its own `bars` if set, else 4 (the spike default).
|
||||
fn play_bars(track: &track_format::Track) -> i64 {
|
||||
if track.bars > 0 {
|
||||
track.bars
|
||||
} else {
|
||||
4
|
||||
}
|
||||
}
|
||||
|
||||
/// Main control + indicator loop. Audio is fully interrupt-driven; here we poll the Pod controls at
|
||||
/// ~1 kHz, apply tempo/program/volume/transport changes, and drive the LEDs.
|
||||
fn led_loop(
|
||||
led_user: &mut daisy::led::LedUser,
|
||||
pod: &mut controls::Pod,
|
||||
track: &mut track_format::Track,
|
||||
bars: &mut i64,
|
||||
prog_idx: &mut usize,
|
||||
) -> ! {
|
||||
let mut last_fired = 0u32;
|
||||
let mut flash_off_at = 0u32; // millis deadline for ending the beat flash; 0 = LED off
|
||||
let mut last_poll = 0u32;
|
||||
|
||||
loop {
|
||||
let now = millis();
|
||||
|
||||
// Beat flash (USER LED + RGB LED 1), driven by the click counter the IRQ publishes.
|
||||
let fired = FIRED.load(Ordering::Relaxed);
|
||||
if fired != last_fired {
|
||||
last_fired = fired;
|
||||
let _ = led_user.set_high();
|
||||
pod.leds.beat_on(LAST_LEVEL.load(Ordering::Relaxed));
|
||||
flash_off_at = now.wrapping_add(25);
|
||||
}
|
||||
if flash_off_at != 0 && now.wrapping_sub(flash_off_at) < 0x8000_0000 && now >= flash_off_at {
|
||||
let _ = led_user.set_low();
|
||||
pod.leds.beat_off();
|
||||
flash_off_at = 0;
|
||||
}
|
||||
|
||||
// Poll controls once per millisecond.
|
||||
if now != last_poll {
|
||||
last_poll = now;
|
||||
let ev = pod.poll();
|
||||
|
||||
// Volume: knob 1, applied continuously via the atomic the IRQ reads (no rebuild).
|
||||
if let Some(v) = ev.volume {
|
||||
VOLUME_BITS.store(v.to_bits(), Ordering::Relaxed);
|
||||
}
|
||||
|
||||
// Transport: encoder push toggles play/pause.
|
||||
if ev.toggle_play {
|
||||
let now_playing = !PLAYING.load(Ordering::Relaxed);
|
||||
PLAYING.store(now_playing, Ordering::Relaxed);
|
||||
pod.leds.set_transport(now_playing);
|
||||
defmt::info!("transport: {}", if now_playing { "play" } else { "pause" });
|
||||
}
|
||||
|
||||
// Program change: buttons step through PROGRAMS, then rebuild.
|
||||
if ev.prog_delta != 0 {
|
||||
let n = programs::PROGRAMS.len() as i32;
|
||||
*prog_idx = (((*prog_idx as i32 + ev.prog_delta) % n + n) % n) as usize;
|
||||
*track = track_format::parse(programs::PROGRAMS[*prog_idx].1);
|
||||
*bars = play_bars(track);
|
||||
rebuild_player(track, *bars, true);
|
||||
defmt::info!("program {=usize}: '{}' @ {=i64} bpm", *prog_idx, programs::PROGRAMS[*prog_idx].0, track.bpm);
|
||||
}
|
||||
|
||||
// Tempo: encoder turn nudges BPM, then rebuild preserving loop phase.
|
||||
if ev.bpm_delta != 0 {
|
||||
track.bpm = (track.bpm + ev.bpm_delta as i64).clamp(5, 300);
|
||||
rebuild_player(track, *bars, false);
|
||||
defmt::info!("bpm {=i64}", track.bpm);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Rebuild the [`Player`] for a new tempo/program and swap it into the audio IRQ's slot. The build
|
||||
/// (and the old Player's drop) happen here in thread context; the critical section holds only the
|
||||
/// phase read + pointer swap, so it can't stall the DMA callback. When `reset_phase` is false the
|
||||
/// new Player is seeked to the old one's loop position so a tempo change doesn't jump to bar 1.
|
||||
fn rebuild_player(track: &track_format::Track, bars: i64, reset_phase: bool) {
|
||||
let mut newp = Player::new(track, bars);
|
||||
newp.set_volume(f32::from_bits(VOLUME_BITS.load(Ordering::Relaxed)));
|
||||
let old = cortex_m::interrupt::free(|cs| {
|
||||
let mut slot = PLAYER.borrow(cs).borrow_mut();
|
||||
if !reset_phase {
|
||||
if let Some(cur) = slot.as_ref() {
|
||||
newp.seek(cur.position());
|
||||
}
|
||||
}
|
||||
slot.replace(newp)
|
||||
});
|
||||
drop(old); // free the old Player's Vec/String outside the critical section
|
||||
}
|
||||
|
||||
/// Audio is transferred to/from the codec periodically over DMA. When a transfer completes, the
|
||||
/// DMA1 Stream 1 interrupt fires asking for the next block — we fill it from the engine.
|
||||
#[interrupt]
|
||||
|
|
@ -106,16 +243,20 @@ fn DMA1_STR1() {
|
|||
let mut ai = AUDIO_INTERFACE.borrow(cs).borrow_mut();
|
||||
let mut pl = PLAYER.borrow(cs).borrow_mut();
|
||||
if let (Some(audio_interface), Some(player)) = (ai.as_mut(), pl.as_mut()) {
|
||||
let playing = PLAYING.load(Ordering::Relaxed);
|
||||
player.set_volume(f32::from_bits(VOLUME_BITS.load(Ordering::Relaxed)));
|
||||
audio_interface
|
||||
.handle_interrupt_dma1_str1(|audio_buffer| {
|
||||
for frame in audio_buffer {
|
||||
let s = player.next_sample(); // mono engine sample
|
||||
*frame = (s, s); // → left + right
|
||||
// Paused: feed silence and freeze the engine (don't advance position).
|
||||
let s = if playing { player.next_sample() } else { 0.0 };
|
||||
*frame = (s, s); // mono engine sample → left + right
|
||||
}
|
||||
})
|
||||
.unwrap();
|
||||
// Publish the click count for the main-loop beat LED.
|
||||
// Publish beat state for the main-loop LEDs.
|
||||
FIRED.store(player.fired(), Ordering::Relaxed);
|
||||
LAST_LEVEL.store(player.last_level(), Ordering::Relaxed);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
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 {
|
||||
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
|
||||
|
|
|
|||
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