Callbacks from C# without crackling/popping

I’m using FMOD and FMOD Studio from C# without particular issues, but I ended up needing to add a custom DSP callback.

If the callback is written in C#, as expected, I get occasional pops/crackling (from GC stop-the-world pauses) and audio drops out if I pause to break into the debugger, since it suspends all my managed code, as expected.

I solved this on Windows by porting the DSP callback to C++. I can take the DSP callback and pass it to FMOD like this:

// Pin buffer used by DSP callback
hCollectorBuffer = GCHandle.Alloc(_WaveformSamples, GCHandleType.Pinned);
// Get function pointer to DSP callback from C++, in 'callback' local
Collector_Init(hCollectorBuffer.AddrOfPinnedObject(), _WaveformSamples.Length, out var callback);
// Create delegate for the C++ DSP callback to pass to FMOD via marshaling
CallbackDelegate = Marshal.GetDelegateForFunctionPointer<DSP_READ_CALLBACK>(callback);
// Maintain a handle to the delegate so it's not collected
hCallbackDelegate = GCHandle.Alloc(CallbackDelegate);

// Populate DSP description
var nameBytes = new byte[32];
var name = "Collector\0";
Encoding.UTF8.GetBytes(name, 0, name.Length, nameBytes, 0);
var dspDesc = new DSP_DESCRIPTION {
    name = nameBytes,
    // This part sucks. We're round-tripping the C++ DSP callback through a Delegate wrapper.
    read = CallbackDelegate,
    numinputbuffers = 1,
    numoutputbuffers = 0
};
// Create DSP from description
AssertOk(FMod.System.createDSP(ref dspDesc, out FMod.CollectorDsp));

However, when running under Mono (on Steam Deck), there’s a problem: C++ function pointers don’t round-trip through delegates there. A managed wrapper gets passed to FMod, so the audio crackles/pops a lot on Steam Deck. I’ve filed an issue report about it so it may eventually get fixed in mono, but I was curious whether anyone had suggestions for a clean workaround.

Right now the workaround I’ve come up with is to define my own version of DSP_DESCRIPTION that uses IntPtr instead of delegates, and that seems to work. But it means if DSP_DESCRIPTION or createDSP ever change I’ll need to manually update my custom pinvoke, which isn’t great for maintenance:


            [DllImport(FMOD.VERSION.dll)]
            public static extern RESULT FMOD5_System_CreateDSP (IntPtr system, ref DSP_DESCRIPTION_P description, out IntPtr dsp);

            [StructLayout(LayoutKind.Sequential)]
            public struct DSP_DESCRIPTION_P
            {
                public uint                           pluginsdkversion;
                [MarshalAs(UnmanagedType.ByValArray, SizeConst = 32)]
                public byte[]                         name;
                public uint                           version;
                public int                            numinputbuffers;
                public int                            numoutputbuffers;
                public IntPtr                         create;
                public IntPtr                         release;
                public IntPtr                         reset;
                public IntPtr                         read;
                public IntPtr                         process;
                public IntPtr                         setposition;

                public int                            numparameters;
                public IntPtr                         paramdesc;
                public IntPtr                         setparameterfloat;
                public IntPtr                         setparameterint;
                public IntPtr                         setparameterbool;
                public IntPtr                         setparameterdata;
                public IntPtr                         getparameterfloat;
                public IntPtr                         getparameterint;
                public IntPtr                         getparameterbool;
                public IntPtr                         getparameterdata;
                public IntPtr                         shouldiprocess;
                public IntPtr                         userdata;

                public IntPtr                         sys_register;
                public IntPtr                         sys_deregister;
                public IntPtr                         sys_mix;
            }

It seems like this would probably be an issue for Unity developers as well but I couldn’t find any existing threads discussing this problem. Maybe IL2CPP is immune to this issue and most Unity developers are using that instead of Mono?