pm-synth: port engine.js 808/909/GM voices to Rust + host wav renderer
pm-synth: a polyphonic drum-voice synth, a faithful f32 port of engine.js DRUMS (tone/ampEnv/v_noise/metalHat/clap recipes; RBJ biquads; exp envelopes). A Synth mixes active Voices sample-by-sample (transport-agnostic: offline render now, real-time device buffer fills later). All 808/909 + GM voices ported. synthrender (host bin): parse a groove -> track-format schedule -> trigger voices at click times -> 16-bit/48k mono WAV. Applies the editor default kit (kick-> kick909 etc.). Renders four demo grooves to audition off-bench. This is the reusable half of the audio feature; the device port (no_std + fixed-point/table osc, since the M0+ has no FPU) comes with the transport. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
15392174aa
commit
964dee01d6
6 changed files with 448 additions and 0 deletions
|
|
@ -5,6 +5,8 @@ members = [
|
|||
"pm-ui",
|
||||
"uisim",
|
||||
"glyphgen",
|
||||
"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/,
|
||||
|
|
|
|||
10
rust/pm-synth/Cargo.toml
Normal file
10
rust/pm-synth/Cargo.toml
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
[package]
|
||||
name = "pm-synth"
|
||||
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).
|
||||
[dependencies]
|
||||
345
rust/pm-synth/src/lib.rs
Normal file
345
rust/pm-synth/src/lib.rs
Normal file
|
|
@ -0,0 +1,345 @@
|
|||
//! Polyphonic drum-voice synthesizer — a faithful Rust port of `src/engine.js`'s synthesized
|
||||
//! 808/909/GM voices. Transport-agnostic: a `Synth` mixes active `Voice`s sample-by-sample, which
|
||||
//! 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.
|
||||
|
||||
pub const SR: f32 = 48_000.0;
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum Wave {
|
||||
Sine,
|
||||
Tri,
|
||||
Sq,
|
||||
}
|
||||
|
||||
fn osc(w: Wave, phase: f32) -> f32 {
|
||||
let p = phase - phase.floor();
|
||||
match w {
|
||||
Wave::Sine => (core::f32::consts::TAU * p).sin(),
|
||||
Wave::Sq => {
|
||||
if p < 0.5 {
|
||||
1.0
|
||||
} else {
|
||||
-1.0
|
||||
}
|
||||
}
|
||||
Wave::Tri => 4.0 * if p < 0.5 { p } else { 1.0 - p } - 1.0,
|
||||
}
|
||||
}
|
||||
|
||||
// ----- RBJ biquad (highpass / bandpass), the editor's BiquadFilter -----
|
||||
#[derive(Clone, Copy)]
|
||||
struct Biquad {
|
||||
b0: f32,
|
||||
b1: f32,
|
||||
b2: f32,
|
||||
a1: f32,
|
||||
a2: f32,
|
||||
x1: f32,
|
||||
x2: f32,
|
||||
y1: f32,
|
||||
y2: f32,
|
||||
}
|
||||
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 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 alpha = sw / (2.0 * q);
|
||||
let a0 = 1.0 + alpha;
|
||||
Biquad::norm(alpha, 0.0, -alpha, a0, -2.0 * cw, 1.0 - alpha)
|
||||
}
|
||||
fn norm(b0: f32, b1: f32, b2: f32, a0: f32, a1: f32, a2: f32) -> Self {
|
||||
Biquad { b0: b0 / a0, b1: b1 / a0, b2: b2 / a0, a1: a1 / a0, a2: a2 / a0, x1: 0.0, x2: 0.0, y1: 0.0, y2: 0.0 }
|
||||
}
|
||||
fn process(&mut self, x: f32) -> f32 {
|
||||
let y = self.b0 * x + self.b1 * self.x1 + self.b2 * self.x2 - self.a1 * self.y1 - self.a2 * self.y2;
|
||||
self.x2 = self.x1;
|
||||
self.x1 = x;
|
||||
self.y2 = self.y1;
|
||||
self.y1 = y;
|
||||
y
|
||||
}
|
||||
}
|
||||
|
||||
// ----- exponential amp envelope (ampEnv): 0.0001 → peak over `attack`, → 0.0001 over the rest -----
|
||||
#[derive(Clone, Copy)]
|
||||
struct Env {
|
||||
peak: f32,
|
||||
dur: f32,
|
||||
attack: f32,
|
||||
}
|
||||
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)
|
||||
} else if e < self.dur {
|
||||
peak * (0.0001 / peak).powf((e - self.attack) / (self.dur - self.attack).max(1e-4))
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn rng_next(s: &mut u32) -> f32 {
|
||||
let mut x = *s;
|
||||
x ^= x << 13;
|
||||
x ^= x >> 17;
|
||||
x ^= x << 5;
|
||||
*s = x;
|
||||
(x as f32 / u32::MAX as f32) * 2.0 - 1.0
|
||||
}
|
||||
|
||||
enum Gen {
|
||||
Tone { w: Wave, f0: f32, f1: f32, ramp: f32, phase: f32 },
|
||||
Noise { rng: u32 },
|
||||
Metal { ph: [f32; 6], bp: Biquad, hp: Biquad },
|
||||
Squares { f: [f32; 2], ph: [f32; 2] },
|
||||
}
|
||||
const METAL_RATIOS: [f32; 6] = [2.0, 3.0, 4.16, 5.43, 6.79, 8.21];
|
||||
impl Gen {
|
||||
fn sample(&mut self, e: f32) -> f32 {
|
||||
match self {
|
||||
Gen::Tone { w, f0, f1, ramp, phase } => {
|
||||
let f = if *f1 <= 0.0 || *f1 == *f0 {
|
||||
*f0
|
||||
} else if e >= *ramp {
|
||||
f1.max(1.0)
|
||||
} else {
|
||||
*f0 * (f1.max(1.0) / *f0).powf(e / *ramp)
|
||||
};
|
||||
*phase += f / SR;
|
||||
osc(*w, *phase)
|
||||
}
|
||||
Gen::Noise { rng } => rng_next(rng),
|
||||
Gen::Metal { ph, bp, hp } => {
|
||||
let mut sum = 0.0;
|
||||
for i in 0..6 {
|
||||
ph[i] += (40.0 * METAL_RATIOS[i]) / SR;
|
||||
sum += osc(Wave::Sq, ph[i]);
|
||||
}
|
||||
hp.process(bp.process(sum))
|
||||
}
|
||||
Gen::Squares { f, ph } => {
|
||||
let mut sum = 0.0;
|
||||
for i in 0..2 {
|
||||
ph[i] += f[i] / SR;
|
||||
sum += osc(Wave::Sq, ph[i]);
|
||||
}
|
||||
sum
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Part {
|
||||
gen: Gen,
|
||||
bp: Option<Biquad>,
|
||||
env: Env,
|
||||
delay: f32,
|
||||
t: f32,
|
||||
}
|
||||
impl Part {
|
||||
fn next(&mut self) -> f32 {
|
||||
let e = self.t - self.delay;
|
||||
self.t += 1.0 / SR;
|
||||
if e < 0.0 {
|
||||
return 0.0;
|
||||
}
|
||||
let mut s = self.gen.sample(e);
|
||||
if let Some(bp) = &mut self.bp {
|
||||
s = bp.process(s);
|
||||
}
|
||||
s * self.env.gain(e)
|
||||
}
|
||||
fn done(&self) -> bool {
|
||||
self.t - self.delay > self.env.dur + 0.02
|
||||
}
|
||||
}
|
||||
|
||||
/// One triggered drum hit = a small set of parts mixed together.
|
||||
pub struct Voice {
|
||||
parts: alloc::vec::Vec<Part>,
|
||||
}
|
||||
impl Voice {
|
||||
fn next(&mut self) -> f32 {
|
||||
let mut s = 0.0;
|
||||
for p in &mut self.parts {
|
||||
s += p.next();
|
||||
}
|
||||
s
|
||||
}
|
||||
fn done(&self) -> bool {
|
||||
self.parts.iter().all(|p| p.done())
|
||||
}
|
||||
}
|
||||
|
||||
pub struct Synth {
|
||||
voices: alloc::vec::Vec<Voice>,
|
||||
seed: u32,
|
||||
master: f32,
|
||||
}
|
||||
impl Default for Synth {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
impl Synth {
|
||||
pub fn new() -> Self {
|
||||
Synth { voices: alloc::vec::Vec::new(), seed: 0x2545_f491, master: 0.7 }
|
||||
}
|
||||
/// Trigger a voice by name at a click level (2 = accent, 1 = normal, 3 = ghost).
|
||||
pub fn trigger(&mut self, name: &str, level: u8) {
|
||||
let l = match level {
|
||||
2 => 1.0,
|
||||
1 => 0.7,
|
||||
3 => 0.4,
|
||||
_ => 0.7,
|
||||
};
|
||||
self.seed = self.seed.wrapping_mul(1_664_525).wrapping_add(1_013_904_223) | 1;
|
||||
if let Some(v) = build(name, l, self.seed) {
|
||||
self.voices.push(v);
|
||||
}
|
||||
}
|
||||
/// One mixed mono sample, soft-limited to [-1, 1].
|
||||
pub fn next_sample(&mut self) -> f32 {
|
||||
let mut s = 0.0;
|
||||
for v in &mut self.voices {
|
||||
s += v.next();
|
||||
}
|
||||
self.voices.retain(|v| !v.done());
|
||||
(s * self.master).tanh()
|
||||
}
|
||||
pub fn active(&self) -> usize {
|
||||
self.voices.len()
|
||||
}
|
||||
}
|
||||
|
||||
// ----- voice recipes (ports of the DRUMS table in engine.js) -----
|
||||
fn tone(w: Wave, f0: f32, f1: f32, dur: f32, peak: f32, attack: f32) -> Part {
|
||||
Part { gen: Gen::Tone { w, f0, f1, ramp: dur.min(0.09), phase: 0.0 }, bp: None, env: Env { peak, dur, attack }, delay: 0.0, t: 0.0 }
|
||||
}
|
||||
fn toned(w: Wave, f0: f32, f1: f32, dur: f32, peak: f32, attack: f32, filt: Biquad) -> Part {
|
||||
Part { gen: Gen::Tone { w, f0, f1, ramp: dur.min(0.09), phase: 0.0 }, bp: Some(filt), env: Env { peak, dur, attack }, delay: 0.0, t: 0.0 }
|
||||
}
|
||||
fn noise(filt: Biquad, dur: f32, peak: f32, attack: f32, seed: u32) -> Part {
|
||||
Part { gen: Gen::Noise { rng: seed | 1 }, bp: Some(filt), env: Env { peak, dur, attack }, delay: 0.0, t: 0.0 }
|
||||
}
|
||||
fn metal(dur: f32, hp_freq: f32, peak: f32) -> Part {
|
||||
Part {
|
||||
gen: Gen::Metal { ph: [0.0; 6], bp: Biquad::bp(10000.0, 0.8), hp: Biquad::hp(hp_freq, 1.0) },
|
||||
bp: None,
|
||||
env: Env { peak, dur, attack: 0.001 },
|
||||
delay: 0.0,
|
||||
t: 0.0,
|
||||
}
|
||||
}
|
||||
fn delayed(mut p: Part, d: f32) -> Part {
|
||||
p.delay = d;
|
||||
p
|
||||
}
|
||||
fn cowbell(l: f32) -> Voice {
|
||||
Voice {
|
||||
parts: alloc::vec![Part {
|
||||
gen: Gen::Squares { f: [540.0, 800.0], ph: [0.0, 0.0] },
|
||||
bp: Some(Biquad::bp(2640.0, 1.2)),
|
||||
env: Env { peak: 0.8 * l, dur: 0.3, attack: 0.001 },
|
||||
delay: 0.0,
|
||||
t: 0.0,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
fn build(name: &str, l: f32, seed: u32) -> Option<Voice> {
|
||||
use Wave::*;
|
||||
let v = |parts| Voice { parts };
|
||||
Some(match name {
|
||||
"beep" => v(alloc::vec![tone(Sq, if l >= 1.0 { 1600.0 } else { 1100.0 }, 0.0, 0.04, 0.5 * l, 0.002)]),
|
||||
"kick" => v(alloc::vec![tone(Sine, 150.0, 50.0, 0.18, 1.0 * l, 0.002)]),
|
||||
"snare" => v(alloc::vec![
|
||||
tone(Tri, 190.0, 140.0, 0.12, 0.45 * l, 0.002),
|
||||
noise(Biquad::hp(1500.0, 1.0), 0.2, 0.8 * l, 0.001, seed),
|
||||
]),
|
||||
"rim" => v(alloc::vec![toned(Sq, 1700.0, 0.0, 0.04, 0.6 * l, 0.001, Biquad::bp(1700.0, 4.0))]),
|
||||
"clap" => v(alloc::vec![
|
||||
noise(Biquad::bp(1200.0, 1.4), 0.06, 0.5 * l, 0.001, seed),
|
||||
delayed(noise(Biquad::bp(1200.0, 1.4), 0.06, 0.5 * l, 0.001, seed ^ 0x9e3), 0.012),
|
||||
delayed(noise(Biquad::bp(1200.0, 1.4), 0.06, 0.85 * l, 0.001, seed ^ 0x1b7), 0.024),
|
||||
]),
|
||||
"hatClosed" => v(alloc::vec![noise(Biquad::hp(7000.0, 1.0), 0.045, 0.5 * l, 0.001, seed)]),
|
||||
"hatOpen" => v(alloc::vec![noise(Biquad::hp(7000.0, 1.0), 0.32, 0.45 * l, 0.002, seed)]),
|
||||
"ride" => v(alloc::vec![
|
||||
noise(Biquad::bp(6000.0, 0.8), 0.4, 0.32 * l, 0.002, seed),
|
||||
tone(Sq, 5200.0, 0.0, 0.1, 0.13 * l, 0.002),
|
||||
]),
|
||||
"crash" => v(alloc::vec![noise(Biquad::hp(4000.0, 1.0), 0.8, 0.5 * l, 0.002, seed)]),
|
||||
"tomLow" => v(alloc::vec![tone(Sine, 150.0, 100.0, 0.25, 0.9 * l, 0.002)]),
|
||||
"tomMid" => v(alloc::vec![tone(Sine, 220.0, 150.0, 0.23, 0.9 * l, 0.002)]),
|
||||
"tomHigh" => v(alloc::vec![tone(Sine, 300.0, 210.0, 0.20, 0.9 * l, 0.002)]),
|
||||
"tambourine" => v(alloc::vec![noise(Biquad::hp(8000.0, 1.0), 0.12, 0.5 * l, 0.001, seed)]),
|
||||
"cowbell" | "cowbell808" => cowbell(l),
|
||||
"woodblock" | "jamblock" => {
|
||||
if name == "jamblock" {
|
||||
v(alloc::vec![toned(Sq, 2600.0, 2000.0, 0.045, 0.8 * l, 0.001, Biquad::bp(2000.0, 6.0))])
|
||||
} else {
|
||||
v(alloc::vec![tone(Tri, 1800.0, 1500.0, 0.06, 0.8 * l, 0.002)])
|
||||
}
|
||||
}
|
||||
"claves" => v(alloc::vec![tone(Sine, 2500.0, 0.0, 0.045, 0.85 * l, 0.002)]),
|
||||
"kick808" => v(alloc::vec![
|
||||
tone(Sine, 120.0, 45.0, 0.7, 1.0 * l, 0.002),
|
||||
noise(Biquad::hp(2000.0, 1.0), 0.008, 0.4 * (l * 0.5), 0.001, seed),
|
||||
]),
|
||||
"snare808" => v(alloc::vec![
|
||||
tone(Tri, 178.0, 168.0, 0.16, 0.4 * l, 0.002),
|
||||
tone(Tri, 331.0, 320.0, 0.12, 0.18 * l, 0.002),
|
||||
noise(Biquad::hp(1000.0, 1.0), 0.16, 0.7 * l, 0.001, seed),
|
||||
]),
|
||||
"clap808" => v(alloc::vec![
|
||||
noise(Biquad::bp(1100.0, 1.3), 0.05, 0.5 * l, 0.001, seed),
|
||||
delayed(noise(Biquad::bp(1100.0, 1.3), 0.05, 0.5 * l, 0.001, seed ^ 0x9e3), 0.01),
|
||||
delayed(noise(Biquad::bp(1100.0, 1.3), 0.05, 0.5 * l, 0.001, seed ^ 0x1b7), 0.02),
|
||||
delayed(noise(Biquad::bp(1100.0, 1.3), 0.05, 0.85 * l, 0.001, seed ^ 0x511), 0.032),
|
||||
]),
|
||||
"hat808" => v(alloc::vec![metal(0.045, 7000.0, 0.4 * l)]),
|
||||
"openHat808" => v(alloc::vec![metal(0.34, 7000.0, 0.38 * l)]),
|
||||
"tom808" => v(alloc::vec![tone(Sine, 120.0, 78.0, 0.34, 0.9 * l, 0.002)]),
|
||||
"kick909" => v(alloc::vec![
|
||||
tone(Sine, 110.0, 46.0, 0.26, 1.0 * l, 0.002),
|
||||
tone(Tri, 280.0, 60.0, 0.035, 0.5 * l, 0.002),
|
||||
noise(Biquad::hp(3000.0, 1.0), 0.01, 0.5 * (l * 0.6), 0.001, seed),
|
||||
]),
|
||||
"snare909" => v(alloc::vec![
|
||||
tone(Tri, 190.0, 162.0, 0.09, 0.28 * l, 0.002),
|
||||
noise(Biquad::hp(1200.0, 1.0), 0.2, 0.85 * l, 0.001, seed),
|
||||
]),
|
||||
"clap909" => v(alloc::vec![
|
||||
noise(Biquad::bp(1000.0, 1.0), 0.05, 0.6 * l, 0.001, seed),
|
||||
delayed(noise(Biquad::bp(1000.0, 1.0), 0.05, 0.6 * l, 0.001, seed ^ 0x9e3), 0.009),
|
||||
delayed(noise(Biquad::bp(1000.0, 1.0), 0.05, 0.6 * l, 0.001, seed ^ 0x1b7), 0.018),
|
||||
delayed(noise(Biquad::bp(1000.0, 1.0), 0.2, 0.35 * l, 0.001, seed ^ 0x733), 0.018),
|
||||
]),
|
||||
"hat909" => v(alloc::vec![metal(0.05, 9000.0, 0.4 * l)]),
|
||||
"ride909" => v(alloc::vec![
|
||||
metal(0.5, 6000.0, 0.3 * l),
|
||||
noise(Biquad::bp(7000.0, 0.7), 0.18, 0.18 * l, 0.002, seed),
|
||||
]),
|
||||
"crash909" => v(alloc::vec![
|
||||
metal(0.9, 5000.0, 0.34 * l),
|
||||
noise(Biquad::hp(4000.0, 1.0), 0.9, 0.4 * l, 0.002, seed),
|
||||
]),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
extern crate alloc;
|
||||
2
rust/synthrender/.gitignore
vendored
Normal file
2
rust/synthrender/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
/target
|
||||
*.wav
|
||||
9
rust/synthrender/Cargo.toml
Normal file
9
rust/synthrender/Cargo.toml
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
[package]
|
||||
name = "synthrender"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Host tool: render a PolyMeter groove through pm-synth to a .wav, to audition the 808/909 voices off-bench."
|
||||
|
||||
[dependencies]
|
||||
pm-synth = { path = "../pm-synth" }
|
||||
track-format = { path = "../track-format" }
|
||||
80
rust/synthrender/src/main.rs
Normal file
80
rust/synthrender/src/main.rs
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
//! 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 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,
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue