Skip to main content

wavecraft_dev_server/audio/
ffi_processor.rs

1//! FFI processor wrapper and dev audio processor trait.
2//!
3//! This module bridges the C-ABI `DevProcessorVTable` (loaded from the user's
4//! cdylib) to a safe Rust trait that the audio server can drive.
5
6use std::ffi::c_void;
7use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
8use wavecraft_protocol::DevProcessorVTable;
9
10/// Simplified audio processor trait for dev mode.
11///
12/// Unlike the full `wavecraft_dsp::Processor` trait, this has no associated
13/// types and works with both direct Rust implementations and FFI-loaded
14/// processors via type erasure.
15pub trait DevAudioProcessor: Send + 'static {
16    /// Process deinterleaved audio in-place.
17    fn process(&mut self, channels: &mut [&mut [f32]]);
18
19    /// Apply plain parameter values in canonical generation order.
20    fn apply_plain_values(&mut self, values: &[f32]);
21
22    /// Update the processor's sample rate.
23    fn set_sample_rate(&mut self, sample_rate: f32);
24
25    /// Reset processor state.
26    fn reset(&mut self);
27}
28
29/// Wraps a `DevProcessorVTable` into a safe `DevAudioProcessor`.
30///
31/// Owns the opaque processor instance and dispatches through vtable
32/// function pointers. All allocation and deallocation happens inside
33/// the dylib via the vtable — no cross-allocator issues.
34pub struct FfiProcessor {
35    instance: *mut c_void,
36    vtable: DevProcessorVTable,
37    supports_plain_values: bool,
38    unsupported_channel_count: AtomicU32,
39    unsupported_channel_flag: AtomicBool,
40}
41
42// SAFETY: The processor instance is only accessed from the cpal audio
43// callback thread (single-threaded access). The `Send` bound allows
44// transferring it from the main thread (where it's created) to the
45// audio thread. `FfiProcessor` is NOT `Sync` — no concurrent access.
46unsafe impl Send for FfiProcessor {}
47
48impl FfiProcessor {
49    /// Create a new FFI processor from a loaded vtable.
50    ///
51    /// Calls the vtable's `create` function to allocate the processor
52    /// inside the dylib. Returns `None` if `create` returns null
53    /// (indicating a panic or allocation failure inside the dylib).
54    pub fn new(vtable: &DevProcessorVTable) -> Option<Self> {
55        let instance = (vtable.create)();
56        if instance.is_null() {
57            return None;
58        }
59        Some(Self {
60            instance,
61            vtable: *vtable,
62            supports_plain_values: vtable.version >= 2,
63            unsupported_channel_count: AtomicU32::new(0),
64            unsupported_channel_flag: AtomicBool::new(false),
65        })
66    }
67
68    fn process_dimensions(channels: &[&mut [f32]]) -> Option<(u32, u32)> {
69        let num_channels = channels.len() as u32;
70        if num_channels == 0 || channels[0].is_empty() {
71            return None;
72        }
73
74        Some((num_channels, channels[0].len() as u32))
75    }
76
77    fn prepare_channel_ptrs(&self, channels: &mut [&mut [f32]]) -> Option<[*mut f32; 2]> {
78        // Real-time safety: use a stack-allocated array instead of Vec.
79        // Wavecraft targets stereo (2 channels). Guard against unexpected
80        // multi-channel input to avoid out-of-bounds access.
81        if channels.len() > 2 {
82            // Real-time safe reporting: set a one-shot flag and count events.
83            // A non-RT path can poll and report via `take_unsupported_channel_count`
84            // and `take_unsupported_channel_flag` if needed.
85            self.unsupported_channel_count
86                .fetch_add(1, Ordering::Relaxed);
87            self.unsupported_channel_flag.store(true, Ordering::Relaxed);
88            return None;
89        }
90
91        // Build fixed-size array of channel pointers for the C-ABI call.
92        // No heap allocation — this lives on the stack.
93        let mut ptrs: [*mut f32; 2] = [std::ptr::null_mut(); 2];
94        for (index, channel) in channels.iter_mut().enumerate() {
95            ptrs[index] = channel.as_mut_ptr();
96        }
97
98        Some(ptrs)
99    }
100
101    /// Non-RT diagnostic hook: returns and resets the count of callback
102    /// invocations that were skipped due to receiving more than 2 channels.
103    pub fn take_unsupported_channel_count(&self) -> u32 {
104        self.unsupported_channel_count.swap(0, Ordering::Relaxed)
105    }
106
107    /// Non-RT diagnostic hook: returns whether any unsupported channel event
108    /// occurred since the last call, then clears the flag.
109    pub fn take_unsupported_channel_flag(&self) -> bool {
110        self.unsupported_channel_flag.swap(false, Ordering::Relaxed)
111    }
112}
113
114impl DevAudioProcessor for FfiProcessor {
115    fn process(&mut self, channels: &mut [&mut [f32]]) {
116        let Some((num_channels, num_samples)) = Self::process_dimensions(channels) else {
117            return;
118        };
119
120        debug_assert!(
121            !self.instance.is_null(),
122            "FFI processor instance should be valid"
123        );
124        debug_assert!(
125            channels
126                .iter()
127                .all(|channel| channel.len() == num_samples as usize),
128            "FFI processor expects channel slices with equal lengths"
129        );
130
131        let Some(mut ptrs) = self.prepare_channel_ptrs(channels) else {
132            return;
133        };
134
135        (self.vtable.process)(self.instance, ptrs.as_mut_ptr(), num_channels, num_samples);
136    }
137
138    fn apply_plain_values(&mut self, values: &[f32]) {
139        if !self.supports_plain_values {
140            return;
141        }
142
143        // SAFETY: `self.instance` originates from the loaded vtable `create` function,
144        // `values.as_ptr()` is valid for `values.len()` elements for this call, and
145        // the plugin owns interpretation of plain-value order.
146        unsafe {
147            (self.vtable.apply_plain_values)(self.instance, values.as_ptr(), values.len());
148        }
149    }
150
151    fn set_sample_rate(&mut self, sample_rate: f32) {
152        (self.vtable.set_sample_rate)(self.instance, sample_rate);
153    }
154
155    fn reset(&mut self) {
156        (self.vtable.reset)(self.instance);
157    }
158}
159
160impl Drop for FfiProcessor {
161    fn drop(&mut self) {
162        if !self.instance.is_null() {
163            (self.vtable.drop)(self.instance);
164            self.instance = std::ptr::null_mut();
165        }
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use std::sync::Mutex;
173
174    // Mutex to serialize tests that share static mock flags.
175    // This prevents race conditions when tests run in parallel.
176    static TEST_LOCK: Mutex<()> = Mutex::new(());
177
178    // Static flags for mock vtable functions
179    static CREATE_CALLED: AtomicBool = AtomicBool::new(false);
180    static PROCESS_CALLED: AtomicBool = AtomicBool::new(false);
181    static SET_SAMPLE_RATE_CALLED: AtomicBool = AtomicBool::new(false);
182    static RESET_CALLED: AtomicBool = AtomicBool::new(false);
183    static DROP_CALLED: AtomicBool = AtomicBool::new(false);
184    static APPLY_PLAIN_VALUES_CALLED: AtomicBool = AtomicBool::new(false);
185    static APPLY_PLAIN_VALUES_LEN: AtomicU32 = AtomicU32::new(0);
186    static PROCESS_CHANNELS: AtomicU32 = AtomicU32::new(0);
187    static PROCESS_SAMPLES: AtomicU32 = AtomicU32::new(0);
188
189    fn reset_flags() {
190        CREATE_CALLED.store(false, Ordering::SeqCst);
191        PROCESS_CALLED.store(false, Ordering::SeqCst);
192        SET_SAMPLE_RATE_CALLED.store(false, Ordering::SeqCst);
193        RESET_CALLED.store(false, Ordering::SeqCst);
194        DROP_CALLED.store(false, Ordering::SeqCst);
195        APPLY_PLAIN_VALUES_CALLED.store(false, Ordering::SeqCst);
196        APPLY_PLAIN_VALUES_LEN.store(0, Ordering::SeqCst);
197        PROCESS_CHANNELS.store(0, Ordering::SeqCst);
198        PROCESS_SAMPLES.store(0, Ordering::SeqCst);
199    }
200
201    extern "C" fn mock_create() -> *mut c_void {
202        CREATE_CALLED.store(true, Ordering::SeqCst);
203        // Return a non-null sentinel (we never dereference it in mock)
204        std::ptr::dangling_mut::<c_void>()
205    }
206
207    extern "C" fn mock_create_null() -> *mut c_void {
208        CREATE_CALLED.store(true, Ordering::SeqCst);
209        std::ptr::null_mut()
210    }
211
212    extern "C" fn mock_process(
213        _instance: *mut c_void,
214        _channels: *mut *mut f32,
215        num_channels: u32,
216        num_samples: u32,
217    ) {
218        PROCESS_CALLED.store(true, Ordering::SeqCst);
219        PROCESS_CHANNELS.store(num_channels, Ordering::SeqCst);
220        PROCESS_SAMPLES.store(num_samples, Ordering::SeqCst);
221    }
222
223    extern "C" fn mock_set_sample_rate(_instance: *mut c_void, _sample_rate: f32) {
224        SET_SAMPLE_RATE_CALLED.store(true, Ordering::SeqCst);
225    }
226
227    extern "C" fn mock_reset(_instance: *mut c_void) {
228        RESET_CALLED.store(true, Ordering::SeqCst);
229    }
230
231    extern "C" fn mock_drop(_instance: *mut c_void) {
232        DROP_CALLED.store(true, Ordering::SeqCst);
233    }
234
235    unsafe extern "C" fn mock_apply_plain_values(
236        _instance: *mut c_void,
237        _values_ptr: *const f32,
238        len: usize,
239    ) {
240        APPLY_PLAIN_VALUES_CALLED.store(true, Ordering::SeqCst);
241        APPLY_PLAIN_VALUES_LEN.store(len as u32, Ordering::SeqCst);
242    }
243
244    fn mock_vtable() -> DevProcessorVTable {
245        DevProcessorVTable {
246            version: wavecraft_protocol::DEV_PROCESSOR_VTABLE_VERSION,
247            create: mock_create,
248            process: mock_process,
249            apply_plain_values: mock_apply_plain_values,
250            set_sample_rate: mock_set_sample_rate,
251            reset: mock_reset,
252            drop: mock_drop,
253        }
254    }
255
256    #[test]
257    fn test_ffi_processor_lifecycle() {
258        let _guard = TEST_LOCK.lock().unwrap();
259        reset_flags();
260        let vtable = mock_vtable();
261
262        let mut processor = FfiProcessor::new(&vtable).expect("create should succeed");
263        assert!(CREATE_CALLED.load(Ordering::SeqCst));
264
265        // Process some audio
266        let mut left = vec![0.0f32; 128];
267        let mut right = vec![0.0f32; 128];
268        let mut channels: Vec<&mut [f32]> = vec![&mut left, &mut right];
269        processor.process(&mut channels);
270        assert!(PROCESS_CALLED.load(Ordering::SeqCst));
271        assert_eq!(PROCESS_CHANNELS.load(Ordering::SeqCst), 2);
272        assert_eq!(PROCESS_SAMPLES.load(Ordering::SeqCst), 128);
273
274        // Drop should call vtable.drop
275        drop(processor);
276        assert!(DROP_CALLED.load(Ordering::SeqCst));
277    }
278
279    #[test]
280    fn test_ffi_processor_set_sample_rate_and_reset() {
281        let _guard = TEST_LOCK.lock().unwrap();
282        reset_flags();
283        let vtable = mock_vtable();
284
285        let mut processor = FfiProcessor::new(&vtable).expect("create should succeed");
286
287        processor.set_sample_rate(48000.0);
288        assert!(SET_SAMPLE_RATE_CALLED.load(Ordering::SeqCst));
289
290        processor.reset();
291        assert!(RESET_CALLED.load(Ordering::SeqCst));
292
293        drop(processor);
294    }
295
296    #[test]
297    fn test_ffi_processor_apply_plain_values() {
298        let _guard = TEST_LOCK.lock().unwrap();
299        reset_flags();
300        let vtable = mock_vtable();
301
302        let mut processor = FfiProcessor::new(&vtable).expect("create should succeed");
303        processor.apply_plain_values(&[0.1, 0.2, 0.3]);
304
305        assert!(APPLY_PLAIN_VALUES_CALLED.load(Ordering::SeqCst));
306        assert_eq!(APPLY_PLAIN_VALUES_LEN.load(Ordering::SeqCst), 3);
307    }
308
309    #[test]
310    fn test_ffi_processor_null_create_returns_none() {
311        let _guard = TEST_LOCK.lock().unwrap();
312        reset_flags();
313        let mut vtable = mock_vtable();
314        vtable.create = mock_create_null;
315
316        let result = FfiProcessor::new(&vtable);
317        assert!(CREATE_CALLED.load(Ordering::SeqCst));
318        assert!(
319            result.is_none(),
320            "Should return None when create returns null"
321        );
322    }
323
324    #[test]
325    fn test_ffi_processor_empty_channels_noop() {
326        let _guard = TEST_LOCK.lock().unwrap();
327        reset_flags();
328        let vtable = mock_vtable();
329        let mut processor = FfiProcessor::new(&vtable).expect("create should succeed");
330
331        // Empty channels → should not call process
332        PROCESS_CALLED.store(false, Ordering::SeqCst);
333        let mut channels: Vec<&mut [f32]> = vec![];
334        processor.process(&mut channels);
335        assert!(
336            !PROCESS_CALLED.load(Ordering::SeqCst),
337            "Should not call vtable.process with empty channels"
338        );
339
340        drop(processor);
341    }
342
343    #[test]
344    fn test_ffi_processor_multichannel_records_rt_safe_diagnostic() {
345        let _guard = TEST_LOCK.lock().unwrap();
346        reset_flags();
347        let vtable = mock_vtable();
348        let mut processor = FfiProcessor::new(&vtable).expect("create should succeed");
349
350        // More than 2 channels should skip processing and record diagnostics.
351        PROCESS_CALLED.store(false, Ordering::SeqCst);
352        let mut ch1 = vec![0.0f32; 16];
353        let mut ch2 = vec![0.0f32; 16];
354        let mut ch3 = vec![0.0f32; 16];
355        let mut channels: Vec<&mut [f32]> = vec![&mut ch1, &mut ch2, &mut ch3];
356
357        processor.process(&mut channels);
358
359        assert!(
360            !PROCESS_CALLED.load(Ordering::SeqCst),
361            "Should not call vtable.process when channel count > 2"
362        );
363        assert!(processor.take_unsupported_channel_flag());
364        assert_eq!(processor.take_unsupported_channel_count(), 1);
365
366        // Hooks are one-shot/resetting.
367        assert!(!processor.take_unsupported_channel_flag());
368        assert_eq!(processor.take_unsupported_channel_count(), 0);
369
370        drop(processor);
371    }
372}