Files
soundcraft/crates/au-host/tests/system_units.rs
2026-10-13 05:31:14 -04:00

219 lines
7.6 KiB
Rust

//! End to end against the Audio Units Apple ships with macOS: scan → instantiate → params →
//! process → state, plus an instrument playing a note. Skips (passes) when a unit is missing.
#![cfg(target_os = "macos")]
use soundcraft_dsp::{Category, Plugin};
const LOWPASS: &str = "au:aufx:lpas:appl";
const DELAY: &str = "au:aufx:dely:appl";
const DLS: &str = "au:aumu:dls :appl";
fn available(id: &str) -> bool {
let ok = soundcraft_au_host::scan().iter().any(|d| d.id == id);
if !ok {
eprintln!("{id} not installed; skipping");
}
ok
}
fn sine(freq: f32, sr: f32, n: usize, phase0: usize) -> Vec<f32> {
(0..n).map(|i| (std::f32::consts::TAU * freq * (i + phase0) as f32 / sr).sin() * 0.5).collect()
}
fn rms(x: &[f32]) -> f32 {
(x.iter().map(|v| v * v).sum::<f32>() / x.len().max(1) as f32).sqrt()
}
/// Runs `freq` through the plugin in 512-frame blocks for ~0.5 s; RMS of the last half.
fn run_tone(p: &mut dyn Plugin, freq: f32) -> f32 {
let sr = 48_000.0;
let block = 512;
let blocks = 48;
let mut tail = Vec::new();
for b in 0..blocks {
let s = sine(freq, sr, block, b * block);
let mut io = vec![s.clone(), s];
p.process(&mut io, block);
for c in &io {
assert!(c.iter().all(|v| v.is_finite()));
}
if b >= blocks / 2 {
tail.extend_from_slice(&io[0]);
}
}
rms(&tail)
}
#[test]
fn scan_lists_apple_units() {
let all = soundcraft_au_host::scan();
for d in &all {
assert!(d.id.starts_with(soundcraft_au_host::ID_PREFIX), "{d:?}");
assert_eq!(soundcraft_au_host::parse_id(&d.id).map(|(t, s, m)| soundcraft_au_host::format_id(t, s, m)), Some(d.id.clone()));
assert_eq!(d.is_instrument, d.component_type == "aumu");
}
if let Some(d) = all.iter().find(|d| d.id == LOWPASS) {
assert_eq!(d.name, "AULowpass");
assert_eq!(d.vendor, "Apple");
assert_eq!(d.category, Category::Eq);
assert!(!d.is_instrument);
}
if let Some(d) = all.iter().find(|d| d.id == DLS) {
assert!(d.is_instrument);
assert_eq!(d.category, Category::Instrument);
}
assert_eq!(soundcraft_au_host::rescan().len(), all.len());
}
#[test]
fn lowpass_attenuates_highs() {
if !available(LOWPASS) {
return;
}
let info = soundcraft_au_host::plugin_info(LOWPASS).unwrap();
assert_eq!(info.id, LOWPASS);
assert!(!info.params.is_empty(), "AULowpass has a cutoff");
let mut p = soundcraft_au_host::create(LOWPASS).unwrap();
p.prepare(48_000.0, 512, 2);
let cutoff = info.params.iter().find(|q| q.unit == soundcraft_dsp::Unit::Hz).unwrap();
assert!(p.set_param(cutoff.id, 500.0));
assert!((p.param(cutoff.id).unwrap() - 500.0).abs() < 1e-3);
assert!(!p.set_param("999999", 1.0));
assert!(p.set_param(cutoff.id, f32::NAN), "NaN becomes the default, never reaches the unit");
assert!(p.set_param(cutoff.id, 500.0));
let low = run_tone(p.as_mut(), 100.0);
p.reset();
let high = run_tone(p.as_mut(), 12_000.0);
eprintln!("lowpass rms: 100 Hz {low}, 12 kHz {high}");
assert!(low > 0.2, "low tone passes: {low}");
assert!(high < low * 0.1, "high tone attenuated: {high} vs {low}");
// State round trip restores the cutoff.
let state = p.save_state().expect("ClassInfo");
assert!(p.set_param(cutoff.id, 5000.0));
assert!(p.load_state(&state));
assert!((p.param(cutoff.id).unwrap() - 500.0).abs() < 1.0, "{:?}", p.param(cutoff.id));
// Hostile state is rejected without harm.
assert!(!p.load_state(b""));
assert!(!p.load_state(b"bplist00 garbage \x00\x01\x02\xff"));
assert!(!p.load_state(&[0xffu8; 1000]));
assert!(run_tone(p.as_mut(), 100.0).is_finite());
}
#[test]
fn state_reader_reads_while_another_thread_renders() {
if !available(LOWPASS) {
return;
}
let info = soundcraft_au_host::plugin_info(LOWPASS).unwrap();
let cutoff = info.params.iter().find(|q| q.unit == soundcraft_dsp::Unit::Hz).unwrap();
let mut p = soundcraft_au_host::create(LOWPASS).unwrap();
p.prepare(48_000.0, 512, 2);
assert!(p.set_param(cutoff.id, 500.0));
let mut reader = p.state_reader().expect("every unit has a ClassInfo");
let audio = std::thread::spawn(move || {
for _ in 0..4 {
run_tone(p.as_mut(), 100.0);
}
p
});
while !audio.is_finished() {
assert!(reader.save_state().is_some());
}
let p = audio.join().unwrap();
let state = reader.save_state().expect("ClassInfo");
drop(p);
let mut q = soundcraft_au_host::create(LOWPASS).unwrap();
assert_eq!(reader.save_state(), None);
assert!(q.load_state(&state));
assert!((q.param(cutoff.id).unwrap() - 500.0).abs() < 1.0, "{:?}", q.param(cutoff.id));
}
#[test]
fn delay_runs_mono_stereo_and_odd_blocks() {
if !available(DELAY) {
return;
}
let mut p = soundcraft_au_host::instantiate(DELAY).unwrap();
for (ch, block) in [(2usize, 1024usize), (1, 300), (2, 5000)] {
p.prepare(44_100.0, block, ch);
let mut io: Vec<Vec<f32>> = (0..ch).map(|_| sine(440.0, 44_100.0, block, 0)).collect();
// Larger than prepared and empty blocks are clamped, not crashes.
p.process(&mut io, block * 4);
p.process(&mut io, 0);
p.process(&mut [], block);
for _ in 0..20 {
p.process(&mut io, block);
}
assert!(io.iter().flatten().all(|v| v.is_finite()));
assert!(rms(&io[0]) > 0.0);
}
// Hostile prepare values.
p.prepare(f32::NAN, 0, 0);
let mut io = vec![vec![0.25f32; 64]];
p.process(&mut io, 64);
assert!(io[0].iter().all(|v| v.is_finite()));
}
#[test]
fn dls_synth_plays_a_note() {
if !available(DLS) {
return;
}
let mut p = match soundcraft_au_host::instantiate(DLS) {
Ok(p) => p,
Err(e) => {
eprintln!("DLSMusicDevice unavailable: {e}");
return;
}
};
assert!(p.info().is_instrument);
p.prepare(48_000.0, 512, 2);
let mut silent = vec![vec![1.0f32; 512]; 2];
p.process(&mut silent, 512);
assert!(silent.iter().flatten().all(|v| v.is_finite() && v.abs() < 1e-3), "instruments replace the buffer");
p.note_on(10, 60, 110);
p.note_on(0, 200, 110); // ignored
let mut energy = 0.0f32;
for _ in 0..40 {
let mut io = vec![vec![0.0f32; 512]; 2];
p.process(&mut io, 512);
assert!(io.iter().flatten().all(|v| v.is_finite()));
energy = energy.max(rms(&io[0]));
}
eprintln!("DLS rms {energy}");
assert!(energy > 1e-3, "note-on produced sound: {energy}");
p.all_notes_off();
// Mono output downmixes.
p.prepare(48_000.0, 256, 1);
p.note_on(0, 64, 100);
let mut mono = vec![vec![0.0f32; 256]];
let mut e = 0.0f32;
for _ in 0..40 {
p.process(&mut mono, 256);
e = e.max(rms(&mono[0]));
}
assert!(e > 1e-3, "{e}");
p.note_off(0, 64);
p.reset();
// Controllers reach the synth: MIDI volume 0 silences a note. (In this test because creating
// two DLS instances at once on two threads crashes inside the unit.)
p.prepare(48_000.0, 512, 2);
let loudest = |p: &mut Box<dyn Plugin>, volume: u8| {
p.midi(0, [0xB0, 7, volume]);
p.note_on(0, 60, 110);
let mut energy = 0.0f32;
for _ in 0..20 {
let mut io = vec![vec![0.0f32; 512]; 2];
p.process(&mut io, 512);
energy = energy.max(rms(&io[0]));
}
p.note_off(0, 60);
p.reset();
energy
};
let full = loudest(&mut p, 127);
let muted = loudest(&mut p, 0);
assert!(full > 1e-3 && muted < full * 0.01, "volume 127: {full}, volume 0: {muted}");
}