Skip to main content

truce_rack_au/
lib.rs

1//! Audio Unit v2 host for the truce-rack framework. Apple-only.
2//!
3//! Built on `objc2-audio-toolbox` — the modern Rust bindings
4//! that replace the C++ `AudioToolbox` scanner the legacy rack 0.4
5//! relied on. No cmake, no bridging-header step.
6//!
7//! # Status
8//!
9//! - **Scan** via `AudioComponentFindNext` across the AU v2 type
10//!   families (Effect, `MusicDevice`, Generator, Mixer, `MusicEffect`,
11//!   `MIDIProcessor`). Each match yields one [`PluginInfo`] with the
12//!   AU's four-CC packed as a `"type:sub:mfr"` `unique_id`.
13//! - **Load** via `AudioComponentInstanceNew`.
14//! - **Process** via `AudioUnitRender` into a per-block
15//!   `AudioBufferList` whose buffers point directly at the host's
16//!   output planes (see [`AuPlugin::process`]).
17//! - **MIDI** via `MusicDeviceMIDIEvent`. Routes channel-voice
18//!   messages with sample-accurate offsets; raw bytes pass through
19//!   the same call. Non-MIDI-accepting effects no-op silently.
20//! - **Editor** via the `AUv2` Cocoa view (`kAudioUnitProperty_CocoaUI`).
21//!
22//! AU v3 plugins that set `kAudioComponentFlag_RequiresAsyncInstantiation`
23//! still need the async path and are not yet loadable through this
24//! crate; truce-rack-au3 forwards them here regardless.
25
26#![cfg(target_vendor = "apple")]
27
28use truce_rack_core::buffer::AudioBuffer;
29use truce_rack_core::bus::BusLayout;
30use truce_rack_core::error::{Error, Result};
31use truce_rack_core::events::EventList;
32use truce_rack_core::info::{ParameterInfo, PluginCategory, PluginInfo, PresetInfo};
33use truce_rack_core::plugin::{Plugin, PluginCore, ProcessContext, ProcessStatus};
34use truce_rack_core::scanner::PluginScanner;
35use truce_rack_core::transport::TransportInfo;
36
37use objc2_audio_toolbox::{
38    AUPreset, AURenderCallbackStruct, AudioComponent, AudioComponentCopyName,
39    AudioComponentDescription, AudioComponentFindNext, AudioComponentFlags, AudioComponentInstance,
40    AudioComponentInstanceDispose, AudioComponentInstanceNew, AudioComponentInstantiate,
41    AudioComponentInstantiationOptions, AudioUnitGetParameter, AudioUnitGetProperty,
42    AudioUnitGetPropertyInfo, AudioUnitInitialize, AudioUnitParameterID, AudioUnitParameterInfo,
43    AudioUnitRender, AudioUnitRenderActionFlags, AudioUnitSetParameter, AudioUnitSetProperty,
44    AudioUnitUninitialize, HostCallbackInfo, MusicDeviceMIDIEvent,
45    kAudioUnitErr_CannotDoInCurrentContext, kAudioUnitProperty_ClassInfo,
46    kAudioUnitProperty_FactoryPresets, kAudioUnitProperty_HostCallbacks,
47    kAudioUnitProperty_MaximumFramesPerSlice, kAudioUnitProperty_ParameterInfo,
48    kAudioUnitProperty_ParameterList, kAudioUnitProperty_PresentPreset,
49    kAudioUnitProperty_SetRenderCallback, kAudioUnitProperty_StreamFormat, kAudioUnitScope_Global,
50    kAudioUnitScope_Input, kAudioUnitScope_Output, kAudioUnitType_Effect, kAudioUnitType_Generator,
51    kAudioUnitType_MIDIProcessor, kAudioUnitType_Mixer, kAudioUnitType_MusicDevice,
52    kAudioUnitType_MusicEffect,
53};
54use objc2_core_audio_types::{
55    AudioBuffer as CAAudioBuffer, AudioBufferList, AudioStreamBasicDescription, AudioTimeStamp,
56    AudioTimeStampFlags, kAudioFormatFlagIsFloat, kAudioFormatFlagIsNonInterleaved,
57    kAudioFormatFlagIsPacked, kAudioFormatLinearPCM,
58};
59use objc2_core_foundation::CFString;
60
61use std::path::Path;
62use std::ptr;
63
64/// Format identifier used on returned [`PluginInfo`].
65pub const FORMAT: &str = "au";
66
67/// AU type families we surface to hosts. Output / `FormatConverter`
68/// are intentionally skipped — they're system plumbing, not
69/// effects.
70const SCAN_TYPES: &[u32] = &[
71    kAudioUnitType_Effect,
72    kAudioUnitType_MusicDevice,
73    kAudioUnitType_Generator,
74    kAudioUnitType_MusicEffect,
75    kAudioUnitType_MIDIProcessor,
76    kAudioUnitType_Mixer,
77];
78
79/// AU v2 scanner.
80#[derive(Debug, Default)]
81pub struct AuScanner;
82
83impl AuScanner {
84    /// Construct a default scanner.
85    #[must_use]
86    pub fn new() -> Self {
87        Self
88    }
89}
90
91impl PluginScanner for AuScanner {
92    type Plugin = AuPlugin;
93
94    fn scan(&self) -> Result<Vec<PluginInfo>> {
95        let mut out = Vec::new();
96        for &type_code in SCAN_TYPES {
97            unsafe { scan_family(type_code, &mut out) };
98        }
99        Ok(out)
100    }
101
102    fn scan_path(&self, _path: &Path) -> Result<Vec<PluginInfo>> {
103        // AU discovery is registry-based, not path-based.
104        Err(Error::Other(
105            "truce-rack-au path-bounded scan is not meaningful (AU uses a registry)".into(),
106        ))
107    }
108
109    fn load(&self, info: &PluginInfo) -> Result<Self::Plugin> {
110        AuPlugin::load_from(info)
111    }
112}
113
114unsafe fn scan_family(component_type: u32, out: &mut Vec<PluginInfo>) {
115    let mut desc = AudioComponentDescription {
116        componentType: component_type,
117        componentSubType: 0,
118        componentManufacturer: 0,
119        componentFlags: 0,
120        componentFlagsMask: 0,
121    };
122    let mut component: AudioComponent = ptr::null_mut();
123    let category = type_category(component_type);
124    let accepts_midi =
125        component_type != kAudioUnitType_Effect && component_type != kAudioUnitType_Generator;
126    loop {
127        let next = unsafe {
128            AudioComponentFindNext(component, ptr::NonNull::new_unchecked(&raw mut desc))
129        };
130        if next.is_null() {
131            break;
132        }
133        component = next;
134        let mut comp_desc = AudioComponentDescription {
135            componentType: 0,
136            componentSubType: 0,
137            componentManufacturer: 0,
138            componentFlags: 0,
139            componentFlagsMask: 0,
140        };
141        let _ = unsafe {
142            objc2_audio_toolbox::AudioComponentGetDescription(
143                component,
144                ptr::NonNull::new_unchecked(&raw mut comp_desc),
145            )
146        };
147        // Skip AUv3 (app-extension) components — they require
148        // AudioComponentInstantiate (async / sandboxed) and are
149        // owned by truce-rack-au3. AUv3 components advertise themselves
150        // via the IsV3AudioUnit flag in componentFlags.
151        if AudioComponentFlags::from_bits_retain(comp_desc.componentFlags)
152            .contains(AudioComponentFlags::IsV3AudioUnit)
153        {
154            continue;
155        }
156        let name = unsafe { component_name(component) };
157        let (vendor, display) = split_name(&name);
158        out.push(PluginInfo {
159            name: display,
160            vendor,
161            version: unsafe { component_version(component) },
162            category,
163            path: std::path::PathBuf::new(),
164            unique_id: format_unique_id(&comp_desc),
165            format: FORMAT,
166            has_editor: false,
167            accepts_midi,
168        });
169    }
170}
171
172/// Query `kAudioUnitProperty_ParameterInfo` for one parameter.
173unsafe fn fetch_parameter_info(
174    unit: AudioComponentInstance,
175    id: AudioUnitParameterID,
176) -> Result<AudioUnitParameterInfo> {
177    let mut info = AudioUnitParameterInfo {
178        name: [0; 52],
179        unitName: ptr::null(),
180        clumpID: 0,
181        cfNameString: ptr::null(),
182        unit: objc2_audio_toolbox::AudioUnitParameterUnit::Generic,
183        minValue: 0.0,
184        maxValue: 0.0,
185        defaultValue: 0.0,
186        flags: objc2_audio_toolbox::AudioUnitParameterOptions::empty(),
187    };
188    #[allow(clippy::cast_possible_truncation)]
189    let mut size = std::mem::size_of::<AudioUnitParameterInfo>() as u32;
190    let status = unsafe {
191        AudioUnitGetProperty(
192            unit,
193            kAudioUnitProperty_ParameterInfo,
194            kAudioUnitScope_Global,
195            id,
196            ptr::NonNull::new_unchecked((&raw mut info).cast()),
197            ptr::NonNull::new_unchecked(&raw mut size),
198        )
199    };
200    if status != 0 {
201        return Err(Error::Other(format!(
202            "kAudioUnitProperty_ParameterInfo failed: OSStatus {status}"
203        )));
204    }
205    Ok(info)
206}
207
208#[allow(clippy::cast_sign_loss)]
209fn au_parameter_to_rack(id: AudioUnitParameterID, info: &AudioUnitParameterInfo) -> ParameterInfo {
210    // CFNameString takes precedence over the 52-byte ASCII name
211    // field — AU plugins set the latter to empty more often than
212    // not since the introduction of CFString-based names.
213    let name = if info.cfNameString.is_null() {
214        // SAFETY: `info.name` is a NUL-terminated 52-byte ASCII
215        // buffer per AU spec.
216        let bytes: Vec<u8> = info
217            .name
218            .iter()
219            .take_while(|&&c| c != 0)
220            .map(|&c| c as u8)
221            .collect();
222        String::from_utf8_lossy(&bytes).into_owned()
223    } else {
224        // SAFETY: `info.cfNameString` is a +0 reference per AU
225        // spec — we don't release it.
226        let s = unsafe { &*info.cfNameString };
227        s.to_string()
228    };
229    let unit_name = if info.unitName.is_null() {
230        String::new()
231    } else {
232        let s = unsafe { &*info.unitName };
233        s.to_string()
234    };
235    let flags = au_param_flags_to_rack(info);
236    ParameterInfo {
237        id,
238        name: name.clone(),
239        short_name: name,
240        unit: unit_name,
241        min: f64::from(info.minValue),
242        max: f64::from(info.maxValue),
243        default: f64::from(info.defaultValue),
244        step_count: 0,
245        flags,
246    }
247}
248
249fn au_param_flags_to_rack(info: &AudioUnitParameterInfo) -> truce_rack_core::info::ParameterFlags {
250    use objc2_audio_toolbox::AudioUnitParameterOptions as Opt;
251    let mut flags = truce_rack_core::info::ParameterFlags::empty();
252    // AU treats most parameters as automatable by default; only
253    // explicit MeterReadOnly takes that away.
254    if info.flags.contains(Opt::Flag_MeterReadOnly) {
255        flags |= truce_rack_core::info::ParameterFlags::READ_ONLY;
256    } else {
257        flags |= truce_rack_core::info::ParameterFlags::AUTOMATABLE;
258    }
259    if info.flags.contains(Opt::Flag_OmitFromPresets) {
260        flags |= truce_rack_core::info::ParameterFlags::HIDDEN;
261    }
262    flags
263}
264
265/// Query `kAudioUnitProperty_FactoryPresets` and return one
266/// [`PresetInfo`] per preset. The property hands us a `CFArrayRef`
267/// of `AUPreset` structs — we copy the fields out and release the
268/// array.
269unsafe fn fetch_factory_presets(unit: AudioComponentInstance) -> Option<Vec<PresetInfo>> {
270    use objc2_core_foundation::{CFArray, CFRetained};
271    let mut presets: *const CFArray = ptr::null();
272    let mut size = u32::try_from(std::mem::size_of::<*const CFArray>()).unwrap_or(0);
273    let status = unsafe {
274        AudioUnitGetProperty(
275            unit,
276            kAudioUnitProperty_FactoryPresets,
277            kAudioUnitScope_Global,
278            0,
279            ptr::NonNull::new_unchecked((&raw mut presets).cast()),
280            ptr::NonNull::new_unchecked(&raw mut size),
281        )
282    };
283    if status != 0 || presets.is_null() {
284        return None;
285    }
286    // CFArrayRef from FactoryPresets is owned per Apple docs —
287    // we wrap in CFRetained so it's released on drop.
288    let array = unsafe { CFRetained::from_raw(ptr::NonNull::new_unchecked(presets.cast_mut())) };
289    let count = array.count();
290    if count <= 0 {
291        return Some(Vec::new());
292    }
293    #[allow(clippy::cast_sign_loss)]
294    let count_usize = count as usize;
295    let mut out = Vec::with_capacity(count_usize);
296    for i in 0..count {
297        // CFArrayGetValueAtIndex returns *const c_void — points
298        // at an `AUPreset` struct living inside the CFArray.
299        let value = unsafe { array.value_at_index(i) };
300        if value.is_null() {
301            continue;
302        }
303        let preset = unsafe { &*value.cast::<AUPreset>() };
304        let name = if preset.presetName.is_null() {
305            format!("Preset {}", preset.presetNumber)
306        } else {
307            unsafe { &*preset.presetName }.to_string()
308        };
309        #[allow(clippy::cast_sign_loss)]
310        out.push(PresetInfo {
311            index: i as usize,
312            name,
313            preset_number: preset.presetNumber,
314        });
315    }
316    Some(out)
317}
318
319/// Route one truce-rack [`truce_rack_core::events::Event`] into the AU via
320/// `MusicDeviceMIDIEvent`. Non-MIDI events (param automation,
321/// transport flags) are skipped — AU parameter automation goes
322/// through `AudioUnitSetParameter` with a sample offset, and
323/// transport flips are surfaced via the render-callback context.
324fn send_midi(unit: AudioComponentInstance, event: &truce_rack_core::events::Event) {
325    use truce_rack_core::events::{EventBody, MidiData};
326    let offset = event.sample_offset;
327    let (status, d1, d2) = match event.body {
328        EventBody::Midi(MidiData::NoteOn {
329            channel,
330            note,
331            velocity,
332        }) => (
333            0x90 | u32::from(channel & 0x0F),
334            u32::from(note & 0x7F),
335            u32::from(velocity & 0x7F),
336        ),
337        EventBody::Midi(MidiData::NoteOff {
338            channel,
339            note,
340            velocity,
341        }) => (
342            0x80 | u32::from(channel & 0x0F),
343            u32::from(note & 0x7F),
344            u32::from(velocity & 0x7F),
345        ),
346        EventBody::Midi(MidiData::ControlChange {
347            channel,
348            controller,
349            value,
350        }) => (
351            0xB0 | u32::from(channel & 0x0F),
352            u32::from(controller & 0x7F),
353            u32::from(value & 0x7F),
354        ),
355        EventBody::Midi(MidiData::ProgramChange { channel, program }) => (
356            0xC0 | u32::from(channel & 0x0F),
357            u32::from(program & 0x7F),
358            0,
359        ),
360        EventBody::Midi(MidiData::ChannelAftertouch { channel, pressure }) => (
361            0xD0 | u32::from(channel & 0x0F),
362            u32::from(pressure & 0x7F),
363            0,
364        ),
365        EventBody::Midi(MidiData::PolyAftertouch {
366            channel,
367            note,
368            pressure,
369        }) => (
370            0xA0 | u32::from(channel & 0x0F),
371            u32::from(note & 0x7F),
372            u32::from(pressure & 0x7F),
373        ),
374        EventBody::Midi(MidiData::PitchBend { channel, value }) => (
375            0xE0 | u32::from(channel & 0x0F),
376            u32::from(value & 0x7F),
377            u32::from((value >> 7) & 0x7F),
378        ),
379        EventBody::Midi(MidiData::Raw { len, data }) if len >= 1 => {
380            let s = u32::from(data[0]);
381            let d1 = if len >= 2 { u32::from(data[1]) } else { 0 };
382            let d2 = if len >= 3 { u32::from(data[2]) } else { 0 };
383            (s, d1, d2)
384        }
385        _ => return,
386    };
387    // Non-MusicDevice / non-MIDIFX components return
388    // kAudioUnitErr_InvalidProperty here; ignore.
389    let _ = unsafe { MusicDeviceMIDIEvent(unit, status, d1, d2, offset) };
390}
391
392/// Synchronous `AudioComponentInstanceNew` wrapper. Used for AU v2
393/// and for AU v3 plugins that don't set `RequiresAsyncInstantiation`.
394fn instantiate_sync(
395    component: AudioComponent,
396    info_path: &std::path::Path,
397) -> Result<AudioComponentInstance> {
398    let mut instance: AudioComponentInstance = ptr::null_mut();
399    let status = unsafe {
400        AudioComponentInstanceNew(component, ptr::NonNull::new_unchecked(&raw mut instance))
401    };
402    if status != 0 || instance.is_null() {
403        return Err(Error::LoadFailed {
404            path: info_path.to_path_buf(),
405            reason: format!("AudioComponentInstanceNew failed with OSStatus {status}"),
406        });
407    }
408    Ok(instance)
409}
410
411/// Asynchronous instantiation via `AudioComponentInstantiate`,
412/// required for AU v3 plugins flagged `RequiresAsyncInstantiation`
413/// (sandboxed App-Extension-hosted units). The completion block
414/// can be dispatched from any thread; we pump the main thread's
415/// `CFRunLoop` until the result lands or a 10-second deadline
416/// elapses.
417fn instantiate_async(
418    component: AudioComponent,
419    info_path: &std::path::Path,
420) -> Result<AudioComponentInstance> {
421    use std::sync::Arc;
422    use std::sync::atomic::{AtomicBool, AtomicI32, AtomicPtr, Ordering};
423    use std::time::{Duration, Instant};
424
425    let done = Arc::new(AtomicBool::new(false));
426    let inst_ptr: Arc<AtomicPtr<std::ffi::c_void>> = Arc::new(AtomicPtr::new(ptr::null_mut()));
427    let status_atom = Arc::new(AtomicI32::new(0));
428
429    let done_clone = Arc::clone(&done);
430    let inst_clone = Arc::clone(&inst_ptr);
431    let status_clone = Arc::clone(&status_atom);
432
433    // RcBlock heap-allocates the block and refcounts via Apple's
434    // blocks runtime; AudioComponentInstantiate retains it for the
435    // duration of the call, releases after firing. As long as our
436    // Arc holders outlive the block (which we ensure by holding
437    // them until `done == true`), the closure's captures stay live.
438    let block = block2::RcBlock::new(move |inst: AudioComponentInstance, status: i32| {
439        inst_clone.store(inst.cast(), Ordering::Release);
440        status_clone.store(status, Ordering::Release);
441        done_clone.store(true, Ordering::Release);
442    });
443    unsafe {
444        AudioComponentInstantiate(
445            component,
446            AudioComponentInstantiationOptions::empty(),
447            &block,
448        );
449    }
450
451    // Pump the main runloop in 50ms slices until the completion
452    // block writes done=true. The runloop dispatches the block to
453    // the main queue; without pumping, it'd never fire.
454    let deadline = Instant::now() + Duration::from_secs(10);
455    while !done.load(Ordering::Acquire) {
456        if Instant::now() > deadline {
457            return Err(Error::LoadFailed {
458                path: info_path.to_path_buf(),
459                reason: "AudioComponentInstantiate timed out after 10s".into(),
460            });
461        }
462        let mode = unsafe { objc2_core_foundation::kCFRunLoopDefaultMode };
463        objc2_core_foundation::CFRunLoop::run_in_mode(mode, 0.05, true);
464    }
465
466    let status = status_atom.load(Ordering::Acquire);
467    let raw = inst_ptr.load(Ordering::Acquire);
468    if status != 0 || raw.is_null() {
469        return Err(Error::LoadFailed {
470            path: info_path.to_path_buf(),
471            reason: format!("AudioComponentInstantiate failed with OSStatus {status}"),
472        });
473    }
474    Ok(raw.cast::<objc2_audio_toolbox::OpaqueAudioComponentInstance>())
475}
476
477fn type_category(type_code: u32) -> PluginCategory {
478    match type_code {
479        t if t == kAudioUnitType_MusicDevice => PluginCategory::Instrument,
480        t if t == kAudioUnitType_MIDIProcessor => PluginCategory::NoteEffect,
481        t if t == kAudioUnitType_Mixer => PluginCategory::Tool,
482        _ => PluginCategory::Effect,
483    }
484}
485
486/// AU `AudioComponentCopyName` returns `"<vendor>: <name>"`. Split
487/// it so the host's browser shows them in separate columns.
488fn split_name(full: &str) -> (String, String) {
489    full.split_once(": ").map_or_else(
490        || (String::new(), full.to_string()),
491        |(v, n)| (v.to_string(), n.to_string()),
492    )
493}
494
495unsafe fn component_name(component: AudioComponent) -> String {
496    let mut cf_str: *const CFString = ptr::null();
497    let status =
498        unsafe { AudioComponentCopyName(component, ptr::NonNull::new_unchecked(&raw mut cf_str)) };
499    if status != 0 || cf_str.is_null() {
500        return String::new();
501    }
502    // SAFETY: AudioComponentCopyName follows the "Copy" rule —
503    // we own the +1 retain on cf_str. Wrap it in CFRetained so it
504    // gets released on drop.
505    let retained = unsafe {
506        objc2_core_foundation::CFRetained::from_raw(ptr::NonNull::new_unchecked(cf_str.cast_mut()))
507    };
508    retained.to_string()
509}
510
511unsafe fn component_version(component: AudioComponent) -> u32 {
512    let mut version: u32 = 0;
513    let _ = unsafe {
514        objc2_audio_toolbox::AudioComponentGetVersion(
515            component,
516            ptr::NonNull::new_unchecked(&raw mut version),
517        )
518    };
519    version
520}
521
522fn format_unique_id(desc: &AudioComponentDescription) -> String {
523    format!(
524        "{}:{}:{}",
525        four_cc(desc.componentType),
526        four_cc(desc.componentSubType),
527        four_cc(desc.componentManufacturer),
528    )
529}
530
531/// Render a four-CC as its printable ASCII representation if all
532/// bytes are printable, otherwise its hex. AU manufacturer codes
533/// are conventionally ASCII (`"appl"`, `"vlse"`, …).
534fn four_cc(code: u32) -> String {
535    let bytes = code.to_be_bytes();
536    if bytes.iter().all(|b| b.is_ascii_graphic() && *b != b':') {
537        String::from_utf8_lossy(&bytes).into_owned()
538    } else {
539        format!("{code:08x}")
540    }
541}
542
543/// Parse a `"type:sub:mfr"` unique id back into a description for
544/// `AudioComponentFindNext`.
545fn parse_unique_id(id: &str) -> Option<AudioComponentDescription> {
546    let mut parts = id.split(':');
547    let t = parse_four_cc(parts.next()?)?;
548    let s = parse_four_cc(parts.next()?)?;
549    let m = parse_four_cc(parts.next()?)?;
550    Some(AudioComponentDescription {
551        componentType: t,
552        componentSubType: s,
553        componentManufacturer: m,
554        componentFlags: 0,
555        componentFlagsMask: 0,
556    })
557}
558
559fn parse_four_cc(s: &str) -> Option<u32> {
560    if s.len() == 4 && s.bytes().all(|b| b.is_ascii_graphic()) {
561        let b = s.as_bytes();
562        Some(u32::from_be_bytes([b[0], b[1], b[2], b[3]]))
563    } else if s.len() == 8 {
564        u32::from_str_radix(s, 16).ok()
565    } else {
566        None
567    }
568}
569
570/// One loaded AU v2 instance. Holds the `AudioComponentInstance`
571/// returned by `AudioComponentInstanceNew`. Disposes on `Drop`.
572pub struct AuPlugin {
573    info: PluginInfo,
574    layouts: Vec<BusLayout>,
575    active_layout: Option<BusLayout>,
576    instance: AudioComponentInstance,
577    /// Parameter id list, cached at load. AU exposes parameters
578    /// by `AudioUnitParameterID` (u32); truce-rack-core's trait surface
579    /// uses zero-based indices. This vec is the index → id map.
580    param_ids: Vec<AudioUnitParameterID>,
581    /// Stable-address render plumbing — the AU calls our render
582    /// callback during `AudioUnitRender` with this Box's pointer as
583    /// `inRefCon`. Kept boxed so its address survives moves of
584    /// `AuPlugin` itself.
585    render_ctx: Option<Box<RenderContext>>,
586    /// Storage for the variable-length output `AudioBufferList`
587    /// we pass to `AudioUnitRender`. Re-sized on activate based on
588    /// channel count.
589    output_buffer_storage: Vec<u8>,
590    /// Running output sample counter for `AudioTimeStamp.mSampleTime`.
591    sample_time: f64,
592    /// Heap-stable host-transport snapshot whose address is handed
593    /// to the AU as `HostCallbackInfo::hostUserData`. `process()`
594    /// refreshes it each block; the AU reads it back through the
595    /// registered callbacks during `AudioUnitRender`.
596    host_transport: Option<Box<HostTransport>>,
597    /// Bus channel counts as set during activate (effects need
598    /// matching input/output; instruments may have 0 input).
599    input_channels: u32,
600    output_channels: u32,
601    /// Live editor state, populated by `PluginEditor::open`. The
602    /// view-factory object (`AUCocoaUIBase`) and the resulting
603    /// `NSView` are kept alive here; dropping the plugin tears them
604    /// down via objc2's automatic retain/release.
605    editor: AuEditorState,
606}
607
608/// Per-plugin Cocoa-view editor state. Lives on the main thread
609/// only — never touched from the audio thread.
610#[derive(Default)]
611struct AuEditorState {
612    factory: Option<objc2::rc::Retained<objc2::runtime::AnyObject>>,
613    view: Option<objc2::rc::Retained<objc2_app_kit::NSView>>,
614}
615
616/// Audio-thread state shared with the AU render callback. The
617/// callback fills the AU's input buffer from `input_planes` —
618/// `process()` refreshes these pointers each block.
619struct RenderContext {
620    /// One pointer per channel into the host's input data for the
621    /// current block. NULL when the plugin doesn't take input
622    /// (instrument case).
623    input_planes: Vec<*const f32>,
624    /// Frames per block — must match `inNumberFrames` the AU
625    /// passes to the callback. We assert and zero-fill on mismatch.
626    frames: usize,
627}
628
629unsafe extern "C-unwind" fn render_input_callback(
630    in_ref_con: std::ptr::NonNull<std::ffi::c_void>,
631    _io_action_flags: std::ptr::NonNull<AudioUnitRenderActionFlags>,
632    _in_time_stamp: std::ptr::NonNull<AudioTimeStamp>,
633    _in_bus_number: u32,
634    in_number_frames: u32,
635    io_data: *mut AudioBufferList,
636) -> i32 {
637    if io_data.is_null() {
638        return -1;
639    }
640    let ctx_ptr = in_ref_con.as_ptr().cast::<RenderContext>();
641    let ctx = unsafe { &*ctx_ptr };
642    let list = unsafe { &mut *io_data };
643    let frames = in_number_frames as usize;
644    let buffer_count = list.mNumberBuffers as usize;
645    // The variable-length tail is reached via mBuffers as a [_; 1]
646    // declared slice we extend via pointer arithmetic.
647    let buffers_ptr: *mut CAAudioBuffer = list.mBuffers.as_mut_ptr();
648    let bytes_u32 = u32::try_from(frames * std::mem::size_of::<f32>()).unwrap_or(0);
649    let bytes_usize = bytes_u32 as usize;
650    for i in 0..buffer_count {
651        let buffer = unsafe { &mut *buffers_ptr.add(i) };
652        buffer.mDataByteSize = bytes_u32;
653        if let Some(&plane) = ctx.input_planes.get(i)
654            && !plane.is_null()
655            && frames <= ctx.frames
656        {
657            buffer.mData = plane.cast::<std::ffi::c_void>().cast_mut();
658            continue;
659        }
660        // No input available for this channel — leave mData where
661        // the caller put it and zero whatever bytes were there.
662        if !buffer.mData.is_null() {
663            unsafe {
664                std::ptr::write_bytes(buffer.mData.cast::<u8>(), 0, bytes_usize);
665            }
666        }
667    }
668    0
669}
670
671/// Host transport snapshot the AU reads via its `HostCallbackInfo`
672/// procs. `process()` refreshes this before each `AudioUnitRender`;
673/// the AU may then call the procs synchronously on the same audio
674/// thread, so no locking is needed. Musical positions are in
675/// quarter notes (the AU "beat" unit).
676#[derive(Default)]
677#[allow(clippy::struct_excessive_bools)]
678struct HostTransport {
679    /// False until `process()` sets a transport for the block; the
680    /// procs then report `kAudioUnitErr_CannotDoInCurrentContext`.
681    valid: bool,
682    tempo: f64,
683    /// Current position in quarter notes.
684    beat: f64,
685    /// Downbeat of the current bar, in quarter notes.
686    bar_start_beat: f64,
687    time_sig_num: f32,
688    time_sig_den: u32,
689    sample_in_timeline: f64,
690    sample_rate: f64,
691    playing: bool,
692    recording: bool,
693    cycling: bool,
694}
695
696/// Copy a host [`TransportInfo`] block into the shared
697/// [`HostTransport`]. Falls back to the running sample counter when
698/// the host didn't report a sample position, and clears `valid`
699/// when there's no transport at all.
700#[allow(clippy::cast_precision_loss)]
701fn update_host_transport(
702    ht: &mut HostTransport,
703    transport: Option<TransportInfo>,
704    sample_rate: f64,
705    sample_time: f64,
706) {
707    let Some(t) = transport else {
708        ht.valid = false;
709        return;
710    };
711    let (num, den) = t.time_signature.unwrap_or((4, 4));
712    ht.valid = true;
713    ht.tempo = t.tempo_bpm.unwrap_or(120.0);
714    ht.beat = t.song_position_beats.unwrap_or(0.0);
715    ht.bar_start_beat = t.bar_start_beats.unwrap_or(0.0);
716    ht.time_sig_num = num as f32;
717    ht.time_sig_den = den;
718    ht.sample_in_timeline = t.song_position_samples.map_or(sample_time, |s| s as f64);
719    ht.sample_rate = sample_rate;
720    ht.playing = t.playing;
721    ht.recording = t.recording;
722    ht.cycling = t.loop_active;
723}
724
725/// `HostCallback_GetBeatAndTempo` — current beat and tempo.
726unsafe extern "C-unwind" fn host_beat_and_tempo(
727    in_host_user_data: *mut std::ffi::c_void,
728    out_beat: *mut f64,
729    out_tempo: *mut f64,
730) -> i32 {
731    let t = unsafe { &*in_host_user_data.cast::<HostTransport>() };
732    if !t.valid {
733        return kAudioUnitErr_CannotDoInCurrentContext;
734    }
735    if !out_beat.is_null() {
736        unsafe { *out_beat = t.beat };
737    }
738    if !out_tempo.is_null() {
739        unsafe { *out_tempo = t.tempo };
740    }
741    0
742}
743
744/// `HostCallback_GetMusicalTimeLocation` — time signature, the
745/// current bar's downbeat, and samples until the next whole beat.
746#[allow(
747    clippy::cast_possible_truncation,
748    clippy::cast_sign_loss,
749    clippy::cast_precision_loss
750)]
751unsafe extern "C-unwind" fn host_musical_time(
752    in_host_user_data: *mut std::ffi::c_void,
753    out_delta_to_next_beat: *mut u32,
754    out_time_sig_num: *mut f32,
755    out_time_sig_den: *mut u32,
756    out_measure_downbeat: *mut f64,
757) -> i32 {
758    let t = unsafe { &*in_host_user_data.cast::<HostTransport>() };
759    if !t.valid {
760        return kAudioUnitErr_CannotDoInCurrentContext;
761    }
762    if !out_delta_to_next_beat.is_null() {
763        let frac = t.beat - t.beat.floor();
764        let samples_per_beat = if t.tempo > 0.0 {
765            t.sample_rate * 60.0 / t.tempo
766        } else {
767            0.0
768        };
769        let delta = ((1.0 - frac) * samples_per_beat).round();
770        let delta = if delta.is_finite() && delta >= 0.0 {
771            delta as u32
772        } else {
773            0
774        };
775        unsafe { *out_delta_to_next_beat = delta };
776    }
777    if !out_time_sig_num.is_null() {
778        unsafe { *out_time_sig_num = t.time_sig_num };
779    }
780    if !out_time_sig_den.is_null() {
781        unsafe { *out_time_sig_den = t.time_sig_den };
782    }
783    if !out_measure_downbeat.is_null() {
784        unsafe { *out_measure_downbeat = t.bar_start_beat };
785    }
786    0
787}
788
789/// `HostCallback_GetTransportState` — play / cycle state and the
790/// current sample position on the host timeline.
791unsafe extern "C-unwind" fn host_transport_state(
792    in_host_user_data: *mut std::ffi::c_void,
793    out_is_playing: *mut u8,
794    out_transport_changed: *mut u8,
795    out_sample_in_timeline: *mut f64,
796    out_is_cycling: *mut u8,
797    out_cycle_start: *mut f64,
798    out_cycle_end: *mut f64,
799) -> i32 {
800    let t = unsafe { &*in_host_user_data.cast::<HostTransport>() };
801    if !t.valid {
802        return kAudioUnitErr_CannotDoInCurrentContext;
803    }
804    if !out_is_playing.is_null() {
805        unsafe { *out_is_playing = u8::from(t.playing) };
806    }
807    if !out_transport_changed.is_null() {
808        unsafe { *out_transport_changed = 0 };
809    }
810    if !out_sample_in_timeline.is_null() {
811        unsafe { *out_sample_in_timeline = t.sample_in_timeline };
812    }
813    if !out_is_cycling.is_null() {
814        unsafe { *out_is_cycling = u8::from(t.cycling) };
815    }
816    if !out_cycle_start.is_null() {
817        unsafe { *out_cycle_start = 0.0 };
818    }
819    if !out_cycle_end.is_null() {
820        unsafe { *out_cycle_end = 0.0 };
821    }
822    0
823}
824
825/// `HostCallback_GetTransportState2` — same as
826/// [`host_transport_state`] plus the record-enable flag.
827unsafe extern "C-unwind" fn host_transport_state2(
828    in_host_user_data: *mut std::ffi::c_void,
829    out_is_playing: *mut u8,
830    out_is_recording: *mut u8,
831    out_transport_changed: *mut u8,
832    out_sample_in_timeline: *mut f64,
833    out_is_cycling: *mut u8,
834    out_cycle_start: *mut f64,
835    out_cycle_end: *mut f64,
836) -> i32 {
837    let t = unsafe { &*in_host_user_data.cast::<HostTransport>() };
838    if !t.valid {
839        return kAudioUnitErr_CannotDoInCurrentContext;
840    }
841    if !out_is_playing.is_null() {
842        unsafe { *out_is_playing = u8::from(t.playing) };
843    }
844    if !out_is_recording.is_null() {
845        unsafe { *out_is_recording = u8::from(t.recording) };
846    }
847    if !out_transport_changed.is_null() {
848        unsafe { *out_transport_changed = 0 };
849    }
850    if !out_sample_in_timeline.is_null() {
851        unsafe { *out_sample_in_timeline = t.sample_in_timeline };
852    }
853    if !out_is_cycling.is_null() {
854        unsafe { *out_is_cycling = u8::from(t.cycling) };
855    }
856    if !out_cycle_start.is_null() {
857        unsafe { *out_cycle_start = 0.0 };
858    }
859    if !out_cycle_end.is_null() {
860        unsafe { *out_cycle_end = 0.0 };
861    }
862    0
863}
864
865// SAFETY: AU v2 instances are not movable across audio rendering
866// threads while active, but we serialize all access through
867// `&mut self` and never share the pointer.
868unsafe impl Send for AuPlugin {}
869
870impl AuPlugin {
871    fn load_from(info: &PluginInfo) -> Result<Self> {
872        let mut desc = parse_unique_id(&info.unique_id).ok_or_else(|| Error::LoadFailed {
873            path: info.path.clone(),
874            reason: format!("could not parse AU unique_id {:?}", info.unique_id),
875        })?;
876        let component = unsafe {
877            AudioComponentFindNext(ptr::null_mut(), ptr::NonNull::new_unchecked(&raw mut desc))
878        };
879        if component.is_null() {
880            return Err(Error::LoadFailed {
881                path: info.path.clone(),
882                reason: format!("no AU matching {}", info.unique_id),
883            });
884        }
885
886        // Re-query the component's actual flags so we can detect
887        // RequiresAsyncInstantiation — `desc` only carries our scan
888        // mask, not the component's own.
889        let mut comp_desc = AudioComponentDescription {
890            componentType: 0,
891            componentSubType: 0,
892            componentManufacturer: 0,
893            componentFlags: 0,
894            componentFlagsMask: 0,
895        };
896        let _ = unsafe {
897            objc2_audio_toolbox::AudioComponentGetDescription(
898                component,
899                ptr::NonNull::new_unchecked(&raw mut comp_desc),
900            )
901        };
902        let flags = AudioComponentFlags::from_bits_retain(comp_desc.componentFlags);
903        let needs_async = flags.contains(AudioComponentFlags::RequiresAsyncInstantiation);
904
905        let instance = if needs_async {
906            instantiate_async(component, &info.path)?
907        } else {
908            instantiate_sync(component, &info.path)?
909        };
910        let param_ids = unsafe { fetch_parameter_ids(instance) };
911        let has_editor = unsafe { has_cocoa_ui(instance) };
912        let mut updated = info.clone();
913        updated.has_editor = has_editor;
914        Ok(Self {
915            info: updated,
916            layouts: vec![BusLayout::stereo()],
917            active_layout: None,
918            instance,
919            param_ids,
920            render_ctx: None,
921            output_buffer_storage: Vec::new(),
922            sample_time: 0.0,
923            host_transport: None,
924            input_channels: 0,
925            output_channels: 0,
926            editor: AuEditorState::default(),
927        })
928    }
929}
930
931/// True if the AU advertises an `AUv2` Cocoa view (one or more
932/// classes implementing `AUCocoaUIBase`).
933unsafe fn has_cocoa_ui(unit: AudioComponentInstance) -> bool {
934    use objc2_audio_toolbox::kAudioUnitProperty_CocoaUI;
935    let mut size: u32 = 0;
936    let info_status = unsafe {
937        AudioUnitGetPropertyInfo(
938            unit,
939            kAudioUnitProperty_CocoaUI,
940            kAudioUnitScope_Global,
941            0,
942            &raw mut size,
943            ptr::null_mut(),
944        )
945    };
946    info_status == 0 && size as usize >= std::mem::size_of::<usize>() * 2
947}
948
949/// Allocate enough bytes to hold an `AudioBufferList` with
950/// `n_buffers` `AudioBuffer`s tail-included. Returns a `Vec<u8>`
951/// large enough; the caller casts the data pointer.
952fn alloc_audio_buffer_list(n_buffers: usize) -> Vec<u8> {
953    let n = n_buffers.max(1);
954    let size =
955        std::mem::size_of::<AudioBufferList>() + (n - 1) * std::mem::size_of::<CAAudioBuffer>();
956    vec![0u8; size]
957}
958
959/// Query `kAudioUnitProperty_ParameterList` and return the
960/// parameter ids in declaration order. Returns an empty vec on
961/// any failure — a plugin with zero parameters is valid.
962unsafe fn fetch_parameter_ids(unit: AudioComponentInstance) -> Vec<AudioUnitParameterID> {
963    let mut size: u32 = 0;
964    let info_status = unsafe {
965        AudioUnitGetPropertyInfo(
966            unit,
967            kAudioUnitProperty_ParameterList,
968            kAudioUnitScope_Global,
969            0,
970            &raw mut size,
971            ptr::null_mut(),
972        )
973    };
974    if info_status != 0 || size == 0 {
975        return Vec::new();
976    }
977    let count = (size as usize) / std::mem::size_of::<AudioUnitParameterID>();
978    let mut ids: Vec<AudioUnitParameterID> = vec![0; count];
979    let mut io_size = size;
980    let get_status = unsafe {
981        AudioUnitGetProperty(
982            unit,
983            kAudioUnitProperty_ParameterList,
984            kAudioUnitScope_Global,
985            0,
986            ptr::NonNull::new_unchecked(ids.as_mut_ptr().cast()),
987            ptr::NonNull::new_unchecked(&raw mut io_size),
988        )
989    };
990    if get_status != 0 {
991        return Vec::new();
992    }
993    ids.truncate(io_size as usize / std::mem::size_of::<AudioUnitParameterID>());
994    ids
995}
996
997impl Drop for AuPlugin {
998    fn drop(&mut self) {
999        if !self.instance.is_null() {
1000            unsafe { AudioComponentInstanceDispose(self.instance) };
1001        }
1002    }
1003}
1004
1005impl PluginCore for AuPlugin {
1006    fn info(&self) -> &PluginInfo {
1007        &self.info
1008    }
1009    fn active_layout(&self) -> Option<&BusLayout> {
1010        self.active_layout.as_ref()
1011    }
1012    fn supported_layouts(&self) -> &[BusLayout] {
1013        &self.layouts
1014    }
1015    fn parameter_count(&self) -> usize {
1016        self.param_ids.len()
1017    }
1018
1019    fn parameter_info(&self, index: usize) -> Result<ParameterInfo> {
1020        let id = *self
1021            .param_ids
1022            .get(index)
1023            .ok_or(Error::InvalidParameter(index))?;
1024        let info = unsafe { fetch_parameter_info(self.instance, id)? };
1025        Ok(au_parameter_to_rack(id, &info))
1026    }
1027
1028    fn parameter_value(&self, index: usize) -> Result<f64> {
1029        let id = *self
1030            .param_ids
1031            .get(index)
1032            .ok_or(Error::InvalidParameter(index))?;
1033        let mut value: f32 = 0.0;
1034        let status = unsafe {
1035            AudioUnitGetParameter(
1036                self.instance,
1037                id,
1038                kAudioUnitScope_Global,
1039                0,
1040                ptr::NonNull::new_unchecked(&raw mut value),
1041            )
1042        };
1043        if status != 0 {
1044            return Err(Error::Other(format!(
1045                "AudioUnitGetParameter failed: OSStatus {status}"
1046            )));
1047        }
1048        Ok(f64::from(value))
1049    }
1050
1051    fn parameter_value_string(&self, _index: usize, _value: f64) -> Result<String> {
1052        // AU exposes a parameter-value-to-string property via
1053        // kAudioUnitProperty_ParameterValueStrings (for indexed
1054        // params) and AUParameterStringFromValue (for everything
1055        // else). Neither maps cleanly to the truce-rack-core surface
1056        // yet; tracked as a follow-on. For now, fall back to the
1057        // unit-stripped numeric form so callers get *something*
1058        // they can display.
1059        Err(Error::Other(
1060            "au parameter_value_string not yet wired".into(),
1061        ))
1062    }
1063
1064    fn set_parameter(&mut self, index: usize, value: f64) -> Result<()> {
1065        let id = *self
1066            .param_ids
1067            .get(index)
1068            .ok_or(Error::InvalidParameter(index))?;
1069        #[allow(clippy::cast_possible_truncation)]
1070        let v = value as f32;
1071        let status =
1072            unsafe { AudioUnitSetParameter(self.instance, id, kAudioUnitScope_Global, 0, v, 0) };
1073        if status != 0 {
1074            return Err(Error::Other(format!(
1075                "AudioUnitSetParameter failed: OSStatus {status}"
1076            )));
1077        }
1078        Ok(())
1079    }
1080    fn preset_count(&self) -> usize {
1081        unsafe { fetch_factory_presets(self.instance) }.map_or(0, |v| v.len())
1082    }
1083
1084    fn preset_info(&self, index: usize) -> Result<PresetInfo> {
1085        let presets = unsafe { fetch_factory_presets(self.instance) }
1086            .ok_or_else(|| Error::Other("kAudioUnitProperty_FactoryPresets failed".into()))?;
1087        let preset = presets.get(index).ok_or(Error::InvalidParameter(index))?;
1088        Ok(preset.clone())
1089    }
1090
1091    fn load_preset(&mut self, preset_number: i32) -> Result<()> {
1092        let preset = AUPreset {
1093            presetNumber: preset_number,
1094            presetName: ptr::null(),
1095        };
1096        let status = unsafe {
1097            AudioUnitSetProperty(
1098                self.instance,
1099                kAudioUnitProperty_PresentPreset,
1100                kAudioUnitScope_Global,
1101                0,
1102                (&raw const preset).cast(),
1103                u32::try_from(std::mem::size_of::<AUPreset>()).unwrap_or(0),
1104            )
1105        };
1106        if status != 0 {
1107            return Err(Error::Other(format!(
1108                "AudioUnitSetProperty(PresentPreset) failed: OSStatus {status}"
1109            )));
1110        }
1111        Ok(())
1112    }
1113    fn save_state(&self) -> Result<Vec<u8>> {
1114        use objc2_core_foundation::{
1115            CFData, CFPropertyList, CFPropertyListCreateData, CFPropertyListFormat, CFRetained,
1116        };
1117        let mut class_info: *const CFPropertyList = ptr::null();
1118        let mut size = u32::try_from(std::mem::size_of::<*const CFPropertyList>()).unwrap_or(0);
1119        let status = unsafe {
1120            AudioUnitGetProperty(
1121                self.instance,
1122                kAudioUnitProperty_ClassInfo,
1123                kAudioUnitScope_Global,
1124                0,
1125                ptr::NonNull::new_unchecked((&raw mut class_info).cast()),
1126                ptr::NonNull::new_unchecked(&raw mut size),
1127            )
1128        };
1129        if status != 0 || class_info.is_null() {
1130            return Err(Error::Other(format!(
1131                "kAudioUnitProperty_ClassInfo failed: OSStatus {status}"
1132            )));
1133        }
1134        let plist = unsafe {
1135            CFRetained::<CFPropertyList>::from_raw(ptr::NonNull::new_unchecked(
1136                class_info.cast_mut(),
1137            ))
1138        };
1139        let mut error: *mut objc2_core_foundation::CFError = ptr::null_mut();
1140        let data: Option<CFRetained<CFData>> = unsafe {
1141            CFPropertyListCreateData(
1142                None,
1143                Some(&plist),
1144                CFPropertyListFormat::BinaryFormat_v1_0,
1145                0,
1146                &raw mut error,
1147            )
1148        };
1149        let data =
1150            data.ok_or_else(|| Error::Other("CFPropertyListCreateData returned null".into()))?;
1151        let len = data.length();
1152        if len < 0 {
1153            return Err(Error::Other("CFData length negative".into()));
1154        }
1155        #[allow(clippy::cast_sign_loss)]
1156        let len_usize = len as usize;
1157        let mut out = vec![0u8; len_usize];
1158        unsafe {
1159            data.bytes(
1160                objc2_core_foundation::CFRange {
1161                    location: 0,
1162                    length: len,
1163                },
1164                out.as_mut_ptr(),
1165            );
1166        }
1167        Ok(out)
1168    }
1169
1170    fn load_state(&mut self, bytes: &[u8]) -> Result<()> {
1171        use objc2_core_foundation::{
1172            CFData, CFPropertyList, CFPropertyListCreateWithData, CFPropertyListFormat, CFRetained,
1173        };
1174        if bytes.is_empty() {
1175            return Err(Error::Other("empty AU state".into()));
1176        }
1177        #[allow(clippy::cast_possible_wrap)]
1178        let cf_data: CFRetained<CFData> =
1179            unsafe { CFData::new(None, bytes.as_ptr(), bytes.len() as isize) }
1180                .ok_or_else(|| Error::Other("CFData::new returned null".into()))?;
1181        let mut error: *mut objc2_core_foundation::CFError = ptr::null_mut();
1182        let mut format = CFPropertyListFormat::BinaryFormat_v1_0;
1183        let plist: Option<CFRetained<CFPropertyList>> = unsafe {
1184            CFPropertyListCreateWithData(None, Some(&cf_data), 0, &raw mut format, &raw mut error)
1185        };
1186        let plist = plist
1187            .ok_or_else(|| Error::Other("CFPropertyListCreateWithData returned null".into()))?;
1188        let plist_ptr: *const CFPropertyList = CFRetained::as_ptr(&plist).as_ptr().cast_const();
1189        let status = unsafe {
1190            AudioUnitSetProperty(
1191                self.instance,
1192                kAudioUnitProperty_ClassInfo,
1193                kAudioUnitScope_Global,
1194                0,
1195                (&raw const plist_ptr).cast(),
1196                u32::try_from(std::mem::size_of::<*const CFPropertyList>()).unwrap_or(0),
1197            )
1198        };
1199        if status != 0 {
1200            return Err(Error::Other(format!(
1201                "AudioUnitSetProperty(ClassInfo) failed: OSStatus {status}"
1202            )));
1203        }
1204        Ok(())
1205    }
1206    fn activate(
1207        &mut self,
1208        layout: BusLayout,
1209        sample_rate: f64,
1210        max_block_size: usize,
1211    ) -> Result<()> {
1212        // Match legacy rack 0.4's sequence: set planar f32 stream
1213        // format on both scopes (effects need input, instruments
1214        // don't), set MaximumFramesPerSlice, then
1215        // AudioUnitInitialize. Sample-rate-dependent parameter
1216        // ranges (e.g. Apple AUBandpass's Center Frequency cap at
1217        // Nyquist) only become correct after this point — we
1218        // refresh the parameter id cache as the last step.
1219        let channels: u32 = 2;
1220        #[allow(clippy::cast_possible_truncation)]
1221        let format = AudioStreamBasicDescription {
1222            mSampleRate: sample_rate,
1223            mFormatID: kAudioFormatLinearPCM,
1224            mFormatFlags: kAudioFormatFlagIsFloat
1225                | kAudioFormatFlagIsPacked
1226                | kAudioFormatFlagIsNonInterleaved,
1227            mBytesPerPacket: 4,
1228            mFramesPerPacket: 1,
1229            mBytesPerFrame: 4,
1230            mChannelsPerFrame: channels,
1231            mBitsPerChannel: 32,
1232            mReserved: 0,
1233        };
1234        let _ = unsafe {
1235            AudioUnitSetProperty(
1236                self.instance,
1237                kAudioUnitProperty_StreamFormat,
1238                kAudioUnitScope_Input,
1239                0,
1240                (&raw const format).cast(),
1241                u32::try_from(std::mem::size_of::<AudioStreamBasicDescription>()).unwrap_or(0),
1242            )
1243        };
1244        let _ = unsafe {
1245            AudioUnitSetProperty(
1246                self.instance,
1247                kAudioUnitProperty_StreamFormat,
1248                kAudioUnitScope_Output,
1249                0,
1250                (&raw const format).cast(),
1251                u32::try_from(std::mem::size_of::<AudioStreamBasicDescription>()).unwrap_or(0),
1252            )
1253        };
1254        let max_frames = u32::try_from(max_block_size).unwrap_or(u32::MAX);
1255        let _ = unsafe {
1256            AudioUnitSetProperty(
1257                self.instance,
1258                kAudioUnitProperty_MaximumFramesPerSlice,
1259                kAudioUnitScope_Global,
1260                0,
1261                (&raw const max_frames).cast(),
1262                u32::try_from(std::mem::size_of::<u32>()).unwrap_or(0),
1263            )
1264        };
1265        // Set the render callback before AudioUnitInitialize so
1266        // the AU sees its input source from frame 0.
1267        let mut render_ctx = Box::new(RenderContext {
1268            input_planes: vec![ptr::null(); channels as usize],
1269            frames: max_block_size,
1270        });
1271        let render_ctx_ptr: *mut RenderContext = render_ctx.as_mut();
1272        let callback_struct = AURenderCallbackStruct {
1273            inputProc: Some(render_input_callback),
1274            inputProcRefCon: render_ctx_ptr.cast(),
1275        };
1276        // Best-effort: instruments don't accept SetRenderCallback
1277        // on the input scope. We ignore the OSStatus here for
1278        // exactly the legacy reasons.
1279        let _ = unsafe {
1280            AudioUnitSetProperty(
1281                self.instance,
1282                kAudioUnitProperty_SetRenderCallback,
1283                kAudioUnitScope_Input,
1284                0,
1285                (&raw const callback_struct).cast(),
1286                u32::try_from(std::mem::size_of::<AURenderCallbackStruct>()).unwrap_or(0),
1287            )
1288        };
1289
1290        // Host transport callbacks: the AU queries these during
1291        // AudioUnitRender for tempo / beat / transport state. The
1292        // boxed HostTransport's address is handed back as
1293        // inHostUserData. Best-effort — older AUs ignore the
1294        // property; we don't treat a failure as fatal.
1295        let mut host_transport = Box::new(HostTransport::default());
1296        let host_transport_ptr: *mut HostTransport = host_transport.as_mut();
1297        let host_callbacks = HostCallbackInfo {
1298            hostUserData: host_transport_ptr.cast(),
1299            beatAndTempoProc: Some(host_beat_and_tempo),
1300            musicalTimeLocationProc: Some(host_musical_time),
1301            transportStateProc: Some(host_transport_state),
1302            transportStateProc2: Some(host_transport_state2),
1303        };
1304        let _ = unsafe {
1305            AudioUnitSetProperty(
1306                self.instance,
1307                kAudioUnitProperty_HostCallbacks,
1308                kAudioUnitScope_Global,
1309                0,
1310                (&raw const host_callbacks).cast(),
1311                u32::try_from(std::mem::size_of::<HostCallbackInfo>()).unwrap_or(0),
1312            )
1313        };
1314
1315        let status = unsafe { AudioUnitInitialize(self.instance) };
1316        if status != 0 {
1317            return Err(Error::Other(format!(
1318                "AudioUnitInitialize failed: OSStatus {status}"
1319            )));
1320        }
1321
1322        // Refresh the parameter id cache now that initialize has
1323        // settled the AU's sample-rate-dependent state.
1324        self.param_ids = unsafe { fetch_parameter_ids(self.instance) };
1325        self.render_ctx = Some(render_ctx);
1326        self.host_transport = Some(host_transport);
1327        self.output_buffer_storage = alloc_audio_buffer_list(channels as usize);
1328        self.input_channels = channels;
1329        self.output_channels = channels;
1330        self.sample_time = 0.0;
1331        self.active_layout = Some(layout);
1332        Ok(())
1333    }
1334    fn deactivate(&mut self) {
1335        if self.is_active() {
1336            let _ = unsafe { AudioUnitUninitialize(self.instance) };
1337        }
1338        self.render_ctx = None;
1339        self.active_layout = None;
1340    }
1341    fn is_active(&self) -> bool {
1342        self.active_layout.is_some()
1343    }
1344
1345    fn editor(&mut self) -> Option<&mut dyn truce_rack_core::editor::PluginEditor> {
1346        if self.info.has_editor {
1347            Some(self)
1348        } else {
1349            None
1350        }
1351    }
1352}
1353
1354impl truce_rack_core::editor::PluginEditor for AuPlugin {
1355    fn open(&mut self, parent: truce_rack_core::editor::WindowHandle, _scale: f64) -> Result<()> {
1356        use truce_rack_core::editor::WindowHandle;
1357        let WindowHandle::NSView(parent_ptr) = parent else {
1358            return Err(Error::Other("AU editor requires an NSView parent".into()));
1359        };
1360        if parent_ptr.is_null() {
1361            return Err(Error::Other("AU editor: parent NSView is null".into()));
1362        }
1363        if self.editor.view.is_some() {
1364            return Ok(());
1365        }
1366        unsafe { open_cocoa_editor(self, parent_ptr) }
1367    }
1368
1369    fn close(&mut self) {
1370        if let Some(view) = self.editor.view.take() {
1371            unsafe { remove_from_superview(&view) };
1372        }
1373        self.editor.factory = None;
1374    }
1375
1376    fn is_open(&self) -> bool {
1377        self.editor.view.is_some()
1378    }
1379
1380    fn size(&self) -> Option<(u32, u32)> {
1381        let view = self.editor.view.as_ref()?;
1382        let frame = unsafe { view_frame(view) };
1383        #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
1384        Some((frame.2.max(0.0) as u32, frame.3.max(0.0) as u32))
1385    }
1386
1387    fn is_resizable(&self) -> bool {
1388        // AUv2 Cocoa views are typically fixed-size: the factory
1389        // returns an NSView with a baked layout. Resizing is
1390        // accepted by setFrame: but most hosts treat AUv2 views as
1391        // non-resizable. We mirror that.
1392        false
1393    }
1394
1395    fn set_size(&mut self, width: u32, height: u32) -> Option<(u32, u32)> {
1396        let view = self.editor.view.as_ref()?;
1397        unsafe { set_view_frame(view, f64::from(width), f64::from(height)) };
1398        Some((width, height))
1399    }
1400
1401    fn show(&mut self) {
1402        if let Some(view) = self.editor.view.as_ref() {
1403            unsafe { set_hidden(view, false) };
1404        }
1405    }
1406
1407    fn hide(&mut self) {
1408        if let Some(view) = self.editor.view.as_ref() {
1409            unsafe { set_hidden(view, true) };
1410        }
1411    }
1412}
1413
1414/// Signature of `[AUCocoaUIBase uiViewForAudioUnit:withSize:]`.
1415/// Used to dispatch directly through the method's raw IMP, side-
1416/// stepping objc2's encoding check (Apple's AUs use one type
1417/// encoding for the `AudioUnit` argument, third-party AUs use the
1418/// older Carbon-era one — same ABI, but objc2 panics on mismatch).
1419type UiViewFn = unsafe extern "C-unwind" fn(
1420    *mut objc2::runtime::AnyObject,
1421    objc2::runtime::Sel,
1422    *mut std::ffi::c_void,
1423    objc2_foundation::NSSize,
1424) -> *mut objc2_app_kit::NSView;
1425
1426/// Fetch the AU's Cocoa view factory descriptor, load the bundle,
1427/// instantiate the factory, ask it for an `NSView`, and add it as a
1428/// subview of `parent_ptr`. Stores the live factory + view on
1429/// `self.editor` so they survive `open` returning.
1430unsafe fn open_cocoa_editor(
1431    plugin: &mut AuPlugin,
1432    parent_ptr: *mut std::ffi::c_void,
1433) -> Result<()> {
1434    use objc2::msg_send;
1435    use objc2::runtime::AnyObject;
1436    use objc2_audio_toolbox::{AudioUnitCocoaViewInfo, kAudioUnitProperty_CocoaUI};
1437    use objc2_core_foundation::{CFRetained, CFString, CFURL};
1438
1439    let mut size: u32 = 0;
1440    let info_status = unsafe {
1441        AudioUnitGetPropertyInfo(
1442            plugin.instance,
1443            kAudioUnitProperty_CocoaUI,
1444            kAudioUnitScope_Global,
1445            0,
1446            &raw mut size,
1447            ptr::null_mut(),
1448        )
1449    };
1450    if info_status != 0 || (size as usize) < std::mem::size_of::<AudioUnitCocoaViewInfo>() {
1451        return Err(Error::Other(format!(
1452            "kAudioUnitProperty_CocoaUI not advertised (status {info_status}, size {size})"
1453        )));
1454    }
1455    // The CocoaUI struct is variable-length: 1 bundle URL +
1456    // N class names. We get the static-sized struct here and only
1457    // use the first class name (mCocoaAUViewClass[0]) — the
1458    // overwhelming majority of AUs publish exactly one view class.
1459    let mut storage = vec![0u8; size as usize];
1460    let mut io_size = size;
1461    let get_status = unsafe {
1462        AudioUnitGetProperty(
1463            plugin.instance,
1464            kAudioUnitProperty_CocoaUI,
1465            kAudioUnitScope_Global,
1466            0,
1467            ptr::NonNull::new_unchecked(storage.as_mut_ptr().cast()),
1468            ptr::NonNull::new_unchecked(&raw mut io_size),
1469        )
1470    };
1471    if get_status != 0 {
1472        return Err(Error::Other(format!(
1473            "kAudioUnitProperty_CocoaUI get failed: OSStatus {get_status}"
1474        )));
1475    }
1476    // SAFETY: The first sizeof(AudioUnitCocoaViewInfo) bytes form
1477    // the descriptor; we've verified size at the property-info
1478    // step. The pointers inside are +1 references owned by us —
1479    // we wrap them in CFRetained to release on drop.
1480    #[allow(clippy::cast_ptr_alignment)]
1481    let view_info = unsafe { &*storage.as_ptr().cast::<AudioUnitCocoaViewInfo>() };
1482    let bundle_url = unsafe { CFRetained::<CFURL>::from_raw(view_info.mCocoaAUViewBundleLocation) };
1483    let class_name = unsafe { CFRetained::<CFString>::from_raw(view_info.mCocoaAUViewClass[0]) };
1484
1485    // CFURL ↔ NSURL and CFString ↔ NSString are toll-free
1486    // bridged, but objc2's msg_send! checks the type encoding —
1487    // raw CFType pointers register as struct pointers, not '@'.
1488    // Cast through the matching NSObject type so the encoding
1489    // matches `id` (and any future runtime type checks pass).
1490    // SAFETY: toll-free bridging guarantees identical layout.
1491    let bundle_url_ns: &objc2_foundation::NSURL = unsafe {
1492        &*CFRetained::as_ptr(&bundle_url)
1493            .as_ptr()
1494            .cast::<objc2_foundation::NSURL>()
1495    };
1496    let class_name_ns: &objc2_foundation::NSString = unsafe {
1497        &*CFRetained::as_ptr(&class_name)
1498            .as_ptr()
1499            .cast::<objc2_foundation::NSString>()
1500    };
1501
1502    // [NSBundle bundleWithURL:url] → NSBundle*
1503    let ns_bundle_class = objc2::class!(NSBundle);
1504    let bundle: *mut AnyObject =
1505        unsafe { msg_send![ns_bundle_class, bundleWithURL: bundle_url_ns] };
1506    if bundle.is_null() {
1507        return Err(Error::Other("NSBundle bundleWithURL: returned nil".into()));
1508    }
1509    // [bundle classNamed:name] → Class
1510    let view_factory_class: *mut objc2::runtime::AnyClass =
1511        unsafe { msg_send![bundle, classNamed: class_name_ns] };
1512    if view_factory_class.is_null() {
1513        return Err(Error::Other(format!(
1514            "view factory class not found in bundle: {}",
1515            unsafe { (*CFRetained::as_ptr(&class_name).as_ptr()).to_string() }
1516        )));
1517    }
1518    // [[class alloc] init]
1519    let factory_alloc: *mut AnyObject = unsafe { msg_send![view_factory_class, alloc] };
1520    let factory: *mut AnyObject = unsafe { msg_send![factory_alloc, init] };
1521    if factory.is_null() {
1522        return Err(Error::Other("AU view factory init returned nil".into()));
1523    }
1524    let factory_retained = unsafe { objc2::rc::Retained::from_raw(factory) }
1525        .ok_or_else(|| Error::Other("could not retain AU view factory".into()))?;
1526
1527    // Default preferred size — most factories ignore this and pick
1528    // their own.
1529    let pref = objc2_foundation::NSSize::new(0.0, 0.0);
1530    // `uiViewForAudioUnit:withSize:` was historically declared with
1531    // the Carbon-era `^{ComponentInstanceRecord=[1q]}` encoding;
1532    // Apple's own AU factories migrated to the newer
1533    // `^{OpaqueAudioComponentInstance=}` form. objc2's msg_send!
1534    // panics on encoding mismatch. Skip the check by dispatching
1535    // through the method's raw IMP directly — same ABI, no encoding
1536    // verification.
1537    let sel = objc2::sel!(uiViewForAudioUnit:withSize:);
1538    let factory_class = (*factory_retained).class();
1539    let Some(method) = factory_class.instance_method(sel) else {
1540        return Err(Error::Other(
1541            "AU view factory has no uiViewForAudioUnit:withSize: method".into(),
1542        ));
1543    };
1544    let imp = method.implementation();
1545    // SAFETY: AUv2 view factories all expose this exact signature;
1546    // we matched the selector above before transmuting the IMP.
1547    let typed: UiViewFn = unsafe { std::mem::transmute(imp) };
1548    let factory_obj: *mut objc2::runtime::AnyObject =
1549        objc2::rc::Retained::as_ptr(&factory_retained).cast_mut();
1550    let view_raw: *mut objc2_app_kit::NSView =
1551        unsafe { typed(factory_obj, sel, plugin.instance.cast(), pref) };
1552    if view_raw.is_null() {
1553        return Err(Error::Other(
1554            "uiViewForAudioUnit:withSize: returned nil".into(),
1555        ));
1556    }
1557    // The returned view is autoreleased; retain it.
1558    let view = unsafe { objc2::rc::Retained::retain(view_raw) }
1559        .ok_or_else(|| Error::Other("could not retain AU NSView".into()))?;
1560
1561    // [parent addSubview:view]
1562    let parent_view: *mut objc2_app_kit::NSView = parent_ptr.cast();
1563    let _: () = unsafe { msg_send![parent_view, addSubview: &*view] };
1564
1565    plugin.editor.factory = Some(factory_retained);
1566    plugin.editor.view = Some(view);
1567    Ok(())
1568}
1569
1570unsafe fn remove_from_superview(view: &objc2_app_kit::NSView) {
1571    use objc2::msg_send;
1572    let _: () = unsafe { msg_send![view, removeFromSuperview] };
1573}
1574
1575unsafe fn set_hidden(view: &objc2_app_kit::NSView, hidden: bool) {
1576    use objc2::msg_send;
1577    let flag: objc2::runtime::Bool = objc2::runtime::Bool::new(hidden);
1578    let _: () = unsafe { msg_send![view, setHidden: flag] };
1579}
1580
1581unsafe fn view_frame(view: &objc2_app_kit::NSView) -> (f64, f64, f64, f64) {
1582    use objc2::msg_send;
1583    use objc2_foundation::NSRect;
1584
1585    // Force any pending Auto Layout pass so the descendant frames
1586    // we're about to walk are post-layout. Apple's stock AUv2 views
1587    // (AULowpass, AUMultibandCompressor, …) only finalize their
1588    // child-view positions on the first layout pass.
1589    let _: () = unsafe { msg_send![view, layoutSubtreeIfNeeded] };
1590
1591    let r: NSRect = unsafe { msg_send![view, frame] };
1592
1593    // AUGenericView and friends report a `frame` that's smaller
1594    // than the union of their descendant subviews — macOS doesn't
1595    // clip children to a parent's bounds by default, so the visible
1596    // UI extends past the parent's frame. Walk the subtree and use
1597    // the bounding box of every descendant frame as the real
1598    // preferred size.
1599    let (extent_w, extent_h) = unsafe { subtree_extent(view) };
1600    let w = extent_w.max(r.size.width);
1601    let h = extent_h.max(r.size.height);
1602    (r.origin.x, r.origin.y, w, h)
1603}
1604
1605/// Recursively walk `view`'s subviews, computing the bounding box
1606/// (in `view`'s coordinate system) of every descendant's frame.
1607/// Returns `(max_x, max_y)` — i.e. the max width/height the view
1608/// would need so no descendant overflows.
1609unsafe fn subtree_extent(view: &objc2_app_kit::NSView) -> (f64, f64) {
1610    use objc2::msg_send;
1611    use objc2_foundation::{NSArray, NSRect};
1612
1613    let subviews: *mut NSArray<objc2_app_kit::NSView> = unsafe { msg_send![view, subviews] };
1614    if subviews.is_null() {
1615        return (0.0, 0.0);
1616    }
1617    let count: usize = unsafe { msg_send![subviews, count] };
1618    let mut max_x = 0.0_f64;
1619    let mut max_y = 0.0_f64;
1620    for i in 0..count {
1621        let sub: *mut objc2_app_kit::NSView = unsafe { msg_send![subviews, objectAtIndex: i] };
1622        if sub.is_null() {
1623            continue;
1624        }
1625        let frame: NSRect = unsafe { msg_send![sub, frame] };
1626        max_x = max_x.max(frame.origin.x + frame.size.width);
1627        max_y = max_y.max(frame.origin.y + frame.size.height);
1628        // Recurse into the descendant, translating its extent into
1629        // `view`'s coordinate space.
1630        let (sub_w, sub_h) = unsafe { subtree_extent(&*sub) };
1631        max_x = max_x.max(frame.origin.x + sub_w);
1632        max_y = max_y.max(frame.origin.y + sub_h);
1633    }
1634    (max_x, max_y)
1635}
1636
1637unsafe fn set_view_frame(view: &objc2_app_kit::NSView, w: f64, h: f64) {
1638    use objc2::msg_send;
1639    use objc2_foundation::{NSPoint, NSRect, NSSize};
1640    let r = NSRect::new(NSPoint::new(0.0, 0.0), NSSize::new(w, h));
1641    let _: () = unsafe { msg_send![view, setFrame: r] };
1642}
1643
1644impl Plugin<f32> for AuPlugin {
1645    fn process(
1646        &mut self,
1647        buffer: &mut AudioBuffer<'_, f32>,
1648        events: &EventList,
1649        context: &mut ProcessContext<'_>,
1650    ) -> Result<ProcessStatus> {
1651        if !self.is_active() {
1652            return Err(Error::NotActivated);
1653        }
1654        let frames = buffer.num_frames();
1655        let frames_u32 = u32::try_from(frames).unwrap_or(u32::MAX);
1656
1657        // Refresh the transport snapshot the AU reads back through
1658        // its host callbacks during AudioUnitRender.
1659        let sample_time = self.sample_time;
1660        if let Some(ht) = self.host_transport.as_deref_mut() {
1661            update_host_transport(ht, context.transport, context.sample_rate, sample_time);
1662        }
1663
1664        // Feed MIDI to the AU before AudioUnitRender so the
1665        // plugin sees the events at the correct sample offset
1666        // within this block. MusicDeviceMIDIEvent works for both
1667        // MusicDevice (instruments) and AUMIDIFX effects that
1668        // accept MIDI; for plain audio effects the call no-ops
1669        // with `kAudioUnitErr_InvalidProperty`, which we ignore.
1670        for event in events {
1671            send_midi(self.instance, event);
1672        }
1673
1674        // Refresh the render context's input pointers so the
1675        // callback hands the AU the right plane addresses for this
1676        // block.
1677        let main_inputs = buffer.main_inputs();
1678        if let Some(ctx) = self.render_ctx.as_deref_mut() {
1679            ctx.frames = frames;
1680            ctx.input_planes.clear();
1681            for chan in main_inputs.iter().take(self.input_channels as usize) {
1682                ctx.input_planes.push(chan.as_ptr());
1683            }
1684            while ctx.input_planes.len() < self.input_channels as usize {
1685                ctx.input_planes.push(ptr::null());
1686            }
1687        }
1688
1689        // Build the output AudioBufferList in-place over our
1690        // pre-allocated bytes. Each entry's `mData` points directly
1691        // at the corresponding truce-rack-core output channel.
1692        let main_outputs = buffer.main_outputs();
1693        // SAFETY: alloc_audio_buffer_list returned a Vec<u8>
1694        // sized to hold an AudioBufferList plus its variable
1695        // tail; the Vec is heap-allocated to 8-byte alignment via
1696        // the global allocator, which suffices for AudioBufferList.
1697        #[allow(clippy::cast_ptr_alignment)]
1698        let list_ptr = self
1699            .output_buffer_storage
1700            .as_mut_ptr()
1701            .cast::<AudioBufferList>();
1702        unsafe {
1703            (*list_ptr).mNumberBuffers = self.output_channels;
1704            let buffers_start: *mut CAAudioBuffer = (*list_ptr).mBuffers.as_mut_ptr();
1705            for ch in 0..self.output_channels as usize {
1706                let buf = &mut *buffers_start.add(ch);
1707                buf.mNumberChannels = 1;
1708                buf.mDataByteSize = u32::try_from(frames * std::mem::size_of::<f32>()).unwrap_or(0);
1709                buf.mData = main_outputs
1710                    .get_mut(ch)
1711                    .map_or(ptr::null_mut(), |c| c.as_mut_ptr().cast());
1712            }
1713        }
1714
1715        let mut flags = AudioUnitRenderActionFlags::empty();
1716        let timestamp = AudioTimeStamp {
1717            mSampleTime: self.sample_time,
1718            mHostTime: 0,
1719            mRateScalar: 1.0,
1720            mWordClockTime: 0,
1721            mSMPTETime: objc2_core_audio_types::SMPTETime {
1722                mSubframes: 0,
1723                mSubframeDivisor: 0,
1724                mCounter: 0,
1725                mType: objc2_core_audio_types::SMPTETimeType(0),
1726                mFlags: objc2_core_audio_types::SMPTETimeFlags(0),
1727                mHours: 0,
1728                mMinutes: 0,
1729                mSeconds: 0,
1730                mFrames: 0,
1731            },
1732            mFlags: AudioTimeStampFlags::SampleTimeValid,
1733            mReserved: 0,
1734        };
1735        let status = unsafe {
1736            AudioUnitRender(
1737                self.instance,
1738                &raw mut flags,
1739                ptr::NonNull::new_unchecked((&raw const timestamp).cast_mut()),
1740                0, // output bus 0
1741                frames_u32,
1742                ptr::NonNull::new_unchecked(list_ptr),
1743            )
1744        };
1745        #[allow(clippy::cast_precision_loss)]
1746        let frames_f = frames as f64;
1747        self.sample_time += frames_f;
1748        if status != 0 {
1749            return Ok(ProcessStatus::Error);
1750        }
1751        Ok(ProcessStatus::Continue)
1752    }
1753}
1754
1755#[cfg(test)]
1756mod tests {
1757    use super::*;
1758
1759    #[test]
1760    fn four_cc_roundtrip() {
1761        let aufx = u32::from_be_bytes(*b"aufx");
1762        assert_eq!(four_cc(aufx), "aufx");
1763        assert_eq!(parse_four_cc("aufx"), Some(aufx));
1764    }
1765
1766    #[test]
1767    fn unique_id_roundtrip() {
1768        let desc = AudioComponentDescription {
1769            componentType: u32::from_be_bytes(*b"aufx"),
1770            componentSubType: u32::from_be_bytes(*b"dely"),
1771            componentManufacturer: u32::from_be_bytes(*b"appl"),
1772            componentFlags: 0,
1773            componentFlagsMask: 0,
1774        };
1775        let id = format_unique_id(&desc);
1776        assert_eq!(id, "aufx:dely:appl");
1777        let parsed = parse_unique_id(&id).unwrap();
1778        assert_eq!(parsed.componentType, desc.componentType);
1779        assert_eq!(parsed.componentSubType, desc.componentSubType);
1780        assert_eq!(parsed.componentManufacturer, desc.componentManufacturer);
1781    }
1782}