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",
|
||||
"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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
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"
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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<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
|
||||
//! 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<i16> {
|
||||
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<i16> {
|
||||
|
|
@ -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),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue