Skip to main content

maolan_engine/plugins/
clap_proc.rs

1//! Out-of-process CLAP processor using `maolan-plugin-host` IPC.
2
3use crate::audio::io::AudioIO;
4use crate::midi::io::MidiEvent;
5use crate::mutex::UnsafeMutex;
6use crate::plugins::ipc;
7use crate::plugins::types::{
8    ClapMidiOutputEvent, ClapParamUpdate, ClapParameterInfo, ClapTransportInfo,
9};
10use maolan_plugin_protocol::events::EventPair;
11use maolan_plugin_protocol::protocol::*;
12use maolan_plugin_protocol::ringbuf::RingBuffer;
13use maolan_plugin_protocol::shm::ShmMapping;
14use std::collections::HashMap;
15use std::path::PathBuf;
16use std::process::Child;
17use std::sync::atomic::{AtomicBool, Ordering};
18use std::sync::{Arc, atomic::AtomicU32};
19use std::time::{Duration, Instant};
20
21/// Shared state for an out-of-process CLAP plugin instance.
22pub struct ClapProcessor {
23    path: String,
24    plugin_id: String,
25    name: String,
26    audio_inputs: Vec<Arc<AudioIO>>,
27    audio_outputs: Vec<Arc<AudioIO>>,
28    main_audio_inputs: usize,
29    main_audio_outputs: usize,
30    param_infos: Vec<ClapParameterInfo>,
31    param_values: UnsafeMutex<HashMap<u32, f64>>,
32    bypassed: Arc<AtomicBool>,
33    // IPC state
34    child: UnsafeMutex<Option<Child>>,
35    mapping: Option<ShmMapping>,
36    events: Option<EventPair>,
37    shm_name: String,
38    // Crash recovery
39    crash_count: AtomicU32,
40    last_process_time: UnsafeMutex<Instant>,
41}
42
43pub type SharedClapProcessor = Arc<UnsafeMutex<ClapProcessor>>;
44
45impl ClapProcessor {
46    pub fn new(
47        _sample_rate: f64,
48        buffer_size: usize,
49        plugin_spec: &str,
50        input_count: usize,
51        output_count: usize,
52        host_binary: PathBuf,
53    ) -> Result<Self, String> {
54        let (plugin_path, plugin_id) = split_plugin_spec(plugin_spec);
55
56        let audio_inputs = (0..input_count.max(1))
57            .map(|_| Arc::new(AudioIO::new(buffer_size)))
58            .collect::<Vec<_>>();
59        let audio_outputs = (0..output_count.max(1))
60            .map(|_| Arc::new(AudioIO::new(buffer_size)))
61            .collect::<Vec<_>>();
62
63        // Spawn the host immediately so we can query params.
64        let instance_id = format!("clap-{}", std::process::id());
65        let plugin_spec = if plugin_id.is_empty() {
66            plugin_path.to_string()
67        } else {
68            format!("{plugin_path}::{plugin_id}")
69        };
70        let (mut child, mapping, events, shm_name) = ipc::spawn_host(ipc::HostSpawnArgs {
71            host_binary: &host_binary,
72            format: "clap",
73            plugin_spec: &plugin_spec,
74            instance_id: &instance_id,
75            extra_args: &[],
76        })?;
77
78        let header = unsafe { header_ref(mapping.as_ptr()) };
79        if !ipc::wait_for_ready(header, Duration::from_secs(10)) {
80            let _ = child.kill();
81            return Err("host did not signal ready".to_string());
82        }
83
84        let name = unsafe {
85            let mut name = None;
86            for _ in 0..50 {
87                name = maolan_plugin_protocol::protocol::read_plugin_name_from_scratch(
88                    mapping.as_ptr(),
89                );
90                if name.is_some() {
91                    break;
92                }
93                std::thread::sleep(std::time::Duration::from_millis(10));
94            }
95            name.unwrap_or_else(|| plugin_id.to_string())
96        };
97
98        // Query parameter count from host via a simple param ring echo.
99        // For now, we use a minimal stub param list.
100        let param_infos = Vec::new();
101
102        Ok(Self {
103            path: plugin_spec.to_string(),
104            plugin_id: plugin_id.to_string(),
105            name,
106            audio_inputs,
107            audio_outputs,
108            main_audio_inputs: input_count.max(1),
109            main_audio_outputs: output_count.max(1),
110            param_infos,
111            param_values: UnsafeMutex::new(HashMap::new()),
112            bypassed: Arc::new(AtomicBool::new(false)),
113            child: UnsafeMutex::new(Some(child)),
114            mapping: Some(mapping),
115            events: Some(events),
116            shm_name,
117            crash_count: AtomicU32::new(0),
118            last_process_time: UnsafeMutex::new(Instant::now()),
119        })
120    }
121
122    pub fn setup_audio_ports(&self) {
123        for port in &self.audio_inputs {
124            port.setup();
125        }
126        for port in &self.audio_outputs {
127            port.setup();
128        }
129    }
130
131    pub fn audio_inputs(&self) -> &[Arc<AudioIO>] {
132        &self.audio_inputs
133    }
134
135    pub fn audio_outputs(&self) -> &[Arc<AudioIO>] {
136        &self.audio_outputs
137    }
138
139    pub fn main_audio_input_count(&self) -> usize {
140        self.main_audio_inputs
141    }
142
143    pub fn main_audio_output_count(&self) -> usize {
144        self.main_audio_outputs
145    }
146
147    pub fn midi_input_count(&self) -> usize {
148        0 // Stub: MIDI not yet wired over IPC
149    }
150
151    pub fn midi_output_count(&self) -> usize {
152        0
153    }
154
155    pub fn set_bypassed(&self, bypassed: bool) {
156        self.bypassed.store(bypassed, Ordering::Relaxed);
157    }
158
159    pub fn is_bypassed(&self) -> bool {
160        self.bypassed.load(Ordering::Relaxed)
161    }
162
163    pub fn parameter_infos(&self) -> Vec<ClapParameterInfo> {
164        self.param_infos.clone()
165    }
166
167    pub fn parameter_values(&self) -> HashMap<u32, f64> {
168        self.param_values.lock().clone()
169    }
170
171    pub fn set_parameter(&self, param_id: u32, value: f64) -> Result<(), String> {
172        self.set_parameter_at(param_id, value, 0)
173    }
174
175    pub fn set_parameter_at(&self, param_id: u32, value: f64, _frame: u32) -> Result<(), String> {
176        self.param_values.lock().insert(param_id, value);
177        // Write to param ring buffer if host is alive.
178        if let Some(ref mapping) = self.mapping {
179            let ring = unsafe {
180                let buf = param_ring_ptr(mapping.as_ptr());
181                let (w, r) = param_indices(mapping.as_ptr());
182                RingBuffer::new(buf, w, r, RING_CAPACITY)
183            };
184            let ev = ParameterEvent {
185                param_index: param_id,
186                value: value as f32,
187                sample_offset: 0,
188                event_kind: maolan_plugin_protocol::PARAM_EVENT_VALUE,
189            };
190            if !ring.push(ev) {
191                tracing::warn!("param ring full, dropping parameter event");
192            }
193        }
194        Ok(())
195    }
196
197    pub fn begin_parameter_edit(&self, _param_id: u32) -> Result<(), String> {
198        Ok(())
199    }
200
201    pub fn end_parameter_edit(&self, _param_id: u32) -> Result<(), String> {
202        Ok(())
203    }
204
205    pub fn is_parameter_edit_active(&self, _param_id: u32) -> bool {
206        false
207    }
208
209    pub fn snapshot_state(&self) -> Result<crate::plugins::types::ClapPluginState, String> {
210        Err("state snapshot not yet implemented".to_string())
211    }
212
213    pub fn restore_state(
214        &self,
215        _state: &crate::plugins::types::ClapPluginState,
216    ) -> Result<(), String> {
217        Err("state restore not yet implemented".to_string())
218    }
219
220    pub fn process_with_audio_io(&self, frames: usize) {
221        let _ = self.process_with_midi(frames, &[], ClapTransportInfo::default());
222    }
223
224    pub fn process_with_midi(
225        &self,
226        frames: usize,
227        _midi_in: &[MidiEvent],
228        _transport: ClapTransportInfo,
229    ) -> Vec<ClapMidiOutputEvent> {
230        if self.bypassed.load(Ordering::Relaxed) {
231            ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
232            return Vec::new();
233        }
234
235        {
236            let child = self.child.lock();
237            if let Some(ref mut c) = child.as_mut() {
238                match c.try_wait() {
239                    Ok(Some(status)) if !status.success() => {
240                        tracing::error!("plugin host crashed for '{}' ({})", self.name, self.path);
241                        self.crash_count.fetch_add(1, Ordering::Relaxed);
242                        ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
243                        return Vec::new();
244                    }
245                    _ => {}
246                }
247            }
248        }
249
250        let started = Instant::now();
251
252        let (mapping, events) = match (&self.mapping, &self.events) {
253            (Some(m), Some(e)) => (m, e),
254            _ => {
255                ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
256                return Vec::new();
257            }
258        };
259
260        let ptr = mapping.as_ptr();
261        unsafe {
262            ipc::configure_shm_header(
263                ptr,
264                frames,
265                self.audio_inputs.len(),
266                self.audio_outputs.len(),
267            );
268            ipc::copy_inputs_to_shm(&self.audio_inputs, ptr, frames);
269        }
270
271        if let Err(e) = events.signal_host() {
272            tracing::error!("Failed to signal host: {e}");
273            ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
274            return Vec::new();
275        }
276
277        let timeout = Duration::from_millis(100);
278        if let Err(e) = events.wait_host(timeout) {
279            tracing::error!(
280                "host did not respond for '{}' ({}): {e}",
281                self.name,
282                self.path
283            );
284            ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
285            return Vec::new();
286        }
287
288        unsafe {
289            ipc::copy_outputs_from_shm(&self.audio_outputs, ptr, frames);
290        }
291
292        let elapsed = started.elapsed();
293        if elapsed > Duration::from_millis(20) {
294            tracing::warn!(
295                "Slow process '{}' ({}) took {:.3} ms for {} frames",
296                self.name,
297                self.path,
298                elapsed.as_secs_f64() * 1000.0,
299                frames
300            );
301        }
302
303        *self.last_process_time.lock() = Instant::now();
304        Vec::new()
305    }
306
307    pub fn path(&self) -> &str {
308        &self.path
309    }
310
311    pub fn plugin_id(&self) -> &str {
312        &self.plugin_id
313    }
314
315    pub fn name(&self) -> &str {
316        &self.name
317    }
318
319    pub fn begin_parameter_edit_at(&self, _param_id: u32, _frame: u32) -> Result<(), String> {
320        Ok(())
321    }
322
323    pub fn end_parameter_edit_at(&self, _param_id: u32, _frame: u32) -> Result<(), String> {
324        Ok(())
325    }
326
327    pub fn run_host_callbacks_main_thread(&self) {}
328
329    pub fn reconfigure_ports_if_needed(&self) -> Result<bool, String> {
330        Ok(false)
331    }
332
333    pub fn ui_begin_session(&self) {}
334    pub fn ui_end_session(&self) {}
335    pub fn ui_should_close(&self) -> bool {
336        false
337    }
338    pub fn ui_take_due_timers(&self) -> Vec<u32> {
339        Vec::new()
340    }
341    pub fn ui_take_param_updates(&self) -> Vec<ClapParamUpdate> {
342        Vec::new()
343    }
344    pub fn ui_take_state_update(&self) -> Option<crate::plugins::types::ClapPluginState> {
345        None
346    }
347
348    pub fn gui_info(&self) -> Result<crate::plugins::types::ClapGuiInfo, String> {
349        Err("GUI not yet supported for CLAP plugins".to_string())
350    }
351
352    pub fn gui_create(&self, _api: &str, _is_floating: bool) -> Result<(), String> {
353        Err("GUI not yet supported for CLAP plugins".to_string())
354    }
355
356    pub fn gui_get_size(&self) -> Result<(u32, u32), String> {
357        Err("GUI not yet supported for CLAP plugins".to_string())
358    }
359
360    pub fn gui_set_parent_x11(&self, window: usize) -> Result<(), String> {
361        if let Some(ref mapping) = self.mapping {
362            let header = unsafe { header_mut(mapping.as_ptr()) };
363            header.set_parent_window(window);
364            return Ok(());
365        }
366        Err("No active host to set parent window".to_string())
367    }
368
369    pub fn gui_show(&self) -> Result<(), String> {
370        if let Some(ref mapping) = self.mapping
371            && let Some(ref events) = self.events
372        {
373            let header = unsafe { header_mut(mapping.as_ptr()) };
374            header.request_type.store(3, Ordering::Release);
375            let _ = events.signal_host();
376            return Ok(());
377        }
378        Err("No active host to show GUI".to_string())
379    }
380
381    pub fn gui_hide(&self) {
382        if let Some(ref mapping) = self.mapping
383            && let Some(ref events) = self.events
384        {
385            let header = unsafe { header_mut(mapping.as_ptr()) };
386            header.request_type.store(4, Ordering::Release);
387            let _ = events.signal_host();
388        }
389    }
390
391    pub fn gui_destroy(&self) {}
392
393    pub fn gui_on_main_thread(&self) {}
394
395    pub fn gui_on_timer(&self, _timer_id: u32) {}
396
397    pub fn note_names(&self) -> std::collections::HashMap<u8, String> {
398        std::collections::HashMap::new()
399    }
400
401    pub fn drain_echoed_parameters(&self) -> Vec<ParameterEvent> {
402        let mut result = Vec::new();
403        if let Some(ref mapping) = self.mapping {
404            let ring = unsafe {
405                let buf = echo_ring_ptr(mapping.as_ptr());
406                let (w, r) = echo_indices(mapping.as_ptr());
407                RingBuffer::new(buf, w, r, RING_CAPACITY)
408            };
409            while let Some(ev) = ring.pop() {
410                result.push(ev);
411            }
412        }
413        result
414    }
415
416    pub fn drain_midi_outputs(&self) -> Vec<crate::midi::io::MidiEvent> {
417        let mut result = Vec::new();
418        if let Some(ref mapping) = self.mapping {
419            let ring = unsafe {
420                let buf = midi_out_ring_ptr(mapping.as_ptr());
421                let (w, r) = midi_out_indices(mapping.as_ptr());
422                RingBuffer::new(buf, w, r, RING_CAPACITY)
423            };
424            while let Some(ev) = ring.pop() {
425                result.push(crate::midi::io::MidiEvent {
426                    frame: ev.sample_offset,
427                    data: ev.data.to_vec(),
428                });
429            }
430        }
431        result
432    }
433}
434
435impl Drop for ClapProcessor {
436    fn drop(&mut self) {
437        ipc::drop_host(&self.mapping, &self.events, &self.child, &self.shm_name);
438    }
439}
440
441crate::impl_ipc_processor_wrapper!(ClapProcessor);
442
443impl UnsafeMutex<ClapProcessor> {
444    pub fn process_with_midi(
445        &self,
446        frames: usize,
447        midi_events: &[MidiEvent],
448        transport: ClapTransportInfo,
449    ) -> Vec<ClapMidiOutputEvent> {
450        self.lock()
451            .process_with_midi(frames, midi_events, transport)
452    }
453
454    pub fn is_bypassed(&self) -> bool {
455        self.lock().is_bypassed()
456    }
457
458    pub fn parameter_infos(&self) -> Vec<ClapParameterInfo> {
459        self.lock().parameter_infos()
460    }
461
462    pub fn set_parameter(&self, param_id: u32, value: f64) -> Result<(), String> {
463        self.lock().set_parameter(param_id, value)
464    }
465
466    pub fn set_parameter_at(&self, param_id: u32, value: f64, frame: u32) -> Result<(), String> {
467        self.lock().set_parameter_at(param_id, value, frame)
468    }
469
470    pub fn begin_parameter_edit_at(&self, param_id: u32, frame: u32) -> Result<(), String> {
471        self.lock().begin_parameter_edit_at(param_id, frame)
472    }
473
474    pub fn end_parameter_edit_at(&self, param_id: u32, frame: u32) -> Result<(), String> {
475        self.lock().end_parameter_edit_at(param_id, frame)
476    }
477
478    pub fn snapshot_state(&self) -> Result<crate::plugins::types::ClapPluginState, String> {
479        self.lock().snapshot_state()
480    }
481
482    pub fn restore_state(
483        &self,
484        state: &crate::plugins::types::ClapPluginState,
485    ) -> Result<(), String> {
486        self.lock().restore_state(state)
487    }
488
489    pub fn path(&self) -> String {
490        self.lock().path().to_string()
491    }
492
493    pub fn plugin_id(&self) -> String {
494        self.lock().plugin_id().to_string()
495    }
496
497    pub fn ui_begin_session(&self) {
498        self.lock().ui_begin_session();
499    }
500
501    pub fn ui_end_session(&self) {
502        self.lock().ui_end_session();
503    }
504
505    pub fn ui_should_close(&self) -> bool {
506        self.lock().ui_should_close()
507    }
508
509    pub fn ui_take_due_timers(&self) -> Vec<u32> {
510        self.lock().ui_take_due_timers()
511    }
512
513    pub fn ui_take_param_updates(&self) -> Vec<ClapParamUpdate> {
514        self.lock().ui_take_param_updates()
515    }
516
517    pub fn ui_take_state_update(&self) -> Option<crate::plugins::types::ClapPluginState> {
518        self.lock().ui_take_state_update()
519    }
520
521    pub fn gui_info(&self) -> Result<crate::plugins::types::ClapGuiInfo, String> {
522        self.lock().gui_info()
523    }
524
525    pub fn gui_create(&self, api: &str, is_floating: bool) -> Result<(), String> {
526        self.lock().gui_create(api, is_floating)
527    }
528
529    pub fn gui_get_size(&self) -> Result<(u32, u32), String> {
530        self.lock().gui_get_size()
531    }
532
533    pub fn gui_set_parent_x11(&self, window: usize) -> Result<(), String> {
534        self.lock().gui_set_parent_x11(window)
535    }
536
537    pub fn gui_show(&self) -> Result<(), String> {
538        self.lock().gui_show()
539    }
540
541    pub fn gui_hide(&self) {
542        self.lock().gui_hide();
543    }
544
545    pub fn gui_destroy(&self) {
546        self.lock().gui_destroy();
547    }
548
549    pub fn gui_on_main_thread(&self) {
550        self.lock().gui_on_main_thread();
551    }
552
553    pub fn gui_on_timer(&self, timer_id: u32) {
554        self.lock().gui_on_timer(timer_id);
555    }
556
557    pub fn note_names(&self) -> std::collections::HashMap<u8, String> {
558        self.lock().note_names()
559    }
560}
561
562/// Locate the `maolan-plugin-host` binary at runtime.
563///
564/// Search order:
565/// 1. Same directory as the current executable.
566/// 2. Workspace `target/debug` or `target/release` (development).
567/// 3. `PATH` environment variable.
568fn split_plugin_spec(spec: &str) -> (&str, &str) {
569    // CLAP scanner uses "path::id"; host protocol uses "path#id".
570    if let Some(pos) = spec.rfind("::") {
571        (&spec[..pos], &spec[pos + 2..])
572    } else if let Some(pos) = spec.rfind('#') {
573        (&spec[..pos], &spec[pos + 1..])
574    } else {
575        (spec, "")
576    }
577}
578
579#[cfg(test)]
580mod tests {
581    use super::*;
582
583    fn find_host_binary() -> PathBuf {
584        let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap();
585        let workspace_root = std::path::Path::new(&manifest)
586            .parent()
587            .unwrap()
588            .join("daw");
589        workspace_root
590            .join("target")
591            .join("debug")
592            .join("maolan-plugin-host")
593    }
594
595    #[test]
596    fn clap_processor_processes_audio() {
597        let host_bin = find_host_binary();
598        if !host_bin.exists() {
599            eprintln!(
600                "Skipping test: host binary not found at {}",
601                host_bin.display()
602            );
603            return;
604        }
605
606        let plugin_path = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
607            .parent()
608            .unwrap()
609            .join("daw")
610            .join("plugin-host")
611            .join("tests")
612            .join("test_passthrough.clap");
613
614        if !plugin_path.exists() {
615            eprintln!(
616                "Skipping test: plugin not found at {}",
617                plugin_path.display()
618            );
619            return;
620        }
621
622        let processor = ClapProcessor::new(
623            48000.0,
624            256,
625            &format!("{}#com.maolan.test.passthrough", plugin_path.display()),
626            2,
627            2,
628            host_bin,
629        )
630        .expect("should create processor");
631
632        processor.setup_audio_ports();
633
634        // Fill input buffers with a ramp.
635        for (i, input) in processor.audio_inputs().iter().enumerate() {
636            let buf = input.buffer.lock();
637            for (j, sample) in buf.iter_mut().enumerate() {
638                *sample = (i * 1000 + j) as f32;
639            }
640            *input.finished.lock() = true;
641        }
642
643        // Process one block.
644        processor.process_with_audio_io(256);
645
646        // Verify output buffers were written (non-zero).
647        for output in processor.audio_outputs().iter() {
648            let buf = output.buffer.lock();
649            assert!(
650                buf.iter().any(|&s| s != 0.0),
651                "output buffer should contain non-zero samples"
652            );
653        }
654
655        // Processor is dropped here, which should gracefully shut down the host.
656    }
657
658    #[test]
659    fn clap_processor_crash_bypass() {
660        let host_bin = find_host_binary();
661        if !host_bin.exists() {
662            eprintln!("Skipping crash test: host binary not found");
663            return;
664        }
665
666        // Use the crash test mode.
667        let processor = ClapProcessor::new(48000.0, 256, "__crash__", 1, 1, host_bin)
668            .expect("should create processor for crash test");
669
670        processor.setup_audio_ports();
671
672        // Fill input buffer.
673        {
674            let buf = processor.audio_inputs()[0].buffer.lock();
675            buf.fill(1.0);
676            *processor.audio_inputs()[0].finished.lock() = true;
677        }
678
679        // First process should trigger the crash; subsequent calls should bypass.
680        processor.process_with_audio_io(256);
681
682        // After crash, output should be a copy of input (bypass).
683        let out_buf = processor.audio_outputs()[0].buffer.lock();
684        assert!(
685            out_buf.iter().all(|&s| s == 1.0),
686            "after crash, output should be bypass copy of input"
687        );
688    }
689
690    #[test]
691    fn clap_track_integration() {
692        use crate::track::Track;
693
694        let host_bin = find_host_binary();
695        if !host_bin.exists() {
696            eprintln!("Skipping track integration test: host binary not found");
697            return;
698        }
699
700        let plugin_path = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
701            .parent()
702            .unwrap()
703            .join("daw")
704            .join("plugin-host")
705            .join("tests")
706            .join("test_passthrough.clap");
707
708        if !plugin_path.exists() {
709            eprintln!(
710                "Skipping track integration test: plugin not found at {}",
711                plugin_path.display()
712            );
713            return;
714        }
715
716        let mut track = Track::new("test-track".to_string(), 2, 2, 0, 0, 256, 48000.0);
717
718        track
719            .load_clap_plugin(
720                &format!("{}::com.maolan.test.passthrough", plugin_path.display()),
721                None,
722            )
723            .expect("should load CLAP plugin on track");
724
725        assert_eq!(track.clap_plugins.len(), 1);
726
727        // Fill plugin inputs with a ramp and mark them ready.
728        for input in track.clap_plugins[0].processor.audio_inputs() {
729            let buf = input.buffer.lock();
730            for (j, sample) in buf.iter_mut().enumerate() {
731                *sample = j as f32;
732            }
733            *input.finished.lock() = true;
734        }
735
736        // Process one block.
737        track.process();
738
739        // Verify the plugin's output buffers contain the passthrough signal.
740        for (ch, output) in track.clap_plugins[0]
741            .processor
742            .audio_outputs()
743            .iter()
744            .enumerate()
745        {
746            let buf = output.buffer.lock();
747            assert!(
748                buf.iter().any(|&s| s != 0.0),
749                "plugin output ch={ch} should contain non-zero samples after CLAP processing"
750            );
751        }
752    }
753}