feat: add resonance compressor and Windows CI

This commit is contained in:
nvrl committed 2026-11-05 23:37:24 +01:00
1 parent 3281535a04
commit 3c5b05fdec
31 files changed
+6340 -57

No files matched your search

File diff suppressed because it is too large. Load diff
+25
View File
@@ -0,0 +1,25 @@
[package]
name = "resonance-compressor"
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" }
resonance-core = { path = "../../crates/resonance-core" }
resonance-ui = { path = "../../crates/resonance-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"
+202
View File
@@ -0,0 +1,202 @@
//! CLAP audio effect. 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::{values, ResonanceParams};
use resonance_core::{AnalyzerFrame, Engine, Monitor};
use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
};
struct Shared {
analyzer: ArrayQueue<AnalyzerFrame>,
delta: AtomicBool,
reset: AtomicU64,
}
pub struct ResonanceCompressor {
params: Arc<ResonanceParams>,
shared: Arc<Shared>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for ResonanceCompressor {
fn default() -> Self {
Self {
params: Arc::new(ResonanceParams::default()),
shared: Arc::new(Shared {
analyzer: ArrayQueue::new(3),
delta: AtomicBool::new(false),
reset: AtomicU64::new(0),
}),
engine: None,
publish_count: 0,
publish_interval: 1600,
}
}
}
impl Plugin for ResonanceCompressor {
const NAME: &'static str = "Resonance Compressor";
const VENDOR: &'static str = "Storyboard";
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 SAMPLE_ACCURATE_AUTOMATION: bool = true;
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(),
resonance_ui::State::default(),
|ctx, state| {
state.delta = false;
resonance_ui::style(ctx);
},
move |ctx, setter, state| {
let epoch = shared.reset.load(Ordering::Acquire);
if epoch != state_epoch(ctx) {
state.delta = false;
store_epoch(ctx, epoch);
}
// Delta is never retained after editor close/reopen or a host reset.
shared.delta.store(state.delta, Ordering::Release);
while let Some(frame) = shared.analyzer.pop() {
state.accept_frame(frame);
}
let before = params.settings();
let mut edited = before;
nih_plug_egui::resizable_window::ResizableWindow::new("resonance-editor")
.min_size(egui::vec2(1040., 700.))
.show(ctx, &params.editor, |ui| {
resonance_ui::show(ui, &mut edited, state)
});
let old = values(&before);
let new = values(&edited);
let floats = params.floats();
for i in 0..29 {
if new[i] != old[i] {
if !state.gestures[i] {
setter.begin_set_parameter(floats[i]);
state.gestures[i] = true;
}
setter.set_parameter(floats[i], new[i]);
}
if !state.gesture_active && state.gestures[i] {
setter.end_set_parameter(floats[i]);
state.gestures[i] = false;
}
}
if edited.bypass != before.bypass {
setter.begin_set_parameter(&params.bypass);
setter.set_parameter(&params.bypass, edited.bypass);
setter.end_set_parameter(&params.bypass);
}
shared.delta.store(state.delta, Ordering::Release);
},
)
}
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);
self.shared.delta.store(false, Ordering::Release);
self.shared.reset.fetch_add(1, Ordering::AcqRel);
true
}
fn reset(&mut self) {
if let Some(e) = self.engine.as_mut() {
e.reset();
}
self.publish_count = 0;
self.shared.delta.store(false, Ordering::Release);
self.shared.reset.fetch_add(1, Ordering::AcqRel);
}
fn process(
&mut self,
buffer: &mut Buffer,
_: &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());
let delta = self.shared.delta.load(Ordering::Acquire) && self.params.editor.is_open();
for mut sample in buffer.iter_samples() {
let x = [
*sample.get_mut(0).unwrap(),
if engine.channels() == 2 {
*sample.get_mut(1).unwrap()
} else {
0.
},
];
let out = engine.process_sample(x, Monitor { delta });
for (c, value) in sample.into_iter().enumerate() {
*value = out[c];
}
self.publish_count += 1;
if self.publish_count >= self.publish_interval {
if let Some(frame) = engine.take_analyzer() {
self.shared.analyzer.force_push(frame);
}
self.publish_count = 0;
}
}
// The scheduler must drain its overlap buffers on silent input.
ProcessStatus::KeepAlive
}
}
fn state_epoch(ctx: &egui::Context) -> u64 {
ctx.data(|d| d.get_temp(egui::Id::new("reset-epoch")).unwrap_or(0))
}
fn store_epoch(ctx: &egui::Context, e: u64) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("reset-epoch"), e));
}
impl ClapPlugin for ResonanceCompressor {
const CLAP_ID: &'static str = "io.storyboard.resonance-compressor";
const CLAP_DESCRIPTION: Option<&'static str> =
Some("Dynamic resonance attenuation with explicit tone protection");
const CLAP_MANUAL_URL: Option<&'static str> = None;
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Compressor,
ClapFeature::Stereo,
ClapFeature::Mono,
];
}
nih_export_clap!(ResonanceCompressor);
+188
View File
@@ -0,0 +1,188 @@
use nih_plug::prelude::*;
use nih_plug_egui::EguiState;
use resonance_core::{Focus, Settings};
use std::sync::Arc;
#[derive(Params)]
pub struct FocusParams {
#[id = "enabled"]
pub enabled: FloatParam,
#[id = "frequency"]
pub frequency: FloatParam,
#[id = "width"]
pub width: FloatParam,
#[id = "sensitivity"]
pub sensitivity: FloatParam,
}
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 log(name: &str, v: f32, min: f32, max: f32, unit: &'static str) -> FloatParam {
FloatParam::new(
name,
v,
FloatRange::Skewed {
min,
max,
factor: FloatRange::skew_factor(-2.),
},
)
.with_unit(unit)
}
impl Default for FocusParams {
fn default() -> Self {
Self {
enabled: linear("Enabled", 0., 0., 1., "").with_step_size(1.),
frequency: log("Frequency", 2000., 20., 20000., " Hz"),
width: linear("Width", 0.5, 0.05, 2., " oct"),
sensitivity: linear("Sensitivity", 0., -18., 18., " dB"),
}
}
}
#[derive(Params)]
pub struct ResonanceParams {
#[persist = "editor"]
pub editor: Arc<EguiState>,
#[id = "amount"]
pub amount: FloatParam,
#[id = "sensitivity"]
pub sensitivity: FloatParam,
#[id = "max-cut"]
pub max_cut: FloatParam,
#[id = "neighborhood"]
pub neighborhood: FloatParam,
#[id = "cut-width"]
pub cut_width: FloatParam,
#[id = "knee"]
pub knee: FloatParam,
#[id = "attack"]
pub attack: FloatParam,
#[id = "release"]
pub release: FloatParam,
#[id = "transient-guard"]
pub transient_guard: FloatParam,
#[id = "low"]
pub low: FloatParam,
#[id = "high"]
pub high: FloatParam,
#[id = "mix"]
pub mix: FloatParam,
#[id = "output"]
pub output: FloatParam,
#[id = "bypass"]
pub bypass: BoolParam,
#[nested(array, group = "Focus")]
pub focus: [FocusParams; 4],
}
impl Default for ResonanceParams {
fn default() -> Self {
Self {
editor: EguiState::from_size(1180, 760),
amount: linear("Amount", 35., 0., 100., " %"),
sensitivity: linear("Sensitivity", 6., 0., 24., " dB"),
max_cut: linear("Max cut", 3., 0., 18., " dB"),
neighborhood: linear("Neighborhood", 1., 0.25, 2., " oct"),
cut_width: linear("Cut width", 0.08, 0.03, 0.5, " oct"),
knee: linear("Knee", 3., 0., 12., " dB"),
attack: log("Attack", 15., 1., 200., " ms"),
release: log("Release", 120., 10., 1000., " ms"),
transient_guard: linear("Transient guard", 50., 0., 100., " %"),
low: log("Low frequency", 120., 20., 19999., " Hz"),
high: log("High frequency", 12000., 21., 20000., " Hz"),
mix: linear("Mix", 100., 0., 100., " %"),
output: linear("Output", 0., -12., 12., " dB"),
bypass: BoolParam::new("Bypass", false).make_bypass(),
focus: std::array::from_fn(|_| FocusParams::default()),
}
}
}
impl ResonanceParams {
pub fn floats(&self) -> [&FloatParam; 29] {
[
&self.amount,
&self.sensitivity,
&self.max_cut,
&self.neighborhood,
&self.cut_width,
&self.knee,
&self.attack,
&self.release,
&self.transient_guard,
&self.low,
&self.high,
&self.mix,
&self.output,
&self.focus[0].enabled,
&self.focus[0].frequency,
&self.focus[0].width,
&self.focus[0].sensitivity,
&self.focus[1].enabled,
&self.focus[1].frequency,
&self.focus[1].width,
&self.focus[1].sensitivity,
&self.focus[2].enabled,
&self.focus[2].frequency,
&self.focus[2].width,
&self.focus[2].sensitivity,
&self.focus[3].enabled,
&self.focus[3].frequency,
&self.focus[3].width,
&self.focus[3].sensitivity,
]
}
pub fn settings(&self) -> Settings {
let mut s = Settings {
amount: self.amount.value(),
sensitivity: self.sensitivity.value(),
max_cut: self.max_cut.value(),
neighborhood: self.neighborhood.value(),
cut_width: self.cut_width.value(),
knee: self.knee.value(),
attack: self.attack.value(),
release: self.release.value(),
transient_guard: self.transient_guard.value(),
low: self.low.value(),
high: self.high.value(),
mix: self.mix.value(),
output: self.output.value(),
bypass: self.bypass.value(),
focus: std::array::from_fn(|i| Focus {
enabled: self.focus[i].enabled.value() >= 0.5,
frequency: self.focus[i].frequency.value(),
width: self.focus[i].width.value(),
sensitivity: self.focus[i].sensitivity.value(),
}),
};
// Independent automation of bounds may cross them; preserve a valid effective range.
if s.low >= s.high {
s.low = (s.high - 1.).max(20.);
}
s
}
}
pub fn values(s: &Settings) -> [f32; 29] {
let mut v = [0.; 29];
v[..13].copy_from_slice(&[
s.amount,
s.sensitivity,
s.max_cut,
s.neighborhood,
s.cut_width,
s.knee,
s.attack,
s.release,
s.transient_guard,
s.low,
s.high,
s.mix,
s.output,
]);
for i in 0..4 {
v[13 + i * 4..17 + i * 4].copy_from_slice(&[
f32::from(s.focus[i].enabled),
s.focus[i].frequency,
s.focus[i].width,
s.focus[i].sensitivity,
]);
}
v
}
+199
View File
@@ -0,0 +1,199 @@
//! 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 = &resonance_compressor::clap_entry;
assert!(entry.init.unwrap()(c"resonance-compressor.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).id),
c"io.storyboard.resonance-compressor"
);
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("amount".into(), ParamValue::F32(0.));
envelope
.params
.insert("frequency_1".into(), ParamValue::F32(2300.));
envelope
.params
.insert("enabled_1".into(), ParamValue::F32(1.));
envelope
.params
.insert("sensitivity_1".into(), ParamValue::F32(-12.));
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()
);
assert!(!restored.fields.contains_key("delta"));
for rate in [44100., 48000., 96000.] {
assert!(p.activate.unwrap()(plugin, rate, 1, 512));
let delay = (*latency).get.unwrap()(plugin) as usize;
assert_eq!(delay, if rate > 48000. { 8192 } else { 4096 });
assert!(p.start_processing.unwrap()(plugin));
let mut position = 0;
while position < delay + 1024 {
let frames = [1, 127, 512, 31][position % 4].min(delay + 1024 - position);
let mut input_left = [0f32; 512];
let mut input_right = [0f32; 512];
for i in 0..frames {
let x = if position + i == 0 { 0.5 } else { 0. };
input_left[i] = x;
input_right[i] = -x;
}
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 expected = if position + i == delay { 0.5 } else { 0. };
assert!((left[i] - expected).abs() < 1e-6);
assert!((right[i] + expected).abs() < 1e-6);
}
position += frames;
}
p.stop_processing.unwrap()(plugin);
p.deactivate.unwrap()(plugin);
}
p.destroy.unwrap()(plugin);
entry.deinit.unwrap()();
}
}