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

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

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

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

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

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

87 lines
3.7 KiB
Rust

//! 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::{default_kit, Player, Synth, SPIKE_BARS, SPIKE_PROGRAM, SR};
use std::fs::File;
use std::io::{self, Write};
/// 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> {
let track = track_format::parse(prog);
let mbar = track_format::schedule::master_bar_ns(&track);
let clicks = track_format::schedule::render(&track, bars);
let total_ns = mbar * bars + 1_000_000_000; // + 1 s tail for decays
let total_samples = (total_ns as f64 / 1e9 * SR as f64) as usize;
let mut synth = Synth::new();
let mut out = Vec::with_capacity(total_samples);
let mut ci = 0usize;
for n in 0..total_samples {
let t_ns = (n as f64 / SR as f64 * 1e9) as i64;
while ci < clicks.len() && clicks[ci].time_ns <= t_ns {
let voice = default_kit(&track.lanes[clicks[ci].lane].sound);
synth.trigger(voice, clicks[ci].level);
ci += 1;
}
out.push((synth.next_sample() * 30000.0) as i16);
}
out
}
fn write_wav(path: &str, samples: &[i16]) -> io::Result<()> {
let data_len = (samples.len() * 2) as u32;
let sr = 48_000u32;
let mut f = File::create(path)?;
f.write_all(b"RIFF")?;
f.write_all(&(36 + data_len).to_le_bytes())?;
f.write_all(b"WAVE")?;
f.write_all(b"fmt ")?;
f.write_all(&16u32.to_le_bytes())?; // PCM fmt chunk
f.write_all(&1u16.to_le_bytes())?; // format = PCM
f.write_all(&1u16.to_le_bytes())?; // channels = mono
f.write_all(&sr.to_le_bytes())?;
f.write_all(&(sr * 2).to_le_bytes())?; // byte rate
f.write_all(&2u16.to_le_bytes())?; // block align
f.write_all(&16u16.to_le_bytes())?; // bits/sample
f.write_all(b"data")?;
f.write_all(&data_len.to_le_bytes())?;
for s in samples {
f.write_all(&s.to_le_bytes())?;
}
Ok(())
}
fn main() {
let demos: &[(&str, &str, i64)] = &[
("pm-synth-909.wav", "t124;kick909:4;clap909:4=.X.X;hat909:4/2=.X.X.X.X", 4),
("pm-synth-808.wav", "t90;kick808:4=X..x;snare808:4=.X.X;hat808:4/2", 4),
("pm-synth-default.wav", "t120;kick:4;snare:4=.x.x;hatClosed:4/2", 4),
("pm-synth-poly.wav", "t100;kick909:4;clap909:5~;hat808:4/2", 4),
];
for (name, prog, bars) in demos {
let s = render(prog, *bars);
match write_wav(name, &s) {
Ok(_) => println!("wrote {} ({:.1}s, {} samples)", name, s.len() as f32 / SR, s.len()),
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),
}
}