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

+16
View File
@@ -98,5 +98,21 @@
"temper-core",
"temper-ui"
]
},
"weld": {
"name": "Weld",
"description": "Align the phase of a bass layer to a sidechain layer so the two sum instead of cancel",
"manifest": "plugins/weld/plugin/Cargo.toml",
"library": "weld",
"documentation": "docs/weld",
"clap_id": "io.storyboard.layer-align",
"licenses": {
"plugins/common/ui/assets/OFL.txt": "licenses/Roboto-OFL.txt"
},
"packages": [
"common-ui",
"weld-core",
"weld-ui"
]
}
}
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "weld-core"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
serde = { workspace = true }
[dev-dependencies]
serde_json = { workspace = true }
+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")
}
}
}
+97
View File
@@ -0,0 +1,97 @@
use weld_core::{Engine, Listen, Settings};
fn signal(n: usize, hz: f32) -> f32 {
(std::f32::consts::TAU * hz * n as f32 / 48000.).sin() * 0.5
}
#[test]
fn layer_output_is_the_main_input_with_zero_latency() {
let mut engine = Engine::new(48000., 2).unwrap();
engine.set_settings(Settings::default()).unwrap();
assert_eq!(engine.latency_samples(), 0);
for n in 0..4800 {
let x = [signal(n, 110.), -signal(n, 220.)];
let out = engine.process_sample(x, [signal(n, 330.), 0.25]);
assert_eq!(out, x);
}
}
#[test]
fn sum_modes_add_the_sidechain() {
for listen in [Listen::Sum, Listen::SumDry] {
let mut engine = Engine::new(48000., 2).unwrap();
engine
.set_settings(Settings {
listen,
..Settings::default()
})
.unwrap();
for n in 0..480 {
let x = [signal(n, 110.), signal(n, 120.)];
let s = [signal(n, 330.), -signal(n, 90.)];
let out = engine.process_sample(x, s);
for c in 0..2 {
assert!((out[c] - (x[c] + s[c])).abs() < 1e-6);
}
}
}
}
#[test]
fn mono_bypass_and_output_gain() {
let mut engine = Engine::new(44100., 1).unwrap();
engine
.set_settings(Settings {
output: -6.,
..Settings::default()
})
.unwrap();
let out = engine.process_sample([0.5, 0.], [0., 0.]);
assert!((out[0] - 0.5 * 10f32.powf(-6. / 20.)).abs() < 1e-6);
assert_eq!(out[1], 0.);
engine
.set_settings(Settings {
bypass: true,
..Settings::default()
})
.unwrap();
assert_eq!(engine.process_sample([0.3, 0.], [0.9, 0.]), [0.3, 0.]);
}
#[test]
fn analyzer_reports_sidechain_presence_and_level() {
let mut engine = Engine::new(48000., 2).unwrap();
engine.set_settings(Settings::default()).unwrap();
for n in 0..4800 {
engine.process_sample([signal(n, 100.); 2], [0.; 2]);
}
let frame = engine.take_analyzer();
assert!(!frame.side_seen);
assert!(frame.side_db < -100.);
assert!((frame.main_db - (-9.03)).abs() < 0.1, "{}", frame.main_db);
for n in 0..4800 {
engine.process_sample([0.; 2], [signal(n, 100.); 2]);
}
let frame = engine.take_analyzer();
assert!(frame.side_seen);
assert!((frame.side_db - (-9.03)).abs() < 0.1);
assert!((frame.side_peak_db - (-6.02)).abs() < 0.1);
}
#[test]
fn invalid_settings_and_rates_are_rejected() {
assert!(Engine::new(0., 2).is_err());
assert!(Engine::new(48000., 3).is_err());
let mut engine = Engine::new(48000., 2).unwrap();
assert!(engine
.set_settings(Settings {
output: f32::NAN,
..Settings::default()
})
.is_err());
let json = serde_json::to_string(&Settings::default()).unwrap();
assert_eq!(
serde_json::from_str::<Settings>(&json).unwrap(),
Settings::default()
);
}
+2375
View File
File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "weld"
version = "0.1.0"
edition = "2021"
license = "ISC"
[workspace]
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683", default-features = false }
nih_plug_egui = { git = "https://github.com/robbert-vdh/nih-plug.git", rev = "de421011f41a6d10fc8c7a6084e4f4dee0143683" }
weld-core = { path = "../core" }
weld-ui = { path = "../ui" }
crossbeam-queue = "0.3"
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+179
View File
@@ -0,0 +1,179 @@
//! CLAP audio effect with a sidechain input. All DSP state is owned by the processing thread.
mod params;
use crossbeam_queue::ArrayQueue;
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::WeldParams;
use std::sync::Arc;
use weld_core::{AnalyzerFrame, Engine};
struct EditorData {
ui: weld_ui::State,
gestures: [bool; 1],
}
pub struct Weld {
params: Arc<WeldParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for Weld {
fn default() -> Self {
Self {
params: Arc::new(WeldParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for Weld {
const NAME: &'static str = "Weld";
const VENDOR: &'static str = "Rivet & Patina";
const URL: &'static str = "";
const EMAIL: &'static str = "";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
aux_input_ports: &[new_nonzero_u32(2)],
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
aux_input_ports: &[new_nonzero_u32(1)],
..AudioIOLayout::const_default()
},
];
const SAMPLE_ACCURATE_AUTOMATION: bool = false;
type SysExMessage = ();
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
let params = self.params.clone();
let analyzer = self.analyzer.clone();
create_egui_editor(
params.editor.clone(),
EditorData {
ui: weld_ui::State::default(),
gestures: [false; 1],
},
|ctx, _| weld_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("weld-editor")
.min_size(egui::vec2(
weld_ui::WIDTH as f32 - 80.,
weld_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
weld_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// Meters follow the audio.
ctx.request_repaint_after(std::time::Duration::from_millis(40));
},
)
}
fn initialize(
&mut self,
layout: &AudioIOLayout,
config: &BufferConfig,
context: &mut impl InitContext<Self>,
) -> bool {
let Ok(mut engine) = Engine::new(
config.sample_rate,
layout.main_input_channels.map_or(0, |v| v.get() as usize),
) else {
return false;
};
if engine.set_settings(self.params.settings()).is_err() {
return false;
}
context.set_latency_samples(engine.latency_samples());
self.publish_interval = (config.sample_rate / 30.) as usize;
self.engine = Some(engine);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
}
fn process(
&mut self,
buffer: &mut Buffer,
aux: &mut AuxiliaryBuffers,
_: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let _ = engine.set_settings(self.params.settings());
// A host that does not connect the sidechain still passes a buffer; treat a
// missing one as silence.
let side = aux.inputs.first().map(|b| b.as_slice_immutable());
let stereo = engine.channels() == 2;
for (index, mut sample) in buffer.iter_samples().enumerate() {
let x = [
*sample.get_mut(0).unwrap(),
if stereo {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let mut s = [0f32; 2];
if let Some(side) = side {
for (c, value) in s.iter_mut().enumerate().take(side.len().min(2)) {
*value = side[c].get(index).copied().unwrap_or(0.);
}
if !stereo && side.len() > 1 {
s[0] = 0.5 * (s[0] + s[1]);
}
}
let out = engine.process_sample(x, s);
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
self.analyzer.force_push(engine.take_analyzer());
self.publish_count = 0;
}
}
ProcessStatus::Normal
}
}
impl ClapPlugin for Weld {
const CLAP_ID: &'static str = "io.storyboard.layer-align";
const CLAP_DESCRIPTION: Option<&'static str> = Some(
"Align the phase of a bass layer to a sidechain layer so the two sum instead of cancel",
);
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Utility,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Weld);
+104
View File
@@ -0,0 +1,104 @@
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::Arc;
use weld_core::{Listen, Settings};
#[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 = 1;
#[derive(Params)]
pub struct WeldParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "listen"]
pub listen: EnumParam<ListenParam>,
#[id = "output"]
pub output: FloatParam,
#[id = "bypass"]
pub bypass: BoolParam,
}
impl Default for WeldParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(weld_ui::WIDTH, weld_ui::HEIGHT),
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(),
}
}
}
impl WeldParams {
pub fn settings(&self) -> Settings {
Settings {
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(),
}
}
fn floats(&self) -> [&FloatParam; FLOATS] {
[&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.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;
}
}
if new.bypass != old.bypass {
setter.begin_set_parameter(&self.bypass);
setter.set_parameter(&self.bypass, new.bypass);
setter.end_set_parameter(&self.bypass);
}
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);
}
}
}
+226
View File
@@ -0,0 +1,226 @@
//! Exercise the actual exported CLAP ABI without requiring a GUI or installed DAW.
use clap_sys::{
audio_buffer::*,
events::*,
ext::{audio_ports::*, latency::*, state::*},
factory::plugin_factory::*,
host::*,
process::*,
stream::*,
version::*,
};
use std::{
ffi::{c_char, c_void, CStr},
io::{Cursor, Read},
ptr,
};
unsafe extern "C" fn extension(_: *const clap_host, _: *const c_char) -> *const c_void {
ptr::null()
}
unsafe extern "C" fn request(_: *const clap_host) {}
unsafe extern "C" fn count(_: *const clap_input_events) -> u32 {
0
}
unsafe extern "C" fn event(_: *const clap_input_events, _: u32) -> *const clap_event_header {
ptr::null()
}
unsafe extern "C" fn push(_: *const clap_output_events, _: *const clap_event_header) -> bool {
true
}
unsafe extern "C" fn write(stream: *const clap_ostream, buffer: *const c_void, size: u64) -> i64 {
// SAFETY: The test owns the Vec for the entire synchronous save call.
let bytes = unsafe { &mut *((*stream).ctx as *mut Vec<u8>) };
bytes.extend_from_slice(unsafe {
std::slice::from_raw_parts(buffer as *const u8, size as usize)
});
size as i64
}
unsafe extern "C" fn read(stream: *const clap_istream, buffer: *mut c_void, size: u64) -> i64 {
// SAFETY: The test owns the Cursor; the wrapper supplies writable stream storage.
let cursor = unsafe { &mut *((*stream).ctx as *mut Cursor<Vec<u8>>) };
cursor
.read(unsafe { std::slice::from_raw_parts_mut(buffer as *mut u8, size as usize) })
.unwrap() as i64
}
#[test]
fn sidechain_port_state_latency_and_audio_lifecycle() {
// SAFETY: All callbacks, strings, streams, buffers, and the host outlive their
// synchronous ABI calls. Activation/processing/destruction follow CLAP order.
unsafe {
let entry = &weld::clap_entry;
assert!(entry.init.unwrap()(c"weld.clap".as_ptr()));
let factory = entry.get_factory.unwrap()(CLAP_PLUGIN_FACTORY_ID.as_ptr())
as *const clap_plugin_factory;
assert!(!factory.is_null());
let factory = &*factory;
assert_eq!(factory.get_plugin_count.unwrap()(factory), 1);
let descriptor = factory.get_plugin_descriptor.unwrap()(factory, 0);
assert!(!descriptor.is_null());
assert_eq!(CStr::from_ptr((*descriptor).name), c"Weld");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.storyboard.layer-align"
);
let host = clap_host {
clap_version: CLAP_VERSION,
host_data: ptr::null_mut(),
name: c"Storyboard test host".as_ptr(),
vendor: c"Tests".as_ptr(),
url: c"".as_ptr(),
version: c"1.0".as_ptr(),
get_extension: Some(extension),
request_restart: Some(request),
request_process: Some(request),
request_callback: Some(request),
};
let plugin = factory.create_plugin.unwrap()(factory, &host, (*descriptor).id);
assert!(!plugin.is_null());
let p = &*plugin;
assert!(p.init.unwrap()(plugin));
let ports = p.get_extension.unwrap()(plugin, CLAP_EXT_AUDIO_PORTS.as_ptr())
as *const clap_plugin_audio_ports;
assert!(!ports.is_null());
// Main input plus the sidechain input; one output.
assert_eq!((*ports).count.unwrap()(plugin, true), 2);
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
let mut info: clap_audio_port_info = std::mem::zeroed();
assert!((*ports).get.unwrap()(plugin, 1, true, &mut info));
assert_eq!(info.channel_count, 2);
assert_eq!(info.flags & CLAP_AUDIO_PORT_IS_MAIN, 0);
let latency = p.get_extension.unwrap()(plugin, CLAP_EXT_LATENCY.as_ptr())
as *const clap_plugin_latency;
assert!(!latency.is_null());
let state =
p.get_extension.unwrap()(plugin, CLAP_EXT_STATE.as_ptr()) as *const clap_plugin_state;
assert!(!state.is_null());
let state = &*state;
let save = || {
let mut bytes = Vec::new();
let stream = clap_ostream {
ctx: &mut bytes as *mut Vec<u8> as *mut c_void,
write: Some(write),
};
assert!(state.save.unwrap()(plugin, &stream));
bytes
};
let mut envelope: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
use nih_plug::wrapper::state::ParamValue;
envelope
.params
.insert("listen".into(), ParamValue::String("sum".into()));
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);
let mut bytes = Cursor::new(framed);
let stream = clap_istream {
ctx: &mut bytes as *mut Cursor<Vec<u8>> as *mut c_void,
read: Some(read),
};
assert!(state.load.unwrap()(plugin, &stream));
let restored: nih_plug::prelude::PluginState =
serde_json::from_slice(&save()[8..]).unwrap();
assert_eq!(
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).
for (rate, with_side, expected_db) in [
(44100., true, 6.02),
(48000., false, 0.),
(96000., true, 6.02),
] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
assert_eq!((*latency).get.unwrap()(plugin), 0);
assert!(p.start_processing.unwrap()(plugin));
let total = 9600;
let mut position = 0;
let (mut energy_in, mut energy_out) = (0f64, 0f64);
while position < total {
let frames = [1, 127, 512, 31][position % 4].min(total - position);
let tone = |n: usize| {
0.25 * (std::f64::consts::TAU * 1000. * n as f64 / rate).sin() as f32
};
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
input_left[i] = tone(position + i);
input_right[i] = -tone(position + i);
}
let (mut side_left, mut side_right) = if with_side {
(input_left, input_right)
} else {
([0f32; 512], [0f32; 512])
};
let mut left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_right.as_mut_ptr()];
let mut side_channels = [side_left.as_mut_ptr(), side_right.as_mut_ptr()];
let mut out_channels = [left.as_mut_ptr(), right.as_mut_ptr()];
let incoming = [
clap_audio_buffer {
data32: in_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
},
clap_audio_buffer {
data32: side_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
},
];
let mut outgoing_audio = clap_audio_buffer {
data32: out_channels.as_mut_ptr(),
data64: ptr::null_mut(),
channel_count: 2,
latency: 0,
constant_mask: 0,
};
let input = clap_input_events {
ctx: ptr::null_mut(),
size: Some(count),
get: Some(event),
};
let outgoing = clap_output_events {
ctx: ptr::null_mut(),
try_push: Some(push),
};
let process = clap_process {
steady_time: position as i64,
frames_count: frames as u32,
transport: ptr::null(),
audio_inputs: incoming.as_ptr(),
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 2,
audio_outputs_count: 1,
in_events: &input,
out_events: &outgoing,
};
assert_ne!(p.process.unwrap()(plugin, &process), CLAP_PROCESS_ERROR);
for i in 0..frames {
let n = position + i;
energy_in += 2. * (tone(n) as f64).powi(2);
energy_out += (left[i] as f64).powi(2) + (right[i] as f64).powi(2);
}
position += frames;
}
let ratio_db = 10. * (energy_out / energy_in).log10();
assert!(
(ratio_db - expected_db).abs() < 0.2,
"{rate} {with_side} {ratio_db}"
);
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}
+13
View File
@@ -0,0 +1,13 @@
[package]
name = "weld-ui"
version = "0.1.0"
edition.workspace = true
license.workspace = true
[dependencies]
egui = "0.31.1"
weld-core = { path = "../core" }
common-ui = { path = "../../common/ui" }
+115
View File
@@ -0,0 +1,115 @@
//! Weld editor, shared by the CLAP wrapper and the headless renderer.
use common_ui::*;
use egui::{self, FontId, RichText};
use weld_core::{AnalyzerFrame, Listen, Settings};
pub use common_ui::style;
pub const WIDTH: u32 = 1040;
pub const HEIGHT: u32 = 650;
pub struct State {
pub analyzer: AnalyzerFrame,
/// True while a pointer button is held, so knob drags become one host gesture.
pub gesture_active: bool,
pub help: String,
}
impl Default for State {
fn default() -> Self {
Self {
analyzer: AnalyzerFrame::default(),
gesture_active: false,
help: DEFAULT_HELP.into(),
}
}
}
const DEFAULT_HELP: &str =
"Put Weld on one bass layer and send the other layer to its sidechain input.";
impl State {
pub fn accept_frame(&mut self, frame: AnalyzerFrame) {
let old = self.analyzer;
self.analyzer = frame;
// Meters fall smoothly between frames.
self.analyzer.main_db = frame.main_db.max(old.main_db - 3.);
self.analyzer.side_db = frame.side_db.max(old.side_db - 3.);
self.analyzer.side_peak_db = frame.side_peak_db.max(old.side_peak_db - 3.);
}
}
fn hint(r: &egui::Response, state: &mut State, text: &str) {
if r.hovered() || r.has_focus() {
state.help = text.into();
}
}
pub fn show(ui: &mut egui::Ui, settings: &mut Settings, state: &mut State) {
backdrop(ui);
state.help = DEFAULT_HELP.into();
state.gesture_active = ui.input(|i| i.pointer.any_down());
ui.add_space(2.);
header(ui, "WELD", "LAYER ALIGN", |ui| {
let r = ui
.selectable_label(settings.bypass, RichText::new("BYPASS").size(13.))
.on_hover_text("Pass the main layer through unchanged.");
if r.clicked() {
settings.bypass = !settings.bypass;
}
});
ui.add_space(6.);
let frame = state.analyzer;
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.set_min_height(380.);
ui.vertical(|ui| {
caption(ui, "SIDECHAIN");
let (text, color) = if frame.side_db > -90. {
(format!("sidechain: {:.0} dBFS", frame.side_db), TEXT)
} else if frame.side_seen {
("sidechain: silent".to_string(), MUTED)
} else {
("no sidechain signal".to_string(), DIM)
};
ui.label(
RichText::new(text)
.font(FontId::new(40., egui::FontFamily::Name("medium".into())))
.color(color),
);
ui.add_space(12.);
meter(ui, "MAIN", frame.main_db, STEEL, 520.);
meter(ui, "SIDECHAIN", frame.side_db, ACCENT, 520.);
meter(ui, "SIDE PEAK", frame.side_peak_db, FOCUS, 520.);
ui.add_space(18.);
ui.label(
RichText::new(
"FL Studio: on the other layer's mixer track, right-click the route arrow \
to this track and choose \"Sidechain to this track\". Then open Weld's \
wrapper settings and set the sidechain input to that track.",
)
.size(13.)
.color(MUTED),
);
});
});
ui.add_space(6.);
panel(ui, |ui| {
ui.set_width(ui.available_width());
ui.horizontal(|ui| {
caption(ui, "LISTEN");
for (listen, label, help) in [
(Listen::Layer, "LAYER", "Output the main layer only. Use this in the mix."),
(Listen::Sum, "SUM", "Output main + sidechain layer. Mute the other layer's own send while listening."),
(Listen::SumDry, "SUM DRY", "Output the unprocessed main + sidechain layer, to compare with SUM."),
] {
let r = ui.selectable_label(settings.listen == listen, RichText::new(label).size(13.));
if r.clicked() {
settings.listen = listen;
}
hint(&r, state, help);
}
ui.add_space(40.);
let d = Settings::default();
let r = rotary(ui, "Output", &mut settings.output, -12. ..=12., d.output, " dB", 54., ACCENT);
hint(&r, state, "Output trim.");
});
});
ui.separator();
ui.label(RichText::new(&state.help).color(MUTED));
}