diff --git a/oboe_bridge_base.cpp b/oboe_bridge_base.cpp new file mode 100644 index 000000000000..3c49bc205004 --- /dev/null +++ b/oboe_bridge_base.cpp @@ -0,0 +1,351 @@ +// 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 +#include +#include + +#define LOG_TAG "osu!native" +#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_); + + // 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. + 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); + + 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", + 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; + } + + 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(); + + if (burst > 0) { + auto setResult = stream_->setBufferSizeInFrames(burst); + + if (setResult) { + 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. + if ((callbackCount_.fetch_add(1, std::memory_order_relaxed) & 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; + } + + LOGE("Failed to restart recovered stream: %s", oboe::convertToText(result)); + } + } + + 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 +// ============================================================ + +// 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" { + +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" diff --git a/oboe_bridge_fix_branch.cpp b/oboe_bridge_fix_branch.cpp new file mode 100644 index 000000000000..6e1c4f23dbee --- /dev/null +++ b/oboe_bridge_fix_branch.cpp @@ -0,0 +1,374 @@ +// 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 +#include +#include + +#define LOG_TAG "osu!native" +#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_); + + // 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. + 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); + + 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", + 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; + } + + 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(); + + if (burst > 0) { + auto setResult = stream_->setBufferSizeInFrames(burst); + + if (setResult) { + 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(); + } + + affinitySet_.store(false); + 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(); + + // 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 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; +} + +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; + } + + LOGE("Failed to restart recovered stream: %s", oboe::convertToText(result)); + } + } + + 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 +// ============================================================ + +// 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" { + +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" diff --git a/oboe_bridge_fix_branch.h b/oboe_bridge_fix_branch.h new file mode 100644 index 000000000000..a1f42da4c68f --- /dev/null +++ b/oboe_bridge_fix_branch.h @@ -0,0 +1,78 @@ +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. + +#pragma once + +#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: +/// - 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(); + ~OboeBridge(); + + bool open(); + 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 (ideally == framesPerBurst for lowest latency). + 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). + /// 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; + + void onErrorBeforeClose(oboe::AudioStream* stream, oboe::Result error) override; + void onErrorAfterClose(oboe::AudioStream* stream, oboe::Result error) override; + +private: + std::shared_ptr stream_; + std::mutex streamLock_; + std::atomic active_{false}; + std::atomic affinitySet_{false}; + std::atomic latencyMs_{-1.0}; + std::atomic callbackCount_{0}; + std::atomic provider_{nullptr}; + + void updateLatency(); + void optimiseBufferSize(); + bool reopenAndRestart(); +}; diff --git a/osu.Android/AndroidNativeBridgeManager.cs b/osu.Android/AndroidNativeBridgeManager.cs index a4fff6293f2c..8be6acb0e1b3 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, Action? onStarted = null) + public void StartOboeBridge(Scheduler scheduler, Action onLatencyMeasured, IntPtr provider, Action? onStarted = null) { if (oboeBridge != null) return; @@ -43,7 +43,7 @@ public void StartOboeBridge(Scheduler scheduler, Action onLatencyMeasure { oboeBridge = bridge; - if (provider != null) + if (provider != IntPtr.Zero) bridge.SetProvider(provider); bool started = bridge.Start(); diff --git a/osu.Android/Native/OboeAudioBridge.cs b/osu.Android/Native/OboeAudioBridge.cs index 208b3b38c5c2..8993cdf16a1e 100644 --- a/osu.Android/Native/OboeAudioBridge.cs +++ b/osu.Android/Native/OboeAudioBridge.cs @@ -10,19 +10,13 @@ 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, 1x burst buffer. + /// Optimised for lowest possible latency: AAudio preferred, exclusive mode, 2x burst buffer, + /// and [UnmanagedCallersOnly] provider for zero-overhead callback delivery. /// 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; @@ -199,8 +193,8 @@ public int FramesPerBurst } /// - /// The actual buffer size in frames. When optimised, this equals - /// for minimum latency (1x burst). + /// The actual buffer size in frames. When optimised, this equals 2x + /// for maximum stability. /// public int BufferSizeInFrames { @@ -264,8 +258,9 @@ public bool IsMMap /// /// Sets the provider function that will be called to fill the audio buffer. + /// The provider should be a function pointer obtained from a static method with [UnmanagedCallersOnly]. /// - public void SetProvider(OboeAudioProvider? provider) + public void SetProvider(IntPtr provider) { if (disposed || nativePtr == IntPtr.Zero) return; @@ -341,6 +336,6 @@ public void Dispose() private static extern byte nOboeIsMMap(IntPtr ptr); [DllImport(lib_name)] - private static extern void nOboeSetProvider(IntPtr ptr, [MarshalAs(UnmanagedType.FunctionPtr)] OboeAudioProvider? provider); + private static extern void nOboeSetProvider(IntPtr ptr, IntPtr provider); } } diff --git a/osu.Android/Native/oboe_bridge.cpp b/osu.Android/Native/oboe_bridge.cpp index 3136f98fbc96..27e71d36fa2a 100644 --- a/osu.Android/Native/oboe_bridge.cpp +++ b/osu.Android/Native/oboe_bridge.cpp @@ -1,20 +1,365 @@ -<<<<<<< SEARCH - if (burst > 0) { - auto setResult = stream_->setBufferSizeInFrames(burst); +// Copyright (c) ppy Pty Ltd . Licensed under the MIT Licence. +// See the LICENCE file in the repository root for full licence text. - if (setResult) { - LOGI("Buffer size tuned to %d frames (1x burst)", setResult.value()); - } +#include "oboe_bridge.h" +#include +#include +#include +#include +#include + +#define LOG_TAG "osu!native" +#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_); + + // Low-latency MMAP path requires explicit enabling in Oboe. + 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 (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(); + + 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 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) { - // 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); if (setResult) { LOGI("Buffer size tuned to %d frames (2x burst)", setResult.value()); } } ->>>>>>> REPLACE +} + +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); + affinitySet_.store(false); + 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) { + + // Record the start time of this callback for ADPF work duration reporting. + int64_t startTime = oboe::DefaultClock::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(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); + } + + // 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(); + 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. + if (!affinitySet_.load(std::memory_order_relaxed)) { + std::vector 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; +} + +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; + } + + LOGE("Failed to restart recovered stream: %s", oboe::convertToText(result)); + } + } + + 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" diff --git a/osu.Android/Native/oboe_bridge.h b/osu.Android/Native/oboe_bridge.h index 3a1d6e124a06..75a96c71463b 100644 --- a/osu.Android/Native/oboe_bridge.h +++ b/osu.Android/Native/oboe_bridge.h @@ -13,6 +13,15 @@ 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(); @@ -34,7 +43,7 @@ class OboeBridge : public oboe::AudioStreamCallback { /// Returns the optimal burst size in frames (one callback quantum). int32_t getFramesPerBurst() const; - /// Returns the current buffer size in frames (ideally == framesPerBurst for lowest latency). + /// Returns the current buffer size in frames. int32_t getBufferSizeInFrames() const; /// Returns true if the stream is using AAudio (vs OpenSL ES fallback). @@ -60,6 +69,7 @@ class OboeBridge : public oboe::AudioStreamCallback { std::atomic latencyMs_{-1.0}; std::atomic callbackCount_{0}; std::atomic provider_{nullptr}; + std::atomic affinitySet_{false}; void updateLatency(); void optimiseBufferSize(); diff --git a/osu.Android/OboeAudioRedirector.cs b/osu.Android/OboeAudioRedirector.cs index 3e9d462d7797..07dfa3c17b95 100644 --- a/osu.Android/OboeAudioRedirector.cs +++ b/osu.Android/OboeAudioRedirector.cs @@ -10,6 +10,7 @@ using osu.Android.Native; using osu.Framework.Audio; using osu.Framework.Audio.Mixing; +using System.Runtime.CompilerServices; using Debug = System.Diagnostics.Debug; namespace osu.Android @@ -25,15 +26,13 @@ internal sealed class OboeAudioRedirector : IDisposable private int masterMixer; private bool devicesSilenced; private int sampleRate = 44100; // Default, will be updated from bridge. - private readonly OboeAudioBridge.OboeAudioProvider providerDelegate; public OboeAudioRedirector(AudioManager audioManager) { this.audioManager = audioManager; - this.providerDelegate = provideAudio; } - public OboeAudioBridge.OboeAudioProvider Provider => providerDelegate; + public unsafe IntPtr Provider => (IntPtr)(delegate* unmanaged[Cdecl])&provideAudio; public void RefreshMixers(int hardwareSampleRate) { @@ -46,6 +45,8 @@ public void RefreshMixers(int hardwareSampleRate) silenceDefaultAudio(); setupMasterMixer(); + ActiveMasterMixer = masterMixer; + Debug.WriteLine($"[osu!] Oboe redirector initialized: rate={sampleRate}Hz, mixers={mixerHandles.Count}"); } @@ -73,9 +74,9 @@ private void setupMasterMixer() 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)) + // We remove BASS_MIXER_BUFFER to eliminate internal BASS buffering latency, + // relying entirely on the Oboe callback timing for rock-solid sync. + if (!BassMix.MixerAddChannel(masterMixer, handle, BassFlags.MixerChanNoRampin)) { Debug.WriteLine($"[osu!] Failed to add mixer {handle} to master mixer: {Bass.LastError}"); } @@ -122,6 +123,7 @@ private void silenceDefaultAudio() private void restoreDefaultAudio() { + ActiveMasterMixer = 0; if (!devicesSilenced) return; try @@ -173,20 +175,27 @@ private void addMixer(AudioMixer? mixer) } } - private int provideAudio(IntPtr audioData, int numFrames) + [UnmanagedCallersOnly(CallConvs = new[] { typeof(CallConvCdecl) })] + private static int provideAudio(IntPtr audioData, int numFrames) { - if (masterMixer == 0 || !devicesSilenced) return 0; + // We use a static method with [UnmanagedCallersOnly] to eliminate delegate marshalling overhead. + // Since this is static, we need a way to find the active mixer. + int mixer = ActiveMasterMixer; + + if (mixer == 0) return 0; // 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); + int bytesRead = Bass.ChannelGetData(mixer, audioData, bytesToRead); if (bytesRead <= 0) return 0; return bytesRead / 8; } + internal static int ActiveMasterMixer; + public void Dispose() { restoreDefaultAudio(); diff --git a/osu.Android/OsuGameAndroid.cs b/osu.Android/OsuGameAndroid.cs index 57d781bf2f9d..5f8d8cca7fb2 100644 --- a/osu.Android/OsuGameAndroid.cs +++ b/osu.Android/OsuGameAndroid.cs @@ -1,42 +1,156 @@ -<<<<<<< SEARCH - lowLatencyAudio.BindValueChanged(e => +// 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 { - if (e.NewValue) - { - audioRedirector?.RefreshMixers(); + packageInfo = gameActivity.PackageManager?.GetPackageInfo(gameActivity.PackageName!, 0); + } + catch + { + // ignore errors. + } + finally + { + packageInfoChecked = true; + } - startOboeBridge(latency => - { - // Only auto-suggest when the user hasn't already configured a manual offset. - if (Math.Abs(audioOffset.Value) >= 0.01) - return; + return packageInfo; + } + } - 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(); - audioRedirector?.Dispose(); - audioRedirector = new OboeAudioRedirector(Audio); - } + 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 Oboe bridge: {ex.Message}"); + Debug.WriteLine($"[osu!] Failed to toggle performance mode: {ex.Message}"); } }, true); -======= + lowLatencyAudio.BindValueChanged(e => { try { if (e.NewValue) { + audioRedirector?.RefreshMixers(); + startOboeBridge(latency => { // Only auto-suggest when the user hasn't already configured a manual offset. @@ -46,41 +160,289 @@ 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 => + }, audioRedirector != null ? audioRedirector.Provider : IntPtr.Zero, sampleRate => { // Initialise BASS mixers at the hardware sample rate to eliminate resampling latency. audioRedirector?.RefreshMixers(sampleRate); }); } else if (nativeBridges != null) - { stopOboeBridge(); - audioRedirector?.Dispose(); - audioRedirector = new OboeAudioRedirector(Audio); - } } catch (Exception ex) { Debug.WriteLine($"[osu!] Failed to toggle Oboe bridge: {ex.Message}"); } }, true); ->>>>>>> REPLACE -<<<<<<< SEARCH + + vulkanProbeEnabled.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) + { + 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(); + + gameActivity.RunOnUiThread(() => + { + try + { + if (window.Attributes is WindowManagerLayoutParams layoutParams) + { + layoutParams.PreferredDisplayModeId = preferred.ModeId; + window.Attributes = layoutParams; + Debug.WriteLine($"[osu!] Highest refresh rate selected: {preferred.RefreshRate}Hz (mode {preferred.ModeId})"); + } + } + catch (Exception e) + { + // 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}"); + } + }); + } + 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. + [MethodImpl(MethodImplOptions.NoInlining)] - private void startOboeBridge(Action onLatencyMeasured, OboeAudioBridge.OboeAudioProvider? provider = null) + private void startOboeBridge(Action onLatencyMeasured, IntPtr provider, Action? onStarted = null) { nativeBridges ??= new AndroidNativeBridgeManager(); if (nativeBridges is AndroidNativeBridgeManager mgr) - mgr.StartOboeBridge(Scheduler, onLatencyMeasured, provider); + mgr.StartOboeBridge(Scheduler, onLatencyMeasured, provider, onStarted); + } + + [MethodImpl(MethodImplOptions.NoInlining)] + private void stopOboeBridge() + { + (nativeBridges as AndroidNativeBridgeManager)?.StopOboeBridge(); } -======= + [MethodImpl(MethodImplOptions.NoInlining)] - private void startOboeBridge(Action onLatencyMeasured, OboeAudioBridge.OboeAudioProvider? provider = null, Action? onStarted = null) + private void startVulkanProbe() { nativeBridges ??= new AndroidNativeBridgeManager(); if (nativeBridges is AndroidNativeBridgeManager mgr) - mgr.StartOboeBridge(Scheduler, onLatencyMeasured, provider, onStarted); + 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; + } + } + } } ->>>>>>> REPLACE + } +}