Skip to content

Commit 6eae29e

Browse files
Copilotwinnerspiros
andcommitted
Isolate native bridge types from OsuGameAndroid to prevent eager type loading crash
Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/ee5b0ae0-1820-472e-ace3-3add459ff4cc Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com>
1 parent b41603a commit 6eae29e

2 files changed

Lines changed: 195 additions & 141 deletions

File tree

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. Licensed under the MIT Licence.
2+
// See the LICENCE file in the repository root for full licence text.
3+
4+
using System;
5+
using osu.Android.Native;
6+
using osu.Framework.Threading;
7+
using Debug = System.Diagnostics.Debug;
8+
9+
namespace osu.Android
10+
{
11+
/// <summary>
12+
/// Encapsulates all native bridge lifecycle management (Oboe audio, Vulkan probe).
13+
/// Kept in a SEPARATE class so that <see cref="OboeAudioBridge"/> and <see cref="VulkanProbe"/>
14+
/// types are only loaded by the runtime when this class is first accessed — NOT during
15+
/// <see cref="OsuGameAndroid"/> class initialization, which happens before the framework
16+
/// is ready and before native libraries are expected to be available.
17+
/// </summary>
18+
internal sealed class AndroidNativeBridgeManager : IDisposable
19+
{
20+
private OboeAudioBridge? oboeBridge;
21+
private VulkanProbe? vulkanProbe;
22+
private volatile bool disposed;
23+
24+
public void StartOboeBridge(Scheduler scheduler, Action<double> onLatencyMeasured)
25+
{
26+
if (oboeBridge != null) return;
27+
28+
try
29+
{
30+
oboeBridge = OboeAudioBridge.Create();
31+
32+
if (oboeBridge != null)
33+
{
34+
bool started = oboeBridge.Start();
35+
36+
if (started)
37+
{
38+
logOboeInfo();
39+
40+
// Latency is measured asynchronously by the audio callback.
41+
// Schedule a check after a short warm-up period to get a stable reading.
42+
scheduler.AddDelayed(() =>
43+
{
44+
if (oboeBridge == null) return;
45+
46+
double latency = oboeBridge.GetOutputLatencyMs();
47+
Debug.WriteLine($"[osu!] Oboe measured latency after warm-up: {latency:F1}ms");
48+
49+
if (latency > 0)
50+
onLatencyMeasured(latency);
51+
}, 2000);
52+
}
53+
else
54+
{
55+
Debug.WriteLine("[osu!] Oboe bridge created but failed to start");
56+
}
57+
}
58+
}
59+
catch (Exception e)
60+
{
61+
Debug.WriteLine($"[osu!] Oboe bridge init failed: {e.Message}");
62+
}
63+
}
64+
65+
public void StopOboeBridge()
66+
{
67+
oboeBridge?.Dispose();
68+
oboeBridge = null;
69+
Debug.WriteLine("[osu!] Oboe bridge stopped by user setting");
70+
}
71+
72+
public void StartVulkanProbe()
73+
{
74+
if (vulkanProbe != null) return;
75+
76+
try
77+
{
78+
vulkanProbe = VulkanProbe.Create();
79+
80+
if (vulkanProbe != null)
81+
logVulkanInfo();
82+
}
83+
catch (Exception e)
84+
{
85+
Debug.WriteLine($"[osu!] Vulkan probe init failed: {e.Message}");
86+
}
87+
}
88+
89+
public void StopVulkanProbe()
90+
{
91+
vulkanProbe?.Dispose();
92+
vulkanProbe = null;
93+
Debug.WriteLine("[osu!] Vulkan probe stopped by user setting");
94+
}
95+
96+
/// <summary>
97+
/// Returns the measured audio output latency in milliseconds via the Oboe bridge,
98+
/// or -1 if unavailable.
99+
/// </summary>
100+
public double GetMeasuredAudioLatencyMs()
101+
{
102+
return oboeBridge?.GetOutputLatencyMs() ?? -1;
103+
}
104+
105+
private void logVulkanInfo()
106+
{
107+
if (vulkanProbe == null) return;
108+
109+
int ver = vulkanProbe.ApiVersion;
110+
int major = (ver >> 22) & 0x3FF;
111+
int minor = (ver >> 12) & 0x3FF;
112+
int patch = ver & 0xFFF;
113+
114+
Debug.WriteLine($"[osu!] Vulkan GPU: available={vulkanProbe.IsAvailable}, "
115+
+ $"API={major}.{minor}.{patch}, "
116+
+ $"swapchain={vulkanProbe.SupportsSwapchain}, "
117+
+ $"mailbox={vulkanProbe.SupportsMailboxPresentMode}, "
118+
+ $"VRAM={vulkanProbe.DeviceLocalMemoryMB}MB, "
119+
+ $"queueFamilies={vulkanProbe.QueueFamilyCount}, "
120+
+ $"dedicatedCompute={vulkanProbe.HasDedicatedComputeQueue}, "
121+
+ $"dedicatedTransfer={vulkanProbe.HasDedicatedTransferQueue}");
122+
}
123+
124+
private void logOboeInfo()
125+
{
126+
if (oboeBridge == null) return;
127+
128+
Debug.WriteLine($"[osu!] Oboe audio: active={oboeBridge.IsActive}, "
129+
+ $"api={(oboeBridge.IsAAudio ? "AAudio" : "OpenSLES")}, "
130+
+ $"sampleRate={oboeBridge.SampleRate}Hz, "
131+
+ $"burst={oboeBridge.FramesPerBurst}frames, "
132+
+ $"bufferSize={oboeBridge.BufferSizeInFrames}frames");
133+
}
134+
135+
public void Dispose()
136+
{
137+
if (disposed) return;
138+
139+
disposed = true;
140+
141+
oboeBridge?.Dispose();
142+
oboeBridge = null;
143+
144+
vulkanProbe?.Dispose();
145+
vulkanProbe = null;
146+
}
147+
}
148+
}

osu.Android/OsuGameAndroid.cs

Lines changed: 47 additions & 141 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
using Android.Content.PM;
88
using Android.Views;
99
using Microsoft.Maui.Devices;
10-
using osu.Android.Native;
1110
using osu.Framework.Allocation;
1211
using osu.Framework.Bindables;
1312
using osu.Framework.Development;
@@ -38,8 +37,11 @@ public partial class OsuGameAndroid : OsuGame
3837
private readonly Bindable<bool> vulkanProbeEnabled = new Bindable<bool>();
3938
private readonly BindableDouble audioOffset = new BindableDouble();
4039

41-
private OboeAudioBridge? oboeBridge;
42-
private VulkanProbe? vulkanProbe;
40+
/// <summary>
41+
/// Native bridge manager — kept as a separate type so OboeAudioBridge / VulkanProbe
42+
/// types are never loaded during OsuGameAndroid class initialisation.
43+
/// </summary>
44+
private AndroidNativeBridgeManager? nativeBridges;
4345

4446
public OsuGameAndroid(OsuGameActivity activity)
4547
: base(null)
@@ -116,18 +118,49 @@ protected override void LoadComplete()
116118

117119
lowLatencyAudio.BindValueChanged(e =>
118120
{
119-
if (e.NewValue)
120-
startOboeBridge();
121-
else
122-
stopOboeBridge();
121+
try
122+
{
123+
nativeBridges ??= new AndroidNativeBridgeManager();
124+
125+
if (e.NewValue)
126+
{
127+
nativeBridges.StartOboeBridge(Scheduler, latency =>
128+
{
129+
// Only auto-suggest when the user hasn't already configured a manual offset.
130+
if (Math.Abs(audioOffset.Value) >= 0.01)
131+
return;
132+
133+
double suggested = Math.Clamp(-latency, audioOffset.MinValue, audioOffset.MaxValue);
134+
audioOffset.Value = suggested;
135+
Debug.WriteLine($"[osu!] Audio offset auto-suggested: {suggested:F1}ms (hardware latency={latency:F1}ms)");
136+
});
137+
}
138+
else
139+
{
140+
nativeBridges.StopOboeBridge();
141+
}
142+
}
143+
catch (Exception ex)
144+
{
145+
Debug.WriteLine($"[osu!] Failed to toggle Oboe bridge: {ex.Message}");
146+
}
123147
}, true);
124148

125149
vulkanProbeEnabled.BindValueChanged(e =>
126150
{
127-
if (e.NewValue)
128-
startVulkanProbe();
129-
else
130-
stopVulkanProbe();
151+
try
152+
{
153+
nativeBridges ??= new AndroidNativeBridgeManager();
154+
155+
if (e.NewValue)
156+
nativeBridges.StartVulkanProbe();
157+
else
158+
nativeBridges.StopVulkanProbe();
159+
}
160+
catch (Exception ex)
161+
{
162+
Debug.WriteLine($"[osu!] Failed to toggle Vulkan probe: {ex.Message}");
163+
}
131164
}, true);
132165

133166
// Apply unbuffered touch dispatch (deferred from Activity lifecycle to avoid early crash).
@@ -203,137 +236,13 @@ private void selectHighestRefreshRate()
203236
}
204237
}
205238

206-
private void startOboeBridge()
207-
{
208-
if (oboeBridge != null) return;
209-
210-
try
211-
{
212-
oboeBridge = OboeAudioBridge.Create();
213-
214-
if (oboeBridge != null)
215-
{
216-
bool started = oboeBridge.Start();
217-
218-
if (started)
219-
{
220-
// Log basic stream info immediately.
221-
logOboeInfo();
222-
223-
// Latency is measured asynchronously by the audio callback.
224-
// Schedule a check after a short warm-up period to get a stable reading
225-
// and apply the auto-suggested audio offset if appropriate.
226-
Scheduler.AddDelayed(applyMeasuredLatencyOffset, 2000);
227-
}
228-
else
229-
{
230-
Debug.WriteLine("[osu!] Oboe bridge created but failed to start");
231-
}
232-
}
233-
}
234-
catch (Exception e)
235-
{
236-
Debug.WriteLine($"[osu!] Oboe bridge init failed: {e.Message}");
237-
}
238-
}
239-
240-
private void stopOboeBridge()
241-
{
242-
oboeBridge?.Dispose();
243-
oboeBridge = null;
244-
Debug.WriteLine("[osu!] Oboe bridge stopped by user setting");
245-
}
246-
247-
private void startVulkanProbe()
248-
{
249-
if (vulkanProbe != null) return;
250-
251-
try
252-
{
253-
vulkanProbe = VulkanProbe.Create();
254-
255-
if (vulkanProbe != null)
256-
{
257-
logVulkanInfo();
258-
}
259-
}
260-
catch (Exception e)
261-
{
262-
Debug.WriteLine($"[osu!] Vulkan probe init failed: {e.Message}");
263-
}
264-
}
265-
266-
private void stopVulkanProbe()
267-
{
268-
vulkanProbe?.Dispose();
269-
vulkanProbe = null;
270-
Debug.WriteLine("[osu!] Vulkan probe stopped by user setting");
271-
}
272-
273-
private void logVulkanInfo()
274-
{
275-
if (vulkanProbe == null) return;
276-
277-
int ver = vulkanProbe.ApiVersion;
278-
int major = (ver >> 22) & 0x3FF;
279-
int minor = (ver >> 12) & 0x3FF;
280-
int patch = ver & 0xFFF;
281-
282-
Debug.WriteLine($"[osu!] Vulkan GPU: available={vulkanProbe.IsAvailable}, "
283-
+ $"API={major}.{minor}.{patch}, "
284-
+ $"swapchain={vulkanProbe.SupportsSwapchain}, "
285-
+ $"mailbox={vulkanProbe.SupportsMailboxPresentMode}, "
286-
+ $"VRAM={vulkanProbe.DeviceLocalMemoryMB}MB, "
287-
+ $"queueFamilies={vulkanProbe.QueueFamilyCount}, "
288-
+ $"dedicatedCompute={vulkanProbe.HasDedicatedComputeQueue}, "
289-
+ $"dedicatedTransfer={vulkanProbe.HasDedicatedTransferQueue}");
290-
}
291-
292-
private void logOboeInfo()
293-
{
294-
if (oboeBridge == null) return;
295-
296-
Debug.WriteLine($"[osu!] Oboe audio: active={oboeBridge.IsActive}, "
297-
+ $"api={(oboeBridge.IsAAudio ? "AAudio" : "OpenSLES")}, "
298-
+ $"sampleRate={oboeBridge.SampleRate}Hz, "
299-
+ $"burst={oboeBridge.FramesPerBurst}frames, "
300-
+ $"bufferSize={oboeBridge.BufferSizeInFrames}frames");
301-
}
302-
303-
/// <summary>
304-
/// Called after a warm-up delay to read the stable measured latency and apply it
305-
/// as an auto-suggested audio offset when the user hasn't set a manual value.
306-
/// </summary>
307-
private void applyMeasuredLatencyOffset()
308-
{
309-
if (oboeBridge == null) return;
310-
311-
double latency = oboeBridge.GetOutputLatencyMs();
312-
313-
Debug.WriteLine($"[osu!] Oboe measured latency after warm-up: {latency:F1}ms");
314-
315-
if (latency <= 0)
316-
return;
317-
318-
// Only auto-suggest when the user hasn't already configured a manual offset.
319-
// Use a small epsilon to safely compare against the default value of 0.
320-
if (Math.Abs(audioOffset.Value) >= 0.01)
321-
return;
322-
323-
// The audio offset compensates for hardware output delay: if audio arrives
324-
// 20 ms late, we need to set the offset to -20 ms so osu! plays notes earlier.
325-
double suggested = Math.Clamp(-latency, audioOffset.MinValue, audioOffset.MaxValue);
326-
audioOffset.Value = suggested;
327-
Debug.WriteLine($"[osu!] Audio offset auto-suggested: {suggested:F1}ms (hardware latency={latency:F1}ms)");
328-
}
329-
330239
/// <summary>
331240
/// Returns the measured audio output latency in milliseconds via the Oboe bridge,
332241
/// or -1 if unavailable. Can be used to auto-suggest audio offset calibration.
333242
/// </summary>
334243
public double GetMeasuredAudioLatencyMs()
335244
{
336-
return oboeBridge?.GetOutputLatencyMs() ?? -1;
245+
return nativeBridges?.GetMeasuredAudioLatencyMs() ?? -1;
337246
}
338247

339248
protected override void ScreenChanged(IOsuScreen? current, IOsuScreen? newScreen)
@@ -393,11 +302,8 @@ protected override void Dispose(bool isDisposing)
393302
{
394303
base.Dispose(isDisposing);
395304

396-
oboeBridge?.Dispose();
397-
oboeBridge = null;
398-
399-
vulkanProbe?.Dispose();
400-
vulkanProbe = null;
305+
nativeBridges?.Dispose();
306+
nativeBridges = null;
401307
}
402308

403309
private class AndroidBatteryInfo : BatteryInfo

0 commit comments

Comments
 (0)