diff --git a/docs/daisy-spike.md b/docs/daisy-spike.md new file mode 100644 index 0000000..1340932 --- /dev/null +++ b/docs/daisy-spike.md @@ -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`. 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` + + diff --git a/rust/Cargo.toml b/rust/Cargo.toml index f8542e7..89b47e7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -8,11 +8,11 @@ members = [ "pm-synth", "synthrender", ] -# pm-kit (RP2350/thumbv8m) and pm-grid (RP2040/thumbv6m) are embedded firmware (no_std + their own -# profile/build). Each is built on its own from its crate dir (e.g. `cargo build` inside pm-grid/, -# which picks up its .cargo/config.toml target), so they are kept OUT of this host workspace to -# avoid pulling their cortex-m deps into host `cargo build`/`cargo test`. -exclude = ["pm-kit", "pm-grid"] +# pm-kit (RP2350/thumbv8m), pm-grid (RP2040/thumbv6m), and pm-daisy (STM32H750/thumbv7em) are +# embedded firmware (no_std + their own profile/build). Each is built on its own from its crate dir +# (e.g. `cargo build` inside pm-grid/, which picks up its .cargo/config.toml target), so they are +# kept OUT of this host workspace to avoid pulling their cortex-m deps into host build/test. +exclude = ["pm-kit", "pm-grid", "pm-daisy"] # 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. diff --git a/rust/Containerfile b/rust/Containerfile index 23e0892..1e02019 100644 --- a/rust/Containerfile +++ b/rust/Containerfile @@ -3,8 +3,9 @@ FROM docker.io/library/rust:1-slim # 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. -RUN rustup target add thumbv8m.main-none-eabihf thumbv6m-none-eabi \ +# plain RP2040 (pm-grid / Pico Scroll Pack), Cortex-M7F (thumbv7em-eabihf) for the STM32H750 +# (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 \ && cargo install flip-link # stack-overflow-safe linker (overflow faults instead of corrupting statics) diff --git a/rust/pm-daisy/.cargo/config.toml b/rust/pm-daisy/.cargo/config.toml new file mode 100644 index 0000000..6e69dfb --- /dev/null +++ b/rust/pm-daisy/.cargo/config.toml @@ -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" diff --git a/rust/pm-daisy/.gitignore b/rust/pm-daisy/.gitignore new file mode 100644 index 0000000..48271b8 --- /dev/null +++ b/rust/pm-daisy/.gitignore @@ -0,0 +1,4 @@ +/target +*.bin +*.elf +*.uf2 diff --git a/rust/pm-daisy/Cargo.toml b/rust/pm-daisy/Cargo.toml new file mode 100644 index 0000000..3e6f444 --- /dev/null +++ b/rust/pm-daisy/Cargo.toml @@ -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 diff --git a/rust/pm-daisy/README.md b/rust/pm-daisy/README.md new file mode 100644 index 0000000..ee58d59 --- /dev/null +++ b/rust/pm-daisy/README.md @@ -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 (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. diff --git a/rust/pm-daisy/build.rs b/rust/pm-daisy/build.rs new file mode 100644 index 0000000..b1d971f --- /dev/null +++ b/rust/pm-daisy/build.rs @@ -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"); +} diff --git a/rust/pm-daisy/build.sh b/rust/pm-daisy/build.sh new file mode 100755 index 0000000..63a7a51 --- /dev/null +++ b/rust/pm-daisy/build.sh @@ -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" diff --git a/rust/pm-daisy/memory.x b/rust/pm-daisy/memory.x new file mode 100644 index 0000000..8ae7160 --- /dev/null +++ b/rust/pm-daisy/memory.x @@ -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 +} diff --git a/rust/pm-daisy/src/main.rs b/rust/pm-daisy/src/main.rs new file mode 100644 index 0000000..11964ad --- /dev/null +++ b/rust/pm-daisy/src/main.rs @@ -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>> = Mutex::new(RefCell::new(None)); +static AUDIO_INTERFACE: Mutex>> = 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; 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); + } + }); +} diff --git a/rust/pm-synth/Cargo.toml b/rust/pm-synth/Cargo.toml index cf804eb..8ac2978 100644 --- a/rust/pm-synth/Cargo.toml +++ b/rust/pm-synth/Cargo.toml @@ -4,7 +4,9 @@ version = "0.1.0" 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)." -# f32 reference implementation for now (host-rendered to .wav to verify the sound). The on-device -# port (no_std + fixed-point/table osc, since the Cortex-M0+ has no FPU) comes with the audio -# transport. Kept buildable for the host (the synthrender bin). +# `#![no_std]` f32 reference: the same code host-renders to .wav (synthrender) AND runs on a +# Cortex-M with an FPU (the Daisy Seed's STM32H750, via pm-daisy). Float math goes through `libm` +# so host and device compute identically. The RP2350/M0+ fixed-point port is still future work. [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 diff --git a/rust/pm-synth/src/lib.rs b/rust/pm-synth/src/lib.rs index 027266c..a1892d6 100644 --- a/rust/pm-synth/src/lib.rs +++ b/rust/pm-synth/src/lib.rs @@ -3,8 +3,15 @@ //! works both for offline rendering (host → .wav, to verify the sound) and real-time buffer fills //! on the device later. //! -//! This is the f32 reference. The on-device port (no_std + fixed-point / table oscillators, since -//! the Cortex-M0+ has no FPU) comes with the audio transport — the voice recipes are the contract. +//! This is the f32 reference. It is `#![no_std]` (mirroring `track-format`) and routes its +//! 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; @@ -16,9 +23,9 @@ enum Wave { } fn osc(w: Wave, phase: f32) -> f32 { - let p = phase - phase.floor(); + let p = phase - libm::floorf(phase); match w { - Wave::Sine => (core::f32::consts::TAU * p).sin(), + Wave::Sine => libm::sinf(core::f32::consts::TAU * p), Wave::Sq => { if p < 0.5 { 1.0 @@ -46,14 +53,14 @@ struct Biquad { impl Biquad { fn hp(freq: f32, q: f32) -> Self { 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 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) } fn bp(freq: f32, q: f32) -> Self { 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 a0 = 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 { let peak = self.peak.max(0.0003); 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 { - 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 { 0.0 } @@ -116,7 +123,7 @@ impl Gen { } else if e >= *ramp { f1.max(1.0) } else { - *f0 * (f1.max(1.0) / *f0).powf(e / *ramp) + *f0 * libm::powf(f1.max(1.0) / *f0, e / *ramp) }; *phase += f / SR; osc(*w, *phase) @@ -218,7 +225,7 @@ impl Synth { s += v.next(); } self.voices.retain(|v| !v.done()); - (s * self.master).tanh() + libm::tanhf(s * self.master) } pub fn active(&self) -> usize { self.voices.len() @@ -342,4 +349,96 @@ fn build(name: &str, l: f32, seed: u32) -> Option { }) } -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, + 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; diff --git a/rust/synthrender/src/main.rs b/rust/synthrender/src/main.rs index 309a6a8..ea08ad5 100644 --- a/rust/synthrender/src/main.rs +++ b/rust/synthrender/src/main.rs @@ -1,22 +1,17 @@ //! 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. -use pm_synth::{Synth, SR}; +use pm_synth::{default_kit, Player, Synth, SPIKE_BARS, SPIKE_PROGRAM, SR}; use std::fs::File; use std::io::{self, Write}; -/// The editor's default kit: friendly GM names point at the punchier 808/909 renders (engine.js). -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, - } +/// Render through the shared [`Player`] — the *exact* code path the Daisy firmware runs in its SAI +/// callback (`pm-daisy`). This is the host-side preview/"simulator": the samples here are what the +/// hardware will produce (before its codec). `secs` of audio, looping at the pattern boundary. +fn render_device(prog: &str, bars: i64, secs: f32) -> Vec { + let track = track_format::parse(prog); + let mut player = Player::new(&track, bars); + let total = (SR * secs) as usize; + (0..total).map(|_| (player.next_sample() * 30000.0) as i16).collect() } fn render(prog: &str, bars: i64) -> Vec { @@ -77,4 +72,16 @@ fn main() { 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), + } }