mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-15 06:46:45 -04:00
527 lines
19 KiB
Rust
527 lines
19 KiB
Rust
use std::collections::HashSet;
|
|
use std::f32::consts::TAU;
|
|
|
|
use soundcraft_dsp::{Plugin, Unit, create, db_to_gain, gain_to_db, is_generator, plugin_info, plugins};
|
|
|
|
const SR: f32 = 48_000.0;
|
|
const BLOCK: usize = 512;
|
|
|
|
fn run(p: &mut dyn Plugin, input: &[Vec<f32>]) -> Vec<Vec<f32>> {
|
|
let len = input[0].len();
|
|
let ch = input.len();
|
|
let mut out = vec![Vec::with_capacity(len); ch];
|
|
let mut pos = 0;
|
|
let mut buf = vec![vec![0.0f32; BLOCK]; ch];
|
|
while pos < len {
|
|
let n = BLOCK.min(len - pos);
|
|
for c in 0..ch {
|
|
buf[c][..n].copy_from_slice(&input[c][pos..pos + n]);
|
|
}
|
|
p.process(&mut buf, n);
|
|
for c in 0..ch {
|
|
out[c].extend_from_slice(&buf[c][..n]);
|
|
}
|
|
pos += n;
|
|
}
|
|
out
|
|
}
|
|
|
|
fn sine(freq: f32, amp: f32, len: usize) -> Vec<f32> {
|
|
(0..len).map(|i| amp * (TAU * freq * i as f32 / SR).sin()).collect()
|
|
}
|
|
|
|
fn peak(x: &[f32]) -> f32 {
|
|
x.iter().fold(0.0f32, |m, v| m.max(v.abs()))
|
|
}
|
|
|
|
fn rms(x: &[f32]) -> f32 {
|
|
(x.iter().map(|v| v * v).sum::<f32>() / x.len().max(1) as f32).sqrt()
|
|
}
|
|
|
|
fn prepared(id: &str) -> Box<dyn Plugin> {
|
|
let mut p = create(id).unwrap();
|
|
p.prepare(SR, BLOCK, 2);
|
|
p
|
|
}
|
|
|
|
fn is_source(id: &str) -> bool {
|
|
plugin_info(id).unwrap().is_instrument || is_generator(id)
|
|
}
|
|
|
|
#[test]
|
|
fn registry_ids_unique_and_creatable() {
|
|
let mut ids = HashSet::new();
|
|
for info in plugins() {
|
|
assert!(ids.insert(info.id), "duplicate id {}", info.id);
|
|
assert!(info.short_name.chars().count() <= 8, "{} short name too long", info.id);
|
|
let p = create(info.id).unwrap_or_else(|| panic!("cannot create {}", info.id));
|
|
assert_eq!(p.info().id, info.id);
|
|
assert_eq!(plugin_info(info.id).unwrap().id, info.id);
|
|
}
|
|
assert!(create("nope").is_none());
|
|
assert!(plugins().len() >= 25);
|
|
}
|
|
|
|
#[test]
|
|
fn params_well_formed() {
|
|
for info in plugins() {
|
|
let mut ids = HashSet::new();
|
|
for p in info.params {
|
|
assert!(ids.insert(p.id), "{}: duplicate param {}", info.id, p.id);
|
|
assert!(p.min <= p.default && p.default <= p.max, "{}.{} default out of range", info.id, p.id);
|
|
assert!(p.min.is_finite() && p.max.is_finite());
|
|
if p.unit == Unit::Choice {
|
|
assert_eq!(p.choices.len() as f32, p.max + 1.0, "{}.{}", info.id, p.id);
|
|
}
|
|
let n = p.to_normalized(p.default);
|
|
assert!((p.from_normalized(n) - p.default).abs() <= 1e-3 * (1.0 + p.default.abs()), "{}.{}", info.id, p.id);
|
|
}
|
|
let mut plug = create(info.id).unwrap();
|
|
for p in info.params {
|
|
assert_eq!(plug.param(p.id), Some(p.default), "{}.{}", info.id, p.id);
|
|
assert!(plug.set_param(p.id, p.max + 1000.0));
|
|
assert_eq!(plug.param(p.id), Some(p.max));
|
|
}
|
|
assert!(!plug.set_param("definitely_not_a_param", 1.0));
|
|
assert_eq!(plug.param("definitely_not_a_param"), None);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn silence_in_silence_out() {
|
|
for info in plugins() {
|
|
if is_source(info.id) {
|
|
continue;
|
|
}
|
|
let mut p = prepared(info.id);
|
|
let out = run(p.as_mut(), &vec![vec![0.0; 48_000]; 2]);
|
|
for c in &out {
|
|
assert!(peak(c) < 1e-6, "{} made noise from silence: {}", info.id, peak(c));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn impulse_response_finite_and_bounded() {
|
|
for info in plugins() {
|
|
let mut p = prepared(info.id);
|
|
let mut x = vec![vec![0.0; 96_000]; 2];
|
|
x[0][100] = 1.0;
|
|
x[1][100] = 1.0;
|
|
p.note_on(0, 36, 100);
|
|
let out = run(p.as_mut(), &x);
|
|
for c in &out {
|
|
assert!(c.iter().all(|v| v.is_finite()), "{} produced non-finite output", info.id);
|
|
assert!(peak(c) < 8.0, "{} impulse peak {}", info.id, peak(c));
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn hostile_params_never_break_processing() {
|
|
let hostile = [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -1e30, 1e30, 0.0, -0.0];
|
|
for info in plugins() {
|
|
for &v in &hostile {
|
|
let mut p = prepared(info.id);
|
|
for param in info.params {
|
|
assert!(p.set_param(param.id, v));
|
|
let got = p.param(param.id).unwrap();
|
|
assert!(got.is_finite() && got >= param.min && got <= param.max, "{}.{} = {got}", info.id, param.id);
|
|
}
|
|
p.note_on(3, 60, 127);
|
|
let x = vec![sine(440.0, 0.5, 9600), sine(660.0, 0.5, 9600)];
|
|
let out = run(p.as_mut(), &x);
|
|
assert!(out.iter().flatten().all(|s| s.is_finite()), "{} non-finite with param value {v}", info.id);
|
|
let _ = p.latency();
|
|
let _ = p.tail_samples();
|
|
assert!(p.gain_reduction_db().is_finite());
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn odd_host_calls_do_not_panic() {
|
|
for info in plugins() {
|
|
let mut p = create(info.id).unwrap();
|
|
// Unprepared-ish, weird rates, block bigger than prepared, mismatched channels.
|
|
for (sr, block, ch) in [(0.0, 0, 0), (f32::NAN, 16, 1), (-44_100.0, 64, 2), (1e9, 64, 8), (44_100.0, 32, 2)] {
|
|
p.prepare(sr, block, ch);
|
|
let mut io = vec![vec![0.25f32; 300]; 3];
|
|
p.process(&mut io, 4096);
|
|
p.process(&mut io, 0);
|
|
p.process(&mut [], 128);
|
|
let mut short = vec![vec![0.1f32; 10], vec![0.1f32; 3]];
|
|
p.process(&mut short, 100);
|
|
p.note_on(10_000, 200, 255);
|
|
p.note_off(0, 200);
|
|
p.all_notes_off();
|
|
p.process(&mut io, 300);
|
|
p.reset();
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn param_changes_are_smooth() {
|
|
// Jumping gain by 24 dB mid-stream must not produce a step discontinuity.
|
|
let mut p = prepared("gain");
|
|
let x = vec![vec![0.5f32; 4096]; 1];
|
|
let _ = run(p.as_mut(), &x);
|
|
p.set_param("gain", 24.0);
|
|
let out = run(p.as_mut(), &x);
|
|
let max_step = out[0].windows(2).map(|w| (w[1] - w[0]).abs()).fold(0.0f32, f32::max);
|
|
assert!(max_step < 0.05, "zipper step {max_step}");
|
|
let out = run(p.as_mut(), &x);
|
|
assert!((out[0][4095] - 0.5 * db_to_gain(24.0)).abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn eq_magnitude_at_center_matches_gain() {
|
|
let sr = SR;
|
|
let resp = soundcraft_dsp::eq7_response(&[("mid_freq", 1000.0), ("mid_gain", 9.0), ("mid_q", 2.0)], &[1000.0, 30.0, 15_000.0], sr);
|
|
assert!((resp[0] - 9.0).abs() < 0.05, "{resp:?}");
|
|
assert!(resp[1].abs() < 0.2 && resp[2].abs() < 0.2, "{resp:?}");
|
|
let r1 = soundcraft_dsp::eq1_response(&[("freq", 2500.0), ("gain", -12.0)], &[2500.0], sr);
|
|
assert!((r1[0] + 12.0).abs() < 0.05);
|
|
|
|
// And the real-time EQ agrees with the curve.
|
|
let mut p = prepared("eq_7band");
|
|
p.set_param("mid_freq", 1000.0);
|
|
p.set_param("mid_gain", 9.0);
|
|
p.set_param("mid_q", 2.0);
|
|
p.reset();
|
|
let out = run(p.as_mut(), &[sine(1000.0, 0.1, 48_000)]);
|
|
let g = gain_to_db(rms(&out[0][24_000..]) / rms(&sine(1000.0, 0.1, 48_000)[24_000..]));
|
|
assert!((g - 9.0).abs() < 0.2, "measured {g}");
|
|
|
|
// HPF attenuates lows.
|
|
let r = soundcraft_dsp::eq7_response(&[("hpf_on", 1.0), ("hpf_freq", 200.0), ("hpf_slope", 3.0)], &[50.0, 200.0, 2000.0], sr);
|
|
assert!(r[0] < -40.0 && (r[1] + 3.0).abs() < 0.2 && r[2].abs() < 0.1, "{r:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn eq_switches_and_choices_apply_to_a_running_eq() {
|
|
// (plugin, settings, the one switch or choice changed while running, test tone)
|
|
let cases: [(&str, &[(&str, f32)], (&str, f32), f32); 7] = [
|
|
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0)], ("hpf_on", 1.0), 1000.0),
|
|
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 3.0), ("hpf_on", 1.0)], ("hpf_on", 0.0), 1000.0),
|
|
("eq_7band", &[("hpf_freq", 2000.0), ("hpf_slope", 0.0), ("hpf_on", 1.0)], ("hpf_slope", 3.0), 1000.0),
|
|
("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 3.0)], ("lpf_on", 1.0), 4000.0),
|
|
("eq_7band", &[("lpf_freq", 2000.0), ("lpf_slope", 0.0), ("lpf_on", 1.0)], ("lpf_slope", 3.0), 4000.0),
|
|
("eq_1band", &[("freq", 1000.0)], ("type", 4.0), 10_000.0),
|
|
("channel_strip", &[("hpf_freq", 2000.0)], ("hpf_on", 1.0), 1000.0),
|
|
];
|
|
let mut stale = Vec::new();
|
|
for (id, setup, (key, value), tone) in cases {
|
|
let x = [sine(tone, 0.5, 24_000)];
|
|
let level = |p: &mut dyn Plugin| gain_to_db(rms(&run(p, &x)[0][12_000..]));
|
|
let build = |settings: &[(&str, f32)]| {
|
|
let mut p = create(id).unwrap();
|
|
p.prepare(SR, BLOCK, 1);
|
|
for &(k, v) in settings {
|
|
assert!(p.set_param(k, v), "{id} {k}");
|
|
}
|
|
p.reset();
|
|
p
|
|
};
|
|
let mut live = build(setup);
|
|
let before = level(live.as_mut());
|
|
assert!(live.set_param(key, value));
|
|
let after = level(live.as_mut());
|
|
let want = level(build(&[setup, &[(key, value)]].concat()).as_mut());
|
|
assert!((before - want).abs() > 3.0, "{id} {key}={value}: the tone must tell the settings apart");
|
|
if (after - want).abs() >= 0.1 {
|
|
stale.push(format!("{id} {key}={value}: live {after:.2} dB, fresh {want:.2} dB, before {before:.2} dB"));
|
|
}
|
|
}
|
|
assert!(stale.is_empty(), "{stale:#?}");
|
|
}
|
|
|
|
#[test]
|
|
fn compressor_steady_state_gain_reduction() {
|
|
let mut p = prepared("compressor");
|
|
p.set_param("threshold", -20.0);
|
|
p.set_param("ratio", 4.0);
|
|
p.set_param("knee", 0.0);
|
|
p.set_param("attack", 1.0);
|
|
p.set_param("release", 50.0);
|
|
// Constant level at -10 dBFS: 10 dB over → 7.5 dB of reduction.
|
|
let level = db_to_gain(-10.0);
|
|
let out = run(p.as_mut(), &vec![vec![level; 48_000]; 2]);
|
|
let got = gain_to_db(out[0][47_999]) - -10.0;
|
|
assert!((got + 7.5).abs() < 0.1, "gain change {got}");
|
|
assert!((p.gain_reduction_db() - 7.5).abs() < 0.1);
|
|
|
|
// A sine settles near the same reduction (peak detection).
|
|
p.reset();
|
|
let x = sine(1000.0, level, 48_000);
|
|
let out = run(p.as_mut(), &[x.clone(), x]);
|
|
let got = gain_to_db(peak(&out[0][40_000..])) + 10.0;
|
|
assert!((got + 7.5).abs() < 1.0, "sine gain change {got}");
|
|
|
|
// Limiter mode with lookahead reports latency.
|
|
p.set_param("limiter", 1.0);
|
|
p.set_param("lookahead", 5.0);
|
|
assert_eq!(p.latency(), 240);
|
|
}
|
|
|
|
#[test]
|
|
fn gate_closes_below_threshold() {
|
|
let mut p = prepared("expander_gate");
|
|
p.set_param("threshold", -40.0);
|
|
p.set_param("range", 80.0);
|
|
p.set_param("hold", 10.0);
|
|
p.set_param("release", 20.0);
|
|
let quiet = sine(300.0, db_to_gain(-60.0), 48_000);
|
|
let out = run(p.as_mut(), std::slice::from_ref(&quiet));
|
|
let atten = gain_to_db(peak(&out[0][24_000..]) / peak(&quiet[24_000..]));
|
|
assert!(atten < -70.0, "gate attenuation only {atten}");
|
|
let loud = sine(300.0, db_to_gain(-10.0), 48_000);
|
|
let out = run(p.as_mut(), std::slice::from_ref(&loud));
|
|
let g = gain_to_db(peak(&out[0][24_000..]) / peak(&loud[24_000..]));
|
|
assert!(g.abs() < 0.5, "open gate changed level {g}");
|
|
}
|
|
|
|
#[test]
|
|
fn maximizer_respects_ceiling_and_reports_latency() {
|
|
let mut p = prepared("maximizer");
|
|
p.set_param("threshold", -12.0);
|
|
p.set_param("ceiling", -1.0);
|
|
let x = sine(220.0, 0.9, 48_000);
|
|
let out = run(p.as_mut(), &[x.clone(), x]);
|
|
assert!(peak(&out[0]) <= db_to_gain(-1.0) + 1e-5, "{}", peak(&out[0]));
|
|
assert!(p.latency() > 0 && p.latency() < 200);
|
|
assert!(p.gain_reduction_db() > 5.0);
|
|
}
|
|
|
|
#[test]
|
|
fn de_esser_reduces_sibilance_only() {
|
|
let mut p = prepared("de_esser");
|
|
p.set_param("threshold", -40.0);
|
|
p.set_param("range", 20.0);
|
|
let hi = sine(8000.0, 0.5, 48_000);
|
|
let out = run(p.as_mut(), std::slice::from_ref(&hi));
|
|
assert!(gain_to_db(rms(&out[0][24_000..]) / rms(&hi[24_000..])) < -6.0);
|
|
p.reset();
|
|
let lo = sine(200.0, 0.005, 48_000);
|
|
let out = run(p.as_mut(), std::slice::from_ref(&lo));
|
|
assert!(gain_to_db(rms(&out[0][24_000..]) / rms(&lo[24_000..])).abs() < 0.5);
|
|
}
|
|
|
|
#[test]
|
|
fn latency_reporting_aligns_pitch_shifter() {
|
|
let mut p = prepared("pitch_shifter");
|
|
let lat = p.latency();
|
|
assert!(lat > 0);
|
|
let mut x = vec![0.0f32; 8000];
|
|
x[1000] = 1.0;
|
|
let out = run(p.as_mut(), &[x]);
|
|
let pos = out[0].iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).unwrap().0;
|
|
assert_eq!(pos, 1000 + lat);
|
|
}
|
|
|
|
#[test]
|
|
fn pitch_shifter_moves_pitch() {
|
|
let mut p = prepared("pitch_shifter");
|
|
p.set_param("semitones", 12.0);
|
|
p.reset();
|
|
let out = run(p.as_mut(), &[sine(440.0, 0.5, 48_000)]);
|
|
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][16_384..], 16_384);
|
|
let bin = spec.iter().enumerate().max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0;
|
|
let hz = soundcraft_dsp::spectrum::bin_hz(bin, 16_384, SR);
|
|
assert!((hz - 880.0).abs() < 15.0, "peak at {hz}");
|
|
}
|
|
|
|
#[test]
|
|
fn reverb_has_tail_and_decays() {
|
|
for id in ["room_reverb", "plate_reverb"] {
|
|
let mut p = prepared(id);
|
|
p.set_param("mix", 100.0);
|
|
p.set_param("decay", 1.0);
|
|
let mut x = vec![vec![0.0; 48_000 * 4]; 2];
|
|
x[0][0] = 1.0;
|
|
x[1][0] = 1.0;
|
|
let out = run(p.as_mut(), &x);
|
|
let early = rms(&out[0][2400..24_000]);
|
|
let late = rms(&out[0][48_000 * 3..]);
|
|
assert!(early > 1e-3, "{id} no reverb");
|
|
assert!(late < early * 1e-2, "{id} not decaying {early} {late}");
|
|
assert!(p.tail_samples() > 48_000);
|
|
// Stereo decorrelation.
|
|
assert!(out[0][2400..4800] != out[1][2400..4800]);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn delay_repeats_at_time() {
|
|
let mut p = prepared("mod_delay");
|
|
p.set_param("time", 100.0);
|
|
p.set_param("mix", 100.0);
|
|
p.set_param("feedback", 0.0);
|
|
p.set_param("lpf", 20_000.0);
|
|
p.reset();
|
|
let mut x = vec![0.0f32; 20_000];
|
|
x[0] = 1.0;
|
|
let out = run(p.as_mut(), &[x]);
|
|
let pos = out[0].iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).unwrap().0;
|
|
assert!((pos as i64 - 4800).abs() <= 2, "echo at {pos}");
|
|
}
|
|
|
|
#[test]
|
|
fn modulation_tail_covers_feedback_echoes() {
|
|
for (id, delay, feedback) in [("chorus", 30.0, 90.0), ("flanger", 10.0, 95.0), ("flanger", 10.0, -95.0)] {
|
|
let mut p = prepared(id);
|
|
for (param, v) in [("delay", delay), ("feedback", feedback), ("depth", 0.0), ("mix", 100.0)] {
|
|
assert!(p.set_param(param, v));
|
|
}
|
|
p.reset();
|
|
let mut x = vec![0.0f32; 48_000 * 5];
|
|
x[0] = 0.5;
|
|
let out = run(p.as_mut(), &[x]);
|
|
let floor = peak(&out[0]) * 1e-3;
|
|
let last = out[0].iter().rposition(|v| v.abs() > floor).unwrap();
|
|
assert!(last < p.tail_samples(), "{id} feedback {feedback}: echo at {last}, tail {}", p.tail_samples());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn trim_and_invert() {
|
|
let mut p = prepared("invert");
|
|
let out = run(p.as_mut(), &[vec![0.5; 16]]);
|
|
assert!(out[0].iter().all(|&v| v == -0.5));
|
|
let mut t = prepared("trim");
|
|
t.set_param("gain", -96.0);
|
|
t.reset();
|
|
let out = run(t.as_mut(), &[vec![0.5; 16]]);
|
|
assert!(out[0].iter().all(|&v| v == 0.0));
|
|
t.set_param("gain", 0.0);
|
|
t.set_param("invert", 1.0);
|
|
t.reset();
|
|
let out = run(t.as_mut(), &[vec![0.5; 16]]);
|
|
assert!(out[0].iter().all(|&v| (v + 0.5).abs() < 1e-6));
|
|
}
|
|
|
|
#[test]
|
|
fn time_shift_delays() {
|
|
let mut p = prepared("time_shift");
|
|
p.set_param("delay_samples", 123.0);
|
|
p.reset();
|
|
let mut x = vec![0.0f32; 1000];
|
|
x[10] = 1.0;
|
|
let out = run(p.as_mut(), &[x]);
|
|
assert_eq!(out[0][133], 1.0);
|
|
}
|
|
|
|
#[test]
|
|
fn dc_removal_removes_dc() {
|
|
let mut p = prepared("dc_offset_removal");
|
|
let out = run(p.as_mut(), &[vec![0.3; 96_000]]);
|
|
assert!(out[0][95_999].abs() < 1e-3);
|
|
}
|
|
|
|
#[test]
|
|
fn signal_generator_level() {
|
|
let mut p = prepared("signal_generator");
|
|
p.set_param("level", -6.0);
|
|
p.reset();
|
|
let out = run(p.as_mut(), &[vec![0.0; 48_000]]);
|
|
assert!((gain_to_db(peak(&out[0])) + 6.0).abs() < 0.05);
|
|
}
|
|
|
|
#[test]
|
|
fn synth_note_on_sounds_and_note_off_decays() {
|
|
let mut p = prepared("subtractive_synth");
|
|
p.note_on(0, 60, 100);
|
|
let out = run(p.as_mut(), &vec![vec![0.0; 24_000]; 2]);
|
|
assert!(rms(&out[0][4800..]) > 0.01, "synth silent");
|
|
assert_eq!(out[0], out[1]);
|
|
p.note_off(0, 60);
|
|
let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]);
|
|
assert!(peak(&out[0][72_000..]) < 1e-6, "synth did not decay");
|
|
// Polyphony: a chord is louder than one note and stays finite.
|
|
for n in [60, 64, 67, 71, 74, 77, 81, 84, 60, 62, 65, 69, 72, 76, 79, 83, 86, 88] {
|
|
p.note_on(0, n, 127);
|
|
}
|
|
let out = run(p.as_mut(), &vec![vec![0.0; 9600]; 2]);
|
|
assert!(out[0].iter().all(|v| v.is_finite()) && peak(&out[0]) > 0.05);
|
|
p.all_notes_off();
|
|
let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]);
|
|
assert!(peak(&out[0][72_000..]) < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn synth_follows_the_sustain_pedal_and_pitch_bend() {
|
|
let mut p = prepared("subtractive_synth");
|
|
p.midi(0, [0xB0, 64, 127]);
|
|
p.note_on(0, 60, 100);
|
|
run(p.as_mut(), &vec![vec![0.0; 4800]; 2]);
|
|
p.note_off(0, 60);
|
|
let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]);
|
|
assert!(rms(&out[0][72_000..]) > 0.01, "the pedal holds the note");
|
|
p.midi(0, [0xB0, 64, 0]);
|
|
let out = run(p.as_mut(), &vec![vec![0.0; 96_000]; 2]);
|
|
assert!(peak(&out[0][72_000..]) < 1e-6, "lifting the pedal releases it");
|
|
|
|
let rises = |x: &[f32]| x.windows(2).filter(|w| w[0] < 0.0 && w[1] >= 0.0).count() as f64;
|
|
let mut p = prepared("subtractive_synth");
|
|
p.set_param("osc1_wave", 2.0);
|
|
p.set_param("osc_mix", 0.0);
|
|
p.note_on(0, 69, 100);
|
|
let plain = run(p.as_mut(), &[vec![0.0; 48_000]]);
|
|
p.midi(0, [0xE0, 0x7F, 0x7F]);
|
|
let bent = run(p.as_mut(), &[vec![0.0; 48_000]]);
|
|
let ratio = rises(&bent[0][4800..]) / rises(&plain[0][4800..]);
|
|
assert!((ratio - 2f64.powf(2.0 / 12.0)).abs() < 0.01, "{ratio}");
|
|
p.midi(0, [0xB0, 121, 0]);
|
|
let reset = run(p.as_mut(), &[vec![0.0; 48_000]]);
|
|
assert!((rises(&reset[0][4800..]) / rises(&plain[0][4800..]) - 1.0).abs() < 0.01);
|
|
}
|
|
|
|
#[test]
|
|
fn synth_event_offsets_are_sample_accurate() {
|
|
let mut p = prepared("subtractive_synth");
|
|
p.set_param("amp_attack", 0.5);
|
|
p.note_on(300, 69, 127);
|
|
let out = run(p.as_mut(), &[vec![0.0; 512]]);
|
|
assert!(out[0][..300].iter().all(|&v| v == 0.0));
|
|
assert!(out[0][300..].iter().any(|&v| v != 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn drum_kit_notes_sound_and_end() {
|
|
for note in [36, 38, 39, 42, 46, 45, 47, 50, 49, 51] {
|
|
let mut p = prepared("drum_synth");
|
|
p.note_on(0, note, 120);
|
|
let out = run(p.as_mut(), &[vec![0.0; 48_000 * 7]]);
|
|
assert!(peak(&out[0][..4800]) > 0.01, "drum note {note} silent");
|
|
assert!(peak(&out[0][48_000 * 6..]) < 1e-6, "drum note {note} rings forever");
|
|
}
|
|
let mut p = prepared("drum_synth");
|
|
p.note_on(0, 99, 120);
|
|
let out = run(p.as_mut(), &[vec![0.0; 4800]]);
|
|
assert!(peak(&out[0]) == 0.0, "unmapped note should be silent");
|
|
}
|
|
|
|
#[test]
|
|
fn saturator_adds_harmonics_and_stays_bounded() {
|
|
let mut p = prepared("saturator");
|
|
p.set_param("drive", 30.0);
|
|
let out = run(p.as_mut(), &[sine(1000.0, 1.0, 48_000)]);
|
|
assert!(peak(&out[0]) <= 1.01);
|
|
let spec = soundcraft_dsp::spectrum::magnitude_db(&out[0][8192..], 8192);
|
|
let third = (3000.0 / (SR / 8192.0)) as usize;
|
|
assert!(spec[third - 1..=third + 1].iter().cloned().fold(f32::MIN, f32::max) > -30.0);
|
|
}
|
|
|
|
#[test]
|
|
fn lofi_quantizes() {
|
|
let mut p = prepared("lofi");
|
|
p.set_param("bits", 2.0);
|
|
p.set_param("sample_rate", 48_000.0);
|
|
let out = run(p.as_mut(), &[sine(100.0, 0.9, 4800)]);
|
|
let mut levels: Vec<i32> = out[0].iter().map(|v| (v * 1000.0).round() as i32).collect();
|
|
levels.sort();
|
|
levels.dedup();
|
|
assert!(levels.len() <= 5, "{levels:?}");
|
|
}
|