feat(forge): add Forge tracked channel strip

One note tracker reads the input and Lathe, Temper and Knurl follow it as
modules, in any of six orders, each on or off. Every module gets the
tracker's events delayed by the latency of the modules in front of it.

- One module alone matches its standalone plugin sample for sample in
  every order.
- With Knurl's sub-octave pattern in front, Lathe in Forge follows the
  played note; a standalone Lathe after Knurl reads it an octave low.
- About 72 ms latency against about 206 ms for the three in a row.

The module pages reuse the standalone editors. Register Forge in the
catalog, CI, README, docs and website, and drop the built Ballast from
the site's idea list.
This commit is contained in:
nvrl committed 2026-11-06 16:30:23 +01:00
1 parent 340376eee2
commit 7b989f7685
28 files changed
+4678 -23

No files matched your search

+2448
View File
File diff suppressed because it is too large. Load diff
+29
View File
@@ -0,0 +1,29 @@
[package]
name = "forge"
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" }
forge-core = { path = "../core" }
lathe-core = { path = "../../lathe/core" }
temper-core = { path = "../../temper/core" }
knurl-core = { path = "../../knurl/core" }
forge-ui = { path = "../ui" }
crossbeam-queue = "0.3"
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
clap-sys = { git = "https://github.com/micahrj/clap-sys.git", rev = "25d7f53fdb6363ad63fbd80049cb7a42a97ac156" }
serde_json = "1"
[profile.release]
lto = "thin"
strip = "symbols"
+201
View File
@@ -0,0 +1,201 @@
//! CLAP audio effect. All DSP state is owned by the processing thread.
mod params;
use crossbeam_queue::ArrayQueue;
use forge_core::{AnalyzerFrame, Engine};
use nih_plug::prelude::*;
use nih_plug_egui::{create_egui_editor, egui};
use params::{Drawn, ForgeParams, FLOATS};
use std::sync::Arc;
struct EditorData {
ui: forge_ui::State,
gestures: [bool; FLOATS],
}
pub struct Forge {
params: Arc<ForgeParams>,
analyzer: Arc<ArrayQueue<AnalyzerFrame>>,
engine: Option<Engine>,
drawn: Drawn,
publish_count: usize,
publish_interval: usize,
}
impl Default for Forge {
fn default() -> Self {
Self {
params: Arc::new(ForgeParams::default()),
analyzer: Arc::new(ArrayQueue::new(2)),
engine: None,
drawn: Drawn::default(),
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Forge {
fn settings(&mut self) -> forge_core::Settings {
let s = self.params.settings(&self.drawn);
self.drawn = Drawn {
gains: s.lathe.gains,
pattern: params::Pattern {
gains: s.knurl.pattern,
grit: s.knurl.grit_steps,
},
};
s
}
}
impl Plugin for Forge {
const NAME: &'static str = "Forge";
const VENDOR: &'static str = "Bass Foundry";
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),
..AudioIOLayout::const_default()
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
..AudioIOLayout::const_default()
},
];
const MIDI_INPUT: MidiConfig = MidiConfig::Basic;
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: forge_ui::State::default(),
gestures: [false; FLOATS],
},
|ctx, _| forge_ui::style(ctx),
move |ctx, setter, data| {
while let Some(frame) = analyzer.pop() {
data.ui.accept_frame(frame);
}
let before = params.settings(&params.drawn());
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("forge-editor")
.min_size(egui::vec2(
forge_ui::WIDTH as f32 - 80.,
forge_ui::HEIGHT as f32 - 40.,
))
.show(ctx, &params.editor, |ui| {
forge_ui::show(ui, &mut edited, &mut data.ui)
});
params.apply(
setter,
&before,
&edited,
data.ui.gesture_active,
&mut data.gestures,
);
// Meters and scopes 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;
};
let settings = self.settings();
if engine.set_settings(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,
_: &mut AuxiliaryBuffers,
context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
let settings = self.settings();
let Some(engine) = self.engine.as_mut() else {
return ProcessStatus::Normal;
};
let _ = engine.set_settings(settings);
let mut next = context.next_event();
for (index, mut sample) in buffer.iter_samples().enumerate() {
while let Some(event) = next {
if event.timing() as usize > index {
break;
}
match event {
NoteEvent::NoteOn { note, velocity, .. } if velocity > 0. => {
engine.note_on(note)
}
NoteEvent::NoteOn { note, .. } | NoteEvent::NoteOff { note, .. } => {
engine.note_off(note)
}
NoteEvent::Choke { note, .. } => engine.note_off(note),
_ => {}
}
next = context.next_event();
}
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x);
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;
}
}
// The lookahead delay and the module tails must drain after input stops.
ProcessStatus::KeepAlive
}
}
impl ClapPlugin for Forge {
const CLAP_ID: &'static str = "io.narl.bass-foundry.forge";
const CLAP_DESCRIPTION: Option<&'static str> = Some(
"One tracked channel strip: Lathe, Temper and Knurl behind one note tracker, in any order",
);
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Distortion,
ClapFeature::Equalizer,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(Forge);
+605
View File
@@ -0,0 +1,605 @@
use forge_core::{Settings, Source, ORDERS};
use knurl_core::{Seed, GRIT_OFF, STEPS, STEP_MIN};
use lathe_core::HARMONICS;
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use std::sync::{Arc, RwLock};
use temper_core::Curve;
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SourceParam {
#[id = "audio"]
Audio,
#[id = "midi"]
#[name = "MIDI"]
Midi,
}
/// The six orders of `ORDERS`, in the same order.
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum OrderParam {
#[id = "lathe-temper-knurl"]
#[name = "Lathe, Temper, Knurl"]
LatheTemperKnurl,
#[id = "temper-lathe-knurl"]
#[name = "Temper, Lathe, Knurl"]
TemperLatheKnurl,
#[id = "lathe-knurl-temper"]
#[name = "Lathe, Knurl, Temper"]
LatheKnurlTemper,
#[id = "temper-knurl-lathe"]
#[name = "Temper, Knurl, Lathe"]
TemperKnurlLathe,
#[id = "knurl-lathe-temper"]
#[name = "Knurl, Lathe, Temper"]
KnurlLatheTemper,
#[id = "knurl-temper-lathe"]
#[name = "Knurl, Temper, Lathe"]
KnurlTemperLathe,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum CurveParam {
#[id = "soft"]
Soft,
#[id = "hard"]
Hard,
#[id = "fold"]
Fold,
#[id = "tube"]
Tube,
}
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
pub enum SeedParam {
#[id = "same"]
#[name = "Same every note"]
Same,
#[id = "free"]
Free,
}
fn linear(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(name, v, FloatRange::Linear { min, max }).with_unit(unit)
}
fn on(name: &str) -> BoolParam {
BoolParam::new(format!("{name} on"), true)
}
/// Knurl's drawn pattern: step gains and grit switches.
#[derive(Debug, Clone, Copy, PartialEq, serde::Serialize, serde::Deserialize)]
pub struct Pattern {
pub gains: [f32; STEPS],
pub grit: [bool; STEPS],
}
impl Default for Pattern {
fn default() -> Self {
let d = knurl_core::Settings::default();
Self {
gains: d.pattern,
grit: d.grit_steps,
}
}
}
/// Drawn data saved with the project, not exposed as automation lanes.
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct Drawn {
pub gains: [f32; HARMONICS],
pub pattern: Pattern,
}
impl Default for Drawn {
fn default() -> Self {
Self {
gains: lathe_core::Settings::default().gains,
pattern: Pattern::default(),
}
}
}
#[derive(Params)]
pub struct LatheParams {
#[persist = "gains"]
pub gains: RwLock<[f32; HARMONICS]>,
#[id = "on"]
pub on: BoolParam,
#[id = "tilt"]
pub tilt: FloatParam,
#[id = "odd-even"]
pub odd_even: FloatParam,
#[id = "between"]
pub between: FloatParam,
#[id = "width"]
pub width: FloatParam,
#[id = "smooth"]
pub smooth: FloatParam,
#[id = "mix"]
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "harmonics"]
pub harmonics: IntParam,
#[id = "snap"]
pub snap: BoolParam,
}
impl Default for LatheParams {
fn default() -> Self {
let d = lathe_core::Settings::default();
Self {
gains: RwLock::new(d.gains),
on: on("Lathe"),
tilt: linear("Lathe tilt", d.tilt, -12., 12., " dB/oct"),
odd_even: linear("Lathe odd/even", d.odd_even, -12., 12., " dB"),
between: linear("Lathe between", d.between, -24., 12., " dB"),
width: linear("Lathe width", d.width, 5., 50., " %"),
smooth: linear("Lathe smooth", d.smooth, 0., 100., " ms"),
mix: linear("Lathe mix", d.mix, 0., 100., " %"),
output: linear("Lathe output", d.output, -12., 12., " dB"),
harmonics: IntParam::new(
"Lathe harmonics",
d.harmonics as i32,
IntRange::Linear {
min: 8,
max: HARMONICS as i32,
},
),
snap: BoolParam::new("Lathe snap to semitone", d.snap),
}
}
}
#[derive(Params)]
pub struct TemperParams {
#[id = "on"]
pub on: BoolParam,
#[id = "curve"]
pub curve: EnumParam<CurveParam>,
#[id = "drive"]
pub drive: FloatParam,
#[id = "bias"]
pub bias: FloatParam,
#[id = "normalize"]
pub normalize: FloatParam,
#[id = "floor"]
pub floor: FloatParam,
#[id = "push"]
pub push_gain: FloatParam,
#[id = "bright"]
pub bright: FloatParam,
#[id = "mix"]
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "push-at"]
pub push_harmonic: IntParam,
#[id = "auto-gain"]
pub auto_gain: BoolParam,
}
impl Default for TemperParams {
fn default() -> Self {
let d = temper_core::Settings::default();
Self {
on: on("Temper"),
curve: EnumParam::new("Temper curve", CurveParam::Soft),
drive: linear("Temper drive", d.drive, 0., 36., " dB"),
bias: linear("Temper bias", d.bias, 0., 100., " %"),
normalize: linear("Temper normalize", d.normalize, 0., 100., " %"),
floor: linear("Temper floor", d.floor, 0., 2., " ×f0"),
push_gain: linear("Temper push", d.push_gain, -12., 12., " dB"),
bright: linear("Temper bright", d.bright, 0., 64., " ×f0"),
mix: linear("Temper mix", d.mix, 0., 100., " %"),
output: linear("Temper output", d.output, -12., 12., " dB"),
push_harmonic: IntParam::new(
"Temper push at",
d.push_harmonic as i32,
IntRange::Linear { min: 1, max: 16 },
),
auto_gain: BoolParam::new("Temper auto gain", d.auto_gain),
}
}
}
#[derive(Params)]
pub struct KnurlParams {
#[persist = "pattern"]
pub pattern: RwLock<Pattern>,
#[id = "on"]
pub on: BoolParam,
#[id = "seed"]
pub seed: EnumParam<SeedParam>,
#[id = "steps"]
pub steps: IntParam,
#[id = "edge"]
pub edge: FloatParam,
#[id = "level-jitter"]
pub level_jitter: FloatParam,
#[id = "time-jitter"]
pub time_jitter: FloatParam,
#[id = "grit"]
pub grit: FloatParam,
#[id = "tone"]
pub tone: FloatParam,
#[id = "position"]
pub position: FloatParam,
#[id = "length"]
pub length: FloatParam,
#[id = "mix"]
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "snap"]
pub snap: BoolParam,
}
impl Default for KnurlParams {
fn default() -> Self {
let d = knurl_core::Settings::default();
Self {
pattern: RwLock::new(Pattern::default()),
on: on("Knurl"),
seed: EnumParam::new("Knurl random", SeedParam::Same),
steps: IntParam::new(
"Knurl steps",
d.steps as i32,
IntRange::Linear {
min: 1,
max: STEPS as i32,
},
),
edge: linear("Knurl edge", d.edge, 5., 50., " %"),
level_jitter: linear("Knurl level jitter", d.level_jitter, 0., 6., " dB"),
time_jitter: linear("Knurl time jitter", d.time_jitter, 0., 10., " %"),
grit: linear("Knurl grit", d.grit, GRIT_OFF, 0., " dB").with_value_to_string(Arc::new(
|v| {
if v <= GRIT_OFF {
"Off".into()
} else {
format!("{v:.1}")
}
},
)),
tone: FloatParam::new(
"Knurl tone",
d.tone,
FloatRange::Skewed {
min: 4.,
max: 64.,
factor: FloatRange::skew_factor(-1.),
},
)
.with_unit(" ×f0"),
position: linear("Knurl position", d.position, 0., 100., " %"),
length: linear("Knurl length", d.length, 2., 50., " %"),
mix: linear("Knurl mix", d.mix, 0., 100., " %"),
output: linear("Knurl output", d.output, -12., 12., " dB"),
snap: BoolParam::new("Knurl snap to semitone", d.snap),
}
}
}
/// Continuous controls, in the order of the gesture slots.
pub const FLOATS: usize = 25;
#[derive(Params)]
pub struct ForgeParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "source"]
pub source: EnumParam<SourceParam>,
#[id = "gate"]
pub gate: FloatParam,
#[id = "order"]
pub order: EnumParam<OrderParam>,
#[id = "bypass"]
pub bypass: BoolParam,
#[nested(id_prefix = "lathe", group = "Lathe")]
pub lathe: LatheParams,
#[nested(id_prefix = "temper", group = "Temper")]
pub temper: TemperParams,
#[nested(id_prefix = "knurl", group = "Knurl")]
pub knurl: KnurlParams,
}
impl Default for ForgeParams {
fn default() -> Self {
let d = Settings::default();
Self {
editor: EguiState::from_size(forge_ui::WIDTH, forge_ui::HEIGHT),
source: EnumParam::new("Pitch source", SourceParam::Audio),
gate: linear("Gate", d.gate, -60., -20., " dB"),
order: EnumParam::new("Order", OrderParam::LatheTemperKnurl),
bypass: BoolParam::new("Bypass", false).make_bypass(),
lathe: LatheParams::default(),
temper: TemperParams::default(),
knurl: KnurlParams::default(),
}
}
}
const CURVES: [(CurveParam, Curve); 4] = [
(CurveParam::Soft, Curve::Soft),
(CurveParam::Hard, Curve::Hard),
(CurveParam::Fold, Curve::Fold),
(CurveParam::Tube, Curve::Tube),
];
const ORDER_PARAMS: [OrderParam; 6] = [
OrderParam::LatheTemperKnurl,
OrderParam::TemperLatheKnurl,
OrderParam::LatheKnurlTemper,
OrderParam::TemperKnurlLathe,
OrderParam::KnurlLatheTemper,
OrderParam::KnurlTemperLathe,
];
/// Every continuous control's value, in the order of `ForgeParams::floats`.
fn float_values(s: &Settings) -> [f32; FLOATS] {
let (l, t, k) = (&s.lathe, &s.temper, &s.knurl);
[
s.gate,
l.tilt,
l.odd_even,
l.between,
l.width,
l.smooth,
l.mix,
l.output,
t.drive,
t.bias,
t.normalize,
t.floor,
t.push_gain,
t.bright,
t.mix,
t.output,
k.edge,
k.level_jitter,
k.time_jitter,
k.grit,
k.tone,
k.position,
k.length,
k.mix,
k.output,
]
}
fn switch<P: Param>(setter: &ParamSetter, p: &P, old: P::Plain, new: P::Plain)
where
P::Plain: PartialEq,
{
if old != new {
setter.begin_set_parameter(p);
setter.set_parameter(p, new);
setter.end_set_parameter(p);
}
}
impl ForgeParams {
/// The drawn data. Falls back to defaults while the editor holds a lock.
pub fn drawn(&self) -> Drawn {
let mut d = Drawn::default();
if let Ok(g) = self.lathe.gains.read() {
d.gains = *g;
}
if let Ok(p) = self.knurl.pattern.read() {
d.pattern = *p;
}
d
}
/// Current settings. Drawn data falls back to `last` when the editor holds a lock, so the
/// audio thread never waits.
pub fn settings(&self, last: &Drawn) -> Settings {
let gains = self
.lathe
.gains
.try_read()
.map(|g| *g)
.unwrap_or(last.gains);
let pattern = self
.knurl
.pattern
.try_read()
.map(|p| *p)
.unwrap_or(last.pattern);
let (l, t, k) = (&self.lathe, &self.temper, &self.knurl);
let order = ORDER_PARAMS
.iter()
.position(|o| *o == self.order.value())
.unwrap_or(0);
let curve = self.temper.curve.value();
let mut s = Settings {
source: match self.source.value() {
SourceParam::Audio => Source::Audio,
SourceParam::Midi => Source::Midi,
},
gate: self.gate.value(),
order: ORDERS[order],
lathe: lathe_core::Settings {
gains: gains.map(|g| {
if g.is_finite() {
g.clamp(lathe_core::GAIN_MIN, lathe_core::GAIN_MAX)
} else {
0.
}
}),
tilt: l.tilt.value(),
odd_even: l.odd_even.value(),
between: l.between.value(),
width: l.width.value(),
harmonics: l.harmonics.value() as u32,
smooth: l.smooth.value(),
snap: l.snap.value(),
mix: l.mix.value(),
output: l.output.value(),
bypass: !l.on.value(),
..lathe_core::Settings::default()
},
temper: temper_core::Settings {
drive: t.drive.value(),
curve: CURVES
.iter()
.find(|(p, _)| *p == curve)
.map_or(Curve::Soft, |(_, c)| *c),
bias: t.bias.value(),
normalize: t.normalize.value(),
floor: t.floor.value(),
push_harmonic: t.push_harmonic.value() as u32,
push_gain: t.push_gain.value(),
bright: t.bright.value(),
auto_gain: t.auto_gain.value(),
mix: t.mix.value(),
output: t.output.value(),
bypass: !t.on.value(),
..temper_core::Settings::default()
},
knurl: knurl_core::Settings {
pattern: pattern.gains.map(|g| {
if g.is_finite() {
g.clamp(STEP_MIN, 0.)
} else {
0.
}
}),
grit_steps: pattern.grit,
steps: k.steps.value().clamp(1, STEPS as i32) as usize,
edge: k.edge.value(),
level_jitter: k.level_jitter.value(),
time_jitter: k.time_jitter.value(),
grit: k.grit.value(),
tone: k.tone.value(),
position: k.position.value(),
length: k.length.value(),
seed: match k.seed.value() {
SeedParam::Same => Seed::Same,
SeedParam::Free => Seed::Free,
},
snap: k.snap.value(),
mix: k.mix.value(),
output: k.output.value(),
bypass: !k.on.value(),
..knurl_core::Settings::default()
},
bypass: self.bypass.value(),
};
s.share();
s
}
fn floats(&self) -> [&FloatParam; FLOATS] {
let (l, t, k) = (&self.lathe, &self.temper, &self.knurl);
[
&self.gate,
&l.tilt,
&l.odd_even,
&l.between,
&l.width,
&l.smooth,
&l.mix,
&l.output,
&t.drive,
&t.bias,
&t.normalize,
&t.floor,
&t.push_gain,
&t.bright,
&t.mix,
&t.output,
&k.edge,
&k.level_jitter,
&k.time_jitter,
&k.grit,
&k.tone,
&k.position,
&k.length,
&k.mix,
&k.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. Drawn data is stored
/// directly.
pub fn apply(
&self,
setter: &ParamSetter,
old: &Settings,
new: &Settings,
dragging: bool,
open: &mut [bool; FLOATS],
) {
if new.lathe.gains != old.lathe.gains {
if let Ok(mut g) = self.lathe.gains.write() {
*g = new.lathe.gains;
}
}
if new.knurl.pattern != old.knurl.pattern || new.knurl.grit_steps != old.knurl.grit_steps {
if let Ok(mut p) = self.knurl.pattern.write() {
*p = Pattern {
gains: new.knurl.pattern,
grit: new.knurl.grit_steps,
};
}
}
let (a, b) = (float_values(old), float_values(new));
for (i, p) in self.floats().into_iter().enumerate() {
if a[i] != b[i] {
if !open[i] {
open[i] = true;
setter.begin_set_parameter(p);
}
setter.set_parameter(p, b[i]);
}
if !dragging && open[i] {
setter.end_set_parameter(p);
open[i] = false;
}
}
let (l, t, k) = (&self.lathe, &self.temper, &self.knurl);
switch(setter, &self.bypass, old.bypass, new.bypass);
switch(setter, &l.on, !old.lathe.bypass, !new.lathe.bypass);
switch(setter, &t.on, !old.temper.bypass, !new.temper.bypass);
switch(setter, &k.on, !old.knurl.bypass, !new.knurl.bypass);
switch(setter, &l.snap, old.lathe.snap, new.lathe.snap);
switch(setter, &k.snap, old.knurl.snap, new.knurl.snap);
switch(
setter,
&t.auto_gain,
old.temper.auto_gain,
new.temper.auto_gain,
);
switch(
setter,
&l.harmonics,
old.lathe.harmonics as i32,
new.lathe.harmonics as i32,
);
switch(
setter,
&t.push_harmonic,
old.temper.push_harmonic as i32,
new.temper.push_harmonic as i32,
);
switch(
setter,
&k.steps,
old.knurl.steps as i32,
new.knurl.steps as i32,
);
let source = |s: Source| match s {
Source::Audio => SourceParam::Audio,
Source::Midi => SourceParam::Midi,
};
switch(setter, &self.source, source(old.source), source(new.source));
let order =
|s: &Settings| ORDER_PARAMS[ORDERS.iter().position(|o| *o == s.order).unwrap_or(0)];
switch(setter, &self.order, order(old), order(new));
let curve = |c: Curve| {
CURVES
.iter()
.find(|(_, k)| *k == c)
.map_or(CurveParam::Soft, |(p, _)| *p)
};
switch(
setter,
&t.curve,
curve(old.temper.curve),
curve(new.temper.curve),
);
let seed = |s: Seed| match s {
Seed::Same => SeedParam::Same,
Seed::Free => SeedParam::Free,
};
switch(setter, &k.seed, seed(old.knurl.seed), seed(new.knurl.seed));
}
}
+231
View File
@@ -0,0 +1,231 @@
//! 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 effect_ports_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 = &forge::clap_entry;
assert!(entry.init.unwrap()(c"forge.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"Forge");
assert_eq!(
CStr::from_ptr((*descriptor).id),
c"io.narl.bass-foundry.forge"
);
assert!(factory.get_plugin_descriptor.unwrap()(factory, 1).is_null());
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());
assert_eq!((*ports).count.unwrap()(plugin, false), 1);
assert_eq!((*ports).count.unwrap()(plugin, true), 1);
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("lathe_tilt".into(), ParamValue::F32(-3.));
envelope
.params
.insert("lathe_harmonics".into(), ParamValue::I32(16));
let mut gains = [0f32; 32];
gains[2] = -12.;
gains[4] = 6.;
envelope
.fields
.insert("lathe_gains".into(), serde_json::to_string(&gains).unwrap());
envelope
.params
.insert("lathe_snap".into(), ParamValue::Bool(false));
// Every module off: the strip passes the input, delayed.
for module in ["lathe", "temper", "knurl"] {
envelope
.params
.insert(format!("{module}_on"), ParamValue::Bool(false));
}
envelope.params.insert(
"order".into(),
ParamValue::String("knurl-lathe-temper".into()),
);
envelope
.params
.insert("source".into(), ParamValue::String("midi".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()
);
let saved: [f32; 32] = serde_json::from_str(&restored.fields["lathe_gains"]).unwrap();
assert_eq!(saved, gains);
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
// One lookahead (about 67 ms), Temper's oversampling and Knurl's jitter headroom.
assert!((delay as f64 / rate - 0.072).abs() < 0.003, "{delay}");
assert!(p.start_processing.unwrap()(plugin));
let total = delay + 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);
// With every module off a 1 kHz tone passes unchanged.
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 left = [0f32; 512];
let mut right = [0f32; 512];
let mut in_channels = [input_left.as_mut_ptr(), input_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,
};
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,
audio_outputs: &mut outgoing_audio,
audio_inputs_count: 1,
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;
if n < delay {
assert!(left[i].abs() < 1e-6, "output before the lookahead");
} else if n > delay + 4800 {
energy_in += (tone(n - delay) 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 / (2. * energy_in)).log10();
assert!(ratio_db.abs() < 0.2, "{ratio_db}");
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}