Files
bass-foundry/plugins/patina/plugin/src/lib.rs
T
nvrl 9ca4e4f9dd feat(patina): per-band compressor gain stage with Threshold, Ratio and Knee
Tester feedback asked for a threshold and a ratio. Each band now cuts like a
small compressor: Threshold (dB above the local floor) replaces Selectivity,
Ratio scales the cut per dB above it (inf:1 is the old behaviour), and a
Soft/Hard knee switch sets the curve. A small graph shows bump height in, cut
out. Mix and Output move to the slider panel so the detection panel fits.
common-ui gains rotary_text for knobs with custom value text.
2026-11-06 17:29:54 +01:00

212 lines
7.7 KiB
Rust

//! 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, PatinaParams, COUNT};
use patina_core::{AnalyzerFrame, Engine, Monitor};
use std::sync::{
atomic::{AtomicBool, AtomicU64, Ordering},
Arc,
};
struct Shared {
analyzer: ArrayQueue<AnalyzerFrame>,
delta: AtomicBool,
reset: AtomicU64,
}
pub struct Patina {
params: Arc<PatinaParams>,
shared: Arc<Shared>,
engine: Option<Engine>,
publish_count: usize,
publish_interval: usize,
}
impl Default for Patina {
fn default() -> Self {
Self {
params: Arc::new(PatinaParams::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 Patina {
const NAME: &'static str = "Patina";
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 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(),
patina_ui::State::default(),
|ctx, state| {
state.delta = false;
patina_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| {
patina_ui::show(ui, &mut edited, state)
});
let old = values(&before);
let new = values(&edited);
let floats = params.floats();
for i in 0..COUNT {
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.knee != before.knee {
let knee = match edited.knee {
patina_core::Knee::Soft => params::KneeParam::Soft,
patina_core::Knee::Hard => params::KneeParam::Hard,
};
setter.begin_set_parameter(&params.knee);
setter.set_parameter(&params.knee, knee);
setter.end_set_parameter(&params.knee);
}
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 Patina {
const CLAP_ID: &'static str = "io.narl.bass-foundry.patina";
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!(Patina);