CLAP plugin hosting (isolated unsafe crate), stem bouncing
- soundcraft-clap-host: scans standard CLAP folders, loads plugins via clap-sys/libloading, implements the Plugin trait (audio, params, latency, notes); the only crate allowed unsafe, every block documented; stubs on wasm - mixer, engine (mix.insert, engine.clap_plugins) and Mix window CLAP menu - end-to-end tests against a Rust-built fixture plugin - file.bounce_stems: one soloed file per track Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
fd2a5fda4a
commit
2fc8e2ecb8
|
|
@ -24,6 +24,7 @@ Pro Tools is installed on the dev machine and may be *observed* black-box: run i
|
|||
## Never crash
|
||||
People trust SoundCraft with their recordings; a crash loses takes. **This outranks feature work.** Standard: [`craftrules/standards/never-crash.md`](https://github.com/storytold/craftrules/blob/main/standards/never-crash.md).
|
||||
- No panics in non-test code: no `unwrap()`, `expect()`, `panic!`, `unreachable!`, `todo!`, `unimplemented!`; no `unsafe` (`unsafe_code = "forbid"`).
|
||||
- The one named exception is `soundcraft-clap-host` (`crates/clap-host`), the isolated unsafe helper crate for CLAP plugin FFI: `unsafe_code = "deny"` crate-wide, allowed only in `src/ffi.rs` with a `// SAFETY:` comment on every block, safe `Result` API.
|
||||
- Errors are `Result<T, E>` + `?`. An unfinished feature returns an "unsupported" error.
|
||||
- Input-derived numbers are hostile (files, commands, MCP/control params): `get()` not `[i]`, checked/saturating arithmetic, no NaN casts, cap allocations.
|
||||
- The audio callback never blocks, allocates in steady state, or panics.
|
||||
|
|
|
|||
|
|
@ -623,6 +623,12 @@ dependencies = [
|
|||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a76abbdb2907f6fd97fb6bc0b7be96b77d328f2dd9669d1075cc03369ed22154"
|
||||
|
||||
[[package]]
|
||||
name = "clipboard-win"
|
||||
version = "5.4.1"
|
||||
|
|
@ -3382,6 +3388,18 @@ dependencies = [
|
|||
"soundcraft-time",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-clap-host"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
"libloading",
|
||||
"log",
|
||||
"serde",
|
||||
"soundcraft-dsp",
|
||||
"thiserror 2.0.21",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "soundcraft-cli"
|
||||
version = "0.1.0"
|
||||
|
|
@ -3413,6 +3431,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-clap-host",
|
||||
"soundcraft-dsp",
|
||||
"soundcraft-midi",
|
||||
"soundcraft-mix",
|
||||
|
|
@ -3438,6 +3457,7 @@ dependencies = [
|
|||
"log",
|
||||
"rayon",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-clap-host",
|
||||
"soundcraft-dsp",
|
||||
"soundcraft-midi",
|
||||
"soundcraft-model",
|
||||
|
|
@ -3490,6 +3510,7 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"soundcraft-audio-io",
|
||||
"soundcraft-clap-host",
|
||||
"soundcraft-dsp",
|
||||
"soundcraft-engine",
|
||||
"soundcraft-midi",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ soundcraft-time = { path = "crates/time" }
|
|||
soundcraft-audio-io = { path = "crates/audio-io" }
|
||||
soundcraft-midi = { path = "crates/midi" }
|
||||
soundcraft-dsp = { path = "crates/dsp" }
|
||||
soundcraft-clap-host = { path = "crates/clap-host" }
|
||||
soundcraft-model = { path = "crates/model" }
|
||||
soundcraft-mix = { path = "crates/mix" }
|
||||
soundcraft-engine = { path = "crates/engine" }
|
||||
|
|
|
|||
|
|
@ -0,0 +1,30 @@
|
|||
[package]
|
||||
name = "soundcraft-clap-host"
|
||||
description = "SoundCraft CLAP plugin host: scans, loads and runs third-party CLAP plugins behind the safe soundcraft-dsp Plugin trait (the only crate with unsafe code)"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
rust-version.workspace = true
|
||||
repository.workspace = true
|
||||
|
||||
[dependencies]
|
||||
soundcraft-dsp.workspace = true
|
||||
serde.workspace = true
|
||||
thiserror.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
|
||||
clap-sys = "0.5"
|
||||
libloading = "0.8"
|
||||
|
||||
# The isolated unsafe helper crate (craftrules never-crash): this crate opts out of the workspace
|
||||
# `unsafe_code = "forbid"` for itself only. It denies unsafe everywhere and allows it solely on the
|
||||
# FFI module (`src/ffi.rs`). The rest mirrors `[workspace.lints]`.
|
||||
[lints.rust]
|
||||
unsafe_code = "deny"
|
||||
|
||||
[lints.clippy]
|
||||
dbg_macro = "warn"
|
||||
todo = "warn"
|
||||
too_many_arguments = "allow"
|
||||
type_complexity = "allow"
|
||||
|
|
@ -0,0 +1,845 @@
|
|||
//! The FFI boundary: the only module in SoundCraft allowed to use `unsafe`.
|
||||
//!
|
||||
//! It loads CLAP binaries (`libloading`), calls the plugin entry/factory/plugin vtables
|
||||
//! (`clap-sys` raw bindings) and implements the host callbacks. Everything it exposes to the rest
|
||||
//! of the crate is a safe API returning `Result`.
|
||||
//!
|
||||
//! Soundness rests on the CLAP contract (the plugin honours the vtable signatures and the
|
||||
//! documented pointer lifetimes). A plugin that violates it (e.g. dereferences garbage) can still
|
||||
//! crash the process: hosting in-process can't prevent that. What this module guarantees is that
|
||||
//! *SoundCraft* never hands the plugin dangling or mis-sized pointers, never calls a missing
|
||||
//! (null) function pointer, and never reads plugin strings beyond their bounds.
|
||||
|
||||
use crate::{ClapError, scan};
|
||||
use clap_sys::audio_buffer::clap_audio_buffer;
|
||||
use clap_sys::entry::clap_plugin_entry;
|
||||
use clap_sys::events::{
|
||||
CLAP_CORE_EVENT_SPACE_ID, CLAP_EVENT_MIDI, CLAP_EVENT_NOTE_OFF, CLAP_EVENT_NOTE_ON, CLAP_EVENT_PARAM_VALUE, clap_event_header, clap_event_midi,
|
||||
clap_event_note, clap_event_param_value, clap_input_events, clap_output_events,
|
||||
};
|
||||
use clap_sys::ext::audio_ports::{CLAP_AUDIO_PORT_IS_MAIN, CLAP_EXT_AUDIO_PORTS, clap_audio_port_info, clap_plugin_audio_ports};
|
||||
use clap_sys::ext::latency::{CLAP_EXT_LATENCY, clap_host_latency, clap_plugin_latency};
|
||||
use clap_sys::ext::log::{CLAP_EXT_LOG, CLAP_LOG_ERROR, CLAP_LOG_FATAL, CLAP_LOG_WARNING, clap_host_log, clap_log_severity};
|
||||
use clap_sys::ext::note_ports::{CLAP_EXT_NOTE_PORTS, clap_note_port_info, clap_plugin_note_ports};
|
||||
use clap_sys::ext::params::{
|
||||
CLAP_EXT_PARAMS, clap_host_params, clap_param_clear_flags, clap_param_info, clap_param_rescan_flags, clap_plugin_params,
|
||||
};
|
||||
use clap_sys::factory::plugin_factory::{CLAP_PLUGIN_FACTORY_ID, clap_plugin_factory};
|
||||
use clap_sys::host::clap_host;
|
||||
use clap_sys::id::clap_id;
|
||||
use clap_sys::plugin::clap_plugin;
|
||||
use clap_sys::process::{CLAP_PROCESS_ERROR, clap_process};
|
||||
use clap_sys::version::{CLAP_VERSION, clap_version_is_compatible};
|
||||
use std::collections::HashMap;
|
||||
use std::ffi::{CStr, CString, c_char, c_void};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::sync::{Arc, Mutex, OnceLock, PoisonError};
|
||||
|
||||
/// Most plugins one factory may report (hostile-input cap).
|
||||
const MAX_PLUGINS_PER_FACTORY: u32 = 1024;
|
||||
/// Most parameters read from one plugin.
|
||||
pub(crate) const MAX_PARAMS: u32 = 4096;
|
||||
/// Most audio ports per direction we provide buffers for.
|
||||
pub(crate) const MAX_PORTS: u32 = 16;
|
||||
/// Most channels per audio port.
|
||||
pub(crate) const MAX_PORT_CHANNELS: u32 = 64;
|
||||
/// Most feature strings read from a descriptor.
|
||||
const MAX_FEATURES: usize = 64;
|
||||
/// Capacity of the per-block event list (preallocated: `process` never allocates).
|
||||
pub(crate) const EVENT_CAP: usize = 1024;
|
||||
|
||||
// ---- plugin strings ------------------------------------------------------------------------
|
||||
|
||||
/// Reads a NUL-terminated plugin string (`None` for null).
|
||||
///
|
||||
/// SAFETY contract for callers: `p` is null or points to a NUL-terminated string that stays valid
|
||||
/// for the duration of the call (CLAP guarantees this for descriptor fields).
|
||||
unsafe fn read_cstr(p: *const c_char) -> Option<String> {
|
||||
if p.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: non-null and NUL-terminated per this function's contract.
|
||||
Some(unsafe { CStr::from_ptr(p) }.to_string_lossy().into_owned())
|
||||
}
|
||||
|
||||
/// Reads a fixed-size `char[N]` name buffer, stopping at the first NUL or the end of the array.
|
||||
fn read_name(buf: &[c_char]) -> String {
|
||||
let bytes: Vec<u8> = buf.iter().take_while(|c| **c != 0).map(|c| *c as u8).collect();
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
// ---- loaded binaries -----------------------------------------------------------------------
|
||||
|
||||
/// A plugin descriptor as reported by the factory.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RawDescriptor {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub vendor: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub features: Vec<String>,
|
||||
}
|
||||
|
||||
/// A loaded, initialised CLAP binary. Loaded binaries are cached for the life of the process
|
||||
/// (see [`Bundle::load`]): plugin code must never be unmapped while an instance may still run.
|
||||
pub(crate) struct Bundle {
|
||||
entry: *const clap_plugin_entry,
|
||||
factory: *const clap_plugin_factory,
|
||||
// Dropped last: the library must outlive every pointer above.
|
||||
_lib: libloading::Library,
|
||||
}
|
||||
|
||||
// SAFETY: CLAP requires the entry and the plugin factory to be thread-safe; the raw pointers are
|
||||
// read-only vtables inside the loaded library, which `_lib` keeps mapped.
|
||||
unsafe impl Send for Bundle {}
|
||||
// SAFETY: as above; `Bundle` has no interior mutability on the Rust side.
|
||||
unsafe impl Sync for Bundle {}
|
||||
|
||||
fn loaded() -> &'static Mutex<HashMap<PathBuf, Arc<Bundle>>> {
|
||||
static L: OnceLock<Mutex<HashMap<PathBuf, Arc<Bundle>>>> = OnceLock::new();
|
||||
L.get_or_init(|| Mutex::new(HashMap::new()))
|
||||
}
|
||||
|
||||
impl Bundle {
|
||||
/// Loads (once per path, then cached for the life of the process) and initialises a `.clap`.
|
||||
pub fn load(path: &Path) -> Result<Arc<Bundle>, ClapError> {
|
||||
let mut map = loaded().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(b) = map.get(path) {
|
||||
return Ok(b.clone());
|
||||
}
|
||||
let b = Arc::new(Bundle::open(path)?);
|
||||
map.insert(path.to_path_buf(), b.clone());
|
||||
Ok(b)
|
||||
}
|
||||
|
||||
fn open(path: &Path) -> Result<Bundle, ClapError> {
|
||||
let err = |m: String| ClapError::Load(path.display().to_string(), m);
|
||||
let exe = scan::bundle_executable(path).ok_or_else(|| err("no loadable binary".into()))?;
|
||||
// SAFETY: loading a shared library runs its static initialisers. That is inherent to
|
||||
// hosting plugins; we only load files found in CLAP plugin folders or named explicitly.
|
||||
let lib = unsafe { libloading::Library::new(&exe) }.map_err(|e| err(e.to_string()))?;
|
||||
// SAFETY: `clap_entry` is the CLAP-mandated exported data symbol of type
|
||||
// `const clap_plugin_entry`; the symbol's address is therefore a `*const clap_plugin_entry`.
|
||||
let entry: *const clap_plugin_entry = match unsafe { lib.get::<*const clap_plugin_entry>(b"clap_entry\0") } {
|
||||
Ok(sym) => *sym,
|
||||
Err(e) => return Err(err(format!("not a CLAP plugin: {e}"))),
|
||||
};
|
||||
if entry.is_null() {
|
||||
return Err(err("null clap_entry".into()));
|
||||
}
|
||||
// SAFETY: non-null pointer to the entry struct inside the library, which `lib` keeps mapped.
|
||||
let e = unsafe { *entry };
|
||||
if !clap_version_is_compatible(e.clap_version) {
|
||||
return Err(err(format!("unsupported CLAP version {}.{}", e.clap_version.major, e.clap_version.minor)));
|
||||
}
|
||||
let (Some(init), Some(get_factory)) = (e.init, e.get_factory) else { return Err(err("incomplete clap_entry".into())) };
|
||||
let cpath = CString::new(path.to_string_lossy().into_owned()).map_err(|_| err("path contains NUL".into()))?;
|
||||
// SAFETY: `init` comes from a valid entry; the path is a valid NUL-terminated string that
|
||||
// outlives the call.
|
||||
if !unsafe { init(cpath.as_ptr()) } {
|
||||
return Err(err("clap_entry.init failed".into()));
|
||||
}
|
||||
// SAFETY: the entry is initialised; the id is a static NUL-terminated string.
|
||||
let factory = unsafe { get_factory(CLAP_PLUGIN_FACTORY_ID.as_ptr()) } as *const clap_plugin_factory;
|
||||
if factory.is_null() {
|
||||
if let Some(deinit) = e.deinit {
|
||||
// SAFETY: balanced with the successful `init` above.
|
||||
unsafe { deinit() };
|
||||
}
|
||||
return Err(err("no plugin factory".into()));
|
||||
}
|
||||
Ok(Bundle { entry, factory, _lib: lib })
|
||||
}
|
||||
|
||||
fn factory(&self) -> clap_plugin_factory {
|
||||
// SAFETY: `factory` was non-null at load time and points into the still-mapped library.
|
||||
unsafe { *self.factory }
|
||||
}
|
||||
|
||||
/// The plugins this binary provides.
|
||||
pub fn descriptors(&self) -> Vec<RawDescriptor> {
|
||||
let f = self.factory();
|
||||
let (Some(count), Some(get)) = (f.get_plugin_count, f.get_plugin_descriptor) else { return Vec::new() };
|
||||
// SAFETY: vtable call with the factory pointer it belongs to.
|
||||
let n = unsafe { count(self.factory) }.min(MAX_PLUGINS_PER_FACTORY);
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n {
|
||||
// SAFETY: index is below the reported count.
|
||||
let d = unsafe { get(self.factory, i) };
|
||||
if d.is_null() {
|
||||
continue;
|
||||
}
|
||||
// SAFETY: non-null descriptor owned by the plugin library, valid while it is loaded.
|
||||
let d = unsafe { *d };
|
||||
// SAFETY: descriptor string fields are null or NUL-terminated (CLAP contract).
|
||||
let Some(id) = (unsafe { read_cstr(d.id) }).filter(|s| !s.is_empty()) else { continue };
|
||||
// SAFETY: as above.
|
||||
let name = unsafe { read_cstr(d.name) }.unwrap_or_else(|| id.clone());
|
||||
let mut features = Vec::new();
|
||||
if !d.features.is_null() {
|
||||
for k in 0..MAX_FEATURES {
|
||||
// SAFETY: `features` is a NULL-terminated array; we stop at the terminator
|
||||
// (or the cap) and never read past it.
|
||||
let p = unsafe { *d.features.add(k) };
|
||||
if p.is_null() {
|
||||
break;
|
||||
}
|
||||
// SAFETY: a non-null entry of the feature array is a NUL-terminated string.
|
||||
if let Some(s) = unsafe { read_cstr(p) } {
|
||||
features.push(s);
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(RawDescriptor {
|
||||
id,
|
||||
name,
|
||||
// SAFETY: descriptor string fields are null or NUL-terminated.
|
||||
vendor: unsafe { read_cstr(d.vendor) }.unwrap_or_default(),
|
||||
// SAFETY: as above.
|
||||
version: unsafe { read_cstr(d.version) }.unwrap_or_default(),
|
||||
// SAFETY: as above.
|
||||
description: unsafe { read_cstr(d.description) }.unwrap_or_default(),
|
||||
features,
|
||||
});
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Creates and initialises one plugin instance.
|
||||
pub fn instantiate(self: &Arc<Self>, plugin_id: &str) -> Result<Instance, ClapError> {
|
||||
let err = |m: &str| ClapError::Instantiate(plugin_id.to_string(), m.to_string());
|
||||
let create = self.factory().create_plugin.ok_or_else(|| err("factory cannot create plugins"))?;
|
||||
let cid = CString::new(plugin_id).map_err(|_| err("id contains NUL"))?;
|
||||
let state = Box::new(HostState::default());
|
||||
let host = Box::new(clap_host {
|
||||
clap_version: CLAP_VERSION,
|
||||
host_data: (&*state as *const HostState).cast_mut().cast::<c_void>(),
|
||||
name: c"SoundCraft".as_ptr(),
|
||||
vendor: c"SoundCraft contributors".as_ptr(),
|
||||
url: c"https://github.com/storytold/soundcraft".as_ptr(),
|
||||
version: c"0.1.0".as_ptr(),
|
||||
get_extension: Some(host_get_extension),
|
||||
request_restart: Some(host_request_restart),
|
||||
request_process: Some(host_request_process),
|
||||
request_callback: Some(host_request_callback),
|
||||
});
|
||||
// SAFETY: the factory is valid; `host` is boxed (stable address) and owned by the returned
|
||||
// `Instance`, which destroys the plugin before dropping the box; `cid` outlives the call.
|
||||
let plugin = unsafe { create(self.factory, &*host, cid.as_ptr()) };
|
||||
if plugin.is_null() {
|
||||
return Err(err("factory returned no instance"));
|
||||
}
|
||||
let mut inst = Instance {
|
||||
plugin,
|
||||
params: std::ptr::null(),
|
||||
ports: std::ptr::null(),
|
||||
latency: std::ptr::null(),
|
||||
notes: std::ptr::null(),
|
||||
raw: Vec::with_capacity(EVENT_CAP),
|
||||
active: false,
|
||||
processing: false,
|
||||
max_frames: 0,
|
||||
created: true,
|
||||
_host: host,
|
||||
state,
|
||||
_bundle: self.clone(),
|
||||
};
|
||||
// SAFETY: `plugin` is a fresh non-null instance; we copy its vtable struct.
|
||||
let vt = unsafe { *plugin };
|
||||
let ok = match vt.init {
|
||||
// SAFETY: `init` is called exactly once, right after creation, as CLAP requires.
|
||||
Some(init) => unsafe { init(plugin) },
|
||||
None => false,
|
||||
};
|
||||
if !ok {
|
||||
// `Drop` destroys the instance (without deactivating: it never activated).
|
||||
return Err(err("plugin init failed"));
|
||||
}
|
||||
inst.params = inst.extension::<clap_plugin_params>(CLAP_EXT_PARAMS);
|
||||
inst.ports = inst.extension::<clap_plugin_audio_ports>(CLAP_EXT_AUDIO_PORTS);
|
||||
inst.latency = inst.extension::<clap_plugin_latency>(CLAP_EXT_LATENCY);
|
||||
inst.notes = inst.extension::<clap_plugin_note_ports>(CLAP_EXT_NOTE_PORTS);
|
||||
Ok(inst)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Bundle {
|
||||
fn drop(&mut self) {
|
||||
// SAFETY: `entry` was valid at load time and `_lib` (dropped after this) keeps it mapped.
|
||||
let e = unsafe { *self.entry };
|
||||
if let Some(deinit) = e.deinit {
|
||||
// SAFETY: balanced with the successful `init` in `open`; no instance holds an `Arc`
|
||||
// to this bundle any more (they keep it alive), so no plugin code still runs.
|
||||
unsafe { deinit() };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- host side -----------------------------------------------------------------------------
|
||||
|
||||
/// Flags the plugin raises through the host callbacks (any thread).
|
||||
#[derive(Default)]
|
||||
pub(crate) struct HostState {
|
||||
pub restart: AtomicBool,
|
||||
pub process: AtomicBool,
|
||||
pub callback: AtomicBool,
|
||||
pub latency_changed: AtomicBool,
|
||||
pub params_rescan: AtomicBool,
|
||||
}
|
||||
|
||||
/// SAFETY contract: `host` is null or one of our `clap_host` structs, whose `host_data` points
|
||||
/// to the `HostState` owned by the same `Instance`.
|
||||
unsafe fn state<'a>(host: *const clap_host) -> Option<&'a HostState> {
|
||||
if host.is_null() {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: non-null host created by `Bundle::instantiate` (contract above).
|
||||
let data = unsafe { (*host).host_data } as *const HostState;
|
||||
// SAFETY: `host_data` points to the boxed `HostState`, alive as long as the instance.
|
||||
unsafe { data.as_ref() }
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_request_restart(host: *const clap_host) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
s.restart.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_request_process(host: *const clap_host) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
s.process.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_request_callback(host: *const clap_host) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
s.callback.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_log(_host: *const clap_host, severity: clap_log_severity, msg: *const c_char) {
|
||||
// SAFETY: CLAP passes a NUL-terminated message (or null, handled).
|
||||
let m = unsafe { read_cstr(msg) }.unwrap_or_default();
|
||||
match severity {
|
||||
CLAP_LOG_ERROR | CLAP_LOG_FATAL => log::error!("clap plugin: {m}"),
|
||||
CLAP_LOG_WARNING => log::warn!("clap plugin: {m}"),
|
||||
_ => log::debug!("clap plugin: {m}"),
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_params_rescan(host: *const clap_host, _flags: clap_param_rescan_flags) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
s.params_rescan.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_params_clear(_host: *const clap_host, _id: clap_id, _flags: clap_param_clear_flags) {}
|
||||
|
||||
unsafe extern "C" fn host_params_request_flush(host: *const clap_host) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
s.process.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn host_latency_changed(host: *const clap_host) {
|
||||
// SAFETY: the plugin passes back the host pointer we gave it.
|
||||
if let Some(s) = unsafe { state(host) } {
|
||||
s.latency_changed.store(true, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
static HOST_LOG: clap_host_log = clap_host_log { log: Some(host_log) };
|
||||
static HOST_PARAMS: clap_host_params =
|
||||
clap_host_params { rescan: Some(host_params_rescan), clear: Some(host_params_clear), request_flush: Some(host_params_request_flush) };
|
||||
static HOST_LATENCY: clap_host_latency = clap_host_latency { changed: Some(host_latency_changed) };
|
||||
|
||||
unsafe extern "C" fn host_get_extension(_host: *const clap_host, id: *const c_char) -> *const c_void {
|
||||
if id.is_null() {
|
||||
return std::ptr::null();
|
||||
}
|
||||
// SAFETY: extension ids are NUL-terminated strings (CLAP contract); non-null checked above.
|
||||
let id = unsafe { CStr::from_ptr(id) };
|
||||
if id == CLAP_EXT_LOG {
|
||||
(&HOST_LOG as *const clap_host_log).cast()
|
||||
} else if id == CLAP_EXT_PARAMS {
|
||||
(&HOST_PARAMS as *const clap_host_params).cast()
|
||||
} else if id == CLAP_EXT_LATENCY {
|
||||
(&HOST_LATENCY as *const clap_host_latency).cast()
|
||||
} else {
|
||||
// Everything else (gui, state, thread-check, timers, …) is unsupported: CLAP allows null.
|
||||
std::ptr::null()
|
||||
}
|
||||
}
|
||||
|
||||
// ---- events --------------------------------------------------------------------------------
|
||||
|
||||
/// A host → plugin event, safe to build anywhere; converted to CLAP structs inside `process`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) enum Event {
|
||||
Param {
|
||||
time: u32,
|
||||
id: u32,
|
||||
value: f64,
|
||||
},
|
||||
NoteOn {
|
||||
time: u32,
|
||||
key: u8,
|
||||
velocity: f64,
|
||||
},
|
||||
NoteOff {
|
||||
time: u32,
|
||||
key: u8,
|
||||
},
|
||||
/// A raw MIDI 1.0 message (for plugins whose note port speaks only MIDI).
|
||||
Midi {
|
||||
time: u32,
|
||||
data: [u8; 3],
|
||||
},
|
||||
}
|
||||
|
||||
impl Event {
|
||||
pub fn time(&self) -> u32 {
|
||||
match *self {
|
||||
Event::Param { time, .. } | Event::NoteOn { time, .. } | Event::NoteOff { time, .. } | Event::Midi { time, .. } => time,
|
||||
}
|
||||
}
|
||||
pub fn set_time(&mut self, t: u32) {
|
||||
match self {
|
||||
Event::Param { time, .. } | Event::NoteOn { time, .. } | Event::NoteOff { time, .. } | Event::Midi { time, .. } => *time = t,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
enum RawEvent {
|
||||
Note(clap_event_note),
|
||||
Param(clap_event_param_value),
|
||||
Midi(clap_event_midi),
|
||||
}
|
||||
|
||||
impl RawEvent {
|
||||
fn header(&self) -> *const clap_event_header {
|
||||
// The header is the first field of every `repr(C)` CLAP event struct, so a pointer to it is
|
||||
// a pointer to the whole event, as CLAP expects.
|
||||
match self {
|
||||
RawEvent::Note(e) => &e.header,
|
||||
RawEvent::Param(e) => &e.header,
|
||||
RawEvent::Midi(e) => &e.header,
|
||||
}
|
||||
}
|
||||
|
||||
fn from_event(e: &Event, offset: u32) -> RawEvent {
|
||||
let header = |size: usize, type_: u16, time: u32| clap_event_header {
|
||||
size: u32::try_from(size).unwrap_or(u32::MAX),
|
||||
time: time.saturating_sub(offset),
|
||||
space_id: CLAP_CORE_EVENT_SPACE_ID,
|
||||
type_,
|
||||
flags: 0,
|
||||
};
|
||||
match *e {
|
||||
Event::Param { time, id, value } => RawEvent::Param(clap_event_param_value {
|
||||
header: header(size_of::<clap_event_param_value>(), CLAP_EVENT_PARAM_VALUE, time),
|
||||
param_id: id,
|
||||
cookie: std::ptr::null_mut(),
|
||||
note_id: -1,
|
||||
port_index: -1,
|
||||
channel: -1,
|
||||
key: -1,
|
||||
value,
|
||||
}),
|
||||
Event::NoteOn { time, key, velocity } => RawEvent::Note(clap_event_note {
|
||||
header: header(size_of::<clap_event_note>(), CLAP_EVENT_NOTE_ON, time),
|
||||
note_id: -1,
|
||||
port_index: 0,
|
||||
channel: 0,
|
||||
key: i16::from(key),
|
||||
velocity,
|
||||
}),
|
||||
Event::NoteOff { time, key } => RawEvent::Note(clap_event_note {
|
||||
header: header(size_of::<clap_event_note>(), CLAP_EVENT_NOTE_OFF, time),
|
||||
note_id: -1,
|
||||
port_index: 0,
|
||||
channel: 0,
|
||||
key: i16::from(key),
|
||||
velocity: 0.0,
|
||||
}),
|
||||
Event::Midi { time, data } => {
|
||||
RawEvent::Midi(clap_event_midi { header: header(size_of::<clap_event_midi>(), CLAP_EVENT_MIDI, time), port_index: 0, data })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn in_events_size(list: *const clap_input_events) -> u32 {
|
||||
if list.is_null() {
|
||||
return 0;
|
||||
}
|
||||
// SAFETY: `list` is the `clap_input_events` we built in `process`; its ctx is the event Vec.
|
||||
let v = unsafe { ((*list).ctx as *const Vec<RawEvent>).as_ref() };
|
||||
v.map_or(0, |v| u32::try_from(v.len()).unwrap_or(u32::MAX))
|
||||
}
|
||||
|
||||
unsafe extern "C" fn in_events_get(list: *const clap_input_events, index: u32) -> *const clap_event_header {
|
||||
if list.is_null() {
|
||||
return std::ptr::null();
|
||||
}
|
||||
// SAFETY: as in `in_events_size`; the Vec is not mutated while the plugin processes.
|
||||
let v = unsafe { ((*list).ctx as *const Vec<RawEvent>).as_ref() };
|
||||
v.and_then(|v| v.get(index as usize)).map_or(std::ptr::null(), RawEvent::header)
|
||||
}
|
||||
|
||||
unsafe extern "C" fn out_events_try_push(_list: *const clap_output_events, _event: *const clap_event_header) -> bool {
|
||||
// Plugin → host events (e.g. GUI parameter edits) are accepted and ignored: SoundCraft's
|
||||
// session is the source of truth for parameter values.
|
||||
true
|
||||
}
|
||||
|
||||
// ---- audio buffers -------------------------------------------------------------------------
|
||||
|
||||
/// Planar f32 buffers for every audio port in one direction, with the pointer tables CLAP needs.
|
||||
/// Allocated in `new`; `process` only rewrites pointers in place (no allocation).
|
||||
pub(crate) struct PortBuffers {
|
||||
data: Vec<Vec<Vec<f32>>>,
|
||||
ptrs: Vec<Vec<*mut f32>>,
|
||||
bufs: Vec<clap_audio_buffer>,
|
||||
frames: usize,
|
||||
}
|
||||
|
||||
// SAFETY: the raw pointers only ever point into `data`, which this struct owns; moving the struct
|
||||
// to another thread moves that ownership with it.
|
||||
unsafe impl Send for PortBuffers {}
|
||||
|
||||
impl PortBuffers {
|
||||
/// One buffer set per port, `channels[p]` channels each (capped), `frames` long.
|
||||
pub fn new(channels: &[u32], frames: usize) -> PortBuffers {
|
||||
let data: Vec<Vec<Vec<f32>>> =
|
||||
channels.iter().take(MAX_PORTS as usize).map(|&c| (0..c.min(MAX_PORT_CHANNELS)).map(|_| vec![0.0; frames]).collect()).collect();
|
||||
let ptrs = data.iter().map(|p| vec![std::ptr::null_mut(); p.len()]).collect();
|
||||
let bufs = data
|
||||
.iter()
|
||||
.map(|_| clap_audio_buffer { data32: std::ptr::null_mut(), data64: std::ptr::null_mut(), channel_count: 0, latency: 0, constant_mask: 0 })
|
||||
.collect();
|
||||
PortBuffers { data, ptrs, bufs, frames }
|
||||
}
|
||||
|
||||
pub fn channels(&self, port: usize) -> usize {
|
||||
self.data.get(port).map_or(0, Vec::len)
|
||||
}
|
||||
|
||||
pub fn channel(&self, port: usize, ch: usize) -> Option<&[f32]> {
|
||||
self.data.get(port).and_then(|p| p.get(ch)).map(Vec::as_slice)
|
||||
}
|
||||
|
||||
pub fn channel_mut(&mut self, port: usize, ch: usize) -> Option<&mut [f32]> {
|
||||
self.data.get_mut(port).and_then(|p| p.get_mut(ch)).map(Vec::as_mut_slice)
|
||||
}
|
||||
|
||||
/// Points the CLAP tables at the current data (no allocation).
|
||||
fn refresh(&mut self) {
|
||||
for ((d, p), b) in self.data.iter_mut().zip(self.ptrs.iter_mut()).zip(self.bufs.iter_mut()) {
|
||||
for (dc, pc) in d.iter_mut().zip(p.iter_mut()) {
|
||||
*pc = dc.as_mut_ptr();
|
||||
}
|
||||
b.data32 = p.as_mut_ptr();
|
||||
b.data64 = std::ptr::null_mut();
|
||||
b.channel_count = u32::try_from(p.len()).unwrap_or(0);
|
||||
b.constant_mask = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---- instances -----------------------------------------------------------------------------
|
||||
|
||||
/// What one audio port looks like.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct PortInfo {
|
||||
pub channels: u32,
|
||||
pub main: bool,
|
||||
}
|
||||
|
||||
/// One parameter as reported by `clap_plugin_params.get_info`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct RawParam {
|
||||
pub id: u32,
|
||||
pub name: String,
|
||||
pub module: String,
|
||||
pub flags: u32,
|
||||
pub min: f64,
|
||||
pub max: f64,
|
||||
pub default: f64,
|
||||
}
|
||||
|
||||
/// Note dialects of the plugin's first input note port.
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
pub(crate) struct NotePort {
|
||||
pub supported: u32,
|
||||
pub preferred: u32,
|
||||
}
|
||||
|
||||
/// A live plugin instance plus the host struct it was created with.
|
||||
pub(crate) struct Instance {
|
||||
plugin: *const clap_plugin,
|
||||
params: *const clap_plugin_params,
|
||||
ports: *const clap_plugin_audio_ports,
|
||||
latency: *const clap_plugin_latency,
|
||||
notes: *const clap_plugin_note_ports,
|
||||
raw: Vec<RawEvent>,
|
||||
active: bool,
|
||||
processing: bool,
|
||||
max_frames: usize,
|
||||
created: bool,
|
||||
// Field order matters for drop: the plugin is destroyed in `Drop::drop` first; then the host
|
||||
// struct and its state, then the bundle (which keeps the code mapped) go.
|
||||
_host: Box<clap_host>,
|
||||
state: Box<HostState>,
|
||||
_bundle: Arc<Bundle>,
|
||||
}
|
||||
|
||||
// SAFETY: an `Instance` is used through `&mut self` only, so calls into the plugin are always
|
||||
// serialised. CLAP lets audio-thread functions run on any one thread at a time; SoundCraft creates
|
||||
// instances on the engine/UI thread and then moves them into the mix engine (documented
|
||||
// limitation: some main-thread calls such as `activate` may then happen on the audio thread).
|
||||
unsafe impl Send for Instance {}
|
||||
|
||||
impl Instance {
|
||||
fn vt(&self) -> clap_plugin {
|
||||
// SAFETY: `plugin` is non-null (checked at creation) and alive until `Drop`.
|
||||
unsafe { *self.plugin }
|
||||
}
|
||||
|
||||
fn extension<T>(&self, id: &CStr) -> *const T {
|
||||
match self.vt().get_extension {
|
||||
// SAFETY: valid instance; `id` is a static NUL-terminated string.
|
||||
Some(f) => unsafe { f(self.plugin, id.as_ptr()) }.cast::<T>(),
|
||||
None => std::ptr::null(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn state(&self) -> &HostState {
|
||||
&self.state
|
||||
}
|
||||
|
||||
/// Runs `on_main_thread` if the plugin asked for a callback.
|
||||
pub fn service_callback(&mut self) {
|
||||
if self.state.callback.swap(false, Ordering::Relaxed)
|
||||
&& let Some(f) = self.vt().on_main_thread
|
||||
{
|
||||
// SAFETY: valid instance; the plugin asked for this call via `request_callback`.
|
||||
unsafe { f(self.plugin) };
|
||||
}
|
||||
}
|
||||
|
||||
pub fn params(&self) -> Vec<RawParam> {
|
||||
// SAFETY: the extension pointer, when non-null, points to a static vtable in the plugin.
|
||||
let Some(p) = (unsafe { self.params.as_ref() }) else { return Vec::new() };
|
||||
let (Some(count), Some(get_info)) = (p.count, p.get_info) else { return Vec::new() };
|
||||
// SAFETY: vtable call with the instance it belongs to.
|
||||
let n = unsafe { count(self.plugin) }.min(MAX_PARAMS);
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n {
|
||||
// SAFETY: `clap_param_info` is plain data (integers, floats, char arrays, a nullable
|
||||
// pointer); all-zero is a valid value.
|
||||
let mut info: clap_param_info = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: index below the count; `info` is a valid, writable struct.
|
||||
if !unsafe { get_info(self.plugin, i, &mut info) } {
|
||||
continue;
|
||||
}
|
||||
let (min, max) = (info.min_value, info.max_value);
|
||||
if !(min.is_finite() && max.is_finite()) || max < min {
|
||||
continue;
|
||||
}
|
||||
let default = if info.default_value.is_finite() { info.default_value.clamp(min, max) } else { min };
|
||||
out.push(RawParam { id: info.id, name: read_name(&info.name), module: read_name(&info.module), flags: info.flags, min, max, default });
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn param_value(&self, id: u32) -> Option<f64> {
|
||||
// SAFETY: see `params`.
|
||||
let p = unsafe { self.params.as_ref() }?;
|
||||
let get = p.get_value?;
|
||||
let mut v = 0.0f64;
|
||||
// SAFETY: valid instance; `v` is writable.
|
||||
(unsafe { get(self.plugin, id, &mut v) } && v.is_finite()).then_some(v)
|
||||
}
|
||||
|
||||
pub fn value_text(&self, id: u32, value: f64) -> Option<String> {
|
||||
// SAFETY: see `params`.
|
||||
let p = unsafe { self.params.as_ref() }?;
|
||||
let f = p.value_to_text?;
|
||||
let mut buf = [0 as c_char; 128];
|
||||
// SAFETY: valid instance; we pass the buffer's true capacity.
|
||||
let ok = unsafe { f(self.plugin, id, value, buf.as_mut_ptr(), buf.len() as u32) };
|
||||
ok.then(|| read_name(&buf)).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
pub fn audio_ports(&self, is_input: bool) -> Vec<PortInfo> {
|
||||
// SAFETY: see `params`.
|
||||
let Some(p) = (unsafe { self.ports.as_ref() }) else { return Vec::new() };
|
||||
let (Some(count), Some(get)) = (p.count, p.get) else { return Vec::new() };
|
||||
// SAFETY: vtable call with its instance.
|
||||
let n = unsafe { count(self.plugin, is_input) }.min(MAX_PORTS);
|
||||
let mut out = Vec::new();
|
||||
for i in 0..n {
|
||||
// SAFETY: plain data; all-zero (null port_type pointer) is valid.
|
||||
let mut info: clap_audio_port_info = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: index below the count; `info` is writable.
|
||||
if unsafe { get(self.plugin, i, is_input, &mut info) } {
|
||||
out.push(PortInfo { channels: info.channel_count.min(MAX_PORT_CHANNELS), main: info.flags & CLAP_AUDIO_PORT_IS_MAIN != 0 });
|
||||
} else {
|
||||
// Keep port indices aligned: an unreadable port still needs a (silent) buffer slot.
|
||||
out.push(PortInfo { channels: 0, main: false });
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
pub fn note_port(&self) -> Option<NotePort> {
|
||||
// SAFETY: see `params`.
|
||||
let p = unsafe { self.notes.as_ref() }?;
|
||||
let (count, get) = (p.count?, p.get?);
|
||||
// SAFETY: vtable call with its instance.
|
||||
if unsafe { count(self.plugin, true) } == 0 {
|
||||
return None;
|
||||
}
|
||||
// SAFETY: plain data; all-zero is valid.
|
||||
let mut info: clap_note_port_info = unsafe { std::mem::zeroed() };
|
||||
// SAFETY: index 0 is below the non-zero count; `info` is writable.
|
||||
unsafe { get(self.plugin, 0, true, &mut info) }.then_some(NotePort { supported: info.supported_dialects, preferred: info.preferred_dialect })
|
||||
}
|
||||
|
||||
pub fn latency(&self) -> u32 {
|
||||
// SAFETY: see `params`.
|
||||
match unsafe { self.latency.as_ref() }.and_then(|l| l.get) {
|
||||
// SAFETY: valid instance.
|
||||
Some(f) => unsafe { f(self.plugin) },
|
||||
None => 0,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_active(&self) -> bool {
|
||||
self.active
|
||||
}
|
||||
|
||||
pub fn activate(&mut self, sample_rate: f64, min_frames: u32, max_frames: u32) -> Result<(), ClapError> {
|
||||
self.deactivate();
|
||||
let f = self.vt().activate.ok_or_else(|| ClapError::Process("plugin has no activate".into()))?;
|
||||
// SAFETY: valid, inactive instance; arguments are sane (checked by the caller).
|
||||
if !unsafe { f(self.plugin, sample_rate, min_frames, max_frames) } {
|
||||
return Err(ClapError::Process("activate failed".into()));
|
||||
}
|
||||
self.active = true;
|
||||
self.max_frames = max_frames as usize;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn deactivate(&mut self) {
|
||||
self.stop_processing();
|
||||
if self.active {
|
||||
if let Some(f) = self.vt().deactivate {
|
||||
// SAFETY: valid, active instance.
|
||||
unsafe { f(self.plugin) };
|
||||
}
|
||||
self.active = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn stop_processing(&mut self) {
|
||||
if self.processing {
|
||||
if let Some(f) = self.vt().stop_processing {
|
||||
// SAFETY: valid instance in the processing state.
|
||||
unsafe { f(self.plugin) };
|
||||
}
|
||||
self.processing = false;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
if self.active
|
||||
&& let Some(f) = self.vt().reset
|
||||
{
|
||||
// SAFETY: valid, active instance.
|
||||
unsafe { f(self.plugin) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Runs one `clap_process` call over `frames` frames. Events must be sorted by time; their
|
||||
/// times are made relative to `offset`. Returns the CLAP process status.
|
||||
pub fn process(
|
||||
&mut self,
|
||||
frames: usize,
|
||||
steady_time: i64,
|
||||
inputs: &mut PortBuffers,
|
||||
outputs: &mut PortBuffers,
|
||||
events: &[Event],
|
||||
offset: u32,
|
||||
) -> Result<i32, ClapError> {
|
||||
if !self.active {
|
||||
return Err(ClapError::Process("not active".into()));
|
||||
}
|
||||
if frames == 0 || frames > self.max_frames || frames > inputs.frames || frames > outputs.frames {
|
||||
return Err(ClapError::Process("block larger than the activated size".into()));
|
||||
}
|
||||
if !self.processing {
|
||||
let ok = match self.vt().start_processing {
|
||||
// SAFETY: valid, active instance.
|
||||
Some(f) => unsafe { f(self.plugin) },
|
||||
None => true,
|
||||
};
|
||||
if !ok {
|
||||
return Err(ClapError::Process("start_processing failed".into()));
|
||||
}
|
||||
self.processing = true;
|
||||
}
|
||||
let process = self.vt().process.ok_or_else(|| ClapError::Process("plugin has no process".into()))?;
|
||||
self.raw.clear();
|
||||
for e in events.iter().take(self.raw.capacity()) {
|
||||
self.raw.push(RawEvent::from_event(e, offset));
|
||||
}
|
||||
inputs.refresh();
|
||||
outputs.refresh();
|
||||
let in_events = clap_input_events {
|
||||
ctx: (&self.raw as *const Vec<RawEvent>).cast_mut().cast::<c_void>(),
|
||||
size: Some(in_events_size),
|
||||
get: Some(in_events_get),
|
||||
};
|
||||
let out_events = clap_output_events { ctx: std::ptr::null_mut(), try_push: Some(out_events_try_push) };
|
||||
let p = clap_process {
|
||||
steady_time,
|
||||
frames_count: u32::try_from(frames).unwrap_or(0),
|
||||
transport: std::ptr::null(),
|
||||
audio_inputs: inputs.bufs.as_ptr(),
|
||||
audio_outputs: outputs.bufs.as_mut_ptr(),
|
||||
audio_inputs_count: u32::try_from(inputs.bufs.len()).unwrap_or(0),
|
||||
audio_outputs_count: u32::try_from(outputs.bufs.len()).unwrap_or(0),
|
||||
in_events: &in_events,
|
||||
out_events: &out_events,
|
||||
};
|
||||
// SAFETY: valid, processing instance. Every buffer pointer refers to `frames` (or more)
|
||||
// valid f32s owned by `inputs`/`outputs`, which are borrowed for the whole call; the event
|
||||
// lists and their contexts live on this stack frame / in `self.raw`, untouched until the
|
||||
// call returns.
|
||||
let status = unsafe { process(self.plugin, &p) };
|
||||
if status == CLAP_PROCESS_ERROR { Err(ClapError::Process("plugin reported a processing error".into())) } else { Ok(status) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Instance {
|
||||
fn drop(&mut self) {
|
||||
self.deactivate();
|
||||
if self.created
|
||||
&& let Some(f) = self.vt().destroy
|
||||
{
|
||||
// SAFETY: valid instance, deactivated above; destroyed exactly once.
|
||||
unsafe { f(self.plugin) };
|
||||
}
|
||||
self.created = false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,223 @@
|
|||
//! SoundCraft's CLAP plugin host.
|
||||
//!
|
||||
//! Scans the standard CLAP folders (plus `CLAP_PATH`), loads `.clap` binaries in-process and
|
||||
//! exposes each plugin as a [`soundcraft_dsp::Plugin`], so the mix engine treats third-party
|
||||
//! plugins exactly like the built-in ones. Plugin ids are `clap:<CLAP plugin id>`; parameter ids
|
||||
//! are the CLAP param ids as decimal strings, with their human names in [`PluginInfo::params`].
|
||||
//!
|
||||
//! This is SoundCraft's single isolated `unsafe` crate (craftrules never-crash): `unsafe` is
|
||||
//! denied crate-wide and allowed only in the private `ffi` module. The public API is safe, returns
|
||||
//! `Result`/`Option` and never panics. Limits of in-process hosting: a plugin that itself crashes
|
||||
//! (segfault) takes the process down with it; we cannot sandbox native code.
|
||||
//!
|
||||
//! `PluginInfo` must be `&'static`: the registry leaks exactly one `PluginInfo` (with its param
|
||||
//! table) per plugin *type* the first time that type is instantiated, and caches it. The leak is
|
||||
//! bounded by the number of distinct installed plugins actually used. Loaded binaries likewise
|
||||
//! stay mapped for the life of the process.
|
||||
//!
|
||||
//! On `wasm32` the crate compiles to stubs: scans are empty and nothing can be created.
|
||||
//!
|
||||
//! Plugin GUIs (`clap.gui`) are not hosted yet; SoundCraft shows its generic parameter editor.
|
||||
|
||||
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
|
||||
|
||||
pub mod scan;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
#[allow(unsafe_code)]
|
||||
mod ffi;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod plugin;
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod registry;
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
pub use plugin::ClapPlugin;
|
||||
|
||||
use soundcraft_dsp::{Category, Plugin, PluginInfo};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Prefix of every hosted-CLAP plugin id.
|
||||
pub const ID_PREFIX: &str = "clap:";
|
||||
|
||||
/// The CLAP plugin id inside a `clap:<id>` SoundCraft id (`None` for other ids or an empty id).
|
||||
pub fn parse_id(id: &str) -> Option<&str> {
|
||||
id.strip_prefix(ID_PREFIX).filter(|s| !s.is_empty() && !s.contains('\0'))
|
||||
}
|
||||
|
||||
/// One plugin found by a scan.
|
||||
#[derive(Debug, Clone, PartialEq, serde::Serialize)]
|
||||
pub struct ClapDescriptor {
|
||||
/// SoundCraft id: `clap:<plugin_id>`.
|
||||
pub id: String,
|
||||
/// The CLAP plugin id (reverse-DNS, e.g. `com.vendor.reverb`).
|
||||
pub plugin_id: String,
|
||||
pub name: String,
|
||||
pub vendor: String,
|
||||
pub version: String,
|
||||
pub description: String,
|
||||
pub features: Vec<String>,
|
||||
pub category: Category,
|
||||
pub is_instrument: bool,
|
||||
/// The `.clap` file or bundle it lives in.
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
/// Why a CLAP operation failed.
|
||||
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
|
||||
pub enum ClapError {
|
||||
#[error("cannot load CLAP plugin {0}: {1}")]
|
||||
Load(String, String),
|
||||
#[error("no CLAP plugin `{0}`")]
|
||||
NotFound(String),
|
||||
#[error("cannot create CLAP plugin `{0}`: {1}")]
|
||||
Instantiate(String, String),
|
||||
#[error("CLAP processing: {0}")]
|
||||
Process(String),
|
||||
#[error("CLAP plugins are not supported on this platform")]
|
||||
Unsupported,
|
||||
}
|
||||
|
||||
#[cfg(not(target_arch = "wasm32"))]
|
||||
mod api {
|
||||
use super::*;
|
||||
|
||||
pub fn scan() -> Vec<ClapDescriptor> {
|
||||
registry::scan()
|
||||
}
|
||||
pub fn rescan() -> Vec<ClapDescriptor> {
|
||||
registry::rescan()
|
||||
}
|
||||
pub fn add_search_dir(dir: &Path) -> usize {
|
||||
registry::add_search_dir(dir)
|
||||
}
|
||||
pub fn scan_paths(dirs: &[PathBuf]) -> Vec<ClapDescriptor> {
|
||||
registry::scan_paths(dirs)
|
||||
}
|
||||
pub fn load_bundle(path: &Path) -> Result<Vec<ClapDescriptor>, ClapError> {
|
||||
registry::load_bundle(path)
|
||||
}
|
||||
pub fn instantiate(id: &str) -> Result<Box<dyn Plugin>, ClapError> {
|
||||
registry::instantiate(id)
|
||||
}
|
||||
pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
|
||||
registry::plugin_info(id)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_arch = "wasm32")]
|
||||
mod api {
|
||||
use super::*;
|
||||
|
||||
pub fn scan() -> Vec<ClapDescriptor> {
|
||||
Vec::new()
|
||||
}
|
||||
pub fn rescan() -> Vec<ClapDescriptor> {
|
||||
Vec::new()
|
||||
}
|
||||
pub fn add_search_dir(_dir: &Path) -> usize {
|
||||
0
|
||||
}
|
||||
pub fn scan_paths(_dirs: &[PathBuf]) -> Vec<ClapDescriptor> {
|
||||
Vec::new()
|
||||
}
|
||||
pub fn load_bundle(_path: &Path) -> Result<Vec<ClapDescriptor>, ClapError> {
|
||||
Err(ClapError::Unsupported)
|
||||
}
|
||||
pub fn instantiate(_id: &str) -> Result<Box<dyn Plugin>, ClapError> {
|
||||
Err(ClapError::Unsupported)
|
||||
}
|
||||
pub fn plugin_info(_id: &str) -> Option<&'static PluginInfo> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Every plugin in the standard CLAP folders, `CLAP_PATH` and folders added with
|
||||
/// [`add_search_dir`]. Scanned on first call (loads each binary's entry, but creates no plugin
|
||||
/// instances), then cached in memory.
|
||||
pub fn scan() -> Vec<ClapDescriptor> {
|
||||
api::scan()
|
||||
}
|
||||
|
||||
/// Forgets the cached scan (and remembered failures) and scans again.
|
||||
pub fn rescan() -> Vec<ClapDescriptor> {
|
||||
api::rescan()
|
||||
}
|
||||
|
||||
/// Adds a folder to the search path and merges its plugins into the cache. Returns how many
|
||||
/// plugins the folder holds.
|
||||
pub fn add_search_dir(dir: &Path) -> usize {
|
||||
api::add_search_dir(dir)
|
||||
}
|
||||
|
||||
/// Scans the given folders without touching the cache.
|
||||
pub fn scan_paths(dirs: &[PathBuf]) -> Vec<ClapDescriptor> {
|
||||
api::scan_paths(dirs)
|
||||
}
|
||||
|
||||
/// The plugins in one `.clap` file or bundle.
|
||||
pub fn load_bundle(path: &Path) -> Result<Vec<ClapDescriptor>, ClapError> {
|
||||
api::load_bundle(path)
|
||||
}
|
||||
|
||||
/// Creates a plugin by `clap:<id>`, prepared for 48 kHz stereo with 1024-frame blocks (like
|
||||
/// `soundcraft_dsp::create`), with the reason when it fails.
|
||||
pub fn instantiate(id: &str) -> Result<Box<dyn Plugin>, ClapError> {
|
||||
api::instantiate(id)
|
||||
}
|
||||
|
||||
/// Creates a plugin by `clap:<id>`; `None` if unknown or it fails to load.
|
||||
pub fn create(id: &str) -> Option<Box<dyn Plugin>> {
|
||||
parse_id(id)?;
|
||||
match api::instantiate(id) {
|
||||
Ok(p) => Some(p),
|
||||
Err(e) => {
|
||||
log::warn!("{e}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The description of a `clap:<id>` plugin. The first call for a type instantiates it once to
|
||||
/// read its parameters; the result is cached (and leaked, see the crate docs).
|
||||
pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
|
||||
api::plugin_info(id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn ids_parse() {
|
||||
assert_eq!(parse_id("clap:com.u-he.diva"), Some("com.u-he.diva"));
|
||||
assert_eq!(parse_id("clap:"), None);
|
||||
assert_eq!(parse_id("eq_7band"), None);
|
||||
assert_eq!(parse_id("CLAP:x"), None);
|
||||
assert_eq!(parse_id("clap:a\0b"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn non_clap_ids_are_rejected_without_scanning() {
|
||||
assert!(create("eq_7band").is_none());
|
||||
assert!(plugin_info("reverb").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn garbage_files_are_errors_not_crashes() {
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-clap-garbage-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(d.join("Bundle.clap/Contents/MacOS")).unwrap();
|
||||
std::fs::write(d.join("garbage.clap"), b"\x7fELF this is not a plugin \x00\x01\x02").unwrap();
|
||||
std::fs::write(d.join("empty.clap"), b"").unwrap();
|
||||
std::fs::write(d.join("Bundle.clap/Contents/MacOS/Bundle"), [0xcf, 0xfa, 0xed, 0xfe, 1, 2, 3]).unwrap();
|
||||
for f in ["garbage.clap", "empty.clap", "Bundle.clap", "missing.clap"] {
|
||||
assert!(load_bundle(&d.join(f)).is_err(), "{f}");
|
||||
}
|
||||
assert!(scan_paths(std::slice::from_ref(&d)).is_empty());
|
||||
assert!(create("clap:does.not.exist").is_none());
|
||||
assert!(plugin_info("clap:does.not.exist").is_none());
|
||||
assert!(instantiate("clap:does.not.exist").is_err());
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,246 @@
|
|||
//! `ClapPlugin`: a loaded CLAP instance behind the safe `soundcraft_dsp::Plugin` trait.
|
||||
|
||||
use crate::ffi::{EVENT_CAP, Event, Instance, NotePort, PortBuffers};
|
||||
use clap_sys::ext::note_ports::{CLAP_NOTE_DIALECT_CLAP, CLAP_NOTE_DIALECT_MIDI};
|
||||
use soundcraft_dsp::{Plugin, PluginInfo};
|
||||
use std::sync::atomic::Ordering;
|
||||
|
||||
/// Largest block we activate a plugin for.
|
||||
const MAX_BLOCK: usize = 1 << 16;
|
||||
/// Most channels a host strip may ask for.
|
||||
const MAX_CHANNELS: usize = 64;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||
enum NoteMode {
|
||||
None,
|
||||
Clap,
|
||||
Midi,
|
||||
}
|
||||
|
||||
/// A hosted CLAP plugin. Parameter ids are the CLAP param ids as decimal strings ("0", "17", …);
|
||||
/// their human names are in `info().params`.
|
||||
pub struct ClapPlugin {
|
||||
inst: Instance,
|
||||
info: &'static PluginInfo,
|
||||
/// CLAP id of each `info.params` entry (same order).
|
||||
ids: Vec<u32>,
|
||||
values: Vec<f32>,
|
||||
/// Pending events, kept sorted by time (stable for equal times). Never grows past `EVENT_CAP`.
|
||||
queue: Vec<Event>,
|
||||
inputs: PortBuffers,
|
||||
outputs: PortBuffers,
|
||||
in_main: Option<usize>,
|
||||
out_main: Option<usize>,
|
||||
notes: NoteMode,
|
||||
held: [bool; 128],
|
||||
channels: usize,
|
||||
max_block: usize,
|
||||
latency: usize,
|
||||
steady: i64,
|
||||
}
|
||||
|
||||
impl ClapPlugin {
|
||||
pub(crate) fn new(inst: Instance, info: &'static PluginInfo) -> ClapPlugin {
|
||||
let ids: Vec<u32> = info.params.iter().map(|p| p.id.parse::<u32>().unwrap_or(u32::MAX)).collect();
|
||||
let values = info.params.iter().zip(&ids).map(|(p, id)| inst.param_value(*id).map_or(p.default, |v| p.clamp(v as f32))).collect();
|
||||
ClapPlugin {
|
||||
inst,
|
||||
info,
|
||||
ids,
|
||||
values,
|
||||
queue: Vec::with_capacity(EVENT_CAP),
|
||||
inputs: PortBuffers::new(&[], 0),
|
||||
outputs: PortBuffers::new(&[], 0),
|
||||
in_main: None,
|
||||
out_main: None,
|
||||
notes: NoteMode::None,
|
||||
held: [false; 128],
|
||||
channels: 2,
|
||||
max_block: 0,
|
||||
latency: 0,
|
||||
steady: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Inserts keeping the queue time-sorted; drops the event when the queue is full (never
|
||||
/// allocates on the audio thread).
|
||||
fn push(&mut self, e: Event) {
|
||||
if self.queue.len() >= self.queue.capacity() {
|
||||
return;
|
||||
}
|
||||
let pos = self.queue.iter().rposition(|q| q.time() <= e.time()).map_or(0, |i| i + 1);
|
||||
self.queue.insert(pos, e);
|
||||
}
|
||||
}
|
||||
|
||||
fn pick_main(ports: &[crate::ffi::PortInfo]) -> Option<usize> {
|
||||
ports.iter().position(|p| p.main && p.channels > 0).or_else(|| ports.iter().position(|p| p.channels > 0))
|
||||
}
|
||||
|
||||
fn note_mode(p: Option<NotePort>) -> NoteMode {
|
||||
match p {
|
||||
Some(p) if p.supported & CLAP_NOTE_DIALECT_CLAP != 0 => NoteMode::Clap,
|
||||
Some(p) if p.supported & CLAP_NOTE_DIALECT_MIDI != 0 => NoteMode::Midi,
|
||||
_ => NoteMode::None,
|
||||
}
|
||||
}
|
||||
|
||||
impl Plugin for ClapPlugin {
|
||||
fn info(&self) -> &'static PluginInfo {
|
||||
self.info
|
||||
}
|
||||
|
||||
fn prepare(&mut self, sample_rate: f32, max_block: usize, channels: usize) {
|
||||
let sr = if sample_rate.is_finite() && sample_rate > 0.0 { f64::from(sample_rate) } else { 48_000.0 };
|
||||
let mb = max_block.clamp(1, MAX_BLOCK);
|
||||
self.channels = channels.clamp(1, MAX_CHANNELS);
|
||||
self.inst.deactivate();
|
||||
self.inst.service_callback();
|
||||
let ins = self.inst.audio_ports(true);
|
||||
let outs = self.inst.audio_ports(false);
|
||||
self.in_main = pick_main(&ins);
|
||||
self.out_main = pick_main(&outs);
|
||||
self.inputs = PortBuffers::new(&ins.iter().map(|p| p.channels).collect::<Vec<_>>(), mb);
|
||||
self.outputs = PortBuffers::new(&outs.iter().map(|p| p.channels).collect::<Vec<_>>(), mb);
|
||||
self.notes = note_mode(self.inst.note_port());
|
||||
self.max_block = mb;
|
||||
self.inst.state().restart.store(false, Ordering::Relaxed);
|
||||
match self.inst.activate(sr, 1, u32::try_from(mb).unwrap_or(u32::MAX)) {
|
||||
Ok(()) => {
|
||||
self.latency = self.inst.latency() as usize;
|
||||
self.inst.state().latency_changed.store(false, Ordering::Relaxed);
|
||||
}
|
||||
Err(e) => {
|
||||
log::warn!("{}: {e}", self.info.id);
|
||||
self.latency = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn reset(&mut self) {
|
||||
self.inst.reset();
|
||||
self.queue.retain(|e| matches!(e, Event::Param { .. }));
|
||||
self.held = [false; 128];
|
||||
}
|
||||
|
||||
fn set_param(&mut self, id: &str, value: f32) -> bool {
|
||||
let Some(i) = self.info.params.iter().position(|p| p.id == id) else { return false };
|
||||
let (Some(pi), Some(&cid)) = (self.info.params.get(i), self.ids.get(i)) else { return false };
|
||||
let v = pi.clamp(value);
|
||||
if let Some(slot) = self.values.get_mut(i) {
|
||||
*slot = v;
|
||||
}
|
||||
// Coalesce with a pending change of the same parameter.
|
||||
if let Some(Event::Param { value, .. }) = self.queue.iter_mut().find(|e| matches!(e, Event::Param { id, .. } if *id == cid)) {
|
||||
*value = f64::from(v);
|
||||
} else {
|
||||
self.push(Event::Param { time: 0, id: cid, value: f64::from(v) });
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
fn param(&self, id: &str) -> Option<f32> {
|
||||
let i = self.info.params.iter().position(|p| p.id == id)?;
|
||||
self.values.get(i).copied()
|
||||
}
|
||||
|
||||
fn process(&mut self, io: &mut [Vec<f32>], frames: usize) {
|
||||
if !self.inst.is_active() || self.max_block == 0 {
|
||||
return;
|
||||
}
|
||||
let ch = self.channels.min(io.len());
|
||||
let frames = io.iter().take(ch).map(Vec::len).fold(frames, usize::min);
|
||||
if frames == 0 || ch == 0 {
|
||||
return;
|
||||
}
|
||||
let last = u32::try_from(frames - 1).unwrap_or(u32::MAX);
|
||||
for e in &mut self.queue {
|
||||
if e.time() > last {
|
||||
e.set_time(last);
|
||||
}
|
||||
}
|
||||
let mut start = 0usize;
|
||||
while start < frames {
|
||||
let n = (frames - start).min(self.max_block);
|
||||
let end = u32::try_from(start + n).unwrap_or(u32::MAX);
|
||||
// Feed the main input port (mono sources fill every port channel).
|
||||
if let Some(p) = self.in_main {
|
||||
for c in 0..self.inputs.channels(p) {
|
||||
let src = io.get(c.min(ch - 1)).and_then(|s| s.get(start..start + n));
|
||||
if let (Some(src), Some(dst)) = (src, self.inputs.channel_mut(p, c).and_then(|d| d.get_mut(..n))) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
let k = self.queue.iter().position(|e| e.time() >= end).unwrap_or(self.queue.len());
|
||||
let offset = u32::try_from(start).unwrap_or(u32::MAX);
|
||||
let events = self.queue.get(..k).unwrap_or(&[]);
|
||||
let r = self.inst.process(n, self.steady, &mut self.inputs, &mut self.outputs, events, offset);
|
||||
self.queue.drain(..k);
|
||||
self.steady = self.steady.saturating_add(i64::try_from(n).unwrap_or(0));
|
||||
match (r, self.out_main) {
|
||||
(Ok(_), Some(p)) => {
|
||||
let oc = self.outputs.channels(p);
|
||||
for (c, dst) in io.iter_mut().take(ch).enumerate() {
|
||||
let Some(dst) = dst.get_mut(start..start + n) else { continue };
|
||||
if ch == 1 && oc >= 2 {
|
||||
if let (Some(l), Some(r)) = (self.outputs.channel(p, 0), self.outputs.channel(p, 1)) {
|
||||
for ((d, a), b) in dst.iter_mut().zip(l).zip(r) {
|
||||
*d = 0.5 * (a + b);
|
||||
}
|
||||
}
|
||||
} else if let Some(src) = self.outputs.channel(p, c.min(oc.saturating_sub(1))).and_then(|s| s.get(..n)) {
|
||||
dst.copy_from_slice(src);
|
||||
}
|
||||
}
|
||||
}
|
||||
(Err(e), _) => log::debug!("{}: {e}", self.info.id),
|
||||
_ => {}
|
||||
}
|
||||
start += n;
|
||||
}
|
||||
}
|
||||
|
||||
fn latency(&self) -> usize {
|
||||
self.latency
|
||||
}
|
||||
|
||||
fn note_on(&mut self, offset: usize, note: u8, velocity: u8) {
|
||||
if note > 127 {
|
||||
return;
|
||||
}
|
||||
let time = u32::try_from(offset).unwrap_or(u32::MAX);
|
||||
let vel = velocity.min(127);
|
||||
match self.notes {
|
||||
NoteMode::Clap => self.push(Event::NoteOn { time, key: note, velocity: f64::from(vel) / 127.0 }),
|
||||
NoteMode::Midi => self.push(Event::Midi { time, data: [0x90, note, vel] }),
|
||||
NoteMode::None => return,
|
||||
}
|
||||
if let Some(h) = self.held.get_mut(note as usize) {
|
||||
*h = true;
|
||||
}
|
||||
}
|
||||
|
||||
fn note_off(&mut self, offset: usize, note: u8) {
|
||||
if note > 127 {
|
||||
return;
|
||||
}
|
||||
let time = u32::try_from(offset).unwrap_or(u32::MAX);
|
||||
match self.notes {
|
||||
NoteMode::Clap => self.push(Event::NoteOff { time, key: note }),
|
||||
NoteMode::Midi => self.push(Event::Midi { time, data: [0x80, note, 0] }),
|
||||
NoteMode::None => return,
|
||||
}
|
||||
if let Some(h) = self.held.get_mut(note as usize) {
|
||||
*h = false;
|
||||
}
|
||||
}
|
||||
|
||||
fn all_notes_off(&mut self) {
|
||||
for k in 0..128u8 {
|
||||
if self.held.get(k as usize).copied().unwrap_or(false) {
|
||||
self.note_off(0, k);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,209 @@
|
|||
//! The process-wide CLAP registry: scan cache, leaked `PluginInfo`s, instantiation.
|
||||
|
||||
use crate::ffi::{Bundle, Instance, RawParam};
|
||||
use crate::plugin::ClapPlugin;
|
||||
use crate::scan as paths;
|
||||
use crate::{ClapDescriptor, ClapError, parse_id};
|
||||
use clap_sys::ext::params::{CLAP_PARAM_IS_ENUM, CLAP_PARAM_IS_HIDDEN, CLAP_PARAM_IS_READONLY, CLAP_PARAM_IS_STEPPED};
|
||||
use soundcraft_dsp::{ParamInfo, Plugin, PluginInfo, Taper, Unit};
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::{Mutex, OnceLock, PoisonError};
|
||||
|
||||
/// Most labels read for an enum parameter.
|
||||
const MAX_CHOICES: f64 = 128.0;
|
||||
|
||||
#[derive(Default)]
|
||||
struct Registry {
|
||||
scanned: Option<Vec<ClapDescriptor>>,
|
||||
extra_dirs: Vec<PathBuf>,
|
||||
/// One leaked `PluginInfo` per plugin type that was actually instantiated (bounded by the
|
||||
/// number of distinct installed plugins).
|
||||
infos: HashMap<String, &'static PluginInfo>,
|
||||
/// Ids that failed to instantiate (not retried every UI frame).
|
||||
failed: HashSet<String>,
|
||||
}
|
||||
|
||||
fn registry() -> &'static Mutex<Registry> {
|
||||
static R: OnceLock<Mutex<Registry>> = OnceLock::new();
|
||||
R.get_or_init(|| Mutex::new(Registry::default()))
|
||||
}
|
||||
|
||||
/// Descriptors of every plugin in one `.clap` file or bundle.
|
||||
pub fn load_bundle(path: &Path) -> Result<Vec<ClapDescriptor>, ClapError> {
|
||||
let b = Bundle::load(path)?;
|
||||
Ok(b.descriptors()
|
||||
.into_iter()
|
||||
.map(|d| ClapDescriptor {
|
||||
id: format!("{}{}", crate::ID_PREFIX, d.id),
|
||||
category: paths::category_from_features(&d.features),
|
||||
is_instrument: paths::is_instrument(&d.features),
|
||||
plugin_id: d.id,
|
||||
name: d.name,
|
||||
vendor: d.vendor,
|
||||
version: d.version,
|
||||
description: d.description,
|
||||
features: d.features,
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Scans `dirs` (uncached). Unloadable files are logged and skipped; the first plugin with a
|
||||
/// given id wins.
|
||||
pub fn scan_paths(dirs: &[PathBuf]) -> Vec<ClapDescriptor> {
|
||||
let mut out: Vec<ClapDescriptor> = Vec::new();
|
||||
for path in paths::find_bundles(dirs) {
|
||||
match load_bundle(&path) {
|
||||
Ok(ds) => {
|
||||
for d in ds {
|
||||
if !out.iter().any(|o| o.id == d.id) {
|
||||
out.push(d);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => log::warn!("{e}"),
|
||||
}
|
||||
}
|
||||
out.sort_by_key(|d| d.name.to_lowercase());
|
||||
out
|
||||
}
|
||||
|
||||
fn ensure_scanned(r: &mut Registry) -> &Vec<ClapDescriptor> {
|
||||
if r.scanned.is_none() {
|
||||
let mut dirs = paths::default_search_paths();
|
||||
dirs.extend(r.extra_dirs.iter().cloned());
|
||||
r.scanned = Some(scan_paths(&dirs));
|
||||
}
|
||||
r.scanned.get_or_insert_with(Vec::new)
|
||||
}
|
||||
|
||||
pub fn scan() -> Vec<ClapDescriptor> {
|
||||
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
ensure_scanned(&mut r).clone()
|
||||
}
|
||||
|
||||
pub fn rescan() -> Vec<ClapDescriptor> {
|
||||
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
r.scanned = None;
|
||||
r.failed.clear();
|
||||
ensure_scanned(&mut r).clone()
|
||||
}
|
||||
|
||||
pub fn add_search_dir(dir: &Path) -> usize {
|
||||
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if !r.extra_dirs.iter().any(|d| d == dir) {
|
||||
r.extra_dirs.push(dir.to_path_buf());
|
||||
}
|
||||
let found = scan_paths(&[dir.to_path_buf()]);
|
||||
let n = found.len();
|
||||
let list = r.scanned.get_or_insert_with(Vec::new);
|
||||
for d in found {
|
||||
if !list.iter().any(|o| o.id == d.id) {
|
||||
list.push(d);
|
||||
}
|
||||
}
|
||||
n
|
||||
}
|
||||
|
||||
fn leak(s: String) -> &'static str {
|
||||
Box::leak(s.into_boxed_str())
|
||||
}
|
||||
|
||||
fn param_info(inst: &Instance, p: &RawParam) -> ParamInfo {
|
||||
let stepped = p.flags & CLAP_PARAM_IS_STEPPED != 0;
|
||||
let is_enum = p.flags & CLAP_PARAM_IS_ENUM != 0;
|
||||
let (mut unit, mut choices): (Unit, &'static [&'static str]) = (Unit::None, &[]);
|
||||
if stepped && p.min == 0.0 && p.max == 1.0 && !is_enum {
|
||||
unit = Unit::Toggle;
|
||||
} else if stepped && p.min == 0.0 && p.max >= 1.0 && p.max <= MAX_CHOICES {
|
||||
let labels: Vec<&'static str> =
|
||||
(0..=p.max as u32).map(|i| leak(inst.value_text(p.id, f64::from(i)).unwrap_or_else(|| i.to_string()))).collect();
|
||||
unit = Unit::Choice;
|
||||
choices = Box::leak(labels.into_boxed_slice());
|
||||
}
|
||||
let name = match (p.module.is_empty(), p.name.is_empty()) {
|
||||
(_, true) => format!("Param {}", p.id),
|
||||
(true, false) => p.name.clone(),
|
||||
(false, false) => format!("{}/{}", p.module, p.name),
|
||||
};
|
||||
ParamInfo {
|
||||
id: leak(p.id.to_string()),
|
||||
name: leak(name),
|
||||
min: p.min as f32,
|
||||
max: p.max as f32,
|
||||
default: p.default as f32,
|
||||
unit,
|
||||
taper: Taper::Linear,
|
||||
choices,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds and leaks the `PluginInfo` for one plugin type. Called at most once per id (cached).
|
||||
fn build_info(d: &ClapDescriptor, inst: &Instance) -> &'static PluginInfo {
|
||||
let params: Vec<ParamInfo> =
|
||||
inst.params().iter().filter(|p| p.flags & (CLAP_PARAM_IS_HIDDEN | CLAP_PARAM_IS_READONLY) == 0).map(|p| param_info(inst, p)).collect();
|
||||
let short: String = d.name.chars().take(8).collect();
|
||||
Box::leak(Box::new(PluginInfo {
|
||||
id: leak(d.id.clone()),
|
||||
name: leak(d.name.clone()),
|
||||
short_name: leak(short),
|
||||
category: d.category,
|
||||
params: Box::leak(params.into_boxed_slice()),
|
||||
is_instrument: d.is_instrument,
|
||||
audiosuite: false,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Finds the descriptor, loads its binary and creates an instance plus its (cached) info.
|
||||
fn instantiate_raw(id: &str) -> Result<(Instance, &'static PluginInfo), ClapError> {
|
||||
let plugin_id = parse_id(id).ok_or_else(|| ClapError::NotFound(id.to_string()))?;
|
||||
let desc = {
|
||||
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if r.failed.contains(id) {
|
||||
return Err(ClapError::Instantiate(plugin_id.to_string(), "failed before".into()));
|
||||
}
|
||||
ensure_scanned(&mut r).iter().find(|d| d.id == id).cloned().ok_or_else(|| ClapError::NotFound(id.to_string()))?
|
||||
};
|
||||
let made = Bundle::load(&desc.path).and_then(|b| b.instantiate(plugin_id));
|
||||
let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
match made {
|
||||
Ok(inst) => {
|
||||
let info = match r.infos.get(id) {
|
||||
Some(i) => *i,
|
||||
None => {
|
||||
let i = build_info(&desc, &inst);
|
||||
r.infos.insert(id.to_string(), i);
|
||||
i
|
||||
}
|
||||
};
|
||||
Ok((inst, info))
|
||||
}
|
||||
Err(e) => {
|
||||
r.failed.insert(id.to_string());
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn instantiate(id: &str) -> Result<Box<dyn Plugin>, ClapError> {
|
||||
let (inst, info) = instantiate_raw(id)?;
|
||||
let mut p = ClapPlugin::new(inst, info);
|
||||
p.prepare(48_000.0, 1024, 2);
|
||||
Ok(Box::new(p))
|
||||
}
|
||||
|
||||
pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
|
||||
parse_id(id)?;
|
||||
{
|
||||
let r = registry().lock().unwrap_or_else(PoisonError::into_inner);
|
||||
if let Some(i) = r.infos.get(id) {
|
||||
return Some(*i);
|
||||
}
|
||||
if r.failed.contains(id) {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
// Instantiate once to learn the parameters (CLAP exposes them only on an instance).
|
||||
instantiate_raw(id).ok().map(|(_, info)| info)
|
||||
}
|
||||
|
|
@ -0,0 +1,215 @@
|
|||
//! Finding CLAP plugins on disk (safe, pure `std::fs`) and mapping CLAP features to categories.
|
||||
|
||||
use soundcraft_dsp::Category;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
/// Directory recursion limit while scanning (CLAP folders may be nested, never deeply).
|
||||
const MAX_DEPTH: usize = 8;
|
||||
/// Upper bound on bundles collected by one scan (hostile or huge folders).
|
||||
const MAX_BUNDLES: usize = 4096;
|
||||
/// Upper bound on directory entries visited by one scan.
|
||||
const MAX_ENTRIES: usize = 100_000;
|
||||
|
||||
/// The standard CLAP search locations for this OS (existing or not), followed by every entry
|
||||
/// of the `CLAP_PATH` environment variable.
|
||||
pub fn default_search_paths() -> Vec<PathBuf> {
|
||||
let mut v = os_search_paths(
|
||||
std::env::var_os("HOME").map(PathBuf::from),
|
||||
std::env::var_os("COMMONPROGRAMFILES").map(PathBuf::from),
|
||||
std::env::var_os("LOCALAPPDATA").map(PathBuf::from),
|
||||
);
|
||||
if let Some(cp) = std::env::var_os("CLAP_PATH") {
|
||||
v.extend(std::env::split_paths(&cp).filter(|p| !p.as_os_str().is_empty()));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// The OS-specific standard locations, given the relevant environment values.
|
||||
pub fn os_search_paths(home: Option<PathBuf>, common_program_files: Option<PathBuf>, local_app_data: Option<PathBuf>) -> Vec<PathBuf> {
|
||||
let mut v = Vec::new();
|
||||
if cfg!(target_os = "macos") {
|
||||
if let Some(h) = home {
|
||||
v.push(h.join("Library/Audio/Plug-Ins/CLAP"));
|
||||
}
|
||||
v.push(PathBuf::from("/Library/Audio/Plug-Ins/CLAP"));
|
||||
} else if cfg!(windows) {
|
||||
if let Some(c) = common_program_files {
|
||||
v.push(c.join("CLAP"));
|
||||
}
|
||||
if let Some(l) = local_app_data {
|
||||
v.push(l.join("Programs").join("Common").join("CLAP"));
|
||||
}
|
||||
} else {
|
||||
if let Some(h) = home {
|
||||
v.push(h.join(".clap"));
|
||||
}
|
||||
v.push(PathBuf::from("/usr/lib/clap"));
|
||||
v.push(PathBuf::from("/usr/local/lib/clap"));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
/// True when the path names a CLAP plugin (`*.clap`, case-insensitive).
|
||||
pub fn is_clap_path(p: &Path) -> bool {
|
||||
p.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("clap"))
|
||||
}
|
||||
|
||||
/// Recursively collects `.clap` files and bundles under `dirs` (missing directories are skipped).
|
||||
/// A `.clap` directory is a bundle and is not descended into. The result is sorted and deduplicated.
|
||||
pub fn find_bundles(dirs: &[PathBuf]) -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let mut budget = MAX_ENTRIES;
|
||||
for d in dirs {
|
||||
if is_clap_path(d) && d.exists() {
|
||||
out.push(d.clone());
|
||||
} else {
|
||||
walk(d, 0, &mut out, &mut budget);
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out.dedup();
|
||||
out.truncate(MAX_BUNDLES);
|
||||
out
|
||||
}
|
||||
|
||||
fn walk(dir: &Path, depth: usize, out: &mut Vec<PathBuf>, budget: &mut usize) {
|
||||
if depth > MAX_DEPTH || out.len() >= MAX_BUNDLES {
|
||||
return;
|
||||
}
|
||||
let Ok(rd) = std::fs::read_dir(dir) else { return };
|
||||
for entry in rd.flatten() {
|
||||
if *budget == 0 || out.len() >= MAX_BUNDLES {
|
||||
return;
|
||||
}
|
||||
*budget -= 1;
|
||||
let p = entry.path();
|
||||
// `metadata` follows symlinks (plugins are often symlinked in); errors are skipped.
|
||||
let Ok(md) = std::fs::metadata(&p) else { continue };
|
||||
if is_clap_path(&p) {
|
||||
out.push(p);
|
||||
} else if md.is_dir() {
|
||||
walk(&p, depth + 1, out, budget);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The loadable binary of a plugin: the file itself, or for a macOS bundle directory
|
||||
/// `Contents/MacOS/<name>` (the executable named like the bundle, else the only/first file there).
|
||||
pub fn bundle_executable(path: &Path) -> Option<PathBuf> {
|
||||
if path.is_file() {
|
||||
return Some(path.to_path_buf());
|
||||
}
|
||||
if !path.is_dir() {
|
||||
return None;
|
||||
}
|
||||
let macos = path.join("Contents").join("MacOS");
|
||||
if let Some(stem) = path.file_stem() {
|
||||
let p = macos.join(stem);
|
||||
if p.is_file() {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
let mut files: Vec<PathBuf> = std::fs::read_dir(&macos).ok()?.flatten().map(|e| e.path()).filter(|p| p.is_file()).take(64).collect();
|
||||
files.sort();
|
||||
files.into_iter().next()
|
||||
}
|
||||
|
||||
/// Maps CLAP feature strings to a SoundCraft insert-menu category.
|
||||
pub fn category_from_features<S: AsRef<str>>(features: &[S]) -> Category {
|
||||
let has = |names: &[&str]| features.iter().any(|f| names.iter().any(|n| f.as_ref().eq_ignore_ascii_case(n)));
|
||||
if has(&["instrument", "synthesizer", "sampler", "drum", "drum-machine"]) {
|
||||
Category::Instrument
|
||||
} else if has(&["equalizer", "filter"]) {
|
||||
Category::Eq
|
||||
} else if has(&["compressor", "limiter", "expander", "gate", "de-esser", "transient-shaper"]) {
|
||||
Category::Dynamics
|
||||
} else if has(&["reverb"]) {
|
||||
Category::Reverb
|
||||
} else if has(&["delay"]) {
|
||||
Category::Delay
|
||||
} else if has(&["chorus", "flanger", "phaser", "tremolo"]) {
|
||||
Category::Modulation
|
||||
} else if has(&["distortion"]) {
|
||||
Category::Harmonic
|
||||
} else if has(&["pitch-shifter", "pitch-correction"]) {
|
||||
Category::PitchShift
|
||||
} else {
|
||||
Category::Other
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the features mark an instrument (CLAP `instrument`).
|
||||
pub fn is_instrument<S: AsRef<str>>(features: &[S]) -> bool {
|
||||
features.iter().any(|f| f.as_ref().eq_ignore_ascii_case("instrument"))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn tmp(name: &str) -> PathBuf {
|
||||
let d = std::env::temp_dir().join(format!("soundcraft-clap-scan-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
std::fs::create_dir_all(&d).unwrap();
|
||||
d
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn finds_files_and_bundles_recursively_but_not_inside_bundles() {
|
||||
let d = tmp("find");
|
||||
std::fs::write(d.join("a.clap"), b"x").unwrap();
|
||||
std::fs::write(d.join("readme.txt"), b"x").unwrap();
|
||||
std::fs::create_dir_all(d.join("vendor/sub")).unwrap();
|
||||
std::fs::write(d.join("vendor/sub/B.CLAP"), b"x").unwrap();
|
||||
std::fs::create_dir_all(d.join("c.clap/Contents/MacOS")).unwrap();
|
||||
std::fs::write(d.join("c.clap/Contents/MacOS/c"), b"x").unwrap();
|
||||
std::fs::write(d.join("c.clap/Contents/inner.clap"), b"x").unwrap();
|
||||
let found = find_bundles(&[d.clone(), d.join("missing")]);
|
||||
let names: Vec<String> = found.iter().map(|p| p.strip_prefix(&d).unwrap().to_string_lossy().replace('\\', "/")).collect();
|
||||
assert_eq!(names, vec!["a.clap", "c.clap", "vendor/sub/B.CLAP"]);
|
||||
assert_eq!(bundle_executable(&d.join("c.clap")), Some(d.join("c.clap/Contents/MacOS/c")));
|
||||
assert_eq!(bundle_executable(&d.join("a.clap")), Some(d.join("a.clap")));
|
||||
assert_eq!(bundle_executable(&d.join("nope.clap")), None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bundle_executable_falls_back_to_first_file() {
|
||||
let d = tmp("exe");
|
||||
std::fs::create_dir_all(d.join("X.clap/Contents/MacOS")).unwrap();
|
||||
std::fs::write(d.join("X.clap/Contents/MacOS/other"), b"x").unwrap();
|
||||
assert_eq!(bundle_executable(&d.join("X.clap")), Some(d.join("X.clap/Contents/MacOS/other")));
|
||||
std::fs::create_dir_all(d.join("Empty.clap")).unwrap();
|
||||
assert_eq!(bundle_executable(&d.join("Empty.clap")), None);
|
||||
let _ = std::fs::remove_dir_all(&d);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn os_paths_cover_the_standard_locations() {
|
||||
let v = os_search_paths(Some("/home/u".into()), Some("C:/CPF".into()), Some("C:/LAD".into()));
|
||||
if cfg!(target_os = "macos") {
|
||||
assert_eq!(v, vec![PathBuf::from("/home/u/Library/Audio/Plug-Ins/CLAP"), PathBuf::from("/Library/Audio/Plug-Ins/CLAP")]);
|
||||
} else if cfg!(windows) {
|
||||
assert_eq!(v.len(), 2);
|
||||
assert!(v[0].ends_with("CLAP") && v[1].ends_with("CLAP"));
|
||||
} else {
|
||||
assert_eq!(v, vec![PathBuf::from("/home/u/.clap"), PathBuf::from("/usr/lib/clap"), PathBuf::from("/usr/local/lib/clap")]);
|
||||
}
|
||||
assert!(default_search_paths().len() >= v.len().saturating_sub(1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn features_map_to_categories() {
|
||||
assert_eq!(category_from_features(&["audio-effect", "reverb", "stereo"]), Category::Reverb);
|
||||
assert_eq!(category_from_features(&["instrument", "synthesizer"]), Category::Instrument);
|
||||
assert_eq!(category_from_features(&["audio-effect", "Compressor"]), Category::Dynamics);
|
||||
assert_eq!(category_from_features(&["audio-effect", "equalizer"]), Category::Eq);
|
||||
assert_eq!(category_from_features(&["chorus"]), Category::Modulation);
|
||||
assert_eq!(category_from_features(&["distortion"]), Category::Harmonic);
|
||||
assert_eq!(category_from_features(&["pitch-shifter"]), Category::PitchShift);
|
||||
assert_eq!(category_from_features(&["delay"]), Category::Delay);
|
||||
assert_eq!(category_from_features::<&str>(&[]), Category::Other);
|
||||
assert!(is_instrument(&["instrument"]));
|
||||
assert!(!is_instrument(&["audio-effect"]));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,16 @@
|
|||
# This file is automatically @generated by Cargo.
|
||||
# It is not intended for manual editing.
|
||||
version = 4
|
||||
|
||||
[[package]]
|
||||
name = "clap-fixture"
|
||||
version = "0.0.0"
|
||||
dependencies = [
|
||||
"clap-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "clap-sys"
|
||||
version = "0.5.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a76abbdb2907f6fd97fb6bc0b7be96b77d328f2dd9669d1075cc03369ed22154"
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# A tiny CLAP plugin used only by soundcraft-clap-host's integration tests. Not part of the
|
||||
# SoundCraft workspace (the empty [workspace] table makes it its own root); the test builds it
|
||||
# with `cargo build` and loads the resulting library. Never shipped.
|
||||
[package]
|
||||
name = "clap-fixture"
|
||||
version = "0.0.0"
|
||||
edition = "2024"
|
||||
publish = false
|
||||
license = "MIT OR Apache-2.0"
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
path = "src/lib.rs"
|
||||
|
||||
[dependencies]
|
||||
clap-sys = "0.5"
|
||||
|
||||
[workspace]
|
||||
|
|
@ -0,0 +1,398 @@
|
|||
//! Test-only CLAP plugins for soundcraft-clap-host: a gain effect and a "DC synth".
|
||||
//!
|
||||
//! - `org.soundcraft.test.gain`: stereo in/out, param 7 "Gain" (0..2, default 1), param 3 "Mode"
|
||||
//! (stepped enum A/B/C), hidden param 9, latency 3 samples.
|
||||
//! - `org.soundcraft.test.synth`: instrument, stereo out, CLAP note port; outputs a constant equal
|
||||
//! to the velocity of the held note (0 when none), switching at the event's sample offset.
|
||||
#![allow(clippy::missing_safety_doc, non_upper_case_globals)]
|
||||
|
||||
use clap_sys::audio_buffer::clap_audio_buffer;
|
||||
use clap_sys::entry::clap_plugin_entry;
|
||||
use clap_sys::events::*;
|
||||
use clap_sys::ext::audio_ports::*;
|
||||
use clap_sys::ext::latency::*;
|
||||
use clap_sys::ext::log::*;
|
||||
use clap_sys::ext::note_ports::*;
|
||||
use clap_sys::ext::params::*;
|
||||
use clap_sys::factory::plugin_factory::*;
|
||||
use clap_sys::host::clap_host;
|
||||
use clap_sys::id::clap_id;
|
||||
use clap_sys::plugin::*;
|
||||
use clap_sys::process::*;
|
||||
use clap_sys::version::CLAP_VERSION;
|
||||
use std::ffi::{CStr, c_char, c_void};
|
||||
|
||||
struct Features<const N: usize>([*const c_char; N]);
|
||||
unsafe impl<const N: usize> Sync for Features<N> {}
|
||||
|
||||
static GAIN_FEATURES: Features<3> = Features([c"audio-effect".as_ptr(), c"stereo".as_ptr(), std::ptr::null()]);
|
||||
static SYNTH_FEATURES: Features<3> = Features([c"instrument".as_ptr(), c"synthesizer".as_ptr(), std::ptr::null()]);
|
||||
|
||||
static GAIN_DESC: clap_plugin_descriptor = clap_plugin_descriptor {
|
||||
clap_version: CLAP_VERSION,
|
||||
id: c"org.soundcraft.test.gain".as_ptr(),
|
||||
name: c"Fixture Gain".as_ptr(),
|
||||
vendor: c"SoundCraft tests".as_ptr(),
|
||||
url: c"".as_ptr(),
|
||||
manual_url: c"".as_ptr(),
|
||||
support_url: c"".as_ptr(),
|
||||
version: c"1.0.0".as_ptr(),
|
||||
description: c"test gain".as_ptr(),
|
||||
features: GAIN_FEATURES.0.as_ptr(),
|
||||
};
|
||||
|
||||
static SYNTH_DESC: clap_plugin_descriptor = clap_plugin_descriptor {
|
||||
clap_version: CLAP_VERSION,
|
||||
id: c"org.soundcraft.test.synth".as_ptr(),
|
||||
name: c"Fixture Synth".as_ptr(),
|
||||
vendor: c"SoundCraft tests".as_ptr(),
|
||||
url: std::ptr::null(),
|
||||
manual_url: std::ptr::null(),
|
||||
support_url: std::ptr::null(),
|
||||
version: c"1.0.0".as_ptr(),
|
||||
description: std::ptr::null(),
|
||||
features: SYNTH_FEATURES.0.as_ptr(),
|
||||
};
|
||||
|
||||
struct Fx {
|
||||
plugin: clap_plugin,
|
||||
host: *const clap_host,
|
||||
synth: bool,
|
||||
gain: f64,
|
||||
mode: f64,
|
||||
level: f64,
|
||||
active: bool,
|
||||
processing: bool,
|
||||
}
|
||||
|
||||
unsafe fn fx<'a>(p: *const clap_plugin) -> &'a mut Fx {
|
||||
unsafe { &mut *((*p).plugin_data as *mut Fx) }
|
||||
}
|
||||
|
||||
unsafe extern "C" fn init(p: *const clap_plugin) -> bool {
|
||||
let f = unsafe { fx(p) };
|
||||
// Exercise the host's extension lookup + log callback.
|
||||
let host = unsafe { &*f.host };
|
||||
if let Some(ge) = host.get_extension {
|
||||
let log = unsafe { ge(f.host, CLAP_EXT_LOG.as_ptr()) } as *const clap_host_log;
|
||||
if let Some(l) = unsafe { log.as_ref() }.and_then(|l| l.log) {
|
||||
unsafe { l(f.host, CLAP_LOG_INFO, c"fixture init".as_ptr()) };
|
||||
}
|
||||
let none = unsafe { ge(f.host, c"clap.does-not-exist".as_ptr()) };
|
||||
if !none.is_null() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
unsafe extern "C" fn destroy(p: *const clap_plugin) {
|
||||
let data = unsafe { (*p).plugin_data } as *mut Fx;
|
||||
drop(unsafe { Box::from_raw(data) });
|
||||
}
|
||||
|
||||
unsafe extern "C" fn activate(p: *const clap_plugin, sr: f64, min: u32, max: u32) -> bool {
|
||||
let f = unsafe { fx(p) };
|
||||
if f.active || !(sr > 0.0) || min == 0 || max < min {
|
||||
return false;
|
||||
}
|
||||
f.active = true;
|
||||
true
|
||||
}
|
||||
|
||||
unsafe extern "C" fn deactivate(p: *const clap_plugin) {
|
||||
let f = unsafe { fx(p) };
|
||||
assert!(!f.processing, "deactivate while processing");
|
||||
f.active = false;
|
||||
}
|
||||
|
||||
unsafe extern "C" fn start_processing(p: *const clap_plugin) -> bool {
|
||||
let f = unsafe { fx(p) };
|
||||
assert!(f.active, "start_processing while inactive");
|
||||
f.processing = true;
|
||||
true
|
||||
}
|
||||
|
||||
unsafe extern "C" fn stop_processing(p: *const clap_plugin) {
|
||||
unsafe { fx(p) }.processing = false;
|
||||
}
|
||||
|
||||
unsafe extern "C" fn reset(p: *const clap_plugin) {
|
||||
unsafe { fx(p) }.level = 0.0;
|
||||
}
|
||||
|
||||
fn apply(f: &mut Fx, h: &clap_event_header) {
|
||||
if h.space_id != CLAP_CORE_EVENT_SPACE_ID {
|
||||
return;
|
||||
}
|
||||
match h.type_ {
|
||||
CLAP_EVENT_PARAM_VALUE => {
|
||||
let e = unsafe { &*(h as *const clap_event_header as *const clap_event_param_value) };
|
||||
match e.param_id {
|
||||
7 => f.gain = e.value,
|
||||
3 => f.mode = e.value,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
CLAP_EVENT_NOTE_ON => {
|
||||
let e = unsafe { &*(h as *const clap_event_header as *const clap_event_note) };
|
||||
f.level = e.velocity;
|
||||
}
|
||||
CLAP_EVENT_NOTE_OFF => f.level = 0.0,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn process(p: *const clap_plugin, pr: *const clap_process) -> clap_process_status {
|
||||
let f = unsafe { fx(p) };
|
||||
let pr = unsafe { &*pr };
|
||||
assert!(f.processing, "process while not processing");
|
||||
let n = pr.frames_count as usize;
|
||||
let ev = unsafe { &*pr.in_events };
|
||||
let count = unsafe { (ev.size.unwrap())(pr.in_events) };
|
||||
if pr.audio_outputs_count != 1 {
|
||||
return CLAP_PROCESS_ERROR;
|
||||
}
|
||||
let out: &clap_audio_buffer = unsafe { &*pr.audio_outputs };
|
||||
let input: Option<&clap_audio_buffer> = if f.synth {
|
||||
if pr.audio_inputs_count != 0 {
|
||||
return CLAP_PROCESS_ERROR;
|
||||
}
|
||||
None
|
||||
} else {
|
||||
if pr.audio_inputs_count != 1 {
|
||||
return CLAP_PROCESS_ERROR;
|
||||
}
|
||||
Some(unsafe { &*pr.audio_inputs })
|
||||
};
|
||||
let mut next = 0u32;
|
||||
let mut last_time = 0u32;
|
||||
for i in 0..n {
|
||||
while next < count {
|
||||
let h = unsafe { &*(ev.get.unwrap())(pr.in_events, next) };
|
||||
assert!(h.time >= last_time, "events not sorted");
|
||||
assert!((h.time as usize) < n, "event time outside the block");
|
||||
if h.time as usize > i {
|
||||
break;
|
||||
}
|
||||
last_time = h.time;
|
||||
apply(f, h);
|
||||
next += 1;
|
||||
}
|
||||
for c in 0..out.channel_count as usize {
|
||||
let o = unsafe { *out.data32.add(c) };
|
||||
let v = match input {
|
||||
Some(inp) => {
|
||||
let x = unsafe { *(*inp.data32.add(c.min(inp.channel_count as usize - 1))).add(i) };
|
||||
x * f.gain as f32
|
||||
}
|
||||
None => f.level as f32,
|
||||
};
|
||||
unsafe { *o.add(i) = v };
|
||||
}
|
||||
}
|
||||
CLAP_PROCESS_CONTINUE
|
||||
}
|
||||
|
||||
// ---- extensions ----
|
||||
|
||||
unsafe extern "C" fn params_count(p: *const clap_plugin) -> u32 {
|
||||
if unsafe { fx(p) }.synth { 0 } else { 3 }
|
||||
}
|
||||
|
||||
fn write_name(dst: &mut [c_char], s: &CStr) {
|
||||
for (d, b) in dst.iter_mut().zip(s.to_bytes_with_nul()) {
|
||||
*d = *b as c_char;
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn params_get_info(_p: *const clap_plugin, index: u32, info: *mut clap_param_info) -> bool {
|
||||
let info = unsafe { &mut *info };
|
||||
let (id, name, flags, min, max, def): (clap_id, &CStr, u32, f64, f64, f64) = match index {
|
||||
0 => (7, c"Gain", CLAP_PARAM_IS_AUTOMATABLE, 0.0, 2.0, 1.0),
|
||||
1 => (3, c"Mode", CLAP_PARAM_IS_STEPPED | CLAP_PARAM_IS_ENUM, 0.0, 2.0, 0.0),
|
||||
2 => (9, c"Secret", CLAP_PARAM_IS_HIDDEN, 0.0, 1.0, 0.0),
|
||||
_ => return false,
|
||||
};
|
||||
info.id = id;
|
||||
info.flags = flags;
|
||||
info.cookie = std::ptr::null_mut();
|
||||
write_name(&mut info.name, name);
|
||||
write_name(&mut info.module, c"");
|
||||
info.min_value = min;
|
||||
info.max_value = max;
|
||||
info.default_value = def;
|
||||
true
|
||||
}
|
||||
|
||||
unsafe extern "C" fn params_get_value(p: *const clap_plugin, id: clap_id, out: *mut f64) -> bool {
|
||||
let f = unsafe { fx(p) };
|
||||
let v = match id {
|
||||
7 => f.gain,
|
||||
3 => f.mode,
|
||||
9 => 0.0,
|
||||
_ => return false,
|
||||
};
|
||||
unsafe { *out = v };
|
||||
true
|
||||
}
|
||||
|
||||
unsafe extern "C" fn params_value_to_text(_p: *const clap_plugin, id: clap_id, v: f64, buf: *mut c_char, cap: u32) -> bool {
|
||||
if id != 3 || cap < 2 {
|
||||
return false;
|
||||
}
|
||||
let label = [b'A', b'B', b'C'].get(v.round() as usize).copied().unwrap_or(b'?');
|
||||
unsafe {
|
||||
*buf = label as c_char;
|
||||
*buf.add(1) = 0;
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
unsafe extern "C" fn params_flush(p: *const clap_plugin, inp: *const clap_input_events, _out: *const clap_output_events) {
|
||||
let f = unsafe { fx(p) };
|
||||
let ev = unsafe { &*inp };
|
||||
let n = unsafe { (ev.size.unwrap())(inp) };
|
||||
for i in 0..n {
|
||||
apply(f, unsafe { &*(ev.get.unwrap())(inp, i) });
|
||||
}
|
||||
}
|
||||
|
||||
static PARAMS: clap_plugin_params = clap_plugin_params {
|
||||
count: Some(params_count),
|
||||
get_info: Some(params_get_info),
|
||||
get_value: Some(params_get_value),
|
||||
value_to_text: Some(params_value_to_text),
|
||||
text_to_value: None,
|
||||
flush: Some(params_flush),
|
||||
};
|
||||
|
||||
unsafe extern "C" fn ports_count(p: *const clap_plugin, is_input: bool) -> u32 {
|
||||
if is_input && unsafe { fx(p) }.synth { 0 } else { 1 }
|
||||
}
|
||||
|
||||
unsafe extern "C" fn ports_get(p: *const clap_plugin, index: u32, is_input: bool, info: *mut clap_audio_port_info) -> bool {
|
||||
if index != 0 || (is_input && unsafe { fx(p) }.synth) {
|
||||
return false;
|
||||
}
|
||||
let info = unsafe { &mut *info };
|
||||
info.id = if is_input { 0 } else { 1 };
|
||||
write_name(&mut info.name, c"main");
|
||||
info.flags = CLAP_AUDIO_PORT_IS_MAIN;
|
||||
info.channel_count = 2;
|
||||
info.port_type = CLAP_PORT_STEREO.as_ptr();
|
||||
info.in_place_pair = clap_sys::id::CLAP_INVALID_ID;
|
||||
true
|
||||
}
|
||||
|
||||
static PORTS: clap_plugin_audio_ports = clap_plugin_audio_ports { count: Some(ports_count), get: Some(ports_get) };
|
||||
|
||||
unsafe extern "C" fn latency_get(_p: *const clap_plugin) -> u32 {
|
||||
3
|
||||
}
|
||||
|
||||
static LATENCY: clap_plugin_latency = clap_plugin_latency { get: Some(latency_get) };
|
||||
|
||||
unsafe extern "C" fn notes_count(p: *const clap_plugin, is_input: bool) -> u32 {
|
||||
if is_input && unsafe { fx(p) }.synth { 1 } else { 0 }
|
||||
}
|
||||
|
||||
unsafe extern "C" fn notes_get(_p: *const clap_plugin, index: u32, is_input: bool, info: *mut clap_note_port_info) -> bool {
|
||||
if index != 0 || !is_input {
|
||||
return false;
|
||||
}
|
||||
let info = unsafe { &mut *info };
|
||||
info.id = 0;
|
||||
info.supported_dialects = CLAP_NOTE_DIALECT_CLAP | CLAP_NOTE_DIALECT_MIDI;
|
||||
info.preferred_dialect = CLAP_NOTE_DIALECT_CLAP;
|
||||
write_name(&mut info.name, c"notes");
|
||||
true
|
||||
}
|
||||
|
||||
static NOTES: clap_plugin_note_ports = clap_plugin_note_ports { count: Some(notes_count), get: Some(notes_get) };
|
||||
|
||||
unsafe extern "C" fn get_extension(p: *const clap_plugin, id: *const c_char) -> *const c_void {
|
||||
let id = unsafe { CStr::from_ptr(id) };
|
||||
let synth = unsafe { fx(p) }.synth;
|
||||
if id == CLAP_EXT_PARAMS {
|
||||
&PARAMS as *const _ as *const c_void
|
||||
} else if id == CLAP_EXT_AUDIO_PORTS {
|
||||
&PORTS as *const _ as *const c_void
|
||||
} else if id == CLAP_EXT_LATENCY && !synth {
|
||||
&LATENCY as *const _ as *const c_void
|
||||
} else if id == CLAP_EXT_NOTE_PORTS && synth {
|
||||
&NOTES as *const _ as *const c_void
|
||||
} else {
|
||||
std::ptr::null()
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn on_main_thread(_p: *const clap_plugin) {}
|
||||
|
||||
// ---- factory + entry ----
|
||||
|
||||
unsafe extern "C" fn factory_count(_f: *const clap_plugin_factory) -> u32 {
|
||||
2
|
||||
}
|
||||
|
||||
unsafe extern "C" fn factory_desc(_f: *const clap_plugin_factory, i: u32) -> *const clap_plugin_descriptor {
|
||||
match i {
|
||||
0 => &GAIN_DESC,
|
||||
1 => &SYNTH_DESC,
|
||||
_ => std::ptr::null(),
|
||||
}
|
||||
}
|
||||
|
||||
unsafe extern "C" fn factory_create(_f: *const clap_plugin_factory, host: *const clap_host, id: *const c_char) -> *const clap_plugin {
|
||||
let id = unsafe { CStr::from_ptr(id) };
|
||||
let (desc, synth) = if id == c"org.soundcraft.test.gain" {
|
||||
(&GAIN_DESC as *const _, false)
|
||||
} else if id == c"org.soundcraft.test.synth" {
|
||||
(&SYNTH_DESC as *const _, true)
|
||||
} else {
|
||||
return std::ptr::null();
|
||||
};
|
||||
let b = Box::new(Fx {
|
||||
plugin: clap_plugin {
|
||||
desc,
|
||||
plugin_data: std::ptr::null_mut(),
|
||||
init: Some(init),
|
||||
destroy: Some(destroy),
|
||||
activate: Some(activate),
|
||||
deactivate: Some(deactivate),
|
||||
start_processing: Some(start_processing),
|
||||
stop_processing: Some(stop_processing),
|
||||
reset: Some(reset),
|
||||
process: Some(process),
|
||||
get_extension: Some(get_extension),
|
||||
on_main_thread: Some(on_main_thread),
|
||||
},
|
||||
host,
|
||||
synth,
|
||||
gain: 1.0,
|
||||
mode: 0.0,
|
||||
level: 0.0,
|
||||
active: false,
|
||||
processing: false,
|
||||
});
|
||||
let raw = Box::into_raw(b);
|
||||
unsafe {
|
||||
(*raw).plugin.plugin_data = raw as *mut c_void;
|
||||
&(*raw).plugin
|
||||
}
|
||||
}
|
||||
|
||||
static FACTORY: clap_plugin_factory =
|
||||
clap_plugin_factory { get_plugin_count: Some(factory_count), get_plugin_descriptor: Some(factory_desc), create_plugin: Some(factory_create) };
|
||||
|
||||
unsafe extern "C" fn entry_init(_path: *const c_char) -> bool {
|
||||
true
|
||||
}
|
||||
unsafe extern "C" fn entry_deinit() {}
|
||||
unsafe extern "C" fn entry_get_factory(id: *const c_char) -> *const c_void {
|
||||
if unsafe { CStr::from_ptr(id) } == CLAP_PLUGIN_FACTORY_ID { &FACTORY as *const _ as *const c_void } else { std::ptr::null() }
|
||||
}
|
||||
|
||||
#[unsafe(no_mangle)]
|
||||
pub static clap_entry: clap_plugin_entry =
|
||||
clap_plugin_entry { clap_version: CLAP_VERSION, init: Some(entry_init), deinit: Some(entry_deinit), get_factory: Some(entry_get_factory) };
|
||||
|
|
@ -0,0 +1,160 @@
|
|||
//! End to end: build the test CLAP plugin in `tests/fixture` (a cdylib outside the workspace),
|
||||
//! install it as a `.clap` in a temp folder, then scan → instantiate → process → params → notes.
|
||||
|
||||
use soundcraft_dsp::{Category, Unit};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
/// Builds the fixture once and returns a folder containing `Fixture.clap`.
|
||||
fn fixture_dir() -> &'static Path {
|
||||
static DIR: OnceLock<PathBuf> = OnceLock::new();
|
||||
DIR.get_or_init(|| {
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixture/Cargo.toml");
|
||||
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("clap-fixture-target");
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let st = Command::new(cargo)
|
||||
.args(["build", "--quiet", "--manifest-path"])
|
||||
.arg(&manifest)
|
||||
.arg("--target-dir")
|
||||
.arg(&target)
|
||||
.env_remove("RUSTFLAGS")
|
||||
.status()
|
||||
.expect("run cargo");
|
||||
assert!(st.success(), "building the CLAP fixture failed");
|
||||
let lib = if cfg!(target_os = "macos") {
|
||||
"libclap_fixture.dylib"
|
||||
} else if cfg!(windows) {
|
||||
"clap_fixture.dll"
|
||||
} else {
|
||||
"libclap_fixture.so"
|
||||
};
|
||||
let built = target.join("debug").join(lib);
|
||||
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("clap-fixture-install-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
if cfg!(target_os = "macos") {
|
||||
// Install as a real macOS bundle to exercise bundle resolution.
|
||||
let exe = dir.join("vendor/Fixture.clap/Contents/MacOS");
|
||||
std::fs::create_dir_all(&exe).unwrap();
|
||||
std::fs::copy(&built, exe.join("Fixture")).unwrap();
|
||||
} else {
|
||||
std::fs::create_dir_all(dir.join("vendor")).unwrap();
|
||||
std::fs::copy(&built, dir.join("vendor/Fixture.clap")).unwrap();
|
||||
}
|
||||
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 2);
|
||||
dir
|
||||
})
|
||||
}
|
||||
|
||||
const GAIN: &str = "clap:org.soundcraft.test.gain";
|
||||
const SYNTH: &str = "clap:org.soundcraft.test.synth";
|
||||
|
||||
#[test]
|
||||
fn scan_lists_both_fixture_plugins() {
|
||||
let dir = fixture_dir();
|
||||
let found = soundcraft_clap_host::scan_paths(&[dir.to_path_buf()]);
|
||||
assert_eq!(found.len(), 2);
|
||||
let g = found.iter().find(|d| d.id == GAIN).unwrap();
|
||||
assert_eq!(g.name, "Fixture Gain");
|
||||
assert_eq!(g.vendor, "SoundCraft tests");
|
||||
assert_eq!(g.features, vec!["audio-effect", "stereo"]);
|
||||
assert_eq!(g.category, Category::Other);
|
||||
assert!(!g.is_instrument);
|
||||
let s = found.iter().find(|d| d.id == SYNTH).unwrap();
|
||||
assert_eq!(s.category, Category::Instrument);
|
||||
assert!(s.is_instrument);
|
||||
let global = soundcraft_clap_host::scan();
|
||||
assert!(global.iter().any(|d| d.id == GAIN) && global.iter().any(|d| d.id == SYNTH));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn info_exposes_visible_params_with_names() {
|
||||
fixture_dir();
|
||||
let info = soundcraft_clap_host::plugin_info(GAIN).unwrap();
|
||||
assert_eq!(info.id, GAIN);
|
||||
assert_eq!(info.short_name, "Fixture ");
|
||||
let ids: Vec<&str> = info.params.iter().map(|p| p.id).collect();
|
||||
assert_eq!(ids, vec!["7", "3"], "hidden param 9 is skipped");
|
||||
let gain = info.param("7").unwrap();
|
||||
assert_eq!((gain.name, gain.min, gain.max, gain.default), ("Gain", 0.0, 2.0, 1.0));
|
||||
let mode = info.param("3").unwrap();
|
||||
assert_eq!(mode.unit, Unit::Choice);
|
||||
assert_eq!(mode.choices, &["A", "B", "C"]);
|
||||
// Cached: the same leaked info every time.
|
||||
assert!(std::ptr::eq(info, soundcraft_clap_host::plugin_info(GAIN).unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gain_processes_audio_and_follows_params() {
|
||||
fixture_dir();
|
||||
let mut p = soundcraft_clap_host::create(GAIN).unwrap();
|
||||
p.prepare(44_100.0, 256, 2);
|
||||
assert_eq!(p.latency(), 3);
|
||||
assert_eq!(p.param("7"), Some(1.0));
|
||||
let mut io = vec![vec![0.5f32; 256], vec![-0.25f32; 256]];
|
||||
p.process(&mut io, 256);
|
||||
assert!(io[0].iter().all(|&x| (x - 0.5).abs() < 1e-6));
|
||||
assert!(p.set_param("7", 2.0));
|
||||
assert!(!p.set_param("9", 1.0), "hidden params are not exposed");
|
||||
assert!(!p.set_param("Gain", 1.0), "ids are CLAP numbers");
|
||||
assert!(p.set_param("7", 5.0), "clamped");
|
||||
assert_eq!(p.param("7"), Some(2.0));
|
||||
// A block larger than max_block is chunked.
|
||||
let mut io = vec![vec![0.5f32; 700], vec![-0.25f32; 700]];
|
||||
p.process(&mut io, 700);
|
||||
assert!(io[0].iter().all(|&x| (x - 1.0).abs() < 1e-6));
|
||||
assert!(io[1].iter().all(|&x| (x + 0.5).abs() < 1e-6));
|
||||
// Mono strip: input duplicated into the stereo port, stereo output averaged back.
|
||||
p.prepare(48_000.0, 128, 1);
|
||||
let mut mono = vec![vec![0.25f32; 128]];
|
||||
p.process(&mut mono, 128);
|
||||
assert!(mono[0].iter().all(|&x| (x - 0.5).abs() < 1e-6));
|
||||
// Hostile shapes don't crash.
|
||||
p.process(&mut [], 64);
|
||||
let mut short = vec![vec![1.0f32; 4]];
|
||||
p.process(&mut short, 1_000_000);
|
||||
assert!(p.set_param("7", f32::NAN), "NaN becomes the default");
|
||||
assert_eq!(p.param("7"), Some(1.0));
|
||||
p.reset();
|
||||
p.prepare(f32::NAN, 0, 0);
|
||||
let mut io = vec![vec![0.1f32; 8]];
|
||||
p.process(&mut io, 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn synth_plays_notes_at_their_offsets() {
|
||||
fixture_dir();
|
||||
let info = soundcraft_clap_host::plugin_info(SYNTH).unwrap();
|
||||
assert!(info.is_instrument);
|
||||
let mut p = soundcraft_clap_host::create(SYNTH).unwrap();
|
||||
p.prepare(48_000.0, 64, 2);
|
||||
let mut io = vec![vec![9.0f32; 64], vec![9.0f32; 64]];
|
||||
p.note_on(10, 60, 127);
|
||||
p.note_off(40, 60);
|
||||
p.process(&mut io, 64);
|
||||
for (i, &x) in io[0].iter().enumerate() {
|
||||
let want = if (10..40).contains(&i) { 1.0 } else { 0.0 };
|
||||
assert!((x - want).abs() < 1e-6, "frame {i}: {x}");
|
||||
}
|
||||
assert_eq!(io[0], io[1]);
|
||||
// Offsets past the block are clamped into it; all_notes_off releases held notes.
|
||||
p.note_on(1000, 61, 64);
|
||||
let mut io = vec![vec![0.0f32; 64], vec![0.0f32; 64]];
|
||||
p.process(&mut io, 64);
|
||||
assert!(io[0][62].abs() < 1e-6 && (io[0][63] - 64.0 / 127.0).abs() < 1e-6);
|
||||
p.all_notes_off();
|
||||
p.process(&mut io, 64);
|
||||
assert!(io[0].iter().all(|x| x.abs() < 1e-6));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn many_instances_create_and_drop_cleanly() {
|
||||
fixture_dir();
|
||||
for _ in 0..50 {
|
||||
let mut a = soundcraft_clap_host::create(GAIN).unwrap();
|
||||
let b = soundcraft_clap_host::create(SYNTH).unwrap();
|
||||
let mut io = vec![vec![0.0f32; 32]; 2];
|
||||
a.process(&mut io, 32);
|
||||
drop(b);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ soundcraft-time.workspace = true
|
|||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-midi.workspace = true
|
||||
soundcraft-dsp.workspace = true
|
||||
soundcraft-clap-host.workspace = true
|
||||
soundcraft-model.workspace = true
|
||||
soundcraft-mix.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
|
|
@ -0,0 +1,55 @@
|
|||
//! Third-party CLAP plugins: discovery (`engine.clap_plugins`) and id lookup shared by the mixer
|
||||
//! commands. Hosting itself lives in `soundcraft-clap-host`.
|
||||
|
||||
use super::*;
|
||||
use crate::cmd;
|
||||
use serde_json::json;
|
||||
use soundcraft_dsp::PluginInfo;
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![cmd!(
|
||||
query "engine.clap_plugins",
|
||||
"List CLAP Plugins",
|
||||
[],
|
||||
None,
|
||||
"{rescan?: bool} → [{id: \"clap:…\", plugin_id, name, vendor, version, description, features, category, is_instrument, path}]",
|
||||
always,
|
||||
|_, p| {
|
||||
let list = if p.get("rescan").and_then(Value::as_bool).unwrap_or(false) {
|
||||
soundcraft_clap_host::rescan()
|
||||
} else {
|
||||
soundcraft_clap_host::scan()
|
||||
};
|
||||
Ok(json!(list))
|
||||
}
|
||||
)]
|
||||
}
|
||||
|
||||
/// A built-in plugin's description, else a hosted CLAP plugin's (`clap:<id>`).
|
||||
pub fn plugin_info(id: &str) -> Option<&'static PluginInfo> {
|
||||
soundcraft_dsp::plugin_info(id).or_else(|| soundcraft_clap_host::plugin_info(id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::Engine;
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn clap_plugins_query_returns_a_list() {
|
||||
let mut e = Engine::default();
|
||||
let v = e.execute("engine.clap_plugins", &json!({})).unwrap();
|
||||
assert!(v.is_array());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_clap_insert_is_an_error() {
|
||||
let mut e = Engine::default();
|
||||
e.execute("track.new", &json!({})).unwrap();
|
||||
let t = e.session().tracks[0].id.0;
|
||||
let r = e.execute("mix.insert", &json!({"track": t, "plugin": "clap:no.such.plugin"}));
|
||||
assert!(r.is_err());
|
||||
assert!(super::plugin_info("eq_7band").is_some());
|
||||
assert!(super::plugin_info("clap:no.such.plugin").is_none());
|
||||
}
|
||||
}
|
||||
|
|
@ -63,6 +63,23 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
Ok(json!({"tracks": tracks}))
|
||||
}),
|
||||
cmd!(noundo "file.bounce_mix", "Bounce Mix...", ["File"], Some("Cmd+Alt+B"), "{path, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?, source?: main|bus name, normalize?: false, dither?: true}", always, bounce),
|
||||
cmd!(noundo "file.bounce_stems", "Bounce Stems...", [], None, "{dir, format?: wav|aiff|flac, bit_depth?: 16|24|32f, start?, end?}", has_tracks, |e, p| {
|
||||
let dir = str_param(p, "dir").ok_or_else(|| bad("file.bounce_stems", "`dir` required"))?.to_string();
|
||||
let r = range_param(e, "file.bounce_stems", p)?;
|
||||
let r = if r.is_empty() { soundcraft_time::Range::new(0, e.session().content_end().max(1)) } else { r };
|
||||
let format = match str_param(p, "format") {
|
||||
Some("aiff") | Some("aif") => soundcraft_audio_io::FileFormat::Aiff,
|
||||
Some("flac") => soundcraft_audio_io::FileFormat::Flac,
|
||||
_ => soundcraft_audio_io::FileFormat::Wav,
|
||||
};
|
||||
let bit_depth = match p.get("bit_depth").map(|b| b.to_string().trim_matches('"').to_string()).as_deref() {
|
||||
Some("16") => soundcraft_audio_io::BitDepth::Int16,
|
||||
Some("32") | Some("32f") => soundcraft_audio_io::BitDepth::Float32,
|
||||
_ => soundcraft_audio_io::BitDepth::Int24,
|
||||
};
|
||||
let files = crate::io::bounce_stems(e, &dir, r, &soundcraft_audio_io::EncodeOptions { format, bit_depth, dither: true, bwf: None })?;
|
||||
Ok(json!({"files": files}))
|
||||
}),
|
||||
cmd!(noundo "file.export_midi", "MIDI...", ["File", "Export"], None, "{path, tracks?}", always, |e, p| {
|
||||
let path = str_param(p, "path").ok_or_else(|| bad("file.export_midi", "`path` required"))?.to_string();
|
||||
let bytes = crate::io::export_midi(e)?;
|
||||
|
|
|
|||
|
|
@ -278,7 +278,7 @@ fn track_slot(e: &Engine, p: &Value, cmd: &str, max: usize) -> Result<(TrackId,
|
|||
|
||||
fn insert(e: &mut Engine, p: &Value) -> Result<Value> {
|
||||
let plugin = str_param(p, "plugin").ok_or_else(|| bad("mix.insert", "`plugin` required"))?.to_string();
|
||||
let info = soundcraft_dsp::plugin_info(&plugin).ok_or_else(|| bad("mix.insert", format!("unknown plugin `{plugin}`")))?;
|
||||
let info = super::clap::plugin_info(&plugin).ok_or_else(|| bad("mix.insert", format!("unknown plugin `{plugin}`")))?;
|
||||
let t = tracks_required(e, "mix.insert", p)?.first().copied().ok_or_else(|| bad("mix.insert", "no track"))?;
|
||||
let explicit = slot_of(p, "slot", INSERT_SLOTS);
|
||||
let s = e.session_mut();
|
||||
|
|
@ -335,7 +335,7 @@ fn insert_param(e: &mut Engine, p: &Value) -> Result<Value> {
|
|||
let s = e.session_mut();
|
||||
let ins =
|
||||
s.track_mut(t).and_then(|tr| tr.mixer.inserts.get_mut(slot)).and_then(Option::as_mut).ok_or_else(|| bad("mix.insert_param", "empty slot"))?;
|
||||
let info = soundcraft_dsp::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_param", "unknown plugin"))?;
|
||||
let info = super::clap::plugin_info(&ins.plugin).ok_or_else(|| bad("mix.insert_param", "unknown plugin"))?;
|
||||
let pi =
|
||||
info.params.iter().find(|x| x.id == param).ok_or_else(|| bad("mix.insert_param", format!("`{}` has no parameter `{param}`", info.id)))?;
|
||||
let v = value.clamp(pi.min, pi.max);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ use soundcraft_time::{Range, Samples};
|
|||
use std::sync::OnceLock;
|
||||
|
||||
mod audiosuite;
|
||||
mod clap;
|
||||
mod clip;
|
||||
mod clip_more;
|
||||
mod edit;
|
||||
|
|
@ -108,6 +109,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(clip_more::specs());
|
||||
v.extend(event_more::specs());
|
||||
v.extend(setup_more::specs());
|
||||
v.extend(clap::specs());
|
||||
v
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -148,6 +148,38 @@ pub fn bounce_bytes(e: &Engine, r: Range, opts: &EncodeOptions, normalize: bool)
|
|||
Ok((bytes, (peak, lufs, true_peak)))
|
||||
}
|
||||
|
||||
/// Bounce one file per audio/instrument track (each soloed, so its sends and auxes are included)
|
||||
/// into `dir`. Returns the written paths.
|
||||
pub fn bounce_stems(e: &Engine, dir: &str, r: Range, opts: &EncodeOptions) -> Result<Vec<String>> {
|
||||
let s = e.session();
|
||||
std::fs::create_dir_all(dir).map_err(|err| EngineError::Io(format!("{dir}: {err}")))?;
|
||||
let ext = match opts.format {
|
||||
FileFormat::Aiff => "aif",
|
||||
FileFormat::Flac => "flac",
|
||||
_ => "wav",
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let stems: Vec<(TrackId, String)> = s
|
||||
.tracks
|
||||
.iter()
|
||||
.filter(|t| matches!(t.kind, TrackKind::Audio | TrackKind::Instrument | TrackKind::Midi) && !t.inactive && !t.mixer.mute)
|
||||
.map(|t| (t.id, t.name.clone()))
|
||||
.collect();
|
||||
for (id, name) in stems {
|
||||
let mut solo = s.clone();
|
||||
for t in &mut solo.tracks {
|
||||
t.mixer.solo = t.id == id;
|
||||
}
|
||||
let ch = soundcraft_mix::render_range(&solo, r, 1024);
|
||||
let buf = AudioBuffer { sample_rate: s.sample_rate.hz(), channels: ch };
|
||||
let bytes = soundcraft_audio_io::encode(&buf, opts).map_err(|err| EngineError::Io(err.to_string()))?;
|
||||
let path = Path::new(dir).join(format!("{} - {}.{ext}", sanitize_name(&s.name), sanitize_name(&name)));
|
||||
std::fs::write(&path, bytes).map_err(|err| EngineError::Io(format!("{}: {err}", path.display())))?;
|
||||
out.push(path.to_string_lossy().into_owned());
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Transient positions (absolute) in a track's clips within `r`.
|
||||
pub fn transients_in(s: &Session, t: TrackId, r: Range, sens: f32) -> Vec<Samples> {
|
||||
let r = if r.is_empty() { Range::new(0, s.content_end()) } else { r };
|
||||
|
|
@ -466,3 +498,18 @@ mod save_tests {
|
|||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod stem_tests {
|
||||
#[test]
|
||||
fn stems_one_file_per_track() {
|
||||
let mut e = crate::demo::demo_engine();
|
||||
let dir = std::env::temp_dir().join(format!("soundcraft-stems-{}", std::process::id()));
|
||||
let r = e
|
||||
.execute("file.bounce_stems", &serde_json::json!({"dir": dir.to_string_lossy(), "start": {"seconds": 10.0}, "end": {"seconds": 11.0}}))
|
||||
.unwrap();
|
||||
let n = r["files"].as_array().map_or(0, Vec::len);
|
||||
assert_eq!(n, 7, "{r}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,65 @@
|
|||
//! A hosted CLAP plugin through the whole stack: `mix.insert` / `mix.insert_param` accept a
|
||||
//! `clap:` id and the mix engine renders through it. Uses the test plugin built from
|
||||
//! `crates/clap-host/tests/fixture` (a gain whose param "7" is linear gain).
|
||||
|
||||
use serde_json::json;
|
||||
use soundcraft_audio_io::AudioBuffer;
|
||||
use soundcraft_engine::Engine;
|
||||
use soundcraft_model::{ChannelFormat, Clip, SourceAudio, SourceId, TrackKind};
|
||||
use soundcraft_time::Range;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
use std::sync::Arc;
|
||||
|
||||
fn install_fixture() {
|
||||
let manifest = Path::new(env!("CARGO_MANIFEST_DIR")).join("../clap-host/tests/fixture/Cargo.toml");
|
||||
let target = Path::new(env!("CARGO_TARGET_TMPDIR")).join("clap-fixture-target");
|
||||
let cargo = std::env::var("CARGO").unwrap_or_else(|_| "cargo".into());
|
||||
let st = Command::new(cargo)
|
||||
.args(["build", "--quiet", "--manifest-path"])
|
||||
.arg(&manifest)
|
||||
.arg("--target-dir")
|
||||
.arg(&target)
|
||||
.env_remove("RUSTFLAGS")
|
||||
.status()
|
||||
.unwrap();
|
||||
assert!(st.success());
|
||||
let lib = if cfg!(target_os = "macos") {
|
||||
"libclap_fixture.dylib"
|
||||
} else if cfg!(windows) {
|
||||
"clap_fixture.dll"
|
||||
} else {
|
||||
"libclap_fixture.so"
|
||||
};
|
||||
let dir = Path::new(env!("CARGO_TARGET_TMPDIR")).join(format!("engine-clap-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::copy(target.join("debug").join(lib), dir.join("Fixture.clap")).unwrap();
|
||||
assert_eq!(soundcraft_clap_host::add_search_dir(&dir), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clap_insert_renders_through_the_mix() {
|
||||
install_fixture();
|
||||
let mut e = Engine::default();
|
||||
let list = e.execute("engine.clap_plugins", &json!({})).unwrap();
|
||||
assert!(list.as_array().unwrap().iter().any(|d| d["id"] == "clap:org.soundcraft.test.gain"));
|
||||
|
||||
let s = e.session_mut();
|
||||
let t = s.add_track(TrackKind::Audio, ChannelFormat::Mono, None);
|
||||
s.pool.insert(SourceId(77), Arc::new(SourceAudio::new(AudioBuffer { sample_rate: 48_000, channels: vec![vec![0.25; 4800]] })));
|
||||
let id = s.new_clip_id();
|
||||
s.track_mut(t).unwrap().playlist_mut().unwrap().clips.push(Clip::audio(id, "dc", SourceId(77), 0, 0, 4800));
|
||||
let dry = soundcraft_mix::render_range(e.session(), Range::new(0, 4800), 512)[0][2000];
|
||||
assert!(dry.abs() > 0.01);
|
||||
|
||||
let r = e.execute("mix.insert", &json!({"track": t.0, "plugin": "clap:org.soundcraft.test.gain", "params": {"7": 2.0}})).unwrap();
|
||||
assert_eq!(r["plugin"], "clap:org.soundcraft.test.gain");
|
||||
let wet = soundcraft_mix::render_range(e.session(), Range::new(0, 4800), 512)[0][2000];
|
||||
assert!((wet - 2.0 * dry).abs() < 1e-4, "dry {dry} wet {wet}");
|
||||
|
||||
e.execute("mix.insert_param", &json!({"track": t.0, "slot": 0, "param": "7", "value": 0.5})).unwrap();
|
||||
let half = soundcraft_mix::render_range(e.session(), Range::new(0, 4800), 512)[0][2000];
|
||||
assert!((half - 0.5 * dry).abs() < 1e-4, "dry {dry} half {half}");
|
||||
assert!(e.execute("mix.insert_param", &json!({"track": t.0, "slot": 0, "param": "Gain", "value": 1.0})).is_err());
|
||||
}
|
||||
|
|
@ -12,6 +12,7 @@ soundcraft-time.workspace = true
|
|||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-midi.workspace = true
|
||||
soundcraft-dsp.workspace = true
|
||||
soundcraft-clap-host.workspace = true
|
||||
soundcraft-model.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
|
|
|
|||
|
|
@ -209,7 +209,7 @@ impl MixEngine {
|
|||
for (slot, ins) in strip.plugins.iter_mut().zip(t.mixer.inserts.iter()) {
|
||||
match ins {
|
||||
Some(i) if slot.as_ref().is_none_or(|p| p.id != i.plugin) => {
|
||||
*slot = soundcraft_dsp::create(&i.plugin).map(|mut p| {
|
||||
*slot = create_plugin(&i.plugin).map(|mut p| {
|
||||
p.prepare(sr, mb, ch);
|
||||
PluginSlot { id: i.plugin.clone(), plugin: p, applied: Vec::new() }
|
||||
});
|
||||
|
|
@ -221,7 +221,7 @@ impl MixEngine {
|
|||
let want = t.instrument.as_ref().map(|i| i.plugin.as_str());
|
||||
if strip.instrument.as_ref().map(|p| p.id.as_str()) != want {
|
||||
strip.instrument = want.and_then(|id| {
|
||||
soundcraft_dsp::create(id).map(|mut p| {
|
||||
create_plugin(id).map(|mut p| {
|
||||
p.prepare(sr, mb, ch.max(2));
|
||||
PluginSlot { id: id.to_string(), plugin: p, applied: Vec::new() }
|
||||
})
|
||||
|
|
@ -287,11 +287,8 @@ impl MixEngine {
|
|||
// for the slowest aux return (aux latencies are from the previous block; they are stable).
|
||||
let pdc = s.edit.delay_compensation;
|
||||
let lmax_t = if pdc { work.iter().map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
|
||||
let lmax_a = if pdc {
|
||||
self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0)
|
||||
} else {
|
||||
0
|
||||
};
|
||||
let lmax_a =
|
||||
if pdc { self.strips.iter().filter(|(id, _)| s.track(**id).is_some_and(is_aux)).map(|(_, st)| st.latency).max().unwrap_or(0) } else { 0 };
|
||||
self.latency = lmax_t + lmax_a;
|
||||
for (t, st) in work.iter_mut() {
|
||||
let ch = st.buf.len();
|
||||
|
|
@ -1107,6 +1104,11 @@ pub fn render_track_pre_fader(s: &Session, track: TrackId, range: Range) -> Vec<
|
|||
out
|
||||
}
|
||||
|
||||
/// A built-in plugin, else a hosted CLAP plugin (`clap:<id>`).
|
||||
fn create_plugin(id: &str) -> Option<Box<dyn Plugin>> {
|
||||
soundcraft_dsp::create(id).or_else(|| soundcraft_clap_host::create(id))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ soundcraft-time.workspace = true
|
|||
soundcraft-audio-io.workspace = true
|
||||
soundcraft-midi.workspace = true
|
||||
soundcraft-dsp.workspace = true
|
||||
soundcraft-clap-host.workspace = true
|
||||
soundcraft-model.workspace = true
|
||||
soundcraft-mix.workspace = true
|
||||
soundcraft-engine.workspace = true
|
||||
|
|
|
|||
|
|
@ -285,7 +285,7 @@ fn insert_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: R
|
|||
Stroke::new(1.0, Color32::from_rgb(16, 16, 16)),
|
||||
StrokeKind::Inside,
|
||||
);
|
||||
let label = ins.as_ref().and_then(|i| soundcraft_dsp::plugin_info(&i.plugin)).map_or("", |p| p.short_name);
|
||||
let label = ins.as_ref().and_then(|i| plugin_info(&i.plugin)).map_or("", |p| p.short_name);
|
||||
if label.is_empty() {
|
||||
ui.painter().circle_filled(pos2(r.min.x + 6.0, r.center().y), 1.5, t.text_dim);
|
||||
} else {
|
||||
|
|
@ -324,6 +324,23 @@ pub fn plugin_menu(app: &mut SoundApp, ui: &mut Ui, id: TrackId, slot: usize, oc
|
|||
}
|
||||
});
|
||||
}
|
||||
// Third-party CLAP plugins (scanned once, cached by soundcraft-clap-host).
|
||||
ui.menu_button("CLAP", |ui| {
|
||||
let found: Vec<_> = soundcraft_clap_host::scan().into_iter().filter(|d| !d.is_instrument).collect();
|
||||
if found.is_empty() {
|
||||
ui.label("No CLAP plugins found");
|
||||
}
|
||||
for d in found {
|
||||
if ui.button(format!("{} ({})", d.name, d.vendor)).clicked() {
|
||||
let _ = app.run("mix.insert", json!({"track": id.0, "slot": slot, "plugin": d.id}));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// A built-in plugin's description, else a hosted CLAP plugin's (`clap:<id>`).
|
||||
pub fn plugin_info(id: &str) -> Option<&'static soundcraft_dsp::PluginInfo> {
|
||||
soundcraft_dsp::plugin_info(id).or_else(|| soundcraft_clap_host::plugin_info(id))
|
||||
}
|
||||
|
||||
fn send_slot(app: &mut SoundApp, ui: &mut Ui, track: &Track, slot: usize, r: Rect) {
|
||||
|
|
|
|||
|
|
@ -248,7 +248,7 @@ fn plugin_windows(app: &mut SoundApp, ctx: &egui::Context) {
|
|||
else {
|
||||
continue;
|
||||
};
|
||||
let Some(info) = soundcraft_dsp::plugin_info(&ins.plugin) else { continue };
|
||||
let Some(info) = crate::mix_window::plugin_info(&ins.plugin) else { continue };
|
||||
let mut open = true;
|
||||
egui::Window::new(format!("{tname} · {} · {}", (b'a' + slot as u8) as char, info.name))
|
||||
.id(egui::Id::new(("plugin", tid.0, slot)))
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@ const LAYERS: &[(&str, u8)] = &[
|
|||
("audio-io", 0),
|
||||
("midi", 0),
|
||||
("dsp", 1),
|
||||
("clap-host", 1),
|
||||
("model", 2),
|
||||
("mix", 3),
|
||||
("engine", 4),
|
||||
|
|
|
|||
Loading…
Reference in New Issue