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 wavecraft_protocol::DevProcessorVTable;
8
9/// Simplified audio processor trait for dev mode.
10///
11/// Unlike the full `wavecraft_dsp::Processor` trait, this has no associated
12/// types and works with both direct Rust implementations and FFI-loaded
13/// processors via type erasure.
14pub trait DevAudioProcessor: Send + 'static {
15    /// Process deinterleaved audio in-place.
16    fn process(&mut self, channels: &mut [&mut [f32]]);
17
18    /// Update the processor's sample rate.
19    fn set_sample_rate(&mut self, sample_rate: f32);
20
21    /// Reset processor state.
22    fn reset(&mut self);
23}
24
25/// Wraps a `DevProcessorVTable` into a safe `DevAudioProcessor`.
26///
27/// Owns the opaque processor instance and dispatches through vtable
28/// function pointers. All allocation and deallocation happens inside
29/// the dylib via the vtable — no cross-allocator issues.
30pub struct FfiProcessor {
31    instance: *mut c_void,
32    vtable: DevProcessorVTable,
33}
34
35// SAFETY: The processor instance is only accessed from the cpal audio
36// callback thread (single-threaded access). The `Send` bound allows
37// transferring it from the main thread (where it's created) to the
38// audio thread. `FfiProcessor` is NOT `Sync` — no concurrent access.
39unsafe impl Send for FfiProcessor {}
40
41impl FfiProcessor {
42    /// Create a new FFI processor from a loaded vtable.
43    ///
44    /// Calls the vtable's `create` function to allocate the processor
45    /// inside the dylib. Returns `None` if `create` returns null
46    /// (indicating a panic or allocation failure inside the dylib).
47    pub fn new(vtable: &DevProcessorVTable) -> Option<Self> {
48        let instance = (vtable.create)();
49        if instance.is_null() {
50            return None;
51        }
52        Some(Self {
53            instance,
54            vtable: *vtable,
55        })
56    }
57}
58
59impl DevAudioProcessor for FfiProcessor {
60    fn process(&mut self, channels: &mut [&mut [f32]]) {
61        let num_channels = channels.len() as u32;
62        if num_channels == 0 || channels[0].is_empty() {
63            return;
64        }
65        let num_samples = channels[0].len() as u32;
66
67        // Real-time safety: use a stack-allocated array instead of Vec.
68        // Wavecraft targets stereo (2 channels). Guard against unexpected
69        // multi-channel input to avoid out-of-bounds access.
70        if channels.len() > 2 {
71            tracing::error!(
72                num_channels = channels.len(),
73                "FfiProcessor::process() received more than 2 channels; skipping"
74            );
75            return;
76        }
77
78        // Build fixed-size array of channel pointers for the C-ABI call.
79        // No heap allocation — this lives on the stack.
80        let mut ptrs: [*mut f32; 2] = [std::ptr::null_mut(); 2];
81        for (i, ch) in channels.iter_mut().enumerate() {
82            ptrs[i] = ch.as_mut_ptr();
83        }
84
85        (self.vtable.process)(self.instance, ptrs.as_mut_ptr(), num_channels, num_samples);
86    }
87
88    fn set_sample_rate(&mut self, sample_rate: f32) {
89        (self.vtable.set_sample_rate)(self.instance, sample_rate);
90    }
91
92    fn reset(&mut self) {
93        (self.vtable.reset)(self.instance);
94    }
95}
96
97impl Drop for FfiProcessor {
98    fn drop(&mut self) {
99        if !self.instance.is_null() {
100            (self.vtable.drop)(self.instance);
101            self.instance = std::ptr::null_mut();
102        }
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use super::*;
109    use std::sync::atomic::{AtomicBool, AtomicU32, Ordering};
110    use std::sync::Mutex;
111
112    // Mutex to serialize tests that share static mock flags.
113    // This prevents race conditions when tests run in parallel.
114    static TEST_LOCK: Mutex<()> = Mutex::new(());
115
116    // Static flags for mock vtable functions
117    static CREATE_CALLED: AtomicBool = AtomicBool::new(false);
118    static PROCESS_CALLED: AtomicBool = AtomicBool::new(false);
119    static SET_SAMPLE_RATE_CALLED: AtomicBool = AtomicBool::new(false);
120    static RESET_CALLED: AtomicBool = AtomicBool::new(false);
121    static DROP_CALLED: AtomicBool = AtomicBool::new(false);
122    static PROCESS_CHANNELS: AtomicU32 = AtomicU32::new(0);
123    static PROCESS_SAMPLES: AtomicU32 = AtomicU32::new(0);
124
125    fn reset_flags() {
126        CREATE_CALLED.store(false, Ordering::SeqCst);
127        PROCESS_CALLED.store(false, Ordering::SeqCst);
128        SET_SAMPLE_RATE_CALLED.store(false, Ordering::SeqCst);
129        RESET_CALLED.store(false, Ordering::SeqCst);
130        DROP_CALLED.store(false, Ordering::SeqCst);
131        PROCESS_CHANNELS.store(0, Ordering::SeqCst);
132        PROCESS_SAMPLES.store(0, Ordering::SeqCst);
133    }
134
135    extern "C" fn mock_create() -> *mut c_void {
136        CREATE_CALLED.store(true, Ordering::SeqCst);
137        // Return a non-null sentinel (we never dereference it in mock)
138        std::ptr::dangling_mut::<c_void>()
139    }
140
141    extern "C" fn mock_create_null() -> *mut c_void {
142        CREATE_CALLED.store(true, Ordering::SeqCst);
143        std::ptr::null_mut()
144    }
145
146    extern "C" fn mock_process(
147        _instance: *mut c_void,
148        _channels: *mut *mut f32,
149        num_channels: u32,
150        num_samples: u32,
151    ) {
152        PROCESS_CALLED.store(true, Ordering::SeqCst);
153        PROCESS_CHANNELS.store(num_channels, Ordering::SeqCst);
154        PROCESS_SAMPLES.store(num_samples, Ordering::SeqCst);
155    }
156
157    extern "C" fn mock_set_sample_rate(_instance: *mut c_void, _sample_rate: f32) {
158        SET_SAMPLE_RATE_CALLED.store(true, Ordering::SeqCst);
159    }
160
161    extern "C" fn mock_reset(_instance: *mut c_void) {
162        RESET_CALLED.store(true, Ordering::SeqCst);
163    }
164
165    extern "C" fn mock_drop(_instance: *mut c_void) {
166        DROP_CALLED.store(true, Ordering::SeqCst);
167    }
168
169    fn mock_vtable() -> DevProcessorVTable {
170        DevProcessorVTable {
171            version: wavecraft_protocol::DEV_PROCESSOR_VTABLE_VERSION,
172            create: mock_create,
173            process: mock_process,
174            set_sample_rate: mock_set_sample_rate,
175            reset: mock_reset,
176            drop: mock_drop,
177        }
178    }
179
180    #[test]
181    fn test_ffi_processor_lifecycle() {
182        let _guard = TEST_LOCK.lock().unwrap();
183        reset_flags();
184        let vtable = mock_vtable();
185
186        let mut processor = FfiProcessor::new(&vtable).expect("create should succeed");
187        assert!(CREATE_CALLED.load(Ordering::SeqCst));
188
189        // Process some audio
190        let mut left = vec![0.0f32; 128];
191        let mut right = vec![0.0f32; 128];
192        let mut channels: Vec<&mut [f32]> = vec![&mut left, &mut right];
193        processor.process(&mut channels);
194        assert!(PROCESS_CALLED.load(Ordering::SeqCst));
195        assert_eq!(PROCESS_CHANNELS.load(Ordering::SeqCst), 2);
196        assert_eq!(PROCESS_SAMPLES.load(Ordering::SeqCst), 128);
197
198        // Drop should call vtable.drop
199        drop(processor);
200        assert!(DROP_CALLED.load(Ordering::SeqCst));
201    }
202
203    #[test]
204    fn test_ffi_processor_set_sample_rate_and_reset() {
205        let _guard = TEST_LOCK.lock().unwrap();
206        reset_flags();
207        let vtable = mock_vtable();
208
209        let mut processor = FfiProcessor::new(&vtable).expect("create should succeed");
210
211        processor.set_sample_rate(48000.0);
212        assert!(SET_SAMPLE_RATE_CALLED.load(Ordering::SeqCst));
213
214        processor.reset();
215        assert!(RESET_CALLED.load(Ordering::SeqCst));
216
217        drop(processor);
218    }
219
220    #[test]
221    fn test_ffi_processor_null_create_returns_none() {
222        let _guard = TEST_LOCK.lock().unwrap();
223        reset_flags();
224        let mut vtable = mock_vtable();
225        vtable.create = mock_create_null;
226
227        let result = FfiProcessor::new(&vtable);
228        assert!(CREATE_CALLED.load(Ordering::SeqCst));
229        assert!(
230            result.is_none(),
231            "Should return None when create returns null"
232        );
233    }
234
235    #[test]
236    fn test_ffi_processor_empty_channels_noop() {
237        let _guard = TEST_LOCK.lock().unwrap();
238        reset_flags();
239        let vtable = mock_vtable();
240        let mut processor = FfiProcessor::new(&vtable).expect("create should succeed");
241
242        // Empty channels → should not call process
243        PROCESS_CALLED.store(false, Ordering::SeqCst);
244        let mut channels: Vec<&mut [f32]> = vec![];
245        processor.process(&mut channels);
246        assert!(
247            !PROCESS_CALLED.load(Ordering::SeqCst),
248            "Should not call vtable.process with empty channels"
249        );
250
251        drop(processor);
252    }
253}