Restructured project for separate plugins

This commit is contained in:
Ebu
2025-12-04 09:34:55 +01:00
parent 3cf22b7189
commit 28f14ba713
29 changed files with 364 additions and 248 deletions

6
.cargo/config.toml Normal file
View File

@@ -0,0 +1,6 @@
[unstable]
unstable-options = true
bindeps = true
[build]
artifact-dir = "out"

1
.gitignore vendored
View File

@@ -1 +1,2 @@
/target /target
clap-host

27
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,27 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"type": "lldb",
"request": "launch",
"name": "Debug unit tests in library 'dsp'",
"cargo": {
"args": [
"test",
"--no-run",
"--lib",
"--package=ebu-dsp"
],
"filter": {
"name": "dsp",
"kind": "lib"
}
},
"args": [],
"cwd": "${workspaceFolder}"
}
]
}

32
Cargo.lock generated
View File

@@ -17,6 +17,20 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "airfreshener"
version = "0.1.0"
dependencies = [
"baseview",
"crossbeam",
"dsp",
"femtovg",
"image",
"nih_plug",
"parking_lot",
"serde",
]
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.100" version = "1.0.100"
@@ -321,7 +335,7 @@ dependencies = [
] ]
[[package]] [[package]]
name = "ebu-dsp" name = "dsp"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"femtovg", "femtovg",
@@ -330,17 +344,11 @@ dependencies = [
] ]
[[package]] [[package]]
name = "ebu-plug-core" name = "ebu-plugs"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"baseview", "airfreshener",
"crossbeam", "helpers",
"ebu-dsp",
"femtovg",
"image",
"nih_plug",
"parking_lot",
"serde",
] ]
[[package]] [[package]]
@@ -469,6 +477,10 @@ dependencies = [
"web-sys", "web-sys",
] ]
[[package]]
name = "helpers"
version = "0.1.0"
[[package]] [[package]]
name = "hermit-abi" name = "hermit-abi"
version = "0.1.19" version = "0.1.19"

View File

@@ -1,5 +1,5 @@
[package] [package]
name = "ebu-plug-core" name = "ebu-plugs"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"
@@ -7,14 +7,10 @@ edition = "2024"
crate-type = ["cdylib"] crate-type = ["cdylib"]
[dependencies] [dependencies]
ebu-dsp = { version = "0.1.0", path = "ebu-dsp" } airfreshener = { path = "plug-airfreshener", lib = true, artifact = "cdylib" }
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug", version = "0.0.0", default-features = false }
parking_lot = "0.12.5" [build-dependencies]
baseview = { git = "https://github.com/RustAudio/baseview", rev = "237d323c729f3aa99476ba3efa50129c5e86cad3", features = ["opengl"]} helpers = { path = "helpers" }
crossbeam = "0.8.4"
femtovg = "0.19.3"
serde = { version = "1.0.228", features = ["derive"] }
image = { version = "0.25.9", default-features = false, features = ["png"] }
[workspace] [workspace]
members = ["ebu-dsp"] members = ["dsp", "helpers", "plug-airfreshener"]

3
build.rs Normal file
View File

@@ -0,0 +1,3 @@
fn main() {
//helpers::copy_artifact_to_out_dir("airfreshener", "AirFreshener");
}

View File

@@ -1,5 +1,5 @@
[package] [package]
name = "ebu-dsp" name = "dsp"
version = "0.1.0" version = "0.1.0"
edition = "2024" edition = "2024"

View File

@@ -33,7 +33,6 @@ impl SpriteSheet {
scale_factor: Arc<AtomicF32>, scale_factor: Arc<AtomicF32>,
frame_width: usize, frame_width: usize,
frame_height: usize, frame_height: usize,
frames: usize,
) -> Self { ) -> Self {
let image = canvas let image = canvas
.load_image_mem(data, ImageFlags::empty()) .load_image_mem(data, ImageFlags::empty())

6
helpers/Cargo.toml Normal file
View File

@@ -0,0 +1,6 @@
[package]
name = "helpers"
version = "0.1.0"
edition = "2024"
[dependencies]

40
helpers/src/lib.rs Normal file
View File

@@ -0,0 +1,40 @@
use std::{
env, fs,
path::{Path, PathBuf},
};
#[cfg(debug_assertions)]
const CONFIG: &str = "debug";
#[cfg(not(debug_assertions))]
const CONFIG: &str = "release";
#[cfg(target_os = "macos")]
const OS_LIB_EXT: &str = "dylib";
#[cfg(target_os = "windows")]
const OS_LIB_EXT: &str = "dll";
#[cfg(target_os = "linux")]
const OS_LIB_EXT: &str = "so";
#[cfg(target_os = "macos")]
const OS_NAME: &str = "macOS";
#[cfg(target_os = "windows")]
const OS_NAME: &str = "Windows";
#[cfg(target_os = "linux")]
const OS_NAME: &str = "Linux";
pub fn built_lib_path(lib_name: &str) -> PathBuf {
Path::new(&env::var("CARGO_MANIFEST_DIR").unwrap_or("".to_owned()))
.join(format!("target/{CONFIG}/lib{lib_name}.{OS_LIB_EXT}"))
}
pub fn copy_artifact_to_out_dir(lib_name: &str, pretty_name: &str) {
let src_path = built_lib_path(lib_name);
let dst_path = Path::new(&env::var("CARGO_MANIFEST_DIR").unwrap_or("".to_owned())).join(
format!("target/build/{CONFIG}/{pretty_name}_{OS_NAME}.clap"),
);
fs::create_dir_all(dst_path.parent().unwrap())
.expect("Failed to create artifact output directory");
fs::copy(src_path, dst_path).expect("Failed to move artifact");
}

View File

@@ -0,0 +1,17 @@
[package]
name = "airfreshener"
version = "0.1.0"
edition = "2024"
[lib]
crate-type = ["cdylib"]
[dependencies]
dsp = { version = "0.1.0", path = "../dsp" }
nih_plug = { git = "https://github.com/robbert-vdh/nih-plug", version = "0.0.0", default-features = false }
parking_lot = "0.12.5"
baseview = { git = "https://github.com/RustAudio/baseview", rev = "237d323c729f3aa99476ba3efa50129c5e86cad3", features = ["opengl"]}
crossbeam = "0.8.4"
femtovg = "0.19.3"
serde = { version = "1.0.228", features = ["derive"] }
image = { version = "0.25.9", default-features = false, features = ["png"] }

View File

@@ -2,16 +2,16 @@ use crate::parameters::PluginParams;
use baseview::{ use baseview::{
Event, EventStatus, MouseButton, MouseEvent, WindowEvent, WindowHandler, gl::GlContext, Event, EventStatus, MouseButton, MouseEvent, WindowEvent, WindowHandler, gl::GlContext,
}; };
use ebu_dsp::{ScaledPoint, ScaledRect, Sprite, SpriteSheet}; use dsp::{ScaledPoint, ScaledRect, Sprite, SpriteSheet};
use femtovg::{Canvas, Color, FontId, Paint, Path, renderer::OpenGl}; use femtovg::{Canvas, Color, FontId, Paint, Path, renderer::OpenGl};
use nih_plug::prelude::*; use nih_plug::prelude::*;
use std::sync::{Arc, atomic::Ordering}; use std::sync::{Arc, atomic::Ordering};
const DROID_SANS_FONT: &'static [u8] = include_bytes!("../assets/DroidSans.ttf"); const DROID_SANS_FONT: &'static [u8] = include_bytes!("../../assets/DroidSans.ttf");
const FRESHENER_IMAGE: &'static [u8] = include_bytes!("../assets/AirFreshener/sheet.png"); const FRESHENER_IMAGE: &'static [u8] = include_bytes!("../../assets/AirFreshener/sheet.png");
const NOT_SO_FRESH_BG_IMAGE: &'static [u8] = include_bytes!("../assets/AirFreshener/bg0.png"); const NOT_SO_FRESH_BG_IMAGE: &'static [u8] = include_bytes!("../../assets/AirFreshener/bg0.png");
const FRESH_DUMBLEDORE_BG_IMAGE: &'static [u8] = include_bytes!("../assets/AirFreshener/bg1.png"); const FRESH_DUMBLEDORE_BG_IMAGE: &'static [u8] = include_bytes!("../../assets/AirFreshener/bg1.png");
const FRESHENER_FRAMES: usize = 256; const FRESHENER_FRAMES: usize = 256;
const FRESHENER_FRAME_WIDTH: usize = 73; const FRESHENER_FRAME_WIDTH: usize = 73;
const FRESHENER_FRAME_HEIGHT: usize = 144; const FRESHENER_FRAME_HEIGHT: usize = 144;
@@ -88,7 +88,6 @@ impl PluginGui {
scaling_factor.clone(), scaling_factor.clone(),
FRESHENER_FRAME_WIDTH, FRESHENER_FRAME_WIDTH,
FRESHENER_FRAME_HEIGHT, FRESHENER_FRAME_HEIGHT,
FRESHENER_FRAMES,
); );
this.freshener_image_bounds = this this.freshener_image_bounds = this
.freshener_image .freshener_image
@@ -100,6 +99,13 @@ impl PluginGui {
); );
this.not_so_fresh_image = this.not_so_fresh_image =
Sprite::new(&mut canvas, NOT_SO_FRESH_BG_IMAGE, scaling_factor.clone()); Sprite::new(&mut canvas, NOT_SO_FRESH_BG_IMAGE, scaling_factor.clone());
let canvas_width = canvas.width();
let editor_width = params.editor_state.size.load().0;
if canvas_width != editor_width {
this.scaling_factor
.store(canvas_width as f32 / editor_width as f32, Ordering::Release);
}
this.canvas = Some(canvas); this.canvas = Some(canvas);
} }
unsafe { unsafe {
@@ -164,8 +170,10 @@ impl WindowHandler for PluginGui {
let freshness = self.params.freshness.unmodulated_normalized_value(); let freshness = self.params.freshness.unmodulated_normalized_value();
let frame_index = (freshness * (FRESHENER_FRAMES - 1) as f32).floor() as usize; let frame_index = (freshness * (FRESHENER_FRAMES - 1) as f32).floor() as usize;
self.not_so_fresh_image.draw(canvas, 0.0, 0.0, 1.0); self.not_so_fresh_image.draw(canvas, 0.0, 0.0, 1.0);
self.fresh_dumbledore_image.draw(canvas, 0.0, 0.0, freshness); self.fresh_dumbledore_image
self.freshener_image.draw(canvas, FRESHENER_SCREEN_X, FRESHENER_SCREEN_Y, frame_index); .draw(canvas, 0.0, 0.0, freshness);
self.freshener_image
.draw(canvas, FRESHENER_SCREEN_X, FRESHENER_SCREEN_Y, frame_index);
#[cfg(debug_assertions)] #[cfg(debug_assertions)]
{ {

View File

@@ -0,0 +1,218 @@
mod editor;
mod gui;
mod parameters;
mod window;
use dsp::{
BiquadFilter, BiquadFilterState, Compressor, CompressorState, Decibel, Equalizer,
EqualizerState, FilterMode, FreqSplitter, FreqSplitterState, Lerp, Processor, Ratio,
SampleRate,
};
use nih_plug::prelude::*;
use std::{num::NonZero, sync::Arc};
use crate::{parameters::PluginParams, window::EditorWindow};
struct AirFreshener {
params: Arc<PluginParams>,
eqs: Vec<Equalizer>,
compressors: Vec<Compressor>,
splitters: Vec<FreqSplitter>,
}
impl Default for AirFreshener {
fn default() -> Self {
Self {
params: Arc::new(PluginParams::default()),
eqs: Vec::new(),
compressors: Vec::new(),
splitters: Vec::new(),
}
}
}
impl Plugin for AirFreshener {
const NAME: &'static str = "Air Freshener";
const VENDOR: &'static str = "Simon Elberich";
// You can use `env!("CARGO_PKG_HOMEPAGE")` to reference the homepage field from the
// `Cargo.toml` file here
const URL: &'static str = "https://youtu.be/dQw4w9WgXcQ";
const EMAIL: &'static str = "info@example.com";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
// The first audio IO layout is used as the default. The other layouts may be selected either
// explicitly or automatically by the host or the user depending on the plugin API/backend.
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
aux_input_ports: &[],
aux_output_ports: &[],
// Individual ports and the layout as a whole can be named here. By default these names
// are generated as needed. This layout will be called 'Stereo', while the other one is
// given the name 'Mono' based no the number of input and output channels.
names: PortNames::const_default(),
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
..AudioIOLayout::const_default()
},
];
const MIDI_INPUT: MidiConfig = MidiConfig::None;
// Setting this to `true` will tell the wrapper to split the buffer up into smaller blocks
// whenever there are inter-buffer parameter changes. This way no changes to the plugin are
// required to support sample accurate automation and the wrapper handles all of the boring
// stuff like making sure transport and other timing information stays consistent between the
// splits.
const SAMPLE_ACCURATE_AUTOMATION: bool = true;
// If the plugin can send or receive SysEx messages, it can define a type to wrap around those
// messages here. The type implements the `SysExMessage` trait, which allows conversion to and
// from plain byte buffers.
type SysExMessage = ();
// More advanced plugins can use this to run expensive background tasks. See the field's
// documentation for more information. `()` means that the plugin does not have any background
// tasks.
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
Some(Box::new(EditorWindow::new(self.params.clone())))
}
// This plugin doesn't need any special initialization, but if you need to do anything expensive
// then this would be the place. State is kept around when the host reconfigures the
// plugin. If we do need special initialization, we could implement the `initialize()` and/or
// `reset()` methods
fn initialize(
&mut self,
audio_io_layout: &AudioIOLayout,
buffer_config: &BufferConfig,
_context: &mut impl InitContext<Self>,
) -> bool {
//dsp::init();
let sample_rate = SampleRate(buffer_config.sample_rate as f64);
for _ in 0..audio_io_layout
.main_input_channels
.unwrap_or(NonZero::new(2).unwrap())
.get()
{
self.eqs.push(Equalizer::from_state(EqualizerState {
nodes: [
Some(BiquadFilter::from_state(
sample_rate,
BiquadFilterState {
gain_db: Decibel::from(8.0),
cutoff: 1900.0,
q: 0.5,
mode: FilterMode::HighShelf,
..Default::default()
},
)),
Some(BiquadFilter::from_state(
sample_rate,
BiquadFilterState {
gain_db: Decibel::from(-3.0),
cutoff: 2600.0,
mode: FilterMode::Peak,
..Default::default()
},
)),
None,
None,
None,
None,
],
}));
self.compressors.push(Compressor::from_state(
sample_rate,
CompressorState {
threshold: Decibel::from(-30.0),
ratio: Ratio(1.0, 0.9),
..Default::default()
},
));
self.splitters.push(FreqSplitter::from_state(
sample_rate,
FreqSplitterState {
split_frequency: 3050.0,
..Default::default()
},
));
}
true
}
fn process(
&mut self,
buffer: &mut Buffer,
_aux: &mut AuxiliaryBuffers,
_context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
_context.set_latency_samples(6);
for mut channel_samples in buffer.iter_samples() {
// Smoothing is optionally built into the parameters themselves
let freshness = self.params.freshness.smoothed.next() as f64;
/*let frequency = self.params.frequency.smoothed.next() as f64;
let ratio = self.params.ratio.smoothed.next() as f64;
let threshold = self.params.threshold.smoothed.next() as f64;
for compressor in &mut self.compressors {
compressor.set_ratio(Ratio(1.0, ratio));
compressor.set_threshold(Decibel::from(threshold));
}
for splitter in &mut self.splitters {
splitter.state.split_frequency = frequency;
}
for filter in &mut self.filters {
filter.state.cutoff = frequency;
}
*/
let fresh_lerp_of_bel_air = freshness as f32;
for (channel, sample) in channel_samples.iter_mut().enumerate() {
*sample = sample.lerp(self.eqs[channel].process(*sample), fresh_lerp_of_bel_air);
let (low, high) = self.splitters[channel].process(*sample);
*sample = low
+ high.lerp(
self.compressors[channel].process(high),
fresh_lerp_of_bel_air,
);
}
}
ProcessStatus::Normal
}
// This can be used for cleaning up special resources like socket connections whenever the
// plugin is deactivated. Most plugins won't need to do anything here.
fn deactivate(&mut self) {}
}
impl ClapPlugin for AirFreshener {
const CLAP_ID: &'static str = "com.moist-plugins-gmbh.gain";
const CLAP_DESCRIPTION: Option<&'static str> = Some("A smoothed gain parameter example plugin");
const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL);
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Stereo,
ClapFeature::Utility,
];
}
nih_export_clap!(AirFreshener);

1
rust-toolchain Normal file
View File

@@ -0,0 +1 @@
nightly

View File

@@ -1,218 +0,0 @@
mod editor;
mod gui;
mod parameters;
mod window;
use ebu_dsp::{
BiquadFilter, BiquadFilterState, Compressor, CompressorState, Decibel, Equalizer,
EqualizerState, FilterMode, FreqSplitter, FreqSplitterState, Lerp, Processor, Ratio,
SampleRate,
};
use nih_plug::prelude::*;
use std::{num::NonZero, sync::Arc};
use crate::{parameters::PluginParams, window::EditorWindow};
struct AirFreshener {
params: Arc<PluginParams>,
eqs: Vec<Equalizer>,
compressors: Vec<Compressor>,
splitters: Vec<FreqSplitter>,
}
impl Default for AirFreshener {
fn default() -> Self {
Self {
params: Arc::new(PluginParams::default()),
eqs: Vec::new(),
compressors: Vec::new(),
splitters: Vec::new(),
}
}
}
impl Plugin for AirFreshener {
const NAME: &'static str = "Air Freshener";
const VENDOR: &'static str = "Simon Elberich";
// You can use `env!("CARGO_PKG_HOMEPAGE")` to reference the homepage field from the
// `Cargo.toml` file here
const URL: &'static str = "https://youtu.be/dQw4w9WgXcQ";
const EMAIL: &'static str = "info@example.com";
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
// The first audio IO layout is used as the default. The other layouts may be selected either
// explicitly or automatically by the host or the user depending on the plugin API/backend.
const AUDIO_IO_LAYOUTS: &'static [AudioIOLayout] = &[
AudioIOLayout {
main_input_channels: NonZeroU32::new(2),
main_output_channels: NonZeroU32::new(2),
aux_input_ports: &[],
aux_output_ports: &[],
// Individual ports and the layout as a whole can be named here. By default these names
// are generated as needed. This layout will be called 'Stereo', while the other one is
// given the name 'Mono' based no the number of input and output channels.
names: PortNames::const_default(),
},
AudioIOLayout {
main_input_channels: NonZeroU32::new(1),
main_output_channels: NonZeroU32::new(1),
..AudioIOLayout::const_default()
},
];
const MIDI_INPUT: MidiConfig = MidiConfig::None;
// Setting this to `true` will tell the wrapper to split the buffer up into smaller blocks
// whenever there are inter-buffer parameter changes. This way no changes to the plugin are
// required to support sample accurate automation and the wrapper handles all of the boring
// stuff like making sure transport and other timing information stays consistent between the
// splits.
const SAMPLE_ACCURATE_AUTOMATION: bool = true;
// If the plugin can send or receive SysEx messages, it can define a type to wrap around those
// messages here. The type implements the `SysExMessage` trait, which allows conversion to and
// from plain byte buffers.
type SysExMessage = ();
// More advanced plugins can use this to run expensive background tasks. See the field's
// documentation for more information. `()` means that the plugin does not have any background
// tasks.
type BackgroundTask = ();
fn params(&self) -> Arc<dyn Params> {
self.params.clone()
}
fn editor(&mut self, _async_executor: AsyncExecutor<Self>) -> Option<Box<dyn Editor>> {
Some(Box::new(EditorWindow::new(self.params.clone())))
}
// This plugin doesn't need any special initialization, but if you need to do anything expensive
// then this would be the place. State is kept around when the host reconfigures the
// plugin. If we do need special initialization, we could implement the `initialize()` and/or
// `reset()` methods
fn initialize(
&mut self,
audio_io_layout: &AudioIOLayout,
buffer_config: &BufferConfig,
_context: &mut impl InitContext<Self>,
) -> bool {
//ebu_dsp::init();
let sample_rate = SampleRate(buffer_config.sample_rate as f64);
for _ in 0..audio_io_layout
.main_input_channels
.unwrap_or(NonZero::new(2).unwrap())
.get()
{
self.eqs.push(Equalizer::from_state(EqualizerState {
nodes: [
Some(BiquadFilter::from_state(
sample_rate,
BiquadFilterState {
gain_db: Decibel::from(8.0),
cutoff: 1900.0,
q: 0.5,
mode: FilterMode::HighShelf,
..Default::default()
},
)),
Some(BiquadFilter::from_state(
sample_rate,
BiquadFilterState {
gain_db: Decibel::from(-3.0),
cutoff: 2600.0,
mode: FilterMode::Peak,
..Default::default()
},
)),
None,
None,
None,
None,
],
}));
self.compressors.push(Compressor::from_state(
sample_rate,
CompressorState {
threshold: Decibel::from(-30.0),
ratio: Ratio(1.0, 0.9),
..Default::default()
},
));
self.splitters.push(FreqSplitter::from_state(
sample_rate,
FreqSplitterState {
split_frequency: 3050.0,
..Default::default()
},
));
}
true
}
fn process(
&mut self,
buffer: &mut Buffer,
_aux: &mut AuxiliaryBuffers,
_context: &mut impl ProcessContext<Self>,
) -> ProcessStatus {
_context.set_latency_samples(6);
for mut channel_samples in buffer.iter_samples() {
// Smoothing is optionally built into the parameters themselves
let freshness = self.params.freshness.smoothed.next() as f64;
/*let frequency = self.params.frequency.smoothed.next() as f64;
let ratio = self.params.ratio.smoothed.next() as f64;
let threshold = self.params.threshold.smoothed.next() as f64;
for compressor in &mut self.compressors {
compressor.set_ratio(Ratio(1.0, ratio));
compressor.set_threshold(Decibel::from(threshold));
}
for splitter in &mut self.splitters {
splitter.state.split_frequency = frequency;
}
for filter in &mut self.filters {
filter.state.cutoff = frequency;
}
*/
let fresh_lerp_of_bel_air = freshness as f32;
for (channel, sample) in channel_samples.iter_mut().enumerate() {
*sample = sample.lerp(self.eqs[channel].process(*sample), fresh_lerp_of_bel_air);
let (low, high) = self.splitters[channel].process(*sample);
*sample = low
+ high.lerp(
self.compressors[channel].process(high),
fresh_lerp_of_bel_air,
);
}
}
ProcessStatus::Normal
}
// This can be used for cleaning up special resources like socket connections whenever the
// plugin is deactivated. Most plugins won't need to do anything here.
fn deactivate(&mut self) {}
}
impl ClapPlugin for AirFreshener {
const CLAP_ID: &'static str = "com.moist-plugins-gmbh.gain";
const CLAP_DESCRIPTION: Option<&'static str> = Some("A smoothed gain parameter example plugin");
const CLAP_MANUAL_URL: Option<&'static str> = Some(Self::URL);
const CLAP_SUPPORT_URL: Option<&'static str> = None;
const CLAP_FEATURES: &'static [ClapFeature] = &[
ClapFeature::AudioEffect,
ClapFeature::Stereo,
ClapFeature::Utility,
];
}
nih_export_clap!(AirFreshener);