Skip to content

Commit 0e4787a

Browse files
authored
Merge pull request #105 from winnerspiros/copilot/fix-android-app-crash-on-startup
Fix native library build pipeline, upgrade Oboe/NDK/Vulkan, optimize for lowest latency
2 parents 862d4df + b770acd commit 0e4787a

12 files changed

Lines changed: 329 additions & 30 deletions

.github/workflows/release.yml

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,34 @@ jobs:
3232
- name: Install .NET Android workload
3333
run: dotnet workload install android
3434

35+
- name: Ensure Android NDK and CMake are available
36+
run: |
37+
# The ubuntu-latest runner has $ANDROID_HOME pre-installed.
38+
# Install latest stable NDK (r29) + CMake.
39+
yes | sdkmanager --licenses > /dev/null 2>&1 || true
40+
sdkmanager --install "ndk;29.0.14206865" "cmake;3.22.1" > /dev/null 2>&1
41+
42+
- name: Build native library (libosu_native.so)
43+
run: |
44+
NDK_HOME="$ANDROID_HOME/ndk/29.0.14206865"
45+
CMAKE_BIN="$ANDROID_HOME/cmake/3.22.1/bin/cmake"
46+
47+
for ABI in arm64-v8a armeabi-v7a x86; do
48+
echo "::group::Building osu_native for $ABI"
49+
"$CMAKE_BIN" -B "build-native/$ABI" -S osu.Android/Native \
50+
-DCMAKE_TOOLCHAIN_FILE="$NDK_HOME/build/cmake/android.toolchain.cmake" \
51+
-DANDROID_ABI="$ABI" \
52+
-DANDROID_PLATFORM=android-30 \
53+
-DCMAKE_BUILD_TYPE=Release
54+
"$CMAKE_BIN" --build "build-native/$ABI" --config Release -j "$(nproc)"
55+
mkdir -p "osu.Android/libs/$ABI"
56+
cp "build-native/$ABI/libosu_native.so" "osu.Android/libs/$ABI/"
57+
echo "::endgroup::"
58+
done
59+
60+
echo "Native libraries built successfully:"
61+
find osu.Android/libs -name "*.so" -exec ls -lh {} \;
62+
3563
- name: Decode keystore
3664
id: keystore
3765
run: |

.gitignore

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -344,3 +344,7 @@ FodyWeavers.xsd
344344

345345
.idea/.idea.osu.Desktop/.idea/misc.xml
346346
.idea/.idea.osu.Android/.idea/deploymentTargetDropDown.xml
347+
348+
# Native library build artifacts (built by CMake/NDK in CI or locally)
349+
osu.Android/libs/
350+
build-native/

osu.Android/AndroidNativeBridgeManager.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -133,14 +133,18 @@ private static void logVulkanInfo(VulkanProbe probe)
133133
+ $"VRAM={probe.DeviceLocalMemoryMB}MB, "
134134
+ $"queueFamilies={probe.QueueFamilyCount}, "
135135
+ $"dedicatedCompute={probe.HasDedicatedComputeQueue}, "
136-
+ $"dedicatedTransfer={probe.HasDedicatedTransferQueue}");
136+
+ $"dedicatedTransfer={probe.HasDedicatedTransferQueue}, "
137+
+ $"vk1.3={probe.MeetsVulkan13}, "
138+
+ $"dynamicRendering={probe.SupportsDynamicRendering}, "
139+
+ $"synchronization2={probe.SupportsSynchronization2}");
137140
}
138141

139142
[MethodImpl(MethodImplOptions.NoInlining)]
140143
private static void logOboeInfo(OboeAudioBridge bridge)
141144
{
142145
Debug.WriteLine($"[osu!] Oboe audio: active={bridge.IsActive}, "
143146
+ $"api={(bridge.IsAAudio ? "AAudio" : "OpenSLES")}, "
147+
+ $"mmap={bridge.IsMMap}, "
144148
+ $"sampleRate={bridge.SampleRate}Hz, "
145149
+ $"burst={bridge.FramesPerBurst}frames, "
146150
+ $"bufferSize={bridge.BufferSizeInFrames}frames");

osu.Android/Native/CMakeLists.txt

Lines changed: 31 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,37 @@ project(osu_native LANGUAGES CXX)
33

44
set(CMAKE_CXX_STANDARD 17)
55

6-
# Release build optimizations for low-latency performance.
7-
set(CMAKE_CXX_FLAGS_RELEASE "-O2 -flto -fvisibility=hidden -DNDEBUG")
8-
set(CMAKE_C_FLAGS_RELEASE "-O2 -flto -fvisibility=hidden -DNDEBUG")
6+
# Maximum release optimizations for lowest-latency audio callback path.
7+
# -O3: aggressive inlining & vectorisation
8+
# -flto: link-time optimisation across translation units
9+
# -ffast-math: enable SIMD-friendly FP (safe — we only memset + store a double)
10+
# -ffunction/data-sections + --gc-sections: dead-code elimination
11+
# -fvisibility=hidden: only OSU_EXPORT symbols are visible
12+
set(CMAKE_CXX_FLAGS_RELEASE "-O3 -flto -ffast-math -ffunction-sections -fdata-sections -fvisibility=hidden -DNDEBUG")
13+
set(CMAKE_C_FLAGS_RELEASE "-O3 -flto -ffast-math -ffunction-sections -fdata-sections -fvisibility=hidden -DNDEBUG")
14+
set(CMAKE_SHARED_LINKER_FLAGS_RELEASE "-Wl,--gc-sections -s")
15+
16+
# Disable Oboe's flowgraph module — we don't use any audio processing/conversion
17+
# features (our bridge outputs silence for latency measurement only).
18+
# This reduces the Oboe portion of the binary by ~50%.
19+
set(OBOE_ENABLE_FLOWGRAPH OFF CACHE BOOL "Disable Oboe flowgraph to reduce binary size")
20+
21+
# Try pre-installed Oboe first (e.g. via Android NDK prefab or local install).
22+
# If not found, download and build from source for CI/hermetic builds.
23+
find_package(oboe QUIET CONFIG)
24+
25+
if(oboe_FOUND)
26+
set(OBOE_LIB oboe::oboe)
27+
else()
28+
include(FetchContent)
29+
FetchContent_Declare(oboe
30+
URL https://github.com/google/oboe/archive/refs/tags/1.10.0.tar.gz
31+
URL_HASH SHA256=0e4245f8860c4287040a5d76501c588490bcc9cb57614c486c0c201a5dde3e9f
32+
)
33+
FetchContent_MakeAvailable(oboe)
34+
set(OBOE_LIB oboe)
35+
endif()
936

10-
find_package(oboe REQUIRED CONFIG)
1137
find_library(vulkan-lib vulkan)
1238
find_library(log-lib log)
1339
find_library(android-lib android)
@@ -18,7 +44,7 @@ add_library(osu_native SHARED
1844
)
1945

2046
target_link_libraries(osu_native
21-
oboe::oboe
47+
${OBOE_LIB}
2248
${vulkan-lib}
2349
${log-lib}
2450
${android-lib}

osu.Android/Native/OboeAudioBridge.cs

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,28 @@ public bool IsAAudio
227227
}
228228
}
229229

230+
/// <summary>
231+
/// Whether the stream is using the hardware MMAP path (lowest possible latency).
232+
/// MMAP provides direct memory-mapped access to audio hardware buffers,
233+
/// bypassing the normal kernel copy path.
234+
/// </summary>
235+
public bool IsMMap
236+
{
237+
get
238+
{
239+
if (disposed || nativePtr == IntPtr.Zero) return false;
240+
241+
try
242+
{
243+
return nOboeIsMMap(nativePtr) != 0;
244+
}
245+
catch
246+
{
247+
return false;
248+
}
249+
}
250+
}
251+
230252
public void Dispose()
231253
{
232254
if (disposed) return;
@@ -284,5 +306,8 @@ public void Dispose()
284306

285307
[DllImport(lib_name)]
286308
private static extern byte nOboeIsAAudio(IntPtr ptr);
309+
310+
[DllImport(lib_name)]
311+
private static extern byte nOboeIsMMap(IntPtr ptr);
287312
}
288313
}

osu.Android/Native/VulkanProbe.cs

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,69 @@ public bool SupportsMailboxPresentMode
233233
}
234234
}
235235

236+
/// <summary>
237+
/// Whether the device reports Vulkan 1.3+ API version.
238+
/// </summary>
239+
public bool MeetsVulkan13
240+
{
241+
get
242+
{
243+
if (disposed || nativePtr == IntPtr.Zero) return false;
244+
245+
try
246+
{
247+
return nVulkanMeetsVulkan13(nativePtr) != 0;
248+
}
249+
catch
250+
{
251+
return false;
252+
}
253+
}
254+
}
255+
256+
/// <summary>
257+
/// Whether the device supports VkPhysicalDeviceVulkan13Features::dynamicRendering.
258+
/// Dynamic rendering eliminates VkRenderPass/VkFramebuffer boilerplate for simpler,
259+
/// more flexible rendering.
260+
/// </summary>
261+
public bool SupportsDynamicRendering
262+
{
263+
get
264+
{
265+
if (disposed || nativePtr == IntPtr.Zero) return false;
266+
267+
try
268+
{
269+
return nVulkanSupportsDynamicRendering(nativePtr) != 0;
270+
}
271+
catch
272+
{
273+
return false;
274+
}
275+
}
276+
}
277+
278+
/// <summary>
279+
/// Whether the device supports VkPhysicalDeviceVulkan13Features::synchronization2.
280+
/// Provides a cleaner, less error-prone GPU synchronization model.
281+
/// </summary>
282+
public bool SupportsSynchronization2
283+
{
284+
get
285+
{
286+
if (disposed || nativePtr == IntPtr.Zero) return false;
287+
288+
try
289+
{
290+
return nVulkanSupportsSynchronization2(nativePtr) != 0;
291+
}
292+
catch
293+
{
294+
return false;
295+
}
296+
}
297+
}
298+
236299
public void Dispose()
237300
{
238301
if (disposed) return;
@@ -290,5 +353,14 @@ public void Dispose()
290353

291354
[DllImport(lib_name)]
292355
private static extern byte nVulkanSupportsMailboxPresentMode(IntPtr ptr);
356+
357+
[DllImport(lib_name)]
358+
private static extern byte nVulkanMeetsVulkan13(IntPtr ptr);
359+
360+
[DllImport(lib_name)]
361+
private static extern byte nVulkanSupportsDynamicRendering(IntPtr ptr);
362+
363+
[DllImport(lib_name)]
364+
private static extern byte nVulkanSupportsSynchronization2(IntPtr ptr);
293365
}
294366
}

osu.Android/Native/oboe_bridge.cpp

Lines changed: 36 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// See the LICENCE file in the repository root for full licence text.
33

44
#include "oboe_bridge.h"
5+
#include <oboe/OboeExtensions.h>
56
#include <android/log.h>
67
#include <cstdint>
78
#include <cstring>
@@ -22,16 +23,29 @@ OboeBridge::~OboeBridge() {
2223
bool OboeBridge::open() {
2324
std::lock_guard<std::mutex> lock(streamLock_);
2425

26+
// Request MMAP mode globally before opening the stream.
27+
// MMAP provides a hardware-level DMA path that bypasses the kernel audio
28+
// copy, shaving ~1-2 ms off the round-trip latency on supported devices.
29+
oboe::OboeExtensions::setMMapEnabled(true);
30+
2531
oboe::AudioStreamBuilder builder;
2632
builder.setDirection(oboe::Direction::Output)
2733
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
2834
->setSharingMode(oboe::SharingMode::Exclusive)
2935
->setFormat(oboe::AudioFormat::Float)
30-
->setChannelCount(oboe::ChannelCount::Stereo)
36+
// Mono — this stream outputs silence for latency measurement only.
37+
// Mono halves the per-callback buffer vs stereo, reducing the
38+
// minimum achievable latency.
39+
->setChannelCount(oboe::ChannelCount::Mono)
3140
// Let Oboe pick the device's native sample rate.
3241
// Hardcoding (e.g. 48000) would force Android's SRC resampler when the
3342
// device native rate differs, adding measurable latency.
3443
->setSampleRate(oboe::kUnspecified)
44+
// Explicitly forbid all internal conversions so that no resampler,
45+
// channel mixer, or format converter sits in the audio path.
46+
->setChannelConversionAllowed(false)
47+
->setFormatConversionAllowed(false)
48+
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::None)
3549
// Semantic hints help Android route through the optimal audio path.
3650
->setContentType(oboe::ContentType::Music)
3751
->setUsage(oboe::Usage::Game)
@@ -62,13 +76,14 @@ bool OboeBridge::open() {
6276
optimiseBufferSize();
6377

6478
LOGI("Oboe stream opened: api=%s, sampleRate=%d, framesPerBurst=%d, "
65-
"bufferSize=%d, bufferCapacity=%d, sharingMode=%s",
79+
"bufferSize=%d, bufferCapacity=%d, sharingMode=%s, mmap=%s",
6680
stream_->getAudioApi() == oboe::AudioApi::AAudio ? "AAudio" : "OpenSLES",
6781
stream_->getSampleRate(),
6882
stream_->getFramesPerBurst(),
6983
stream_->getBufferSizeInFrames(),
7084
stream_->getBufferCapacityInFrames(),
71-
stream_->getSharingMode() == oboe::SharingMode::Exclusive ? "Exclusive" : "Shared");
85+
stream_->getSharingMode() == oboe::SharingMode::Exclusive ? "Exclusive" : "Shared",
86+
oboe::OboeExtensions::isMMapUsed(stream_.get()) ? "yes" : "no");
7287

7388
return true;
7489
}
@@ -121,6 +136,7 @@ void OboeBridge::stop() {
121136
}
122137

123138
latencyMs_.store(-1.0);
139+
callbackCount_.store(0);
124140
LOGI("Oboe stream stopped");
125141
}
126142

@@ -152,6 +168,11 @@ bool OboeBridge::isAAudio() const {
152168
return stream_ && stream_->getAudioApi() == oboe::AudioApi::AAudio;
153169
}
154170

171+
bool OboeBridge::isMMap() const {
172+
std::lock_guard<std::mutex> lock(const_cast<std::mutex&>(streamLock_));
173+
return stream_ && oboe::OboeExtensions::isMMapUsed(stream_.get());
174+
}
175+
155176
oboe::DataCallbackResult OboeBridge::onAudioReady(
156177
oboe::AudioStream* stream, void* audioData, int32_t numFrames) {
157178

@@ -163,7 +184,13 @@ oboe::DataCallbackResult OboeBridge::onAudioReady(
163184
* sizeof(float);
164185
memset(audioData, 0, byteCount);
165186

166-
updateLatency();
187+
// Sample latency every 128 callbacks (~250 ms at typical burst/sample rates)
188+
// instead of every single callback. calculateLatencyMillis() issues a
189+
// system call; keeping it out of the majority of callbacks reduces jitter
190+
// in this real-time audio thread.
191+
if ((callbackCount_.fetch_add(1, std::memory_order_relaxed) & 127) == 0) {
192+
updateLatency();
193+
}
167194

168195
return oboe::DataCallbackResult::Continue;
169196
}
@@ -297,4 +324,9 @@ OSU_EXPORT unsigned char nOboeIsAAudio(intptr_t ptr) {
297324
return (bridge && bridge->isAAudio()) ? 1 : 0;
298325
}
299326

327+
OSU_EXPORT unsigned char nOboeIsMMap(intptr_t ptr) {
328+
auto* bridge = reinterpret_cast<OboeBridge*>(ptr);
329+
return (bridge && bridge->isMMap()) ? 1 : 0;
330+
}
331+
300332
} // extern "C"

osu.Android/Native/oboe_bridge.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,12 @@
1010
/// Low-latency audio bridge using Google's Oboe library.
1111
/// Optimised for rhythm-game audio-visual synchronization with:
1212
/// - AAudio preferred (lowest latency path on Android 8.1+)
13+
/// - MMAP enabled (hardware-level DMA, bypasses kernel copy)
1314
/// - Exclusive sharing mode (bypass system mixer)
15+
/// - Mono output (minimum buffer for latency-measurement stream)
1416
/// - Buffer size tuned to 1× burst for minimum latency
17+
/// - All format/rate/channel conversions disabled (zero resampler overhead)
18+
/// - Latency sampled every 128 callbacks (avoids syscall overhead in hot path)
1519
/// - Automatic stream recovery on disconnect / route change
1620
class OboeBridge : public oboe::AudioStreamCallback {
1721
public:
@@ -40,6 +44,10 @@ class OboeBridge : public oboe::AudioStreamCallback {
4044
/// Returns true if the stream is using AAudio (vs OpenSL ES fallback).
4145
bool isAAudio() const;
4246

47+
/// Returns true if the stream is using the hardware MMAP path (lowest possible latency).
48+
/// MMAP provides direct memory-mapped access to audio hardware buffers.
49+
bool isMMap() const;
50+
4351
// oboe::AudioStreamCallback
4452
oboe::DataCallbackResult onAudioReady(
4553
oboe::AudioStream* stream, void* audioData, int32_t numFrames) override;
@@ -52,6 +60,7 @@ class OboeBridge : public oboe::AudioStreamCallback {
5260
std::mutex streamLock_;
5361
std::atomic<bool> active_{false};
5462
std::atomic<double> latencyMs_{-1.0};
63+
std::atomic<uint32_t> callbackCount_{0};
5564

5665
void updateLatency();
5766
void optimiseBufferSize();

0 commit comments

Comments
 (0)