fix(player): distinguish AAudio access and decoder failures

This commit is contained in:
zarzet
2026-09-27 15:26:11 +07:00
parent d92bcf7716
commit e0f5a96564
6 changed files with 55 additions and 6 deletions
@@ -96,12 +96,34 @@ class HiResPlaybackTest {
} finally { playback.dispose(); file.delete() }
}
@Test
fun invalidFileIsNotReportedAsExclusiveAccessFailure() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val file = File.createTempFile("invalid-pcm-", ".wav", context.cacheDir)
file.writeBytes(ByteArray(64))
val playback = HiResPlayback(context) {}
try {
val done = CountDownLatch(1)
var reply: Any? = null
var error: String? = null
playback.command(MethodCall("prepare", mapOf("path" to file.path, "token" to 1))) { value, failure ->
reply = value; error = failure; done.countDown()
}
assertTrue(done.await(10, TimeUnit.SECONDS))
assertNull(error)
assertEquals("format", (reply as Map<*, *>)["reason"])
assertEquals(false, (reply as Map<*, *>)["fatal"])
} finally { playback.dispose(); file.delete() }
}
@Test
fun exclusiveOutputEitherVerifiesExactFormatOrRejectsSharedFallback() {
assertThrows(IllegalStateException::class.java) { NativeAudio.openOboe(1, 2, 24, 0) }
val handle = try { NativeAudio.openOboe(48000, 2, 16, 0) }
catch (error: IllegalStateException) {
assertTrue(error.message.orEmpty().contains("exclusive output unavailable"))
assertTrue(error.message.orEmpty().contains("requested=48000Hz/2ch/16bit"))
assertTrue(error.message.orEmpty().contains("format="))
return
}
try {
+16 -2
View File
@@ -9,6 +9,7 @@
#include <memory>
#include <limits>
#include <mutex>
#include <sstream>
#include <stdexcept>
#include <unordered_map>
#include <vector>
@@ -72,6 +73,9 @@ public:
if (precision <= 24) formats.push_back(oboe::AudioFormat::I24);
formats.push_back(oboe::AudioFormat::I32);
if (precision <= 24) formats.push_back(oboe::AudioFormat::Float);
std::ostringstream attempts;
attempts << "requested=" << rate << "Hz/" << channels << "ch/" << precision
<< "bit, device=" << device;
for (auto format : formats) {
oboe::AudioStreamBuilder builder;
builder.setDirection(oboe::Direction::Output)
@@ -84,7 +88,12 @@ public:
->setDeviceId(device)->setDataCallback(this)
->setChannelConversionAllowed(false)->setFormatConversionAllowed(false)
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::None);
if (builder.openStream(stream) != oboe::Result::OK) continue;
const auto result = builder.openStream(stream);
attempts << "; format=" << oboe::convertToText(format);
if (result != oboe::Result::OK) {
attempts << " open=" << oboe::convertToText(result);
continue;
}
// Exclusive is a request: Android can silently return Shared. Reject
// that stream instead of displaying a misleading exclusive badge.
if (stream->getAudioApi() == oboe::AudioApi::AAudio &&
@@ -97,10 +106,15 @@ public:
ring.resize(static_cast<size_t>(rate / 4) * frameBytes);
return;
}
attempts << " actual=" << oboe::convertToText(stream->getSharingMode())
<< "/" << stream->getSampleRate() << "Hz/" << stream->getChannelCount()
<< "ch/" << oboe::convertToText(stream->getFormat())
<< ", api=" << oboe::convertToText(stream->getAudioApi())
<< ", device=" << stream->getDeviceId();
stream->close();
stream.reset();
}
throw std::runtime_error("Exact-rate AAudio exclusive output unavailable");
throw std::runtime_error("Exact-rate AAudio exclusive output unavailable: " + attempts.str());
}
~ExclusiveOutput() override { if (stream) stream->close(); }
@@ -70,6 +70,7 @@ internal class HiResPlayback(context: Context, private val emit: (Map<String, An
if (Build.VERSION.SDK_INT < 27) return mapOf("reason" to "exclusive_unavailable")
token = call.argument<Int>("token") ?: 0
var isDsd = call.argument<Boolean>("requiresDsd") == true
var failureReason = "format"
try {
val path = requireNotNull(call.argument<String>("path"))
isDsd = isDsd || DsdSource.isDsd(path)
@@ -78,9 +79,11 @@ internal class HiResPlayback(context: Context, private val emit: (Map<String, An
val pcm = UsbPcmSource(path).also { source = it }
val devices = manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)
val wired = devices.firstOrNull { it.type == AudioDeviceInfo.TYPE_WIRED_HEADPHONES || it.type == AudioDeviceInfo.TYPE_WIRED_HEADSET }
failureReason = "exclusive_unavailable"
output = NativeAudio.openOboe(pcm.rate, pcm.channels, pcm.bits, wired?.id ?: 0)
bits = NativeAudio.bitsOboe(output)
device = NativeAudio.deviceOboe(output)
failureReason = "format"
val deadline = android.os.SystemClock.elapsedRealtime() + 5000
while (pending == null && !pcm.ended) {
require(expected == revision) { "AAudio prepare cancelled" }
@@ -93,9 +96,12 @@ internal class HiResPlayback(context: Context, private val emit: (Map<String, An
"sampleRate" to pcm.rate, "bitDepth" to pcm.bits, "duration" to pcm.durationUs / 1000,
"device" to (devices.firstOrNull { it.id == device }?.productName?.toString() ?: "AAudio"))
} catch (error: Exception) {
android.util.Log.i("HiResPlayback", "Exclusive output unavailable: ${error.message}")
close()
return mapOf("reason" to if (isDsd) "dsd_unsupported" else "exclusive_unavailable", "fatal" to isDsd)
if (expected != revision) return mapOf("reason" to "cancelled")
val reason = if (isDsd) "dsd_unsupported" else failureReason
val detail = error.message.orEmpty().take(3000)
android.util.Log.i("HiResPlayback", "$reason: $detail")
return mapOf("reason" to reason, "fatal" to isDsd, "detail" to detail)
}
}
+1 -1
View File
@@ -11,7 +11,7 @@
"usbFixedVolumeWarning": "The app cannot lower the volume on this output. Audio may start at full level, and the phone volume slider will not help. Only continue after lowering the volume on your DAC or external amplifier. Do not enable this for a headset without its own volume control.",
"dapExclusive": "DAP hi-res (AAudio exclusive)",
"dapExclusiveDescription": "Use Oboe/AAudio at the track's original rate and precision for FLAC/WAV PCM. Requires Android 8.1 or later and compatible hardware. Exclusive access is checked; unavailable formats use normal playback. DSD requires the direct USB driver. Applies from the next track.",
"dapExclusiveUnavailable": "This device cannot open an exact-rate AAudio exclusive stream for this track. Using normal playback.",
"dapExclusiveUnavailable": "AAudio exclusive access is unavailable for this track and current output. Using normal playback. For a USB DAC, try the direct USB driver.",
"usbDirect": "Direct USB driver",
"usbDirectDescription": "Use exclusive USB audio for FLAC/WAV, DSF/DFF and WavPack DSD, with Android USB permission. Native DSD requires a recognized compatible DAC. Other PCM formats use normal playback. Applies from the next track.",
"usbDsdOverPcm": "DSD over PCM (DoP)",
+1 -1
View File
@@ -10,7 +10,7 @@
"usbFixedVolumeWarning": "Aplikasi tidak bisa menurunkan volume keluaran ini. Suara bisa mulai pada tingkat penuh dan slider volume HP tidak akan membantu. Lanjutkan hanya setelah menurunkan volume pada DAC atau amplifier eksternal. Jangan aktifkan untuk headset tanpa kontrol volume sendiri.",
"dapExclusive": "Hi-res DAP (AAudio eksklusif)",
"dapExclusiveDescription": "Gunakan Oboe/AAudio pada laju sampel dan presisi asli lagu PCM FLAC/WAV. Memerlukan Android 8.1 atau lebih baru dan perangkat kompatibel. Akses eksklusif diperiksa; format yang tidak didukung memakai pemutaran biasa. DSD memerlukan driver USB langsung. Berlaku mulai lagu berikutnya.",
"dapExclusiveUnavailable": "Perangkat ini tidak bisa membuka AAudio eksklusif pada laju sampel asli lagu. Memakai pemutaran biasa.",
"dapExclusiveUnavailable": "Akses AAudio eksklusif tidak tersedia untuk lagu dan keluaran saat ini. Memakai pemutaran biasa. Untuk DAC USB, coba driver USB langsung.",
"usbDirect": "Driver USB langsung",
"usbDirectDescription": "Gunakan audio USB eksklusif untuk FLAC/WAV, DSF/DFF dan WavPack DSD, dengan izin USB Android. Native DSD memerlukan DAC kompatibel yang dikenali. Format PCM lainnya memakai pemutaran biasa. Berlaku mulai lagu berikutnya.",
"usbDsdOverPcm": "DSD melalui PCM (DoP)",
+7
View File
@@ -4,6 +4,9 @@ import 'dart:io';
import 'package:audioplayers/audioplayers.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart';
import 'package:spotiflac_android/utils/logger.dart';
final _log = AppLogger('AudioOutput');
/// Only verified native playback reports active. File metadata alone never does.
final usbAudioStatus = ValueNotifier<UsbAudioStatus>(const UsbAudioStatus());
@@ -213,6 +216,10 @@ class MusicPlaybackDeck {
usbAudioStatus.value = UsbAudioStatus(
reason: response?['reason'] as String? ?? 'unsupported',
);
final detail = response?['detail'] as String?;
if (detail != null && detail.isNotEmpty) {
_log.w('Native output ${usbAudioStatus.value.reason}: $detail');
}
if (response?['fatal'] == true) {
throw UsbDsdUnavailable();
}