diff --git a/osu.Android/AndroidNativeBridgeManager.cs b/osu.Android/AndroidNativeBridgeManager.cs index 8be6acb0e1b3..815cd512067e 100644 --- a/osu.Android/AndroidNativeBridgeManager.cs +++ b/osu.Android/AndroidNativeBridgeManager.cs @@ -31,13 +31,13 @@ internal sealed class AndroidNativeBridgeManager : IDisposable // ── Oboe ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [MethodImpl(MethodImplOptions.NoInlining)] - public void StartOboeBridge(Scheduler scheduler, Action onLatencyMeasured, IntPtr provider, Action? onStarted = null) + public void StartOboeBridge(Scheduler scheduler, Action onLatencyMeasured, IntPtr provider, int sampleRate = 0, Action? onStarted = null) { if (oboeBridge != null) return; try { - var bridge = OboeAudioBridge.Create(); + var bridge = OboeAudioBridge.Create(sampleRate); if (bridge != null) { diff --git a/osu.Android/Native/CMakeLists.txt b/osu.Android/Native/CMakeLists.txt index f71cbc60e344..3ae7d08e7dc2 100644 --- a/osu.Android/Native/CMakeLists.txt +++ b/osu.Android/Native/CMakeLists.txt @@ -18,11 +18,12 @@ set(CMAKE_SHARED_LINKER_FLAGS_RELEASE "-Wl,--gc-sections -s") # This reduces the Oboe portion of the binary by ~50%. set(OBOE_ENABLE_FLOWGRAPH OFF CACHE BOOL "Disable Oboe flowgraph to reduce binary size") -# Download and build Oboe 1.10.0 from source to ensure we have the latest +# Download and build Oboe main branch from source to ensure we have the latest # features (like ADPF performance hints) regardless of the build environment. include(FetchContent) FetchContent_Declare(oboe - URL https://github.com/google/oboe/archive/refs/tags/1.10.0.tar.gz + GIT_REPOSITORY https://github.com/google/oboe.git + GIT_TAG main ) FetchContent_MakeAvailable(oboe) diff --git a/osu.Android/Native/OboeAudioBridge.cs b/osu.Android/Native/OboeAudioBridge.cs index 8993cdf16a1e..a083a489e5e5 100644 --- a/osu.Android/Native/OboeAudioBridge.cs +++ b/osu.Android/Native/OboeAudioBridge.cs @@ -48,14 +48,14 @@ static OboeAudioBridge() /// Creates and opens a new low-latency Oboe audio stream. /// Returns null if native library or stream creation fails. /// - public static OboeAudioBridge? Create() + public static OboeAudioBridge? Create(int sampleRate = 0) { if (!native_loaded) return null; try { - IntPtr ptr = nOboeCreate(); + IntPtr ptr = nOboeCreate(sampleRate); if (ptr == IntPtr.Zero) { @@ -303,7 +303,7 @@ public void Dispose() } [DllImport(lib_name)] - private static extern IntPtr nOboeCreate(); + private static extern IntPtr nOboeCreate(int sampleRate); [DllImport(lib_name)] private static extern void nOboeDestroy(IntPtr ptr); diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp index 27e71d36fa2a..34f3012e3ad5 100644 --- a/osu.Android/Native/oboe_bridge.cpp +++ b/osu.Android/Native/oboe_bridge.cpp @@ -3,6 +3,8 @@ #include "oboe_bridge.h" #include +#include +#include #include #include #include @@ -21,19 +23,24 @@ OboeBridge::~OboeBridge() { LOGI("OboeBridge destroyed"); } -bool OboeBridge::open() { +bool OboeBridge::open(int32_t sampleRate) { std::lock_guard lock(streamLock_); + requestedSampleRate_ = sampleRate; // Low-latency MMAP path requires explicit enabling in Oboe. oboe::OboeExtensions::setMMapEnabled(true); + // Initialise StabilizedCallback to even out callback execution time. + // We create it here so we can pass it to the builder. + stabilizedCallback_ = std::make_unique(this); + oboe::AudioStreamBuilder builder; builder.setDirection(oboe::Direction::Output) ->setPerformanceMode(oboe::PerformanceMode::LowLatency) ->setSharingMode(oboe::SharingMode::Exclusive) ->setFormat(oboe::AudioFormat::Float) ->setChannelCount(oboe::ChannelCount::Stereo) - ->setSampleRate(oboe::kUnspecified) + ->setSampleRate(sampleRate > 0 ? sampleRate : oboe::kUnspecified) ->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::None) ->setContentType(oboe::ContentType::Music) ->setUsage(oboe::Usage::Game) @@ -42,7 +49,7 @@ bool OboeBridge::open() { ->setBufferCapacityInFrames(oboe::kUnspecified) ->setChannelConversionAllowed(false) ->setFormatConversionAllowed(false) - ->setCallback(this); + ->setCallback(stabilizedCallback_.get()); oboe::Result result = builder.openStream(stream_); @@ -59,11 +66,11 @@ bool OboeBridge::open() { } // Enable ADPF (Android Dynamic Performance Framework) hint support. - // This allows the Android kernel to provide maximum priority and frequency scaling - // to the audio thread for improved stability and lower jitter. stream_->setPerformanceHintEnabled(true); - optimiseBufferSize(); + // Initialise LatencyTuner for dynamic buffer management. + // This allows us to start at 1x burst and only grow if underruns occur. + tuner_ = std::make_unique(*stream_); LOGI("Oboe stream opened: api=%s, sampleRate=%d, framesPerBurst=%d, " "bufferSize=%d, bufferCapacity=%d, sharingMode=%s, mmap=%s", @@ -78,23 +85,6 @@ bool OboeBridge::open() { return true; } -void OboeBridge::optimiseBufferSize() { - if (!stream_) return; - - // Set buffer size to exactly 2× burst for optimal stability/latency balance. - // 1x is the theoretical minimum but often results in "crusty" audio (underruns) - // on modern devices due to OS scheduler jitter. 2x is a reliable "gold standard". - int32_t burst = stream_->getFramesPerBurst(); - - if (burst > 0) { - auto setResult = stream_->setBufferSizeInFrames(burst * 2); - - if (setResult) { - LOGI("Buffer size tuned to %d frames (2x burst)", setResult.value()); - } - } -} - bool OboeBridge::start() { std::lock_guard lock(streamLock_); @@ -127,6 +117,8 @@ void OboeBridge::stop() { stream_.reset(); } + tuner_.reset(); + stabilizedCallback_.reset(); latencyMs_.store(-1.0); callbackCount_.store(0); LOGI("Oboe stream stopped"); @@ -173,7 +165,7 @@ oboe::DataCallbackResult OboeBridge::onAudioReady( oboe::AudioStream* stream, void* audioData, int32_t numFrames) { // Record the start time of this callback for ADPF work duration reporting. - int64_t startTime = oboe::DefaultClock::getNanoseconds(); + int64_t startTime = oboe::AudioClock::getNanoseconds(); OboeAudioProvider provider = provider_.load(std::memory_order_acquire); @@ -181,13 +173,11 @@ oboe::DataCallbackResult OboeBridge::onAudioReady( int32_t framesRead = provider(audioData, numFrames); if (framesRead < numFrames) { - // Fill remaining buffer with silence if provider didn't return enough data. size_t bytesDone = static_cast(framesRead) * stream->getChannelCount() * sizeof(float); size_t totalBytes = static_cast(numFrames) * stream->getChannelCount() * sizeof(float); memset(static_cast(audioData) + bytesDone, 0, totalBytes - bytesDone); } } else { - // Fallback to silence if no provider is registered. size_t byteCount = static_cast(numFrames) * static_cast(stream->getChannelCount()) * sizeof(float); @@ -196,7 +186,7 @@ oboe::DataCallbackResult OboeBridge::onAudioReady( // Reporting actual work duration helps ADPF (Android Dynamic Performance Framework) // adjust CPU frequency precisely to handle the audio load without skipping. - int64_t endTime = oboe::DefaultClock::getNanoseconds(); + int64_t endTime = oboe::AudioClock::getNanoseconds(); stream->reportActualWorkDuration(endTime - startTime); uint32_t count = callbackCount_.fetch_add(1, std::memory_order_relaxed); @@ -204,9 +194,12 @@ 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. This is essential for preventing scheduler-related underruns. + // Dynamically tune the buffer size to the lowest stable value. + if (tuner_) { + tuner_->tune(); + } + + // Attempt to set CPU affinity to high-performance cores. if (!affinitySet_.load(std::memory_order_relaxed)) { std::vector exclusiveCores = oboe::Process::getExclusiveCores(); @@ -217,8 +210,6 @@ oboe::DataCallbackResult OboeBridge::onAudioReady( 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); @@ -238,9 +229,6 @@ void OboeBridge::onErrorAfterClose(oboe::AudioStream* stream, oboe::Result error oboe::convertToText(error)); active_.store(false); - // Automatic stream recovery: re-open and restart on disconnect / route change. - // This is critical for maintaining low-latency audio when headphones are - // plugged/unplugged or Bluetooth devices connect/disconnect. if (error == oboe::Result::ErrorDisconnected) { { std::lock_guard lock(streamLock_); @@ -259,7 +247,7 @@ void OboeBridge::onErrorAfterClose(oboe::AudioStream* stream, oboe::Result error } bool OboeBridge::reopenAndRestart() { - if (open()) { + if (open(requestedSampleRate_)) { std::lock_guard lock(streamLock_); if (stream_) { @@ -295,12 +283,12 @@ void OboeBridge::updateLatency() { extern "C" { -OSU_EXPORT intptr_t nOboeCreate() { +OSU_EXPORT intptr_t nOboeCreate(int sampleRate) { auto* bridge = new (std::nothrow) OboeBridge(); if (!bridge) return 0; - if (!bridge->open()) { + if (!bridge->open(sampleRate)) { delete bridge; return 0; } diff --git a/osu.Android/Native/oboe_bridge.h b/osu.Android/Native/oboe_bridge.h index 75a96c71463b..4b8f08222ecf 100644 --- a/osu.Android/Native/oboe_bridge.h +++ b/osu.Android/Native/oboe_bridge.h @@ -4,55 +4,34 @@ #pragma once #include +#include +#include #include #include #include +#include /// Callback function type for providing PCM audio data to the Oboe stream. /// Returns the number of frames actually written to the buffer. typedef int32_t (*OboeAudioProvider)(void* audioData, int32_t numFrames); /// Low-latency audio bridge using Google's Oboe library. -/// Optimized 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) -/// - Stereo Float output (matches BASS master mixer format) -/// - Buffer size tuned to 2× burst for stability on modern devices -/// - ADPF (Android Dynamic Performance Framework) integration -/// - CPU Affinity pinning to high-performance cores -/// - Automatic stream recovery on disconnect / route change class OboeBridge : public oboe::AudioStreamCallback { public: OboeBridge(); ~OboeBridge(); - bool open(); + bool open(int32_t sampleRate = 0); bool start(); void stop(); - /// Returns the measured output latency in milliseconds, or -1 if unavailable. double getOutputLatencyMs() const; - - /// Returns true if the stream is currently active. bool isActive() const; - - /// Returns the negotiated sample rate of the open stream (e.g. 48000). int32_t getSampleRate() const; - - /// Returns the optimal burst size in frames (one callback quantum). int32_t getFramesPerBurst() const; - - /// Returns the current buffer size in frames. int32_t getBufferSizeInFrames() const; - - /// Returns true if the stream is using AAudio (vs OpenSL ES fallback). bool isAAudio() const; - - /// Returns true if the stream is using the hardware MMAP path (lowest possible latency). bool isMMap() const; - - /// Sets the provider function that will be called to fill the audio buffer. void setProvider(OboeAudioProvider provider); // oboe::AudioStreamCallback @@ -64,14 +43,17 @@ class OboeBridge : public oboe::AudioStreamCallback { private: std::shared_ptr stream_; + std::unique_ptr tuner_; + std::unique_ptr stabilizedCallback_; + std::mutex streamLock_; std::atomic active_{false}; std::atomic latencyMs_{-1.0}; std::atomic callbackCount_{0}; std::atomic provider_{nullptr}; std::atomic affinitySet_{false}; + int32_t requestedSampleRate_{0}; void updateLatency(); - void optimiseBufferSize(); bool reopenAndRestart(); }; diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 5f8d8cca7fb2..c5657bc46fa9 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -1,6 +1,7 @@ // Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. +using Android.Media; using System; using System.Linq; using System.Runtime.CompilerServices; @@ -145,11 +146,22 @@ protected override void LoadComplete() lowLatencyAudio.BindValueChanged(e => { + int hardwareSampleRate = 0; + try + { + if (gameActivity.GetSystemService(Android.Content.Context.AudioService) is AudioManager audioManager) + { + string? rateStr = audioManager.GetProperty(AudioManager.PropertyOutputSampleRate); + if (!string.IsNullOrEmpty(rateStr)) + hardwareSampleRate = int.Parse(rateStr); + } + } + catch { } try { if (e.NewValue) { - audioRedirector?.RefreshMixers(); + audioRedirector?.RefreshMixers(hardwareSampleRate); startOboeBridge(latency => { @@ -297,15 +309,28 @@ public double GetMeasuredAudioLatencyMs() // Every method below is [MethodImplOptions.NoInlining] so that AndroidNativeBridgeManager // (and its P/Invoke field types) are never resolved until explicitly called. + [MethodImpl(MethodImplOptions.NoInlining)] private void startOboeBridge(Action onLatencyMeasured, IntPtr provider, Action? onStarted = null) { + int hardwareSampleRate = 0; + + try + { + if (gameActivity.GetSystemService(Android.Content.Context.AudioService) is AudioManager audioManager) + { + string? rateStr = audioManager.GetProperty(AudioManager.PropertyOutputSampleRate); + if (!string.IsNullOrEmpty(rateStr)) + hardwareSampleRate = int.Parse(rateStr); + } + } + catch { } + nativeBridges ??= new AndroidNativeBridgeManager(); if (nativeBridges is AndroidNativeBridgeManager mgr) - mgr.StartOboeBridge(Scheduler, onLatencyMeasured, provider, onStarted); + mgr.StartOboeBridge(Scheduler, onLatencyMeasured, provider, hardwareSampleRate, onStarted); } - [MethodImpl(MethodImplOptions.NoInlining)] private void stopOboeBridge() {