truce_au/lib.rs
1//! Audio Unit v3 format wrapper for truce.
2//!
3//! Uses an Objective-C shim compiled via `cc` that implements the
4//! `AUAudioUnit` subclass. The shim calls back into Rust for all
5//! plugin logic via C FFI.
6
7pub mod ffi;
8
9use std::ffi::CString;
10use std::os::raw::c_char;
11use std::slice;
12
13use std::sync::atomic::{AtomicU32, Ordering};
14use std::sync::{Arc, OnceLock};
15
16// `Float::from_f64` is only invoked from the macOS-only `set_param`
17// closure in `cb_gui_open` (the AU v2 host notifier path). Gate the
18// import so iOS builds, which take a `_id`-no-op branch instead,
19// don't flag it as unused.
20#[cfg(target_os = "macos")]
21use truce_core::Float;
22use truce_core::SYSEX_POOL_PREALLOC;
23use truce_core::cast::{len_u32, sample_pos_i64};
24use truce_core::editor::Editor;
25// `ClosureBridge`, `PluginContext`, `SendPtr`, `RawWindowHandle` are
26// consumed only inside the apple-gated body of `cb_gui_open` - the
27// AppKit/UiKit variants don't exist on Linux/Windows. Importing them
28// from a non-apple module would also trigger the unused-import lint
29// there.
30use truce_core::chunked_process::{ChunkedProcess, process_chunked};
31#[cfg(any(target_os = "macos", target_os = "ios"))]
32use truce_core::editor::{ClosureBridge, PluginContext, RawWindowHandle, SendPtr};
33use truce_core::events::{EVENT_LIST_PREALLOC, Event, EventBody, EventList, TransportInfo};
34use truce_core::export::PluginExport;
35use truce_core::info::PluginCategory;
36use truce_core::midi::{decode_short_message, pitch_bend_to_bytes};
37use truce_core::state;
38use truce_core::ump::{SysExAssembler, SysExFeed, decode_ump_channel_voice_2};
39use truce_core::wrapper::{
40 default_io_channels, log_missing_bus_layout, run_audio_block, run_extern_callback_with,
41 run_register,
42};
43use truce_params::{ParamFlags, ParamInfo, Params};
44
45use ffi::{
46 AuCallbacks, AuMidi2Event, AuMidiEvent, AuParamDescriptor, AuParamEvent, AuPluginDescriptor,
47 AuTransportSnapshot,
48};
49
50// ---------------------------------------------------------------------------
51// Instance wrapper - one per plugin instance, stored as the opaque ctx
52// ---------------------------------------------------------------------------
53
54/// Bounded handoff slot for state loads. Capacity 1: presets don't
55/// arrive faster than the audio thread completes a block, and on
56/// overflow we want most-recent-wins (`force_push`) so a rapid
57/// double-recall doesn't get the audio thread to apply a stale state
58/// after the host already moved on.
59type StateLoadQueue = crossbeam_queue::ArrayQueue<state::DeserializedState>;
60
61struct AuInstance<P: PluginExport> {
62 plugin: P,
63 /// Stable handle to the params Arc, set once at instance creation.
64 /// Host-thread callbacks (`cb_param_*`, `cb_state_save`) read params
65 /// through this handle so they never form a `&inst.plugin`
66 /// reference. Params are atomic-backed and `Sync`.
67 params_arc: Arc<P::Params>,
68 /// Atomic snapshots of the plugin's most recent `latency()` /
69 /// `tail()`. Updated by the audio thread (or `cb_reset`).
70 latency_cache: AtomicU32,
71 tail_cache: AtomicU32,
72 event_list: EventList,
73 output_events: EventList,
74 /// Per-sub-block scratch for `chunked_process::process_chunked`.
75 sub_event_scratch: EventList,
76 /// Cached param-info table for the chunker's split predicate.
77 param_infos: Vec<ParamInfo>,
78 /// `min_subblock_samples` from `truce.toml`'s `[automation]`.
79 min_subblock_samples: u32,
80 /// Per-instance UMP `SysEx` reassembler. AU v3 hosts deliver
81 /// long `SysEx` payloads as a chain of `SysEx`-7 (6-byte) or
82 /// `SysEx`-8 (13-byte) UMPs; the assembler concatenates them
83 /// into one logical [`EventBody::SysEx`] before pushing to the
84 /// plugin's `event_list`. Holds
85 /// [`truce_core::ump::SYSEX_ASSEMBLER_SLOTS`] ×
86 /// [`SYSEX_POOL_PREALLOC`] (4 × 128 KiB = 512 KiB) of buffer
87 /// space so concurrent streams across UMP groups don't bleed
88 /// into each other. Cleared at the top of `cb_process` so a
89 /// partial message can't bleed across blocks.
90 sysex_assembler: SysExAssembler,
91 plugin_id_hash: u64,
92 sample_rate: f64,
93 /// Max block size declared by the host via
94 /// `kAudioUnitProperty_MaximumFramesPerSlice` (delivered through
95 /// `cb_reset`'s `max_frames`). A generous default keeps the
96 /// contract assert in `cb_process` from tripping for hosts that
97 /// send process before declaring a max.
98 max_block_size: usize,
99 /// `true` once `cb_reset` has run. `cb_process` early-returns and
100 /// zeros outputs while false so DSP doesn't run with un-snapped
101 /// smoothers / unset sample rate.
102 prepared: bool,
103 /// Reused per-block scratch for `RawBufferScratch::build`. Lives
104 /// on the instance so the audio thread doesn't heap-allocate.
105 ///
106 /// Parameterised by `P::Sample`; widens/narrows host-`f32`
107 /// buffers around `plugin.process()` for plugins on `prelude64`.
108 scratch: truce_core::buffer::RawBufferScratch<<P as truce_core::plugin::PluginRuntime>::Sample>,
109 editor: Option<Box<dyn Editor>>,
110 /// Shared transport slot: audio thread writes each block, editor reads.
111 transport_slot: Arc<truce_core::TransportSlot>,
112 /// Bounded SPSC handoff for state loads. Host (`cb_state_load`)
113 /// and editor (`set_state` callback) deserialize on their thread
114 /// and push the result; the audio thread pops at the top of
115 /// `cb_process` and calls [`state::apply_state`]
116 /// under its exclusive `&mut plugin`.
117 pending_state: Arc<StateLoadQueue>,
118}
119
120// ---------------------------------------------------------------------------
121// Intentional leaks
122//
123// Every `CString::into_raw()` and `Vec::leak()` / `param_descs.leak()`
124// in this file feeds a `*const c_char` (or `*const SomeDesc`) into a
125// descriptor that the AU host caches for the process lifetime. Hosts
126// re-read these pointers on demand (display, parameter sweeps,
127// validation) - there's no signal back to Rust saying "you may free
128// this now". Freeing is therefore unsound.
129//
130// The leak is bounded: O(plugin_count × (param_count + a few strings))
131// per process, allocated once at registration time. No leak per audio
132// callback, per render, per editor open. AU bundles get unloaded with
133// the host process, which reclaims the allocation.
134//
135// `Box::into_raw(boxed_instance)` in `cb_create` follows the same
136// pattern but is *paired* with `cb_destroy` reconstituting the Box -
137// so it isn't a leak, just a C-lifetime handoff.
138//
139// ---------------------------------------------------------------------------
140// C callback implementations (generic over P)
141//
142// SAFETY for all unsafe extern "C" fn below:
143// - `ctx` is a *mut c_void created by Box::into_raw in cb_create().
144// Valid until cb_destroy() (called exactly once by the AU shim).
145// - The AU v2 shim (au_v2_shim.c) and v3 shim (au_shim.m) own the
146// Rust context. The AU host guarantees: render callback on the
147// audio thread with exclusive access; all other callbacks on the
148// main thread, serialized.
149// - Audio buffer pointers come from the host's AudioBufferList and
150// are valid for the declared channel count × frame count.
151// - MIDI events come from MusicDeviceMIDIEvent (v2) or
152// AURenderEvent linked list (v3).
153// ---------------------------------------------------------------------------
154
155unsafe extern "C" fn cb_create<P: PluginExport>() -> *mut std::ffi::c_void {
156 let mut plugin = P::create();
157 plugin.init();
158 let info = P::info();
159 let param_infos = plugin.params().param_infos();
160 let params_arc = plugin.params_arc();
161 let latency_cache = AtomicU32::new(plugin.latency());
162 let tail_cache = AtomicU32::new(plugin.tail());
163 let instance = Box::new(AuInstance::<P> {
164 plugin,
165 params_arc,
166 latency_cache,
167 tail_cache,
168 event_list: EventList::with_capacity(EVENT_LIST_PREALLOC),
169 output_events: EventList::with_capacity(EVENT_LIST_PREALLOC),
170 sub_event_scratch: EventList::with_capacity(EVENT_LIST_PREALLOC),
171 param_infos,
172 min_subblock_samples: info.automation.min_subblock_samples,
173 sysex_assembler: SysExAssembler::with_capacity(SYSEX_POOL_PREALLOC),
174 plugin_id_hash: state::shared_plugin_state_hash(&info),
175 sample_rate: 44100.0,
176 max_block_size: 8192,
177 prepared: false,
178 scratch: truce_core::buffer::RawBufferScratch::default(),
179 editor: None,
180 transport_slot: truce_core::TransportSlot::new(),
181 pending_state: Arc::new(StateLoadQueue::new(1)),
182 });
183 Box::into_raw(instance).cast::<std::ffi::c_void>()
184}
185
186unsafe extern "C" fn cb_destroy<P: PluginExport>(ctx: *mut std::ffi::c_void) {
187 unsafe {
188 if !ctx.is_null() {
189 // Wrap the drop in `catch_unwind`: dropping the
190 // `AuInstance` cascades into the editor's `Drop`,
191 // which tears down wgpu surfaces / `NSView` /
192 // baseview / runloop timers. A panic anywhere in
193 // that chain propagates across this `extern "C"`
194 // boundary as UB - in practice the host catches it
195 // as an Objective-C exception, `objc_exception_rethrow`
196 // can't recover, and `std::terminate` aborts the host
197 // (the REAPER / Cubase quit-time SIGABRT pattern).
198 // Catching here keeps the host alive; the process is
199 // going away anyway so swallowing is fine.
200 let raw = ctx.cast::<AuInstance<P>>();
201 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
202 drop(Box::from_raw(raw));
203 }));
204 }
205 }
206}
207
208unsafe extern "C" fn cb_reset<P: PluginExport>(
209 ctx: *mut std::ffi::c_void,
210 sample_rate: f64,
211 max_frames: u32,
212) {
213 unsafe {
214 let inst = &mut *ctx.cast::<AuInstance<P>>();
215 // Clamp host-supplied max_frames to a sane minimum.
216 let max_frames = (max_frames as usize).max(1024);
217 inst.sample_rate = sample_rate;
218 inst.max_block_size = max_frames;
219 let (num_in, num_out) = default_io_channels::<P>().unwrap_or((2, 2));
220 inst.scratch
221 .ensure_capacity(num_in as usize, num_out as usize, max_frames);
222 inst.plugin.reset(sample_rate, max_frames);
223 inst.plugin.params().set_sample_rate(sample_rate);
224 inst.plugin.params().snap_smoothers();
225 inst.latency_cache
226 .store(inst.plugin.latency(), Ordering::Relaxed);
227 inst.tail_cache.store(inst.plugin.tail(), Ordering::Relaxed);
228 inst.prepared = true;
229 }
230}
231
232#[allow(clippy::too_many_lines)] // step-by-step block processing reads top-to-bottom
233unsafe extern "C" fn cb_process<P: PluginExport>(
234 ctx: *mut std::ffi::c_void,
235 inputs: *const *const f32,
236 outputs: *mut *mut f32,
237 num_input_channels: u32,
238 num_output_channels: u32,
239 num_frames: u32,
240 events: *const AuMidiEvent,
241 num_events: u32,
242 events2: *const AuMidi2Event,
243 num_events2: u32,
244 param_events: *const AuParamEvent,
245 num_param_events: u32,
246 transport_ptr: *const AuTransportSnapshot,
247) {
248 let nf = num_frames as usize;
249 let ok = run_audio_block::<P>("AU", || unsafe {
250 let inst = &mut *ctx.cast::<AuInstance<P>>();
251 let num_frames = nf;
252
253 // Host called render before AU initialized us - sample rate
254 // and smoothers haven't been primed. Zero outputs and bail.
255 if !inst.prepared {
256 for ch in 0..num_output_channels as usize {
257 let ptr = *outputs.add(ch);
258 if !ptr.is_null() {
259 std::ptr::write_bytes(ptr, 0, num_frames);
260 }
261 }
262 return;
263 }
264
265 // Apply any pending state-load before per-block work so the
266 // plugin sees consistent params and extra state for the
267 // entire block. See `pending_state` field comment for the
268 // queue-overflow policy.
269 if let Some(state) = inst.pending_state.pop() {
270 state::apply_state(&mut inst.plugin, &state);
271 }
272
273 // Convert MIDI events
274 inst.event_list.clear();
275 if !events.is_null() && num_events > 0 {
276 let event_slice = slice::from_raw_parts(events, num_events as usize);
277 for ev in event_slice {
278 if let Some(body) = decode_short_message(ev.status, ev.data1, ev.data2) {
279 inst.event_list.push(Event {
280 sample_offset: ev.sample_offset,
281 body,
282 });
283 }
284 }
285 }
286 // MIDI 2.0 UMP decode. AU v3 hosts on iOS 17+ / macOS 14+
287 // deliver per-note expression + 32-bit-resolution channel
288 // voice messages through `AURenderEvent.MIDIEventList`; the
289 // Swift shim hands them here as 64-bit UMPs (MIDI 2.0 CV
290 // message type 0x4) plus the SysEx-7 (mt 0x3) / SysEx-8
291 // (mt 0x5) variable-length streams that the assembler
292 // reconstitutes into one `EventBody::SysEx` per logical
293 // message. Utility / system / data UMPs are still skipped.
294 inst.sysex_assembler.reset();
295 if !events2.is_null() && num_events2 > 0 {
296 let slice2 = slice::from_raw_parts(events2, num_events2 as usize);
297 for ev in slice2 {
298 let mt = ((ev.words[0] >> 28) & 0xF) as u8;
299 match mt {
300 0x4 => {
301 if let Some(body) = decode_ump_channel_voice_2(ev.words) {
302 inst.event_list.push(Event {
303 sample_offset: ev.sample_offset,
304 body,
305 });
306 }
307 }
308 0x3 => {
309 let feed = inst
310 .sysex_assembler
311 .push_sysex7_packet([ev.words[0], ev.words[1]]);
312 if let SysExFeed::Complete(p) = feed {
313 // `push_sysex` failure here would mean the
314 // pool is full mid-block; drop the
315 // message rather than corrupt-splitting it.
316 let _ = inst.event_list.push_sysex(ev.sample_offset, p.bytes);
317 }
318 }
319 0x5 => {
320 let feed = inst.sysex_assembler.push_sysex8_packet(ev.words);
321 if let SysExFeed::Complete(p) = feed {
322 let _ = inst.event_list.push_sysex(ev.sample_offset, p.bytes);
323 }
324 }
325 _ => {
326 // mt 0x0 (utility), 0x1 (system real-time),
327 // 0x2 (MIDI 1 CV, already arrived via the
328 // legacy `events` slice above), 0xD / 0xF
329 // (flex / stream): not decoded.
330 }
331 }
332 }
333 }
334
335 // Host-side parameter automation. The AU v3 Swift shim
336 // decodes `AURenderEvent.parameter` / `.parameterRamp`
337 // entries into `AuParamEvent` rows with within-block
338 // sample offsets; convert each into an
339 // `EventBody::ParamChange` so the chunker
340 // (`process_chunked` below) splits the audio block at the
341 // automation point. Ramps are treated as a step at the
342 // ramp's start - the plugin's own smoother handles the
343 // actual interpolation, matching truce-vst3's treatment of
344 // VST3 parameter queues. The v2 path passes
345 // `param_events = NULL, num_param_events = 0` because AU v2
346 // has no per-sample automation API at the format boundary.
347 if !param_events.is_null() && num_param_events > 0 {
348 let pe_slice = slice::from_raw_parts(param_events, num_param_events as usize);
349 for pe in pe_slice {
350 inst.event_list.push(Event {
351 sample_offset: pe.sample_offset,
352 body: EventBody::ParamChange {
353 id: pe.param_id,
354 value: f64::from(pe.value),
355 },
356 });
357 }
358 }
359
360 inst.event_list.sort();
361
362 // Build AudioBuffer from raw pointers, reusing the per-instance scratch.
363 debug_assert!(
364 num_frames <= inst.max_block_size,
365 "host violated AU contract: render() got {num_frames} frames \
366 but kAudioUnitProperty_MaximumFramesPerSlice declared max {}",
367 inst.max_block_size
368 );
369 let mut audio_buffer = inst.scratch.build(
370 inputs,
371 outputs,
372 num_input_channels,
373 num_output_channels,
374 len_u32(num_frames),
375 P::supports_in_place(),
376 );
377
378 let transport = if !transport_ptr.is_null() && (*transport_ptr).valid != 0 {
379 let t = &*transport_ptr;
380 TransportInfo {
381 playing: t.playing != 0,
382 recording: t.recording != 0,
383 tempo: t.tempo,
384 // The two `as u8` casts are post-clamped to `0..=255`.
385 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
386 time_sig_num: t.time_sig_num.clamp(0, i32::from(u8::MAX)) as u8,
387 #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
388 time_sig_den: t.time_sig_den.clamp(0, i32::from(u8::MAX)) as u8,
389 position_samples: sample_pos_i64(t.position_samples),
390 position_seconds: 0.0,
391 position_beats: t.position_beats,
392 bar_start_beats: t.bar_start_beats,
393 loop_active: t.loop_active != 0,
394 loop_start_beats: t.loop_start_beats,
395 loop_end_beats: t.loop_end_beats,
396 }
397 } else {
398 TransportInfo::default()
399 };
400 inst.output_events.clear();
401 inst.transport_slot.write(&transport);
402
403 let mut transport_snap = transport;
404 let chunk_args = ChunkedProcess {
405 events: &inst.event_list,
406 sub_event_scratch: &mut inst.sub_event_scratch,
407 transport: &mut transport_snap,
408 sample_rate: inst.sample_rate,
409 output_events: &mut inst.output_events,
410 params_fn: None,
411 meters_fn: None,
412 param_infos: &inst.param_infos,
413 min_subblock_samples: inst.min_subblock_samples,
414 };
415 process_chunked(
416 &mut inst.plugin,
417 inst.params_arc.as_ref() as &dyn Params,
418 &mut audio_buffer,
419 chunk_args,
420 );
421 let _ = audio_buffer;
422 // Narrow rendered f64 output back to host f32 when needed.
423 // No-op for `f32` plugins.
424 inst.scratch
425 .finish_widening_f32(outputs, num_output_channels, len_u32(num_frames));
426
427 // Refresh latency / tail caches so the host's main-thread
428 // queries don't have to call into `inst.plugin`.
429 inst.latency_cache
430 .store(inst.plugin.latency(), Ordering::Relaxed);
431 inst.tail_cache.store(inst.plugin.tail(), Ordering::Relaxed);
432 });
433 if !ok {
434 unsafe {
435 for ch in 0..num_output_channels as usize {
436 let ptr = *outputs.add(ch);
437 if !ptr.is_null() {
438 std::ptr::write_bytes(ptr, 0, nf);
439 }
440 }
441 }
442 }
443}
444
445unsafe extern "C" fn cb_param_count<P: PluginExport>(ctx: *mut std::ffi::c_void) -> u32 {
446 unsafe {
447 let inst = &*ctx.cast::<AuInstance<P>>();
448 len_u32(inst.params_arc.count())
449 }
450}
451
452unsafe extern "C" fn cb_param_get_value<P: PluginExport>(
453 ctx: *mut std::ffi::c_void,
454 id: u32,
455) -> f64 {
456 unsafe {
457 let inst = &*ctx.cast::<AuInstance<P>>();
458 inst.params_arc.get_plain(id).unwrap_or(0.0)
459 }
460}
461
462unsafe extern "C" fn cb_param_set_value<P: PluginExport>(
463 ctx: *mut std::ffi::c_void,
464 id: u32,
465 value: f64,
466) {
467 unsafe {
468 let inst = &*ctx.cast::<AuInstance<P>>();
469 inst.params_arc.set_plain(id, value);
470 }
471}
472
473unsafe extern "C" fn cb_param_format_value<P: PluginExport>(
474 ctx: *mut std::ffi::c_void,
475 id: u32,
476 value: f64,
477 out: *mut c_char,
478 out_len: u32,
479) -> u32 {
480 unsafe {
481 // `out_len == 0` would underflow on `out_len as usize - 1`
482 // and let `copy_nonoverlapping` write past the host-supplied
483 // buffer. Treat zero capacity as "host wants nothing".
484 if out_len == 0 || out.is_null() {
485 return 0;
486 }
487 let inst = &*ctx.cast::<AuInstance<P>>();
488 match inst.params_arc.format_value(id, value) {
489 Some(text) => {
490 let bytes = text.as_bytes();
491 let len = bytes.len().min((out_len as usize) - 1);
492 std::ptr::copy_nonoverlapping(bytes.as_ptr().cast::<c_char>(), out, len);
493 *out.add(len) = 0;
494 len_u32(len)
495 }
496 None => 0,
497 }
498 }
499}
500
501unsafe extern "C" fn cb_state_save<P: PluginExport>(
502 ctx: *mut std::ffi::c_void,
503 out_data: *mut *mut u8,
504 out_len: *mut u32,
505) {
506 // Pre-zero the out pointers so a panic anywhere in the body below
507 // leaves the host seeing an empty blob rather than a stale buffer
508 // pointer paired with whatever length was last written.
509 unsafe {
510 *out_data = std::ptr::null_mut();
511 *out_len = 0;
512 }
513 run_extern_callback_with::<P, ()>("au", "save_state", (), || unsafe {
514 let inst = &*ctx.cast::<AuInstance<P>>();
515 let (ids, values) = inst.params_arc.collect_values();
516 // `plugin.save_state()` reads through the plugin reference: a
517 // user impl that mutates non-atomic state from `process` while
518 // also reading it from `save_state` races here. The contract
519 // is "save_state must be safe to call concurrently with
520 // process"; impls that copy from atomic params are fine.
521 //
522 // Allocator pin: this wrapper allocates with libc `malloc` and
523 // the AU shim frees with libc `free`. The Rust global allocator
524 // must not appear on either side; mixing allocators is UB.
525 let extra = inst.plugin.save_state();
526 let blob = state::serialize_state(inst.plugin_id_hash, &ids, &values, &extra);
527
528 let len = blob.len();
529 let ptr = malloc(len).cast::<u8>();
530 if ptr.is_null() {
531 // malloc failed - `*out_data` is already null and
532 // `*out_len` already 0 from the pre-zero above.
533 return;
534 }
535 std::ptr::copy_nonoverlapping(blob.as_ptr(), ptr, len);
536 *out_data = ptr;
537 *out_len = len_u32(len);
538 });
539}
540
541unsafe extern "C" fn cb_state_load<P: PluginExport>(
542 ctx: *mut std::ffi::c_void,
543 data: *const u8,
544 len: u32,
545) {
546 run_extern_callback_with::<P, ()>("au", "load_state", (), || unsafe {
547 let inst = &mut *ctx.cast::<AuInstance<P>>();
548 // `slice::from_raw_parts(null, n)` for `n > 0` is UB. Treat
549 // `(null, *)` and `(_, 0)` the same as "host gave us nothing".
550 if data.is_null() || len == 0 {
551 return;
552 }
553 let blob = slice::from_raw_parts(data, len as usize);
554 if let Some(deserialized) = state::deserialize_state(blob, inst.plugin_id_hash) {
555 // Apply params synchronously on the host thread (atomic-safe)
556 // so host queries that read parameter values right after
557 // `setFullState:` see the restored values without first
558 // running a render block.
559 state::apply_params(&*inst.params_arc, &deserialized);
560 // Hand the deserialized state to the audio thread for
561 // application. `force_push` overwrites any older pending
562 // blob - see the `pending_state` field comment for why
563 // newest-wins is the right policy.
564 let _ = inst.pending_state.force_push(deserialized);
565 if let Some(ref mut editor) = inst.editor {
566 editor.state_changed();
567 }
568 }
569 });
570}
571
572unsafe extern "C" fn cb_state_free(data: *mut u8, _len: u32) {
573 unsafe {
574 if !data.is_null() {
575 free(data.cast::<std::ffi::c_void>());
576 }
577 }
578}
579
580// ---------------------------------------------------------------------------
581// Factory presets (kAudioUnitProperty_FactoryPresets backing)
582// ---------------------------------------------------------------------------
583
584/// One bundled factory preset: the display name handed to the shim
585/// (C-string, lives for the process) plus the file to load.
586struct FactoryPresetEntry {
587 name: CString,
588 path: std::path::PathBuf,
589}
590
591/// Lazily-enumerated factory presets from the component bundle's
592/// `Contents/Resources/Presets/`. One static per shared library, like
593/// the registration statics: an AU dylib ships exactly one plugin
594/// type, so the single `OnceLock` never sees a second
595/// monomorphization. Empty when the bundle ships no presets (or when
596/// the dylib isn't inside a component bundle, e.g. the AU v3 appex
597/// layout - the shim then reports the property as invalid).
598static FACTORY_PRESETS: OnceLock<Vec<FactoryPresetEntry>> = OnceLock::new();
599
600fn factory_presets<P: PluginExport>() -> &'static [FactoryPresetEntry] {
601 FACTORY_PRESETS.get_or_init(|| {
602 let Some(root) = component_presets_root::<P>() else {
603 return Vec::new();
604 };
605 let info = P::info();
606 let mut refs = truce_core::presets::enumerate_scope(
607 &root,
608 truce_core::presets::PresetScope::Factory,
609 info.vendor,
610 info.name,
611 );
612 // The library's `default = true` preset leads the list: hosts
613 // treat factory preset 0 as the de-facto initial sound. The
614 // stable sort keeps the walk's alphabetical order behind it.
615 refs.sort_by_key(|preset| !preset.default);
616 refs.into_iter()
617 .filter_map(|preset| {
618 // Hosts show the factory list flat; keep the category
619 // visible the way the LV2 labels do.
620 let display = match &preset.category {
621 Some(category) => format!("{category}/{}", preset.name),
622 None => preset.name.clone(),
623 };
624 Some(FactoryPresetEntry {
625 name: CString::new(display).ok()?,
626 path: preset.path,
627 })
628 })
629 .collect()
630 })
631}
632
633/// Mirrors the layout `<libc/dlfcn.h>` defines; bound directly like
634/// the `malloc` / `free` externs above to keep the crate free of a
635/// libc dependency. Field names keep dlfcn's `dli_` prefix so they
636/// line up with the C declaration they shadow.
637#[repr(C)]
638#[allow(clippy::struct_field_names)]
639struct DlInfo {
640 dli_fname: *const c_char,
641 dli_fbase: *mut std::ffi::c_void,
642 dli_sname: *const c_char,
643 dli_saddr: *mut std::ffi::c_void,
644}
645
646unsafe extern "C" {
647 fn dladdr(addr: *const std::ffi::c_void, info: *mut DlInfo) -> i32;
648}
649
650/// Resolve the `Resources/Presets/` directory of the bundle this
651/// code lives in, via `dladdr` on one of our own functions. Two
652/// layouts exist:
653///
654/// - AU v2 component: `<X>.component/Contents/MacOS/<X>` with presets
655/// in `Contents/Resources/Presets/` (two levels up).
656/// - AU v3 framework: `<F>.framework/Versions/A/<F>` with presets in
657/// `Versions/A/Resources/Presets/` (one level up).
658fn component_presets_root<P: PluginExport>() -> Option<std::path::PathBuf> {
659 let mut info = DlInfo {
660 dli_fname: std::ptr::null(),
661 dli_fbase: std::ptr::null_mut(),
662 dli_sname: std::ptr::null(),
663 dli_saddr: std::ptr::null_mut(),
664 };
665 let probe = cb_factory_preset_count::<P> as *const std::ffi::c_void;
666 // SAFETY: `probe` is a function in this image; `dladdr` only
667 // writes into the out-struct on success.
668 if unsafe { dladdr(probe, &raw mut info) } == 0 || info.dli_fname.is_null() {
669 return None;
670 }
671 // SAFETY: `dli_fname` is a NUL-terminated path owned by dyld.
672 let exe = unsafe { std::ffi::CStr::from_ptr(info.dli_fname) };
673 let exe = std::path::Path::new(exe.to_str().ok()?);
674 let parent = exe.parent()?;
675 [parent.parent()?, parent]
676 .into_iter()
677 .map(|dir| dir.join("Resources/Presets"))
678 .find(|root| root.is_dir())
679}
680
681unsafe extern "C" fn cb_factory_preset_count<P: PluginExport>(_ctx: *mut std::ffi::c_void) -> u32 {
682 run_extern_callback_with::<P, u32>("au", "factory_preset_count", 0, || {
683 len_u32(factory_presets::<P>().len())
684 })
685}
686
687unsafe extern "C" fn cb_factory_preset_name<P: PluginExport>(
688 _ctx: *mut std::ffi::c_void,
689 index: u32,
690) -> *const c_char {
691 run_extern_callback_with::<P, *const c_char>(
692 "au",
693 "factory_preset_name",
694 std::ptr::null(),
695 || {
696 factory_presets::<P>()
697 .get(index as usize)
698 .map_or(std::ptr::null(), |entry| entry.name.as_ptr())
699 },
700 )
701}
702
703unsafe extern "C" fn cb_factory_preset_load<P: PluginExport>(
704 ctx: *mut std::ffi::c_void,
705 index: u32,
706) -> i32 {
707 run_extern_callback_with::<P, i32>("au", "factory_preset_load", 0, || unsafe {
708 let inst = &mut *ctx.cast::<AuInstance<P>>();
709 let Some(entry) = factory_presets::<P>().get(index as usize) else {
710 return 0;
711 };
712 let Some(deserialized) =
713 truce_core::presets::load_preset_file(&entry.path, inst.plugin_id_hash)
714 else {
715 return 0;
716 };
717 // Same apply path as cb_state_load: params synchronously on
718 // the host thread, full state through the audio-thread
719 // handoff, editor notified.
720 state::apply_params(&*inst.params_arc, &deserialized);
721 let _ = inst.pending_state.force_push(deserialized);
722 if let Some(ref mut editor) = inst.editor {
723 editor.state_changed();
724 }
725 1
726 })
727}
728
729// ---------------------------------------------------------------------------
730// Output event callbacks (plugin → host MIDI)
731// ---------------------------------------------------------------------------
732
733// UMP MIDI 2.0 CV decoder lives in `truce-core::ump` so the same
734// codec backs CLAP's `CLAP_EVENT_MIDI2` path and AU's MIDIEventList
735// path.
736
737/// Map a truce `Event` body to a 3-byte AU MIDI packet. Returns
738/// `None` for event types that don't fit (MIDI 2.0, `ParamChange`,
739/// Transport, etc.).
740fn try_encode_au_midi(event: &Event) -> Option<AuMidiEvent> {
741 let (status, data1, data2) = match &event.body {
742 EventBody::NoteOn {
743 channel,
744 note,
745 velocity,
746 ..
747 } => (0x90 | (channel & 0x0F), *note, *velocity),
748 EventBody::NoteOff {
749 channel,
750 note,
751 velocity,
752 ..
753 } => (0x80 | (channel & 0x0F), *note, *velocity),
754 EventBody::ControlChange {
755 channel, cc, value, ..
756 } => (0xB0 | (channel & 0x0F), *cc, *value),
757 EventBody::Aftertouch {
758 channel,
759 note,
760 pressure,
761 ..
762 } => (0xA0 | (channel & 0x0F), *note, *pressure),
763 EventBody::ChannelPressure {
764 channel, pressure, ..
765 } => (0xD0 | (channel & 0x0F), *pressure, 0),
766 EventBody::PitchBend { channel, value, .. } => {
767 let (lsb, msb) = pitch_bend_to_bytes(*value);
768 (0xE0 | (channel & 0x0F), lsb, msb)
769 }
770 EventBody::ProgramChange {
771 channel, program, ..
772 } => (0xC0 | (channel & 0x0F), *program, 0),
773 _ => return None,
774 };
775 Some(AuMidiEvent {
776 sample_offset: event.sample_offset,
777 status,
778 data1,
779 data2,
780 _pad: 0,
781 })
782}
783
784unsafe extern "C" fn cb_output_event_count<P: PluginExport>(ctx: *mut std::ffi::c_void) -> u32 {
785 unsafe {
786 let inst = &*ctx.cast::<AuInstance<P>>();
787 let n = inst
788 .output_events
789 .iter()
790 .filter(|e| try_encode_au_midi(e).is_some())
791 .count();
792 len_u32(n)
793 }
794}
795
796unsafe extern "C" fn cb_output_event_at<P: PluginExport>(
797 ctx: *mut std::ffi::c_void,
798 index: u32,
799 out: *mut AuMidiEvent,
800) {
801 unsafe {
802 let inst = &*ctx.cast::<AuInstance<P>>();
803 if let Some(packet) = inst
804 .output_events
805 .iter()
806 .filter_map(try_encode_au_midi)
807 .nth(index as usize)
808 {
809 *out = packet;
810 }
811 }
812}
813
814unsafe extern "C" fn cb_output_sysex_count<P: PluginExport>(ctx: *mut std::ffi::c_void) -> u32 {
815 unsafe {
816 let inst = &*ctx.cast::<AuInstance<P>>();
817 len_u32(
818 inst.output_events
819 .iter()
820 .filter(|e| matches!(e.body, EventBody::SysEx { .. }))
821 .count(),
822 )
823 }
824}
825
826unsafe extern "C" fn cb_output_sysex_at<P: PluginExport>(
827 ctx: *mut std::ffi::c_void,
828 index: u32,
829 out_delta_frames: *mut u32,
830 out_bytes: *mut *const u8,
831 out_len: *mut u32,
832) {
833 unsafe {
834 let inst = &*ctx.cast::<AuInstance<P>>();
835 if let Some(event) = inst
836 .output_events
837 .iter()
838 .filter(|e| matches!(e.body, EventBody::SysEx { .. }))
839 .nth(index as usize)
840 {
841 let bytes = inst.output_events.sysex_bytes(&event.body);
842 *out_delta_frames = event.sample_offset;
843 *out_bytes = bytes.as_ptr();
844 *out_len = len_u32(bytes.len());
845 }
846 }
847}
848
849// ---------------------------------------------------------------------------
850// GUI callbacks
851// ---------------------------------------------------------------------------
852
853unsafe extern "C" fn cb_gui_has_editor<P: PluginExport>(ctx: *mut std::ffi::c_void) -> i32 {
854 unsafe {
855 if ctx.is_null() {
856 return 0;
857 }
858 let inst = &mut *ctx.cast::<AuInstance<P>>();
859 if inst.editor.is_none() {
860 inst.editor = inst.plugin.editor();
861 }
862 i32::from(inst.editor.is_some())
863 }
864}
865
866/// Used by the AU v3 Swift shim in `viewDidLayoutSubviews` to
867/// decide whether to forward host bounds changes to the editor,
868/// and by the AU v2 `uiViewForAudioUnit:withSize:` path to pick
869/// between the host's `preferredSize` and the editor's natural
870/// size. Returns 1 / 0 mapping to "yes / no resizable".
871unsafe extern "C" fn cb_gui_can_resize<P: PluginExport>(ctx: *mut std::ffi::c_void) -> i32 {
872 unsafe {
873 if ctx.is_null() {
874 return 0;
875 }
876 let inst = &*ctx.cast::<AuInstance<P>>();
877 i32::from(inst.editor.as_ref().is_some_and(|e| e.can_resize()))
878 }
879}
880
881/// Host-driven `set_size`. The AU v2 Cocoa view's
882/// `setFrameSize:` / superview-frame observer calls this when the
883/// host resizes its outer container; the AU v3 Swift shim calls
884/// it from `viewDidLayoutSubviews`. Clamps to the editor's
885/// `min_size` / `max_size` / `aspect_ratio` so a host dragging
886/// below the editor's floor doesn't clip widgets (mirrors the
887/// CLAP and VST3 wrappers).
888unsafe extern "C" fn cb_gui_set_size<P: PluginExport>(ctx: *mut std::ffi::c_void, w: u32, h: u32) {
889 unsafe {
890 if ctx.is_null() || w == 0 || h == 0 {
891 return;
892 }
893 let inst = &mut *ctx.cast::<AuInstance<P>>();
894 if let Some(ref mut editor) = inst.editor
895 && editor.can_resize()
896 {
897 let (cw, ch) = clamp_logical_to_editor(w, h, editor.as_ref());
898 editor.set_size(cw, ch);
899 }
900 }
901}
902
903/// Clamp a requested logical size to the editor's `min_size` /
904/// `max_size` / `aspect_ratio`. Mirrors the helpers that live in
905/// the CLAP and VST3 wrappers - kept local rather than in
906/// truce-core because it's the wrapper's job to honour host-side
907/// constraints, not the trait's.
908fn clamp_logical_to_editor(w: u32, h: u32, editor: &dyn truce_core::editor::Editor) -> (u32, u32) {
909 let (min_w, min_h) = editor.min_size();
910 let (max_w, max_h) = editor.max_size();
911 let mut w = w.clamp(min_w.max(1), max_w);
912 let mut h = h.clamp(min_h.max(1), max_h);
913 if let Some((num, denom)) = editor.aspect_ratio()
914 && num > 0
915 && denom > 0
916 {
917 let num64 = u64::from(num);
918 let denom64 = u64::from(denom);
919 let h_implied = (u64::from(w) * denom64 / num64).clamp(1, u64::from(u32::MAX));
920 #[allow(clippy::cast_possible_truncation)]
921 let h_implied_u32 = h_implied as u32;
922 if h_implied_u32 >= min_h.max(1) && h_implied_u32 <= max_h {
923 h = h_implied_u32;
924 } else {
925 let w_implied = (u64::from(h) * num64 / denom64).clamp(1, u64::from(u32::MAX));
926 #[allow(clippy::cast_possible_truncation)]
927 let w_implied_u32 = w_implied as u32;
928 w = w_implied_u32.clamp(min_w.max(1), max_w);
929 let h_final = (u64::from(w) * denom64 / num64).clamp(1, u64::from(u32::MAX));
930 #[allow(clippy::cast_possible_truncation)]
931 {
932 h = (h_final as u32).clamp(min_h.max(1), max_h);
933 }
934 }
935 }
936 (w, h)
937}
938
939unsafe extern "C" fn cb_gui_get_size<P: PluginExport>(
940 ctx: *mut std::ffi::c_void,
941 w: *mut u32,
942 h: *mut u32,
943) {
944 unsafe {
945 if ctx.is_null() {
946 return;
947 }
948 // Lazily install the editor here too. Some AU validators
949 // (`auval`, Logic Pro's plugin validator) call `..._get_size`
950 // before `..._has_editor`, which is the canonical install
951 // site. Without the lazy install here those validators see
952 // `inst.editor == None` and silently receive a 0x0 view,
953 // which shows up as "plugin reports invalid size" in their
954 // reports.
955 let inst = &mut *ctx.cast::<AuInstance<P>>();
956 if inst.editor.is_none() {
957 inst.editor = inst.plugin.editor();
958 }
959 if let Some(ref editor) = inst.editor {
960 // AU is macOS-only; hosts embed our NSView inside a Cocoa
961 // container at logical-point coordinates and AppKit handles
962 // the Retina backing transparently. Report the editor size
963 // as-is - no scaling.
964 let (ew, eh) = editor.size();
965 *w = ew;
966 *h = eh;
967 }
968 }
969}
970
971unsafe extern "C" fn cb_gui_open<P: PluginExport>(
972 ctx: *mut std::ffi::c_void,
973 parent: *mut std::ffi::c_void,
974) {
975 // AU is macOS+iOS-only at runtime. Linux/Windows builds compile
976 // the wrapper crate for completeness (it's part of the workspace
977 // build matrix) but the body references AppKit / UIKit /
978 // AUEventListener APIs that don't exist off-Apple. Stubbing the
979 // body keeps the FFI table population in `register_au_inner`
980 // type-checking on every platform.
981 #[cfg(not(any(target_os = "macos", target_os = "ios")))]
982 {
983 let _ = ctx;
984 let _ = parent;
985 let _ = std::marker::PhantomData::<P>;
986 }
987 #[cfg(any(target_os = "macos", target_os = "ios"))]
988 unsafe {
989 let inst = &mut *ctx.cast::<AuInstance<P>>();
990 if let Some(ref mut editor) = inst.editor {
991 let params = inst.plugin.params_arc();
992 let plugin_ptr = SendPtr::new(&raw const inst.plugin);
993 let ctx_raw = SendPtr::new(ctx);
994 let params_for_set = params.clone();
995 let params_for_get = params.clone();
996 let params_for_plain = params.clone();
997 let params_for_fmt = params.clone();
998 let params_for_ctx = params.clone();
999 let pending_state_for_set = inst.pending_state.clone();
1000 let transport_slot = inst.transport_slot.clone();
1001 let ctx_for_begin = ctx_raw;
1002 let ctx_for_end = ctx_raw;
1003 // iOS AU v3 hosts the editor inside an .appex; v2's
1004 // `AUEventListener` doesn't exist there. Parameter
1005 // changes from the editor flow to the host directly
1006 // through the AUParameterTree's setter (handled by the
1007 // Swift shim). The begin/set/end closures are no-ops on
1008 // iOS so the plugin's editor code stays platform-agnostic.
1009 let context = PluginContext::from_closures(
1010 ClosureBridge {
1011 #[cfg(target_os = "macos")]
1012 begin_edit: Box::new(move |id| {
1013 // Broadcasts kAudioUnitEvent_BeginParameterChangeGesture
1014 // via AUEventListenerNotify so hosts (Logic, Live,
1015 // Reaper) group subsequent set_param calls into one
1016 // undo step and one automation gesture.
1017 truce_au_v2_host_begin_param_gesture(ctx_for_begin.as_ptr().cast_mut(), id);
1018 }),
1019 #[cfg(target_os = "ios")]
1020 begin_edit: Box::new(move |_id| {
1021 let _ = ctx_for_begin;
1022 }),
1023 #[cfg(target_os = "macos")]
1024 set_param: Box::new(move |id, value| {
1025 // One combined trait dispatch (set_normalized
1026 // + get_plain) instead of two - the
1027 // `#[derive(Params)]` impl can compute both in
1028 // a single match-arm walk.
1029 let plain =
1030 f32::from_f64(params_for_set.set_normalized_returning_plain(id, value));
1031 truce_au_v2_host_set_param(ctx_raw.as_ptr().cast_mut(), id, plain);
1032 }),
1033 #[cfg(target_os = "ios")]
1034 set_param: Box::new(move |id, value| {
1035 // No host-notify on iOS; just write the
1036 // normalised value through. The Swift shim
1037 // polls the parameter tree.
1038 let _ = ctx_raw;
1039 let _ = params_for_set.set_normalized_returning_plain(id, value);
1040 }),
1041 #[cfg(target_os = "macos")]
1042 end_edit: Box::new(move |id| {
1043 // Closes the gesture started by begin_edit so the
1044 // host commits the undo group / stops automation
1045 // recording.
1046 truce_au_v2_host_end_param_gesture(ctx_for_end.as_ptr().cast_mut(), id);
1047 }),
1048 #[cfg(target_os = "ios")]
1049 end_edit: Box::new(move |_id| {
1050 let _ = ctx_for_end;
1051 }),
1052 request_resize: Box::new(move |w, h| {
1053 // AU v2 has no host-driven resize API: the
1054 // host observes the plug-in's NSView frame
1055 // via AppKit and updates its container in
1056 // response. So `ctx.request_resize` here
1057 // routes back into the editor's own
1058 // `set_size`, which resizes the baseview
1059 // NSView; AppKit propagates the frame
1060 // change to the host as a notification.
1061 //
1062 // SAFETY: `ctx_raw` points at the live
1063 // `AuInstance<P>`. The closure runs on the
1064 // GUI thread, same as `cb_gui_open` which
1065 // installed it. `editor.set_size` on the
1066 // existing backends writes to an atomic
1067 // cell only - no aliasing UB even if the
1068 // editor's own `update()` holds a borrow
1069 // higher up the stack.
1070 if w == 0 || h == 0 {
1071 return false;
1072 }
1073 let inst = &mut *ctx_raw.as_ptr().cast_mut().cast::<AuInstance<P>>();
1074 inst.editor.as_mut().is_some_and(|e| e.set_size(w, h))
1075 }),
1076 get_param: Box::new(move |id| params_for_get.get_normalized(id).unwrap_or(0.0)),
1077 get_param_plain: Box::new(move |id| {
1078 params_for_plain.get_plain(id).unwrap_or(0.0)
1079 }),
1080 format_param: Box::new(move |id| {
1081 let plain = params_for_fmt.get_plain(id).unwrap_or(0.0);
1082 params_for_fmt
1083 .format_value(id, plain)
1084 .unwrap_or_else(|| format!("{plain:.1}"))
1085 }),
1086 get_meter: Box::new(move |id| {
1087 let plugin = plugin_ptr.get();
1088 plugin.get_meter(id)
1089 }),
1090 get_state: Box::new(move || {
1091 let plugin = plugin_ptr.get();
1092 plugin.save_state()
1093 }),
1094 set_state: Box::new(move |bytes| {
1095 // The editor sends RAW custom-state bytes -
1096 // exactly what `save_state()` emits and
1097 // `get_state` above returns - NOT a full
1098 // `serialize_state` envelope. Route them to the
1099 // plugin's `load_state` on the audio thread via
1100 // the same handoff queue the host load path uses
1101 // (the queue is what avoids aliasing
1102 // `process()`'s `&mut plugin`). No params ride
1103 // along: the editor mutates params through
1104 // `set_param`.
1105 let _ = pending_state_for_set.force_push(state::DeserializedState {
1106 params: Vec::new(),
1107 extra: Some(bytes),
1108 });
1109 }),
1110 transport: Box::new(move || transport_slot.read()),
1111 },
1112 params_for_ctx,
1113 );
1114 #[cfg(target_os = "macos")]
1115 let handle = RawWindowHandle::AppKit(parent);
1116 #[cfg(target_os = "ios")]
1117 let handle = RawWindowHandle::UiKit(parent);
1118 editor.open(handle, context);
1119 }
1120 }
1121}
1122
1123unsafe extern "C" fn cb_gui_close<P: PluginExport>(ctx: *mut std::ffi::c_void) {
1124 unsafe {
1125 let inst = &mut *ctx.cast::<AuInstance<P>>();
1126 if let Some(editor) = inst.editor.as_mut() {
1127 // Same boundary-protection as `cb_destroy`: any panic
1128 // during `editor.close()` (wgpu surface drop, baseview
1129 // window close, NSView removal) would otherwise cross
1130 // the FFI line and become an unhandled ObjC exception
1131 // in the host.
1132 let editor_ptr: *mut dyn truce_core::editor::Editor = editor.as_mut();
1133 let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| {
1134 (*editor_ptr).close();
1135 }));
1136 }
1137 // Keep the editor alive - just closed, not dropped.
1138 //
1139 // Dropping the editor here would synchronously deallocate its
1140 // baseview NSWindow + content NSView. Logic / Pro Tools tend
1141 // to call `gui_close` from inside their own NSTimer fire or
1142 // a `[CALayer display]` callback, both of which run inside an
1143 // implicit autorelease pool that's about to pop. If
1144 // `[NSTimer invalidate]` (which baseview's drop chain calls
1145 // via `WindowHandle::drop`) re-enters that pool's pop
1146 // sequence, the host crashes inside `objc_release` on a
1147 // freed `NSAutoreleasePool*`. The editor's `close()` has
1148 // already released the NSView contents and Metal resources;
1149 // the lightweight Rust struct that survives is reopened
1150 // in-place by the next `gui_open` call.
1151 }
1152}
1153
1154unsafe extern "C" {
1155 fn malloc(size: usize) -> *mut std::ffi::c_void;
1156 fn free(ptr: *mut std::ffi::c_void);
1157}
1158
1159// AU v2 host-side automation notifiers: gated to macOS because v2 is
1160// macOS-only. iOS uses AU v3 exclusively, where host notification
1161// goes through the parameter tree directly.
1162#[cfg(target_os = "macos")]
1163unsafe extern "C" {
1164 fn truce_au_v2_host_set_param(ctx: *mut std::ffi::c_void, param_id: u32, value: f32);
1165 fn truce_au_v2_host_begin_param_gesture(ctx: *mut std::ffi::c_void, param_id: u32);
1166 fn truce_au_v2_host_end_param_gesture(ctx: *mut std::ffi::c_void, param_id: u32);
1167}
1168
1169// ---------------------------------------------------------------------------
1170// Registration: called once from the export_au! macro
1171// ---------------------------------------------------------------------------
1172
1173/// Register the plugin with the AU system. Must be called once at load time
1174/// (typically from a constructor function generated by `export_au!`).
1175/// Host-facing AU display name. Reads `truce.toml`'s `au_name`
1176/// (baked into `PluginInfo` by `truce::plugin_info!`), falling back
1177/// to `PluginInfo::name`. The v3 host gets its display name out of
1178/// the appex's `Info.plist` (`AUNAME`, populated by
1179/// `cargo truce install --au3` from `au3_name`), not from this
1180/// function - `g_descriptor->name` only feeds the v2 bridge's
1181/// internal scanning responses, so the same value works for both
1182/// build flavours.
1183fn resolved_plugin_name(info: &truce_core::info::PluginInfo) -> &'static str {
1184 truce_core::info::resolve_name_override(info.au_name, info.name)
1185}
1186
1187pub fn register_au<P: PluginExport>() {
1188 // Called from the export macro's `extern "C" fn init()` static
1189 // initializer. Catch any panic so it doesn't cross the FFI
1190 // boundary and abort the host process.
1191 run_register::<P>("AU", || {
1192 let Some((num_inputs, num_outputs)) = default_io_channels::<P>() else {
1193 log_missing_bus_layout::<P>("AU");
1194 return;
1195 };
1196 register_au_inner::<P>(num_inputs, num_outputs);
1197 });
1198}
1199
1200fn register_au_inner<P: PluginExport>(num_inputs: u32, num_outputs: u32) {
1201 let info = P::info();
1202
1203 // Static metadata path: derive emits a `LazyLock`-cached
1204 // `Vec<ParamInfo>` so registration doesn't construct a plugin
1205 // instance just to read parameter shape. Hand-written
1206 // `PluginExport` impls without a `Params::param_infos_static`
1207 // override fall back to the historical
1208 // `Self::create().params().param_infos()` walk inside the trait
1209 // default - see `PluginExport::param_infos_static`.
1210 let param_infos = P::param_infos_static();
1211 let mut param_descs: Vec<AuParamDescriptor> = Vec::with_capacity(param_infos.len());
1212
1213 for pi in ¶m_infos {
1214 let cs = truce_core::wrapper::ParamCStrings::from_info(pi);
1215 param_descs.push(AuParamDescriptor {
1216 id: pi.id,
1217 name: cs.name.into_raw(),
1218 min: pi.range.min(),
1219 max: pi.range.max(),
1220 default_value: pi.default_plain,
1221 step_count: pi.range.step_count().map_or(0, std::num::NonZero::get),
1222 unit: cs.unit.into_raw(),
1223 group: cs.group.into_raw(),
1224 });
1225 }
1226
1227 let name = CString::new(resolved_plugin_name(&info)).unwrap_or_default();
1228 let vendor = CString::new(info.vendor).unwrap_or_default();
1229
1230 let bypass_param_id = param_infos
1231 .iter()
1232 .find(|pi| pi.flags.contains(ParamFlags::IS_BYPASS))
1233 .map_or(u32::MAX, |pi| pi.id);
1234
1235 // NoteEffect plugins (arpeggiators, chord generators) emit MIDI
1236 // back to the host. Instruments could in theory too but it's rare
1237 // and we don't want to advertise a "MIDI Out" port in every synth's
1238 // host UI without an explicit opt-in. Effects and analyzers never do.
1239 let has_midi_output = i32::from(matches!(info.category, PluginCategory::NoteEffect));
1240
1241 let descriptor = Box::leak(Box::new(AuPluginDescriptor {
1242 component_type: info.au_type,
1243 component_subtype: info.fourcc,
1244 component_manufacturer: info.au_manufacturer,
1245 name: name.into_raw(),
1246 vendor: vendor.into_raw(),
1247 version: 0x0001_0000, // 1.0.0
1248 num_inputs,
1249 num_outputs,
1250 bypass_param_id,
1251 has_midi_output,
1252 }));
1253
1254 let callbacks = Box::leak(Box::new(AuCallbacks {
1255 create: cb_create::<P>,
1256 destroy: cb_destroy::<P>,
1257 reset: cb_reset::<P>,
1258 process: cb_process::<P>,
1259 param_count: cb_param_count::<P>,
1260 param_get_value: cb_param_get_value::<P>,
1261 param_set_value: cb_param_set_value::<P>,
1262 param_format_value: cb_param_format_value::<P>,
1263 state_save: cb_state_save::<P>,
1264 state_load: cb_state_load::<P>,
1265 state_free: cb_state_free,
1266 output_event_count: cb_output_event_count::<P>,
1267 output_event_at: cb_output_event_at::<P>,
1268 output_sysex_count: cb_output_sysex_count::<P>,
1269 output_sysex_at: cb_output_sysex_at::<P>,
1270 gui_has_editor: cb_gui_has_editor::<P>,
1271 gui_get_size: cb_gui_get_size::<P>,
1272 gui_open: cb_gui_open::<P>,
1273 gui_close: cb_gui_close::<P>,
1274 gui_can_resize: cb_gui_can_resize::<P>,
1275 gui_set_size: cb_gui_set_size::<P>,
1276 factory_preset_count: cb_factory_preset_count::<P>,
1277 factory_preset_name: cb_factory_preset_name::<P>,
1278 factory_preset_load: cb_factory_preset_load::<P>,
1279 }));
1280
1281 let param_descs = param_descs.leak();
1282
1283 unsafe {
1284 ffi::truce_au_register(
1285 std::ptr::from_ref::<AuPluginDescriptor>(descriptor),
1286 std::ptr::from_ref::<AuCallbacks>(callbacks),
1287 param_descs.as_ptr(),
1288 len_u32(param_descs.len()),
1289 );
1290 }
1291}
1292
1293// ---------------------------------------------------------------------------
1294// export_au! macro
1295// ---------------------------------------------------------------------------
1296
1297/// Export an Audio Unit v3 plugin entry point.
1298///
1299/// Usage:
1300/// ```ignore
1301/// export_au!(MyPlugin);
1302/// ```
1303///
1304/// Where `MyPlugin` implements `PluginExport`.
1305#[macro_export]
1306macro_rules! export_au {
1307 ($plugin_type:ty) => {
1308 // macOS: register both AU v2 (`.component`) and AU v3 (`.appex`)
1309 // entry points. AU v2's factory delegates to the C shim.
1310 #[cfg(target_os = "macos")]
1311 mod _au_entry {
1312 use super::*;
1313
1314 /// Called by the constructor to init the plugin.
1315 #[unsafe(no_mangle)]
1316 pub extern "C" fn truce_au_init() {
1317 ::truce_au::register_au::<$plugin_type>();
1318 }
1319
1320 // AU v2 factory: delegates to au_v2_shim.c. The whole
1321 // `_au_entry` module is gated on `target_os = "macos"`
1322 // because v2 only exists on macOS, matching `build.rs`'s
1323 // `is_macos` gate on compiling au_v2_shim.c.
1324 unsafe extern "C" {
1325 fn truce_au_v2_factory_bridge(
1326 desc: *const ::std::ffi::c_void,
1327 ) -> *mut ::std::ffi::c_void;
1328 }
1329
1330 #[unsafe(no_mangle)]
1331 pub unsafe extern "C" fn TruceAUFactory(
1332 desc: *const ::std::ffi::c_void,
1333 ) -> *mut ::std::ffi::c_void {
1334 truce_au_v2_factory_bridge(desc)
1335 }
1336 }
1337 // iOS: AU v3 only. The Swift `AudioUnitFactory` /
1338 // `TruceAUAudioUnit` in the .appex bundle reads our exported
1339 // globals (g_callbacks / g_descriptor / ...) at runtime via
1340 // the dynamic symbol table; we just need `truce_au_init` to
1341 // run from the dylib constructor.
1342 #[cfg(target_os = "ios")]
1343 mod _au_entry {
1344 use super::*;
1345
1346 #[unsafe(no_mangle)]
1347 pub extern "C" fn truce_au_init() {
1348 ::truce_au::register_au::<$plugin_type>();
1349 }
1350 }
1351 };
1352}
1353
1354#[cfg(test)]
1355mod tests {
1356 use truce_core::SYSEX_POOL_PREALLOC;
1357 use truce_shim_types::AU_SHIM_TYPES_H;
1358
1359 #[test]
1360 fn sysex_pool_prealloc_matches_header() {
1361 // The Swift AU v3 template (`AudioUnitFactory.swift`)
1362 // reads `TRUCE_SYSEX_POOL_PREALLOC` from `au_shim_types.h`
1363 // to size its per-render `sysexOutScratch`. Confirm the C
1364 // macro still expands to the same value as the Rust const
1365 // - otherwise the scratch is either undersized (event
1366 // drops) or wasteful (memory bloat per AU instance).
1367 let needle = format!("#define TRUCE_SYSEX_POOL_PREALLOC ({SYSEX_POOL_PREALLOC})");
1368 let needle_paren = format!(
1369 "#define TRUCE_SYSEX_POOL_PREALLOC ({} * 1024)",
1370 SYSEX_POOL_PREALLOC / 1024,
1371 );
1372 assert!(
1373 AU_SHIM_TYPES_H.contains(&needle) || AU_SHIM_TYPES_H.contains(&needle_paren),
1374 "au_shim_types.h::TRUCE_SYSEX_POOL_PREALLOC must equal \
1375 truce_core::SYSEX_POOL_PREALLOC ({} bytes / {} KiB). \
1376 Looked for `{}` or `{}` in the header.",
1377 SYSEX_POOL_PREALLOC,
1378 SYSEX_POOL_PREALLOC / 1024,
1379 needle,
1380 needle_paren,
1381 );
1382 }
1383}