What is the least awful way to do this in Rust?
I've got some DSP code - audio compression (the sonic dynamic compression kind, not the data compression kind) written in Rust, and am attempting to wire this up to Apple's AudioUnit API so it can run as a plug-in in Logic and other music/audio composition apps.
At this point, I've done a decent amount of Rust programming over the last few years, but nearly all of it has been safe rust - much less experience with the right way to do things that necessitate unsafe operations.
Plumbing it to the Apple's API starting from their Xcode template AudioUnit project is done - so there are two Rust libraries - one for the UI code (just calls some number crunching code on the Rust side to render some curves graphically) using uniffi and for the DSP code (which Apple's template implements as C++ called via Objective C called by Swift for performance reasons) some straight-up extern "C" calls and cbindgen.
This was a multi-day nightmare of dealing with underdocumented, opaque build tools and weird quirks - like, Apple's universal-binary xcframework's module map files clobbering each other (solution: Don't have more than one library that tries to be a C++ module, and for others, create an Objective C bridging header (you don't want to know); header files which will be interpreted as both C++ headers and Objective C headers, and having to stitch #ifdefs for that into the generated bindings. Anyway, it was hellish, but it now builds and links and does what it's supposed to.
The technical problem is twofold, around mutation and safety:
- An AudioUnit has two sets of inputs:
- Configuration parameters - like gain or compression ratio or threshold - generally 32-bit floating point values - which are mutable and can change on the fly during processing (like, a DAW automates a gain fade-in)
- Buffers of audio - input is a
*const f32pointer, output is a*mut f32pointer and a length which is the same for both- There's some other stuff that comes with that, like sample rate, but that's straightforward pass-by-value stuff
- Some state needs to be held on the Rust side between calls from the C++ side - it's being handed buffers of audio during live audio playback - and then dropped when the C++ side of things is being torn down
- This means there's a
structon the Rust side that holds the current state of processing - stuff like number of samples since the last sample was seen whoseabs()was > n - that needs to remain intact between calls across the language boundary (and needs to be at least internally mutable), held until a call to drop that state. I don't forsee a magical way to implement this stuff, and am fine with it being old-school 1990s "here's my handle to this thing I'm done with" - maybe there's some way to expose the actual Rust structs (which have a ferocious generic signature) as C++ structs, but if it could be done at all, that seems like it could go wrong far more easily than something ugly that looks like 1990s Windows COM programming with opaque handles.
- This means there's a
To reiterate: I did not design this API, but I have to live within it (and with the real-time constraints and parameter mutation requirements, it's a pretty reasonable one).
I don't have any illusions that this is going to be entirely safe Rust, but it would be nice to minimize the risks and have the result be sound, and carefully manage the cases where state has to be held on either side of the language boundary - and this is bordering on hard-real-time code, so just wrap everything in a lock (which won't be shared across both languages) is a non-starter. The good news is, none of this involves both languages mutating anything - parameters mutate on the C++ side, the output buffer mutates on the Rust side, and that's it.
Re problem 1: For parameters changing on the fly, on the C++ side I've got
struct alignas(8) {
float mThreshold = 0.375;
float mLimit = 0.7;
float mPower = 0.9;
unsigned long mRelease = 90;
} params;
with mutations on the C++ side preceded by std::atomic_thread_fence(std::memory_order_release) - and on both Intel and Apple Silicon, aligned 32-bit reads and writes are atomic. On the Rust side, there are a few layers of wrapping of the pointers to members of that struct - the bones of which look like
struct RawFloatParameter {
ptr: *const std::ffi::c_float,
}
impl RawFloatParameter {
pub fn get_raw(&self) -> std::ffi::c_float {
std::sync::atomic::fence(std::sync::atomic::Ordering::AcqRel);
let result = unsafe { *self.ptr };
result
}
}
#[allow(private_interfaces)]
pub enum FloatParameter {
Raw(RawFloatParameter),
Safe(std::ffi::c_float),
}
and so forth for each type I need it for, generated by a macro_rules block.
Is there a better way to do this? Should it be using UnsafePointer (values are read on-the-fly and I don't see how the possibility of LLVM assuming it could cache the value and reuse it, but who knows)? AtomicPtr appears to be about changing the thing pointed to atomically (which won't happen), not about the value changing atomically - I think?
It seems like *const is right for the pointer on the Rust side - yes, it can be mutated by the C++ side, but Rust code should not ever mutate it, so ruling that out seems like a good idea. But maybe a mutable pointer is necessary to advise the compiler that the pointed-to value really can change even though it never will from the Rust side?
For problem 2, keeping a struct allocated on the Rust side until a call comes to explicitly discard it: This part I haven't implemented anything for yet, and it seems like there are myriad options with different quirks. Some incantation of Box::leak and ManuallyDrop?
I guess the most dirt-simple implementation of it would be to allocate and Box::leak the struct (and Pin? It's not clear to me how it could be moved after that), and just hand the pointer back to the C++ code to retain, and in its destructor, pass it back in and use drop_in_place to drop it?
Or something more complicated involving a TreeMap<SomeOpaqueIntegerHandler, ThingThatDoesTheWork> which would make it possible to detect if the inbound pointer was garbage (think of ThingThatDoesTheWork as essentially a pointer to a function - here are your input and output buffer pointers and their length, however it gets handed back and forth across the language boundary).
Or something else?
On the one hand, yes it involves a bunch of unsafe stuff; on the other hand, this sort of thing is what unsafe is for.
I will say that Rust is a phenomenal language for DSP programming - I can do things like use const generics for the number of channels and make it a compile-time error to miswire a chain of things that process audio, and things like that. And the performance of the result is amazing.