feat(weld): add sidechain test build

Weld will phase-align a bass layer to another layer it hears through the
sidechain. This first build only checks that FL Studio delivers the
sidechain to a CLAP aux port: it passes the main layer through unchanged,
meters the sidechain and offers Layer/Sum/Sum dry listening. No latency.
This commit is contained in:
nvrl committed 2026-11-06 07:53:20 +01:00
1 parent b3ebe4d952
commit e3286f9094
21 files changed
+3471 -9

No files matched your search

+150
View File
@@ -0,0 +1,150 @@
//! Weld engine: aligns the phase of the main layer to a sidechain layer so the two sum
//! instead of cancel. Allocation-free after `new`; zero latency.
mod settings;
pub use settings::{Listen, Settings};
/// Levels for the editor, published a few times per second.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct AnalyzerFrame {
/// RMS of the main input in dBFS since the last frame.
pub main_db: f32,
/// RMS of the sidechain input in dBFS since the last frame.
pub side_db: f32,
/// Peak of the sidechain input in dBFS since the last frame.
pub side_peak_db: f32,
/// True once any sidechain sample above −90 dBFS arrived since activation.
pub side_seen: bool,
pub sample_rate: f32,
}
impl Default for AnalyzerFrame {
fn default() -> Self {
Self {
main_db: -120.,
side_db: -120.,
side_peak_db: -120.,
side_seen: false,
sample_rate: 48000.,
}
}
}
fn db(power_or_amp: f32, power: bool) -> f32 {
let v = power_or_amp.max(1e-12);
if power {
10. * v.log10()
} else {
20. * v.log10()
}
.max(-120.)
}
#[derive(Default)]
struct Levels {
main: f64,
side: f64,
peak: f32,
count: usize,
}
pub struct Engine {
rate: f32,
channels: usize,
settings: Settings,
gain: f32,
target_gain: f32,
gain_coef: f32,
fresh: bool,
levels: Levels,
side_seen: bool,
}
impl Engine {
pub fn new(rate: f32, channels: usize) -> Result<Self, &'static str> {
if !(rate.is_finite() && (8000.0..=768000.).contains(&rate)) {
return Err("unsupported sample rate");
}
if !(1..=2).contains(&channels) {
return Err("unsupported channel count");
}
Ok(Self {
rate,
channels,
settings: Settings::default(),
gain: 1.,
target_gain: 1.,
gain_coef: 1. - (-1. / (0.02 * rate)).exp(),
fresh: true,
levels: Levels::default(),
side_seen: false,
})
}
pub const fn latency_samples(&self) -> u32 {
0
}
pub fn channels(&self) -> usize {
self.channels
}
pub fn settings(&self) -> &Settings {
&self.settings
}
pub fn set_settings(&mut self, settings: Settings) -> Result<(), &'static str> {
settings.validate()?;
self.settings = settings;
self.target_gain = 10f32.powf(settings.output / 20.);
if self.fresh {
self.gain = self.target_gain;
self.fresh = false;
}
Ok(())
}
pub fn reset(&mut self) {
self.gain = self.target_gain;
self.levels = Levels::default();
}
/// One frame of main input `x` and sidechain input `side`. Mono uses index 0 only.
#[inline]
pub fn process_sample(&mut self, x: [f32; 2], side: [f32; 2]) -> [f32; 2] {
let n = self.channels;
let (mut m, mut s) = (0f32, 0f32);
for c in 0..n {
m += x[c];
s += side[c];
self.levels.peak = self.levels.peak.max(side[c].abs());
}
m /= n as f32;
s /= n as f32;
self.levels.main += (m * m) as f64;
self.levels.side += (s * s) as f64;
self.levels.count += 1;
if s.abs() > 3e-5 {
self.side_seen = true;
}
self.gain += (self.target_gain - self.gain) * self.gain_coef;
if self.settings.bypass {
return x;
}
let mut out = [0.; 2];
for c in 0..n {
let layer = x[c];
out[c] = self.gain
* match self.settings.listen {
Listen::Layer => layer,
Listen::Sum => layer + side[c],
Listen::SumDry => x[c] + side[c],
};
}
out
}
/// Levels since the previous call.
pub fn take_analyzer(&mut self) -> AnalyzerFrame {
let n = self.levels.count.max(1) as f64;
let frame = AnalyzerFrame {
main_db: db((self.levels.main / n) as f32, true),
side_db: db((self.levels.side / n) as f32, true),
side_peak_db: db(self.levels.peak, false),
side_seen: self.side_seen,
sample_rate: self.rate,
};
self.levels = Levels::default();
frame
}
}
+43
View File
@@ -0,0 +1,43 @@
//! Host-independent controls. Gains in dB.
use serde::{Deserialize, Serialize};
/// What the plugin outputs.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
pub enum Listen {
/// The aligned main layer only. The sidechain layer reaches the mix on its own track.
#[default]
Layer,
/// Aligned main layer plus the sidechain layer, to judge the sum in one place.
Sum,
/// Unprocessed main layer plus the sidechain layer, to compare with `Sum`.
SumDry,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq)]
#[serde(deny_unknown_fields)]
pub struct Settings {
pub listen: Listen,
pub output: f32,
pub bypass: bool,
}
impl Default for Settings {
fn default() -> Self {
Self {
listen: Listen::Layer,
output: 0.,
bypass: false,
}
}
}
impl Settings {
pub fn validate(&self) -> Result<(), &'static str> {
if !self.output.is_finite() {
return Err("non-finite setting");
}
if (-12.0..=12.).contains(&self.output) {
Ok(())
} else {
Err("setting out of range")
}
}
}