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
3 changes: 1 addition & 2 deletions osu.Android/Native/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -27,8 +27,7 @@ if(oboe_FOUND)
else()
include(FetchContent)
FetchContent_Declare(oboe
URL https://github.com/google/oboe/archive/refs/tags/1.10.0.tar.gz
URL_HASH SHA256=0e4245f8860c4287040a5d76501c588490bcc9cb57614c486c0c201a5dde3e9f
URL https://github.com/google/oboe/archive/39b1fd258998eb3cd80a0f9e800498bb4efa4eb2.tar.gz
)
FetchContent_MakeAvailable(oboe)
set(OBOE_LIB oboe)
Expand Down
64 changes: 14 additions & 50 deletions osu.Android/Native/oboe_bridge.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,8 @@
#include "oboe_bridge.h"
#include <oboe/OboeExtensions.h>
#include <android/log.h>
#include <cstdint>
#include <cstring>

#define LOG_TAG "osu!native"
#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__)

Expand All @@ -23,45 +21,37 @@ OboeBridge::~OboeBridge() {
bool OboeBridge::open() {
std::lock_guard<std::mutex> lock(streamLock_);

// Request MMAP mode globally before opening the stream.
// MMAP provides a hardware-level DMA path that bypasses the kernel audio
// copy, shaving ~1-2 ms off the round-trip latency on supported devices.
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)
// 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.
->setSampleRate(oboe::kUnspecified)
// Explicitly forbid all internal conversions so that no resampler,
// channel mixer, or format converter sits in the audio path.
->setChannelConversionAllowed(false)
->setFormatConversionAllowed(false)
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::None)
// Semantic hints help Android route through the optimal audio path.
->setContentType(oboe::ContentType::Music)
->setUsage(oboe::Usage::Game)
->setCallback(this)
// Prefer AAudio for lowest latency (available on Android 8.1+).
// Falls back to OpenSL ES automatically on older devices.
->setAudioApi(oboe::AudioApi::AAudio)
// Request minimum buffer for lowest latency.
// Oboe will clamp to the smallest safe value.
->setFramesPerCallback(oboe::kUnspecified)
->setBufferCapacityInFrames(oboe::kUnspecified);
->setBufferCapacityInFrames(oboe::kUnspecified)
->setPerformanceHintEnabled(true) // Enable ADPF for dynamic performance management
->setChannelConversionAllowed(false)
->setFormatConversionAllowed(false)
->setCallback(this);

oboe::Result result = builder.openStream(stream_);

if (result != oboe::Result::OK) {
// AAudio might not be available; retry without API preference.
LOGI("AAudio open failed (%s), falling back to unspecified API",
LOGE("AAudio open failed (%s), falling back to unspecified API",
oboe::convertToText(result));
builder.setAudioApi(oboe::AudioApi::Unspecified);
result = builder.openStream(stream_);
Expand Down Expand Up @@ -134,7 +124,6 @@ void OboeBridge::stop() {
stream_.reset();
}

affinitySet_.store(false);
latencyMs_.store(-1.0);
callbackCount_.store(0);
LOGI("Oboe stream stopped");
Expand Down Expand Up @@ -207,26 +196,6 @@ 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.
if (!affinitySet_.load(std::memory_order_relaxed)) {
std::vector<int> exclusiveCores = oboe::Process::getExclusiveCores();

if (!exclusiveCores.empty()) {
oboe::Result result = oboe::Process::setThreadAffinity(
oboe::Process::getThreadId(),
exclusiveCores);

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);
}
}

return oboe::DataCallbackResult::Continue;
Expand Down Expand Up @@ -295,11 +264,6 @@ void OboeBridge::updateLatency() {
// C exports for P/Invoke from .NET
// ============================================================

// Use intptr_t for pointer handles so the size matches C# IntPtr on both
// 32-bit (4 bytes) and 64-bit (8 bytes) platforms. The previous use of
// C++ `long` was 4 bytes on 32-bit ARM/x86 but C# `long` is always
// 8 bytes, causing a calling-convention mismatch and crash.

#define OSU_EXPORT __attribute__((visibility("default")))

extern "C" {
Expand Down
11 changes: 0 additions & 11 deletions osu.Android/Native/oboe_bridge.h
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,6 @@
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:
/// - AAudio preferred (lowest latency path on Android 8.1+)
/// - MMAP enabled (hardware-level DMA, bypasses kernel copy)
/// - Exclusive sharing mode (bypass system mixer)
/// - Mono output (minimum buffer for latency-measurement stream)
/// - Buffer size tuned to 1× burst for minimum latency
/// - All format/rate/channel conversions disabled (zero resampler overhead)
/// - Latency sampled every 128 callbacks (avoids syscall overhead in hot path)
/// - Automatic stream recovery on disconnect / route change
class OboeBridge : public oboe::AudioStreamCallback {
public:
OboeBridge();
Expand Down Expand Up @@ -50,7 +41,6 @@ class OboeBridge : public oboe::AudioStreamCallback {
bool isAAudio() const;

/// Returns true if the stream is using the hardware MMAP path (lowest possible latency).
/// 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.
Expand All @@ -67,7 +57,6 @@ class OboeBridge : public oboe::AudioStreamCallback {
std::shared_ptr<oboe::AudioStream> stream_;
std::mutex streamLock_;
std::atomic<bool> active_{false};
std::atomic<bool> affinitySet_{false};
std::atomic<double> latencyMs_{-1.0};
std::atomic<uint32_t> callbackCount_{0};
std::atomic<OboeAudioProvider> provider_{nullptr};
Expand Down
52 changes: 28 additions & 24 deletions osu.Android/OboeAudioRedirector.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System;
using System.Collections.Generic;
using System.Linq;
using System.Numerics;
using System.Reflection;
using System.Runtime.InteropServices;
Expand Down Expand Up @@ -49,8 +50,6 @@ public void RefreshMixers()

private void silenceDefaultAudio()
{
if (devicesSilenced) return;

try
{
// Initialize BASS "No Sound" device (0) if not already.
Expand All @@ -61,16 +60,23 @@ private void silenceDefaultAudio()
return;
}

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))
{
Debug.WriteLine($"[osu!] Failed to move mixer {handle} to silent device: {Bass.LastError}");
allSuccess = false;
}
}

devicesSilenced = true;
Debug.WriteLine($"[osu!] BASS mixers moved to silent device 0 (Oboe active)");
devicesSilenced = allSuccess;

if (allSuccess && mixerHandles.Count > 0)
Debug.WriteLine($"[osu!] BASS mixers ({mixerHandles.Count}) moved to silent device 0 (Oboe active)");
}
catch (Exception e)
{
Expand Down Expand Up @@ -101,31 +107,27 @@ private void restoreDefaultAudio()
}
}

private void addMixer(AudioMixer mixer)
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);
// 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);

if (field != null)
{
int handle = field.GetValue(mixer) is int h ? h : 0;
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 = prop.GetValue(mixer) is int h ? h : 0;
if (handle != 0) mixerHandles.Add(handle);
}
}
if (handleObj == null) return;

int handle = 0;
if (handleObj is int ih) handle = ih;
else if (handleObj is long lh) handle = (int)lh;
else if (handleObj is IntPtr ph) handle = (int)ph.ToInt64();

if (handle != 0 && !mixerHandles.Contains(handle))
mixerHandles.Add(handle);
}
catch (Exception e)
{
Expand All @@ -135,7 +137,9 @@ private void addMixer(AudioMixer mixer)

private int provideAudio(IntPtr audioData, int numFrames)
{
if (mixerHandles.Count == 0) return 0;
// 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;
Expand Down
Loading