audio_plugin_bsd/plugin.rs
1//! The host-side [`Plugin`] trait, the [`HostPlugin`] adapter that bridges the
2//! C ABI to it, and the [`AudioNodeAdapter`] that wraps an opaque plugin handle
3//! behind [`audio_core_bsd::AudioNode`].
4//!
5//! See [`crate`] for why plugins cross the `.so` boundary as `extern "C"`
6//! symbols rather than Rust trait objects.
7
8use std::ffi::c_void;
9
10use audio_core_bsd::{
11 AudioFrame, AudioNode, PortDescriptor, PortDirection, ProcessContext, SampleFormat,
12};
13
14use crate::metadata::PluginMetadata;
15use crate::symbols::{CreateFn, DestroyFn};
16
17/// Host-side abstraction over a loaded plugin.
18///
19/// `Plugin` is the trait the host calls after the ABI check passes. The
20/// loader's own implementation ([`HostPlugin`]) bridges the plugin's
21/// `extern "C"` entry symbols to this trait, but consumers that build their
22/// own plugin adapters (e.g. for in-process test plugins) may implement it
23/// directly.
24///
25/// `Send + Sync` is required so a [`PluginLoader`](crate::PluginLoader) can be
26/// moved between setup threads; instantiation produces a `Box<dyn AudioNode>`
27/// (which is only `Send`) for the single RT thread.
28pub trait Plugin: Send + Sync {
29 /// Static identity of this plugin (name, version, description, ...).
30 fn metadata(&self) -> &PluginMetadata;
31
32 /// Construct a fresh audio-node instance backed by this plugin.
33 ///
34 /// Each call yields an independent `Box<dyn AudioNode>` whose lifetime is
35 /// bounded by the caller; dropping it releases the opaque handle (when the
36 /// plugin exposes `audio_plugin_destroy`).
37 fn instantiate(&self) -> Box<dyn AudioNode>;
38}
39
40/// Host adapter that bridges a plugin's `extern "C"` entry symbols to the
41/// [`Plugin`] trait.
42///
43/// Constructed by [`PluginLoader`](crate::PluginLoader) after a successful ABI
44/// check; not intended to be built by external callers.
45pub(crate) struct HostPlugin {
46 metadata: PluginMetadata,
47 create_fn: CreateFn,
48 destroy_fn: Option<DestroyFn>,
49}
50
51impl HostPlugin {
52 /// Build a host adapter from verified entry symbols.
53 pub(crate) fn new(
54 metadata: PluginMetadata,
55 create_fn: CreateFn,
56 destroy_fn: Option<DestroyFn>,
57 ) -> Self {
58 Self {
59 metadata,
60 create_fn,
61 destroy_fn,
62 }
63 }
64}
65
66impl Plugin for HostPlugin {
67 fn metadata(&self) -> &PluginMetadata {
68 &self.metadata
69 }
70
71 fn instantiate(&self) -> Box<dyn AudioNode> {
72 // SAFETY: `create_fn` is the plugin's verified `audio_plugin_create`
73 // entry symbol. The plugin is responsible for returning a valid,
74 // non-aliased opaque handle (or null, which the adapter treats as a
75 // no-op handle). The plugin remains loaded for at least as long as the
76 // returned adapter because the loader owns the `Library`.
77 let handle = unsafe { (self.create_fn)() };
78 Box::new(AudioNodeAdapter::new(handle, self.destroy_fn))
79 }
80}
81
82/// `AudioNode` wrapper around an opaque plugin handle.
83///
84/// # 0.1.0 scope — pass-through
85///
86/// The 0.1.0 ABI defines `create`/`destroy` but not a per-cycle `process`
87/// dispatch protocol on the opaque handle. Accordingly this adapter's
88/// [`AudioNode::process`] is a **sample pass-through**: it copies the first
89/// input frame's samples into the first output frame unchanged. This lets the
90/// Task 4 integration test verify the full load → ABI check → instantiate →
91/// process → unload lifecycle without requiring a plugin DSP protocol.
92///
93/// A follow-up milestone will define the opaque-handle C process protocol and
94/// replace this pass-through with real DSP dispatch.
95pub(crate) struct AudioNodeAdapter {
96 /// Opaque handle returned by `audio_plugin_create` (may be null).
97 handle: *mut c_void,
98 /// Matching deallocator (optional; absent ⇒ leak on drop).
99 destroy_fn: Option<DestroyFn>,
100 /// Single mono F32 input port.
101 in_port: [PortDescriptor; 1],
102 /// Single mono F32 output port.
103 out_port: [PortDescriptor; 1],
104}
105
106// SAFETY: the opaque handle is owned solely by this adapter and is only
107// touched from `process` (RT thread) and `Drop` (RT or setup thread), never
108// concurrently. The graph engine guarantees a single RT thread, and the
109// adapter is moved onto it by value. Function pointers (`Option<DestroyFn>`)
110// are `Send + Sync`. Hence the adapter as a whole is safe to send.
111unsafe impl Send for AudioNodeAdapter {}
112
113impl AudioNodeAdapter {
114 /// Build a pass-through adapter around an opaque handle.
115 pub(crate) fn new(handle: *mut c_void, destroy_fn: Option<DestroyFn>) -> Self {
116 Self {
117 handle,
118 destroy_fn,
119 in_port: [PortDescriptor::new(
120 PortDirection::Input,
121 1,
122 SampleFormat::F32,
123 )],
124 out_port: [PortDescriptor::new(
125 PortDirection::Output,
126 1,
127 SampleFormat::F32,
128 )],
129 }
130 }
131}
132
133impl AudioNode for AudioNodeAdapter {
134 fn inputs(&self) -> &[PortDescriptor] {
135 &self.in_port
136 }
137
138 fn outputs(&self) -> &[PortDescriptor] {
139 &self.out_port
140 }
141
142 fn process(
143 &mut self,
144 _ctx: &mut ProcessContext,
145 in_frames: &[AudioFrame],
146 out_frames: &mut [AudioFrame],
147 ) {
148 // RT-safe: bounded slicing, no alloc / lock / panic. The handle is not
149 // dispatched to in 0.1.0 (see the struct doc for the pass-through
150 // rationale); it is held only for lifecycle correctness.
151 let _ = self.handle;
152 let Some(inp) = in_frames.first() else {
153 return;
154 };
155 let Some(out) = out_frames.get_mut(0) else {
156 return;
157 };
158 let n = inp.samples.len().min(out.samples.len());
159 out.samples[..n].copy_from_slice(&inp.samples[..n]);
160 }
161}
162
163impl Drop for AudioNodeAdapter {
164 fn drop(&mut self) {
165 if let Some(destroy) = self.destroy_fn {
166 // SAFETY: `self.handle` was obtained from `audio_plugin_create`
167 // exactly once and has not been freed before. `destroy` is the
168 // plugin's matching deallocator. A null handle is passed through;
169 // the plugin's destroy must tolerate it (or the plugin must not
170 // return null from create).
171 unsafe { destroy(self.handle) };
172 }
173 // When `destroy_fn` is `None` the handle leaks — a documented 0.1.0
174 // limitation of plugins that do not expose `audio_plugin_destroy`.
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use super::*;
181
182 #[test]
183 fn adapter_is_pass_through_preserving_samples() {
184 let mut adapter = AudioNodeAdapter::new(std::ptr::null_mut(), None);
185 let mut ctx = ProcessContext::new(4, 0, 48_000);
186 let input = AudioFrame::from_planar(1, 48_000, vec![0.1, 0.2, -0.3, 0.5]);
187 let mut output = AudioFrame::silence(1, 4, 48_000);
188 let mut out_slice = [output];
189 adapter.process(&mut ctx, [input.clone()].as_slice(), &mut out_slice);
190 output = out_slice.into_iter().next().unwrap();
191 for (a, b) in output.samples.iter().zip(input.samples.iter()) {
192 assert!(((a - b).abs() < 1e-6));
193 }
194 }
195
196 #[test]
197 fn adapter_declares_one_mono_f32_input_and_output() {
198 let adapter = AudioNodeAdapter::new(std::ptr::null_mut(), None);
199 assert_eq!(adapter.inputs().len(), 1);
200 assert_eq!(adapter.outputs().len(), 1);
201 assert_eq!(adapter.inputs()[0].channels, 1);
202 assert_eq!(adapter.inputs()[0].sample_format, SampleFormat::F32);
203 assert_eq!(adapter.outputs()[0].direction, PortDirection::Output);
204 }
205
206 #[test]
207 fn adapter_process_with_empty_frames_does_not_panic() {
208 let mut adapter = AudioNodeAdapter::new(std::ptr::null_mut(), None);
209 let mut ctx = ProcessContext::new(0, 0, 48_000);
210 adapter.process(&mut ctx, &[], &mut []);
211 }
212
213 #[test]
214 fn adapter_process_clamps_to_shorter_buffer() {
215 // Input has 2 samples, output has room for 4 — only 2 are written.
216 let mut adapter = AudioNodeAdapter::new(std::ptr::null_mut(), None);
217 let mut ctx = ProcessContext::new(2, 0, 48_000);
218 let input = AudioFrame::from_planar(1, 48_000, vec![0.5, -0.5]);
219 let mut output = AudioFrame::silence(1, 4, 48_000);
220 let mut out_slice = [output];
221 adapter.process(&mut ctx, [input].as_slice(), &mut out_slice);
222 output = out_slice.into_iter().next().unwrap();
223 assert!(((output.samples[0] - 0.5).abs() < 1e-6));
224 assert!(((output.samples[1] + 0.5).abs() < 1e-6));
225 // Untouched tail stays zero.
226 assert!(((output.samples[2]).abs() < 1e-6));
227 assert!(((output.samples[3]).abs() < 1e-6));
228 }
229}