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
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:
1 parent
0a0917ac56
commit
9b3ddeaf56
26 files changed
+2898
-144
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user