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:
parent
0bec13abab
commit
d80c35984e
14 changed files with 616 additions and 35 deletions
126
docs/daisy-spike.md
Normal file
126
docs/daisy-spike.md
Normal 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>
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
14
rust/pm-daisy/.cargo/config.toml
Normal file
14
rust/pm-daisy/.cargo/config.toml
Normal 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
4
rust/pm-daisy/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
/target
|
||||||
|
*.bin
|
||||||
|
*.elf
|
||||||
|
*.uf2
|
||||||
34
rust/pm-daisy/Cargo.toml
Normal file
34
rust/pm-daisy/Cargo.toml
Normal 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
64
rust/pm-daisy/README.md
Normal 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
16
rust/pm-daisy/build.rs
Normal 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
33
rust/pm-daisy/build.sh
Executable 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
60
rust/pm-daisy/memory.x
Normal 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
121
rust/pm-daisy/src/main.rs
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue