Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 9 additions & 5 deletions osu.Android/AndroidNativeBridgeManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,10 @@ internal sealed class AndroidNativeBridgeManager : IDisposable

private volatile bool disposed;

// ── Oboe ───────────────────────────────────────────────────────────
// ── Oboe ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[MethodImpl(MethodImplOptions.NoInlining)]
public void StartOboeBridge(Scheduler scheduler, Action<double> onLatencyMeasured)
public void StartOboeBridge(Scheduler scheduler, Action<double> onLatencyMeasured, OboeAudioBridge.OboeAudioProvider? provider = null)
{
if (oboeBridge != null) return;

Expand All @@ -42,6 +42,10 @@ public void StartOboeBridge(Scheduler scheduler, Action<double> onLatencyMeasure
if (bridge != null)
{
oboeBridge = bridge;

if (provider != null)
bridge.SetProvider(provider);

bool started = bridge.Start();

if (started)
Expand Down Expand Up @@ -85,7 +89,7 @@ public double GetMeasuredAudioLatencyMs()
return (oboeBridge as OboeAudioBridge)?.GetOutputLatencyMs() ?? -1;
}

// ── Vulkan ─────────────────────────────────────────────────────────
// ── Vulkan ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[MethodImpl(MethodImplOptions.NoInlining)]
public void StartVulkanProbe()
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -150,7 +154,7 @@ private static void logOboeInfo(OboeAudioBridge bridge)
+ $"bufferSize={bridge.BufferSizeInFrames}frames");
}

// ── Cleanup ────────────────────────────────────────────────────────
// ── Cleanup ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

[MethodImpl(MethodImplOptions.NoInlining)]
public void Dispose()
Expand Down
35 changes: 31 additions & 4 deletions osu.Android/Native/OboeAudioBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@ namespace osu.Android.Native
/// <summary>
/// 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, burst buffer.
/// Optimised for lowest possible latency: AAudio preferred, exclusive mode, 1x burst buffer.
/// </summary>
public sealed class OboeAudioBridge : IDisposable
{
private const string lib_name = "osu_native";

/// <summary>
/// Callback function type for providing PCM audio data to the Oboe stream.
/// Returns the number of frames actually written to the buffer.
/// </summary>
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
public delegate int OboeAudioProvider(IntPtr audioData, int numFrames);

private IntPtr nativePtr;
private volatile bool disposed;

Expand Down Expand Up @@ -171,8 +178,8 @@ public int SampleRate
}

/// <summary>
/// The burst size in frames the optimal callback quantum.
/// Lower burst = lower latency. Typical Android values: 96192 frames.
/// The burst size in frames - the optimal callback quantum.
/// Lower burst = lower latency. Typical Android values: 96-192 frames.
/// </summary>
public int FramesPerBurst
{
Expand All @@ -193,7 +200,7 @@ public int FramesPerBurst

/// <summary>
/// The actual buffer size in frames. When optimised, this equals <see cref="FramesPerBurst"/>
/// for minimum latency ( burst).
/// for minimum latency (1x burst).
/// </summary>
public int BufferSizeInFrames
{
Expand Down Expand Up @@ -255,6 +262,23 @@ public bool IsMMap
}
}

/// <summary>
/// Sets the provider function that will be called to fill the audio buffer.
/// </summary>
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;
Expand Down Expand Up @@ -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);
}
}
41 changes: 30 additions & 11 deletions osu.Android/Native/oboe_bridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<size_t>(numFrames)
* static_cast<size_t>(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<size_t>(framesRead) * stream->getChannelCount() * sizeof(float);
size_t totalBytes = static_cast<size_t>(numFrames) * stream->getChannelCount() * sizeof(float);
memset(static_cast<char*>(audioData) + bytesDone, 0, totalBytes - bytesDone);
}
} else {
// Fallback to silence if no provider is registered.
size_t byteCount = static_cast<size_t>(numFrames)
* static_cast<size_t>(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
Expand Down Expand Up @@ -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<OboeBridge*>(ptr);
if (bridge) bridge->setProvider(provider);
}

} // extern "C"
9 changes: 9 additions & 0 deletions osu.Android/Native/oboe_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,11 @@
#include <oboe/Oboe.h>
#include <atomic>
#include <mutex>
#include <functional>

/// 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:
Expand Down Expand Up @@ -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;
Expand All @@ -61,6 +69,7 @@ class OboeBridge : public oboe::AudioStreamCallback {
std::atomic<bool> active_{false};
std::atomic<double> latencyMs_{-1.0};
std::atomic<uint32_t> callbackCount_{0};
std::atomic<OboeAudioProvider> provider_{nullptr};

void updateLatency();
void optimiseBufferSize();
Expand Down
120 changes: 120 additions & 0 deletions osu.Android/OboeAudioRedirector.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright (c) ppy Pty Ltd <contact@ppy.sh>. 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
{
/// <summary>
/// Redirects audio from the framework's BASS mixers into the Oboe bridge.
/// </summary>
internal sealed class OboeAudioRedirector : IDisposable
{
private readonly AudioManager audioManager;
private readonly List<int> mixerHandles = new List<int>();
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();
}
}
}
Loading
Loading