From 2fc8e2ecb893e1368c64f942195068519b7b4ddf Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Mon, 5 Oct 2026 23:44:22 -0400 Subject: [PATCH] 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 --- AGENTS.md | 1 + Cargo.lock | 21 + Cargo.toml | 1 + crates/clap-host/Cargo.toml | 30 + crates/clap-host/src/ffi.rs | 845 ++++++++++++++++++++++ crates/clap-host/src/lib.rs | 223 ++++++ crates/clap-host/src/plugin.rs | 246 +++++++ crates/clap-host/src/registry.rs | 209 ++++++ crates/clap-host/src/scan.rs | 215 ++++++ crates/clap-host/tests/fixture/Cargo.lock | 16 + crates/clap-host/tests/fixture/Cargo.toml | 18 + crates/clap-host/tests/fixture/src/lib.rs | 398 ++++++++++ crates/clap-host/tests/fixture_e2e.rs | 160 ++++ crates/engine/Cargo.toml | 1 + crates/engine/src/cmd/clap.rs | 55 ++ crates/engine/src/cmd/file.rs | 17 + crates/engine/src/cmd/mix.rs | 4 +- crates/engine/src/cmd/mod.rs | 2 + crates/engine/src/io.rs | 47 ++ crates/engine/tests/clap_e2e.rs | 65 ++ crates/mix/Cargo.toml | 1 + crates/mix/src/lib.rs | 16 +- crates/ui-egui/Cargo.toml | 1 + crates/ui-egui/src/mix_window.rs | 19 +- crates/ui-egui/src/panels.rs | 2 +- xtask/src/main.rs | 1 + 26 files changed, 2603 insertions(+), 11 deletions(-) create mode 100644 crates/clap-host/Cargo.toml create mode 100644 crates/clap-host/src/ffi.rs create mode 100644 crates/clap-host/src/lib.rs create mode 100644 crates/clap-host/src/plugin.rs create mode 100644 crates/clap-host/src/registry.rs create mode 100644 crates/clap-host/src/scan.rs create mode 100644 crates/clap-host/tests/fixture/Cargo.lock create mode 100644 crates/clap-host/tests/fixture/Cargo.toml create mode 100644 crates/clap-host/tests/fixture/src/lib.rs create mode 100644 crates/clap-host/tests/fixture_e2e.rs create mode 100644 crates/engine/src/cmd/clap.rs create mode 100644 crates/engine/tests/clap_e2e.rs diff --git a/AGENTS.md b/AGENTS.md index 8a1d3b2..9943d23 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` + `?`. 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. diff --git a/Cargo.lock b/Cargo.lock index 60e9409..3af133d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", diff --git a/Cargo.toml b/Cargo.toml index 33911bc..f78c5b8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" } diff --git a/crates/clap-host/Cargo.toml b/crates/clap-host/Cargo.toml new file mode 100644 index 0000000..b0e0554 --- /dev/null +++ b/crates/clap-host/Cargo.toml @@ -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" diff --git a/crates/clap-host/src/ffi.rs b/crates/clap-host/src/ffi.rs new file mode 100644 index 0000000..4215038 --- /dev/null +++ b/crates/clap-host/src/ffi.rs @@ -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 { + 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 = 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, +} + +/// 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>> { + static L: OnceLock>>> = 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, 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 { + 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 { + 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, plugin_id: &str) -> Result { + 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::(), + 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_EXT_PARAMS); + inst.ports = inst.extension::(CLAP_EXT_AUDIO_PORTS); + inst.latency = inst.extension::(CLAP_EXT_LATENCY); + inst.notes = inst.extension::(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, 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_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_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, 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).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).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>>, + ptrs: Vec>, + bufs: Vec, + 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>> = + 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, + 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, + state: Box, + _bundle: Arc, +} + +// 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(&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::(), + 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 { + // 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 { + // 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 { + // 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 { + // 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 { + // 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 { + 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).cast_mut().cast::(), + 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; + } +} diff --git a/crates/clap-host/src/lib.rs b/crates/clap-host/src/lib.rs new file mode 100644 index 0000000..1300826 --- /dev/null +++ b/crates/clap-host/src/lib.rs @@ -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:`; 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:` 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:`. + 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, + 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 { + registry::scan() + } + pub fn rescan() -> Vec { + registry::rescan() + } + pub fn add_search_dir(dir: &Path) -> usize { + registry::add_search_dir(dir) + } + pub fn scan_paths(dirs: &[PathBuf]) -> Vec { + registry::scan_paths(dirs) + } + pub fn load_bundle(path: &Path) -> Result, ClapError> { + registry::load_bundle(path) + } + pub fn instantiate(id: &str) -> Result, 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 { + Vec::new() + } + pub fn rescan() -> Vec { + Vec::new() + } + pub fn add_search_dir(_dir: &Path) -> usize { + 0 + } + pub fn scan_paths(_dirs: &[PathBuf]) -> Vec { + Vec::new() + } + pub fn load_bundle(_path: &Path) -> Result, ClapError> { + Err(ClapError::Unsupported) + } + pub fn instantiate(_id: &str) -> Result, 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 { + api::scan() +} + +/// Forgets the cached scan (and remembered failures) and scans again. +pub fn rescan() -> Vec { + 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 { + api::scan_paths(dirs) +} + +/// The plugins in one `.clap` file or bundle. +pub fn load_bundle(path: &Path) -> Result, ClapError> { + api::load_bundle(path) +} + +/// Creates a plugin by `clap:`, 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, ClapError> { + api::instantiate(id) +} + +/// Creates a plugin by `clap:`; `None` if unknown or it fails to load. +pub fn create(id: &str) -> Option> { + parse_id(id)?; + match api::instantiate(id) { + Ok(p) => Some(p), + Err(e) => { + log::warn!("{e}"); + None + } + } +} + +/// The description of a `clap:` 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); + } +} diff --git a/crates/clap-host/src/plugin.rs b/crates/clap-host/src/plugin.rs new file mode 100644 index 0000000..6dbd45d --- /dev/null +++ b/crates/clap-host/src/plugin.rs @@ -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, + values: Vec, + /// Pending events, kept sorted by time (stable for equal times). Never grows past `EVENT_CAP`. + queue: Vec, + inputs: PortBuffers, + outputs: PortBuffers, + in_main: Option, + out_main: Option, + 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 = info.params.iter().map(|p| p.id.parse::().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 { + ports.iter().position(|p| p.main && p.channels > 0).or_else(|| ports.iter().position(|p| p.channels > 0)) +} + +fn note_mode(p: Option) -> 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::>(), mb); + self.outputs = PortBuffers::new(&outs.iter().map(|p| p.channels).collect::>(), 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 { + let i = self.info.params.iter().position(|p| p.id == id)?; + self.values.get(i).copied() + } + + fn process(&mut self, io: &mut [Vec], 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); + } + } + } +} diff --git a/crates/clap-host/src/registry.rs b/crates/clap-host/src/registry.rs new file mode 100644 index 0000000..32eedce --- /dev/null +++ b/crates/clap-host/src/registry.rs @@ -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>, + extra_dirs: Vec, + /// One leaked `PluginInfo` per plugin type that was actually instantiated (bounded by the + /// number of distinct installed plugins). + infos: HashMap, + /// Ids that failed to instantiate (not retried every UI frame). + failed: HashSet, +} + +fn registry() -> &'static Mutex { + static R: OnceLock> = 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, 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 { + let mut out: Vec = 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 { + 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 { + let mut r = registry().lock().unwrap_or_else(PoisonError::into_inner); + ensure_scanned(&mut r).clone() +} + +pub fn rescan() -> Vec { + 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 = + 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, 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) +} diff --git a/crates/clap-host/src/scan.rs b/crates/clap-host/src/scan.rs new file mode 100644 index 0000000..92a6f6d --- /dev/null +++ b/crates/clap-host/src/scan.rs @@ -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 { + 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, common_program_files: Option, local_app_data: Option) -> Vec { + 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 { + 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, 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/` (the executable named like the bundle, else the only/first file there). +pub fn bundle_executable(path: &Path) -> Option { + 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 = 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>(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>(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 = 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"])); + } +} diff --git a/crates/clap-host/tests/fixture/Cargo.lock b/crates/clap-host/tests/fixture/Cargo.lock new file mode 100644 index 0000000..a5e7d1d --- /dev/null +++ b/crates/clap-host/tests/fixture/Cargo.lock @@ -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" diff --git a/crates/clap-host/tests/fixture/Cargo.toml b/crates/clap-host/tests/fixture/Cargo.toml new file mode 100644 index 0000000..dac4af3 --- /dev/null +++ b/crates/clap-host/tests/fixture/Cargo.toml @@ -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] diff --git a/crates/clap-host/tests/fixture/src/lib.rs b/crates/clap-host/tests/fixture/src/lib.rs new file mode 100644 index 0000000..4381123 --- /dev/null +++ b/crates/clap-host/tests/fixture/src/lib.rs @@ -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 c_char; N]); +unsafe impl Sync for Features {} + +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) }; diff --git a/crates/clap-host/tests/fixture_e2e.rs b/crates/clap-host/tests/fixture_e2e.rs new file mode 100644 index 0000000..7f29514 --- /dev/null +++ b/crates/clap-host/tests/fixture_e2e.rs @@ -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 = 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); + } +} diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 6e13cfc..0548e44 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -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 diff --git a/crates/engine/src/cmd/clap.rs b/crates/engine/src/cmd/clap.rs new file mode 100644 index 0000000..51a045b --- /dev/null +++ b/crates/engine/src/cmd/clap.rs @@ -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 { + 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:`). +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()); + } +} diff --git a/crates/engine/src/cmd/file.rs b/crates/engine/src/cmd/file.rs index 7c6c09b..385f0ce 100644 --- a/crates/engine/src/cmd/file.rs +++ b/crates/engine/src/cmd/file.rs @@ -63,6 +63,23 @@ pub fn specs() -> Vec { 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)?; diff --git a/crates/engine/src/cmd/mix.rs b/crates/engine/src/cmd/mix.rs index da81136..5c1407d 100644 --- a/crates/engine/src/cmd/mix.rs +++ b/crates/engine/src/cmd/mix.rs @@ -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 { 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 { 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); diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index e28365b..2903436 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -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 }) } diff --git a/crates/engine/src/io.rs b/crates/engine/src/io.rs index d7afba6..c2cf798 100644 --- a/crates/engine/src/io.rs +++ b/crates/engine/src/io.rs @@ -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> { + 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 { 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); + } +} diff --git a/crates/engine/tests/clap_e2e.rs b/crates/engine/tests/clap_e2e.rs new file mode 100644 index 0000000..dc92a2d --- /dev/null +++ b/crates/engine/tests/clap_e2e.rs @@ -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()); +} diff --git a/crates/mix/Cargo.toml b/crates/mix/Cargo.toml index 7e17890..07c9001 100644 --- a/crates/mix/Cargo.toml +++ b/crates/mix/Cargo.toml @@ -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 diff --git a/crates/mix/src/lib.rs b/crates/mix/src/lib.rs index f434a0b..865643f 100644 --- a/crates/mix/src/lib.rs +++ b/crates/mix/src/lib.rs @@ -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:`). +fn create_plugin(id: &str) -> Option> { + soundcraft_dsp::create(id).or_else(|| soundcraft_clap_host::create(id)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index facbdd3..39f627f 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -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 diff --git a/crates/ui-egui/src/mix_window.rs b/crates/ui-egui/src/mix_window.rs index d0fb44e..8b93453 100644 --- a/crates/ui-egui/src/mix_window.rs +++ b/crates/ui-egui/src/mix_window.rs @@ -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:`). +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) { diff --git a/crates/ui-egui/src/panels.rs b/crates/ui-egui/src/panels.rs index 93949b2..9bf7400 100644 --- a/crates/ui-egui/src/panels.rs +++ b/crates/ui-egui/src/panels.rs @@ -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))) diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c604516..0cf30f3 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -22,6 +22,7 @@ const LAYERS: &[(&str, u8)] = &[ ("audio-io", 0), ("midi", 0), ("dsp", 1), + ("clap-host", 1), ("model", 2), ("mix", 3), ("engine", 4),