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:
1 parent
b3ebe4d952
commit
e3286f9094
21 files changed
+3471
-9
No files matched your search
@@ -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,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
@@ -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
@@ -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
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
);
|
||||
}
|
||||
Generated
+2375
File diff suppressed because it is too large.
Load diff
@@ -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"
|
||||
@@ -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, ¶ms.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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()();
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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));
|
||||
}
|
||||
Generated
+18
@@ -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"
|
||||
|
||||
@@ -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" }
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user