diff --git a/osu.Android/AndroidNativeBridgeManager.cs b/osu.Android/AndroidNativeBridgeManager.cs index 255bc2269859..04a1bb5d59be 100644 --- a/osu.Android/AndroidNativeBridgeManager.cs +++ b/osu.Android/AndroidNativeBridgeManager.cs @@ -28,10 +28,10 @@ internal sealed class AndroidNativeBridgeManager : IDisposable private volatile bool disposed; - // ── Oboe ─────────────────────────────────────────────────────────── + // ── Oboe ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [MethodImpl(MethodImplOptions.NoInlining)] - public void StartOboeBridge(Scheduler scheduler, Action onLatencyMeasured) + public void StartOboeBridge(Scheduler scheduler, Action onLatencyMeasured, OboeAudioBridge.OboeAudioProvider? provider = null) { if (oboeBridge != null) return; @@ -42,6 +42,10 @@ public void StartOboeBridge(Scheduler scheduler, Action onLatencyMeasure if (bridge != null) { oboeBridge = bridge; + + if (provider != null) + bridge.SetProvider(provider); + bool started = bridge.Start(); if (started) @@ -85,7 +89,7 @@ public double GetMeasuredAudioLatencyMs() return (oboeBridge as OboeAudioBridge)?.GetOutputLatencyMs() ?? -1; } - // ── Vulkan ───────────────────────────────────────────────────────── + // ── Vulkan ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [MethodImpl(MethodImplOptions.NoInlining)] public void StartVulkanProbe() @@ -116,7 +120,7 @@ public void StopVulkanProbe() Debug.WriteLine("[osu!] Vulkan probe stopped by user setting"); } - // ── Logging ──────────────────────────────────────────────────────── + // ── Logging ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [MethodImpl(MethodImplOptions.NoInlining)] private static void logVulkanInfo(VulkanProbe probe) @@ -150,7 +154,7 @@ private static void logOboeInfo(OboeAudioBridge bridge) + $"bufferSize={bridge.BufferSizeInFrames}frames"); } - // ── Cleanup ──────────────────────────────────────────────────────── + // ── Cleanup ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ [MethodImpl(MethodImplOptions.NoInlining)] public void Dispose() diff --git a/osu.Android/Native/OboeAudioBridge.cs b/osu.Android/Native/OboeAudioBridge.cs index 952833b5c6c5..208b3b38c5c2 100644 --- a/osu.Android/Native/OboeAudioBridge.cs +++ b/osu.Android/Native/OboeAudioBridge.cs @@ -10,12 +10,19 @@ namespace osu.Android.Native /// /// Managed wrapper around the native Oboe low-latency audio bridge. /// Provides accurate audio output latency measurement for rhythm-game synchronisation. - /// Optimised for lowest possible latency: AAudio preferred, exclusive mode, 1× burst buffer. + /// Optimised for lowest possible latency: AAudio preferred, exclusive mode, 1x burst buffer. /// public sealed class OboeAudioBridge : IDisposable { private const string lib_name = "osu_native"; + /// + /// Callback function type for providing PCM audio data to the Oboe stream. + /// Returns the number of frames actually written to the buffer. + /// + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + public delegate int OboeAudioProvider(IntPtr audioData, int numFrames); + private IntPtr nativePtr; private volatile bool disposed; @@ -171,8 +178,8 @@ public int SampleRate } /// - /// The burst size in frames — the optimal callback quantum. - /// Lower burst = lower latency. Typical Android values: 96–192 frames. + /// The burst size in frames - the optimal callback quantum. + /// Lower burst = lower latency. Typical Android values: 96-192 frames. /// public int FramesPerBurst { @@ -193,7 +200,7 @@ public int FramesPerBurst /// /// The actual buffer size in frames. When optimised, this equals - /// for minimum latency (1× burst). + /// for minimum latency (1x burst). /// public int BufferSizeInFrames { @@ -255,6 +262,23 @@ public bool IsMMap } } + /// + /// Sets the provider function that will be called to fill the audio buffer. + /// + public void SetProvider(OboeAudioProvider? provider) + { + if (disposed || nativePtr == IntPtr.Zero) return; + + try + { + nOboeSetProvider(nativePtr, provider); + } + catch (Exception e) + { + Debug.WriteLine($"[osu!] Oboe set provider failed: {e.Message}"); + } + } + public void Dispose() { if (disposed) return; @@ -315,5 +339,8 @@ public void Dispose() [DllImport(lib_name)] private static extern byte nOboeIsMMap(IntPtr ptr); + + [DllImport(lib_name)] + private static extern void nOboeSetProvider(IntPtr ptr, [MarshalAs(UnmanagedType.FunctionPtr)] OboeAudioProvider? provider); } } diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp index 0266250ee0b0..3c49bc205004 100644 --- a/osu.Android/Native/oboe_bridge.cpp +++ b/osu.Android/Native/oboe_bridge.cpp @@ -33,10 +33,9 @@ bool OboeBridge::open() { ->setPerformanceMode(oboe::PerformanceMode::LowLatency) ->setSharingMode(oboe::SharingMode::Exclusive) ->setFormat(oboe::AudioFormat::Float) - // Mono — this stream outputs silence for latency measurement only. - // Mono halves the per-callback buffer vs stereo, reducing the - // minimum achievable latency. - ->setChannelCount(oboe::ChannelCount::Mono) + // 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. @@ -173,16 +172,31 @@ bool OboeBridge::isMMap() const { 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) { - // Output silence — the primary purpose of this stream is latency measurement. - // Future: route game audio through this path for lowest possible latency. - // Using explicit cast to size_t to prevent overflow on large frame counts. - size_t byteCount = static_cast(numFrames) - * static_cast(stream->getChannelCount()) - * sizeof(float); - memset(audioData, 0, byteCount); + 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 @@ -329,4 +343,9 @@ OSU_EXPORT unsigned char nOboeIsMMap(intptr_t 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" diff --git a/osu.Android/Native/oboe_bridge.h b/osu.Android/Native/oboe_bridge.h index fa18efa1a66d..d7ccbb0efc69 100644 --- a/osu.Android/Native/oboe_bridge.h +++ b/osu.Android/Native/oboe_bridge.h @@ -6,6 +6,11 @@ #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. /// Optimised for rhythm-game audio-visual synchronization with: @@ -48,6 +53,9 @@ class OboeBridge : public oboe::AudioStreamCallback { /// 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. + void setProvider(OboeAudioProvider provider); + // oboe::AudioStreamCallback oboe::DataCallbackResult onAudioReady( oboe::AudioStream* stream, void* audioData, int32_t numFrames) override; @@ -61,6 +69,7 @@ class OboeBridge : public oboe::AudioStreamCallback { std::atomic active_{false}; std::atomic latencyMs_{-1.0}; std::atomic callbackCount_{0}; + std::atomic provider_{nullptr}; void updateLatency(); void optimiseBufferSize(); diff --git a/osu.Android/OboeAudioRedirector.cs b/osu.Android/OboeAudioRedirector.cs new file mode 100644 index 000000000000..4fe9a5b777bc --- /dev/null +++ b/osu.Android/OboeAudioRedirector.cs @@ -0,0 +1,120 @@ +// 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.Collections.Generic; +using System.Reflection; +using System.Runtime.InteropServices; +using ManagedBass; +using osu.Android.Native; +using osu.Framework.Audio; +using osu.Framework.Audio.Mixing; +using Debug = System.Diagnostics.Debug; + +namespace osu.Android +{ + /// + /// Redirects audio from the framework's BASS mixers into the Oboe bridge. + /// + internal sealed class OboeAudioRedirector : IDisposable + { + private readonly AudioManager audioManager; + private readonly List mixerHandles = new List(); + private readonly OboeAudioBridge.OboeAudioProvider providerDelegate; + + private float[]? mixBuffer; + private float[]? channelBuffer; + + public OboeAudioRedirector(AudioManager audioManager) + { + this.audioManager = audioManager; + this.providerDelegate = provideAudio; + } + + public OboeAudioBridge.OboeAudioProvider Provider => providerDelegate; + + public void RefreshMixers() + { + mixerHandles.Clear(); + + addMixer(audioManager.TrackMixer); + addMixer(audioManager.SampleMixer); + + Debug.WriteLine($"[osu!] Oboe redirector initialized with {mixerHandles.Count} BASS mixers"); + } + + 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); + + if (field != null) + { + int handle = (int)field.GetValue(mixer); + 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 = (int)prop.GetValue(mixer); + if (handle != 0) mixerHandles.Add(handle); + } + } + } + catch (Exception e) + { + Debug.WriteLine($"[osu!] Failed to get mixer handle via reflection: {e.Message}"); + } + } + + private int provideAudio(IntPtr audioData, int numFrames) + { + if (mixerHandles.Count == 0) 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; + + for (int i = 0; i < samplesRead; i++) + { + mixBuffer[i] += channelBuffer[i]; + } + } + + if (!anyRead) return 0; + + Marshal.Copy(mixBuffer, 0, audioData, numSamples); + return numFrames; + } + + public void Dispose() + { + mixerHandles.Clear(); + } + } +} diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 35971ca6b6e6..101ad8451a19 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -1,4 +1,4 @@ -// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. // See the LICENCE file in the repository root for full licence text. using System; @@ -17,6 +17,7 @@ using osu.Game.Screens; using osu.Game.Updater; using osu.Game.Utils; +using osu.Android.Native; using osuTK; using Debug = System.Diagnostics.Debug; @@ -28,6 +29,7 @@ public partial class OsuGameAndroid : OsuGame private readonly OsuGameActivity gameActivity; private readonly object packageInfoLock = new object(); + private PackageInfo? packageInfo; private bool packageInfoChecked; @@ -35,18 +37,15 @@ public partial class OsuGameAndroid : OsuGame { lock (packageInfoLock) { - if (packageInfoChecked) - return packageInfo; + if (packageInfoChecked) return packageInfo; try { - // Use the activity instance directly instead of Application.Context to ensure - // the PackageManager is accessible even on newer/stricter Android versions. packageInfo = gameActivity.PackageManager?.GetPackageInfo(gameActivity.PackageName!, 0); } - catch (Exception e) + catch { - Debug.WriteLine($"[osu!] Failed to retrieve package info: {e.Message}"); + // ignore errors. } finally { @@ -66,6 +65,8 @@ public partial class OsuGameAndroid : OsuGame 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 @@ -121,6 +122,8 @@ private void load() LocalConfig.BindWith(OsuSetting.AndroidLowLatencyAudio, lowLatencyAudio); LocalConfig.BindWith(OsuSetting.AndroidVulkanProbe, vulkanProbeEnabled); LocalConfig.BindWith(OsuSetting.AudioOffset, audioOffset); + + audioRedirector = new OboeAudioRedirector(Audio); } protected override void LoadComplete() @@ -146,6 +149,8 @@ protected override void LoadComplete() { if (e.NewValue) { + audioRedirector?.RefreshMixers(); + startOboeBridge(latency => { // Only auto-suggest when the user hasn't already configured a manual offset. @@ -155,7 +160,7 @@ protected override void LoadComplete() 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); } else if (nativeBridges != null) stopOboeBridge(); @@ -284,17 +289,17 @@ public double GetMeasuredAudioLatencyMs() return getMeasuredAudioLatencyFromBridge(); } - // ── Native bridge helpers ────────────────────────────────────────── - // Every method below is [NoInlining] so that AndroidNativeBridgeManager + // ── Native bridge helpers ━━━━━━━━━━━━━━━━━━━━━━━━━━ + // 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) + private void startOboeBridge(Action onLatencyMeasured, OboeAudioBridge.OboeAudioProvider? provider = null) { nativeBridges ??= new AndroidNativeBridgeManager(); if (nativeBridges is AndroidNativeBridgeManager mgr) - mgr.StartOboeBridge(Scheduler, onLatencyMeasured); + mgr.StartOboeBridge(Scheduler, onLatencyMeasured, provider); } [MethodImpl(MethodImplOptions.NoInlining)] @@ -395,6 +400,9 @@ protected override void Dispose(bool isDisposing) } finally { + audioRedirector?.Dispose(); + audioRedirector = null; + if (nativeBridges != null) disposeNativeBridges(); }