Files
bass-foundry/plugins/weld/plugin/src/params.rs
T
nvrl 9b3ddeaf56
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
feat(weld): phase alignment for layered bass, release 0.1.0
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.
2026-11-06 08:31:41 +01:00

228 lines
6.7 KiB
Rust

use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
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"]
Layer,
#[id = "sum"]
Sum,
#[id = "sum-dry"]
#[name = "Sum dry"]
SumDry,
}
/// Continuous controls, in the order of the gesture slots.
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"]
pub output: FloatParam,
#[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",
d.output,
FloatRange::Linear {
min: -12.,
max: 12.,
},
)
.with_unit(" dB"),
bypass: BoolParam::new("Bypass", false).make_bypass(),
}
}
}
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,
ListenParam::SumDry => Listen::SumDry,
},
output: self.output.value(),
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.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.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
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] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
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 {
switch(setter, &self.bypass, new.bypass);
}
if new.mode != old.mode {
switch(setter, &self.mode, mode_param(new.mode));
}
if new.listen != old.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;