Skip to content

Commit 9cf4b0c

Browse files
Fix Oboe audio pipeline, optimize all Android features for low latency
- Fix missing nOboeGetLastErrorMessage in C++ native bridge (P/Invoke crash) - Replace fragile reflection-based BASS handle discovery with direct BassAudioMixer.Handle - Add thread safety (lock) to StartOboeBridge/StopOboeBridge - Dynamic CPU core affinity based on device core count (not hardcoded 0xF8) - Remove duplicate GC latency mode setting (let performance session manage it) - Fix JNI global reference leak in SurfaceCreated - Fix performance mode overriding user's manual refresh rate selection - Remove dead code from input handlers (unused View property, commented-out code) - Add refresh rate dropdown to Android Performance settings - Add DeX immersive fullscreen mode (hide system bars) - Re-query display modes on DeX connect/disconnect - Improve error logging throughout Oboe initialization Agent-Logs-Url: https://github.com/winnerspiros/osu/sessions/62a8048b-6fe1-424d-9c00-a3fbddf6ba74 Co-authored-by: winnerspiros <1675249+winnerspiros@users.noreply.github.com>
1 parent 5b9faf6 commit 9cf4b0c

11 files changed

Lines changed: 401 additions & 215 deletions

osu.Android/AndroidNativeBridgeManager.cs

Lines changed: 59 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -17,77 +17,87 @@ internal sealed class AndroidNativeBridgeManager : IDisposable
1717
private object? oboeBridge;
1818
private object? vulkanProbe;
1919
private volatile bool disposed;
20-
private string? cachedOboeStatus;
21-
private string? cachedVulkanStatus;
20+
private volatile string? cachedOboeStatus;
21+
private volatile string? cachedVulkanStatus;
22+
private readonly object oboeLock = new object();
2223

2324
[MethodImpl(MethodImplOptions.NoInlining)]
2425
public void StartOboeBridge(Scheduler scheduler, Action<double> onLatencyMeasured, IntPtr provider, int sampleRate = 0, Action<int>? onStarted = null)
2526
{
26-
if (oboeBridge != null)
27+
lock (oboeLock)
2728
{
28-
Debug.WriteLine("[osu!] Oboe bridge already started, ignoring request");
29-
return;
30-
}
31-
32-
Debug.WriteLine($"[osu!] Starting Oboe bridge (sampleRate={sampleRate}, hasProvider={provider != IntPtr.Zero})");
33-
cachedOboeStatus = null;
29+
if (oboeBridge != null)
30+
{
31+
Debug.WriteLine("[osu!] Oboe bridge already started, ignoring request");
32+
return;
33+
}
3434

35-
try
36-
{
37-
var bridge = OboeAudioBridge.Create(sampleRate);
35+
Debug.WriteLine($"[osu!] Starting Oboe bridge (sampleRate={sampleRate}, hasProvider={provider != IntPtr.Zero})");
36+
cachedOboeStatus = null;
3837

39-
if (bridge != null)
38+
try
4039
{
41-
oboeBridge = bridge;
40+
var bridge = OboeAudioBridge.Create(sampleRate);
4241

43-
if (provider != IntPtr.Zero)
44-
bridge.SetProvider(provider);
42+
if (bridge != null)
43+
{
44+
oboeBridge = bridge;
4545

46-
try { SetThreadAffinity(0xF8); } catch { }
47-
bool started = bridge.Start();
48-
if (!started) { System.Threading.Thread.Sleep(100); started = bridge.Start(); }
46+
if (provider != IntPtr.Zero)
47+
bridge.SetProvider(provider);
4948

50-
if (started)
51-
{
52-
Debug.WriteLine("[osu!] Oboe bridge started successfully");
53-
logOboeInfo(bridge);
49+
try { SetThreadAffinity(Environment.ProcessorCount > 4 ? 0xF0 : 0x0C); }
50+
catch (Exception e) { Debug.WriteLine($"[osu!] Audio thread affinity failed: {e.Message}"); }
5451

55-
onStarted?.Invoke(bridge.SampleRate);
52+
bool started = bridge.Start();
53+
if (!started) { System.Threading.Thread.Sleep(100); started = bridge.Start(); }
5654

57-
scheduler.Add(new ScheduledDelegate(() =>
55+
if (started)
5856
{
59-
if (oboeBridge is not OboeAudioBridge b) return;
57+
Debug.WriteLine("[osu!] Oboe bridge started successfully");
58+
logOboeInfo(bridge);
59+
60+
onStarted?.Invoke(bridge.SampleRate);
6061

61-
double latency = b.GetOutputLatencyMs();
62+
scheduler.Add(new ScheduledDelegate(() =>
63+
{
64+
if (oboeBridge is not OboeAudioBridge b) return;
6265

63-
if (latency > 0)
64-
onLatencyMeasured(latency);
65-
}, 2000, 5000));
66+
double latency = b.GetOutputLatencyMs();
67+
68+
if (latency > 0)
69+
onLatencyMeasured(latency);
70+
}, 2000, 5000));
71+
}
72+
else
73+
{
74+
string error = bridge.GetLastErrorMessage() ?? "Unknown";
75+
Debug.WriteLine($"[osu!] Oboe bridge created but failed to start: {error}");
76+
}
6677
}
6778
else
6879
{
69-
Debug.WriteLine("[osu!] Oboe bridge created but failed to start (Start() returned false)");
80+
Debug.WriteLine("[osu!] Oboe bridge creation failed — native library not loaded or stream open failed");
7081
}
7182
}
72-
else
83+
catch (Exception e)
7384
{
74-
Debug.WriteLine("[osu!] Oboe bridge creation failed (Create() returned null)");
85+
Debug.WriteLine($"[osu!] Oboe bridge init failed with exception: {e.Message}");
7586
}
7687
}
77-
catch (Exception e)
78-
{
79-
Debug.WriteLine($"[osu!] Oboe bridge init failed with exception: {e.Message}");
80-
}
8188
}
8289

8390
[MethodImpl(MethodImplOptions.NoInlining)]
8491
public void StopOboeBridge()
8592
{
86-
Debug.WriteLine("[osu!] Stopping Oboe bridge...");
87-
(oboeBridge as OboeAudioBridge)?.Dispose();
88-
oboeBridge = null;
89-
cachedOboeStatus = null;
90-
Debug.WriteLine("[osu!] Oboe bridge stopped");
93+
lock (oboeLock)
94+
{
95+
Debug.WriteLine("[osu!] Stopping Oboe bridge...");
96+
(oboeBridge as OboeAudioBridge)?.Dispose();
97+
oboeBridge = null;
98+
cachedOboeStatus = null;
99+
Debug.WriteLine("[osu!] Oboe bridge stopped");
100+
}
91101
}
92102

93103
[MethodImpl(MethodImplOptions.NoInlining)]
@@ -100,7 +110,13 @@ public void StopOboeBridge()
100110
public string GetOboeStatus()
101111
{
102112
if (oboeBridge is not OboeAudioBridge bridge) return "Not Created";
103-
if (!bridge.IsActive) return "Failed: " + bridge.GetLastErrorMessage();
113+
114+
if (!bridge.IsActive)
115+
{
116+
try { return "Failed: " + (bridge.GetLastErrorMessage() ?? "Unknown"); }
117+
catch { return "Failed: Unknown"; }
118+
}
119+
104120
return cachedOboeStatus ??= $"{(bridge.IsAAudio ? "AAudio" : "OpenSLES")} [{(bridge.IsMMap ? "MMAP" : "Legacy")}]";
105121
}
106122

osu.Android/Input/AndroidMouseHandler.cs

Lines changed: 6 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ public class AndroidMouseHandler : InputHandler
1515
public override string Description => "Mouse (Low Latency)";
1616
public override bool IsActive => Enabled.Value;
1717

18-
public View? View { get; set; }
18+
private bool lastLeft;
19+
private bool lastRight;
20+
private bool lastMiddle;
21+
private bool lastBack;
22+
private bool lastForward;
1923

2024
public AndroidMouseHandler()
2125
{
@@ -46,7 +50,7 @@ public bool HandleMotionEvent(MotionEvent e)
4650
}
4751
handlePointer(e, -1);
4852

49-
return true; // We consume movement/buttons to prevent system from doing weird things with our cursor
53+
return true;
5054
}
5155

5256
private void handlePointer(MotionEvent e, int historyIndex)
@@ -57,18 +61,6 @@ private void handlePointer(MotionEvent e, int historyIndex)
5761
float x = historyIndex < 0 ? e.GetX(pointer_index) : e.GetHistoricalX(pointer_index, historyIndex);
5862
float y = historyIndex < 0 ? e.GetY(pointer_index) : e.GetHistoricalY(pointer_index, historyIndex);
5963

60-
// In windowed mode (DeX), raw coordinates might be needed for consistency, but view-relative is usually better.
61-
// If the view offset is weird, we could calculate it here:
62-
/*
63-
if (View != null)
64-
{
65-
int[] location = new int[2];
66-
View.GetLocationOnScreen(location);
67-
x = (historyIndex < 0 ? e.RawX : e.GetHistoricalRawX(pointer_index, historyIndex)) - location[0];
68-
y = (historyIndex < 0 ? e.RawY : e.GetHistoricalRawY(pointer_index, historyIndex)) - location[1];
69-
}
70-
*/
71-
7264
PendingInputs.Enqueue(new MousePositionAbsoluteInput { Position = new Vector2(x, y) });
7365

7466
bool left = (e.ButtonState & MotionEventButtonState.Primary) != 0;
@@ -86,11 +78,5 @@ private void handlePointer(MotionEvent e, int historyIndex)
8678
if (back != lastBack) { PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Button1, back)); lastBack = back; }
8779
if (forward != lastForward) { PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Button2, forward)); lastForward = forward; }
8880
}
89-
90-
private bool lastLeft;
91-
private bool lastRight;
92-
private bool lastMiddle;
93-
private bool lastBack;
94-
private bool lastForward;
9581
}
9682
}

osu.Android/Input/AndroidStylusHandler.cs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@ public class AndroidStylusHandler : InputHandler, ITabletHandler
1818
public override string Description => "S Pen / Stylus (Low Latency)";
1919
public override bool IsActive => Enabled.Value;
2020

21-
public View? View { get; set; }
22-
2321
public Bindable<Vector2> AreaOffset { get; } = new Bindable<Vector2>();
2422
public Bindable<Vector2> AreaSize { get; } = new Bindable<Vector2>();
2523
public Bindable<Vector2> OutputAreaSize { get; } = new Bindable<Vector2>();
@@ -80,16 +78,6 @@ private void handlePointer(MotionEvent e, int historyIndex)
8078
float x = historyIndex < 0 ? e.GetX(pointer_index) : e.GetHistoricalX(pointer_index, historyIndex);
8179
float y = historyIndex < 0 ? e.GetY(pointer_index) : e.GetHistoricalY(pointer_index, historyIndex);
8280
float pressure = historyIndex < 0 ? e.GetPressure(pointer_index) : e.GetHistoricalPressure(pointer_index, historyIndex);
83-
float tiltX = e.GetAxisValue(Axis.Tilt, pointer_index);
84-
float tiltY = e.GetAxisValue(Axis.Orientation, pointer_index);
85-
86-
// DeX windowed mode offset correction
87-
if (View != null)
88-
{
89-
// On some DeX versions, GetX/Y might be screen-relative if the window isn't focused.
90-
// Using GetX/Y is generally safer for windowed mode as Android handles the subtraction,
91-
// but we ensure the View is passed for future coordinate scaling needs.
92-
}
9381

9482
if (tablet.Value == null || x > tablet.Value.Size.X || y > tablet.Value.Size.Y)
9583
{
@@ -121,7 +109,6 @@ private void handlePointer(MotionEvent e, int historyIndex)
121109
bool isEraserDown = (e.ButtonState & MotionEventButtonState.StylusSecondary) != 0 || e.GetToolType(pointer_index) == MotionEventToolType.Eraser;
122110
if (isEraserDown != lastEraserDown)
123111
{
124-
// Map eraser to Middle Click or a specific tablet button if framework supports it
125112
PendingInputs.Enqueue(new MouseButtonInput(MouseButton.Middle, isEraserDown));
126113
lastEraserDown = isEraserDown;
127114
}

osu.Android/Native/OboeAudioBridge.cs

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -52,9 +52,29 @@ static OboeAudioBridge()
5252

5353
public static OboeAudioBridge? Create(int sampleRate = 0)
5454
{
55-
if (!native_loaded) return null;
56-
try { IntPtr ptr = nOboeCreate(sampleRate); return ptr == IntPtr.Zero ? null : new OboeAudioBridge(ptr); }
57-
catch { return null; }
55+
if (!native_loaded)
56+
{
57+
Debug.WriteLine("[osu!] Oboe Create() skipped — native library not loaded");
58+
return null;
59+
}
60+
61+
try
62+
{
63+
IntPtr ptr = nOboeCreate(sampleRate);
64+
65+
if (ptr == IntPtr.Zero)
66+
{
67+
Debug.WriteLine($"[osu!] nOboeCreate({sampleRate}) returned null — stream open failed");
68+
return null;
69+
}
70+
71+
return new OboeAudioBridge(ptr);
72+
}
73+
catch (Exception e)
74+
{
75+
Debug.WriteLine($"[osu!] nOboeCreate failed: {e.Message}");
76+
return null;
77+
}
5878
}
5979

6080
private OboeAudioBridge(IntPtr ptr) => nativePtr = ptr;

osu.Android/Native/oboe_bridge.cpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,13 +60,15 @@ bool OboeBridge::open(int32_t sampleRate) {
6060
if (result != oboe::Result::OK) {
6161
LOGE("AAudio open failed (%s), falling back to unspecified API",
6262
oboe::convertToText(result));
63+
lastError_ = std::string("AAudio: ") + oboe::convertToText(result);
6364
builder.setAudioApi(oboe::AudioApi::Unspecified);
6465
builder.setSharingMode(oboe::SharingMode::Shared);
6566
result = builder.openStream(stream_);
6667
}
6768

6869
if (result != oboe::Result::OK) {
6970
LOGE("Failed to open Oboe stream: %s", oboe::convertToText(result));
71+
lastError_ = std::string("Open failed: ") + oboe::convertToText(result);
7072
return false;
7173
}
7274

@@ -106,6 +108,7 @@ bool OboeBridge::start() {
106108

107109
if (result != oboe::Result::OK) {
108110
LOGE("Failed to start Oboe stream: %s", oboe::convertToText(result));
111+
lastError_ = std::string("Start failed: ") + oboe::convertToText(result);
109112
return false;
110113
}
111114

@@ -170,6 +173,10 @@ void OboeBridge::setProvider(OboeAudioProvider provider) {
170173
provider_.store(provider, std::memory_order_release);
171174
}
172175

176+
const char* OboeBridge::getLastError() const {
177+
return lastError_.empty() ? nullptr : lastError_.c_str();
178+
}
179+
173180
oboe::DataCallbackResult OboeBridge::onAudioReady(
174181
oboe::AudioStream* stream, void* audioData, int32_t numFrames) {
175182

@@ -368,6 +375,11 @@ OSU_EXPORT void nOboeSetProvider(intptr_t ptr, OboeAudioProvider provider) {
368375
if (bridge) bridge->setProvider(provider);
369376
}
370377

378+
OSU_EXPORT const char* nOboeGetLastErrorMessage(intptr_t ptr) {
379+
auto* bridge = reinterpret_cast<OboeBridge*>(ptr);
380+
return bridge ? bridge->getLastError() : nullptr;
381+
}
382+
371383
} // extern "C"
372384

373385
extern "C" {

osu.Android/Native/oboe_bridge.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ class OboeBridge : public oboe::AudioStreamCallback {
3333
bool isAAudio() const;
3434
bool isMMap() const;
3535
void setProvider(OboeAudioProvider provider);
36+
const char* getLastError() const;
3637

3738
// oboe::AudioStreamCallback
3839
oboe::DataCallbackResult onAudioReady(
@@ -53,6 +54,7 @@ class OboeBridge : public oboe::AudioStreamCallback {
5354
std::atomic<OboeAudioProvider> provider_{nullptr};
5455
std::atomic<bool> affinitySet_{false};
5556
int32_t requestedSampleRate_{0};
57+
std::string lastError_;
5658

5759
void updateLatency();
5860
bool reopenAndRestart();

0 commit comments

Comments
 (0)