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
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.
339 lines
10 KiB
Rust
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);
|
|
}
|