diff --git a/osu.Android/Native/CMakeLists.txt b/osu.Android/Native/CMakeLists.txt index dfa1af42cdc5..1cba98597e2b 100644 --- a/osu.Android/Native/CMakeLists.txt +++ b/osu.Android/Native/CMakeLists.txt @@ -27,8 +27,7 @@ if(oboe_FOUND) else() include(FetchContent) FetchContent_Declare(oboe - URL https://github.com/google/oboe/archive/refs/tags/1.10.0.tar.gz - URL_HASH SHA256=0e4245f8860c4287040a5d76501c588490bcc9cb57614c486c0c201a5dde3e9f + URL https://github.com/google/oboe/archive/39b1fd258998eb3cd80a0f9e800498bb4efa4eb2.tar.gz ) FetchContent_MakeAvailable(oboe) set(OBOE_LIB oboe) diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp index 6e1c4f23dbee..9b4112bc7234 100644 --- a/osu.Android/Native/oboe_bridge.cpp +++ b/osu.Android/Native/oboe_bridge.cpp @@ -4,10 +4,8 @@ #include "oboe_bridge.h" #include #include -#include -#include -#define LOG_TAG "osu!native" +#define LOG_TAG "OboeBridge" #define LOGI(...) __android_log_print(ANDROID_LOG_INFO, LOG_TAG, __VA_ARGS__) #define LOGE(...) __android_log_print(ANDROID_LOG_ERROR, LOG_TAG, __VA_ARGS__) @@ -23,9 +21,13 @@ OboeBridge::~OboeBridge() { bool OboeBridge::open() { std::lock_guard lock(streamLock_); - // Request MMAP mode globally before opening the stream. - // MMAP provides a hardware-level DMA path that bypasses the kernel audio - // copy, shaving ~1-2 ms off the round-trip latency on supported devices. + if (stream_) { + LOGI("Stream already open, closing first"); + stream_->close(); + stream_.reset(); + } + + // Enable AAudio MMAP for lowest possible latency if supported. oboe::OboeExtensions::setMMapEnabled(true); oboe::AudioStreamBuilder builder; @@ -33,35 +35,23 @@ bool OboeBridge::open() { ->setPerformanceMode(oboe::PerformanceMode::LowLatency) ->setSharingMode(oboe::SharingMode::Exclusive) ->setFormat(oboe::AudioFormat::Float) - // Stereo output for high-quality game audio. - // Most Android devices use stereo as their native "Fast Path" configuration. ->setChannelCount(oboe::ChannelCount::Stereo) - // Let Oboe pick the device's native sample rate. - // Hardcoding (e.g. 48000) would force Android's SRC resampler when the - // device native rate differs, adding measurable latency. ->setSampleRate(oboe::kUnspecified) - // Explicitly forbid all internal conversions so that no resampler, - // channel mixer, or format converter sits in the audio path. - ->setChannelConversionAllowed(false) - ->setFormatConversionAllowed(false) ->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::None) - // Semantic hints help Android route through the optimal audio path. ->setContentType(oboe::ContentType::Music) ->setUsage(oboe::Usage::Game) - ->setCallback(this) - // Prefer AAudio for lowest latency (available on Android 8.1+). - // Falls back to OpenSL ES automatically on older devices. ->setAudioApi(oboe::AudioApi::AAudio) - // Request minimum buffer for lowest latency. - // Oboe will clamp to the smallest safe value. ->setFramesPerCallback(oboe::kUnspecified) - ->setBufferCapacityInFrames(oboe::kUnspecified); + ->setBufferCapacityInFrames(oboe::kUnspecified) + ->setPerformanceHintEnabled(true) // Enable ADPF for dynamic performance management + ->setChannelConversionAllowed(false) + ->setFormatConversionAllowed(false) + ->setCallback(this); oboe::Result result = builder.openStream(stream_); if (result != oboe::Result::OK) { - // AAudio might not be available; retry without API preference. - LOGI("AAudio open failed (%s), falling back to unspecified API", + LOGE("AAudio open failed (%s), falling back to unspecified API", oboe::convertToText(result)); builder.setAudioApi(oboe::AudioApi::Unspecified); result = builder.openStream(stream_); @@ -134,7 +124,6 @@ void OboeBridge::stop() { stream_.reset(); } - affinitySet_.store(false); latencyMs_.store(-1.0); callbackCount_.store(0); LOGI("Oboe stream stopped"); @@ -207,26 +196,6 @@ oboe::DataCallbackResult OboeBridge::onAudioReady( if ((count & 127) == 0) { updateLatency(); - - // Attempt to set CPU affinity to high-performance cores on the first few callbacks. - // Doing this inside the callback ensures we are targeting the actual audio thread - // created by Oboe/AAudio. - if (!affinitySet_.load(std::memory_order_relaxed)) { - std::vector exclusiveCores = oboe::Process::getExclusiveCores(); - - if (!exclusiveCores.empty()) { - oboe::Result result = oboe::Process::setThreadAffinity( - oboe::Process::getThreadId(), - exclusiveCores); - - if (result == oboe::Result::OK) { - LOGI("Oboe audio thread pinned to exclusive cores"); - } else { - LOGI("Failed to pin Oboe audio thread: %s", oboe::convertToText(result)); - } - } - affinitySet_.store(true); - } } return oboe::DataCallbackResult::Continue; @@ -295,11 +264,6 @@ void OboeBridge::updateLatency() { // C exports for P/Invoke from .NET // ============================================================ -// Use intptr_t for pointer handles so the size matches C# IntPtr on both -// 32-bit (4 bytes) and 64-bit (8 bytes) platforms. The previous use of -// C++ `long` was 4 bytes on 32-bit ARM/x86 but C# `long` is always -// 8 bytes, causing a calling-convention mismatch and crash. - #define OSU_EXPORT __attribute__((visibility("default"))) extern "C" { diff --git a/osu.Android/Native/oboe_bridge.h b/osu.Android/Native/oboe_bridge.h index a1f42da4c68f..3a1d6e124a06 100644 --- a/osu.Android/Native/oboe_bridge.h +++ b/osu.Android/Native/oboe_bridge.h @@ -13,15 +13,6 @@ typedef int32_t (*OboeAudioProvider)(void* audioData, int32_t numFrames); /// Low-latency audio bridge using Google's Oboe library. -/// Optimised for rhythm-game audio-visual synchronization with: -/// - AAudio preferred (lowest latency path on Android 8.1+) -/// - MMAP enabled (hardware-level DMA, bypasses kernel copy) -/// - Exclusive sharing mode (bypass system mixer) -/// - Mono output (minimum buffer for latency-measurement stream) -/// - Buffer size tuned to 1× burst for minimum latency -/// - All format/rate/channel conversions disabled (zero resampler overhead) -/// - Latency sampled every 128 callbacks (avoids syscall overhead in hot path) -/// - Automatic stream recovery on disconnect / route change class OboeBridge : public oboe::AudioStreamCallback { public: OboeBridge(); @@ -50,7 +41,6 @@ class OboeBridge : public oboe::AudioStreamCallback { bool isAAudio() const; /// Returns true if the stream is using the hardware MMAP path (lowest possible latency). - /// MMAP provides direct memory-mapped access to audio hardware buffers. bool isMMap() const; /// Sets the provider function that will be called to fill the audio buffer. @@ -67,7 +57,6 @@ class OboeBridge : public oboe::AudioStreamCallback { std::shared_ptr stream_; std::mutex streamLock_; std::atomic active_{false}; - std::atomic affinitySet_{false}; std::atomic latencyMs_{-1.0}; std::atomic callbackCount_{0}; std::atomic provider_{nullptr}; diff --git a/osu.Android/OboeAudioRedirector.cs b/osu.Android/OboeAudioRedirector.cs index 4a39f335a79e..9c7d8275f79d 100644 --- a/osu.Android/OboeAudioRedirector.cs +++ b/osu.Android/OboeAudioRedirector.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Numerics; using System.Reflection; using System.Runtime.InteropServices; @@ -49,8 +50,6 @@ public void RefreshMixers() private void silenceDefaultAudio() { - if (devicesSilenced) return; - try { // Initialize BASS "No Sound" device (0) if not already. @@ -61,16 +60,23 @@ private void silenceDefaultAudio() return; } + bool allSuccess = true; + // Move all redirected mixers to the silent device. // This "unplugs" them from the system hardware while keeping them active so we can pull data. foreach (int handle in mixerHandles) { if (!Bass.ChannelSetDevice(handle, 0)) + { Debug.WriteLine($"[osu!] Failed to move mixer {handle} to silent device: {Bass.LastError}"); + allSuccess = false; + } } - devicesSilenced = true; - Debug.WriteLine($"[osu!] BASS mixers moved to silent device 0 (Oboe active)"); + devicesSilenced = allSuccess; + + if (allSuccess && mixerHandles.Count > 0) + Debug.WriteLine($"[osu!] BASS mixers ({mixerHandles.Count}) moved to silent device 0 (Oboe active)"); } catch (Exception e) { @@ -101,31 +107,27 @@ private void restoreDefaultAudio() } } - private void addMixer(AudioMixer mixer) + private void addMixer(AudioMixer? mixer) { if (mixer == null) return; try { - // osu-framework AudioMixer usually has a private 'mixerHandle' field. - var field = mixer.GetType().GetField("mixerHandle", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public) - ?? mixer.GetType().GetField("Handle", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); + // Try various names and types for the native handle. + // osu-framework's AudioMixer usually wraps a BASS mixer handle. + object? handleObj = mixer.GetType().GetField("mixerHandle", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)?.GetValue(mixer) + ?? mixer.GetType().GetField("Handle", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)?.GetValue(mixer) + ?? mixer.GetType().GetProperty("Handle", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)?.GetValue(mixer); - if (field != null) - { - int handle = field.GetValue(mixer) is int h ? h : 0; - if (handle != 0) mixerHandles.Add(handle); - } - else - { - // Fallback to property - var prop = mixer.GetType().GetProperty("Handle", BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public); - if (prop != null) - { - int handle = prop.GetValue(mixer) is int h ? h : 0; - if (handle != 0) mixerHandles.Add(handle); - } - } + if (handleObj == null) return; + + int handle = 0; + if (handleObj is int ih) handle = ih; + else if (handleObj is long lh) handle = (int)lh; + else if (handleObj is IntPtr ph) handle = (int)ph.ToInt64(); + + if (handle != 0 && !mixerHandles.Contains(handle)) + mixerHandles.Add(handle); } catch (Exception e) { @@ -135,7 +137,9 @@ private void addMixer(AudioMixer mixer) private int provideAudio(IntPtr audioData, int numFrames) { - if (mixerHandles.Count == 0) return 0; + // If we haven't successfully silenced the default BASS output, + // return silence to avoid duplicated audio. + if (mixerHandles.Count == 0 || !devicesSilenced) return 0; // Oboe is configured for Stereo (2 channels). int numSamples = numFrames * 2;