Debugging some FMOD interop stuff
March 1, 2023
Tagged: Programming C# FMOD
UPDATE: New fix at the end of the post
I had a weird issue in FMOD recently that I was struggling to find any information on so just capturing it here in hopes to help other people that run into something similar.
I recently set up callbacks in Unity for FMOD event instances. The code was much simpler that I thought it would be, and consisted of basically just creating a delegate
of the correct type and passing that to the event instance. Here’s the full code:
FMOD.Studio.EVENT_CALLBACK FMODCallback;
public void InitializeAudio() {
try {
FMODInstance = RuntimeManager.CreateInstance(Sound.FMODPath);
FMODCallback = FMODEventCallback;
FMODInstance.setCallback(FMODCallback);
}
catch (Exception) {
FMODInstance = RuntimeManager.CreateInstance(GameManager.AudioEvents.EMPTY.FMODPath);
}
}
[AOT.MonoPInvokeCallback(typeof(FMOD.Studio.EVENT_CALLBACK))]
FMOD.RESULT FMODEventCallback(FMOD.Studio.EVENT_CALLBACK_TYPE type, IntPtr _event, IntPtr parameterPtr) {
switch (type)
{
case FMOD.Studio.EVENT_CALLBACK_TYPE.SOUND_STOPPED:
case FMOD.Studio.EVENT_CALLBACK_TYPE.STOPPED:
if(State != AudioState.STOPPED) {
Debug.Log($"{Name} pool item was stopped internally, marking as stopped");
State = AudioState.STOPPED;
}
break;
}
return FMOD.RESULT.OK;
}
This worked fine for it’s normal use case. The reason I implemented callbacks was because we maintain a shim object that maps to internal FMOD instances to allow for a better API, but there were some things that would happen in FMOD independent to our own instance handing that we wanted to listen to (like audio stopping). So now when audio stops internally in FMOD, we just make sure our State
matches the latest. Cool. This worked.
But when I was switching scenes, the game would crash.
NullReferenceException: Object reference not set to an instance of an object
at Cantata.AudioPoolItem.FMODEventCallback (FMOD.Studio.EVENT_CALLBACK_TYPE type, System.IntPtr _event, System.IntPtr parameterPtr) [0x00001] in C:\Users\kyle\Workspace\cantata\Assets\Scripts\Audio\AudioPoolItem.cs:97
Line 97 here was the if
statement in the STOPPED
case.
I was also seeing these weird errors:
[FMOD] ShadowEventInstance::createProgrammerSound : Programmer sound callback is not set for instrument ''.
[FMOD] EventInstance::createProgrammerSoundImpl : Programmer sound callback for instrument '' returned no sound.
I was stumped. State
is an enum
so literally couldn’t be null. Inside the if
was just a debug statement and an assignment. What was nulling out?
So some context on when I was switching scenes. The issue that was happening was when a user tried to load a save inside an existing game, the game would crash. Loading fresh saves from the main menu was fine. So what was special about loading from inside a game?
Well for audio, the main thing we do is destroy all instances of audio we created for that match (so as to not persist them across matches). This has always worked prior to us doing a callback, but now them nulling out meant something was wonk with callback stuff. I looked around online and found basically nothing and was honestly almost stumped. What was even possible to be null?
Well…
See this line here?
[AOT.MonoPInvokeCallback(typeof(FMOD.Studio.EVENT_CALLBACK))]
This isn’t really documented in FMOD but using my own knowledge I have a pretty good guess - basically its a way to grab the function and bind it as a callback so as not to get stripped during app trimming for AOT compilation. I think. However, knowing that FMOD was needing to PInvoke for callbacks to its own native layer and thinking about that boundary got me thinking about the “state” of things that are possible. The managed layer in FMOD just holds a pointer to the native object, and we’re not literally cleaning up the native object (afaik you can’t even do that - you can release the pointer but that’s it).
So what if something happened where the native object and the managed object fell out of sync? Like, say, maybe if the managed object was GC’d after being destroyed for a scene switch, but FMOD still wanted to call back to that object after its native instance was destroyed? That could maybe result in a null reference
right? Maybe?
But again a function itself can’t be “null
” in that sense. So what’s the issue?
Well here’s the code that works without crashing:
[AOT.MonoPInvokeCallback(typeof(FMOD.Studio.EVENT_CALLBACK))]
FMOD.RESULT FMODEventCallback(FMOD.Studio.EVENT_CALLBACK_TYPE type, IntPtr _event, IntPtr parameterPtr) {
switch (type)
{
case FMOD.Studio.EVENT_CALLBACK_TYPE.SOUND_STOPPED:
case FMOD.Studio.EVENT_CALLBACK_TYPE.STOPPED:
if(State != AudioState.STOPPED) {
State = AudioState.STOPPED;
}
break;
}
return FMOD.RESULT.OK;
}
Do you even see the difference?
All I did was remove this line:
Debug.Log($"{Name} pool item was stopped internally, marking as stopped");
What? So here’s my best guess.
What I think was happening was that the managed object was being cleared out by me (calling ReleaseInstance()
). This worked as intended, but the instance callback function on the native
side was still trying to hit the callback function with a pointer. The function was pinned somehow in memory so that it could still get hit, BUT the object instance the callback function was “on” was null.
Because the object itself was null, the string (reference type!) variable Name
was resolving null and crashing. Interestingly enough, State is also on the object, but since it’s an enum
its a value type so is stored on the stack instead of the managed heap. I think!
Anyways, here’s the possible learning:
Don’t use reference type variables in the FMOD callback function.
Hope this helps people who run into something similar!
UPDATE:
So turns out my fix still wasn’t working in builds (but working in editor). I once again couldn’t figure out what wasn’t working. I searched around more for callback information, and found this useful thread that pointed out to these docs.
One thing that stood out to me was that their example callback function was static. So the function itself is pinned in memory instead of needing a possible GC’d object reference.
I changed my callback function to also be static, and bing bang boom, it works now. So here’s the better learning:
Make sure your callback is static.
Here’s the updated code. Only major change is needing to grab the event instance reference via the callback pointer, but it’s painless.
public void InitializeAudio()
{
try
{
FMODInstance = RuntimeManager.CreateInstance(Sound.FMODPath);
FMODInstance.setCallback(FMODEventCallback);
}
catch (Exception)
{
FMODInstance = RuntimeManager.CreateInstance(GameManager.AudioEvents.EMPTY.FMODPath);
}
}
[AOT.MonoPInvokeCallback(typeof(FMOD.Studio.EVENT_CALLBACK))]
static FMOD.RESULT FMODEventCallback(FMOD.Studio.EVENT_CALLBACK_TYPE type, IntPtr _event, IntPtr parameterPtr)
{
var instance = new FMOD.Studio.EventInstance(_event);
var poolItem = GameManager.AudioManager.GetPoolItemByInstance(instance);
if(poolItem != null)
{
switch (type)
{
case FMOD.Studio.EVENT_CALLBACK_TYPE.SOUND_STOPPED:
case FMOD.Studio.EVENT_CALLBACK_TYPE.STOPPED:
if(poolItem.State != AudioState.STOPPED)
{
poolItem.State = AudioState.STOPPED;
}
break;
}
}
return FMOD.RESULT.OK;
}
Hope this is it! Thanks for reading.
Published on March 1, 2023.
Tagged: Programming C# FMOD