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

+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;