987 lines
41 KiB
Rust
987 lines
41 KiB
Rust
//! 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<bool> = 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<R>(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<f32>, Vec<f32>) {
|
|
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<f32>, Vec<f32>) {
|
|
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<f32>, Vec<f32>) {
|
|
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<u32> {
|
|
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<f32> {
|
|
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<f32> = (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<f32> = (0..n).map(|i| ((i * 7919) % 2001) as f32 / 1000.0 - 1.0).collect();
|
|
let r: Vec<f32> = (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::<f64>() / (x.len() - lag) as f64 };
|
|
let vals: Vec<f64> = (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<Item = u32>) -> 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<Item = u32>) {
|
|
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<f32>, Vec<f32>) {
|
|
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::<Vec<_>>(), 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");
|
|
}
|
|
}
|