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>
This commit is contained in:
Me Here 2026-06-05 11:41:10 -05:00
parent 0bec13abab
commit d80c35984e
14 changed files with 616 additions and 35 deletions

126
docs/daisy-spike.md Normal file
View file

@ -0,0 +1,126 @@
# Daisy Pod spike — scope + status
**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.
**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`).
**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
engine *if* PolyMeter ever grows past "metronome" into a real-time audio workstation (live input FX,
heavy polyphony, recording)? Today the answer is "RP2350 is the pick" (see [rust-port.md](rust-port.md))
— this spike exists to get a **first-hand, low-stakes** read on the alternative, not to commit to it.
## What's built (this is done)
| Piece | Where | Verified |
|---|---|---|
| Shared self-running sequencer `Player` | `rust/pm-synth/src/lib.rs` | host build + render |
| `pm-synth` made `#![no_std]` (libm float math) | same | builds host **and** thumbv7em |
| Host preview / "simulator" → `pm-daisy-preview.wav` | `rust/synthrender` | renders; 48 kHz, on-grid onsets |
| Daisy firmware (heap, board init, SAI-DMA callback, beat LED) | `rust/pm-daisy/` | **cross-compiles + links**, ~87 KB (fits 128 KB flash), all 3 codec revisions build |
| Flashing + revision docs | `rust/pm-daisy/README.md` | — |
**The host preview IS the simulator.** Full STM32H7 emulation (Renode) exists but won't faithfully
model the audio codec — useless for "does it sound right." Instead, because the engine is shared,
`synthrender` drives the *exact* `Player` the firmware runs and writes `pm-daisy-preview.wav` — a
bit-identical preview of the hardware output (before its codec). Run `cargo run` in `rust/synthrender`
and listen. When the Pod arrives, flashing should reproduce that WAV out the jack.
This is a **digital/DSP-home** experiment only. It deliberately does **not** touch the heirloom
pro-analog chain (THAT receivers/drivers, low-jitter clock, mute relay) from
[`hardware/DESIGN.md`](../hardware/DESIGN.md) — the Daisy's onboard codec is a consumer part. The
spike answers "does the engine feel at home on this chip," not "is this the heirloom audio path."
## The bet, in one sentence
Almost the entire engine already transfers: `track-format` is `no_std` and RP2350-proven, `pm-synth`
is pure `f32` with no real dependencies, **the STM32H750 has a hardware FPU so those `f32` voices run
natively** (no fixed-point rewrite — the opposite of the RP2350's Cortex-M0+ situation flagged in
`pm-synth/Cargo.toml`), and `synthrender/src/main.rs`'s render loop *is* the audio callback. So the
spike is mostly **transport bring-up**, not an engine rewrite.
## What transfers for free vs. what's new
| Piece | Source | Spike work |
|---|---|---|
| Track parse + schedule | `rust/track-format` (`#![no_std]`, builds for RP2350) | **none** — use verbatim |
| Drum voices / click engine | `rust/pm-synth` (`Synth::new` / `trigger(name,level)` / `next_sample()->f32`) | **small** — see "no_std-ify" below |
| Render→audio loop shape | `rust/synthrender/src/main.rs` `render()` | **adapt** — pre-rendered buffer → streaming callback |
| Audio transport (codec + SAI) | — | **new** — the actual spike |
| Toolchain / flash / logs | reuse the probe-rs + defmt workflow from `pm-grid`/`pm-kit` ([probe-flash.md](../rust/probe-flash.md)) | **setup** |
### no_std-ifying `pm-synth` (mechanical, ~½ day)
`pm-synth` already uses `alloc::vec` and `core::f32::consts` — it's half-way there. To build for the
target it needs:
1. `#![no_std]` + `extern crate alloc;` at the crate root.
2. **`libm`** for the float methods it calls as inherent fns (`.sin()`, `.cos()`, `.powf()`,
`.tanh()`, `.floor()` — see `pm-synth/src/lib.rs:19,21,49,56,85,221`). In `no_std` these aren't
inherent on `f32`; route them through `libm::{sinf,cosf,powf,tanhf,floorf}` (or the `num-traits`
`Float` shim). Gate with `#[cfg(feature = "std")]` so the host `synthrender` keeps building.
3. A **global allocator** on-device (`embedded-alloc`), because `trigger()` does `alloc::vec![…]`
per voice. The STM32H750 has ample RAM (≥1 MB SRAM, +64 MB SDRAM on the Seed), so a small heap is
trivial — **but** allocating in the audio path is a real-time smell. Whether it glitches is itself
a useful spike finding (see Decision criteria); production would pre-allocate fixed voices.
### Streaming the render loop (~½ day)
`synthrender` pre-renders an entire `Vec<i16>`. On-device, keep a running sample counter `n` and a
click index `ci`, and inside the SAI block callback do per-frame what the host loop does per-sample:
advance `t_ns`, `trigger()` any clicks whose `time_ns <= t_ns`, then `next_sample()`. The engine is
48 kHz mono; the codec is stereo — duplicate the sample to L/R. Loop the pattern by resetting `n`/`ci`
at `master_bar_ns * bars`.
## Phases (time-boxed ~2 days)
1. **Toolchain + flash hello (½ day).** Add `rust/pm-daisy` (thin BSP binary). Target
`thumbv7em-none-eabihf`. Pick the Rust BSP: **`daisy`** (zlosynth) or **`libdaisy`** for a
blocking SAI-DMA callback, or **`daisy-embassy`** if we want async. Blink + a defmt "hello" over
RTT to confirm flashing. Flash via **USB DFU** (no extra hardware — Daisy boots to DFU) *or*
probe-rs RTT with the Pi Debug Probe we already use.
2. **Codec test tone (½ day).** Stand up the SAI audio callback at 48 kHz and emit a sine — confirms
the codec init (match the Seed revision: **AK4556** rev4 / **WM8731** Seed 1.1 / **PCM3060** Seed
1.2/2 — the BSP crate selects this; verify the rev at purchase) and that audio comes out the line jack.
3. **Drop in the engine (½ day).** Depend on `track-format` + `pm-synth`, no_std-ify `pm-synth`, port
the streaming loop. Play a hardcoded program (`t124;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X`,
the same vector `synthrender` already auditions) on a loop.
4. **Measure + write up (½ day).** Capture findings against the criteria below; append a verdict to
this file and a one-line pointer in `rust-port.md`.
## Deliverables
- `rust/pm-daisy/` — a thin BSP crate playing a hardcoded 909 pattern out the Daisy line jack.
- `pm-synth` building `no_std` (host `synthrender` still builds — guarded by a `std` feature).
- A **findings verdict** appended here: go/no-go, with the numbers below.
## Decision criteria (what the verdict answers)
- **Sound:** clean line-out, no audible glitches/zipper noise on the looping pattern.
- **Timing:** clicks land tight (callback keeps up; no underruns logged).
- **Alloc-in-callback:** does `trigger()`'s per-voice `alloc::vec!` cause dropouts under polyphony
(the `poly` demo vector)? If yes → production needs fixed pre-alloc voices (true on *any* target —
useful regardless of chip choice).
- **Porting friction:** how much of the ~2-day estimate was real? Low friction + the codec "just
working" = Daisy is a credible workstation home. High friction / ecosystem fights = confirms RP2350.
## Non-goals (explicitly out of scope for the spike)
USB-MIDI, live-sync/SysEx, display/LEDs, set lists, buttons, the pro-analog chain, fixed-point/
production voice allocation, A/B firmware update. The spike proves **one thing**: the click engine
sings on this chip with acceptable effort.
## Risks / unknowns
- **Codec revision mismatch** — confirm which Seed rev (AK4556/WM8731/PCM3060) ships; the BSP crate
must match. Verify at purchase ([verify-datasheets memory]).
- **`pm-synth` float port** — mechanical but touches every voice; the `std`-feature gate keeps the
host renderer green so the conformance story is unaffected.
- **Heap in the audio path** — flagged above; the spike is exactly how we learn if it matters.
- **Sunk-cost honesty** — even a *great* result doesn't move the metronome off RP2350 today; it only
de-risks a *future* workstation pivot. Keep that framing in the verdict.
## References
- [Daisy Seed product page + datasheet](https://electro-smith.com/products/daisy-seed) — STM32H750, 24-bit/96 kHz codec, FPU
- Rust BSPs: [`daisy` (zlosynth)](https://github.com/zlosynth/daisy) · [`libdaisy`](https://crates.io/crates/libdaisy) · [`daisy-embassy`](https://crates.io/crates/daisy-embassy)
- [libDaisy audio-callback model](https://electro-smith.github.io/libDaisy/md_doc_2md_2__a3___getting-_started-_audio.html) (interleaved/non-interleaved; the C reference for the SAI callback shape)
- Reuse targets in-repo: `rust/track-format`, `rust/pm-synth`, `rust/synthrender/src/main.rs`
</content>
</invoke>

View file

@ -8,11 +8,11 @@ members = [
"pm-synth", "pm-synth",
"synthrender", "synthrender",
] ]
# pm-kit (RP2350/thumbv8m) and pm-grid (RP2040/thumbv6m) are embedded firmware (no_std + their own # pm-kit (RP2350/thumbv8m), pm-grid (RP2040/thumbv6m), and pm-daisy (STM32H750/thumbv7em) are
# profile/build). Each is built on its own from its crate dir (e.g. `cargo build` inside pm-grid/, # embedded firmware (no_std + their own profile/build). Each is built on its own from its crate dir
# which picks up its .cargo/config.toml target), so they are kept OUT of this host workspace to # (e.g. `cargo build` inside pm-grid/, which picks up its .cargo/config.toml target), so they are
# avoid pulling their cortex-m deps into host `cargo build`/`cargo test`. # kept OUT of this host workspace to avoid pulling their cortex-m deps into host build/test.
exclude = ["pm-kit", "pm-grid"] exclude = ["pm-kit", "pm-grid", "pm-daisy"]
# Profiles live at the workspace root (member profiles are ignored in a workspace). The firmware's # Profiles live at the workspace root (member profiles are ignored in a workspace). The firmware's
# size/LTO profile stays in pm-kit/Cargo.toml since pm-kit is excluded. # size/LTO profile stays in pm-kit/Cargo.toml since pm-kit is excluded.

View file

@ -3,8 +3,9 @@
FROM docker.io/library/rust:1-slim FROM docker.io/library/rust:1-slim
# Firmware targets: Cortex-M33 (thumbv8m) for the RP2350 (pm-kit), Cortex-M0+ (thumbv6m) for the # Firmware targets: Cortex-M33 (thumbv8m) for the RP2350 (pm-kit), Cortex-M0+ (thumbv6m) for the
# plain RP2040 (pm-grid / Pico Scroll Pack). Harmless for the host tests. # plain RP2040 (pm-grid / Pico Scroll Pack), Cortex-M7F (thumbv7em-eabihf) for the STM32H750
RUN rustup target add thumbv8m.main-none-eabihf thumbv6m-none-eabi \ # (pm-daisy / Daisy Pod). Harmless for the host tests.
RUN rustup target add thumbv8m.main-none-eabihf thumbv6m-none-eabi thumbv7em-none-eabihf \
&& rustup component add llvm-tools-preview \ && rustup component add llvm-tools-preview \
&& cargo install flip-link # stack-overflow-safe linker (overflow faults instead of corrupting statics) && cargo install flip-link # stack-overflow-safe linker (overflow faults instead of corrupting statics)

View file

@ -0,0 +1,14 @@
[build]
target = "thumbv7em-none-eabihf" # STM32H750 = Cortex-M7F (hardware FPU → pm-synth's f32 runs native)
[target.thumbv7em-none-eabihf]
# `cargo run` flashes over a debug probe (probe-rs) and streams defmt logs/panics via RTT.
# The Daisy's STM32H750 presents to probe-rs as STM32H750VBTx.
runner = "probe-rs run --chip STM32H750VBTx"
rustflags = [
"-C", "link-arg=-Tlink.x",
"-C", "link-arg=-Tdefmt.x", # defmt's linker section for log-string interning (not flash-resident)
]
[env]
DEFMT_LOG = "debug"

4
rust/pm-daisy/.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
/target
*.bin
*.elf
*.uf2

34
rust/pm-daisy/Cargo.toml Normal file
View file

@ -0,0 +1,34 @@
[package]
name = "pm-daisy"
version = "0.1.0"
edition = "2021"
description = "PM Daisy-Pod spike: plays the PolyMeter click engine (pm-synth Player) out the Daisy Pod's audio jack, flashing the Seed LED on each click. STM32H750 / Cortex-M7F. See docs/daisy-spike.md."
[dependencies]
cortex-m = "0.7"
cortex-m-rt = "0.7"
stm32h7xx-hal = { version = "0.16", features = ["stm32h750v", "rt", "revision_v"] }
# Daisy Seed board-support. The board REVISION is a feature (below) — it selects the right audio
# codec driver (AK4556 / WM8731 / PCM3060). Picking the wrong one = silence. See README.
daisy = { version = "0.10", default-features = false, features = ["defmt"] }
embedded-alloc = "0.6" # pm-synth's Player parses into Vec/String + allocates voices → needs a heap
defmt = "0.3"
defmt-rtt = "0.4" # logs over RTT, read by `probe-rs run` (a debug probe)
panic-probe = { version = "0.3", features = ["print-defmt"] }
pm-synth = { path = "../pm-synth" } # the click engine + the shared Player (host-verified)
track-format = { path = "../track-format" } # parse the program string into a Track
# ----- Board revision: pick ONE to match the Daisy Seed on your Pod (check the sticker / silkscreen).
# Default targets the Daisy Seed 1.1 (WM8731), the common recent revision. Override on the CLI, e.g.
# cargo build --release --no-default-features --features seed_1_2
[features]
default = ["seed_1_1"]
seed = ["daisy/seed"] # original Daisy Seed (AK4556 codec)
seed_1_1 = ["daisy/seed_1_1"] # Daisy Seed 1.1 (WM8731 codec)
seed_1_2 = ["daisy/seed_1_2"] # Daisy Seed 1.2 / Seed2 DFM (PCM3060 codec)
[profile.release]
opt-level = "s" # size — the STM32H750 has only 128 KB internal flash (see memory.x / README)
lto = true
codegen-units = 1
debug = 2

64
rust/pm-daisy/README.md Normal file
View file

@ -0,0 +1,64 @@
# pm-daisy — PolyMeter click engine on the Daisy Pod
A time-boxed spike (see [`docs/daisy-spike.md`](../../docs/daisy-spike.md)): play the PolyMeter
groove engine on real Cortex-M7 hardware to judge whether the [Electrosmith Daisy
Pod](https://electro-smith.com/products/pod) (STM32H750, onboard audio codec) is a credible home
for the audio engine if PolyMeter ever grows into a real-time audio workstation.
It boots playing a hardcoded 124-BPM 909 pattern (`pm_synth::SPIKE_PROGRAM`) out the Pod's audio
jack, flashing the Daisy Seed's USER LED on each click.
## What runs here
- **Shared engine, verified on host.** The audio is produced by `pm_synth::Player` — the *exact*
same code the host `synthrender` renders to `pm-daisy-preview.wav`. Listen to that WAV to hear
what the hardware should play before you flash anything.
- **Transport only is new.** This crate is a thin board-support binary: heap + board bring-up +
the SAI audio-DMA interrupt feeding `Player::next_sample()` into stereo frames. Structure follows
the `daisy` crate's `examples/audio.rs`.
## ⚠️ Set the board revision (or you get silence)
The Daisy Seed comes in revisions with different audio codecs. Pick the one on **your** Seed (check
the sticker / silkscreen) — it's a Cargo feature:
| Your Seed | Codec | Build with |
|---|---|---|
| Daisy Seed (original) | AK4556 | `./build.sh seed` |
| **Daisy Seed 1.1** (default) | WM8731 | `./build.sh` (or `seed_1_1`) |
| Daisy Seed 1.2 / Seed2 DFM | PCM3060 | `./build.sh seed_1_2` |
## Build
```sh
./build.sh [revision] # containerized (pm-rust:2); produces pm-daisy.bin + pm-daisy.elf
```
## Flash — two options
**A. Debug probe (recommended — gives you defmt logs over RTT):**
A probe wired to the Seed's SWD pins (e.g. the Raspberry Pi Debug Probe you already use for
pm-grid/pm-kit — see [`rust/probe-flash.md`](../probe-flash.md)).
```sh
probe-rs run --chip STM32H750VBTx pm-daisy.elf # or `cargo run --release` from this dir
```
**B. USB DFU (no probe needed):**
Hold the Daisy's **BOOT** button, tap **RESET**, release BOOT — the Seed enumerates as STM32 system
DFU. Then:
```sh
dfu-util -a 0 -s 0x08000000:leave -D pm-daisy.bin -d ,0483:df11
```
## Too big for 128 KB?
The STM32H750 has only **128 KB internal flash**. If the linker reports a `FLASH` overflow, flash via
the **Daisy Bootloader** to the 8 MB QSPI instead:
1. Install the bootloader once at <https://flash.daisy.audio/> (Bootloader tab, v6.x).
2. In [`memory.x`](memory.x), set `FLASH : ORIGIN = 0x90040000, LENGTH = 8M - 0x40000` (commented there).
3. Rebuild, then enter the bootloader (tap BOOT within 2 s of reset; LED pulses) and:
```sh
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.

16
rust/pm-daisy/build.rs Normal file
View file

@ -0,0 +1,16 @@
//! Put `memory.x` on the linker search path (cortex-m-rt's link.x INCLUDEs it).
use std::env;
use std::fs::File;
use std::io::Write;
use std::path::PathBuf;
fn main() {
let out = PathBuf::from(env::var("OUT_DIR").unwrap());
File::create(out.join("memory.x"))
.unwrap()
.write_all(include_bytes!("memory.x"))
.unwrap();
println!("cargo:rustc-link-search={}", out.display());
println!("cargo:rerun-if-changed=memory.x");
println!("cargo:rerun-if-changed=build.rs");
}

33
rust/pm-daisy/build.sh Executable file
View file

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Build pm-daisy for the Daisy Seed (STM32H750, Cortex-M7F) and produce pm-daisy.bin + .elf.
#
# ./build.sh # build for the default board revision (Seed 1.1 / WM8731)
# ./build.sh seed # original Seed (AK4556)
# ./build.sh seed_1_2 # Seed 1.2 / Seed2 DFM (PCM3060)
#
# Flash (pick one — see README.md):
# probe-rs run --chip STM32H750VBTx pm-daisy.elf # debug probe + defmt logs over RTT
# dfu-util -a 0 -s 0x08000000:leave -D pm-daisy.bin -d ,0483:df11 # USB DFU (hold BOOT, tap RESET)
#
# Override the runtime with RUNTIME=docker ./build.sh
set -euo pipefail
DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # rust/pm-daisy
REPO="$(cd "$DIR/../.." && pwd)" # repo root
RUNTIME="${RUNTIME:-podman}"
IMG="pm-rust:2"
REV="${1:-seed_1_1}" # board revision feature
"$RUNTIME" run --rm -v "$REPO":/work:Z -w /work/rust/pm-daisy "$IMG" bash -c "
set -e
rustup target add thumbv7em-none-eabihf >/dev/null 2>&1 || true
rustup component add llvm-tools-preview >/dev/null 2>&1 || true
cargo build --release --no-default-features --features '$REV'
OBJCOPY=\"\$(rustc --print sysroot)/lib/rustlib/\$(rustc -vV | sed -n 's/host: //p')/bin/llvm-objcopy\"
\"\$OBJCOPY\" -O binary target/thumbv7em-none-eabihf/release/pm-daisy pm-daisy.bin
cp target/thumbv7em-none-eabihf/release/pm-daisy pm-daisy.elf
echo '--- size ---'
\"\$(dirname \"\$OBJCOPY\")/llvm-size\" target/thumbv7em-none-eabihf/release/pm-daisy
"
echo "$DIR/pm-daisy.bin + pm-daisy.elf (revision: $REV)"
echo " flash: see the header of this script / README.md"

60
rust/pm-daisy/memory.x Normal file
View file

@ -0,0 +1,60 @@
/* STM32H750IB memory map (Daisy Seed), from the `daisy` crate / libDaisy STM32H750IB_flash.lds.
*
* NOTE: the STM32H750 has only 128 KB of internal flash. If the firmware overflows it (the linker
* will say so), flash via the Daisy Bootloader to the 8 MB QSPI instead — see README.md ("Too big
* for 128 KB?"). For that path, change FLASH to:
* FLASH (RX) : ORIGIN = 0x90040000, LENGTH = 8M - 0x40000
* and flash the .bin with dfu-util to 0x90040000 (the bootloader copies it to SDRAM and runs it).
*/
MEMORY
{
FLASH (RX) : ORIGIN = 0x08000000, LENGTH = 128K
DTCMRAM (RWX) : ORIGIN = 0x20000000, LENGTH = 128K
SRAM (RWX) : ORIGIN = 0x24000000, LENGTH = 512K
RAM_D2 (RWX) : ORIGIN = 0x30000000, LENGTH = 288K
RAM_D3 (RWX) : ORIGIN = 0x38000000, LENGTH = 64K
ITCMRAM (RWX) : ORIGIN = 0x00000000, LENGTH = 64K
SDRAM (RWX) : ORIGIN = 0xc0000000, LENGTH = 64M
QSPIFLASH (RX) : ORIGIN = 0x90000000, LENGTH = 8M
}
REGION_ALIAS(RAM, DTCMRAM);
SECTIONS
{
.sram1_bss (NOLOAD) :
{
. = ALIGN(4);
_ssram1_bss = .;
PROVIDE(__sram1_bss_start__ = _sram1_bss);
*(.sram1_bss)
*(.sram1_bss*)
. = ALIGN(4);
_esram1_bss = .;
PROVIDE(__sram1_bss_end__ = _esram1_bss);
} > RAM_D2
.sdram_bss (NOLOAD) :
{
. = ALIGN(4);
_ssdram_bss = .;
PROVIDE(__sdram_bss_start = _ssdram_bss);
*(.sdram_bss)
*(.sdram_bss*)
. = ALIGN(4);
_esdram_bss = .;
PROVIDE(__sdram_bss_end = _esdram_bss);
} > SDRAM
.sram (NOLOAD) :
{
. = ALIGN(4);
_ssram = .;
PROVIDE(__sram_start__ = _sram);
*(.sram)
*(.sram*)
. = ALIGN(4);
_esram = .;
PROVIDE(__sram_end__ = _esram);
} > SRAM
}

121
rust/pm-daisy/src/main.rs Normal file
View file

@ -0,0 +1,121 @@
//! 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);
}
});
}

View file

@ -4,7 +4,9 @@ version = "0.1.0"
edition = "2021" edition = "2021"
description = "Polyphonic drum-voice synthesizer — a Rust port of engine.js's 808/909 voices. Transport-agnostic (offline render now; on-device real-time later)." description = "Polyphonic drum-voice synthesizer — a Rust port of engine.js's 808/909 voices. Transport-agnostic (offline render now; on-device real-time later)."
# f32 reference implementation for now (host-rendered to .wav to verify the sound). The on-device # `#![no_std]` f32 reference: the same code host-renders to .wav (synthrender) AND runs on a
# port (no_std + fixed-point/table osc, since the Cortex-M0+ has no FPU) comes with the audio # Cortex-M with an FPU (the Daisy Seed's STM32H750, via pm-daisy). Float math goes through `libm`
# transport. Kept buildable for the host (the synthrender bin). # so host and device compute identically. The RP2350/M0+ fixed-point port is still future work.
[dependencies] [dependencies]
libm = "0.2" # no_std transcendental math (sinf/cosf/powf/tanhf/floorf)
track-format = { path = "../track-format" } # Player schedules a parsed Track into the click timeline

View file

@ -3,8 +3,15 @@
//! works both for offline rendering (host → .wav, to verify the sound) and real-time buffer fills //! works both for offline rendering (host → .wav, to verify the sound) and real-time buffer fills
//! on the device later. //! on the device later.
//! //!
//! This is the f32 reference. The on-device port (no_std + fixed-point / table oscillators, since //! This is the f32 reference. It is `#![no_std]` (mirroring `track-format`) and routes its
//! the Cortex-M0+ has no FPU) comes with the audio transport — the voice recipes are the contract. //! transcendental math through `libm`, so the *exact same code* runs on the host (rendered to
//! .wav by `synthrender`) and on a Cortex-M target with an FPU (the Daisy Seed's STM32H750). The
//! RP2350/Cortex-M0+ port still wants fixed-point / table oscillators later (no FPU there) — the
//! voice recipes are the contract either way.
#![no_std]
extern crate alloc;
pub const SR: f32 = 48_000.0; pub const SR: f32 = 48_000.0;
@ -16,9 +23,9 @@ enum Wave {
} }
fn osc(w: Wave, phase: f32) -> f32 { fn osc(w: Wave, phase: f32) -> f32 {
let p = phase - phase.floor(); let p = phase - libm::floorf(phase);
match w { match w {
Wave::Sine => (core::f32::consts::TAU * p).sin(), Wave::Sine => libm::sinf(core::f32::consts::TAU * p),
Wave::Sq => { Wave::Sq => {
if p < 0.5 { if p < 0.5 {
1.0 1.0
@ -46,14 +53,14 @@ struct Biquad {
impl Biquad { impl Biquad {
fn hp(freq: f32, q: f32) -> Self { fn hp(freq: f32, q: f32) -> Self {
let w0 = core::f32::consts::TAU * freq / SR; let w0 = core::f32::consts::TAU * freq / SR;
let (sw, cw) = (w0.sin(), w0.cos()); let (sw, cw) = (libm::sinf(w0), libm::cosf(w0));
let alpha = sw / (2.0 * q); let alpha = sw / (2.0 * q);
let a0 = 1.0 + alpha; let a0 = 1.0 + alpha;
Biquad::norm((1.0 + cw) / 2.0, -(1.0 + cw), (1.0 + cw) / 2.0, a0, -2.0 * cw, 1.0 - alpha) Biquad::norm((1.0 + cw) / 2.0, -(1.0 + cw), (1.0 + cw) / 2.0, a0, -2.0 * cw, 1.0 - alpha)
} }
fn bp(freq: f32, q: f32) -> Self { fn bp(freq: f32, q: f32) -> Self {
let w0 = core::f32::consts::TAU * freq / SR; let w0 = core::f32::consts::TAU * freq / SR;
let (sw, cw) = (w0.sin(), w0.cos()); let (sw, cw) = (libm::sinf(w0), libm::cosf(w0));
let alpha = sw / (2.0 * q); let alpha = sw / (2.0 * q);
let a0 = 1.0 + alpha; let a0 = 1.0 + alpha;
Biquad::norm(alpha, 0.0, -alpha, a0, -2.0 * cw, 1.0 - alpha) Biquad::norm(alpha, 0.0, -alpha, a0, -2.0 * cw, 1.0 - alpha)
@ -82,9 +89,9 @@ impl Env {
fn gain(&self, e: f32) -> f32 { fn gain(&self, e: f32) -> f32 {
let peak = self.peak.max(0.0003); let peak = self.peak.max(0.0003);
if e < self.attack { if e < self.attack {
0.0001 * (peak / 0.0001).powf(e / self.attack) 0.0001 * libm::powf(peak / 0.0001, e / self.attack)
} else if e < self.dur { } else if e < self.dur {
peak * (0.0001 / peak).powf((e - self.attack) / (self.dur - self.attack).max(1e-4)) peak * libm::powf(0.0001 / peak, (e - self.attack) / (self.dur - self.attack).max(1e-4))
} else { } else {
0.0 0.0
} }
@ -116,7 +123,7 @@ impl Gen {
} else if e >= *ramp { } else if e >= *ramp {
f1.max(1.0) f1.max(1.0)
} else { } else {
*f0 * (f1.max(1.0) / *f0).powf(e / *ramp) *f0 * libm::powf(f1.max(1.0) / *f0, e / *ramp)
}; };
*phase += f / SR; *phase += f / SR;
osc(*w, *phase) osc(*w, *phase)
@ -218,7 +225,7 @@ impl Synth {
s += v.next(); s += v.next();
} }
self.voices.retain(|v| !v.done()); self.voices.retain(|v| !v.done());
(s * self.master).tanh() libm::tanhf(s * self.master)
} }
pub fn active(&self) -> usize { pub fn active(&self) -> usize {
self.voices.len() self.voices.len()
@ -342,4 +349,96 @@ fn build(name: &str, l: f32, seed: u32) -> Option<Voice> {
}) })
} }
extern crate alloc; // ----- self-running sequencer: the shared front-end driven by both host + device -----
use alloc::string::String;
use alloc::vec::Vec;
use track_format::Track;
/// The editor's default kit: friendly GM names point at the punchier 808/909 renders (engine.js).
/// Shared so the host preview and the device pick identical voices.
pub fn default_kit(s: &str) -> &str {
match s {
"kick" => "kick909",
"snare" => "snare909",
"clap" => "clap909",
"hatClosed" => "hat909",
"hatOpen" => "openHat808",
"ride" => "ride909",
"crash" => "crash909",
"cowbell" => "cowbell808",
other => other,
}
}
/// A self-running sequencer. It owns the `Synth` and the scheduled click timeline and renders the
/// groove **sample-by-sample**, looping at the pattern boundary. Decays ring across the loop seam
/// (voices are never cleared), so a hat tail bleeds correctly into the downbeat.
///
/// This is the single piece both front-ends drive identically — the on-device SAI audio callback
/// (`pm-daisy`) and the host WAV preview (`synthrender`) — which is what makes the host render a
/// faithful preview of what the hardware will play. The hot path is integer-only (clicks are
/// pre-resolved to sample indices in [`Player::new`]); no allocation or float-time math per sample.
pub struct Player {
synth: Synth,
/// (sample index, lane, level), sorted by sample index.
events: Vec<(u64, usize, u8)>,
/// Resolved (via `default_kit`) voice name per lane.
lane_voice: Vec<String>,
loop_samples: u64,
n: u64,
ci: usize,
fired: u32,
}
impl Player {
/// Build from a parsed `Track`, scheduling `bars` master bars (the loop length).
pub fn new(track: &Track, bars: i64) -> Self {
let mbar = track_format::schedule::master_bar_ns(track);
let total_ns = mbar * bars.max(1);
let to_sample = |ns: i64| -> u64 { (ns as f64 / 1.0e9 * SR as f64 + 0.5) as u64 };
let mut events: Vec<(u64, usize, u8)> = track_format::schedule::render(track, bars)
.iter()
.map(|c| (to_sample(c.time_ns), c.lane, c.level))
.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 }
}
/// 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 {
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);
self.ci += 1;
}
let s = self.synth.next_sample();
self.n += 1;
if self.n >= self.loop_samples {
self.n = 0;
self.ci = 0;
}
s
}
/// Voices currently ringing (for a "load" readout on-device).
pub fn active(&self) -> usize {
self.synth.active()
}
/// Total clicks triggered since boot (monotonic, wraps). The firmware watches this to flash a
/// beat LED — a change since the last block means at least one click fired.
pub fn fired(&self) -> u32 {
self.fired
}
}
/// The groove the Daisy spike plays on boot. Defined here so the firmware and the host preview
/// (`synthrender` → `pm-daisy-preview.wav`) share one source of truth — change it in one place and
/// both follow. A 124-BPM four-on-the-floor 909 pattern with backbeat clap and 8th-note hats.
pub const SPIKE_PROGRAM: &str = "t124;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X";
/// Loop length in master bars for [`SPIKE_PROGRAM`].
pub const SPIKE_BARS: i64 = 4;

View file

@ -1,22 +1,17 @@
//! Render PolyMeter grooves through pm-synth to 16-bit mono 48 kHz WAVs so we can audition the //! Render PolyMeter grooves through pm-synth to 16-bit mono 48 kHz WAVs so we can audition the
//! ported 808/909 voices on a host (no hardware). Output files are written to the current dir. //! ported 808/909 voices on a host (no hardware). Output files are written to the current dir.
use pm_synth::{Synth, SR}; use pm_synth::{default_kit, Player, Synth, SPIKE_BARS, SPIKE_PROGRAM, SR};
use std::fs::File; use std::fs::File;
use std::io::{self, Write}; use std::io::{self, Write};
/// The editor's default kit: friendly GM names point at the punchier 808/909 renders (engine.js). /// Render through the shared [`Player`] — the *exact* code path the Daisy firmware runs in its SAI
fn default_kit(s: &str) -> &str { /// callback (`pm-daisy`). This is the host-side preview/"simulator": the samples here are what the
match s { /// hardware will produce (before its codec). `secs` of audio, looping at the pattern boundary.
"kick" => "kick909", fn render_device(prog: &str, bars: i64, secs: f32) -> Vec<i16> {
"snare" => "snare909", let track = track_format::parse(prog);
"clap" => "clap909", let mut player = Player::new(&track, bars);
"hatClosed" => "hat909", let total = (SR * secs) as usize;
"hatOpen" => "openHat808", (0..total).map(|_| (player.next_sample() * 30000.0) as i16).collect()
"ride" => "ride909",
"crash" => "crash909",
"cowbell" => "cowbell808",
other => other,
}
} }
fn render(prog: &str, bars: i64) -> Vec<i16> { fn render(prog: &str, bars: i64) -> Vec<i16> {
@ -77,4 +72,16 @@ fn main() {
Err(e) => eprintln!("error writing {}: {}", name, e), Err(e) => eprintln!("error writing {}: {}", name, e),
} }
} }
// Device preview: the SAME Player the Daisy firmware runs, rendered to a WAV. Eight seconds of
// SPIKE_PROGRAM, looping at the pattern boundary — a faithful preview of the hardware output.
let preview = render_device(SPIKE_PROGRAM, SPIKE_BARS, 8.0);
match write_wav("pm-daisy-preview.wav", &preview) {
Ok(_) => println!(
"wrote pm-daisy-preview.wav ({:.1}s) — device-identical render of `{}`",
preview.len() as f32 / SR,
SPIKE_PROGRAM
),
Err(e) => eprintln!("error writing pm-daisy-preview.wav: {}", e),
}
} }