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