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
4 changes: 2 additions & 2 deletions osu.Android/AndroidNativeBridgeManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,13 @@ internal sealed class AndroidNativeBridgeManager : IDisposable
// ── Oboe ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━

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

try
{
var bridge = OboeAudioBridge.Create();
var bridge = OboeAudioBridge.Create(sampleRate);

if (bridge != null)
{
Expand Down
5 changes: 3 additions & 2 deletions osu.Android/Native/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
6 changes: 3 additions & 3 deletions osu.Android/Native/OboeAudioBridge.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
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)
{
Expand Down Expand Up @@ -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);
Expand Down
64 changes: 26 additions & 38 deletions osu.Android/Native/oboe_bridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

#include "oboe_bridge.h"
#include <oboe/OboeExtensions.h>
#include <oboe/AudioClock.h>
#include <oboe/Process.h>
#include <android/log.h>
#include <cstdint>
#include <cstring>
Expand All @@ -21,19 +23,24 @@ OboeBridge::~OboeBridge() {
LOGI("OboeBridge destroyed");
}

bool OboeBridge::open() {
bool OboeBridge::open(int32_t sampleRate) {
std::lock_guard<std::mutex> 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<oboe::StabilizedCallback>(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)
Expand All @@ -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_);

Expand All @@ -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<oboe::LatencyTuner>(*stream_);

LOGI("Oboe stream opened: api=%s, sampleRate=%d, framesPerBurst=%d, "
"bufferSize=%d, bufferCapacity=%d, sharingMode=%s, mmap=%s",
Expand All @@ -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<std::mutex> lock(streamLock_);

Expand Down Expand Up @@ -127,6 +117,8 @@ void OboeBridge::stop() {
stream_.reset();
}

tuner_.reset();
stabilizedCallback_.reset();
latencyMs_.store(-1.0);
callbackCount_.store(0);
LOGI("Oboe stream stopped");
Expand Down Expand Up @@ -173,21 +165,19 @@ 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);

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);
Expand All @@ -196,17 +186,20 @@ 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);

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<int> exclusiveCores = oboe::Process::getExclusiveCores();

Expand All @@ -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);
Expand All @@ -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<std::mutex> lock(streamLock_);
Expand All @@ -259,7 +247,7 @@ void OboeBridge::onErrorAfterClose(oboe::AudioStream* stream, oboe::Result error
}

bool OboeBridge::reopenAndRestart() {
if (open()) {
if (open(requestedSampleRate_)) {
std::lock_guard<std::mutex> lock(streamLock_);

if (stream_) {
Expand Down Expand Up @@ -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;
}
Expand Down
34 changes: 8 additions & 26 deletions osu.Android/Native/oboe_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,55 +4,34 @@
#pragma once

#include <oboe/Oboe.h>
#include <oboe/LatencyTuner.h>
#include <oboe/StabilizedCallback.h>
#include <atomic>
#include <mutex>
#include <functional>
#include <memory>

/// 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
Expand All @@ -64,14 +43,17 @@ class OboeBridge : public oboe::AudioStreamCallback {

private:
std::shared_ptr<oboe::AudioStream> stream_;
std::unique_ptr<oboe::LatencyTuner> tuner_;
std::unique_ptr<oboe::StabilizedCallback> stabilizedCallback_;

std::mutex streamLock_;
std::atomic<bool> active_{false};
std::atomic<double> latencyMs_{-1.0};
std::atomic<uint32_t> callbackCount_{0};
std::atomic<OboeAudioProvider> provider_{nullptr};
std::atomic<bool> affinitySet_{false};
int32_t requestedSampleRate_{0};

void updateLatency();
void optimiseBufferSize();
bool reopenAndRestart();
};
31 changes: 28 additions & 3 deletions osu.Android/OsuGameAndroid.cs
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// 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 Android.Media;
using System;
using System.Linq;
using System.Runtime.CompilerServices;
Expand Down Expand Up @@ -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 =>
{
Expand Down Expand Up @@ -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<double> onLatencyMeasured, IntPtr provider, Action<int>? 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()
{
Expand Down
Loading