Skip to content

Commit 5596889

Browse files
Merge pull request #17 from RoyTheunissen/fix/timeline-marker-event-crashes
Fixed timeline marker events causing a crash.
2 parents f1f1894 + 26b3636 commit 5596889

File tree

3 files changed

+150
-8
lines changed

3 files changed

+150
-8
lines changed

Runtime/Events/FmodAudioPlayback.cs

Lines changed: 140 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
using System;
2+
using System.Collections.Concurrent;
3+
using System.Collections.Generic;
24
using System.IO;
35
using System.Runtime.InteropServices;
46
using FMOD;
@@ -29,6 +31,29 @@ public bool IsOneshot
2931
/// </summary>
3032
public abstract class FmodAudioPlayback : FmodAudioPlaybackBase, IAudioPlayback
3133
{
34+
// ------------------------------------------------------------------------------------------------------------
35+
// Hideous timeline callback functionality. I do not approve, but this is the way FMOD says it needs to be done:
36+
// https://www.fmod.com/docs/2.03/unity/examples-timeline-callbacks.html
37+
// This class exists so that it can be pinned in memory, which in turn is then passed along to a *static*
38+
// callback method for timeline events *that happens on a separate thread, not the Unity main thread*. Then
39+
// we grab this object, queue up the timeline markers that are reached, and then on the main thread,
40+
// on the next Update, the playback instance will fire all the timeline events that were buffered, to ensure
41+
// that the callbacks happen on the main Unity thread. This thing with pinning the class in memory is not
42+
// necessary to get it to work in the editor, but it IS necessary to get it to work in IL2CPP builds.
43+
// So while this may all look very "un-C#" and bloated and unnecessary, I assure you that every part of this
44+
// is necessary for FMOD's code to work.
45+
46+
// Variables that are modified in the callback need to be part of a seperate class.
47+
// This class needs to be 'blittable' otherwise it can't be pinned in memory.
48+
private class TimelineInfo
49+
{
50+
public readonly ConcurrentQueue<string> TimelineMarkersReached = new();
51+
}
52+
private TimelineInfo timelineInfo;
53+
private GCHandle timelineInfoHandle;
54+
private EVENT_CALLBACK timelineEventCallback;
55+
// ------------------------------------------------------------------------------------------------------------
56+
3257
public delegate void TimelineMarkerReachedHandler(FmodAudioPlayback playback, string markerName);
3358
private int timelineMarkerListenerCount;
3459
private event TimelineMarkerReachedHandler timelineMarkerReachedEvent;
@@ -39,19 +64,36 @@ public event TimelineMarkerReachedHandler TimelineMarkerReachedEvent
3964
timelineMarkerReachedEvent += value;
4065
timelineMarkerListenerCount++;
4166

42-
if (timelineMarkerListenerCount == 1)
43-
Instance.setCallback(OnTimelineMarkerReached, EVENT_CALLBACK_TYPE.TIMELINE_MARKER);
67+
UpdateTimelineMarkerCallbackState();
4468
}
4569
remove
4670
{
4771
timelineMarkerReachedEvent -= value;
4872
timelineMarkerListenerCount--;
4973

50-
if (timelineMarkerListenerCount == 0)
51-
Instance.setCallback(null, EVENT_CALLBACK_TYPE.TIMELINE_MARKER);
74+
UpdateTimelineMarkerCallbackState();
5275
}
5376
}
5477

78+
[NonSerialized] private bool hasRegisteredTimelineMarkerReachedCallback;
79+
80+
public void Update()
81+
{
82+
if (hasRegisteredTimelineMarkerReachedCallback)
83+
FireBufferedTimelineMarkerEvents();
84+
}
85+
86+
private void FireBufferedTimelineMarkerEvents()
87+
{
88+
// Timeline marker callbacks happen on a separate FMOD audio thread, so to safely pass it along to Unity,
89+
// we need to buffer the timeline marker events and fire them on the next update of the main thread.
90+
while (timelineInfo.TimelineMarkersReached.TryDequeue(out string id))
91+
{
92+
timelineMarkerReachedEvent?.Invoke(this, id);
93+
}
94+
}
95+
96+
5597
public void Play(EventDescription eventDescription, Transform source)
5698
{
5799
eventDescription.getPath(out string path);
@@ -101,6 +143,8 @@ public override void Cleanup()
101143
timelineMarkerReachedEvent = null;
102144
timelineMarkerListenerCount = 0;
103145

146+
UpdateTimelineMarkerCallbackState(true);
147+
104148
if (Instance.isValid())
105149
{
106150
Instance.setCallback(null, EVENT_CALLBACK_TYPE.TIMELINE_MARKER);
@@ -117,6 +161,43 @@ public override void Cleanup()
117161

118162
FmodSyntaxSystem.UnregisterActiveEventPlayback(this);
119163
}
164+
165+
private void UpdateTimelineMarkerCallbackState(bool forceRemove = false)
166+
{
167+
bool shouldRegisterTimelineMarkerReachedCallback = timelineMarkerListenerCount > 0;
168+
if (forceRemove)
169+
shouldRegisterTimelineMarkerReachedCallback = false;
170+
171+
if (!hasRegisteredTimelineMarkerReachedCallback && shouldRegisterTimelineMarkerReachedCallback)
172+
{
173+
// -----------------------------------------------------------------------------------------------------
174+
// Timeline callback code (see the comment section at the top of this file)
175+
timelineInfo = new TimelineInfo();
176+
177+
// Explicitly create the delegate object and assign it to a member so it doesn't get freed
178+
// by the garbage collected while it's being used
179+
// NOTE: The documentation of 2.00 does this but 2.03 does not, but I am finding a very nasty hard
180+
// editor crash that seemingly only occurs when this delegate object is not created / used,
181+
// so do not remove the delegate object that wraps the method...
182+
timelineEventCallback = new EVENT_CALLBACK(OnTimelineMarkerReached);
183+
184+
// Pin the class that will store the data modified during the callback
185+
timelineInfoHandle = GCHandle.Alloc(timelineInfo);
186+
// Pass the object through the userdata of the instance
187+
Instance.setUserData(GCHandle.ToIntPtr(timelineInfoHandle));
188+
189+
Instance.setCallback(timelineEventCallback, EVENT_CALLBACK_TYPE.TIMELINE_MARKER);
190+
// -----------------------------------------------------------------------------------------------------
191+
}
192+
else if (hasRegisteredTimelineMarkerReachedCallback && !shouldRegisterTimelineMarkerReachedCallback)
193+
{
194+
Instance.setCallback(null, EVENT_CALLBACK_TYPE.TIMELINE_MARKER);
195+
Instance.setUserData(IntPtr.Zero);
196+
timelineInfoHandle.Free();
197+
}
198+
199+
hasRegisteredTimelineMarkerReachedCallback = shouldRegisterTimelineMarkerReachedCallback;
200+
}
120201

121202
/// <summary>
122203
/// Fluid method for subscribing to timeline events so you don't have to save the playback to a variable first
@@ -138,14 +219,65 @@ public FmodAudioPlayback UnsubscribeFromTimelineMarkerReachedEvent(TimelineMarke
138219
return this;
139220
}
140221

141-
private RESULT OnTimelineMarkerReached(EVENT_CALLBACK_TYPE type, IntPtr @event, IntPtr parameterPtr)
222+
// -------------------------------------------------------------------------------------------------------------
223+
// Awful FMOD timeline callback code. Do not change it too much, it is written in this really bizarre way
224+
// for very complicated and undocumented reasons and changing small things is likely to break things.
225+
[AOT.MonoPInvokeCallback(typeof(FMOD.Studio.EVENT_CALLBACK))]
226+
private static RESULT OnTimelineMarkerReached(EVENT_CALLBACK_TYPE type, IntPtr instancePtr, IntPtr parameterPtr)
142227
{
143-
TIMELINE_MARKER_PROPERTIES parameter = (TIMELINE_MARKER_PROPERTIES)Marshal.PtrToStructure(
144-
parameterPtr, typeof(TIMELINE_MARKER_PROPERTIES));
228+
EventInstance instance = new(instancePtr);
229+
230+
// Retrieve the user data
231+
IntPtr timelineInfoPtr;
232+
RESULT result = instance.getUserData(out timelineInfoPtr);
233+
if (result != RESULT.OK)
234+
{
235+
Debug.LogError("Timeline Callback error: " + result);
236+
return RESULT.OK;
237+
}
238+
239+
if (timelineInfoPtr == IntPtr.Zero)
240+
{
241+
Debug.LogError("Bogus Timeline Marker reached: timeline info pointer was NULL.");
242+
return RESULT.OK;
243+
}
244+
245+
// Get the object to store beat and marker details
246+
GCHandle timelineHandle = GCHandle.FromIntPtr(timelineInfoPtr);
247+
TimelineInfo timelineInfo = (TimelineInfo)timelineHandle.Target;
248+
249+
if (timelineInfo == null)
250+
{
251+
Debug.LogError("Bogus Timeline Marker reached: timeline handle could not be cast to timeline handle.");
252+
return RESULT.OK;
253+
}
254+
255+
string id = string.Empty;
256+
switch (type)
257+
{
258+
// Unused
259+
//case FMOD.Studio.EVENT_CALLBACK_TYPE.TIMELINE_BEAT:
260+
261+
case FMOD.Studio.EVENT_CALLBACK_TYPE.TIMELINE_MARKER:
262+
{
263+
TIMELINE_MARKER_PROPERTIES parameter = (FMOD.Studio.TIMELINE_MARKER_PROPERTIES)Marshal
264+
.PtrToStructure(parameterPtr, typeof(FMOD.Studio.TIMELINE_MARKER_PROPERTIES));
265+
266+
id = parameter.name;
267+
break;
268+
}
269+
270+
case FMOD.Studio.EVENT_CALLBACK_TYPE.DESTROYED:
271+
// Now the event has been destroyed, unpin the timeline memory so it can be garbage collected
272+
timelineHandle.Free();
273+
break;
274+
}
145275

146-
timelineMarkerReachedEvent?.Invoke(this, parameter.name);
276+
// Buffer the timeline marker event so we can handle it on the main thread.
277+
timelineInfo.TimelineMarkersReached.Enqueue(id);
147278

148279
return RESULT.OK;
149280
}
281+
// -------------------------------------------------------------------------------------------------------------
150282
}
151283
}

Runtime/FmodSyntaxSystem.cs

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,9 +73,17 @@ public static void UnregisterActiveSnapshotPlayback(FmodSnapshotPlayback playbac
7373
/// <summary>
7474
/// Culls playbacks that are no longer necessary. You should perform this logic continuously.
7575
/// You can either call this or use the ActivePlaybacks list / playback callbacks to do it in your own system.
76+
/// NOTE: This now does more than just cull playbacks, and functions more like an Update method.
77+
/// In a newer version of the package, this method will be renamed to Update.
7678
/// </summary>
7779
public static void CullPlaybacks()
7880
{
81+
// Update active event playbacks (for dispatching buffered timeline marker events on the main thread)
82+
for (int i = 0; i < activeEventPlaybacks.Count; i++)
83+
{
84+
activeEventPlaybacks[i].Update();
85+
}
86+
7987
// Cull any events that are ready to be cleaned up.
8088
for (int i = activeEventPlaybacks.Count - 1; i >= 0; i--)
8189
{

Runtime/IAudioPlayback.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,5 +19,7 @@ public interface IAudioPlayback : IFmodPlayback
1919
bool IsOneshot { get; }
2020
float NormalizedProgress { get; }
2121
float Volume { get; }
22+
23+
public delegate void AudioClipGenericEventHandler(IAudioPlayback audioPlayback, string eventId);
2224
}
2325
}

0 commit comments

Comments
 (0)