feat(ballast): add Ballast switch-up leveller
Ballast gives every part of a bassline one gain that brings it to the target loudness, with no compression inside a part. Parts start at note starts, detected from the audio or taken from MIDI, or on the host's beat grid, so a patch change on the same note counts too. - Loudness is K-weighted over each part's first 100 ms, in 10 ms blocks with a relative gate, and every part is weighted from rest so the last part does not ring into it. - The audio path is delayed by the tracker's lookahead, the window and a 5 ms glide, so each gain is in place on its part's first sample. - Learn takes the next part's loudness as the target. Amount, Range, Measure from (leave the sub out), Gate, Output, Bypass. - Editor shows the last 32 parts in and out, the target, and the spread of the last 16 parts. - Registered in the catalog, workflows, editor-check, README, chain and development docs, and the website copy.
This commit is contained in:
1 parent
d77b88c7a9
commit
d93dd942a8
28 files changed
+4716
-21
No files matched your search
Generated
+2381
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,25 @@
|
||||
[package]
|
||||
name = "ballast"
|
||||
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" }
|
||||
ballast-core = { path = "../core" }
|
||||
ballast-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,210 @@
|
||||
//! CLAP audio effect. All DSP state is owned by the processing thread.
|
||||
mod params;
|
||||
use ballast_core::{AnalyzerFrame, Engine};
|
||||
use crossbeam_queue::ArrayQueue;
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::{create_egui_editor, egui};
|
||||
use params::{BallastParams, FLOATS};
|
||||
use std::sync::{
|
||||
atomic::{AtomicBool, Ordering},
|
||||
Arc,
|
||||
};
|
||||
|
||||
struct Shared {
|
||||
analyzer: ArrayQueue<AnalyzerFrame>,
|
||||
/// Set by the editor's Learn button, taken by the audio thread.
|
||||
learn: AtomicBool,
|
||||
}
|
||||
|
||||
struct EditorData {
|
||||
ui: ballast_ui::State,
|
||||
gestures: [bool; FLOATS],
|
||||
}
|
||||
pub struct Ballast {
|
||||
params: Arc<BallastParams>,
|
||||
shared: Arc<Shared>,
|
||||
engine: Option<Engine>,
|
||||
publish_count: usize,
|
||||
publish_interval: usize,
|
||||
}
|
||||
impl Default for Ballast {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
params: Arc::new(BallastParams::default()),
|
||||
shared: Arc::new(Shared {
|
||||
analyzer: ArrayQueue::new(2),
|
||||
learn: AtomicBool::new(false),
|
||||
}),
|
||||
engine: None,
|
||||
publish_count: 0,
|
||||
publish_interval: 1600,
|
||||
}
|
||||
}
|
||||
}
|
||||
impl Plugin for Ballast {
|
||||
const NAME: &'static str = "Ballast";
|
||||
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 shared = self.shared.clone();
|
||||
create_egui_editor(
|
||||
params.editor.clone(),
|
||||
EditorData {
|
||||
ui: ballast_ui::State::default(),
|
||||
gestures: [false; FLOATS],
|
||||
},
|
||||
|ctx, _| ballast_ui::style(ctx),
|
||||
move |ctx, setter, data| {
|
||||
while let Some(frame) = shared.analyzer.pop() {
|
||||
data.ui.accept_frame(frame);
|
||||
}
|
||||
let before = params.settings();
|
||||
let mut edited = before;
|
||||
nih_plug_egui::resizable_window::ResizableWindow::new("ballast-editor")
|
||||
.min_size(egui::vec2(
|
||||
ballast_ui::WIDTH as f32 - 80.,
|
||||
ballast_ui::HEIGHT as f32 - 40.,
|
||||
))
|
||||
.show(ctx, ¶ms.editor, |ui| {
|
||||
ballast_ui::show(ui, &mut edited, &mut data.ui)
|
||||
});
|
||||
params.apply(
|
||||
setter,
|
||||
&before,
|
||||
&edited,
|
||||
data.ui.gesture_active,
|
||||
&mut data.gestures,
|
||||
);
|
||||
if std::mem::take(&mut data.ui.learn_request) {
|
||||
shared.learn.store(true, Ordering::Release);
|
||||
}
|
||||
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.params.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 Some(engine) = self.engine.as_mut() else {
|
||||
return ProcessStatus::Normal;
|
||||
};
|
||||
let settings = self.params.settings();
|
||||
let _ = engine.set_settings(settings);
|
||||
if self.shared.learn.swap(false, Ordering::AcqRel) {
|
||||
engine.learn();
|
||||
}
|
||||
let transport = context.transport();
|
||||
engine.set_transport(
|
||||
transport.tempo,
|
||||
if transport.playing {
|
||||
transport.pos_beats()
|
||||
} else {
|
||||
None
|
||||
},
|
||||
);
|
||||
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 {
|
||||
let _ = self.shared.analyzer.force_push(engine.take_analyzer());
|
||||
self.publish_count = 0;
|
||||
}
|
||||
}
|
||||
// The lookahead delay must drain after input stops.
|
||||
ProcessStatus::KeepAlive
|
||||
}
|
||||
}
|
||||
impl ClapPlugin for Ballast {
|
||||
const CLAP_ID: &'static str = "io.narl.bass-foundry.ballast";
|
||||
const CLAP_DESCRIPTION: Option<&'static str> =
|
||||
Some("Levels every note or switch-up to one loudness with a single gain each");
|
||||
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!(Ballast);
|
||||
@@ -0,0 +1,203 @@
|
||||
use ballast_core::{
|
||||
Grid, Segment, Settings, Source, FROM_MAX, FROM_MIN, RANGE_MAX, TARGET_MAX, TARGET_MIN,
|
||||
};
|
||||
use nih_plug::prelude::*;
|
||||
use nih_plug_egui::EguiState;
|
||||
use std::sync::Arc;
|
||||
|
||||
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum SourceParam {
|
||||
#[id = "audio"]
|
||||
Audio,
|
||||
#[id = "midi"]
|
||||
#[name = "MIDI"]
|
||||
Midi,
|
||||
}
|
||||
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum SegmentParam {
|
||||
#[id = "notes"]
|
||||
Notes,
|
||||
#[id = "grid"]
|
||||
Grid,
|
||||
}
|
||||
#[derive(Enum, Debug, PartialEq, Eq, Clone, Copy)]
|
||||
pub enum GridParam {
|
||||
#[id = "bar"]
|
||||
#[name = "1 bar"]
|
||||
Bar,
|
||||
#[id = "1-2"]
|
||||
#[name = "1/2"]
|
||||
Half,
|
||||
#[id = "1-4"]
|
||||
#[name = "1/4"]
|
||||
Quarter,
|
||||
#[id = "1-8"]
|
||||
#[name = "1/8"]
|
||||
Eighth,
|
||||
#[id = "1-16"]
|
||||
#[name = "1/16"]
|
||||
Sixteenth,
|
||||
}
|
||||
const GRIDS: [(GridParam, Grid); 5] = [
|
||||
(GridParam::Bar, Grid::Bar),
|
||||
(GridParam::Half, Grid::Half),
|
||||
(GridParam::Quarter, Grid::Quarter),
|
||||
(GridParam::Eighth, Grid::Eighth),
|
||||
(GridParam::Sixteenth, Grid::Sixteenth),
|
||||
];
|
||||
fn grid_param(g: Grid) -> GridParam {
|
||||
GRIDS
|
||||
.iter()
|
||||
.find(|(_, v)| *v == g)
|
||||
.map_or(GridParam::Quarter, |(p, _)| *p)
|
||||
}
|
||||
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)
|
||||
}
|
||||
/// Continuous controls, in the order of the gesture slots.
|
||||
pub const FLOATS: usize = 6;
|
||||
|
||||
#[derive(Params)]
|
||||
pub struct BallastParams {
|
||||
#[persist = "editor"]
|
||||
pub editor: Arc<EguiState>,
|
||||
#[id = "source"]
|
||||
pub source: EnumParam<SourceParam>,
|
||||
#[id = "segment"]
|
||||
pub segment: EnumParam<SegmentParam>,
|
||||
#[id = "grid"]
|
||||
pub grid: EnumParam<GridParam>,
|
||||
#[id = "target"]
|
||||
pub target: FloatParam,
|
||||
#[id = "amount"]
|
||||
pub amount: FloatParam,
|
||||
#[id = "range"]
|
||||
pub range: FloatParam,
|
||||
#[id = "from"]
|
||||
pub from: FloatParam,
|
||||
#[id = "gate"]
|
||||
pub gate: FloatParam,
|
||||
#[id = "output"]
|
||||
pub output: FloatParam,
|
||||
#[id = "bypass"]
|
||||
pub bypass: BoolParam,
|
||||
}
|
||||
impl Default for BallastParams {
|
||||
fn default() -> Self {
|
||||
let d = Settings::default();
|
||||
Self {
|
||||
editor: EguiState::from_size(ballast_ui::WIDTH, ballast_ui::HEIGHT),
|
||||
source: EnumParam::new("Note source", SourceParam::Audio),
|
||||
segment: EnumParam::new("Parts from", SegmentParam::Notes),
|
||||
grid: EnumParam::new("Grid", grid_param(d.grid)),
|
||||
target: linear("Target", d.target, TARGET_MIN, TARGET_MAX, " LUFS"),
|
||||
amount: linear("Amount", d.amount, 0., 100., " %"),
|
||||
range: linear("Range", d.range, 0., RANGE_MAX, " dB"),
|
||||
from: FloatParam::new(
|
||||
"Measure from",
|
||||
d.from,
|
||||
FloatRange::Skewed {
|
||||
min: FROM_MIN,
|
||||
max: FROM_MAX,
|
||||
factor: FloatRange::skew_factor(-1.),
|
||||
},
|
||||
)
|
||||
.with_unit(" Hz"),
|
||||
gate: linear("Gate", d.gate, -60., -20., " dB"),
|
||||
output: linear("Output", d.output, -12., 12., " dB"),
|
||||
bypass: BoolParam::new("Bypass", false).make_bypass(),
|
||||
}
|
||||
}
|
||||
}
|
||||
impl BallastParams {
|
||||
pub fn settings(&self) -> Settings {
|
||||
let grid = self.grid.value();
|
||||
Settings {
|
||||
source: match self.source.value() {
|
||||
SourceParam::Audio => Source::Audio,
|
||||
SourceParam::Midi => Source::Midi,
|
||||
},
|
||||
segment: match self.segment.value() {
|
||||
SegmentParam::Notes => Segment::Notes,
|
||||
SegmentParam::Grid => Segment::Grid,
|
||||
},
|
||||
grid: GRIDS
|
||||
.iter()
|
||||
.find(|(p, _)| *p == grid)
|
||||
.map_or(Grid::Quarter, |(_, g)| *g),
|
||||
target: self.target.value(),
|
||||
amount: self.amount.value(),
|
||||
range: self.range.value(),
|
||||
from: self.from.value(),
|
||||
gate: self.gate.value(),
|
||||
output: self.output.value(),
|
||||
bypass: self.bypass.value(),
|
||||
}
|
||||
}
|
||||
fn floats(&self) -> [&FloatParam; FLOATS] {
|
||||
[
|
||||
&self.target,
|
||||
&self.amount,
|
||||
&self.range,
|
||||
&self.from,
|
||||
&self.gate,
|
||||
&self.output,
|
||||
]
|
||||
}
|
||||
fn values(s: &Settings) -> [f32; FLOATS] {
|
||||
[s.target, s.amount, s.range, s.from, s.gate, s.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 pairs = Self::values(old).into_iter().zip(Self::values(new));
|
||||
for (i, (p, (a, b))) in self.floats().into_iter().zip(pairs).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.source != old.source {
|
||||
let v = match new.source {
|
||||
Source::Audio => SourceParam::Audio,
|
||||
Source::Midi => SourceParam::Midi,
|
||||
};
|
||||
setter.begin_set_parameter(&self.source);
|
||||
setter.set_parameter(&self.source, v);
|
||||
setter.end_set_parameter(&self.source);
|
||||
}
|
||||
if new.segment != old.segment {
|
||||
let v = match new.segment {
|
||||
Segment::Notes => SegmentParam::Notes,
|
||||
Segment::Grid => SegmentParam::Grid,
|
||||
};
|
||||
setter.begin_set_parameter(&self.segment);
|
||||
setter.set_parameter(&self.segment, v);
|
||||
setter.end_set_parameter(&self.segment);
|
||||
}
|
||||
if new.grid != old.grid {
|
||||
setter.begin_set_parameter(&self.grid);
|
||||
setter.set_parameter(&self.grid, grid_param(new.grid));
|
||||
setter.end_set_parameter(&self.grid);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,216 @@
|
||||
//! 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 = &ballast::clap_entry;
|
||||
assert!(entry.init.unwrap()(c"ballast.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"Ballast");
|
||||
assert_eq!(
|
||||
CStr::from_ptr((*descriptor).id),
|
||||
c"io.narl.bass-foundry.ballast"
|
||||
);
|
||||
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;
|
||||
// Amount 0 passes the input through the lookahead delay unchanged.
|
||||
envelope.params.insert("amount".into(), ParamValue::F32(0.));
|
||||
envelope
|
||||
.params
|
||||
.insert("target".into(), ParamValue::F32(-20.));
|
||||
envelope.params.insert("from".into(), ParamValue::F32(100.));
|
||||
envelope
|
||||
.params
|
||||
.insert("segment".into(), ParamValue::String("grid".into()));
|
||||
envelope
|
||||
.params
|
||||
.insert("grid".into(), ParamValue::String("1-8".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()
|
||||
);
|
||||
for rate in [44100., 48000., 96000.] {
|
||||
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
|
||||
let delay = (*latency).get.unwrap()(plugin) as usize;
|
||||
// The tracker's 67 ms, the 100 ms measuring window and the 5 ms ramp.
|
||||
assert!((delay as f64 / rate - 0.172).abs() < 0.005, "{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);
|
||||
// A 1 kHz tone at amount 0.
|
||||
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()();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user