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:
1 parent
340ff13342
commit
d536e3127a
9 files changed
+158
-2
No files matched your search
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 258 KiB After Width: | Height: | Size: 266 KiB |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.,
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
Reference in new issue
Block a user