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
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user