diff --git a/docs/weld/CHANGELOG.md b/docs/weld/CHANGELOG.md index 5026d5d..90b0da9 100644 --- a/docs/weld/CHANGELOG.md +++ b/docs/weld/CHANGELOG.md @@ -1,5 +1,12 @@ # Weld changelog +## Unreleased + +- Show the **delay spread**: how much the alignment's group delay changes + between 500 Hz and 4 kHz, where listeners hear it best. Above 0.64 ms (the + threshold Liski et al. 2021 measured on clicks) a note suggests comparing + attacks in SUM and SUM DRY. Display only; the sound does not change. + ## 0.2.0 - Change the CLAP ID to `io.narl.bass-foundry.weld`. Hosts see this as a new diff --git a/docs/weld/README.md b/docs/weld/README.md index 5699294..43fc44c 100644 --- a/docs/weld/README.md +++ b/docs/weld/README.md @@ -50,6 +50,11 @@ plugins. Keep the font license with redistributed packages. - The lower plot shows the phase of this layer against the other one, before (grey) and after (pink), and Weld's own phase curve (blue). After alignment the pink line sits near 0°. + - **Delay spread** under the sections is how much Weld's delay changes + between 500 Hz and 4 kHz. On clicks, listeners hear about 0.6 ms of change + there (Liski et al. 2021); sustained bass hides much more, and common fits + reach 0.8–1 ms. Above that threshold Weld adds a note: on a plucky layer, + compare the attacks in SUM and SUM DRY. Fewer sections usually spread less. 3. Compare by ear: mute the other layer's own route to the master, set **Listen** to **SUM**, then to **SUM DRY**. Set Listen back to **LAYER** and unmute the route for the mix. diff --git a/docs/weld/assets/editor.png b/docs/weld/assets/editor.png index f571c61..c19e694 100644 Binary files a/docs/weld/assets/editor.png and b/docs/weld/assets/editor.png differ diff --git a/plugins/weld/core/src/analyzer.rs b/plugins/weld/core/src/analyzer.rs index 6922157..939b765 100644 --- a/plugins/weld/core/src/analyzer.rs +++ b/plugins/weld/core/src/analyzer.rs @@ -311,6 +311,7 @@ impl Analyzer { frames: self.frames, low: self.range().0, high: self.range().1, + smear_ms: fit.smear_ms(self.rate), ..Report::default() }; // Display bands over all bins. diff --git a/plugins/weld/core/src/chain.rs b/plugins/weld/core/src/chain.rs index 472b22c..5f12138 100644 --- a/plugins/weld/core/src/chain.rs +++ b/plugins/weld/core/src/chain.rs @@ -7,6 +7,16 @@ pub const MIN_FC: f32 = 30.; pub const MIN_Q: f32 = 0.2; pub const MAX_Q: f32 = 4.; +/// The range where listeners hear group-delay changes best (Liski, Mäkivirta and +/// Välimäki 2021, "Audibility of Group-Delay Equalization"). +pub const SMEAR_LOW: f32 = 500.; +pub const SMEAR_HIGH: f32 = 4000.; +/// Smallest mean threshold that study found for a positive group-delay peak in that +/// range, with a pink impulse over headphones. An allpass only adds positive delay. +/// Sustained bass hides much more; this marks where sharp attacks may soften. +pub const SMEAR_AUDIBLE_MS: f32 = 0.64; +const SMEAR_POINTS: usize = 64; + #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub struct Section { /// Centre frequency in Hz, where the section's phase passes −180°. @@ -67,6 +77,33 @@ impl Fit { } (re, im) } + /// Group delay at `f` Hz in seconds. Polarity adds none. + pub fn group_delay(&self, f: f32, rate: f32) -> f32 { + let w = std::f32::consts::TAU * f / rate; + let (c1, s1, c2, s2) = (w.cos(), w.sin(), (2. * w).cos(), (2. * w).sin()); + let samples: f32 = self + .active() + .iter() + .map(|s| Coefs::allpass(s.fc, s.q, rate).group_delay(c1, s1, c2, s2)) + .sum(); + samples / rate + } + /// How much the group delay varies between `SMEAR_LOW` and `SMEAR_HIGH`, in ms: + /// the largest delay minus the smallest. A constant delay counts as zero. + pub fn smear_ms(&self, rate: f32) -> f32 { + if self.active().is_empty() { + return 0.; + } + let high = SMEAR_HIGH.min(max_fc(rate)); + let (mut lo, mut hi) = (f32::MAX, f32::MIN); + for i in 0..SMEAR_POINTS { + let f = SMEAR_LOW * (high / SMEAR_LOW).powf(i as f32 / (SMEAR_POINTS - 1) as f32); + let d = self.group_delay(f, rate); + lo = lo.min(d); + hi = hi.max(d); + } + (hi - lo).max(0.) * 1000. + } } pub fn max_fc(rate: f32) -> f32 { @@ -104,6 +141,16 @@ impl Coefs { // times z⁻² = c2 − j s2 (qr * c2 + qi * s2, qi * c2 - qr * s2) } + /// Group delay in samples, given cos/sin of ω and 2ω. With H = z⁻²·conj(D)/D the + /// phase is −2ω − 2·arg D, so the delay is 2 + 2·d(arg D)/dω. + #[inline] + pub fn group_delay(&self, c1: f32, s1: f32, c2: f32, s2: f32) -> f32 { + let x = 1. + self.a1 * c1 + self.a2 * c2; + let y = -(self.a1 * s1 + self.a2 * s2); + let dx = -(self.a1 * s1 + 2. * self.a2 * s2); + let dy = -(self.a1 * c1 + 2. * self.a2 * c2); + 2. + 2. * (x * dy - y * dx) / (x * x + y * y) + } } /// Transposed direct form II state, in f64 so sections near 30 Hz stay quiet. diff --git a/plugins/weld/core/src/lib.rs b/plugins/weld/core/src/lib.rs index 1ab6093..81a2f24 100644 --- a/plugins/weld/core/src/lib.rs +++ b/plugins/weld/core/src/lib.rs @@ -9,7 +9,7 @@ mod report; mod settings; mod worker; pub use analyzer::{Analyzer, Update}; -pub use chain::{Chain, Fit, Section, MAX_SECTIONS}; +pub use chain::{Chain, Fit, Section, MAX_SECTIONS, SMEAR_AUDIBLE_MS, SMEAR_HIGH, SMEAR_LOW}; pub use report::{ band_edge, curve_frequency, Band, CurvePoint, Report, Status, BANDS, CURVE, DISPLAY_HIGH, DISPLAY_LOW, diff --git a/plugins/weld/core/src/report.rs b/plugins/weld/core/src/report.rs index b9a57cf..cceef75 100644 --- a/plugins/weld/core/src/report.rs +++ b/plugins/weld/core/src/report.rs @@ -83,6 +83,9 @@ pub struct Report { pub lag_ms: f32, /// How clear the time offset is, 0..1. pub lag_strength: f32, + /// How much the alignment's group delay varies between `SMEAR_LOW` and `SMEAR_HIGH`, + /// ms. Above `SMEAR_AUDIBLE_MS`, sharp attacks in this layer may soften. + pub smear_ms: f32, /// Measured frames in the current average. pub frames: u32, pub low: f32, @@ -100,6 +103,7 @@ impl Default for Report { recovered_db: 0., lag_ms: 0., lag_strength: 0., + smear_ms: 0., frames: 0, low: 60., high: 6000., diff --git a/plugins/weld/core/tests/group_delay.rs b/plugins/weld/core/tests/group_delay.rs new file mode 100644 index 0000000..d939172 --- /dev/null +++ b/plugins/weld/core/tests/group_delay.rs @@ -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}"); +} diff --git a/plugins/weld/ui/src/lib.rs b/plugins/weld/ui/src/lib.rs index e943c5a..dc0b27d 100644 --- a/plugins/weld/ui/src/lib.rs +++ b/plugins/weld/ui/src/lib.rs @@ -3,7 +3,7 @@ use common_ui::*; use egui::{self, Color32, FontId, Pos2, Rect, RichText, Sense, Stroke, Vec2}; use weld_core::{ band_edge, curve_frequency, AnalyzerFrame, Listen, Mode, Report, Settings, Status, CURVE, - DISPLAY_HIGH, DISPLAY_LOW, MAX_SECTIONS, + DISPLAY_HIGH, DISPLAY_LOW, MAX_SECTIONS, SMEAR_AUDIBLE_MS, SMEAR_HIGH, SMEAR_LOW, }; pub use common_ui::style; @@ -218,6 +218,28 @@ fn summary(ui: &mut egui::Ui, settings: &Settings, state: &mut State) { .color(STEEL), ); } + if fit.count > 0 { + // Typical fits vary 0.8–1 ms here, above the click threshold, so this stays a + // neutral note rather than a warning: sustained bass hides much more. + let r = ui.label( + RichText::new(format!( + "Delay spread {:.2} ms, {}–{}", + report.smear_ms, + hz(SMEAR_LOW), + hz(SMEAR_HIGH) + )) + .size(12.) + .color(MUTED), + ); + if report.smear_ms > SMEAR_AUDIBLE_MS { + ui.label( + RichText::new("Clicks may soften: compare SUM, SUM DRY") + .size(12.) + .color(DIM), + ); + } + hint(&r, state, "How much the alignment's delay changes between 500 Hz and 4 kHz. On clicks, listeners hear changes from about 0.6 ms; sustained bass hides much more. On a plucky layer, compare SUM with SUM DRY on the attacks. Fewer sections usually vary less."); + } ui.add_space(10.); let (text, color) = status_text(&report, &frame, settings.mode); ui.label(RichText::new(text).size(13.).color(color));