//! The audio shader suite: every example shader compiles, renders the same //! bits on every backend and under any host slicing, never allocates on //! the audio path, and the math and error paths behave. use makepad_script_audio_aot::{compile_with, AudioShader, AudioShaderRef, Backend, Instance, Kind, ShaderError, CTX_FRAME}; use std::alloc::{GlobalAlloc, Layout, System}; use std::sync::atomic::{AtomicUsize, Ordering}; /// The host's buffers for a shader, allocated as a host does (caller-owned). trait Buffers { fn new_ctx(&self, rate: f32) -> Box<[u32]>; fn new_state(&self) -> Box<[u32]>; fn new_scratch(&self) -> Box<[u32]>; } impl Buffers for AudioShader { fn new_ctx(&self, rate: f32) -> Box<[u32]> { let mut ctx = vec![0u32; self.ctx_words()].into_boxed_slice(); self.init_ctx(&mut ctx, rate); ctx } fn new_state(&self) -> Box<[u32]> { let mut state = vec![0u32; self.state_words()].into_boxed_slice(); self.reset_state(&mut state); state } fn new_scratch(&self) -> Box<[u32]> { vec![0u32; self.scratch_words()].into_boxed_slice() } } // -- a counting allocator (only counts on the thread that armed it) ---------- struct Counting; static ALLOCS: AtomicUsize = AtomicUsize::new(0); thread_local! { static ARMED: std::cell::Cell = const { std::cell::Cell::new(false) }; } unsafe impl GlobalAlloc for Counting { unsafe fn alloc(&self, layout: Layout) -> *mut u8 { if ARMED.with(|a| a.get()) { ALLOCS.fetch_add(1, Ordering::SeqCst); } System.alloc(layout) } unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) { if ARMED.with(|a| a.get()) { ALLOCS.fetch_add(1, Ordering::SeqCst); } System.dealloc(ptr, layout) } unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new: usize) -> *mut u8 { if ARMED.with(|a| a.get()) { ALLOCS.fetch_add(1, Ordering::SeqCst); } System.realloc(ptr, layout, new) } } #[global_allocator] static GLOBAL: Counting = Counting; fn counting(f: impl FnOnce() -> R) -> (R, usize) { let before = ALLOCS.load(Ordering::SeqCst); ARMED.with(|a| a.set(true)); let r = f(); ARMED.with(|a| a.set(false)); (r, ALLOCS.load(Ordering::SeqCst) - before) } // -- helpers ------------------------------------------------------------------- const RATE: f32 = 48000.0; fn shader_src(name: &str) -> String { std::fs::read_to_string(format!("{}/shaders/{}.splash", env!("CARGO_MANIFEST_DIR"), name)).unwrap() } fn build(src: &str, backend: Backend) -> AudioShaderRef { match compile_with(src, backend) { Ok(s) => s, Err(errs) => { let e = &errs[0]; let (line, col) = e.line_col(src); panic!("compile error at {}:{}: {}\n{}", line, col, e.message, src.lines().nth(line - 1).unwrap_or("")); } } } const INSTRUMENTS: &[&str] = &["saw_svf", "fm4", "pluck", "wavetable_pad", "fm2_svf"]; const EFFECTS: &[&str] = &["allpass_reverb", "waveshaper4x"]; /// A deterministic test input for effects: a decaying chirp plus clicks. /// A sine of `turns` from IEEE +, -, * and floor only (no libm), so the test input -- and /// the golden hashes of effects -- are the same bits on every host. fn poly_sin(turns: f32) -> f32 { // x in [-0.5, 0.5) turns, then sin(2 pi x) by an odd polynomial on [-0.25, 0.25] let x = turns - (turns + 0.5).floor(); let x = if x > 0.25 { 0.5 - x } else if x < -0.25 { -0.5 - x } else { x }; let x2 = x * x; x * (6.283_185 + x2 * (-41.341_7 + x2 * (81.605_25 + x2 * (-76.705_86 + x2 * 42.058_69)))) } /// exp(-x) for x in [0, 3] from IEEE +, * only (a truncated series of exp(-x/8), cubed /// three times). fn poly_exp_neg(x: f32) -> f32 { let y = x * 0.125; let e = 1.0 - y + y * y * 0.5 - y * y * y * (1.0 / 6.0) + y * y * y * y * (1.0 / 24.0); let e2 = e * e; let e4 = e2 * e2; e4 * e4 } fn test_input(n: usize) -> (Vec, Vec) { let mut l = vec![0.0; n]; let mut r = vec![0.0; n]; for i in 0..n { let t = i as f32 / RATE; let env = poly_exp_neg((t % 0.5) * 6.0); l[i] = poly_sin(t * 220.0 * (1.0 + t)) * env * 0.7; r[i] = if i % 9000 == 0 { 0.9 } else { l[i] * 0.5 }; } (l, r) } /// Renders a scripted performance: two notes, a param change, releases. /// `slices` gives the host's call sizes (cycled). fn perform(shader: &AudioShaderRef, interp: bool, slices: &[usize], total: usize) -> (Vec, Vec) { let mut scratch = shader.new_scratch(); perform_with(shader, slices, total, &mut |ctx, state, ins, outs, n| { if interp { let [o0, o1] = outs; shader.run_interp(ctx, state, &mut scratch, ins[0], ins[1], o0, o1, n); } else { let [o0, o1] = outs; shader.run(ctx, state, &mut scratch, ins[0], ins[1], o0, o1, n); } }) } /// One host call: (ctx, state, ins, outs, n). type Run<'a> = dyn FnMut(&mut [u32], &mut [u32], [&[f32]; 2], [&mut [f32]; 2], usize) + 'a; /// [`perform`] through any runner of the shader's calls. fn perform_with(shader: &AudioShaderRef, slices: &[usize], total: usize, run: &mut Run) -> (Vec, Vec) { let mut ctx = shader.new_ctx(RATE); let mut state = shader.new_state(); let mut out_l = vec![0.0f32; total]; let mut out_r = vec![0.0f32; total]; let (in_l, in_r) = test_input(total); let zeros = vec![0.0f32; total]; // Event frames: note on at 0 and at 30000; param at 12345; off at 24000. let events = [0usize, 12345, 24000, 30000, 52000]; let mut frame = 0usize; let mut k = 0; while frame < total { for (e, at) in events.iter().enumerate() { if *at == frame { match e { 0 => shader.note_on(&mut state, 57.0, 0.9, frame as u32, 7), 1 => { if !shader.params().is_empty() { let p = &shader.params()[0]; shader.set_param(&mut ctx, 0, p.min + (p.max - p.min) * 0.3); } } 2 => shader.note_off(&mut state), 3 => shader.note_on(&mut state, 64.5, 0.6, frame as u32, 11), _ => shader.note_off(&mut state), } } } let next_event = events.iter().copied().filter(|e| *e > frame).min().unwrap_or(total); let n = slices[k % slices.len()].min(next_event - frame).min(total - frame); k += 1; ctx[CTX_FRAME as usize] = frame as u32; let (ins_l, ins_r): (&[f32], &[f32]) = if shader.kind == Kind::Effect { (&in_l[frame..frame + n], &in_r[frame..frame + n]) } else { (&zeros[frame..frame + n], &zeros[frame..frame + n]) }; let (ol, or) = (&mut out_l[frame..frame + n], &mut out_r[frame..frame + n]); run(&mut ctx, &mut state, [ins_l, ins_r], [ol, or], n); frame += n; } (out_l, out_r) } fn bits(v: &[f32]) -> Vec { v.iter().map(|x| x.to_bits()).collect() } fn first_diff(a: &[f32], b: &[f32]) -> Option<(usize, f32, f32)> { a.iter().zip(b).enumerate().find(|(_, (x, y))| x.to_bits() != y.to_bits()).map(|(i, (x, y))| (i, *x, *y)) } // -- tests ------------------------------------------------------------------- #[test] fn examples_compile_and_make_sound() { for name in INSTRUMENTS.iter().chain(EFFECTS) { let src = shader_src(name); let s = build(&src, Backend::Native); let (l, r) = perform(&s, false, &[128], 60000); let peak = l.iter().chain(&r).fold(0.0f32, |m, x| m.max(x.abs())); let finite = l.iter().chain(&r).all(|x| x.is_finite()); assert!(finite, "{} produced non-finite samples", name); assert!(peak > 0.01 && peak < 4.0, "{} peak {}", name, peak); eprintln!( "{:16} {:?} peak {:.3} state {} words, {} AIR vals, {} bytes native", name, s.backend(), peak, s.state_words(), s.program().vals.len(), s.native_code_bytes() ); } } #[test] fn native_is_bit_identical_to_the_interpreter() { for name in INSTRUMENTS.iter().chain(EFFECTS) { let s = build(&shader_src(name), Backend::Native); let (nl, nr) = perform(&s, false, &[128], 60000); let (il, ir) = perform(&s, true, &[128], 60000); if let Some(d) = first_diff(&nl, &il).or(first_diff(&nr, &ir)) { panic!("{}: native and interpreter differ at frame {}: {} vs {}", name, d.0, d.1, d.2); } } } #[test] fn host_slicing_never_changes_the_output() { for name in INSTRUMENTS.iter().chain(EFFECTS) { let s = build(&shader_src(name), Backend::Native); let whole = perform(&s, false, &[128], 40000); let ragged = perform(&s, false, &[1, 7, 64, 128, 3, 100, 31, 128, 2], 40000); let big = perform(&s, false, &[4096], 40000); assert_eq!(bits(&whole.0), bits(&ragged.0), "{}: slicing changed the left channel", name); assert_eq!(bits(&whole.1), bits(&ragged.1), "{}: slicing changed the right channel", name); assert_eq!(bits(&whole.0), bits(&big.0), "{}: 4096-frame calls changed the output", name); } } #[test] fn renders_are_deterministic_across_compiles() { for name in INSTRUMENTS { let a = build(&shader_src(name), Backend::Native); let b = build(&shader_src(name), Backend::Native); assert_eq!(bits(&perform(&a, false, &[128], 20000).0), bits(&perform(&b, false, &[128], 20000).0), "{}", name); } } #[test] fn the_audio_path_never_allocates() { for name in INSTRUMENTS.iter().chain(EFFECTS) { for backend in [Backend::Native, Backend::Interp] { let s = build(&shader_src(name), backend); let mut inst = Instance::new(s.clone(), RATE); let mut l = vec![0.0f32; 128]; let mut r = vec![0.0f32; 128]; let (in_l, in_r) = test_input(128); let (_, allocs) = counting(|| { inst.note_on(60.0, 1.0, 1); for block in 0..200 { if block == 100 { inst.note_off(); } inst.set_param("mix", 0.5); if s.kind == Kind::Effect { inst.process(&in_l, &in_r, &mut l, &mut r); } else { inst.render(&mut l, &mut r); } } }); assert_eq!(allocs, 0, "{} ({:?}) allocated on the audio path", name, backend); } } } #[test] fn native_code_runs_on_another_thread() { let s = build(&shader_src("fm2_svf"), Backend::Native); let here = perform(&s, false, &[128], 8000); let s2 = s.clone(); let there = std::thread::spawn(move || perform(&s2, false, &[128], 8000)).join().unwrap(); assert_eq!(bits(&here.0), bits(&there.0)); } /// An effect that outputs f(l) on the left, for checking math intrinsics. fn math_probe(expr: &str) -> AudioShaderRef { build(&format!("fn effect(l, r) {{ let x = l\n vec2({}, 0.0) }}", expr), Backend::Native) } fn probe(s: &AudioShaderRef, xs: &[f32], interp: bool) -> Vec { let mut ctx = s.new_ctx(RATE); let mut state = s.new_state(); let mut scratch = s.new_scratch(); let mut out = vec![0.0f32; xs.len()]; let mut other = vec![0.0f32; xs.len()]; let zeros = vec![0.0f32; xs.len()]; for (k, chunk) in xs.chunks(4096).enumerate() { let at = k * 4096; let n = chunk.len(); if interp { s.run_interp(&mut ctx, &mut state, &mut scratch, chunk, &zeros[..n], &mut out[at..at + n], &mut other[at..at + n], n); } else { s.run(&mut ctx, &mut state, &mut scratch, chunk, &zeros[..n], &mut out[at..at + n], &mut other[at..at + n], n); } } out } #[test] fn math_is_accurate_and_backend_exact() { let mut xs: Vec = (0..20000).map(|i| (i as f32 - 10000.0) * 0.00173).collect(); xs.extend([0.0, -0.0, 1e-30, -1e-30, 88.0, -87.0, 0.5, 1.0, 100.0, -100.0, f32::MAX, f32::MIN_POSITIVE]); let cases: &[(&str, fn(f64) -> f64, f64, &dyn Fn(f32) -> bool)] = &[ ("sin(x)", f64::sin, 3e-7, &|x: f32| x.abs() < 400.0), ("cos(x)", f64::cos, 3e-7, &|x: f32| x.abs() < 400.0), ("tanh(x)", f64::tanh, 4e-7, &|_| true), ("exp(x)", f64::exp, 3e-7, &|x: f32| x.abs() < 80.0), ("log(x)", f64::ln, 3e-7, &|x: f32| x > 0.0 && x < 1e30), ("sqrt(x)", f64::sqrt, 1e-7, &|x: f32| x >= 0.0), ]; for (expr, reference, tol, domain) in cases { let s = math_probe(expr); let native = probe(&s, &xs, false); let interp = probe(&s, &xs, true); assert_eq!(bits(&native), bits(&interp), "{}: backends differ", expr); let mut worst = 0.0f64; for (x, y) in xs.iter().zip(&native) { if !domain(*x) { continue; } let want = reference(*x as f64); let err = (*y as f64 - want).abs() / want.abs().max(1.0); worst = worst.max(err); } eprintln!("{:8} worst relative error {:.2e}", expr, worst); assert!(worst < *tol, "{}: worst error {:e}", expr, worst); } } #[test] fn integer_and_edge_ops_match() { // Division by zero, i32::MIN / -1, shifts past 31, saturating casts, // NaN compares, wrapping indices. let src = r#" var buf = [0.0; 7] var k = int(0) fn effect(l, r) { let i = int(l * 1000.0) let j = int(r * 3.0) - 1 let a = i / j + i % j + (i << (j + 40)) + (i >> 33) let big = int(l * 1e12) let m = (i32_min() / -1) + big buf[k * 5 - 3] = l k = k + 1 let nan = sqrt(-1.0 - abs(l)) let c = if nan < 1.0 { 1.0 } elif nan != nan { 2.0 } else { 3.0 } let rd = round(l * 10.5) + trunc(-l * 3.3) + ceil(l) + floor(-l) vec2(float(a) + float(m & 255) + c + rd + buf[k - 2], min(l, nan) + max(nan, r)) } fn i32_min() { -2147483647 - 1 } "#; let s = build(src, Backend::Native); let n = 5000; let mut ctx = s.new_ctx(RATE); let mut st_a = s.new_state(); let mut st_b = s.new_state(); let mut scratch = s.new_scratch(); let l: Vec = (0..n).map(|i| ((i * 7919) % 2001) as f32 / 1000.0 - 1.0).collect(); let r: Vec = (0..n).map(|i| ((i * 104729) % 1001) as f32 / 1000.0).collect(); let (mut a0, mut a1) = (vec![0.0; n], vec![0.0; n]); let (mut b0, mut b1) = (vec![0.0; n], vec![0.0; n]); s.run(&mut ctx, &mut st_a, &mut scratch, &l, &r, &mut a0, &mut a1, n); s.run_interp(&mut ctx, &mut st_b, &mut scratch, &l, &r, &mut b0, &mut b1, n); assert_eq!(bits(&a0), bits(&b0)); assert_eq!(bits(&a1), bits(&b1)); assert_eq!(st_a, st_b); } #[test] fn register_pressure_spills_exactly() { // 48 values live at once force spills of both register classes. let mut src = String::from("var acc = 0.0\nvar n = int(0)\nfn effect(l, r) {\n"); for i in 0..48 { src.push_str(&format!(" let f{} = l * {}.5 + r\n let i{} = n * {} + {}\n", i, i, i, i + 1, i)); } src.push_str(" let s = 0.0\n"); for i in 0..48 { src.push_str(&format!(" s = s + f{} * float(i{} % 7)\n", 47 - i, i)); } src.push_str(" n = n + 1\n acc = acc * 0.5 + s\n vec2(acc, s)\n}\n"); let s = build(&src, Backend::Native); let (l, r) = test_input(3000); let mut ctx = s.new_ctx(RATE); let (mut sa, mut sb) = (s.new_state(), s.new_state()); let mut scratch = s.new_scratch(); let (mut a0, mut a1) = (vec![0.0; 3000], vec![0.0; 3000]); let (mut b0, mut b1) = (vec![0.0; 3000], vec![0.0; 3000]); s.run(&mut ctx, &mut sa, &mut scratch, &l, &r, &mut a0, &mut a1, 3000); s.run_interp(&mut ctx, &mut sb, &mut scratch, &l, &r, &mut b0, &mut b1, 3000); assert_eq!(bits(&a0), bits(&b0)); assert_eq!(bits(&a1), bits(&b1)); } #[test] fn control_flow_matches() { // Loops with runtime bounds, break/continue, early returns from helpers, // match, while, short-circuit with side effects. let src = r#" var hits = int(0) var st = [0.0; 16] fn bump() { hits = hits + 1 true } fn find(x) { for i in 0..16 { if st[i] > x { return float(i) } } -1.0 } fn effect(l, r) { let n = int(abs(l) * 20.0) let s = 0.0 for i in 0..n { if i % 3 == 1 { continue } if s > 4.0 { break } s = s + float(i) * 0.25 } let w = 0 while w * w < n { w = w + 1 } let q = match w % 4 { 0 => 1.5, 1 | 2 => s, _ => -s } if l > 0.0 && bump() { st[hits] = l } if l < -0.5 || bump() { s = s + 1.0 } loop { s = s * 0.5 if s < 1.0 { break } } vec2(s + q + find(l), float(hits)) } "#; let s = build(src, Backend::Native); let (l, r) = test_input(6000); let mut ctx = s.new_ctx(RATE); let (mut sa, mut sb) = (s.new_state(), s.new_state()); let mut scratch = s.new_scratch(); let (mut a0, mut a1) = (vec![0.0; 6000], vec![0.0; 6000]); let (mut b0, mut b1) = (vec![0.0; 6000], vec![0.0; 6000]); s.run(&mut ctx, &mut sa, &mut scratch, &l, &r, &mut a0, &mut a1, 4096); s.run_interp(&mut ctx, &mut sb, &mut scratch, &l, &r, &mut b0, &mut b1, 4096); assert_eq!(bits(&a0[..4096]), bits(&b0[..4096])); assert_eq!(bits(&a1[..4096]), bits(&b1[..4096])); assert!(a1[4095] > 100.0, "side effects of && / || ran: {}", a1[4095]); } fn compile_err(src: &str) -> ShaderError { match compile_with(src, Backend::Interp) { Ok(_) => panic!("expected an error for:\n{}", src), Err(e) => e[0].clone(), } } #[test] fn errors_point_at_the_code() { let cases: &[(&str, &str, &str)] = &[ ("fn voice() { foo + 1.0 }", "foo", "unknown name"), ("fn voice() { let x = 1.0\n x = \"s\" }", "\"s", "no strings"), ("fn voice() { f(1.0) }\nfn f(x) { f(x) }", "f(x)", "itself"), ("var b = [0.0; n]\nfn voice() { 0.0 }", "n]", "unknown name"), ("fn voice() { break }", "break", "outside a loop"), ("fn nothing() { 1.0 }", "f", "missing entry"), ("fn voice() { vec2(1.0, 2.0) + true }", "vec2", "vec2"), ("let t = [0.0; 4]\nfn voice() { t[0] = 1.0\n 0.0 }", "t[0] = 1.0", "read-only"), ]; for (src, at, msg) in cases { let e = compile_err(src); assert!(e.message.contains(msg), "{:?}: message {:?} lacks {:?}", src, e.message, msg); let start = src.rfind(at).unwrap(); if *msg != "missing entry" { assert_eq!(e.start, start, "{:?}: error at {} ({:?}), expected {}", src, e.start, &src[e.start..e.end.min(src.len())], start); } } } #[test] fn hot_swap_state_carry_by_name() { let a = build("var phase = 0.0\nvar amp = 1.0\nfn voice() { phase = fract(phase + freq / sample_rate)\n sin(TAU * phase) * amp }", Backend::Native); let b = build("var amp = 1.0\nvar phase = 0.0\nvar extra = 5.0\nfn voice() { phase = fract(phase + freq / sample_rate)\n sin(TAU * phase) * amp * 0.5 }", Backend::Native); // Same names: phase and amp map across the reordered layout. let va = &a.state_vars()[0]; let vb = b.state_vars().iter().find(|v| v.name == va.name).unwrap(); assert_eq!(va.sig, vb.sig); assert_ne!(va.offset, vb.offset); } /// Fundamental by autocorrelation with parabolic peak interpolation. fn pitch(x: &[f32], rate: f32, lo_hz: f32, hi_hz: f32) -> f32 { let min_lag = (rate / hi_hz) as usize; let max_lag = (rate / lo_hz) as usize; let ac = |lag: usize| -> f64 { x.iter().zip(&x[lag..]).map(|(a, b)| *a as f64 * *b as f64).sum::() / (x.len() - lag) as f64 }; let vals: Vec = (min_lag..=max_lag + 1).map(ac).collect(); // The first strong peak (avoids octave errors). let top = vals.iter().cloned().fold(f64::MIN, f64::max); let mut best = 1; for k in 1..vals.len() - 1 { if vals[k] > vals[k - 1] && vals[k] >= vals[k + 1] && vals[k] > 0.9 * top { best = k; break; } } let (a, b, c) = (vals[best - 1], vals[best], vals[best + 1]); let shift = 0.5 * (a - c) / (a - 2.0 * b + c); rate / ((min_lag + best) as f64 + shift) as f32 } #[test] fn pluck_stays_in_tune_up_high() { let s = build(&shader_src("pluck"), Backend::Native); for note in [48.0f32, 60.0, 72.0, 84.0, 93.0] { let mut inst = Instance::new(s.clone(), RATE); inst.set_param("t60", 10.0); inst.note_on(note, 1.0, 5); let mut l = vec![0.0f32; 24000]; let mut r = vec![0.0f32; 24000]; inst.render(&mut l, &mut r); let want = 440.0 * 2f32.powf((note - 69.0) / 12.0); let got = pitch(&l[4000..20000], RATE, want * 0.7, want * 1.4); let cents = 1200.0 * (got / want).log2(); eprintln!("pluck note {:>4}: want {:8.2} Hz got {:8.2} Hz ({:+.2} cents)", note, want, got, cents); assert!(cents.abs() < 3.0, "note {} is {:+.1} cents off", note, cents); } } // -- dataflow fusion ----------------------------------------------------------- use makepad_script_audio_aot::{fuse, FuseNode, Port}; #[test] fn fused_instrument_chain_equals_the_nodes_run_one_by_one() { let synth = shader_src("saw_svf"); let drive = shader_src("waveshaper4x"); let fused = fuse( &[FuseNode { name: "lead", code: &synth, inputs: vec![] }, FuseNode { name: "drive", code: &drive, inputs: vec![Port::Node(0)] }], &[Port::Node(1)], Backend::Native, ) .unwrap_or_else(|e| panic!("{:?}", e)); assert!(["lead_cutoff", "drive_drive"].iter().all(|name| fused.params().iter().any(|p| p.name == *name))); let n = 20000; // Fused: one program per voice. let mut f = Instance::new(fused.clone(), RATE); f.note_on(45.0, 1.0, 9); let (mut fl, mut fr) = (vec![0.0; n], vec![0.0; n]); for k in 0..n / 100 { f.render(&mut fl[k * 100..k * 100 + 100], &mut fr[k * 100..k * 100 + 100]); } // Separate: the synth into a buffer, the buffer through the effect. let mut a = Instance::new(build(&synth, Backend::Native), RATE); let mut b = Instance::new(build(&drive, Backend::Native), RATE); a.note_on(45.0, 1.0, 9); let (mut al, mut ar) = (vec![0.0; n], vec![0.0; n]); a.render(&mut al, &mut ar); let (mut bl, mut br) = (vec![0.0; n], vec![0.0; n]); b.process(&al, &ar, &mut bl, &mut br); if let Some(d) = first_diff(&fl, &bl) { panic!("fused and chained differ at {}: {} vs {}", d.0, d.1, d.2); } assert_eq!(bits(&fr), bits(&br)); eprintln!("fused lead+drive: {} AIR vals, {} bytes native (vs {} + {})", fused.program().vals.len(), fused.native_code_bytes(), a.shader.native_code_bytes(), b.shader.native_code_bytes()); } #[test] fn fused_effect_graph_with_fan_in_and_feedback() { // y = half(x + y[n-1]) as a graph with a feedback edge, against the // same thing written by hand. let half = "fn effect(l, r) { vec2(l * 0.5, r * 0.5) }"; let graph = fuse(&[FuseNode { name: "h", code: half, inputs: vec![Port::Input, Port::Feedback(0)] }], &[Port::Node(0)], Backend::Native).unwrap(); let by_hand = build("var y = vec2(0.0)\nfn effect(l, r) { y = vec2((l + y.x) * 0.5, (r + y.y) * 0.5)\n y }", Backend::Native); let (l, r) = test_input(5000); let mut out = [(vec![0.0; 5000], vec![0.0; 5000]), (vec![0.0; 5000], vec![0.0; 5000])]; for (s, o) in [graph.clone(), by_hand].iter().zip(out.iter_mut()) { let mut i = Instance::new(s.clone(), RATE); i.process(&l, &r, &mut o.0, &mut o.1); } assert_eq!(bits(&out[0].0), bits(&out[1].0)); // Fan-out and fan-in: reverb and drive in parallel on the input, summed. let verb = shader_src("allpass_reverb"); let drive = shader_src("waveshaper4x"); let par = fuse( &[ FuseNode { name: "verb", code: &verb, inputs: vec![Port::Input] }, FuseNode { name: "drive", code: &drive, inputs: vec![Port::Input] }, ], &[Port::Node(0), Port::Node(1)], Backend::Native, ) .unwrap(); let mut i = Instance::new(par.clone(), RATE); let (mut pl, mut pr) = (vec![0.0; 5000], vec![0.0; 5000]); i.process(&l, &r, &mut pl, &mut pr); let mut v = Instance::new(build(&verb, Backend::Native), RATE); let mut d = Instance::new(build(&drive, Backend::Native), RATE); let (mut sl, mut sr) = (vec![0.0; 5000], vec![0.0; 5000]); let (mut vl, mut vr) = (vec![0.0; 5000], vec![0.0; 5000]); let (mut dl, mut dr) = (vec![0.0; 5000], vec![0.0; 5000]); v.process(&l, &r, &mut vl, &mut vr); d.process(&l, &r, &mut dl, &mut dr); for k in 0..5000 { sl[k] = vl[k] + dl[k]; sr[k] = vr[k] + dr[k]; } assert_eq!(bits(&pl), bits(&sl)); assert_eq!(bits(&pr), bits(&sr)); } #[test] fn fusion_errors_name_the_node() { let good = "fn effect(l, r) { vec2(l, r) }"; let bad = "fn effect(l, r) { vec2(l, nope) }"; let e = fuse(&[FuseNode { name: "a", code: good, inputs: vec![Port::Input] }, FuseNode { name: "b", code: bad, inputs: vec![Port::Node(0)] }], &[Port::Node(1)], Backend::Interp).unwrap_err(); assert_eq!(e.node, Some(1)); assert_eq!(&bad[e.error.start..e.error.end], "nope"); let e = fuse(&[FuseNode { name: "a", code: good, inputs: vec![Port::Node(0)] }], &[Port::Node(0)], Backend::Interp).unwrap_err(); assert!(e.error.message.contains("not before it")); } // -- the prelude, diagnostics and check() --------------------------------------- #[test] fn the_prelude_works_and_backends_agree() { let src = r#" var f = Svf{} var lad = Ladder{} var op = OnePole{} var dc = DcBlock{} var ap = Allpass1{} var tri = Tri{} var env = Adsr{} var dec = Decay{} var os = Os4{} var buf = [0.0; 256] var w = int(0) var p = 0.0 fn block() { svf_set(f, 1200.0, 2.0) ladder_set(lad, 800.0, 0.7) onepole_set(op, 3000.0) } fn voice() { let dt = freq / sample_rate p = advance(p, dt) let x = saw_blep(p, dt) + pulse_blep(p, dt, 0.3) * 0.5 + tri_blep(tri, p, dt) + varsaw(p, 4.0) let y = svf_lp(f, x) + svf_bp(f, x) + svf_hp(f, x) + svf_notch(f, x) + svf_ap(f, x) let z = ladder(lad, y) + onepole_lp(op, y) + onepole_hp(op, y) + dc_block(dc, y) + allpass1(ap, y, 0.3) buf[w] = z w = w + 1 let d = tap(buf, w, 17.5) + tap_cubic(buf, w, 33.25) let s = soft_clip(d) + hard_clip(d) + fold(d * 2.0) + asym_clip(d, 0.2) for k in 0..4 { os4_push(os, soft_clip(os4_up(os, s, k))) } let e = adsr(env, 0.01, 0.1, 0.5, 0.1) * decay_env(dec, 0.5) width(pan(os4_out(os), 0.3), 1.5) * e * 0.05 + vec2(atan(x) * 0.001, atan2(x, 0.5) * 0.001) } "#; let s = build(src, Backend::Native); let (nl, nr) = perform(&s, false, &[128], 30000); let (il, ir) = perform(&s, true, &[128], 30000); assert_eq!(bits(&nl), bits(&il)); assert_eq!(bits(&nr), bits(&ir)); assert!(nl.iter().all(|x| x.is_finite()) && nl.iter().any(|x| x.abs() > 1e-3)); } #[test] fn a_shader_item_overrides_the_prelude() { let s = build("fn soft_clip(x) { 0.25 }\nfn effect(l, r) { vec2(soft_clip(l), 0.0) }", Backend::Native); let mut i = Instance::new(s, RATE); let (mut a, mut b) = (vec![0.0; 4], vec![0.0; 4]); i.process(&[0.9; 4], &[0.0; 4], &mut a, &mut b); assert_eq!(a, vec![0.25; 4]); } #[test] fn near_misses_get_suggestions_and_prelude_errors_point_at_the_call() { let e = compile_err("var phase = 0.0\nfn voice() { phse + 1.0 }"); assert!(e.message.contains("did you mean `phase`"), "{}", e.message); let e = compile_err("fn voice() { saw_blp(0.5, 0.01) }"); assert!(e.message.contains("did you mean `saw_blep`"), "{}", e.message); let e = compile_err("var f = Svf{}\nfn voice() { f.cutof }"); assert!(e.message.contains("it has"), "{}", e.message); // A wrong argument to a prelude helper is reported where it is called. let src = "fn voice() { svf_lp(1.0, 0.5) }"; let e = compile_err(src); assert!(e.message.starts_with("in `svf_lp`"), "{}", e.message); assert_eq!(&src[e.start..e.end], "svf_lp(1.0, 0.5)"); } #[test] fn check_reports_health() { let good = makepad_script_audio_aot::check(&shader_src("saw_svf")).unwrap(); assert!(good.warnings.is_empty(), "{:?}", good.warnings); assert!(good.tail_secs.is_some() && good.peak > 0.05); let endless = makepad_script_audio_aot::check("var p = 0.0\nfn voice() { p = fract(p + freq / sample_rate)\n sin(TAU * p) * 0.3 }").unwrap(); assert!(endless.warnings.iter().any(|w| w.contains("never end")), "{:?}", endless.warnings); let broken = makepad_script_audio_aot::check("fn effect(l, r) { vec2(log(0.0 * l) * 0.0, l * 3.0) }").unwrap(); assert!(broken.nonfinite > 0 && broken.warnings.len() >= 2, "{:?}", broken.warnings); } // -- golden renders: the compute-core refactor must not change a bit ------------- fn fnv(bits: impl Iterator) -> u64 { let mut h = 0xcbf2_9ce4_8422_2325u64; for b in bits { for byte in b.to_le_bytes() { h ^= byte as u64; h = h.wrapping_mul(0x100_0000_01b3); } } h } /// Every example and shipped library shader, rendered through the scripted /// performance on both backends, hashed. fn golden_sources() -> Vec<(String, String)> { let lib_dir = format!("{}/../../../libs/score_player/shaders", env!("CARGO_MANIFEST_DIR")); let mut sources: Vec<(String, String)> = INSTRUMENTS.iter().chain(EFFECTS).map(|n| (n.to_string(), shader_src(n))).collect(); if let Ok(dir) = std::fs::read_dir(&lib_dir) { let mut names: Vec<_> = dir.filter_map(|e| e.ok()).map(|e| e.path()).collect(); names.sort(); for p in names { sources.push((format!("lib/{}", p.file_stem().unwrap().to_string_lossy()), std::fs::read_to_string(&p).unwrap())); } } sources } fn golden_hashes() -> Vec<(String, u64)> { let mut out = Vec::new(); for (name, src) in golden_sources() { for interp in [false, true] { let s = build(&src, Backend::Native); let (l, r) = perform(&s, interp, &[128], 30000); out.push((format!("{}{}", name, if interp { "/interp" } else { "" }), fnv(bits(&l).into_iter().chain(bits(&r))))); } } out } const GOLDEN: &[(&str, u64)] = &[ ("saw_svf", 0xc8dfc528a8eeed81), ("saw_svf/interp", 0xc8dfc528a8eeed81), ("fm4", 0x27017e5aa48b62a5), ("fm4/interp", 0x27017e5aa48b62a5), ("pluck", 0xdc303800f51fe491), ("pluck/interp", 0xdc303800f51fe491), ("wavetable_pad", 0x978a07348239c3bf), ("wavetable_pad/interp", 0x978a07348239c3bf), ("fm2_svf", 0x0d7fe18dcc2fd471), ("fm2_svf/interp", 0x0d7fe18dcc2fd471), ("allpass_reverb", 0xe9c4307947d62634), ("allpass_reverb/interp", 0xe9c4307947d62634), ("waveshaper4x", 0x7aefcc9015b0e175), ("waveshaper4x/interp", 0x7aefcc9015b0e175), ("lib/acid", 0xc785649af8f0a525), ("lib/acid/interp", 0xc785649af8f0a525), ("lib/analog_poly", 0xa14f9b3d160ebab5), ("lib/analog_poly/interp", 0xa14f9b3d160ebab5), ("lib/drive", 0x248a1620a927ba0c), ("lib/drive/interp", 0x248a1620a927ba0c), ("lib/echo", 0x22a372ef7bc88a7c), ("lib/echo/interp", 0x22a372ef7bc88a7c), ("lib/epiano", 0x580aad531bfdd513), ("lib/epiano/interp", 0x580aad531bfdd513), ("lib/flanger", 0x5c5bb8a10d767dad), ("lib/flanger/interp", 0x5c5bb8a10d767dad), ("lib/gated_verb", 0xd496809a931be2ec), ("lib/gated_verb/interp", 0xd496809a931be2ec), ("lib/modal_bell", 0x628b207e9249e181), ("lib/modal_bell/interp", 0x628b207e9249e181), ("lib/phaser", 0x003e4717b331e31d), ("lib/phaser/interp", 0x003e4717b331e31d), ("lib/pluck", 0x44ccdb790d68505d), ("lib/pluck/interp", 0x44ccdb790d68505d), ("lib/supersaw", 0xc259b05b8867b5f2), ("lib/supersaw/interp", 0xc259b05b8867b5f2), ("lib/techno_kick", 0xae479453bd1470d5), ("lib/techno_kick/interp", 0xae479453bd1470d5), ]; #[test] fn renders_match_the_golden_hashes() { let got = golden_hashes(); if GOLDEN.is_empty() || std::env::var("AUDIO_AOT_PRINT_GOLDEN").is_ok() { for (n, h) in &got { println!(" (\"{}\", 0x{:016x}),", n, h); } return; } for (n, h) in &got { let want = GOLDEN.iter().find(|(g, _)| g == n).map(|(_, h)| *h); assert_eq!(want, Some(*h), "{} changed", n); } } /// A constant `for` in an audio program renders the same bits as the same /// loop written out by hand: the lowering unrolls it, so expressions of its /// counter fold exactly as the hand-written literals do. (An unroll rule /// that kept wavetable_pad's 16-harmonic loop a loop computed `mix(saw, sq, /// t * 2.0)` in f32 step by step instead of folding it, and its golden /// render changed.) #[test] fn constant_loops_render_as_written_out() { let src = shader_src("wavetable_pad"); let head = " for h in 1..17 {\n"; let start = src.find(head).expect("wavetable_pad's harmonic loop"); let body_start = start + head.len(); let end = body_start + src[body_start..].find("\n }\n").expect("loop end"); let body = &src[body_start..end]; let mut written = String::new(); for h in 1..17 { written.push_str(&format!(" {{\n let h = {h}\n{body}\n }}\n")); } let unrolled = format!("{}{}{}", &src[..start], written, &src[end + "\n }\n".len()..]); let total = 48000; for backend in [Backend::Native, Backend::Interp] { let a = perform(&build(&src, backend), backend == Backend::Interp, &[128], total); let b = perform(&build(&unrolled, backend), backend == Backend::Interp, &[128], total); assert!(bits(&a.0) == bits(&b.0) && bits(&a.1) == bits(&b.1), "{backend:?}: the loop and its written-out form differ at {:?}", first_diff(&a.0, &b.0)); } } // -- the generated WebAssembly ---------------------------------------------------- /// Runs a shader's generated wasm entry (the compute core's wasm backend, /// audio ABI) on stitch, a wasm interpreter: one instance on a memory laid /// out as ctx, state, shared tables, the I/O address table, two input and /// two output channels of MAX_FRAMES, and the frame scratch. Each call /// copies the host's ctx, state, inputs and the outputs it mixes into in, /// and ctx, state and outputs back. struct WasmRunner { store: makepad_stitch::Store, mem: makepad_stitch::Mem, func: makepad_stitch::Func, ctx: usize, state: usize, io: usize, chans: [usize; 4], frame: usize, shared: usize, } impl WasmRunner { fn new(shader: &AudioShader) -> WasmRunner { use makepad_stitch as stitch; let module = makepad_script_audio_aot::wasm::module(&[shader], Default::default()).expect("audio shader compiles to wasm"); let engine = stitch::Engine::new(); let m = stitch::Module::new(&engine, &module).unwrap_or_else(|e| panic!("decode: {:?}", e)); let mut store = stitch::Store::new(engine); let max = makepad_script_audio_aot::MAX_FRAMES as usize; // Byte addresses, 16-byte aligned, a guard word gap between regions. let mut at = 64usize; let mut alloc = |words: usize| { let a = at; at += (words.max(1) * 4 + 16 + 15) & !15; a }; let ctx = alloc(shader.ctx_words()); let state = alloc(shader.state_words()); let shared = alloc(shader.shared_table().len()); let io = alloc(4); let chans = [alloc(max), alloc(max), alloc(max), alloc(max)]; let frame = alloc(makepad_script_audio_aot::wasm::frame_words(shader)); let pages = at.div_ceil(65536) as u32; let mem = stitch::Mem::new(&mut store, stitch::MemType { limits: stitch::Limits { min: pages, max: None } }); let mut linker = stitch::Linker::new(); linker.define("env", "memory", mem); let inst = linker.instantiate(&mut store, &m).unwrap_or_else(|e| panic!("instantiate: {:?}", e)); let func = inst.exported_func("run0").expect("run0"); let bytes = mem.bytes_mut(&mut store); for (k, w) in shader.shared_table().iter().enumerate() { bytes[shared + 4 * k..shared + 4 * k + 4].copy_from_slice(&w.to_le_bytes()); } for (k, c) in chans.iter().enumerate() { bytes[io + 4 * k..io + 4 * k + 4].copy_from_slice(&(*c as u32).to_le_bytes()); } WasmRunner { store, mem, func, ctx, state, io, chans, frame, shared } } fn put(bytes: &mut [u8], at: usize, words: impl Iterator) { for (k, w) in words.enumerate() { bytes[at + 4 * k..at + 4 * k + 4].copy_from_slice(&w.to_le_bytes()); } } fn get(bytes: &[u8], at: usize, out: &mut [u32]) { for (k, w) in out.iter_mut().enumerate() { *w = u32::from_le_bytes(bytes[at + 4 * k..at + 4 * k + 4].try_into().unwrap()); } } fn run(&mut self, ctx: &mut [u32], state: &mut [u32], ins: [&[f32]; 2], outs: [&mut [f32]; 2], n: usize) { use makepad_stitch::Val; let bytes = self.mem.bytes_mut(&mut self.store); Self::put(bytes, self.ctx, ctx.iter().copied()); Self::put(bytes, self.state, state.iter().copied()); Self::put(bytes, self.chans[0], ins[0][..n].iter().map(|x| x.to_bits())); Self::put(bytes, self.chans[1], ins[1][..n].iter().map(|x| x.to_bits())); Self::put(bytes, self.chans[2], outs[0][..n].iter().map(|x| x.to_bits())); Self::put(bytes, self.chans[3], outs[1][..n].iter().map(|x| x.to_bits())); let args = [self.ctx, self.state, self.shared, self.io, n, self.frame].map(|x| Val::I32(x as i32)); self.func.call(&mut self.store, &args, &mut []).unwrap_or_else(|e| panic!("trap: {:?}", e)); let bytes = self.mem.bytes(&self.store); Self::get(bytes, self.ctx, ctx); Self::get(bytes, self.state, state); for (c, out) in outs.into_iter().enumerate() { let mut w = vec![0u32; n]; Self::get(bytes, self.chans[2 + c], &mut w); for (o, x) in out.iter_mut().zip(w) { *o = f32::from_bits(x); } } } } fn perform_wasm(shader: &AudioShaderRef, slices: &[usize], total: usize) -> (Vec, Vec) { let mut w = WasmRunner::new(shader); perform_with(shader, slices, total, &mut |ctx, state, ins, outs, n| w.run(ctx, state, ins, outs, n)) } /// The generated wasm of every example and shipped library shader renders /// the scripted performance to the golden hashes (the native backend's and /// the interpreter's). #[test] fn wasm_renders_match_the_golden_hashes() { for (name, src) in golden_sources() { let s = build(&src, Backend::Interp); let (l, r) = perform_wasm(&s, &[128], 30000); let got = fnv(bits(&l).into_iter().chain(bits(&r))); let want = GOLDEN.iter().find(|(g, _)| *g == name).map(|(_, h)| *h); if want != Some(got) { let (il, _) = perform(&s, true, &[128], 30000); panic!("{}: wasm render 0x{:016x}, golden {:?}; first difference from the interpreter {:?}", name, got, want.map(|h| format!("0x{:016x}", h)), first_diff(&l, &il)); } } } /// Host slicing (odd sizes, single frames) through the generated wasm gives /// the interpreter's bits. #[test] fn wasm_host_slicing_matches_the_interpreter() { for name in INSTRUMENTS.iter().chain(EFFECTS) { let s = build(&shader_src(name), Backend::Interp); let want = perform(&s, true, &[128], 6000); let got = perform_wasm(&s, &[1, 7, 128, 33, 64, 3], 6000); assert!(bits(&want.0) == bits(&got.0) && bits(&want.1) == bits(&got.1), "{}: first difference {:?}", name, first_diff(&want.0, &got.0)); } } /// A counting build plays the same samples and counts the same ops on /// every run: what `check` calls expensive does not depend on the machine. #[test] fn a_counting_build_plays_the_same_bits_and_counts_the_same_ops() { for name in INSTRUMENTS { let src = shader_src(name); let plain = build(&src, Backend::Native); let play = |s: &AudioShaderRef| { let mut i = Instance::new(s.clone(), 48000.0); let (mut l, mut r) = (vec![0.0f32; 9000], vec![0.0f32; 9000]); i.note_on(50.0, 0.9, 3); i.render(&mut l[..6000], &mut r[..6000]); i.note_off(); i.render(&mut l[6000..], &mut r[6000..]); (l.iter().chain(&r).map(|x| x.to_bits()).collect::>(), i.ops()) }; let counting = makepad_script_audio_aot::compile_counting(&src).unwrap(); let (want, none) = play(&plain); let (got, ops) = play(&counting); assert_eq!(none, 0, "{name}: a plain build counts nothing"); assert!(got == want, "{name}: counting changed the samples"); assert!(ops > 9000, "{name}: {ops} ops"); assert_eq!(play(&makepad_script_audio_aot::compile_counting(&src).unwrap()).1, ops, "{name}: the count is the same every run"); } }