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

+3 -1
View File
@@ -22,7 +22,7 @@ jobs:
strategy:
fail-fast: false
matrix:
plugin: [rivet, patina, anvil, crucible, lathe, temper]
plugin: [rivet, patina, anvil, crucible, lathe, temper, weld]
steps:
- uses: https://github.com/actions/checkout@v4
with:
@@ -60,6 +60,7 @@ jobs:
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/crucible.png --plugin crucible
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/lathe.png --plugin lathe
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/temper.png --plugin temper
cargo run --manifest-path tools/editor-check/Cargo.toml --locked -- target/editor/weld.png --plugin weld
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
name: editor-previews
@@ -89,6 +90,7 @@ jobs:
python3 scripts/build-plugin.py --plugin crucible --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin lathe --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin temper --target x86_64-pc-windows-gnu
python3 scripts/build-plugin.py --plugin weld --target x86_64-pc-windows-gnu
python3 scripts/package-windows.py
- uses: https://github.com/actions/upload-artifact@v3-node20
with:
+1 -1
View File
@@ -1,7 +1,7 @@
name: Independent plugin release
on:
push:
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*"]
tags: ["rivet-v*", "patina-v*", "anvil-v*", "crucible-v*", "lathe-v*", "temper-v*", "weld-v*"]
permissions:
contents: read
Generated
+17
View File
@@ -526,6 +526,23 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "weld-core"
version = "0.1.0"
dependencies = [
"serde",
"serde_json",
]
[[package]]
name = "weld-ui"
version = "0.1.0"
dependencies = [
"common-ui",
"egui",
"weld-core",
]
[[package]]
name = "windows-link"
version = "0.2.1"
+4 -2
View File
@@ -1,7 +1,7 @@
[workspace]
resolver = "2"
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin", "plugins/lathe/plugin", "plugins/temper/plugin"]
members = ["plugins/common/ui", "plugins/common/dsp", "plugins/anvil/core", "plugins/anvil/ui", "plugins/lathe/core", "plugins/lathe/ui", "plugins/temper/core", "plugins/temper/ui", "plugins/weld/core", "plugins/weld/ui", "plugins/crucible/core", "plugins/crucible/ui", "plugins/rivet/core", "plugins/rivet/midi", "plugins/rivet/preview", "plugins/rivet/app", "plugins/patina/core", "plugins/patina/render", "plugins/patina/ui"]
exclude = ["plugins/rivet/plugin", "tools/editor-check", "plugins/patina/plugin", "plugins/anvil/plugin", "plugins/crucible/plugin", "plugins/lathe/plugin", "plugins/temper/plugin", "plugins/weld/plugin"]
[workspace.package]
edition = "2021"
@@ -24,3 +24,5 @@ opt-level = 2
opt-level = 2
[profile.dev.package.temper-core]
opt-level = 2
[profile.dev.package.weld-core]
opt-level = 2
+7
View File
@@ -0,0 +1,7 @@
# Weld changelog
## Unreleased
- Sidechain test build: stereo or mono main input with a matching sidechain
input, a sidechain level readout, Listen (Layer, Sum, Sum dry), Output and
Bypass. No phase alignment yet.
+32
View File
@@ -0,0 +1,32 @@
# Weld
Weld aligns the phase of one bass layer to another layer so the two sum
instead of cancelling in the mids.
> **Status: sidechain test build.** This build only checks that FL Studio
> delivers a sidechain signal to Weld. It passes the main layer through
> unchanged and shows the sidechain level. Phase alignment comes in the next
> build.
## Set up in FL Studio
1. Load Weld on the mixer track of one layer (the main layer).
2. On the mixer track of the other layer, right-click the route arrow to the
main layer's track and choose **Sidechain to this track**.
3. In Weld's plugin wrapper, open the settings (gear icon) and, under the
processing or input routing options, set Weld's sidechain input to that
track.
4. Play both layers. The large readout must show `sidechain: −NN dBFS`, and the
SIDECHAIN meter must move.
## Controls
- **Listen**:
- **Layer** outputs the main layer; use it in the mix.
- **Sum** outputs the main layer plus the sidechain layer. Mute the other
layer's own send to the master while you listen to Sum.
- **Sum dry** outputs the unprocessed sum, for comparison.
- **Output**: output trim.
- **Bypass**.
Weld adds no latency.
+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));
}
+18
View File
@@ -167,6 +167,8 @@ dependencies = [
"rivet-midi",
"temper-core",
"temper-ui",
"weld-core",
"weld-ui",
]
[[package]]
@@ -642,6 +644,22 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "weld-core"
version = "0.1.0"
dependencies = [
"serde",
]
[[package]]
name = "weld-ui"
version = "0.1.0"
dependencies = [
"common-ui",
"egui",
"weld-core",
]
[[package]]
name = "windows-link"
version = "0.2.1"
+2
View File
@@ -23,3 +23,5 @@ lathe-core = { path = "../../plugins/lathe/core" }
lathe-ui = { path = "../../plugins/lathe/ui" }
temper-core = { path = "../../plugins/temper/core" }
temper-ui = { path = "../../plugins/temper/ui" }
weld-core = { path = "../../plugins/weld/core" }
weld-ui = { path = "../../plugins/weld/ui" }
+28 -5
View File
@@ -10,8 +10,8 @@ fn main() {
.find(|a| a[0] == "--plugin")
.map(|a| a[1].as_str())
.unwrap_or("rivet");
if !["rivet", "patina", "anvil", "crucible", "lathe", "temper"].contains(&plugin) {
eprintln!("Use --plugin rivet, patina, anvil, crucible, lathe or temper");
if !["rivet", "patina", "anvil", "crucible", "lathe", "temper", "weld"].contains(&plugin) {
eprintln!("Use --plugin rivet, patina, anvil, crucible, lathe, temper or weld");
std::process::exit(2);
}
let patina = plugin == "patina" || args.iter().any(|arg| arg == "--resonance");
@@ -20,7 +20,10 @@ fn main() {
let crucible = plugin == "crucible";
let lathe = plugin == "lathe";
let temper = plugin == "temper";
let (width, height) = if temper {
let weld = plugin == "weld";
let (width, height) = if weld {
(weld_ui::WIDTH, weld_ui::HEIGHT)
} else if temper {
(temper_ui::WIDTH, temper_ui::HEIGHT)
} else if lathe {
(lathe_ui::WIDTH, lathe_ui::HEIGHT)
@@ -139,6 +142,22 @@ fn main() {
}
temper_state.accept_frame(engine.take_analyzer());
}
let mut weld_settings = weld_core::Settings::default();
let mut weld_state = weld_ui::State::default();
if weld {
// Two layers of an F1 saw bass; the sidechain layer is polarity-flipped.
let mut engine = weld_core::Engine::new(48000., 2).unwrap();
engine.set_settings(weld_settings).unwrap();
let mut saw = common_dsp::Saw::new(48000., 43.65);
for i in 0..36000 {
let x = (saw.sample() * 1.6).tanh() * 0.4;
engine.process_sample([x, x], [-0.7 * x, -0.7 * x]);
if i % 1600 == 0 {
weld_state.accept_frame(engine.take_analyzer());
}
}
weld_state.accept_frame(engine.take_analyzer());
}
let mut crucible_settings = crucible_core::Settings {
folder: "C:\\Users\\you\\Documents\\Crucible".into(),
..Default::default()
@@ -227,7 +246,9 @@ fn main() {
let session_motif = session.motifs[0].id;
shared.replace(session).unwrap();
let ctx = egui::Context::default();
if temper {
if weld {
weld_ui::style(&ctx);
} else if temper {
temper_ui::style(&ctx);
} else if lathe {
lathe_ui::style(&ctx);
@@ -260,7 +281,9 @@ fn main() {
},
|ui| {
egui::CentralPanel::default().show(ui, |ui| {
if temper {
if weld {
weld_ui::show(ui, &mut weld_settings, &mut weld_state);
} else if temper {
temper_ui::show(ui, &mut temper_settings, &mut temper_state);
} else if lathe {
lathe_ui::show(ui, &mut lathe_settings, &mut lathe_state);