diff --git a/osu.Android/AndroidNativeBridgeManager.cs b/osu.Android/AndroidNativeBridgeManager.cs index 04a1bb5d59be..a4fff6293f2c 100644 --- a/osu.Android/AndroidNativeBridgeManager.cs +++ b/osu.Android/AndroidNativeBridgeManager.cs @@ -31,7 +31,7 @@ internal sealed class AndroidNativeBridgeManager : IDisposable // ── Oboe ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [MethodImpl(MethodImplOptions.NoInlining)] - public void StartOboeBridge(Scheduler scheduler, Action onLatencyMeasured, OboeAudioBridge.OboeAudioProvider? provider = null) + public void StartOboeBridge(Scheduler scheduler, Action onLatencyMeasured, OboeAudioBridge.OboeAudioProvider? provider = null, Action? onStarted = null) { if (oboeBridge != null) return; @@ -52,6 +52,8 @@ public void StartOboeBridge(Scheduler scheduler, Action onLatencyMeasure { logOboeInfo(bridge); + onStarted?.Invoke(bridge.SampleRate); + scheduler.AddDelayed(() => { if (oboeBridge is not OboeAudioBridge b) return; diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp index 38597140d67d..3136f98fbc96 100644 --- a/osu.Android/Native/oboe_bridge.cpp +++ b/osu.Android/Native/oboe_bridge.cpp @@ -1,93 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -#include "oboe_bridge.h" -#include -#include - -#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__) - -OboeBridge::OboeBridge() { - LOGI("OboeBridge created"); -} - -OboeBridge::~OboeBridge() { - stop(); - LOGI("OboeBridge destroyed"); -} - -bool OboeBridge::open() { - std::lock_guard lock(streamLock_); - - 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; - builder.setDirection(oboe::Direction::Output) - ->setPerformanceMode(oboe::PerformanceMode::LowLatency) - ->setSharingMode(oboe::SharingMode::Exclusive) - ->setFormat(oboe::AudioFormat::Float) - ->setChannelCount(oboe::ChannelCount::Stereo) - ->setSampleRate(oboe::kUnspecified) - ->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::None) - ->setContentType(oboe::ContentType::Music) - ->setUsage(oboe::Usage::Game) - ->setAudioApi(oboe::AudioApi::AAudio) - ->setFramesPerCallback(oboe::kUnspecified) - ->setBufferCapacityInFrames(oboe::kUnspecified) - ->setChannelConversionAllowed(false) - ->setFormatConversionAllowed(false) - ->setCallback(this); - - oboe::Result result = builder.openStream(stream_); - - if (result != oboe::Result::OK) { - LOGE("AAudio open failed (%s), falling back to unspecified API", - oboe::convertToText(result)); - builder.setAudioApi(oboe::AudioApi::Unspecified); - result = builder.openStream(stream_); - } - - if (result != oboe::Result::OK) { - LOGE("Failed to open Oboe stream: %s", oboe::convertToText(result)); - return false; - } - - // Enable ADPF for dynamic performance management. - // This is only supported on AAudio streams on Android 12+ (API 31+). - // Oboe handles the internal version checks. - stream_->setPerformanceHintEnabled(true); - - optimiseBufferSize(); - - LOGI("Oboe stream opened: api=%s, sampleRate=%d, framesPerBurst=%d, " - "bufferSize=%d, bufferCapacity=%d, sharingMode=%s, mmap=%s", - stream_->getAudioApi() == oboe::AudioApi::AAudio ? "AAudio" : "OpenSLES", - stream_->getSampleRate(), - stream_->getFramesPerBurst(), - stream_->getBufferSizeInFrames(), - stream_->getBufferCapacityInFrames(), - stream_->getSharingMode() == oboe::SharingMode::Exclusive ? "Exclusive" : "Shared", - oboe::OboeExtensions::isMMapUsed(stream_.get()) ? "yes" : "no"); - - return true; -} - -void OboeBridge::optimiseBufferSize() { - if (!stream_) return; - - // Set buffer size to exactly 1× burst for minimum latency. - // This gives the tightest possible callback schedule. - int32_t burst = stream_->getFramesPerBurst(); - +<<<<<<< SEARCH if (burst > 0) { auto setResult = stream_->setBufferSizeInFrames(burst); @@ -95,248 +6,15 @@ void OboeBridge::optimiseBufferSize() { LOGI("Buffer size tuned to %d frames (1x burst)", setResult.value()); } } -} - -bool OboeBridge::start() { - std::lock_guard lock(streamLock_); - - if (!stream_) { - LOGE("Cannot start: stream not opened"); - return false; - } - - oboe::Result result = stream_->requestStart(); - - if (result != oboe::Result::OK) { - LOGE("Failed to start Oboe stream: %s", oboe::convertToText(result)); - return false; - } - - active_.store(true); - LOGI("Oboe stream started"); - return true; -} - -void OboeBridge::stop() { - active_.store(false); - - std::lock_guard lock(streamLock_); - - if (stream_) { - stream_->stop(); - stream_->close(); - stream_.reset(); - } - - latencyMs_.store(-1.0); - callbackCount_.store(0); - LOGI("Oboe stream stopped"); -} - -double OboeBridge::getOutputLatencyMs() const { - return latencyMs_.load(); -} - -bool OboeBridge::isActive() const { - return active_.load(); -} - -int32_t OboeBridge::getSampleRate() const { - std::lock_guard lock(const_cast(streamLock_)); - return stream_ ? stream_->getSampleRate() : 0; -} - -int32_t OboeBridge::getFramesPerBurst() const { - std::lock_guard lock(const_cast(streamLock_)); - return stream_ ? stream_->getFramesPerBurst() : 0; -} - -int32_t OboeBridge::getBufferSizeInFrames() const { - std::lock_guard lock(const_cast(streamLock_)); - return stream_ ? stream_->getBufferSizeInFrames() : 0; -} - -bool OboeBridge::isAAudio() const { - std::lock_guard lock(const_cast(streamLock_)); - return stream_ && stream_->getAudioApi() == oboe::AudioApi::AAudio; -} - -bool OboeBridge::isMMap() const { - std::lock_guard lock(const_cast(streamLock_)); - return stream_ && oboe::OboeExtensions::isMMapUsed(stream_.get()); -} - -void OboeBridge::setProvider(OboeAudioProvider provider) { - provider_.store(provider, std::memory_order_release); -} - -oboe::DataCallbackResult OboeBridge::onAudioReady( - oboe::AudioStream* stream, void* audioData, int32_t numFrames) { - - OboeAudioProvider provider = provider_.load(std::memory_order_acquire); - - if (provider) { - 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); - memset(audioData, 0, byteCount); - } - - // Sample latency every 128 callbacks (~250 ms at typical burst/sample rates) - // instead of every single callback. calculateLatencyMillis() issues a - // system call; keeping it out of the majority of callbacks reduces jitter - // in this real-time audio thread. - uint32_t count = callbackCount_.fetch_add(1, std::memory_order_relaxed); - - if ((count & 127) == 0) { - updateLatency(); - } - - return oboe::DataCallbackResult::Continue; -} - -void OboeBridge::onErrorBeforeClose(oboe::AudioStream* stream, oboe::Result error) { - LOGE("Oboe error before close: %s", oboe::convertToText(error)); - active_.store(false); -} - -void OboeBridge::onErrorAfterClose(oboe::AudioStream* stream, oboe::Result error) { - LOGE("Oboe error after close: %s — attempting automatic recovery", - 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_); - stream_.reset(); - } - - if (reopenAndRestart()) { - LOGI("Oboe stream recovered successfully after disconnect"); - } else { - LOGE("Oboe stream recovery failed"); - } - } else { - std::lock_guard lock(streamLock_); - stream_.reset(); - } -} - -bool OboeBridge::reopenAndRestart() { - if (open()) { - std::lock_guard lock(streamLock_); - - if (stream_) { - oboe::Result result = stream_->requestStart(); - - if (result == oboe::Result::OK) { - active_.store(true); - return true; - } +======= + if (burst > 0) { + // Set buffer size to 2× burst for improved stability on Samsung and other devices. + // 1x burst is often too aggressive for managed code callbacks, causing underruns. + // 2x provides a safe jitter margin while still maintaining extremely low latency. + auto setResult = stream_->setBufferSizeInFrames(burst * 2); - LOGE("Failed to restart recovered stream: %s", oboe::convertToText(result)); + if (setResult) { + LOGI("Buffer size tuned to %d frames (2x burst)", setResult.value()); } } - - return false; -} - -void OboeBridge::updateLatency() { - if (!stream_) return; - - auto result = stream_->calculateLatencyMillis(); - - if (result) { - latencyMs_.store(result.value()); - } -} - -// ============================================================ -// C exports for P/Invoke from .NET -// ============================================================ - -#define OSU_EXPORT __attribute__((visibility("default"))) - -extern "C" { - -OSU_EXPORT intptr_t nOboeCreate() { - auto* bridge = new (std::nothrow) OboeBridge(); - - if (!bridge) return 0; - - if (!bridge->open()) { - delete bridge; - return 0; - } - - return reinterpret_cast(bridge); -} - -OSU_EXPORT void nOboeDestroy(intptr_t ptr) { - if (ptr) delete reinterpret_cast(ptr); -} - -OSU_EXPORT unsigned char nOboeStart(intptr_t ptr) { - auto* bridge = reinterpret_cast(ptr); - return (bridge && bridge->start()) ? 1 : 0; -} - -OSU_EXPORT void nOboeStop(intptr_t ptr) { - auto* bridge = reinterpret_cast(ptr); - if (bridge) bridge->stop(); -} - -OSU_EXPORT double nOboeGetLatencyMs(intptr_t ptr) { - auto* bridge = reinterpret_cast(ptr); - return bridge ? bridge->getOutputLatencyMs() : -1.0; -} - -OSU_EXPORT unsigned char nOboeIsActive(intptr_t ptr) { - auto* bridge = reinterpret_cast(ptr); - return (bridge && bridge->isActive()) ? 1 : 0; -} - -OSU_EXPORT int nOboeGetSampleRate(intptr_t ptr) { - auto* bridge = reinterpret_cast(ptr); - return bridge ? bridge->getSampleRate() : 0; -} - -OSU_EXPORT int nOboeGetFramesPerBurst(intptr_t ptr) { - auto* bridge = reinterpret_cast(ptr); - return bridge ? bridge->getFramesPerBurst() : 0; -} - -OSU_EXPORT int nOboeGetBufferSizeInFrames(intptr_t ptr) { - auto* bridge = reinterpret_cast(ptr); - return bridge ? bridge->getBufferSizeInFrames() : 0; -} - -OSU_EXPORT unsigned char nOboeIsAAudio(intptr_t ptr) { - auto* bridge = reinterpret_cast(ptr); - return (bridge && bridge->isAAudio()) ? 1 : 0; -} - -OSU_EXPORT unsigned char nOboeIsMMap(intptr_t ptr) { - auto* bridge = reinterpret_cast(ptr); - return (bridge && bridge->isMMap()) ? 1 : 0; -} - -OSU_EXPORT void nOboeSetProvider(intptr_t ptr, OboeAudioProvider provider) { - auto* bridge = reinterpret_cast(ptr); - if (bridge) bridge->setProvider(provider); -} - -} // extern "C" +>>>>>>> REPLACE diff --git a/osu.Android/OboeAudioRedirector.cs b/osu.Android/OboeAudioRedirector.cs index 9c7d8275f79d..3e9d462d7797 100644 --- a/osu.Android/OboeAudioRedirector.cs +++ b/osu.Android/OboeAudioRedirector.cs @@ -3,11 +3,10 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Numerics; using System.Reflection; using System.Runtime.InteropServices; using ManagedBass; +using ManagedBass.Mix; using osu.Android.Native; using osu.Framework.Audio; using osu.Framework.Audio.Mixing; @@ -17,17 +16,17 @@ namespace osu.Android { /// /// Redirects audio from the framework's BASS mixers into the Oboe bridge. + /// Optimized for zero-copy delivery and hardware sample rate synchronization. /// internal sealed class OboeAudioRedirector : IDisposable { private readonly AudioManager audioManager; private readonly List mixerHandles = new List(); + private int masterMixer; private bool devicesSilenced; + private int sampleRate = 44100; // Default, will be updated from bridge. private readonly OboeAudioBridge.OboeAudioProvider providerDelegate; - private float[]? mixBuffer; - private float[]? channelBuffer; - public OboeAudioRedirector(AudioManager audioManager) { this.audioManager = audioManager; @@ -36,25 +35,63 @@ public OboeAudioRedirector(AudioManager audioManager) public OboeAudioBridge.OboeAudioProvider Provider => providerDelegate; - public void RefreshMixers() + public void RefreshMixers(int hardwareSampleRate) { - mixerHandles.Clear(); + sampleRate = hardwareSampleRate > 0 ? hardwareSampleRate : 44100; + mixerHandles.Clear(); addMixer(audioManager.TrackMixer); addMixer(audioManager.SampleMixer); silenceDefaultAudio(); + setupMasterMixer(); - Debug.WriteLine($"[osu!] Oboe redirector initialized with {mixerHandles.Count} BASS mixers"); + Debug.WriteLine($"[osu!] Oboe redirector initialized: rate={sampleRate}Hz, mixers={mixerHandles.Count}"); + } + + private void setupMasterMixer() + { + if (masterMixer != 0) + { + Bass.StreamFree(masterMixer); + masterMixer = 0; + } + + if (mixerHandles.Count == 0 || !devicesSilenced) return; + + // Create a BASS master mixer that matches the Oboe hardware format (Stereo Float). + // We use BASS_MIXER_NONSTOP to ensure the mixer doesn't stall if sources are empty. + // BASS_STREAM_DECODE means we pull data manually via ChannelGetData. + masterMixer = BassMix.CreateMixerStream(sampleRate, 2, BassFlags.Float | BassFlags.Decode | BassFlags.MixerNonStop); + + if (masterMixer == 0) + { + Debug.WriteLine($"[osu!] Failed to create BASS master mixer: {Bass.LastError}"); + return; + } + + foreach (int handle in mixerHandles) + { + // Add redirected mixers as sources to our master mixer. + // We use BASS_MIXER_BUFFER to provide some internal buffering in BASS native code if needed, + // although for lowest latency we rely on the Oboe callback timing. + if (!BassMix.MixerAddChannel(masterMixer, handle, BassFlags.MixerChanNoRampin | BassFlags.MixerChanBuffer)) + { + Debug.WriteLine($"[osu!] Failed to add mixer {handle} to master mixer: {Bass.LastError}"); + } + } + + // Move the master mixer to the silent device too. + Bass.ChannelSetDevice(masterMixer, 0); } private void silenceDefaultAudio() { try { - // Initialize BASS "No Sound" device (0) if not already. - // This device allows BASS to process audio streams without outputting to hardware. - if (!Bass.Init(0) && Bass.LastError != Errors.Already) + // Initialize BASS "No Sound" device (0) with the hardware sample rate. + // This minimizes resampling overhead within BASS. + if (!Bass.Init(0, sampleRate) && Bass.LastError != Errors.Already) { Debug.WriteLine($"[osu!] Failed to initialize BASS No Sound device: {Bass.LastError}"); return; @@ -63,7 +100,6 @@ private void silenceDefaultAudio() 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)) @@ -90,10 +126,14 @@ private void restoreDefaultAudio() try { - // Move mixers back to the default device (usually 1 on Android). + if (masterMixer != 0) + { + Bass.StreamFree(masterMixer); + masterMixer = 0; + } + foreach (int handle in mixerHandles) { - // On Android, Device 1 is typically the default output. if (!Bass.ChannelSetDevice(handle, 1)) Debug.WriteLine($"[osu!] Failed to restore mixer {handle} to default device: {Bass.LastError}"); } @@ -113,8 +153,6 @@ private void addMixer(AudioMixer? mixer) try { - // 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); @@ -137,55 +175,16 @@ private void addMixer(AudioMixer? mixer) private int provideAudio(IntPtr audioData, int numFrames) { - // 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; - - if (mixBuffer == null || mixBuffer.Length < numSamples) - mixBuffer = new float[numSamples]; - - if (channelBuffer == null || channelBuffer.Length < numSamples) - channelBuffer = new float[numSamples]; - - Array.Clear(mixBuffer, 0, numSamples); - bool anyRead = false; - - foreach (int handle in mixerHandles) - { - // Pull stereo float data from BASS mixer. - int bytesRead = Bass.ChannelGetData(handle, channelBuffer, (numSamples * 4) | (int)DataFlags.Float); - if (bytesRead <= 0) continue; - - anyRead = true; - int samplesRead = bytesRead / 4; + if (masterMixer == 0 || !devicesSilenced) return 0; - int i = 0; - - if (Vector.IsHardwareAccelerated) - { - int vectorSize = Vector.Count; - - for (; i <= samplesRead - vectorSize; i += vectorSize) - { - var vMix = new Vector(mixBuffer, i); - var vChan = new Vector(channelBuffer, i); - (vMix + vChan).CopyTo(mixBuffer, i); - } - } - - for (; i < samplesRead; i++) - { - mixBuffer[i] += channelBuffer[i]; - } - } + // Zero-copy: Tell BASS to render directly into the memory provided by Oboe. + // BASS_DATA_FLOAT is implied by the mixer stream flags. + int bytesToRead = numFrames * 8; // 2 channels * 4 bytes/sample + int bytesRead = Bass.ChannelGetData(masterMixer, audioData, bytesToRead); - if (!anyRead) return 0; + if (bytesRead <= 0) return 0; - Marshal.Copy(mixBuffer, 0, audioData, numSamples); - return numFrames; + return bytesRead / 8; } public void Dispose() diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 29eb74090018..57d781bf2f9d 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -1,148 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. -// See the LICENCE file in the repository root for full licence text. - -using System; -using System.Linq; -using System.Runtime.CompilerServices; -using Android.App; -using Android.Content.PM; -using Android.Views; -using Microsoft.Maui.Devices; -using osu.Framework.Allocation; -using osu.Framework.Bindables; -using osu.Framework.Development; -using osu.Framework.Platform; -using osu.Game; -using osu.Game.Configuration; -using osu.Game.Screens; -using osu.Game.Updater; -using osu.Game.Utils; -using osu.Android.Native; -using osuTK; -using Debug = System.Diagnostics.Debug; - -namespace osu.Android -{ - public partial class OsuGameAndroid : OsuGame - { - [Cached] - private readonly OsuGameActivity gameActivity; - - private readonly object packageInfoLock = new object(); - - private PackageInfo? packageInfo; - private bool packageInfoChecked; - - private PackageInfo? getPackageInfo() - { - lock (packageInfoLock) - { - if (packageInfoChecked) return packageInfo; - - try - { - packageInfo = gameActivity.PackageManager?.GetPackageInfo(gameActivity.PackageName!, 0); - } - catch - { - // ignore errors. - } - finally - { - packageInfoChecked = true; - } - - return packageInfo; - } - } - - public override Vector2 ScalingContainerTargetDrawSize => DrawWidth > 0 && DrawHeight > 0 - ? new Vector2(1024, 1024 * DrawHeight / DrawWidth) - : new Vector2(1024, 768); - - private readonly Bindable performanceMode = new Bindable(); - private readonly Bindable lowLatencyAudio = new Bindable(); - private readonly Bindable vulkanProbeEnabled = new Bindable(); - private readonly BindableDouble audioOffset = new BindableDouble(); - - private OboeAudioRedirector? audioRedirector; - - /// - /// Boxed reference to the native bridge manager. - /// Declared as object? so that the runtime never resolves the concrete - /// AndroidNativeBridgeManager type (and its P/Invoke field types) during - /// OsuGameAndroid class initialisation — which would trigger - /// NativeLibrary.TryLoad before the framework is ready and crash on some - /// Samsung devices. - /// All access goes through [NoInlining] helpers below. - /// - private object? nativeBridges; - - public OsuGameAndroid(OsuGameActivity activity) - : base(null) - { - gameActivity = activity; - } - - public override string Version - { - get - { - if (!IsDeployedBuild) - return @"local " + (DebugUtils.IsDebugBuild ? @"debug" : @"release"); - - return getPackageInfo()?.VersionName ?? @"unknown"; - } - } - - public override Version AssemblyVersion - { - get - { - try - { - string? versionName = getPackageInfo()?.VersionName; - - if (!string.IsNullOrEmpty(versionName)) - return new Version(versionName.Split('-').First()); - } - catch (Exception e) - { - Debug.WriteLine($"[osu!] Failed to parse assembly version: {e.Message}"); - } - - return new Version(@"0.0.0"); - } - } - - [BackgroundDependencyLoader] - private void load() - { - LocalConfig.BindWith(OsuSetting.AndroidPerformanceMode, performanceMode); - LocalConfig.BindWith(OsuSetting.AndroidLowLatencyAudio, lowLatencyAudio); - LocalConfig.BindWith(OsuSetting.AndroidVulkanProbe, vulkanProbeEnabled); - LocalConfig.BindWith(OsuSetting.AudioOffset, audioOffset); - - audioRedirector = new OboeAudioRedirector(Audio); - } - - protected override void LoadComplete() - { - base.LoadComplete(); - UserPlayingState.BindValueChanged(_ => updateOrientation()); - - performanceMode.BindValueChanged(e => - { - try - { - applyPerformanceOptimizations(e.NewValue); - } - catch (Exception ex) - { - Debug.WriteLine($"[osu!] Failed to toggle performance mode: {ex.Message}"); - } - }, true); - +<<<<<<< SEARCH lowLatencyAudio.BindValueChanged(e => { try @@ -174,129 +30,42 @@ protected override void LoadComplete() Debug.WriteLine($"[osu!] Failed to toggle Oboe bridge: {ex.Message}"); } }, true); - - vulkanProbeEnabled.BindValueChanged(e => +======= + lowLatencyAudio.BindValueChanged(e => { try { if (e.NewValue) - startVulkanProbe(); - else if (nativeBridges != null) - stopVulkanProbe(); - } - catch (Exception ex) - { - Debug.WriteLine($"[osu!] Failed to toggle Vulkan probe: {ex.Message}"); - } - }, true); - - // Apply unbuffered touch dispatch (deferred from Activity lifecycle to avoid early crash). - try - { - if (OperatingSystem.IsAndroidVersionAtLeast(31)) - { - gameActivity.RunOnUiThread(() => { - try - { - gameActivity.Window?.DecorView?.RequestUnbufferedDispatch((int)InputSourceType.Touchscreen); - } - catch (Exception e) + startOboeBridge(latency => { - Debug.WriteLine($"[osu!] Failed to request unbuffered touch dispatch: {e.Message}"); - } - }); - } - } - catch (Exception e) - { - Debug.WriteLine($"[osu!] Failed to schedule unbuffered dispatch: {e.Message}"); - } - } - - private void applyPerformanceOptimizations(bool enabled) - { - gameActivity.RunOnUiThread(() => - { - try - { - gameActivity.Window?.SetSustainedPerformanceMode(enabled); - - if (enabled) - selectHighestRefreshRate(); - } - catch (Exception e) - { - Debug.WriteLine($"[osu!] Failed to apply performance optimizations: {e.Message}"); - } - }); - } - - private void selectHighestRefreshRate() - { - try - { - if (gameActivity.IsFinishing || gameActivity.IsDestroyed) - return; - - var window = gameActivity.Window; - var windowManager = gameActivity.WindowManager; - - if (window == null || windowManager == null) - return; - - var display = windowManager.DefaultDisplay; - if (display == null) - return; - -#pragma warning disable CA1422 - var modes = display.GetSupportedModes(); -#pragma warning restore CA1422 - - if (modes == null || modes.Length == 0) - return; - - var preferred = modes.OrderByDescending(m => m.RefreshRate).First(); + // Only auto-suggest when the user hasn't already configured a manual offset. + if (Math.Abs(audioOffset.Value) >= 0.01) + return; - gameActivity.RunOnUiThread(() => - { - try - { - if (window.Attributes is WindowManagerLayoutParams layoutParams) + double suggested = Math.Clamp(-latency, audioOffset.MinValue, audioOffset.MaxValue); + audioOffset.Value = suggested; + Debug.WriteLine($"[osu!] Audio offset auto-suggested: {suggested:F1}ms (hardware latency={latency:F1}ms)"); + }, audioRedirector?.Provider, sampleRate => { - layoutParams.PreferredDisplayModeId = preferred.ModeId; - window.Attributes = layoutParams; - Debug.WriteLine($"[osu!] Highest refresh rate selected: {preferred.RefreshRate}Hz (mode {preferred.ModeId})"); - } + // Initialise BASS mixers at the hardware sample rate to eliminate resampling latency. + audioRedirector?.RefreshMixers(sampleRate); + }); } - catch (Exception e) + else if (nativeBridges != null) { - // On some devices (e.g. Samsung S23 on Android 16), accessing display properties - // via the vendor property 'vendor.display.enable_optimal_refresh_rate' can trigger - // SELinux denials or crashes if the window is not yet fully trusted. - Debug.WriteLine($"[osu!] Failed to apply preferred display mode: {e.Message}"); + stopOboeBridge(); + audioRedirector?.Dispose(); + audioRedirector = new OboeAudioRedirector(Audio); } - }); - } - catch (Exception e) - { - Debug.WriteLine($"[osu!] Failed to query supported display modes: {e.Message}"); - } - } - - /// - /// Returns the measured audio output latency in milliseconds via the Oboe bridge, - /// or -1 if unavailable. Can be used to auto-suggest audio offset calibration. - /// - public double GetMeasuredAudioLatencyMs() - { - return getMeasuredAudioLatencyFromBridge(); - } - - // ── Native bridge helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━ - // Every method below is [MethodImplOptions.NoInlining] so that AndroidNativeBridgeManager - // (and its P/Invoke field types) are never resolved until explicitly called. - + } + catch (Exception ex) + { + Debug.WriteLine($"[osu!] Failed to toggle Oboe bridge: {ex.Message}"); + } + }, true); +>>>>>>> REPLACE +<<<<<<< SEARCH [MethodImpl(MethodImplOptions.NoInlining)] private void startOboeBridge(Action onLatencyMeasured, OboeAudioBridge.OboeAudioProvider? provider = null) { @@ -305,144 +74,13 @@ private void startOboeBridge(Action onLatencyMeasured, OboeAudioBridge.O if (nativeBridges is AndroidNativeBridgeManager mgr) mgr.StartOboeBridge(Scheduler, onLatencyMeasured, provider); } - - [MethodImpl(MethodImplOptions.NoInlining)] - private void stopOboeBridge() - { - (nativeBridges as AndroidNativeBridgeManager)?.StopOboeBridge(); - } - +======= [MethodImpl(MethodImplOptions.NoInlining)] - private void startVulkanProbe() + private void startOboeBridge(Action onLatencyMeasured, OboeAudioBridge.OboeAudioProvider? provider = null, Action? onStarted = null) { nativeBridges ??= new AndroidNativeBridgeManager(); if (nativeBridges is AndroidNativeBridgeManager mgr) - mgr.StartVulkanProbe(); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private void stopVulkanProbe() - { - (nativeBridges as AndroidNativeBridgeManager)?.StopVulkanProbe(); - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private double getMeasuredAudioLatencyFromBridge() - { - return (nativeBridges as AndroidNativeBridgeManager)?.GetMeasuredAudioLatencyMs() ?? -1; - } - - [MethodImpl(MethodImplOptions.NoInlining)] - private void disposeNativeBridges() - { - (nativeBridges as AndroidNativeBridgeManager)?.Dispose(); - nativeBridges = null; - } - - protected override void ScreenChanged(IOsuScreen? current, IOsuScreen? newScreen) - { - base.ScreenChanged(current, newScreen); - - if (newScreen != null) - updateOrientation(); - } - - private void updateOrientation() - { - // Read framework state on the update thread (the calling thread). - // ScreenStack may not be initialised yet during early LoadComplete callbacks. - if (ScreenStack?.CurrentScreen is not IOsuScreen currentScreen) - return; - - var orientation = MobileUtils.GetOrientation(this, currentScreen, gameActivity.IsTablet); - - // Only the Android UI property assignment is dispatched to the main thread. - gameActivity.RunOnUiThread(() => - { - try - { - switch (orientation) - { - case MobileUtils.Orientation.Locked: - gameActivity.RequestedOrientation = ScreenOrientation.Locked; - break; - - case MobileUtils.Orientation.Portrait: - gameActivity.RequestedOrientation = ScreenOrientation.Portrait; - break; - - case MobileUtils.Orientation.Default: - gameActivity.RequestedOrientation = gameActivity.DefaultOrientation; - break; - } - } - catch (Exception e) - { - Debug.WriteLine($"[osu!] Failed to update orientation: {e.Message}"); - } - }); - } - - public override void SetHost(GameHost host) - { - base.SetHost(host); - - if (host.Window != null) - host.Window.CursorState |= CursorState.Hidden; - } - - protected override UpdateManager CreateUpdateManager() => new MobileUpdateNotifier(); - - protected override BatteryInfo CreateBatteryInfo() => new AndroidBatteryInfo(); - - protected override void Dispose(bool isDisposing) - { - try - { - base.Dispose(isDisposing); - } - finally - { - audioRedirector?.Dispose(); - audioRedirector = null; - - if (nativeBridges != null) - disposeNativeBridges(); - } - } - - private class AndroidBatteryInfo : BatteryInfo - { - public override double? ChargeLevel - { - get - { - try - { - return Battery.ChargeLevel; - } - catch (Exception) - { - return null; - } - } - } - - public override bool OnBattery - { - get - { - try - { - return Battery.PowerSource == BatteryPowerSource.Battery; - } - catch (Exception) - { - return false; - } - } - } + mgr.StartOboeBridge(Scheduler, onLatencyMeasured, provider, onStarted); } - } -} +>>>>>>> REPLACE