metronome/rust/pm-daisy/src/main.rs
Me Here d80c35984e pm-daisy: Daisy Pod spike — play the click engine on STM32H7 (host-verified, awaiting hardware)
Develop the full Daisy Pod spike so it can be flashed the moment the board
arrives. Architecture: one shared engine, two front-ends.

- pm-synth: make it `#![no_std]` (mirroring track-format), routing float math
  through `libm` so the SAME f32 code runs on the host and on the Daisy's
  Cortex-M7F (hardware FPU — no fixed-point port needed). Add `Player`, a
  self-running sequencer that owns the Synth + scheduled clicks and renders
  sample-by-sample, looping at the pattern boundary. Integer-only hot path
  (clicks pre-resolved to sample indices); exposes a `fired()` beat counter.
  Add SPIKE_PROGRAM/SPIKE_BARS as the shared source of truth.

- synthrender: render the SAME Player to pm-daisy-preview.wav — the host-side
  "simulator". Bit-identical preview of the hardware output (before its codec);
  far more useful than chip emulation (Renode can't model the audio codec).

- pm-daisy (new, workspace-excluded firmware): thin BSP binary for the Daisy
  Seed/Pod. embedded-alloc heap + board bring-up + SAI-DMA audio interrupt
  feeding Player::next_sample() into stereo frames, USER LED flashing per click.
  Audio loop follows the `daisy` crate's examples/audio.rs. Board revision
  (codec) is a Cargo feature; README documents matching it + both flash paths
  (probe-rs/RTT and USB DFU) + the QSPI-bootloader fallback.

Verified without hardware: host build + preview render (48 kHz, onsets on the
8th-note grid at 124 BPM); firmware cross-compiles + links for thumbv7em-none-
eabihf at ~87 KB (fits the 128 KB internal flash) across all three codec
revisions; track-format conformance + `node tests/run.mjs` (47 pass) still green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 11:41:10 -05:00

121 lines
4.9 KiB
Rust

//! PM Daisy-Pod spike — play the PolyMeter click engine on real hardware.
//!
//! 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.
//!
//! 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`.
//!
//! Build/flash: see `build.sh` and `README.md`. The board REVISION feature (Cargo.toml) must match
//! your Seed's codec or you get silence.
#![no_main]
#![no_std]
extern crate alloc;
use core::cell::RefCell;
use core::mem::MaybeUninit;
use core::sync::atomic::{AtomicU32, Ordering};
use cortex_m::asm;
use cortex_m::interrupt::Mutex;
use cortex_m_rt::entry;
use defmt_rtt as _;
use panic_probe as _;
use embedded_alloc::LlffHeap as Heap;
use hal::pac::{self, interrupt};
use stm32h7xx_hal as hal;
use daisy::audio;
use pm_synth::{Player, SPIKE_BARS, SPIKE_PROGRAM};
#[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.
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);
#[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".)
{
const HEAP_SIZE: usize = 64 * 1024;
static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
unsafe { HEAP.init(core::ptr::addr_of_mut!(HEAP_MEM) as usize, HEAP_SIZE) }
}
let mut cp = cortex_m::Peripherals::take().unwrap();
let dp = pac::Peripherals::take().unwrap();
// Caches give a major DSP performance boost; the `daisy` crate handles cache management around
// the audio DMA buffers for us.
cp.SCB.enable_icache();
cp.SCB.enable_dcache(&mut cp.CPUID);
// Board bring-up (clocks, GPIO, LED, audio) via the daisy crate's macros.
let board = daisy::Board::take().unwrap();
let ccdr = daisy::board_freeze_clocks!(board, dp);
let pins = daisy::board_split_gpios!(board, ccdr, dp);
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());
// Start audio and hand the peripheral + player to the interrupt.
let audio_interface = audio_interface.spawn().unwrap();
cortex_m::interrupt::free(|cs| {
PLAYER.borrow(cs).replace(Some(player));
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
}
}
/// 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]
fn DMA1_STR1() {
cortex_m::interrupt::free(|cs| {
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()) {
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
}
})
.unwrap();
// Publish the click count for the main-loop beat LED.
FIRED.store(player.fired(), Ordering::Relaxed);
}
});
}