mirror of
https://github.com/storytold/soundcraft.git
synced 2026-10-24 02:26:46 -06:00
* Build: line tables only for workspace crates, no debug info for dependencies Full debug info made most of the dev and test target size and link time. Line tables keep file and line numbers in backtraces; dependency frames lose them. * Tests: build each crate's integration tests as one binary Each tests/*.rs file was its own test binary: 98 binaries, each linking the crate and its dependencies. The files are now modules of tests/it/main.rs, one binary per crate, which cuts link time and target size. Filter by module: `cargo test -p <crate> --test it <module>::`. Binaries with their own main (harness = false) in clap-host and vst3-host stay separate, as do the ui-egui tests that switch the process-wide Light/Dark palette and recovery.rs, whose autosave locks leak into child processes other tests start. * Xtask: run tests with nextest, doctests apart, and no examples in the test build cargo xtask ci runs the tests with cargo nextest when it is installed, else cargo test with a notice, and doctests with cargo test --doc. The test build leaves out examples (--lib --bins --tests); clippy --all-targets still compiles them. Test binaries with harness = false run under cargo test: their own main cannot answer nextest's --list. clippy and the wasm check keep going past a failing crate. cargo xtask test runs the test step alone.
301 lines
11 KiB
Rust
301 lines
11 KiB
Rust
use std::f32::consts::TAU;
|
|
|
|
use soundcraft_dsp::meter::{LoudnessMeter, PeakMeter, RmsMeter};
|
|
use soundcraft_dsp::offline::{self, FadeShape, Waveform};
|
|
use soundcraft_dsp::spectrum::{bin_hz, magnitude_db};
|
|
use soundcraft_dsp::{create, db_to_gain, gain_to_db, pan};
|
|
|
|
fn sine(freq: f32, amp: f32, sr: 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()))
|
|
}
|
|
|
|
/// Dominant frequency by FFT with parabolic interpolation.
|
|
fn dominant_hz(x: &[f32], sr: f32) -> f32 {
|
|
let n = 32_768.min(x.len().next_power_of_two() / 2);
|
|
let spec = magnitude_db(&x[x.len() / 2 - n / 2..], n);
|
|
let k = spec.iter().enumerate().skip(1).max_by(|a, b| a.1.total_cmp(b.1)).unwrap().0;
|
|
let (a, b, c) = (spec[k - 1], spec[k], spec[k + 1]);
|
|
let d = 0.5 * (a - c) / (a - 2.0 * b + c);
|
|
bin_hz(k, n, sr) + d * sr / n as f32
|
|
}
|
|
|
|
#[test]
|
|
fn db_helpers() {
|
|
assert_eq!(db_to_gain(-200.0), 0.0);
|
|
assert_eq!(db_to_gain(f32::NAN), 0.0);
|
|
assert!((db_to_gain(-6.0206) - 0.5).abs() < 1e-4);
|
|
assert_eq!(gain_to_db(0.0), -144.0);
|
|
assert_eq!(gain_to_db(f32::NAN), -144.0);
|
|
assert!((gain_to_db(0.5) + 6.0206).abs() < 1e-3);
|
|
assert!((gain_to_db(-1.0)).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn pan_law_hard_right() {
|
|
let (l, r) = pan::gains(1.0, pan::PanLaw::Minus3);
|
|
assert!(l.abs() < 1e-6 && (r - 1.0).abs() < 1e-6);
|
|
let (l, r) = pan::gains(f32::NAN, pan::PanLaw::Minus6);
|
|
assert!((l - r).abs() < 1e-6);
|
|
}
|
|
|
|
#[test]
|
|
fn loudness_997hz_minus20_stereo_reads_minus20() {
|
|
let sr = 48_000.0;
|
|
let x = sine(997.0, db_to_gain(-20.0), sr, 48_000 * 10);
|
|
let mut m = LoudnessMeter::new(sr, 2);
|
|
m.process(&[x.clone(), x], 48_000 * 10);
|
|
assert!((m.integrated_lufs() + 20.0).abs() < 0.1, "integrated {}", m.integrated_lufs());
|
|
assert!((m.momentary_lufs() + 20.0).abs() < 0.1, "momentary {}", m.momentary_lufs());
|
|
assert!((m.short_term_lufs() + 20.0).abs() < 0.1, "short {}", m.short_term_lufs());
|
|
assert!((m.true_peak_db() + 20.0).abs() < 0.2, "tp {}", m.true_peak_db());
|
|
}
|
|
|
|
#[test]
|
|
fn loudness_full_scale_mono_and_44k() {
|
|
for sr in [44_100.0f32, 48_000.0, 96_000.0] {
|
|
let len = (sr * 5.0) as usize;
|
|
let x = sine(997.0, 1.0, sr, len);
|
|
let mut m = LoudnessMeter::new(sr, 1);
|
|
m.process(&[x], len);
|
|
assert!((m.integrated_lufs() + 3.01).abs() < 0.1, "{sr}: {}", m.integrated_lufs());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn loudness_stays_bounded_at_low_sample_rates() {
|
|
// The K-weighting shelf corner (~1.7 kHz) sits at or above Nyquist below ~3.4 kHz; the
|
|
// filter must stay stable there instead of reading hundreds of LUFS (issue #240).
|
|
for sr in [1000.0f32, 1200.0, 1600.0, 2000.0, 3000.0, 3400.0, 8000.0, 48_000.0] {
|
|
let len = sr as usize;
|
|
let mut m = LoudnessMeter::new(sr, 1);
|
|
assert_eq!(m.sample_rate(), sr);
|
|
m.process(&[sine(100.0, 0.5, sr, len)], len);
|
|
for level in [m.integrated_lufs(), m.momentary_lufs(), m.short_term_lufs()] {
|
|
assert!(level.is_finite() && (-30.0..=0.0).contains(&level), "{sr}: {level}");
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn loudness_gating_ignores_silence() {
|
|
let sr = 48_000.0;
|
|
let mut x = sine(997.0, db_to_gain(-20.0), sr, 48_000 * 5);
|
|
x.extend(std::iter::repeat_n(0.0, 48_000 * 20));
|
|
let mut m = LoudnessMeter::new(sr, 2);
|
|
m.process(&[x.clone(), x], 48_000 * 25);
|
|
assert!((m.integrated_lufs() + 20.0).abs() < 0.2, "{}", m.integrated_lufs());
|
|
assert!(m.momentary_lufs() < -100.0);
|
|
m.reset();
|
|
assert_eq!(m.integrated_lufs(), -144.0);
|
|
}
|
|
|
|
#[test]
|
|
fn true_peak_catches_intersample_overs() {
|
|
// A sine at fs/4 phased 45° has samples at ±0.707·A but true peak A.
|
|
let sr = 48_000.0;
|
|
let x: Vec<f32> = (0..48_000).map(|i| (TAU * 0.25 * (i % 4) as f32 + TAU / 8.0).sin()).collect();
|
|
assert!(peak(&x) < 0.71);
|
|
let mut m = LoudnessMeter::new(sr, 1);
|
|
m.process(&[x], 48_000);
|
|
assert!(m.true_peak_db() > -0.5, "{}", m.true_peak_db());
|
|
}
|
|
|
|
#[test]
|
|
fn peak_meter_ballistics() {
|
|
let mut m = PeakMeter::new(48_000.0);
|
|
m.process(&[0.5; 480]);
|
|
assert!((m.level_db() + 6.02).abs() < 0.01);
|
|
m.process(&vec![0.0; 48_000]);
|
|
assert!((m.level_db() - (-6.02 - 20.0)).abs() < 0.1, "{}", m.level_db());
|
|
assert!((m.peak_hold_db() + 6.02).abs() < 0.01);
|
|
assert!(!m.clipped());
|
|
m.process(&[1.0]);
|
|
assert!(m.clipped());
|
|
m.reset_clip();
|
|
assert!(!m.clipped());
|
|
let mut r = RmsMeter::new(48_000.0);
|
|
r.process(&sine(1000.0, 1.0, 48_000.0, 48_000));
|
|
assert!((r.rms_db() + 3.01).abs() < 0.2, "{}", r.rms_db());
|
|
}
|
|
|
|
#[test]
|
|
fn resample_preserves_frequency_and_length() {
|
|
let x = sine(1000.0, 0.5, 48_000.0, 48_000);
|
|
let out = offline::resample(std::slice::from_ref(&x), 48_000, 44_100);
|
|
assert_eq!(out[0].len(), 44_100);
|
|
let f = dominant_hz(&out[0], 44_100.0);
|
|
assert!((f - 1000.0).abs() < 1.0, "{f}");
|
|
assert!((peak(&out[0][1000..43_000]) - 0.5).abs() < 0.01);
|
|
let up = offline::resample(&out, 44_100, 96_000);
|
|
assert_eq!(up[0].len(), 96_000);
|
|
assert!((dominant_hz(&up[0], 96_000.0) - 1000.0).abs() < 1.0);
|
|
// Content above the new Nyquist is removed.
|
|
let hi = sine(23_000.0, 0.5, 48_000.0, 48_000);
|
|
let down = offline::resample(&[hi], 48_000, 22_050);
|
|
assert!(peak(&down[0][1000..20_000]) < 0.01, "alias {}", peak(&down[0][1000..20_000]));
|
|
assert_eq!(offline::resample(&[vec![1.0; 10]], 0, 48_000)[0].len(), 10);
|
|
}
|
|
|
|
#[test]
|
|
fn time_stretch_length_and_pitch() {
|
|
let sr = 48_000.0;
|
|
let x = sine(440.0, 0.5, sr, 48_000);
|
|
for ratio in [0.5, 0.8, 1.25, 2.0] {
|
|
let out = offline::time_stretch(&[x.clone(), x.clone()], ratio, sr);
|
|
assert_eq!(out.len(), 2);
|
|
assert_eq!(out[0].len(), (48_000.0 * ratio).round() as usize);
|
|
let f = dominant_hz(&out[0], sr);
|
|
assert!((f - 440.0).abs() < 3.0, "ratio {ratio}: {f}");
|
|
}
|
|
assert!(offline::time_stretch(&[vec![]], 2.0, sr)[0].is_empty());
|
|
let _ = offline::time_stretch(std::slice::from_ref(&x), f64::NAN, f32::NAN);
|
|
}
|
|
|
|
#[test]
|
|
fn pitch_shift_preserves_length_and_shifts() {
|
|
let sr = 48_000.0;
|
|
let x = sine(440.0, 0.5, sr, 48_000);
|
|
let out = offline::pitch_shift(std::slice::from_ref(&x), 12.0, sr);
|
|
assert_eq!(out[0].len(), x.len());
|
|
let f = dominant_hz(&out[0], sr);
|
|
assert!((f - 880.0).abs() < 6.0, "{f}");
|
|
let down = offline::pitch_shift(std::slice::from_ref(&x), -7.0, sr);
|
|
assert_eq!(down[0].len(), x.len());
|
|
let f = dominant_hz(&down[0], sr);
|
|
let want = 440.0 * 2f32.powf(-7.0 / 12.0);
|
|
assert!((f - want).abs() < 4.0, "{f} vs {want}");
|
|
}
|
|
|
|
#[test]
|
|
fn pitch_shift_reaches_the_ends_of_its_range() {
|
|
let sr = 48_000.0;
|
|
let x = sine(440.0, 0.5, sr, 48_000);
|
|
for st in [40.0f32, 48.0, -40.0, -48.0] {
|
|
let out = offline::pitch_shift(std::slice::from_ref(&x), st, sr);
|
|
assert_eq!(out[0].len(), x.len());
|
|
let f = dominant_hz(&out[0], sr);
|
|
let want = 440.0 * 2f32.powf(st / 12.0);
|
|
assert!((f / want - 1.0).abs() < 0.002, "{st} semitones: {f} vs {want}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn normalize_hits_target_peak() {
|
|
let mut ch = vec![sine(100.0, 0.2, 48_000.0, 4800), sine(100.0, 0.05, 48_000.0, 4800)];
|
|
offline::normalize(&mut ch, -1.0, false);
|
|
assert!((gain_to_db(peak(&ch[0])) + 1.0).abs() < 0.01);
|
|
assert!((gain_to_db(peak(&ch[1])) + 13.04).abs() < 0.05);
|
|
offline::normalize(&mut ch, -3.0, true);
|
|
assert!((gain_to_db(peak(&ch[1])) + 3.0).abs() < 0.01);
|
|
let mut silent = vec![vec![0.0; 10]];
|
|
offline::normalize(&mut silent, 0.0, true);
|
|
assert!(silent[0].iter().all(|&v| v == 0.0));
|
|
}
|
|
|
|
#[test]
|
|
fn simple_offline_ops() {
|
|
let mut ch = vec![vec![1.0, 2.0, 3.0]];
|
|
offline::reverse(&mut ch);
|
|
assert_eq!(ch[0], vec![3.0, 2.0, 1.0]);
|
|
offline::invert(&mut ch);
|
|
assert_eq!(ch[0], vec![-3.0, -2.0, -1.0]);
|
|
offline::remove_dc(&mut ch);
|
|
assert!(ch[0].iter().sum::<f32>().abs() < 1e-5);
|
|
offline::gain(&mut ch, f32::NAN);
|
|
offline::fade(&mut ch, 2, 2, FadeShape::EqualPower);
|
|
assert_eq!(ch[0][0], 0.0);
|
|
}
|
|
|
|
#[test]
|
|
fn non_silent_ranges_finds_bursts() {
|
|
let sr = 48_000.0;
|
|
let mut x = vec![0.0f32; 48_000];
|
|
for (i, v) in sine(500.0, 0.5, sr, 4800).into_iter().enumerate() {
|
|
x[10_000 + i] = v;
|
|
x[30_000 + i] = v;
|
|
}
|
|
let r = offline::non_silent_ranges(std::slice::from_ref(&x), -40.0, 480, 100, 200);
|
|
assert_eq!(r.len(), 2, "{r:?}");
|
|
assert!((r[0].0 as i64 - 9_900).abs() <= 5 && (r[0].1 as i64 - 15_000).abs() <= 5, "{r:?}");
|
|
assert!((r[1].0 as i64 - 29_900).abs() <= 5, "{r:?}");
|
|
// A huge minimum gap merges them.
|
|
let r = offline::non_silent_ranges(&[x], -40.0, 30_000, 0, 0);
|
|
assert_eq!(r.len(), 1);
|
|
assert!(offline::non_silent_ranges(&[vec![0.0; 100]], -40.0, 10, 5, 5).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn silence_scan_in_blocks_finds_what_one_pass_finds() {
|
|
let mut x = vec![0.0f32; 48_000];
|
|
for (i, v) in sine(500.0, 0.5, 48_000.0, 4800).into_iter().enumerate() {
|
|
x[10_000 + i] = v;
|
|
x[30_000 + i] = v;
|
|
}
|
|
let whole: Vec<(u64, u64)> =
|
|
offline::non_silent_ranges(std::slice::from_ref(&x), -40.0, 480, 100, 200).into_iter().map(|(a, b)| (a as u64, b as u64)).collect();
|
|
let mut blocks = offline::SilenceScan::new(-40.0, 480);
|
|
x.chunks(1000).for_each(|b| blocks.push(&[b.to_vec()]));
|
|
assert_eq!(blocks.finish(100, 200), whole);
|
|
// Skipping the silence between the bursts counts its length like scanning it.
|
|
let mut skipped = offline::SilenceScan::new(-40.0, 480);
|
|
skipped.push(&[x[..20_000].to_vec()]);
|
|
skipped.skip(10_000);
|
|
skipped.push(&[x[30_000..].to_vec()]);
|
|
assert_eq!(skipped.finish(100, 200), whole);
|
|
}
|
|
|
|
#[test]
|
|
fn transients_detected_near_onsets() {
|
|
let sr = 48_000.0;
|
|
let mut x = vec![0.0f32; 48_000 * 2];
|
|
let onsets = [9_600usize, 33_600, 60_000, 81_000];
|
|
for &o in &onsets {
|
|
for i in 0..15000 {
|
|
x[o + i] += 0.8 * (-(i as f32) / 1500.0).exp() * (TAU * 180.0 * i as f32 / sr).sin();
|
|
}
|
|
}
|
|
let t = offline::detect_transients(&[x], sr, 0.5);
|
|
assert_eq!(t.len(), onsets.len(), "{t:?}");
|
|
for (a, b) in t.iter().zip(onsets) {
|
|
assert!((*a as i64 - b as i64).abs() < 480, "{a} vs {b}");
|
|
}
|
|
assert!(offline::detect_transients(&[vec![0.0; 48_000]], sr, 1.0).is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn signal_generator_offline() {
|
|
for w in Waveform::ALL {
|
|
let out = offline::signal_generator(w, 1000.0, -6.0, 48_000.0, 4800, 2);
|
|
assert_eq!(out.len(), 2);
|
|
assert_eq!(out[0].len(), 4800);
|
|
assert!(peak(&out[0]) <= db_to_gain(-6.0) * 1.1 + 1e-3, "{w:?} {}", peak(&out[0]));
|
|
assert!(peak(&out[0]) > 0.05, "{w:?}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn apply_plugin_compensates_latency() {
|
|
let sr = 48_000.0;
|
|
let mut p = create("pitch_shifter").unwrap();
|
|
let mut x = vec![vec![0.0f32; 10_000]];
|
|
x[0][2000] = 1.0;
|
|
offline::apply_plugin(&mut x, p.as_mut(), sr);
|
|
let pos = x[0].iter().enumerate().max_by(|a, b| a.1.abs().total_cmp(&b.1.abs())).unwrap().0;
|
|
assert_eq!(pos, 2000);
|
|
|
|
let mut r = create("room_reverb").unwrap();
|
|
let out = offline::apply_plugin_with_tail(&[vec![1.0; 100]], r.as_mut(), sr);
|
|
assert!(out[0].len() > 100 + 48_000);
|
|
}
|
|
|
|
#[test]
|
|
fn spectrum_edge_cases() {
|
|
assert!(magnitude_db(&[], 1).is_empty());
|
|
let m = magnitude_db(&[f32::NAN; 64], 64);
|
|
assert!(m.iter().all(|v| v.is_finite()));
|
|
}
|