feat(weld): show the alignment's delay spread

Weld's allpass sections add group delay between 500 Hz and 4 kHz, where
listeners hear it best. Liski et al. (2021) measured a threshold of about
0.64 ms for a positive group-delay peak on clicks. Weld now reports how much
its delay varies over that range and, above the threshold, suggests
comparing attacks in SUM and SUM DRY.

Common fits from Weld's own tests spread 0.82-1.03 ms, so the line is a
neutral note, not a warning: sustained bass hides much more than a click.

- Fit::group_delay (analytic, per section) and Fit::smear_ms in weld-core.
- Report::smear_ms, filled by the analyzer from the applied fit.
- Tests against the numeric phase slope and the analog 4Q/w0 peak.
- Display only; the audio path does not change.
This commit is contained in:
nvrl committed 2026-11-06 13:17:29 +01:00
1 parent 340ff13342
commit d536e3127a
9 files changed
+158 -2

No files matched your search

+70
View File
@@ -0,0 +1,70 @@
use weld_core::{Fit, Section, SMEAR_AUDIBLE_MS};
const RATE: f32 = 48000.;
fn fit(sections: &[(f32, f32)], flip: bool) -> Fit {
let mut fit = Fit {
flip,
count: sections.len(),
..Fit::NONE
};
for (s, &(fc, q)) in fit.sections.iter_mut().zip(sections) {
*s = Section { fc, q };
}
fit
}
/// Phase of the fit at `f`, from its complex response.
fn phase(fit: &Fit, f: f64) -> f64 {
let (re, im) = fit.response(f as f32, RATE);
(im as f64).atan2(re as f64)
}
#[test]
fn group_delay_matches_the_phase_slope() {
let fit = fit(&[(180., 0.9), (1200., 0.6), (3500., 2.)], false);
for f in [80., 250., 700., 1500., 3000., 8000.] {
let df = 0.5;
let mut dphi = phase(&fit, f + df) - phase(&fit, f - df);
while dphi > std::f64::consts::PI {
dphi -= std::f64::consts::TAU;
}
while dphi < -std::f64::consts::PI {
dphi += std::f64::consts::TAU;
}
let numeric = -dphi / (std::f64::consts::TAU * 2. * df);
let analytic = fit.group_delay(f as f32, RATE) as f64;
assert!(
(numeric - analytic).abs() < 2e-6 + 0.01 * numeric.abs(),
"{f} Hz: numeric {numeric}, analytic {analytic}"
);
}
}
#[test]
fn one_section_peaks_near_the_analog_value() {
// A second-order analog allpass delays 4·Q/ω₀ at its centre.
let (fc, q) = (1000., 0.7);
let delay = fit(&[(fc, q)], false).group_delay(fc, RATE);
let analog = 4. * q / (std::f32::consts::TAU * fc);
assert!((delay - analog).abs() < 0.05 * analog, "{delay} vs {analog}");
}
#[test]
fn no_sections_and_polarity_do_not_smear() {
assert_eq!(Fit::NONE.smear_ms(RATE), 0.);
assert_eq!(fit(&[], true).smear_ms(RATE), 0.);
let one = fit(&[(1000., 0.7)], false);
assert_eq!(one.smear_ms(RATE), fit(&[(1000., 0.7)], true).smear_ms(RATE));
}
#[test]
fn smear_separates_gentle_and_sharp_fits() {
// Above 500 Hz a low section has only its tail left: well under the threshold.
let low = fit(&[(80., 0.7)], false).smear_ms(RATE);
assert!(low < 0.25 * SMEAR_AUDIBLE_MS, "{low}");
// A high-Q section at 1 kHz puts a 2.5 ms peak inside the range.
let sharp = fit(&[(1000., 4.)], false).smear_ms(RATE);
assert!(sharp > SMEAR_AUDIBLE_MS, "{sharp}");
assert!(sharp > 2. && sharp < 2.7, "{sharp}");
}