Files
bass-foundry/plugins/weld/core/tests/dsp.rs
T
nvrl 9b3ddeaf56
Check and Windows preview / check (anvil) (push) Successful in 2m0s
Check and Windows preview / check (crucible) (push) Successful in 1m29s
Check and Windows preview / check (lathe) (push) Successful in 1m27s
Check and Windows preview / check (patina) (push) Successful in 1m53s
Check and Windows preview / check (rivet) (push) Successful in 1m29s
Check and Windows preview / check (temper) (push) Successful in 1m29s
Check and Windows preview / check (weld) (push) Successful in 2m38s
Check and Windows preview / previews (push) Successful in 1m28s
Independent plugin release / plan (push) Successful in 8s
Check and Windows preview / windows (push) Successful in 3m14s
Independent plugin release / build (push) Successful in 3m50s
Independent plugin release / publish (push) Successful in 9s
feat(weld): phase alignment for layered bass, release 0.1.0
Weld hears the other bass layer through the sidechain and fits polarity
plus up to four RBJ allpass sections to the measured cross-spectrum, so
the layers sum instead of cancelling. No latency; the layer on its own
keeps its sound.

- Worker thread: Welch cross-spectrum and coherence, Nelder-Mead fit with
  1/f weighting, a per-band loss penalty and model selection by share of
  the achievable gain; GCC-PHAT lag detection flags a late layer.
- Audio thread: allocation-free chain with a 40 ms warm-up and 40 ms
  crossfade per new fit; mono blocks go to the worker through a lock-free
  queue.
- Follow / Learn / Hold; the fit is persisted and restored without a
  crossfade.
- Editor: per-band loss and recovery against a perfect alignment, phase
  before and after, recovered dB, time offset and status.
- Tests for flat magnitude, crossfade, fit targets, Follow hysteresis,
  Learn, silence, realtime allocation and the persisted fit in the host.
2026-11-06 08:31:41 +01:00

339 lines
10 KiB
Rust

use weld_core::{Engine, Listen, Settings};
fn signal(n: usize, hz: f32) -> f32 {
(std::f32::consts::TAU * hz * n as f32 / 48000.).sin() * 0.5
}
#[test]
fn layer_output_is_the_main_input_with_zero_latency() {
let mut engine = Engine::new(48000., 2).unwrap();
engine.set_settings(Settings::default()).unwrap();
assert_eq!(engine.latency_samples(), 0);
for n in 0..4800 {
let x = [signal(n, 110.), -signal(n, 220.)];
let out = engine.process_sample(x, [signal(n, 330.), 0.25]);
assert_eq!(out, x);
}
}
#[test]
fn sum_modes_add_the_sidechain() {
for listen in [Listen::Sum, Listen::SumDry] {
let mut engine = Engine::new(48000., 2).unwrap();
engine
.set_settings(Settings {
listen,
..Settings::default()
})
.unwrap();
for n in 0..480 {
let x = [signal(n, 110.), signal(n, 120.)];
let s = [signal(n, 330.), -signal(n, 90.)];
let out = engine.process_sample(x, s);
for c in 0..2 {
assert!((out[c] - (x[c] + s[c])).abs() < 1e-6);
}
}
}
}
#[test]
fn mono_bypass_and_output_gain() {
let mut engine = Engine::new(44100., 1).unwrap();
engine
.set_settings(Settings {
output: -6.,
..Settings::default()
})
.unwrap();
let out = engine.process_sample([0.5, 0.], [0., 0.]);
assert!((out[0] - 0.5 * 10f32.powf(-6. / 20.)).abs() < 1e-6);
assert_eq!(out[1], 0.);
engine
.set_settings(Settings {
bypass: true,
..Settings::default()
})
.unwrap();
assert_eq!(engine.process_sample([0.3, 0.], [0.9, 0.]), [0.3, 0.]);
}
#[test]
fn analyzer_reports_sidechain_presence_and_level() {
let mut engine = Engine::new(48000., 2).unwrap();
engine.set_settings(Settings::default()).unwrap();
for n in 0..4800 {
engine.process_sample([signal(n, 100.); 2], [0.; 2]);
}
let frame = engine.take_analyzer();
assert!(!frame.side_seen);
assert!(frame.side_db < -100.);
assert!((frame.main_db - (-9.03)).abs() < 0.1, "{}", frame.main_db);
for n in 0..4800 {
engine.process_sample([0.; 2], [signal(n, 100.); 2]);
}
let frame = engine.take_analyzer();
assert!(frame.side_seen);
assert!((frame.side_db - (-9.03)).abs() < 0.1);
assert!((frame.side_peak_db - (-6.02)).abs() < 0.1);
}
#[test]
fn invalid_settings_and_rates_are_rejected() {
assert!(Engine::new(0., 2).is_err());
assert!(Engine::new(48000., 3).is_err());
let mut engine = Engine::new(48000., 2).unwrap();
assert!(engine
.set_settings(Settings {
output: f32::NAN,
..Settings::default()
})
.is_err());
let json = serde_json::to_string(&Settings::default()).unwrap();
assert_eq!(
serde_json::from_str::<Settings>(&json).unwrap(),
Settings::default()
);
}
use weld_core::{Analyzer, Chain, Fit, Mode, Section, Status};
fn random_fit(seed: u64) -> Fit {
let mut s = seed;
let mut rand = move || {
s = s
.wrapping_mul(6364136223846793005)
.wrapping_add(1442695040888963407);
(s >> 11) as f32 / (1u64 << 53) as f32
};
let mut fit = Fit {
flip: rand() > 0.5,
count: 1 + (rand() * 3.99) as usize,
..Fit::NONE
};
for section in &mut fit.sections {
*section = Section {
fc: 30. * 600f32.powf(rand()),
q: 0.2 * 20f32.powf(rand()),
};
}
fit
}
#[test]
fn every_chain_is_flat_in_magnitude() {
for seed in 1..40 {
let fit = random_fit(seed);
for i in 0..200 {
let f = 20. * 1000f32.powf(i as f32 / 199.);
let (re, im) = fit.response(f, 48000.);
let db = 10. * (re * re + im * im).log10();
assert!(db.abs() < 0.01, "{fit:?} at {f} Hz: {db}");
}
}
}
#[test]
fn the_running_chain_matches_its_response() {
let fit = random_fit(3);
for f in [50f32, 333., 2100., 9000.] {
let mut chain = Chain::new(fit, 48000.);
let (mut i_sum, mut q_sum, mut energy) = (0f64, 0f64, 0f64);
let n = 96000;
for k in 0..n {
let t = k as f64 / 48000.;
let w = std::f64::consts::TAU * f as f64 * t;
let y = chain.process(0, w.cos() as f32) as f64;
if k > n / 2 {
i_sum += y * w.cos();
q_sum += -y * w.sin();
energy += y * y;
}
}
let m = (n / 2 - 1) as f64;
let (re, im) = fit.response(f, 48000.);
let measured = (2. * i_sum / m, 2. * q_sum / m);
assert!(
(measured.0 - re as f64).abs() < 0.01,
"{f}: {measured:?} vs {re},{im}"
);
assert!(
(measured.1 - im as f64).abs() < 0.01,
"{f}: {measured:?} vs {re},{im}"
);
assert!((2. * energy / m - 1.).abs() < 0.01);
}
}
#[test]
fn the_impulse_response_starts_at_sample_zero() {
let mut chain = Chain::new(random_fit(5), 48000.);
let first = chain.process(0, 1.);
assert!(first.abs() > 0.01, "{first}");
}
#[test]
fn a_new_fit_crossfades_without_clicks() {
let mut engine = Engine::new(48000., 2).unwrap();
engine.set_settings(Settings::default()).unwrap();
let link = engine.link();
let x = |n: usize| signal(n, 97.) + 0.3 * signal(n, 1210.);
let max_input_step = (1..48000)
.map(|n| (x(n) - x(n - 1)).abs())
.fold(0f32, f32::max);
let mut previous = [0f32; 2];
let mut largest = 0f32;
for n in 0..48000 {
if n == 4800 {
link.push_fit(Fit {
flip: true,
count: 2,
sections: [
Section { fc: 300., q: 0.7 },
Section { fc: 2000., q: 2. },
Section::default(),
Section::default(),
],
});
}
let out = engine.process_sample([x(n), x(n)], [0.; 2]);
if n > 0 {
largest = largest.max((out[0] - previous[0]).abs());
}
previous = out;
}
assert!(
largest <= 1.5 * max_input_step,
"{largest} vs {max_input_step}"
);
assert!(engine.fit().flip);
assert_eq!(engine.fit().count, 2);
}
#[test]
fn sum_uses_the_aligned_layer_and_sum_dry_the_unprocessed_one() {
for listen in [Listen::Sum, Listen::SumDry] {
let mut engine = Engine::new(48000., 1).unwrap();
engine
.set_settings(Settings {
listen,
..Settings::default()
})
.unwrap();
engine.set_fit(Fit {
flip: true,
..Fit::NONE
});
let out = engine.process_sample([0.5, 0.], [0.2, 0.]);
let expected = if listen == Listen::Sum { -0.3 } else { 0.7 };
assert!((out[0] - expected).abs() < 1e-6, "{listen:?} {out:?}");
}
}
#[test]
fn blocks_reach_the_worker_and_drops_are_counted() {
let mut engine = Engine::new(48000., 2).unwrap();
engine.set_settings(Settings::default()).unwrap();
let link = engine.link();
assert!(link.pop_settings().is_some());
for n in 0..256 * 70 {
engine.process_sample([signal(n, 100.); 2], [signal(n, 100.); 2]);
}
assert_eq!(link.dropped(), 6);
let block = link.pop_block().unwrap();
assert!((block.a[10] - signal(10, 100.)).abs() < 1e-6);
assert_eq!(block.a, block.b);
}
#[test]
fn silence_holds_the_fit_and_produces_no_nan() {
let flipped = Fit {
flip: true,
..Fit::NONE
};
let mut analyzer = Analyzer::new(48000., flipped);
analyzer.set_settings(Settings::default());
let zeros = [0f32; 256];
let mut last = None;
for _ in 0..400 {
analyzer.push(&zeros, &zeros);
if let Some(update) = analyzer.poll() {
assert!(update.fit.is_none());
last = Some(update.report);
}
}
let report = last.unwrap();
assert_eq!(report.status, Status::NoSidechain);
assert_eq!(report.fit, flipped);
assert!(report.cost_db.is_finite() && report.recovered_db.is_finite());
assert!(report
.bands
.iter()
.all(|b| b.before.is_finite() && b.after.is_finite()));
}
#[test]
fn follow_mode_finds_the_flip_and_then_holds_still() {
let mut analyzer = Analyzer::new(48000., Fit::NONE);
analyzer.set_settings(Settings {
mode: Mode::Follow,
..Settings::default()
});
let a: Vec<f32> = (0..48000 * 6)
.map(|n| signal(n, 87.3) + 0.3 * signal(n, 261.9) + 0.1 * signal(n, 611.))
.collect();
let b: Vec<f32> = a.iter().map(|v| -0.7 * v).collect();
let mut accepted = Vec::new();
let mut last = None;
for (x, y) in a.chunks(256).zip(b.chunks(256)) {
analyzer.push(x, y);
if let Some(update) = analyzer.poll() {
if let Some(fit) = update.fit {
accepted.push(fit);
}
last = Some(update.report);
}
}
assert_eq!(accepted.len(), 1, "{accepted:?}");
assert!(accepted[0].flip);
let report = last.unwrap();
assert_eq!(report.status, Status::Aligned);
assert!(report.recovered_db > 10., "{}", report.recovered_db);
assert!((report.recovered_db - report.cost_db).abs() < 0.2);
}
#[test]
fn learn_measures_and_applies_on_exit() {
let mut analyzer = Analyzer::new(48000., Fit::NONE);
let learn = Settings {
mode: Mode::Learn,
..Settings::default()
};
analyzer.set_settings(learn);
let a: Vec<f32> = (0..48000 * 2)
.map(|n| signal(n, 87.3) + 0.3 * signal(n, 261.9))
.collect();
let b: Vec<f32> = a.iter().map(|v| -v).collect();
for (x, y) in a.chunks(256).zip(b.chunks(256)) {
analyzer.push(x, y);
let _ = analyzer.poll();
}
analyzer.set_settings(Settings {
mode: Mode::Hold,
..learn
});
let update = analyzer.poll().expect("leaving Learn reports at once");
assert!(analyzer.current().flip);
// Learn may already have applied the flip while measuring.
assert!(update.fit.is_none_or(|f| f.flip));
// Hold never refits, even when the layers change.
let c: Vec<f32> = a.clone();
for (x, y) in a.chunks(256).zip(c.chunks(256)) {
analyzer.push(x, y);
if let Some(update) = analyzer.poll() {
assert!(update.fit.is_none());
}
}
assert!(analyzer.current().flip);
}