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    midi_inputs: usize,
31    midi_outputs: usize,
32    param_infos: Vec<ClapParameterInfo>,
33    param_values: UnsafeMutex<HashMap<u32, f64>>,
34    bypassed: Arc<AtomicBool>,
35    // IPC state
36    child: UnsafeMutex<Option<Child>>,
37    mapping: Option<ShmMapping>,
38    events: Option<EventPair>,
39    shm_name: String,
40    // Crash recovery
41    crash_count: AtomicU32,
42    last_process_time: UnsafeMutex<Instant>,
43}
44
45pub type SharedClapProcessor = Arc<UnsafeMutex<ClapProcessor>>;
46
47impl ClapProcessor {
48    pub fn new(
49        _sample_rate: f64,
50        buffer_size: usize,
51        plugin_spec: &str,
52        input_count: usize,
53        output_count: usize,
54        host_binary: PathBuf,
55    ) -> Result<Self, String> {
56        let (plugin_path, plugin_id) = split_plugin_spec(plugin_spec);
57
58        // Spawn the host immediately so we can query params.
59        let instance_id = ipc::unique_instance_id("clap");
60        let plugin_spec = if plugin_id.is_empty() {
61            plugin_path.to_string()
62        } else {
63            format!("{plugin_path}::{plugin_id}")
64        };
65        let (mut child, mapping, events, shm_name) = ipc::spawn_host(ipc::HostSpawnArgs {
66            host_binary: &host_binary,
67            format: "clap",
68            plugin_spec: &plugin_spec,
69            instance_id: &instance_id,
70            extra_args: &[],
71        })?;
72
73        let header = unsafe { header_ref(mapping.as_ptr()) };
74        if !ipc::wait_for_ready(header, Duration::from_secs(10)) {
75            let _ = child.kill();
76            return Err("host did not signal ready".to_string());
77        }
78
79        let name = unsafe {
80            let mut name = None;
81            for _ in 0..50 {
82                name = maolan_plugin_protocol::protocol::read_plugin_name_from_scratch(
83                    mapping.as_ptr(),
84                );
85                if name.is_some() {
86                    break;
87                }
88                std::thread::sleep(std::time::Duration::from_millis(10));
89            }
90            name.unwrap_or_else(|| plugin_id.to_string())
91        };
92
93        // Read port counts written by the host (with fallback to constructor params).
94        let (actual_audio_in, actual_audio_out, actual_midi_in, actual_midi_out) = unsafe {
95            let mut counts = None;
96            for _ in 0..50 {
97                counts = maolan_plugin_protocol::protocol::read_port_counts_from_scratch(
98                    mapping.as_ptr(),
99                );
100                if counts.is_some() {
101                    break;
102                }
103                std::thread::sleep(std::time::Duration::from_millis(10));
104            }
105            let result = counts.unwrap_or((input_count as u32, output_count as u32, 0, 0));
106            tracing::info!(
107                plugin = %plugin_spec,
108                audio_in = result.0,
109                audio_out = result.1,
110                midi_in = result.2,
111                midi_out = result.3,
112                from_host = counts.is_some(),
113                "CLAP processor port counts"
114            );
115            result
116        };
117
118        let audio_inputs = (0..actual_audio_in as usize)
119            .map(|_| Arc::new(AudioIO::new(buffer_size)))
120            .collect::<Vec<_>>();
121        let audio_outputs = (0..actual_audio_out as usize)
122            .map(|_| Arc::new(AudioIO::new(buffer_size)))
123            .collect::<Vec<_>>();
124
125        // Query parameter count from host via a simple param ring echo.
126        // For now, we use a minimal stub param list.
127        let param_infos = Vec::new();
128
129        Ok(Self {
130            path: plugin_spec.to_string(),
131            plugin_id: plugin_id.to_string(),
132            name,
133            audio_inputs,
134            audio_outputs,
135            main_audio_inputs: actual_audio_in as usize,
136            main_audio_outputs: actual_audio_out as usize,
137            midi_inputs: actual_midi_in as usize,
138            midi_outputs: actual_midi_out as usize,
139            param_infos,
140            param_values: UnsafeMutex::new(HashMap::new()),
141            bypassed: Arc::new(AtomicBool::new(false)),
142            child: UnsafeMutex::new(Some(child)),
143            mapping: Some(mapping),
144            events: Some(events),
145            shm_name,
146            crash_count: AtomicU32::new(0),
147            last_process_time: UnsafeMutex::new(Instant::now()),
148        })
149    }
150
151    pub fn setup_audio_ports(&self) {
152        for port in &self.audio_inputs {
153            port.setup();
154        }
155        for port in &self.audio_outputs {
156            port.setup();
157        }
158    }
159
160    pub fn audio_inputs(&self) -> &[Arc<AudioIO>] {
161        &self.audio_inputs
162    }
163
164    pub fn audio_outputs(&self) -> &[Arc<AudioIO>] {
165        &self.audio_outputs
166    }
167
168    pub fn main_audio_input_count(&self) -> usize {
169        self.main_audio_inputs
170    }
171
172    pub fn main_audio_output_count(&self) -> usize {
173        self.main_audio_outputs
174    }
175
176    pub fn midi_input_count(&self) -> usize {
177        self.midi_inputs
178    }
179
180    pub fn midi_output_count(&self) -> usize {
181        self.midi_outputs
182    }
183
184    pub fn set_bypassed(&self, bypassed: bool) {
185        self.bypassed.store(bypassed, Ordering::Relaxed);
186    }
187
188    pub fn is_bypassed(&self) -> bool {
189        self.bypassed.load(Ordering::Relaxed)
190    }
191
192    pub fn parameter_infos(&self) -> Vec<ClapParameterInfo> {
193        self.param_infos.clone()
194    }
195
196    pub fn parameter_values(&self) -> HashMap<u32, f64> {
197        self.param_values.lock().clone()
198    }
199
200    pub fn set_parameter(&self, param_id: u32, value: f64) -> Result<(), String> {
201        self.set_parameter_at(param_id, value, 0)
202    }
203
204    pub fn set_parameter_at(&self, param_id: u32, value: f64, _frame: u32) -> Result<(), String> {
205        self.param_values.lock().insert(param_id, value);
206        // Write to param ring buffer if host is alive.
207        if let Some(ref mapping) = self.mapping {
208            let ring = unsafe {
209                let buf = param_ring_ptr(mapping.as_ptr());
210                let (w, r) = param_indices(mapping.as_ptr());
211                RingBuffer::new(buf, w, r, RING_CAPACITY)
212            };
213            let ev = ParameterEvent {
214                param_index: param_id,
215                value: value as f32,
216                sample_offset: 0,
217                event_kind: maolan_plugin_protocol::PARAM_EVENT_VALUE,
218            };
219            if !ring.push(ev) {
220                tracing::warn!("param ring full, dropping parameter event");
221            }
222        }
223        Ok(())
224    }
225
226    pub fn begin_parameter_edit(&self, _param_id: u32) -> Result<(), String> {
227        Ok(())
228    }
229
230    pub fn end_parameter_edit(&self, _param_id: u32) -> Result<(), String> {
231        Ok(())
232    }
233
234    pub fn is_parameter_edit_active(&self, _param_id: u32) -> bool {
235        false
236    }
237
238    pub fn snapshot_state(&self) -> Result<crate::plugins::types::ClapPluginState, String> {
239        let (mapping, events) = match (&self.mapping, &self.events) {
240            (Some(m), Some(e)) => (m, e),
241            _ => return Err("CLAP processor not initialized".to_string()),
242        };
243        let ptr = mapping.as_ptr();
244        let header = unsafe { header_mut(ptr) };
245
246        header.request_type.store(1, Ordering::Release);
247        header.request_status.store(0, Ordering::Release);
248        if let Err(e) = events.signal_host() {
249            header.request_type.store(0, Ordering::Release);
250            return Err(format!("Failed to signal host for state save: {e}"));
251        }
252
253        if let Err(e) = events.wait_host(Duration::from_secs(5)) {
254            header.request_type.store(0, Ordering::Release);
255            return Err(format!("Host did not respond to state save: {e}"));
256        }
257
258        let status = header.request_status.load(Ordering::Acquire);
259        let size = header.scratch_size.load(Ordering::Acquire) as usize;
260        if status != 1 {
261            header.request_type.store(0, Ordering::Release);
262            return Err("State save failed in host".to_string());
263        }
264        if size > SCRATCH_SIZE {
265            header.request_type.store(0, Ordering::Release);
266            return Err(format!("Host returned invalid CLAP state size: {size}"));
267        }
268
269        let scratch = unsafe { scratch_ptr(ptr) };
270        let mut bytes = vec![0u8; size];
271        unsafe {
272            std::ptr::copy_nonoverlapping(scratch, bytes.as_mut_ptr(), size);
273        }
274        header.request_type.store(0, Ordering::Release);
275        Ok(crate::plugins::types::ClapPluginState { bytes })
276    }
277
278    pub fn restore_state(
279        &self,
280        state: &crate::plugins::types::ClapPluginState,
281    ) -> Result<(), String> {
282        let (mapping, events) = match (&self.mapping, &self.events) {
283            (Some(m), Some(e)) => (m, e),
284            _ => return Err("CLAP processor not initialized".to_string()),
285        };
286        if state.bytes.len() > SCRATCH_SIZE {
287            return Err(format!(
288                "CLAP state is too large for scratch buffer: {} bytes",
289                state.bytes.len()
290            ));
291        }
292
293        let ptr = mapping.as_ptr();
294        let header = unsafe { header_mut(ptr) };
295        let scratch = unsafe { scratch_ptr(ptr) };
296        unsafe {
297            std::ptr::copy_nonoverlapping(state.bytes.as_ptr(), scratch, state.bytes.len());
298        }
299        header
300            .scratch_size
301            .store(state.bytes.len() as u32, Ordering::Release);
302
303        header.request_type.store(2, Ordering::Release);
304        header.request_status.store(0, Ordering::Release);
305        if let Err(e) = events.signal_host() {
306            header.request_type.store(0, Ordering::Release);
307            return Err(format!("Failed to signal host for state restore: {e}"));
308        }
309
310        if let Err(e) = events.wait_host(Duration::from_secs(5)) {
311            header.request_type.store(0, Ordering::Release);
312            return Err(format!("Host did not respond to state restore: {e}"));
313        }
314
315        let status = header.request_status.load(Ordering::Acquire);
316        header.request_type.store(0, Ordering::Release);
317        if status != 1 {
318            return Err("State restore failed in host".to_string());
319        }
320        Ok(())
321    }
322
323    pub fn process_with_audio_io(&self, frames: usize) {
324        let _ = self.process_with_midi(frames, &[], ClapTransportInfo::default());
325    }
326
327    pub fn process_with_midi(
328        &self,
329        frames: usize,
330        midi_in: &[MidiEvent],
331        transport: ClapTransportInfo,
332    ) -> Vec<ClapMidiOutputEvent> {
333        if self.bypassed.load(Ordering::Relaxed) {
334            ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
335            return Vec::new();
336        }
337
338        {
339            let child = self.child.lock();
340            if let Some(ref mut c) = child.as_mut() {
341                match c.try_wait() {
342                    Ok(Some(status)) if !status.success() => {
343                        tracing::error!("plugin host crashed for '{}' ({})", self.name, self.path);
344                        self.crash_count.fetch_add(1, Ordering::Relaxed);
345                        ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
346                        return Vec::new();
347                    }
348                    _ => {}
349                }
350            }
351        }
352
353        let started = Instant::now();
354
355        let (mapping, events) = match (&self.mapping, &self.events) {
356            (Some(m), Some(e)) => (m, e),
357            _ => {
358                ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
359                return Vec::new();
360            }
361        };
362
363        let ptr = mapping.as_ptr();
364        unsafe {
365            ipc::configure_shm_header(
366                ptr,
367                frames,
368                self.audio_inputs.len(),
369                self.audio_outputs.len(),
370            );
371            ipc::copy_inputs_to_shm(&self.audio_inputs, ptr, frames);
372
373            // Write transport state.
374            let t = transport_mut(ptr);
375            t.playhead_sample = transport.transport_sample as u64;
376            t.tempo = transport.bpm;
377            t.numerator = transport.tsig_num as u32;
378            t.denominator = transport.tsig_denom as u32;
379            t.flags = if transport.playing { 1 } else { 0 };
380
381            // Write MIDI input events to the shared-memory ring buffer.
382            let midi_buf = midi_ring_ptr(ptr);
383            let (midi_w, midi_r) = midi_indices(ptr);
384            let midi_ring = RingBuffer::new(midi_buf, midi_w, midi_r, RING_CAPACITY);
385            if !midi_in.is_empty() {
386                eprintln!(
387                    "[CLAP-PROC] {} forwarding {} MIDI events to host",
388                    self.name,
389                    midi_in.len()
390                );
391            }
392            for ev in midi_in {
393                let midi_event = maolan_plugin_protocol::protocol::MidiEvent {
394                    sample_offset: ev.frame,
395                    data: [
396                        ev.data.first().copied().unwrap_or(0),
397                        ev.data.get(1).copied().unwrap_or(0),
398                        ev.data.get(2).copied().unwrap_or(0),
399                    ],
400                    channel: ev.data.first().map(|b| b & 0x0F).unwrap_or(0),
401                    flags: 0,
402                    _pad: 0,
403                };
404                if !midi_ring.push(midi_event) {
405                    tracing::warn!(
406                        "MIDI input ring full for '{}' ({}), dropping event",
407                        self.name,
408                        self.path
409                    );
410                    break;
411                }
412            }
413        }
414
415        if let Err(e) = events.signal_host() {
416            tracing::error!("Failed to signal host: {e}");
417            ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
418            return Vec::new();
419        }
420
421        let timeout = Duration::from_millis(100);
422        if let Err(e) = events.wait_host(timeout) {
423            tracing::error!(
424                "host did not respond for '{}' ({}): {e}",
425                self.name,
426                self.path
427            );
428            ipc::bypass_copy_inputs_to_outputs(&self.audio_inputs, &self.audio_outputs);
429            return Vec::new();
430        }
431
432        unsafe {
433            ipc::copy_outputs_from_shm(&self.audio_outputs, ptr, frames);
434        }
435
436        // Read MIDI output events from the plugin host.
437        let mut midi_out = Vec::new();
438        unsafe {
439            let midi_out_buf = midi_out_ring_ptr(ptr);
440            let (midi_out_w, midi_out_r) = midi_out_indices(ptr);
441            let midi_out_ring =
442                RingBuffer::new(midi_out_buf, midi_out_w, midi_out_r, RING_CAPACITY);
443            while let Some(ev) = midi_out_ring.pop() {
444                midi_out.push(ClapMidiOutputEvent {
445                    port: 0,
446                    event: crate::midi::io::MidiEvent::new(ev.sample_offset, ev.data.to_vec()),
447                });
448            }
449        }
450
451        let elapsed = started.elapsed();
452        if elapsed > Duration::from_millis(20) {
453            tracing::warn!(
454                "Slow process '{}' ({}) took {:.3} ms for {} frames",
455                self.name,
456                self.path,
457                elapsed.as_secs_f64() * 1000.0,
458                frames
459            );
460        }
461
462        *self.last_process_time.lock() = Instant::now();
463        midi_out
464    }
465
466    pub fn path(&self) -> &str {
467        &self.path
468    }
469
470    pub fn plugin_id(&self) -> &str {
471        &self.plugin_id
472    }
473
474    pub fn name(&self) -> &str {
475        &self.name
476    }
477
478    pub fn begin_parameter_edit_at(&self, _param_id: u32, _frame: u32) -> Result<(), String> {
479        Ok(())
480    }
481
482    pub fn end_parameter_edit_at(&self, _param_id: u32, _frame: u32) -> Result<(), String> {
483        Ok(())
484    }
485
486    pub fn run_host_callbacks_main_thread(&self) {}
487
488    pub fn reconfigure_ports_if_needed(&self) -> Result<bool, String> {
489        Ok(false)
490    }
491
492    pub fn ui_begin_session(&self) {}
493    pub fn ui_end_session(&self) {}
494    pub fn ui_should_close(&self) -> bool {
495        false
496    }
497    pub fn ui_take_due_timers(&self) -> Vec<u32> {
498        Vec::new()
499    }
500    pub fn ui_take_param_updates(&self) -> Vec<ClapParamUpdate> {
501        Vec::new()
502    }
503    pub fn ui_take_state_update(&self) -> Option<crate::plugins::types::ClapPluginState> {
504        None
505    }
506
507    pub fn gui_info(&self) -> Result<crate::plugins::types::ClapGuiInfo, String> {
508        Err("GUI not yet supported for CLAP plugins".to_string())
509    }
510
511    pub fn gui_create(&self, _api: &str, _is_floating: bool) -> Result<(), String> {
512        Err("GUI not yet supported for CLAP plugins".to_string())
513    }
514
515    pub fn gui_get_size(&self) -> Result<(u32, u32), String> {
516        Err("GUI not yet supported for CLAP plugins".to_string())
517    }
518
519    pub fn gui_set_parent_x11(&self, window: usize) -> Result<(), String> {
520        if let Some(ref mapping) = self.mapping {
521            let header = unsafe { header_mut(mapping.as_ptr()) };
522            header.set_parent_window(window);
523            return Ok(());
524        }
525        Err("No active host to set parent window".to_string())
526    }
527
528    pub fn gui_show(&self) -> Result<(), String> {
529        if let Some(ref mapping) = self.mapping
530            && let Some(ref events) = self.events
531        {
532            let header = unsafe { header_mut(mapping.as_ptr()) };
533            header.request_type.store(3, Ordering::Release);
534            let _ = events.signal_host();
535            return Ok(());
536        }
537        Err("No active host to show GUI".to_string())
538    }
539
540    pub fn gui_hide(&self) {
541        if let Some(ref mapping) = self.mapping
542            && let Some(ref events) = self.events
543        {
544            let header = unsafe { header_mut(mapping.as_ptr()) };
545            header.request_type.store(4, Ordering::Release);
546            let _ = events.signal_host();
547        }
548    }
549
550    pub fn gui_destroy(&self) {}
551
552    pub fn gui_on_main_thread(&self) {}
553
554    pub fn gui_on_timer(&self, _timer_id: u32) {}
555
556    pub fn note_names(&self) -> std::collections::HashMap<u8, String> {
557        std::collections::HashMap::new()
558    }
559
560    pub fn drain_echoed_parameters(&self) -> Vec<ParameterEvent> {
561        let mut result = Vec::new();
562        if let Some(ref mapping) = self.mapping {
563            let ring = unsafe {
564                let buf = echo_ring_ptr(mapping.as_ptr());
565                let (w, r) = echo_indices(mapping.as_ptr());
566                RingBuffer::new(buf, w, r, RING_CAPACITY)
567            };
568            while let Some(ev) = ring.pop() {
569                result.push(ev);
570            }
571        }
572        result
573    }
574
575    pub fn drain_midi_outputs(&self) -> Vec<crate::midi::io::MidiEvent> {
576        let mut result = Vec::new();
577        if let Some(ref mapping) = self.mapping {
578            let ring = unsafe {
579                let buf = midi_out_ring_ptr(mapping.as_ptr());
580                let (w, r) = midi_out_indices(mapping.as_ptr());
581                RingBuffer::new(buf, w, r, RING_CAPACITY)
582            };
583            while let Some(ev) = ring.pop() {
584                result.push(crate::midi::io::MidiEvent {
585                    frame: ev.sample_offset,
586                    data: ev.data.to_vec(),
587                });
588            }
589        }
590        result
591    }
592}
593
594impl Drop for ClapProcessor {
595    fn drop(&mut self) {
596        ipc::drop_host(&self.mapping, &self.events, &self.child, &self.shm_name);
597    }
598}
599
600crate::impl_ipc_processor_wrapper!(ClapProcessor);
601
602impl UnsafeMutex<ClapProcessor> {
603    pub fn process_with_midi(
604        &self,
605        frames: usize,
606        midi_events: &[MidiEvent],
607        transport: ClapTransportInfo,
608    ) -> Vec<ClapMidiOutputEvent> {
609        self.lock()
610            .process_with_midi(frames, midi_events, transport)
611    }
612
613    pub fn is_bypassed(&self) -> bool {
614        self.lock().is_bypassed()
615    }
616
617    pub fn parameter_infos(&self) -> Vec<ClapParameterInfo> {
618        self.lock().parameter_infos()
619    }
620
621    pub fn set_parameter(&self, param_id: u32, value: f64) -> Result<(), String> {
622        self.lock().set_parameter(param_id, value)
623    }
624
625    pub fn set_parameter_at(&self, param_id: u32, value: f64, frame: u32) -> Result<(), String> {
626        self.lock().set_parameter_at(param_id, value, frame)
627    }
628
629    pub fn begin_parameter_edit_at(&self, param_id: u32, frame: u32) -> Result<(), String> {
630        self.lock().begin_parameter_edit_at(param_id, frame)
631    }
632
633    pub fn end_parameter_edit_at(&self, param_id: u32, frame: u32) -> Result<(), String> {
634        self.lock().end_parameter_edit_at(param_id, frame)
635    }
636
637    pub fn snapshot_state(&self) -> Result<crate::plugins::types::ClapPluginState, String> {
638        self.lock().snapshot_state()
639    }
640
641    pub fn restore_state(
642        &self,
643        state: &crate::plugins::types::ClapPluginState,
644    ) -> Result<(), String> {
645        self.lock().restore_state(state)
646    }
647
648    pub fn path(&self) -> String {
649        self.lock().path().to_string()
650    }
651
652    pub fn plugin_id(&self) -> String {
653        self.lock().plugin_id().to_string()
654    }
655
656    pub fn ui_begin_session(&self) {
657        self.lock().ui_begin_session();
658    }
659
660    pub fn ui_end_session(&self) {
661        self.lock().ui_end_session();
662    }
663
664    pub fn ui_should_close(&self) -> bool {
665        self.lock().ui_should_close()
666    }
667
668    pub fn ui_take_due_timers(&self) -> Vec<u32> {
669        self.lock().ui_take_due_timers()
670    }
671
672    pub fn ui_take_param_updates(&self) -> Vec<ClapParamUpdate> {
673        self.lock().ui_take_param_updates()
674    }
675
676    pub fn ui_take_state_update(&self) -> Option<crate::plugins::types::ClapPluginState> {
677        self.lock().ui_take_state_update()
678    }
679
680    pub fn gui_info(&self) -> Result<crate::plugins::types::ClapGuiInfo, String> {
681        self.lock().gui_info()
682    }
683
684    pub fn gui_create(&self, api: &str, is_floating: bool) -> Result<(), String> {
685        self.lock().gui_create(api, is_floating)
686    }
687
688    pub fn gui_get_size(&self) -> Result<(u32, u32), String> {
689        self.lock().gui_get_size()
690    }
691
692    pub fn gui_set_parent_x11(&self, window: usize) -> Result<(), String> {
693        self.lock().gui_set_parent_x11(window)
694    }
695
696    pub fn gui_show(&self) -> Result<(), String> {
697        self.lock().gui_show()
698    }
699
700    pub fn gui_hide(&self) {
701        self.lock().gui_hide();
702    }
703
704    pub fn gui_destroy(&self) {
705        self.lock().gui_destroy();
706    }
707
708    pub fn gui_on_main_thread(&self) {
709        self.lock().gui_on_main_thread();
710    }
711
712    pub fn gui_on_timer(&self, timer_id: u32) {
713        self.lock().gui_on_timer(timer_id);
714    }
715
716    pub fn note_names(&self) -> std::collections::HashMap<u8, String> {
717        self.lock().note_names()
718    }
719}
720
721/// Locate the `maolan-plugin-host` binary at runtime.
722///
723/// Search order:
724/// 1. Same directory as the current executable.
725/// 2. Workspace `target/debug` or `target/release` (development).
726/// 3. `PATH` environment variable.
727fn split_plugin_spec(spec: &str) -> (&str, &str) {
728    // CLAP scanner uses "path::id"; host protocol uses "path#id".
729    if let Some(pos) = spec.rfind("::") {
730        (&spec[..pos], &spec[pos + 2..])
731    } else if let Some(pos) = spec.rfind('#') {
732        (&spec[..pos], &spec[pos + 1..])
733    } else {
734        (spec, "")
735    }
736}
737
738#[cfg(test)]
739mod tests {
740    use super::*;
741
742    fn find_host_binary() -> PathBuf {
743        let manifest = std::env::var("CARGO_MANIFEST_DIR").unwrap();
744        let workspace_root = std::path::Path::new(&manifest)
745            .parent()
746            .unwrap()
747            .join("daw");
748        workspace_root
749            .join("target")
750            .join("debug")
751            .join("maolan-plugin-host")
752    }
753
754    #[test]
755    fn clap_processor_processes_audio() {
756        let host_bin = find_host_binary();
757        if !host_bin.exists() {
758            eprintln!(
759                "Skipping test: host binary not found at {}",
760                host_bin.display()
761            );
762            return;
763        }
764
765        let plugin_path = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
766            .parent()
767            .unwrap()
768            .join("daw")
769            .join("plugin-host")
770            .join("tests")
771            .join("test_passthrough.clap");
772
773        if !plugin_path.exists() {
774            eprintln!(
775                "Skipping test: plugin not found at {}",
776                plugin_path.display()
777            );
778            return;
779        }
780
781        let processor = ClapProcessor::new(
782            48000.0,
783            256,
784            &format!("{}#com.maolan.test.passthrough", plugin_path.display()),
785            2,
786            2,
787            host_bin,
788        )
789        .expect("should create processor");
790
791        processor.setup_audio_ports();
792
793        // Fill input buffers with a ramp.
794        for (i, input) in processor.audio_inputs().iter().enumerate() {
795            let buf = input.buffer.lock();
796            for (j, sample) in buf.iter_mut().enumerate() {
797                *sample = (i * 1000 + j) as f32;
798            }
799            *input.finished.lock() = true;
800        }
801
802        // Process one block.
803        processor.process_with_audio_io(256);
804
805        // Verify output buffers were written (non-zero).
806        for output in processor.audio_outputs().iter() {
807            let buf = output.buffer.lock();
808            assert!(
809                buf.iter().any(|&s| s != 0.0),
810                "output buffer should contain non-zero samples"
811            );
812        }
813
814        // Processor is dropped here, which should gracefully shut down the host.
815    }
816
817    #[test]
818    fn clap_processor_crash_bypass() {
819        let host_bin = find_host_binary();
820        if !host_bin.exists() {
821            eprintln!("Skipping crash test: host binary not found");
822            return;
823        }
824
825        // Use the crash test mode.
826        let processor = ClapProcessor::new(48000.0, 256, "__crash__", 1, 1, host_bin)
827            .expect("should create processor for crash test");
828
829        processor.setup_audio_ports();
830
831        // Fill input buffer.
832        {
833            let buf = processor.audio_inputs()[0].buffer.lock();
834            buf.fill(1.0);
835            *processor.audio_inputs()[0].finished.lock() = true;
836        }
837
838        // First process should trigger the crash; subsequent calls should bypass.
839        processor.process_with_audio_io(256);
840
841        // After crash, output should be a copy of input (bypass).
842        let out_buf = processor.audio_outputs()[0].buffer.lock();
843        assert!(
844            out_buf.iter().all(|&s| s == 1.0),
845            "after crash, output should be bypass copy of input"
846        );
847    }
848
849    #[test]
850    fn clap_track_integration() {
851        use crate::track::Track;
852
853        let host_bin = find_host_binary();
854        if !host_bin.exists() {
855            eprintln!("Skipping track integration test: host binary not found");
856            return;
857        }
858
859        let plugin_path = std::path::Path::new(&std::env::var("CARGO_MANIFEST_DIR").unwrap())
860            .parent()
861            .unwrap()
862            .join("daw")
863            .join("plugin-host")
864            .join("tests")
865            .join("test_passthrough.clap");
866
867        if !plugin_path.exists() {
868            eprintln!(
869                "Skipping track integration test: plugin not found at {}",
870                plugin_path.display()
871            );
872            return;
873        }
874
875        let mut track = Track::new("test-track".to_string(), 2, 2, 0, 0, 256, 48000.0);
876
877        track
878            .load_clap_plugin(
879                &format!("{}::com.maolan.test.passthrough", plugin_path.display()),
880                None,
881            )
882            .expect("should load CLAP plugin on track");
883
884        assert_eq!(track.clap_plugins.len(), 1);
885
886        // Process directly through the plugin processor to verify IPC works.
887        // Track-level routing requires explicit audio connections; this test
888        // verifies that a plugin loaded on a track can process audio correctly.
889        let processor = track.clap_plugins[0].processor.lock();
890        processor.setup_audio_ports();
891
892        for (i, input) in processor.audio_inputs().iter().enumerate() {
893            let buf = input.buffer.lock();
894            for (j, sample) in buf.iter_mut().enumerate() {
895                *sample = (i * 1000 + j) as f32;
896            }
897            *input.finished.lock() = true;
898        }
899
900        processor.process_with_audio_io(256);
901
902        for (ch, output) in processor.audio_outputs().iter().enumerate() {
903            let buf = output.buffer.lock();
904            assert!(
905                buf.iter().any(|&s| s != 0.0),
906                "plugin output ch={ch} should contain non-zero samples after CLAP processing"
907            );
908        }
909    }
910}