diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 78f0256..f8542e7 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -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/, diff --git a/rust/pm-synth/Cargo.toml b/rust/pm-synth/Cargo.toml new file mode 100644 index 0000000..cf804eb --- /dev/null +++ b/rust/pm-synth/Cargo.toml @@ -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] diff --git a/rust/pm-synth/src/lib.rs b/rust/pm-synth/src/lib.rs new file mode 100644 index 0000000..027266c --- /dev/null +++ b/rust/pm-synth/src/lib.rs @@ -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, + 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, +} +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, + 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 { + 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; diff --git a/rust/synthrender/.gitignore b/rust/synthrender/.gitignore new file mode 100644 index 0000000..3a447bf --- /dev/null +++ b/rust/synthrender/.gitignore @@ -0,0 +1,2 @@ +/target +*.wav diff --git a/rust/synthrender/Cargo.toml b/rust/synthrender/Cargo.toml new file mode 100644 index 0000000..1c4643b --- /dev/null +++ b/rust/synthrender/Cargo.toml @@ -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" } diff --git a/rust/synthrender/src/main.rs b/rust/synthrender/src/main.rs new file mode 100644 index 0000000..309a6a8 --- /dev/null +++ b/rust/synthrender/src/main.rs @@ -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 { + 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), + } + } +}