feat(weld): phase alignment for layered bass, release 0.1.0
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.
This commit is contained in:
nvrl committed 2026-11-06 08:31:41 +01:00
1 parent 0a0917ac56
commit 9b3ddeaf56
26 files changed
+2898 -144

No files matched your search

+68
View File
@@ -1074,12 +1074,39 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bf50223579dc7cdcfb3bfcacf7069ff68243f8c363f62ffa99cf000a6b9c451"
[[package]]
name = "num-complex"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441"
[[package]]
name = "num-integer"
version = "0.1.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b"
dependencies = [
"num-traits",
]
[[package]]
name = "num-traits"
version = "0.2.19"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841"
dependencies = [
"autocfg",
]
[[package]]
name = "num_enum"
version = "0.7.6"
@@ -1454,6 +1481,15 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "primal-check"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc0d895b311e3af9902528fbb8f928688abbd95872819320517cc24ca6b2bd08"
dependencies = [
"num-integer",
]
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
@@ -1547,6 +1583,20 @@ dependencies = [
"semver",
]
[[package]]
name = "rustfft"
version = "6.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "21db5f9893e91f41798c88680037dba611ca6674703c1a18601b01a72c8adb89"
dependencies = [
"num-complex",
"num-integer",
"num-traits",
"primal-check",
"strength_reduce",
"transpose",
]
[[package]]
name = "rustix"
version = "0.38.44"
@@ -1695,6 +1745,12 @@ dependencies = [
"serde",
]
[[package]]
name = "strength_reduce"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe895eb47f22e2ddd4dabc02bce419d2e643c8e3b585c78158b349195bc24d82"
[[package]]
name = "syn"
version = "1.0.109"
@@ -1864,6 +1920,16 @@ version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
[[package]]
name = "transpose"
version = "0.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ad61aed86bc3faea4300c7aee358b4c6d0c8d6ccc36524c96e4c92ccf26e77e"
dependencies = [
"num-integer",
"strength_reduce",
]
[[package]]
name = "ttf-parser"
version = "0.25.1"
@@ -2005,6 +2071,8 @@ dependencies = [
name = "weld-core"
version = "0.1.0"
dependencies = [
"crossbeam-queue",
"rustfft",
"serde",
]
+40 -5
View File
@@ -1,20 +1,23 @@
//! CLAP audio effect with a sidechain input. All DSP state is owned by the processing thread.
//! CLAP audio effect with a sidechain input. The audio thread owns the engine; a worker
//! thread, started in `initialize`, measures and fits.
mod params;
use crossbeam_queue::ArrayQueue;
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::WeldParams;
use params::{WeldParams, GESTURES};
use std::sync::Arc;
use weld_core::{AnalyzerFrame, Engine};
use weld_core::{AnalyzerFrame, Engine, Report, Worker};
struct EditorData {
ui: weld_ui::State,
gestures: [bool; 1],
gestures: [bool; GESTURES],
}
pub struct Weld {
params: Arc<WeldParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
reports: Arc<ArrayQueue<Report>>,
engine: Option<Engine>,
worker: Option<Worker>,
publish_count: usize,
publish_interval: usize,
}
@@ -23,7 +26,9 @@ impl Default for Weld {
Self {
params: Arc::new(WeldParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
reports: Arc::new(ArrayQueue::new(2)),
engine: None,
worker: None,
publish_count: 0,
publish_interval: 1600,
}
@@ -58,17 +63,21 @@ impl Plugin for Weld {
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
let params = self.params.clone();
let analyzer = self.analyzer.clone();
let reports = self.reports.clone();
create_egui_editor(
params.editor.clone(),
EditorData {
ui: weld_ui::State::default(),
gestures: [false; 1],
gestures: [false; GESTURES],
},
|ctx, _| weld_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
while let Some(report) = reports.pop() {
data.ui.accept_report(report);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("weld-editor")
@@ -106,11 +115,37 @@ impl Plugin for Weld {
if engine.set_settings(self.params.settings()).is_err() {
return false;
}
// A restored project starts with its saved alignment, without a crossfade.
let fit = self.params.stored_fit().sanitized(config.sample_rate);
engine.set_fit(fit);
if let Some(mut old) = self.worker.take() {
old.stop();
}
let store = self.params.fit.clone();
let Ok(worker) = Worker::spawn(
engine.link(),
config.sample_rate,
fit,
self.reports.clone(),
move |fit| {
if let Ok(mut stored) = store.write() {
*stored = fit;
}
},
) else {
return false;
};
self.worker = Some(worker);
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn deactivate(&mut self) {
if let Some(mut worker) = self.worker.take() {
worker.stop();
}
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
+139 -16
View File
@@ -1,8 +1,17 @@
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
use weld_core::{Listen, Settings};
use std::sync::{Arc, RwLock};
use weld_core::{Fit, Listen, Mode, Settings, MAX_SECTIONS};
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum ModeParam {
#[id = "follow"]
Follow,
#[id = "learn"]
Learn,
#[id = "hold"]
Hold,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum ListenParam {
#[id = "layer"]
@@ -14,12 +23,27 @@ pub enum ListenParam {
SumDry,
}
/// Continuous controls, in the order of the gesture slots.
const FLOATS: usize = 1;
const FLOATS: usize = 5;
#[derive(Params)]
pub struct WeldParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
/// The applied alignment, so a project reopens with it.
#[persist = "weld-fit"]
pub fit: Arc<RwLock<Fit>>,
#[id = "mode"]
pub mode: EnumParam<ModeParam>,
#[id = "response"]
pub response: FloatParam,
#[id = "low"]
pub low: FloatParam,
#[id = "high"]
pub high: FloatParam,
#[id = "sections"]
pub sections: IntParam,
#[id = "gate"]
pub gate: FloatParam,
#[id = "listen"]
pub listen: EnumParam<ListenParam>,
#[id = "output"]
@@ -27,11 +51,68 @@ pub struct WeldParams {
#[id = "bypass"]
pub bypass: BoolParam,
}
fn hz(value: f32) -> String {
if value >= 1000. {
format!("{:.2} kHz", value / 1000.)
} else {
format!("{value:.0} Hz")
}
}
impl Default for WeldParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(weld_ui::WIDTH, weld_ui::HEIGHT),
fit: Arc::new(RwLock::new(Fit::NONE)),
mode: EnumParam::new("Mode", ModeParam::Follow),
response: FloatParam::new(
"Response",
d.response,
FloatRange::Skewed {
min: 0.3,
max: 5.,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" s")
.with_value_to_string(Arc::new(|v| format!("{v:.2}"))),
low: FloatParam::new(
"Low",
d.low,
FloatRange::Skewed {
min: 30.,
max: 400.,
factor: FloatRange::skew_factor(-1.5),
},
)
.with_value_to_string(Arc::new(hz)),
high: FloatParam::new(
"High",
d.high,
FloatRange::Skewed {
min: 1000.,
max: 12000.,
factor: FloatRange::skew_factor(-1.),
},
)
.with_value_to_string(Arc::new(hz)),
sections: IntParam::new(
"Sections",
d.sections as i32,
IntRange::Linear {
min: 1,
max: MAX_SECTIONS as i32,
},
),
gate: FloatParam::new(
"Gate",
d.gate,
FloatRange::Linear {
min: -70.,
max: -30.,
},
)
.with_unit(" dB"),
listen: EnumParam::new("Listen", ListenParam::Layer),
output: FloatParam::new(
"Output",
@@ -46,9 +127,33 @@ impl Default for WeldParams {
}
}
}
fn mode_param(mode: Mode) -> ModeParam {
match mode {
Mode::Follow => ModeParam::Follow,
Mode::Learn => ModeParam::Learn,
Mode::Hold => ModeParam::Hold,
}
}
fn listen_param(listen: Listen) -> ListenParam {
match listen {
Listen::Layer => ListenParam::Layer,
Listen::Sum => ListenParam::Sum,
Listen::SumDry => ListenParam::SumDry,
}
}
impl WeldParams {
pub fn settings(&self) -> Settings {
Settings {
mode: match self.mode.value() {
ModeParam::Follow => Mode::Follow,
ModeParam::Learn => Mode::Learn,
ModeParam::Hold => Mode::Hold,
},
response: self.response.value(),
low: self.low.value(),
high: self.high.value(),
sections: self.sections.value().clamp(1, MAX_SECTIONS as i32) as usize,
gate: self.gate.value(),
listen: match self.listen.value() {
ListenParam::Layer => Listen::Layer,
ListenParam::Sum => Listen::Sum,
@@ -58,8 +163,17 @@ impl WeldParams {
bypass: self.bypass.value(),
}
}
pub fn stored_fit(&self) -> Fit {
self.fit.read().map(|f| *f).unwrap_or(Fit::NONE)
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[&self.output]
[
&self.response,
&self.low,
&self.high,
&self.gate,
&self.output,
]
}
/// Pushes every changed editor value to the host. Continuous controls hold one gesture
/// while the pointer is down; switches are set in a single gesture.
@@ -71,7 +185,13 @@ impl WeldParams {
dragging: bool,
open: &mut [bool; FLOATS],
) {
let values = [(old.output, new.output)];
let values = [
(old.response, new.response),
(old.low, new.low),
(old.high, new.high),
(old.gate, new.gate),
(old.output, new.output),
];
for (i, (p, (a, b))) in self.floats().into_iter().zip(values).enumerate() {
if a != b {
if !open[i] {
@@ -85,20 +205,23 @@ impl WeldParams {
open[i] = false;
}
}
fn switch<P: Param>(setter: &ParamSetter, p: &P, v: P::Plain) {
setter.begin_set_parameter(p);
setter.set_parameter(p, v);
setter.end_set_parameter(p);
}
if new.bypass != old.bypass {
setter.begin_set_parameter(&self.bypass);
setter.set_parameter(&self.bypass, new.bypass);
setter.end_set_parameter(&self.bypass);
switch(setter, &self.bypass, new.bypass);
}
if new.mode != old.mode {
switch(setter, &self.mode, mode_param(new.mode));
}
if new.listen != old.listen {
let v = match new.listen {
Listen::Layer => ListenParam::Layer,
Listen::Sum => ListenParam::Sum,
Listen::SumDry => ListenParam::SumDry,
};
setter.begin_set_parameter(&self.listen);
setter.set_parameter(&self.listen, v);
setter.end_set_parameter(&self.listen);
switch(setter, &self.listen, listen_param(new.listen));
}
if new.sections != old.sections {
switch(setter, &self.sections, new.sections as i32);
}
}
}
pub const GESTURES: usize = FLOATS;
+27 -10
View File
@@ -111,6 +111,20 @@ fn sidechain_port_state_latency_and_audio_lifecycle() {
envelope
.params
.insert("listen".into(), ParamValue::String("sum".into()));
envelope
.params
.insert("mode".into(), ParamValue::String("hold".into()));
envelope
.params
.insert("sections".into(), ParamValue::I32(2));
// A saved alignment that flips polarity.
let flipped = weld_core::Fit {
flip: true,
..weld_core::Fit::NONE
};
envelope
.fields
.insert("weld-fit".into(), serde_json::to_string(&flipped).unwrap());
let encoded = serde_json::to_vec(&envelope).unwrap();
let mut framed = (encoded.len() as u64).to_le_bytes().to_vec();
framed.extend_from_slice(&encoded);
@@ -126,13 +140,16 @@ fn sidechain_port_state_latency_and_audio_lifecycle() {
serde_json::to_value(&restored.params).unwrap(),
serde_json::to_value(&envelope.params).unwrap()
);
// Listen = Sum: a sidechain equal to the main input doubles the output (+6 dB).
// A silent sidechain leaves the main input alone. CLAP hosts pass every port, so
// the test always passes both (nih_plug reads one port past a short count).
let saved: weld_core::Fit = serde_json::from_str(&restored.fields["weld-fit"]).unwrap();
assert_eq!(saved, flipped);
// Listen = Sum with the restored flip: a sidechain equal to the main input cancels
// it from the first sample on. A silent sidechain leaves the level alone. CLAP hosts
// pass every port, so the test always passes both (nih_plug reads one port past a
// short count).
for (rate, with_side, expected_db) in [
(44100., true, 6.02),
(48000., false, 0.),
(96000., true, 6.02),
(44100., true, None),
(48000., false, Some(0.)),
(96000., true, None),
] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
assert_eq!((*latency).get.unwrap()(plugin), 0);
@@ -213,10 +230,10 @@ fn sidechain_port_state_latency_and_audio_lifecycle() {
position += frames;
}
let ratio_db = 10. * (energy_out / energy_in).log10();
assert!(
(ratio_db - expected_db).abs() < 0.2,
"{rate} {with_side} {ratio_db}"
);
match expected_db {
Some(db) => assert!((ratio_db - db).abs() < 0.2, "{rate} {with_side} {ratio_db}"),
None => assert!(ratio_db < -60., "{rate} {with_side} {ratio_db}"),
}
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}