feat(player): add AAudio hi-res, WavPack DSD and DAC volume

This commit is contained in:
zarzet committed 2026-09-27 15:26:11 +07:00
1 parent 6decce0f8b
commit d92bcf7716
32 files changed
+2118 -55

No files matched your search

+10
View File
@@ -36,6 +36,13 @@ android {
buildConfig = true
}
externalNativeBuild {
cmake {
path = file("src/main/cpp/CMakeLists.txt")
version = "3.22.1"
}
}
sourceSets.getByName("main") {
java.srcDir("src/rust/kotlin")
java.srcDir(rustBackendDir.resolve("target/bindings/kotlin"))
@@ -76,6 +83,9 @@ android {
versionCode = flutter.versionCode
versionName = flutter.versionName
multiDexEnabled = true
externalNativeBuild {
cmake { arguments += "-DANDROID_STL=c++_static" }
}
ndk {
abiFilters.clear()
@@ -0,0 +1,10 @@
# Synthetic DSD fixture
`dsd-pattern.wv` is original test data, not a music recording. WavPack 5.9.0
encoded a stereo DSD64 DSF with 16,384 bytes per channel and 4,096-byte channel
blocks. Normalized MSB-first byte `i` of channel `c` is `(i * 17 + c * 29) & 255`.
The DSF input stores those bytes with reversed bits (LSB-first).
This tests lossless WavPack DSD decoding, channel order, seek alignment, native
LE/BE packing and DoP packing against known bytes. It is packaged only in the
instrumentation test APK.
Binary file not shown.
@@ -0,0 +1,122 @@
package com.zarz.spotiflac
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.flutter.plugin.common.MethodCall
import java.io.File
import java.nio.ByteBuffer
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class HiResPlaybackTest {
private fun fixture(): File {
val instrumentation = InstrumentationRegistry.getInstrumentation()
val file = File.createTempFile("dsd-pattern-", ".wv", instrumentation.targetContext.cacheDir)
instrumentation.context.assets.open("dsd-pattern.wv").use { input ->
file.outputStream().use { input.copyTo(it) }
}
return file
}
@Test
fun wavPackDsdDecodesOriginalBitsAndChannelOrderForNativeAndDop() {
val file = fixture()
try {
for ((encoding, slot) in listOf("dsd_be" to 4, "dsd_le" to 4, "dop" to 3, "dop" to 4)) {
requireNotNull(DsdSource.open(file.path)).use { source ->
assertEquals(2822400, source.rate)
assertEquals(2, source.channels)
val group = if (encoding == "dop") 2 else 4
var cursor = 0
while (true) {
val data = source.readFrames(encoding, slot, 37) ?: break
for (frame in 0 until data.size / (slot * 2)) {
for (channel in 0..1) {
val start = (frame * 2 + channel) * slot
if (encoding == "dop" && slot == 4) assertEquals(0, data[start].toInt())
if (encoding == "dop") assertEquals(5, data[start + slot - 1].toInt())
for (i in 0 until group) {
val sampleIndex = if (encoding == "dsd_be") i else group - 1 - i
val position = cursor + frame * group + sampleIndex
val expected = if (position < 16384) (position * 17 + channel * 29) and 255 else 0x69
val offset = if (encoding == "dop" && slot == 4) 1 else 0
assertEquals(expected, data[start + offset + i].toInt() and 255)
}
}
}
cursor += data.size / (slot * 2) * group
}
assertEquals(16384, cursor)
}
}
} finally { file.delete() }
}
@Test
fun wavPackSeekRetainsBitAlignmentAndSurvivesClosedSafLease() {
val file = fixture()
try {
val source = android.os.ParcelFileDescriptor.open(file, android.os.ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
requireNotNull(DsdSource.open("/proc/self/fd/${fd.fd}"))
}
source.use {
for (time in listOf(20000L, 0L, 40000L)) {
val actual = it.seek(time, "dsd_be")
val cursor = (actual * it.rate + 7_999_999) / 8_000_000
val data = requireNotNull(it.readFrames("dsd_be", 4, 1))
for (channel in 0..1) for (index in 0..3) {
assertEquals(((cursor + index) * 17 + channel * 29).toInt() and 255,
data[channel * 4 + index].toInt() and 255)
}
}
}
} finally { file.delete() }
}
@Test
fun dsdCannotBeSentToAAudioAsPcm() {
val file = fixture()
val context = InstrumentationRegistry.getInstrumentation().targetContext
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("dsd_unsupported", (reply as Map<*, *>)["reason"])
assertEquals(true, (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"))
return
}
try {
val bits = NativeAudio.bitsOboe(handle)
assertTrue(bits in listOf(16, 24, 32))
val silence = ByteBuffer.allocateDirect(4800 * 2 * (bits / 8))
assertEquals(silence.capacity(), NativeAudio.writeOboe(handle, silence, 0, silence.capacity()))
NativeAudio.startOboe(handle)
val deadline = android.os.SystemClock.elapsedRealtime() + 3000
while (NativeAudio.framesOboe(handle) < 4800 && android.os.SystemClock.elapsedRealtime() < deadline) Thread.sleep(10)
assertEquals(4800L, NativeAudio.framesOboe(handle))
NativeAudio.flushOboe(handle)
assertEquals(0L, NativeAudio.framesOboe(handle))
} finally { NativeAudio.closeOboe(handle) }
NativeAudio.closeOboe(handle)
assertThrows(IllegalStateException::class.java) { NativeAudio.framesOboe(handle) }
}
}
+27
View File
@@ -0,0 +1,27 @@
cmake_minimum_required(VERSION 3.22.1)
project(spotiflac_audio LANGUAGES C CXX)
include(FetchContent)
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
set(OBOE_DISABLE_CONVERSION ON CACHE BOOL "" FORCE)
set(WAVPACK_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE)
set(WAVPACK_ENABLE_DSD ON CACHE BOOL "" FORCE)
set(WAVPACK_ENABLE_THREADS OFF CACHE BOOL "" FORCE)
set(WAVPACK_ENABLE_ASM OFF CACHE BOOL "" FORCE)
# Immutable releases plus checksums keep local and CI builds identical.
FetchContent_Declare(oboe
URL https://github.com/google/oboe/archive/refs/tags/1.11.0.tar.gz
URL_HASH SHA256=0ccf2110640a2b4489f1c93a404f2978656b434d8c5dc9cb6258f9cc95c79b15)
FetchContent_Declare(wavpack
URL https://github.com/dbry/WavPack/archive/refs/tags/5.9.0.tar.gz
URL_HASH SHA256=2a53e50aefd8c9f04a0828a0e1ef77b6f4c17b0ef6964ad234ab295f313b7d6d)
FetchContent_MakeAvailable(oboe wavpack)
add_library(spotiflac_audio SHARED native_audio.cpp)
target_compile_features(spotiflac_audio PRIVATE cxx_std_17)
target_compile_options(spotiflac_audio PRIVATE -Wall -Wextra -Werror -fvisibility=hidden)
target_link_libraries(spotiflac_audio PRIVATE oboe WavPack::WavPack log)
target_link_options(spotiflac_audio PRIVATE "-Wl,-z,max-page-size=16384")
+281
View File
@@ -0,0 +1,281 @@
#include <jni.h>
#include <oboe/Oboe.h>
#include <wavpack.h>
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <cstring>
#include <memory>
#include <limits>
#include <mutex>
#include <stdexcept>
#include <unordered_map>
#include <vector>
namespace {
void require(bool condition, const char *message) {
if (!condition) throw std::runtime_error(message);
}
void report(JNIEnv *env, const std::exception &error) {
env->ThrowNew(env->FindClass("java/lang/IllegalStateException"), error.what());
}
// Handles never expose raw pointers. A stale/double-close cannot dereference a
// freed stream; each JNI invocation retains ownership for its entire duration.
template <class T> class Handles {
std::mutex mutex;
std::unordered_map<jlong, std::shared_ptr<T>> values;
jlong next = 1;
public:
jlong add(std::shared_ptr<T> value) {
std::lock_guard<std::mutex> lock(mutex);
const auto id = next++;
values.emplace(id, std::move(value));
return id;
}
std::shared_ptr<T> get(jlong id) {
std::lock_guard<std::mutex> lock(mutex);
auto it = values.find(id);
require(it != values.end(), "Native audio handle closed");
return it->second;
}
void remove(jlong id) {
std::shared_ptr<T> old;
{
std::lock_guard<std::mutex> lock(mutex);
auto it = values.find(id);
if (it != values.end()) { old = std::move(it->second); values.erase(it); }
}
}
};
class ExclusiveOutput final : public oboe::AudioStreamDataCallback {
std::shared_ptr<oboe::AudioStream> stream;
// Single producer (Kotlin worker), single consumer (audio callback).
std::vector<uint8_t> ring;
std::atomic<uint64_t> read{0}, written{0}, musicFrames{0};
std::atomic<int64_t> lastMusicEnd{0};
int64_t callbackFrames = 0;
int frameBytes = 0;
bool floatOutput = false;
int64_t frameBase = 0;
public:
int bits = 0;
int deviceId() const { return stream->getDeviceId(); }
ExclusiveOutput(int rate, int channels, int precision, int device) {
require(rate >= 8000 && rate <= 768000 && channels >= 1 && channels <= 2,
"Unsupported hi-res format");
require(precision == 16 || precision == 24 || precision == 32, "Unsupported PCM precision");
std::vector<oboe::AudioFormat> formats;
if (precision == 16) formats.push_back(oboe::AudioFormat::I16);
if (precision <= 24) formats.push_back(oboe::AudioFormat::I24);
formats.push_back(oboe::AudioFormat::I32);
if (precision <= 24) formats.push_back(oboe::AudioFormat::Float);
for (auto format : formats) {
oboe::AudioStreamBuilder builder;
builder.setDirection(oboe::Direction::Output)
->setAudioApi(oboe::AudioApi::AAudio)
->setSharingMode(oboe::SharingMode::Exclusive)
->setPerformanceMode(oboe::PerformanceMode::LowLatency)
->setUsage(oboe::Usage::Media)
->setContentType(oboe::ContentType::Music)
->setSampleRate(rate)->setChannelCount(channels)->setFormat(format)
->setDeviceId(device)->setDataCallback(this)
->setChannelConversionAllowed(false)->setFormatConversionAllowed(false)
->setSampleRateConversionQuality(oboe::SampleRateConversionQuality::None);
if (builder.openStream(stream) != oboe::Result::OK) 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 &&
stream->getSharingMode() == oboe::SharingMode::Exclusive &&
stream->getSampleRate() == rate && stream->getChannelCount() == channels &&
stream->getFormat() == format && (device == 0 || stream->getDeviceId() == device)) {
floatOutput = format == oboe::AudioFormat::Float;
bits = format == oboe::AudioFormat::I16 ? 16 : format == oboe::AudioFormat::I24 ? 24 : 32;
frameBytes = channels * (bits / 8);
ring.resize(static_cast<size_t>(rate / 4) * frameBytes);
return;
}
stream->close();
stream.reset();
}
throw std::runtime_error("Exact-rate AAudio exclusive output unavailable");
}
~ExclusiveOutput() override { if (stream) stream->close(); }
oboe::DataCallbackResult onAudioReady(oboe::AudioStream *, void *audio, int32_t frames) override {
const auto r = read.load(std::memory_order_relaxed);
const auto w = written.load(std::memory_order_acquire);
const size_t requested = static_cast<size_t>(frames) * frameBytes;
const size_t available = std::min<uint64_t>(requested, w - r);
const size_t start = r % ring.size();
const size_t first = std::min(available, ring.size() - start);
auto *out = static_cast<uint8_t *>(audio);
std::memcpy(out, ring.data() + start, first);
std::memcpy(out + first, ring.data(), available - first);
std::memset(out + available, 0, requested - available);
read.store(r + available, std::memory_order_release);
musicFrames.fetch_add(available / frameBytes, std::memory_order_relaxed);
if (available > 0) lastMusicEnd.store(frameBase + callbackFrames + available / frameBytes, std::memory_order_release);
callbackFrames += frames;
return oboe::DataCallbackResult::Continue;
}
void check() {
require(stream && stream->getState() != oboe::StreamState::Disconnected &&
stream->getState() != oboe::StreamState::Closed, "AAudio output disconnected");
}
int write(const uint8_t *data, size_t size) {
check();
require(size % frameBytes == 0, "Unaligned PCM buffer");
auto w = written.load(std::memory_order_relaxed);
auto r = read.load(std::memory_order_acquire);
size = std::min<uint64_t>(size, ring.size() - (w - r)) / frameBytes * frameBytes;
for (size_t offset = 0; offset < size;) {
const size_t index = (w + offset) % ring.size();
const size_t count = std::min(size - offset, ring.size() - index);
if (floatOutput) {
// Integer <=24-bit samples padded to I32 have an exact float
// representation. Never use this path for 32-bit source audio.
for (size_t sample = 0; sample < count; sample += 4) {
int32_t integer;
std::memcpy(&integer, data + offset + sample, 4);
const float value = static_cast<float>(integer) * (1.0f / 2147483648.0f);
std::memcpy(ring.data() + index + sample, &value, 4);
}
} else std::memcpy(ring.data() + index, data + offset, count);
offset += count;
}
written.store(w + size, std::memory_order_release);
return static_cast<int>(size);
}
void start() { check(); require(stream->requestStart() == oboe::Result::OK, "AAudio start failed"); }
void flush() {
check();
// Wait for the callback to stop before resetting its ring/counters.
const auto state = stream->getState();
if (state != oboe::StreamState::Open && state != oboe::StreamState::Flushed) {
require(stream->pause(2000000000LL) == oboe::Result::OK, "AAudio pause failed");
require(stream->flush(2000000000LL) == oboe::Result::OK, "AAudio flush failed");
}
read.store(0); written.store(0); musicFrames.store(0);
frameBase = stream->getFramesWritten();
callbackFrames = 0;
lastMusicEnd.store(frameBase);
}
int64_t frames() {
check();
int64_t presented = 0, nanos = 0;
auto music = static_cast<int64_t>(musicFrames.load(std::memory_order_relaxed));
if (stream->getTimestamp(CLOCK_MONOTONIC, &presented, &nanos) == oboe::Result::OK) {
const auto pending = std::max<int64_t>(0, lastMusicEnd.load(std::memory_order_acquire) - presented);
return std::max<int64_t>(0, music - pending);
}
// A timestamp may not exist until the first hardware burst completes.
return 0;
}
};
class DsdDecoder {
public:
WavpackContext *context = nullptr;
int channels = 0;
int rate = 0;
int64_t samples = 0;
explicit DsdDecoder(const char *path) {
char error[80]{};
context = WavpackOpenFileInput(path, error, OPEN_DSD_NATIVE | OPEN_ALT_TYPES, 0);
require(context != nullptr, "Cannot open WavPack file");
channels = WavpackGetNumChannels(context);
rate = static_cast<int>(WavpackGetNativeSampleRate(context));
samples = WavpackGetNumSamples64(context);
}
~DsdDecoder() { if (context) WavpackCloseFile(context); }
bool isDsd() const { return (WavpackGetQualifyMode(context) & QMODE_DSD_AUDIO) != 0; }
};
Handles<ExclusiveOutput> outputs;
Handles<DsdDecoder> decoders;
}
#define JNI_AUDIO(name) Java_com_zarz_spotiflac_NativeAudio_##name
extern "C" {
JNIEXPORT jlong JNICALL JNI_AUDIO(openOboe)(JNIEnv *env, jobject, jint rate, jint channels, jint bits, jint device) {
try { return outputs.add(std::make_shared<ExclusiveOutput>(rate, channels, bits, device)); }
catch (const std::exception &e) { report(env, e); return 0; }
}
JNIEXPORT jint JNICALL JNI_AUDIO(bitsOboe)(JNIEnv *env, jobject, jlong handle) {
try { return outputs.get(handle)->bits; } catch (const std::exception &e) { report(env, e); return 0; }
}
JNIEXPORT jint JNICALL JNI_AUDIO(deviceOboe)(JNIEnv *env, jobject, jlong handle) {
try { return outputs.get(handle)->deviceId(); } catch (const std::exception &e) { report(env, e); return 0; }
}
JNIEXPORT jint JNICALL JNI_AUDIO(writeOboe)(JNIEnv *env, jobject, jlong handle, jobject buffer, jint offset, jint count) {
try {
auto *data = static_cast<uint8_t *>(env->GetDirectBufferAddress(buffer));
auto size = env->GetDirectBufferCapacity(buffer);
require(data && offset >= 0 && count >= 0 && count <= 262144 && offset <= size && count <= size - offset, "Invalid PCM buffer");
return outputs.get(handle)->write(data + offset, count);
} catch (const std::exception &e) { report(env, e); return 0; }
}
JNIEXPORT void JNICALL JNI_AUDIO(startOboe)(JNIEnv *env, jobject, jlong handle) {
try { outputs.get(handle)->start(); } catch (const std::exception &e) { report(env, e); }
}
JNIEXPORT void JNICALL JNI_AUDIO(flushOboe)(JNIEnv *env, jobject, jlong handle) {
try { outputs.get(handle)->flush(); } catch (const std::exception &e) { report(env, e); }
}
JNIEXPORT jlong JNICALL JNI_AUDIO(framesOboe)(JNIEnv *env, jobject, jlong handle) {
try { return outputs.get(handle)->frames(); } catch (const std::exception &e) { report(env, e); return 0; }
}
JNIEXPORT void JNICALL JNI_AUDIO(closeOboe)(JNIEnv *, jobject, jlong handle) { outputs.remove(handle); }
JNIEXPORT jlong JNICALL JNI_AUDIO(openWavPack)(JNIEnv *env, jobject, jstring path) {
const char *text = env->GetStringUTFChars(path, nullptr);
if (!text) return 0;
try {
auto decoder = std::make_shared<DsdDecoder>(text);
env->ReleaseStringUTFChars(path, text);
text = nullptr;
if (!decoder->isDsd()) return 0; // PCM WavPack uses ordinary playback.
require(decoder->channels >= 1 && decoder->channels <= 2 && decoder->samples > 0 &&
decoder->samples <= std::numeric_limits<int64_t>::max() / 8000000 &&
(decoder->rate == 2822400 || decoder->rate == 5644800 || decoder->rate == 11289600 || decoder->rate == 22579200),
"Unsupported WavPack DSD format");
return decoders.add(std::move(decoder));
} catch (const std::exception &e) {
if (text) env->ReleaseStringUTFChars(path, text);
report(env, e); return 0;
}
}
JNIEXPORT jlongArray JNICALL JNI_AUDIO(infoWavPack)(JNIEnv *env, jobject, jlong handle) {
try {
auto d = decoders.get(handle);
jlong data[]{d->rate, d->channels, d->samples};
auto result = env->NewLongArray(3);
if (result) env->SetLongArrayRegion(result, 0, 3, data);
return result;
} catch (const std::exception &e) { report(env, e); return nullptr; }
}
JNIEXPORT jbyteArray JNICALL JNI_AUDIO(readWavPack)(JNIEnv *env, jobject, jlong handle, jint frames) {
try {
require(frames > 0 && frames <= 16384, "Invalid DSD read size");
auto d = decoders.get(handle);
std::vector<int32_t> data(static_cast<size_t>(frames) * d->channels);
auto count = WavpackUnpackSamples(d->context, data.data(), frames);
require(WavpackGetNumErrors(d->context) == 0, "Corrupt WavPack DSD data");
require(count > 0 || WavpackGetSampleIndex64(d->context) >= d->samples, "Truncated WavPack DSD data");
std::vector<jbyte> bytes(static_cast<size_t>(count) * d->channels);
// OPEN_DSD_NATIVE returns interleaved, MSB-first DSD bytes regardless
// of the wrapper's original DSF bit-order/blocking flags.
for (size_t i = 0; i < bytes.size(); ++i) bytes[i] = static_cast<jbyte>(data[i]);
auto result = env->NewByteArray(static_cast<jsize>(bytes.size()));
if (result) env->SetByteArrayRegion(result, 0, static_cast<jsize>(bytes.size()), bytes.data());
return result;
} catch (const std::exception &e) { report(env, e); return nullptr; }
}
JNIEXPORT void JNICALL JNI_AUDIO(seekWavPack)(JNIEnv *env, jobject, jlong handle, jlong sample) {
try {
auto d = decoders.get(handle);
require(sample >= 0 && sample < d->samples && WavpackSeekSample64(d->context, sample), "WavPack DSD seek failed");
} catch (const std::exception &e) { report(env, e); }
}
JNIEXPORT void JNICALL JNI_AUDIO(closeWavPack)(JNIEnv *, jobject, jlong handle) { decoders.remove(handle); }
}
@@ -1,15 +1,14 @@
package com.zarz.spotiflac
import java.io.Closeable
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
/** Bounded DSF/uncompressed DSDIFF reader. Samples are normalized to MSB first. */
internal class DsdFile private constructor(private val file: RandomAccessFile) : Closeable {
var rate = 0
internal class DsdFile private constructor(private val file: RandomAccessFile) : DsdSource {
override var rate = 0
private set
var channels = 0
override var channels = 0
private set
private var dataOffset = 0L
private var bytesPerChannel = 0L
@@ -18,7 +17,7 @@ internal class DsdFile private constructor(private val file: RandomAccessFile) :
private var cursor = 0L
private var cachedBlock = -1L
private var block = ByteArray(0)
val durationUs: Long get() = bytesPerChannel * 8_000_000 / rate
override val durationUs: Long get() = bytesPerChannel * 8_000_000 / rate
companion object {
fun open(path: String): DsdFile? {
@@ -125,14 +124,15 @@ internal class DsdFile private constructor(private val file: RandomAccessFile) :
}
}
fun seek(positionUs: Long, encoding: String): Long {
override fun seek(positionUs: Long, encoding: String): Long {
val group = if (encoding == "dop") 2 else 4
cursor = (positionUs.coerceIn(0, durationUs) * rate / 8_000_000 / group * group)
.coerceAtMost(bytesPerChannel)
return cursor * 8_000_000 / rate
}
fun readFrames(encoding: String, subslot: Int, maxFrames: Int = 4096): ByteArray? {
override fun readFrames(encoding: String, subslot: Int, maxFrames: Int): ByteArray? {
require(maxFrames in 1..4096)
if (cursor >= bytesPerChannel) return null
require(encoding in listOf("dop", "dsd_be", "dsd_le"))
require(subslot in 3..4)
@@ -0,0 +1,89 @@
package com.zarz.spotiflac
import java.io.Closeable
import java.io.FileInputStream
internal interface DsdSource : Closeable {
val rate: Int
val channels: Int
val durationUs: Long
fun seek(positionUs: Long, encoding: String): Long
fun readFrames(encoding: String, subslot: Int, maxFrames: Int = 4096): ByteArray?
companion object {
fun isDsd(path: String): Boolean {
val header = ByteArray(32)
val count = FileInputStream(path).use { it.read(header) }
if (count < 4) return false
return when (String(header, 0, 4, Charsets.US_ASCII)) {
"DSD ", "FRM8" -> true
"wvpk" -> count >= 28 && header[27].toInt() and 0x80 != 0
else -> false
}
}
fun open(path: String): DsdSource? {
val magic = ByteArray(4)
FileInputStream(path).use { if (it.read(magic) != 4) return null }
return if (magic.contentEquals("wvpk".toByteArray())) WavPackDsdSource.open(path)
else DsdFile.open(path)
}
}
}
internal class WavPackDsdSource private constructor(private var handle: Long) : DsdSource {
private val info = NativeAudio.infoWavPack(handle)
override val rate = info[0].toInt()
override val channels = info[1].toInt()
private val bytesPerChannel = info[2]
override val durationUs = bytesPerChannel * 8_000_000 / rate
private var cursor = 0L
companion object {
fun open(path: String): WavPackDsdSource? {
val handle = NativeAudio.openWavPack(path)
if (handle == 0L) return null
try { return WavPackDsdSource(handle) }
catch (error: Exception) { NativeAudio.closeWavPack(handle); throw error }
}
}
override fun seek(positionUs: Long, encoding: String): Long {
val group = if (encoding == "dop") 2 else 4
cursor = (positionUs.coerceIn(0, durationUs) * rate / 8_000_000 / group * group).coerceAtMost(bytesPerChannel)
if (cursor < bytesPerChannel) NativeAudio.seekWavPack(handle, cursor)
return cursor * 8_000_000 / rate
}
override fun readFrames(encoding: String, subslot: Int, maxFrames: Int): ByteArray? {
if (cursor >= bytesPerChannel) return null
require(maxFrames in 1..4096 && encoding in listOf("dop", "dsd_be", "dsd_le"))
require(if (encoding == "dop") subslot in 3..4 else subslot == 4)
val group = if (encoding == "dop") 2 else 4
val count = minOf(bytesPerChannel - cursor, maxFrames.toLong() * group).toInt()
val data = NativeAudio.readWavPack(handle, count)
require(data.size == count * channels) { "Truncated WavPack DSD" }
val frames = (count + group - 1) / group
val output = ByteArray(frames * channels * subslot)
var out = 0
for (frame in 0 until frames) {
for (channel in 0 until channels) {
fun sample(index: Int): Byte {
val pos = frame * group + index
return if (pos < count) data[pos * channels + channel] else 0x69.toByte()
}
if (encoding == "dop") {
if (subslot == 4) output[out++] = 0
output[out++] = sample(1)
output[out++] = sample(0)
output[out++] = 5
} else for (index in 0 until 4) output[out++] = sample(if (encoding == "dsd_le") 3 - index else index)
}
}
cursor += count
return output
}
override fun close() {
if (handle != 0L) { NativeAudio.closeWavPack(handle); handle = 0 }
}
}
@@ -0,0 +1,186 @@
package com.zarz.spotiflac
import android.content.Context
import android.media.AudioDeviceCallback
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.PowerManager
import io.flutter.plugin.common.MethodCall
import java.nio.ByteBuffer
/** Exact-rate PCM via Oboe's AAudio backend. DSD never enters a PCM stream. */
internal class HiResPlayback(context: Context, private val emit: (Map<String, Any>) -> Unit) {
private val manager = context.getSystemService(AudioManager::class.java)
private val thread = HandlerThread("SpotiFLAC-AAudio", android.os.Process.THREAD_PRIORITY_AUDIO).apply { start() }
private val worker = Handler(thread.looper)
private val wakeLock = context.getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SpotiFLAC:AAudio")
@Volatile private var revision = 0
private var source: UsbPcmSource? = null
private var output = 0L
private var device = 0
private var bits = 0
private var token = 0
private var pending: ByteBuffer? = null
private var playing = false
private var failed = false
private var needsSeek = false
private var baseUs = 0L
private var pausedUs = 0L
private var writtenFrames = 0L
private val route = object : AudioDeviceCallback() {
override fun onAudioDevicesRemoved(removed: Array<out AudioDeviceInfo>) {
if (output != 0L && removed.any { it.id == device }) fail("AAudio device disconnected")
}
}
init { manager.registerAudioDeviceCallback(route, worker) }
fun command(call: MethodCall, done: (Any?, String?) -> Unit) {
if (call.method == "prepare" || call.method == "stop") revision++
val expected = revision
worker.post {
try {
val result: Any? = when (call.method) {
"prepare" -> prepare(call, expected)
"resume" -> { resume(); null }
"pause" -> { pause(); null }
"seek" -> {
val wasPlaying = playing
pause()
pausedUs = ((call.argument<Number>("position")?.toLong() ?: 0) * 1000)
.coerceIn(0, source?.durationUs ?: 0)
if (wasPlaying) resume()
null
}
"position" -> positionUs() / 1000
"stop" -> { close(); null }
else -> error("Unknown hi-res command")
}
done(result, null)
} catch (error: Exception) { done(null, error.message ?: "AAudio output failed") }
}
}
private fun prepare(call: MethodCall, expected: Int): Map<String, Any> {
close()
if (expected != revision) return mapOf("reason" to "cancelled")
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
try {
val path = requireNotNull(call.argument<String>("path"))
isDsd = isDsd || DsdSource.isDsd(path)
if (!isDsd) DsdSource.open(path)?.use { isDsd = true }
if (isDsd) return mapOf("reason" to "dsd_unsupported", "fatal" to true)
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 }
output = NativeAudio.openOboe(pcm.rate, pcm.channels, pcm.bits, wired?.id ?: 0)
bits = NativeAudio.bitsOboe(output)
device = NativeAudio.deviceOboe(output)
val deadline = android.os.SystemClock.elapsedRealtime() + 5000
while (pending == null && !pcm.ended) {
require(expected == revision) { "AAudio prepare cancelled" }
require(android.os.SystemClock.elapsedRealtime() < deadline) { "Decoder timed out" }
pending = pcm.read(bits)
if (pending == null) Thread.sleep(2)
}
if (expected != revision) { close(); return mapOf("reason" to "cancelled") }
return mapOf("ready" to true, "driver" to "aaudio_exclusive", "transport" to "pcm",
"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)
}
}
private fun fill() {
val pcm = requireNotNull(source)
repeat(8) {
if (pending == null) pending = pcm.read(bits)
val data = pending ?: return
val frame = pcm.channels * (bits / 8)
val remaining = minOf(data.remaining(), 65536) / frame * frame
val count = NativeAudio.writeOboe(output, data, data.position(), remaining)
data.position(data.position() + count)
writtenFrames += count / (pcm.channels * (bits / 8))
if (data.hasRemaining()) return
pending = null
}
}
private fun resume() {
check(!failed && output != 0L) { "AAudio route changed; reopen track" }
if (needsSeek) {
source?.seek(pausedUs)
baseUs = pausedUs
pending = null
writtenFrames = 0
needsSeek = false
}
fill()
NativeAudio.startOboe(output)
playing = true
if (!wakeLock.isHeld) wakeLock.acquire()
emit(mapOf("token" to token, "event" to "active"))
worker.removeCallbacks(pump)
worker.post(pump)
}
private fun positionUs(): Long {
if (needsSeek || output == 0L) return pausedUs
val pcm = source ?: return pausedUs
pausedUs = (baseUs + NativeAudio.framesOboe(output) * 1000000 / pcm.rate).coerceAtMost(pcm.durationUs)
return pausedUs
}
private fun pause() {
if (!needsSeek) pausedUs = runCatching { positionUs() }.getOrDefault(pausedUs)
playing = false
worker.removeCallbacks(pump)
if (wakeLock.isHeld) wakeLock.release()
if (output != 0L && !needsSeek) NativeAudio.flushOboe(output)
needsSeek = true
}
private val pump = object : Runnable {
override fun run() {
if (!playing) return
try {
fill()
val pcm = requireNotNull(source)
if (pcm.ended && pending == null && NativeAudio.framesOboe(output) >= writtenFrames) {
pause()
emit(mapOf("token" to token, "event" to "complete"))
} else worker.postDelayed(this, 5)
} catch (error: Exception) { fail(error.message ?: "AAudio error") }
}
}
private fun fail(reason: String) {
android.util.Log.w("HiResPlayback", reason)
runCatching { pause() }
failed = true
if (output != 0L) NativeAudio.closeOboe(output)
output = 0
emit(mapOf("token" to token, "event" to "paused", "reason" to "route_changed"))
}
private fun close() {
runCatching { pause() }
if (output != 0L) NativeAudio.closeOboe(output)
output = 0
source?.close()
source = null
pending = null
failed = false
needsSeek = false
baseUs = 0
pausedUs = 0
writtenFrames = 0
}
fun dispose() {
revision++
manager.unregisterAudioDeviceCallback(route)
worker.post { close(); thread.quitSafely() }
}
}
@@ -295,8 +295,8 @@ internal fun MainActivity.readAudioMetadataFromUri(
uri, displayNameHint, fallbackExt,
acceptDirect = { !it.optBoolean("metadataFromFilename", false) },
) { path, name ->
if (name.endsWith(".dsf", true) || name.endsWith(".dff", true)) {
DsdFile.open(path)?.use { source ->
if (name.endsWith(".dsf", true) || name.endsWith(".dff", true) || name.endsWith(".wv", true)) {
DsdSource.open(path)?.use { source ->
return@readMetadataFromUri JSONObject().apply {
put("trackName", name.substringBeforeLast('.'))
put("artistName", "Unknown Artist")
@@ -0,0 +1,21 @@
package com.zarz.spotiflac
import java.nio.ByteBuffer
/** All calls are serialized on the owning playback worker. */
internal object NativeAudio {
init { System.loadLibrary("spotiflac_audio") }
external fun openOboe(rate: Int, channels: Int, bits: Int, device: Int): Long
external fun writeOboe(handle: Long, data: ByteBuffer, offset: Int, count: Int): Int
external fun bitsOboe(handle: Long): Int
external fun deviceOboe(handle: Long): Int
external fun startOboe(handle: Long)
external fun flushOboe(handle: Long)
external fun framesOboe(handle: Long): Long
external fun closeOboe(handle: Long)
external fun openWavPack(path: String): Long
external fun infoWavPack(handle: Long): LongArray
external fun readWavPack(handle: Long, frames: Int): ByteArray
external fun seekWavPack(handle: Long, sample: Long)
external fun closeWavPack(handle: Long)
}
@@ -31,6 +31,8 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even
private var engine: UsbPcmPlayback? = null
private var direct: UsbDirectPlayback? = null
private var useDirect = false
private var hiRes: HiResPlayback? = null
private var useHiRes = false
private lateinit var appContext: Context
private var sink: EventChannel.EventSink? = null
private val main = Handler(Looper.getMainLooper())
@@ -50,15 +52,29 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method == "prepare") {
if (useDirect) direct?.command(MethodCall("stop", null)) { _, _ -> }
if (useHiRes) hiRes?.command(MethodCall("stop", null)) { _, _ -> }
else if (useDirect) direct?.command(MethodCall("stop", null)) { _, _ -> }
else if (Build.VERSION.SDK_INT >= 34) engine?.command(MethodCall("stop", null)) { _, _ -> }
useDirect = call.argument<Boolean>("direct") == true
useHiRes = !useDirect && call.argument<Boolean>("dapExclusive") == true
if (useHiRes && hiRes == null) {
hiRes = HiResPlayback(appContext) { event -> main.post { sink?.success(event) } }
}
if (useDirect && direct == null) {
direct = UsbDirectPlayback(appContext) { event ->
main.post { sink?.success(event) }
}
}
}
if (useHiRes) {
hiRes?.command(call) { value, error ->
main.post {
if (error == null) result.success(value)
else result.error("aaudio_exclusive", error, null)
}
}
return
}
if (useDirect) {
direct?.command(call) { value, error ->
main.post {
@@ -90,6 +106,8 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even
sink = null
direct?.dispose()
direct = null
hiRes?.dispose()
hiRes = null
if (Build.VERSION.SDK_INT >= 34) engine?.dispose()
engine = null
}
@@ -15,6 +15,7 @@ import android.os.Looper
import android.os.PowerManager
import com.spotiflac.backend.UsbDirectOutput
import com.spotiflac.backend.UsbOutputFormat
import com.spotiflac.backend.UsbHardwareVolume
import io.flutter.plugin.common.MethodCall
import java.nio.ByteBuffer
@@ -35,7 +36,7 @@ internal class UsbDirectPlayback(private val context: Context, private val emit:
@Volatile private var deviceId: Int? = null
private var format: UsbOutputFormat? = null
private var pcm: UsbPcmSource? = null
private var dsd: DsdFile? = null
private var dsd: DsdSource? = null
private var pending: ByteBuffer? = null
private var playing = false
private var ended = false
@@ -46,13 +47,18 @@ internal class UsbDirectPlayback(private val context: Context, private val emit:
private var writtenFrames = 0L
private var pausedPositionUs = 0L
private var needsSeek = false
private var allowFixedVolume = false
private val deviceVolumes = mutableMapOf<Int, Double>()
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
@Suppress("DEPRECATION")
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) ?: return
if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED && device.deviceId == deviceId) {
worker.post { fail("USB disconnected") }
if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED) {
worker.post {
deviceVolumes.remove(device.deviceId)
if (device.deviceId == deviceId) fail("USB disconnected")
}
} else if (intent.action == permissionAction) {
val request = permission ?: return
if (request.device.deviceId != device.deviceId) return
@@ -109,6 +115,14 @@ internal class UsbDirectPlayback(private val context: Context, private val emit:
"pause" -> { pause(); null }
"seek" -> { seek((call.argument<Number>("position")?.toLong() ?: 0) * 1000); null }
"position" -> positionUs() / 1000
"volume" -> {
require(call.argument<Int>("token") == token) { "Stale volume request" }
val audio = requireNotNull(output)
val db = call.argument<Number>("db")?.toDouble()
val value = if (db == null) audio.volume() else audio.setVolume(db)
if (db != null && value.available) deviceId?.let { deviceVolumes[it] = value.currentDb }
volumeMap(value)
}
"stop" -> { closeSource(); null }
else -> error("Unknown USB command")
}
@@ -124,9 +138,12 @@ internal class UsbDirectPlayback(private val context: Context, private val emit:
if (request.revision != revision) { request.done(mapOf("reason" to "cancelled"), null); return@post }
closeSource()
token = request.call.argument<Int>("token") ?: 0
allowFixedVolume = request.call.argument<Boolean>("allowFixedVolume") == true
val path = requireNotNull(request.call.argument<String>("path"))
dsd = DsdFile.open(path)
isDsd = dsd != null
isDsd = DsdSource.isDsd(path)
dsd = DsdSource.open(path)
isDsd = isDsd || dsd != null
if (isDsd) requireNotNull(dsd) { "DSD decoder unavailable" }
if (!isDsd) pcm = UsbPcmSource(path)
val rate = dsd?.rate ?: requireNotNull(pcm).rate
val channels = dsd?.channels ?: requireNotNull(pcm).channels
@@ -138,6 +155,11 @@ internal class UsbDirectPlayback(private val context: Context, private val emit:
val audio = UsbDirectOutput.open(usb.fileDescriptor, usb.rawDescriptors, rate.toUInt(), channels.toUByte(), bits.toUByte(), isDsd, request.call.argument<Boolean>("allowDop") == true)
output = audio
format = audio.format()
if (audio.volume().available) {
deviceVolumes[request.device.deviceId]?.let {
audio.setVolume(minOf(it, audio.volume().restoreLimitDb))
}
}
if (request.revision != revision) { closeSource(); request.done(mapOf("reason" to "cancelled"), null); return@post }
val deadline = android.os.SystemClock.elapsedRealtime() + 5000
while (pending == null && !ended) {
@@ -149,7 +171,9 @@ internal class UsbDirectPlayback(private val context: Context, private val emit:
val f = requireNotNull(format)
request.done(mapOf("ready" to true, "device" to (request.device.productName ?: "USB audio"),
"sampleRate" to rate, "bitDepth" to bits, "duration" to durationUs / 1000,
"transport" to f.encoding, "carrierRate" to f.sampleRate.toLong(), "driver" to "usb_direct"), null)
"transport" to f.encoding, "carrierRate" to f.sampleRate.toLong(), "driver" to "usb_direct",
"volume" to volumeMap(audio.volume()),
"volumeBlocked" to (!audio.volume().available && !allowFixedVolume)), null)
} catch (error: Exception) {
android.util.Log.i("UsbDirect", "Direct output unavailable: ${error.message}")
closeSource()
@@ -174,6 +198,7 @@ internal class UsbDirectPlayback(private val context: Context, private val emit:
private fun resume() {
check(!failed) { "USB route changed; reopen track" }
val audio = requireNotNull(output)
check(audio.volume().available || allowFixedVolume) { "volume_unavailable" }
if (needsSeek) resetSource(pausedPositionUs)
fill()
audio.start()
@@ -184,6 +209,11 @@ internal class UsbDirectPlayback(private val context: Context, private val emit:
worker.post(pump)
}
private fun volumeMap(volume: UsbHardwareVolume): Map<String, Any> = mapOf(
"available" to volume.available, "minDb" to volume.minDb,
"maxDb" to volume.maxDb, "currentDb" to volume.currentDb, "token" to token,
)
private fun fill() {
val f = requireNotNull(format)
val frameBytes = f.subslot.toInt() * f.channels.toInt()
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+25
View File
@@ -0,0 +1,25 @@
Copyright (c) 1998 - 2025 David Bryant
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are met:
* Redistributions of source code must retain the above copyright notice,
this list of conditions and the following disclaimer.
* Redistributions in binary form must reproduce the above copyright notice,
this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
* Neither the name of the copyright holder nor the names of its
contributors may be used to endorse or promote products derived from
this software without specific prior written permission.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR
ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+15 -4
View File
@@ -1,10 +1,21 @@
{
"usbBitPerfect": "USB bit-perfect audio",
"usbBitPerfectDescription": "Preserve the original audio on compatible USB DACs. Android output requires Android 14 or later; the direct USB driver also works on older Android versions. Applies from the next track, bypassing AutoMix and normalization. Control volume on your DAC; system volume may not apply.",
"usbBitPerfect": "Hi-res and bit-perfect audio",
"usbBitPerfectDescription": "Preserve source precision on compatible outputs. Choose direct USB or AAudio exclusive below; otherwise Android USB output requires Android 14 or later. Applies from the next track, without AutoMix or normalization. System volume may not control direct USB.",
"usbHardwareVolume": "DAC hardware volume",
"usbVolumeWarningTitle": "Check your listening volume",
"usbVolumeWarning": "Direct USB audio can be much louder than normal playback, and phone volume buttons may not control it. Lower your DAC or amplifier volume before starting. When supported, SpotiFLAC sets the DAC to at most −40 dB on connection; this does not guarantee a safe listening level on every amplifier or headphone.",
"usbVolumeAccept": "I understand",
"usbVolumeUnavailable": "Playback is paused because this DAC has no verified hardware volume control. Lower the DAC or amplifier volume, then enable fixed-volume output if needed and select the track again.",
"usbFixedVolume": "Allow fixed-volume USB output",
"usbFixedVolumeDescription": "For DACs without a volume control accessible to the app. Keep off for USB headsets without their own volume control. Applies from the next track.",
"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.",
"usbDirect": "Direct USB driver",
"usbDirectDescription": "Use exclusive USB audio for FLAC/WAV and DSF/DFF, with Android USB permission. Native DSD requires a recognized compatible DAC. Other PCM formats use normal playback. Applies from the next track.",
"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)",
"usbDsdOverPcmDescription": "Enable only if your DAC explicitly supports DoP. Sends DSF/DFF as DSD inside a PCM carrier when native DSD is unavailable. An incompatible device may play noise; leave off for ordinary USB headsets.",
"usbDsdOverPcmDescription": "Enable only if your DAC explicitly supports DoP. Sends DSF/DFF and WavPack DSD inside a PCM carrier when native DSD is unavailable. An incompatible device may play noise; leave off for ordinary USB headsets.",
"usbDsdUnsupported": "This DSD format or connected DAC is not supported. Playback stopped.",
"usbPermissionDenied": "USB permission was not granted. Using normal playback.",
"usbBitPerfectPending": "Waiting for the next track and a compatible USB output.",
+15 -4
View File
@@ -1,12 +1,23 @@
{
"usbBitPerfect": "Audio USB bit-perfect",
"usbBitPerfect": "Audio hi-res dan bit-perfect",
"usbHardwareVolume": "Volume hardware DAC",
"usbVolumeWarningTitle": "Periksa volume sebelum memutar",
"usbVolumeWarning": "Audio USB langsung bisa jauh lebih keras daripada pemutaran biasa, dan tombol volume HP mungkin tidak berpengaruh. Turunkan volume DAC atau amplifier sebelum memutar. Jika didukung, SpotiFLAC mengatur volume DAC paling tinggi −40 dB saat tersambung; angka ini tidak menjamin tingkat suara yang aman untuk semua amplifier atau headphone.",
"usbVolumeAccept": "Saya mengerti",
"usbVolumeUnavailable": "Pemutaran dijeda karena DAC ini tidak memiliki kontrol volume hardware yang terverifikasi. Turunkan volume DAC atau amplifier, lalu aktifkan keluaran volume tetap jika diperlukan dan pilih lagunya kembali.",
"usbFixedVolume": "Izinkan keluaran USB volume tetap",
"usbFixedVolumeDescription": "Untuk DAC yang kontrol volumenya tidak bisa diakses aplikasi. Biarkan mati pada headset USB tanpa kontrol volume sendiri. Berlaku mulai lagu berikutnya.",
"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.",
"usbDirect": "Driver USB langsung",
"usbDirectDescription": "Gunakan audio USB eksklusif untuk FLAC/WAV dan DSF/DFF, dengan izin USB Android. Native DSD memerlukan DAC kompatibel yang dikenali. Format PCM lainnya memakai pemutaran biasa. Berlaku mulai lagu berikutnya.",
"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)",
"usbDsdOverPcmDescription": "Aktifkan hanya jika DAC Anda mendukung DoP. Mengirim DSF/DFF sebagai DSD di dalam pembawa PCM saat native DSD tidak tersedia. Perangkat yang tidak kompatibel dapat menghasilkan suara bising; biarkan mati untuk headset USB biasa.",
"usbDsdOverPcmDescription": "Aktifkan hanya jika DAC Anda mendukung DoP. Mengirim DSF/DFF dan WavPack DSD di dalam pembawa PCM saat native DSD tidak tersedia. Perangkat yang tidak kompatibel dapat menghasilkan suara bising; biarkan mati untuk headset USB biasa.",
"usbDsdUnsupported": "Format DSD atau DAC yang tersambung tidak didukung. Pemutaran dihentikan.",
"usbPermissionDenied": "Izin USB tidak diberikan. Memakai pemutaran biasa.",
"usbBitPerfectDescription": "Pertahankan audio asli pada DAC USB yang kompatibel. Keluaran Android memerlukan Android 14 atau lebih baru; driver USB langsung juga tersedia di Android versi lama. Berlaku mulai lagu berikutnya, tanpa AutoMix dan normalisasi. Atur volume melalui DAC; volume sistem mungkin tidak berlaku.",
"usbBitPerfectDescription": "Pertahankan presisi audio asli pada keluaran kompatibel. Pilih USB langsung atau AAudio eksklusif di bawah; keluaran USB Android biasa memerlukan Android 14 atau lebih baru. Berlaku mulai lagu berikutnya, tanpa AutoMix atau normalisasi. Volume sistem mungkin tidak mengatur USB langsung.",
"usbBitPerfectPending": "Menunggu lagu berikutnya dan keluaran USB yang kompatibel.",
"usbBitPerfectNoDevice": "DAC USB tidak terhubung. Menggunakan keluaran audio biasa.",
"usbBitPerfectFallback": "Perangkat atau format audio ini tidak mendukung keluaran bit-perfect. Menggunakan pemutaran biasa.",
+6
View File
@@ -53,6 +53,12 @@ void main() {
'libusb',
'libusb1-sys',
], await rootBundle.loadString('assets/licenses/usb.txt'));
yield LicenseEntryWithLineBreaks(const [
'Oboe',
], await rootBundle.loadString('assets/licenses/oboe.txt'));
yield LicenseEntryWithLineBreaks(const [
'WavPack',
], await rootBundle.loadString('assets/licenses/wavpack.txt'));
}
});
_startupBenchmark?.observeFirstFrame();
+8
View File
@@ -35,6 +35,8 @@ class AppSettings {
final bool usbBitPerfect;
final bool usbDirect;
final bool usbDsdOverPcm;
final bool usbAllowFixedVolume;
final bool dapExclusive;
final bool autoplay;
final bool isFirstLaunch;
final bool checkForUpdates;
@@ -151,6 +153,8 @@ class AppSettings {
this.usbBitPerfect = false,
this.usbDirect = false,
this.usbDsdOverPcm = false,
this.usbAllowFixedVolume = false,
this.dapExclusive = false,
this.autoplay = false,
this.isFirstLaunch = true,
this.checkForUpdates = true,
@@ -242,6 +246,8 @@ class AppSettings {
bool? usbBitPerfect,
bool? usbDirect,
bool? usbDsdOverPcm,
bool? usbAllowFixedVolume,
bool? dapExclusive,
bool? autoplay,
bool? isFirstLaunch,
bool? checkForUpdates,
@@ -329,6 +335,8 @@ class AppSettings {
usbBitPerfect: usbBitPerfect ?? this.usbBitPerfect,
usbDirect: usbDirect ?? this.usbDirect,
usbDsdOverPcm: usbDsdOverPcm ?? this.usbDsdOverPcm,
usbAllowFixedVolume: usbAllowFixedVolume ?? this.usbAllowFixedVolume,
dapExclusive: dapExclusive ?? this.dapExclusive,
autoplay: autoplay ?? this.autoplay,
isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch,
checkForUpdates: checkForUpdates ?? this.checkForUpdates,
+4
View File
@@ -26,6 +26,8 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> json) => AppSettings(
usbBitPerfect: json['usbBitPerfect'] as bool? ?? false,
usbDirect: json['usbDirect'] as bool? ?? false,
usbDsdOverPcm: json['usbDsdOverPcm'] as bool? ?? false,
usbAllowFixedVolume: json['usbAllowFixedVolume'] as bool? ?? false,
dapExclusive: json['dapExclusive'] as bool? ?? false,
autoplay: json['autoplay'] as bool? ?? false,
isFirstLaunch: json['isFirstLaunch'] as bool? ?? true,
checkForUpdates: json['checkForUpdates'] as bool? ?? true,
@@ -129,6 +131,8 @@ Map<String, dynamic> _$AppSettingsToJson(
'usbBitPerfect': instance.usbBitPerfect,
'usbDirect': instance.usbDirect,
'usbDsdOverPcm': instance.usbDsdOverPcm,
'usbAllowFixedVolume': instance.usbAllowFixedVolume,
'dapExclusive': instance.dapExclusive,
'autoplay': instance.autoplay,
'isFirstLaunch': instance.isFirstLaunch,
'checkForUpdates': instance.checkForUpdates,
+17 -1
View File
@@ -567,7 +567,10 @@ class SettingsNotifier extends Notifier<AppSettings> {
}
void setUsbDirect(bool enabled) {
state = state.copyWith(usbDirect: enabled);
state = state.copyWith(
usbDirect: enabled,
dapExclusive: enabled ? false : null,
);
_saveSettings();
}
@@ -576,6 +579,19 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setUsbAllowFixedVolume(bool enabled) {
state = state.copyWith(usbAllowFixedVolume: enabled);
_saveSettings();
}
void setDapExclusive(bool enabled) {
state = state.copyWith(
dapExclusive: enabled,
usbDirect: enabled ? false : null,
);
_saveSettings();
}
void setEmbedMetadata(bool enabled) {
state = state.copyWith(embedMetadata: enabled);
_saveSettings();
+42 -2
View File
@@ -26,6 +26,7 @@ import 'package:spotiflac_android/screens/settings/settings_tab.dart';
import 'package:spotiflac_android/services/platform_bridge.dart';
import 'package:spotiflac_android/services/shell_navigation_service.dart';
import 'package:spotiflac_android/services/share_intent_service.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
import 'package:spotiflac_android/services/music_player_service.dart';
import 'package:spotiflac_android/services/notification_service.dart';
import 'package:spotiflac_android/services/app_remote_config_service.dart';
@@ -63,6 +64,7 @@ class _MainShellState extends ConsumerState<MainShell>
bool _hasCheckedAppAnnouncement = false;
bool _initialSafRepairComplete = false;
bool _safRepairDialogVisible = false;
bool _usbVolumeWarningShown = false;
StreamSubscription<String>? _shareSubscription;
DateTime? _lastBackPress;
final GlobalKey<NavigatorState> _homeTabNavigatorKey =
@@ -96,6 +98,8 @@ class _MainShellState extends ConsumerState<MainShell>
setUsbOutputOptions(
direct: ref.read(settingsProvider).usbDirect,
allowDop: ref.read(settingsProvider).usbDsdOverPcm,
allowFixedVolume: ref.read(settingsProvider).usbAllowFixedVolume,
dapExclusive: ref.read(settingsProvider).dapExclusive,
);
setAutoplayEnabled(
ref.read(settingsProvider).autoplay,
@@ -114,6 +118,7 @@ class _MainShellState extends ConsumerState<MainShell>
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
usbAudioStatus.addListener(_onUsbAudioStatusChanged);
_homePreviewStopObserver = _PreviewStopNavigatorObserver(
() => ref.read(previewPlayerProvider.notifier).stop(),
);
@@ -158,6 +163,28 @@ class _MainShellState extends ConsumerState<MainShell>
});
}
void _onUsbAudioStatusChanged() {
if (usbAudioStatus.value.reason == 'active') {
_usbVolumeWarningShown = false;
}
if (usbAudioStatus.value.reason != 'volume_unavailable' ||
_usbVolumeWarningShown) {
return;
}
_usbVolumeWarningShown = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || usbAudioStatus.value.reason != 'volume_unavailable') {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.usbVolumeUnavailable),
duration: const Duration(seconds: 8),
),
);
});
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && _initialSafRepairComplete) {
@@ -509,6 +536,7 @@ class _MainShellState extends ConsumerState<MainShell>
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
usbAudioStatus.removeListener(_onUsbAudioStatusChanged);
ShellNavigationService.unregisterTabSelectionHandler(this);
_shareSubscription?.cancel();
_pageController.dispose();
@@ -751,8 +779,20 @@ class _MainShellState extends ConsumerState<MainShell>
setUsbBitPerfectEnabled(enabled);
});
ref.listen(
settingsProvider.select((s) => (s.usbDirect, s.usbDsdOverPcm)),
(_, value) => setUsbOutputOptions(direct: value.$1, allowDop: value.$2),
settingsProvider.select(
(s) => (
s.usbDirect,
s.usbDsdOverPcm,
s.usbAllowFixedVolume,
s.dapExclusive,
),
),
(_, value) => setUsbOutputOptions(
direct: value.$1,
allowDop: value.$2,
allowFixedVolume: value.$3,
dapExclusive: value.$4,
),
);
ref.listen(
settingsProvider.select((s) => (s.autoplay, s.localLibraryEnabled)),
+114 -7
View File
@@ -19,6 +19,7 @@ import 'package:spotiflac_android/widgets/app_bottom_sheet.dart';
import 'package:spotiflac_android/widgets/settings_group.dart';
import 'package:spotiflac_android/widgets/app_sliver_header.dart';
import 'package:spotiflac_android/widgets/ios_library_folder_sheet.dart';
import 'package:spotiflac_android/widgets/mornye_volume_control.dart';
class LibrarySettingsPage extends ConsumerStatefulWidget {
const LibrarySettingsPage({super.key});
@@ -205,6 +206,27 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
await ref.read(localLibraryProvider.notifier).cancelScan();
}
Future<bool> _confirmAudioVolume(String message) async {
return await showAppDialog<bool>(
context: context,
builder: (context) => AppAlertDialog(
title: Text(context.l10n.usbVolumeWarningTitle),
content: Text(message),
actions: [
AppDialogAction(
onPressed: () => Navigator.pop(context, false),
child: Text(context.l10n.dialogCancel),
),
AppDialogAction(
onPressed: () => Navigator.pop(context, true),
child: Text(context.l10n.usbVolumeAccept),
),
],
),
) ??
false;
}
Future<void> _clearLibrary() async {
final confirmed = await showAppDialog<bool>(
context: context,
@@ -913,6 +935,10 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
? 'DSD (DoP)'
: 'DSD (Native)'} · ${(status.sampleRate! / 1000).toStringAsFixed(1)} kHz',
'dsd_unsupported' => context.l10n.usbDsdUnsupported,
'volume_unavailable' =>
context.l10n.usbVolumeUnavailable,
'exclusive_unavailable' =>
context.l10n.dapExclusiveUnavailable,
'permission_denied' =>
context.l10n.usbPermissionDenied,
'no_usb' => context.l10n.usbBitPerfectNoDevice,
@@ -925,9 +951,19 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
},
].join('\n\n'),
value: settings.usbBitPerfect,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsbBitPerfect(value),
onChanged: (value) async {
if (value &&
!await _confirmAudioVolume(
context.l10n.usbVolumeWarning,
)) {
return;
}
if (mounted) {
ref
.read(settingsProvider.notifier)
.setUsbBitPerfect(value);
}
},
showDivider: settings.usbBitPerfect,
),
),
@@ -937,9 +973,18 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
title: context.l10n.usbDirect,
subtitle: context.l10n.usbDirectDescription,
value: settings.usbDirect,
onChanged: (value) =>
ref.read(settingsProvider.notifier).setUsbDirect(value),
showDivider: settings.usbDirect,
onChanged: (value) async {
if (value &&
!await _confirmAudioVolume(
context.l10n.usbVolumeWarning,
)) {
return;
}
if (mounted) {
ref.read(settingsProvider.notifier).setUsbDirect(value);
}
},
showDivider: true,
),
if (settings.usbDirect)
SettingsSwitchItem(
@@ -950,8 +995,70 @@ class _LibrarySettingsPageState extends ConsumerState<LibrarySettingsPage> {
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setUsbDsdOverPcm(value),
showDivider: false,
showDivider: true,
),
if (settings.usbDirect) ...[
SettingsSwitchItem(
icon: Icons.volume_up_outlined,
title: context.l10n.usbFixedVolume,
subtitle: context.l10n.usbFixedVolumeDescription,
value: settings.usbAllowFixedVolume,
onChanged: (value) async {
if (value &&
!await _confirmAudioVolume(
context.l10n.usbFixedVolumeWarning,
)) {
return;
}
if (mounted) {
ref
.read(settingsProvider.notifier)
.setUsbAllowFixedVolume(value);
}
},
),
ValueListenableBuilder<UsbVolumeState?>(
valueListenable: usbHardwareVolume,
builder: (context, volume, _) => Padding(
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 12,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.usbHardwareVolume,
style: Theme.of(context).textTheme.titleMedium,
),
Text(
volume == null
? context.l10n.usbBitPerfectPending
: volume.available
? '${volume.currentDb.toStringAsFixed(1)} dB'
: context.l10n.usbVolumeUnavailable,
),
if (volume?.available == true)
MornyeVolumeControl(
foreground: Theme.of(
context,
).colorScheme.onSurface,
),
],
),
),
),
],
SettingsSwitchItem(
icon: Icons.headphones,
title: context.l10n.dapExclusive,
subtitle: context.l10n.dapExclusiveDescription,
value: settings.dapExclusive,
onChanged: (value) => ref
.read(settingsProvider.notifier)
.setDapExclusive(value),
showDivider: false,
),
],
],
),
+66 -2
View File
@@ -8,10 +8,45 @@ import 'package:flutter/services.dart';
/// Only verified native playback reports active. File metadata alone never does.
final usbAudioStatus = ValueNotifier<UsbAudioStatus>(const UsbAudioStatus());
class UsbDsdUnavailable extends StateError {
class UsbOutputUnavailable extends StateError {
UsbOutputUnavailable(super.message);
}
class UsbDsdUnavailable extends UsbOutputUnavailable {
UsbDsdUnavailable() : super('DSD requires a compatible direct USB output');
}
/// Null outside direct USB; an unavailable control must not change phone volume.
final usbHardwareVolume = ValueNotifier<UsbVolumeState?>(null);
class UsbVolumeState {
UsbVolumeState(Map<dynamic, dynamic> data)
: available = data['available'] == true,
minDb = (data['minDb'] as num?)?.toDouble() ?? -90,
maxDb = (data['maxDb'] as num?)?.toDouble() ?? 0,
currentDb = (data['currentDb'] as num?)?.toDouble() ?? 0,
token = (data['token'] as num?)?.toInt() ?? 0;
final bool available;
final double minDb, maxDb, currentDb;
final int token;
double get fraction => available && maxDb > minDb
? ((currentDb - minDb) / (maxDb - minDb)).clamp(0, 1)
: 0;
}
Future<void> setUsbHardwareVolume(double fraction) async {
final state = usbHardwareVolume.value;
if (state == null || !state.available) return;
final result = await MusicPlaybackDeck._channel
.invokeMapMethod<String, dynamic>('volume', {
'token': state.token,
'db': state.minDb + fraction.clamp(0, 1) * (state.maxDb - state.minDb),
});
if (result != null && usbHardwareVolume.value?.token == state.token) {
usbHardwareVolume.value = UsbVolumeState(result);
}
}
class UsbAudioStatus {
const UsbAudioStatus({
this.reason = 'idle',
@@ -72,6 +107,7 @@ class MusicPlaybackDeck {
DeviceFileSource? _source;
bool _routeLost = false;
bool _nativeStarted = false;
bool _volumeBlocked = false;
PlayerState _directState = PlayerState.stopped;
UsbAudioStatus _preparedStatus = const UsbAudioStatus();
@@ -113,6 +149,8 @@ class MusicPlaybackDeck {
bool directUsb = false,
bool allowDop = false,
bool requiresDsd = false,
bool allowFixedVolume = false,
bool dapExclusive = false,
}) async {
await stop();
_source = source;
@@ -142,9 +180,17 @@ class MusicPlaybackDeck {
'token': _token,
'direct': directUsb,
'allowDop': allowDop,
'allowFixedVolume': allowFixedVolume,
'dapExclusive': dapExclusive,
'requiresDsd': requiresDsd,
});
if (_disposed || preparingToken != _token) return;
if (response?['ready'] == true) {
_volumeBlocked = response?['volumeBlocked'] == true;
final volume = response?['volume'];
usbHardwareVolume.value = volume is Map
? UsbVolumeState(volume)
: null;
_direct = true;
_directState = PlayerState.stopped;
_duration = Duration(
@@ -158,7 +204,9 @@ class MusicPlaybackDeck {
transport: response?['transport'] as String? ?? 'pcm',
driver: response?['driver'] as String? ?? 'android',
);
usbAudioStatus.value = const UsbAudioStatus(reason: 'ready');
usbAudioStatus.value = UsbAudioStatus(
reason: _volumeBlocked ? 'volume_unavailable' : 'ready',
);
_durations.add(_duration!);
return;
}
@@ -211,6 +259,12 @@ class MusicPlaybackDeck {
Future<void> resume() async {
if (!_direct) return _ordinary.resume();
if (_volumeBlocked) {
usbAudioStatus.value = const UsbAudioStatus(reason: 'volume_unavailable');
throw UsbOutputUnavailable(
'DAC hardware volume unavailable; check audio output settings',
);
}
final token = _token;
// Before first playback the SAF lease is still open. After playback starts,
// reconnect through the handler to obtain a fresh lease for the same song.
@@ -219,6 +273,14 @@ class MusicPlaybackDeck {
await _channel.invokeMethod<void>('resume');
} on PlatformException {
if (token != _token || !_direct || _disposed) return;
if (_preparedStatus.driver == 'usb_direct') {
_routeLost = true;
_setState(PlayerState.paused);
usbAudioStatus.value = const UsbAudioStatus(reason: 'route_changed');
throw UsbOutputUnavailable(
'Direct USB output changed; reconnect before playing',
);
}
if (_nativeStarted) {
_routeLost = true;
_setState(PlayerState.paused);
@@ -297,6 +359,8 @@ class MusicPlaybackDeck {
await _channel.invokeMethod<void>('stop');
_setState(PlayerState.stopped);
_direct = false;
_volumeBlocked = false;
usbHardwareVolume.value = null;
usbAudioStatus.value = const UsbAudioStatus();
}
await _ordinary.stop();
+14 -2
View File
@@ -46,6 +46,8 @@ bool _autoMixEnabled = false;
bool _usbBitPerfectEnabled = false;
bool _usbDirectEnabled = false;
bool _usbDopEnabled = false;
bool _usbAllowFixedVolume = false;
bool _dapExclusiveEnabled = false;
MusicPlayerHandler? _activeMusicPlayerHandler;
PlaybackNotification _notificationPresentation = const PlaybackNotification();
Future<void> Function(MediaItem)? _toggleNotificationFavorite;
@@ -111,9 +113,16 @@ void setUsbBitPerfectEnabled(bool enabled) {
// Apply at the next source boundary; never raise a playing track's volume.
}
void setUsbOutputOptions({required bool direct, required bool allowDop}) {
void setUsbOutputOptions({
required bool direct,
required bool allowDop,
bool allowFixedVolume = false,
bool dapExclusive = false,
}) {
_usbDirectEnabled = direct;
_usbDopEnabled = allowDop;
_usbAllowFixedVolume = allowFixedVolume;
_dapExclusiveEnabled = dapExclusive;
}
/// Refreshes gain tags after a successful file update, including SAF copies.
@@ -1377,8 +1386,11 @@ class MusicPlayerHandler extends BaseAudioHandler
DeviceFileSource(path),
preferBitPerfect: _usbBitPerfectEnabled,
directUsb: _usbDirectEnabled,
allowFixedVolume: _usbAllowFixedVolume,
dapExclusive: _dapExclusiveEnabled,
allowDop: _usbDopEnabled,
requiresDsd:
media.bitDepth == 1 ||
const ['dsf', 'dff'].contains(media.format?.toLowerCase()) ||
media.source.toLowerCase().endsWith('.dsf') ||
media.source.toLowerCase().endsWith('.dff'),
@@ -1489,7 +1501,7 @@ class MusicPlayerHandler extends BaseAudioHandler
playbackLease = null;
}
} catch (directError) {
if (directError is UsbDsdUnavailable ||
if (directError is UsbOutputUnavailable ||
playbackLease == null ||
!_isCurrentPlayRequest(generation, media)) {
rethrow;
+28 -2
View File
@@ -7,6 +7,19 @@ import 'package:spotiflac_android/l10n/l10n.dart';
import 'package:spotiflac_android/widgets/mornye_player_slider.dart';
import 'package:spotiflac_android/utils/logger.dart';
import 'package:volume_controller/volume_controller.dart';
import 'package:spotiflac_android/services/music_playback_deck.dart';
final usbVolumeProvider = StreamProvider.autoDispose<UsbVolumeState?>((ref) {
final events = StreamController<UsbVolumeState?>();
void changed() => events.add(usbHardwareVolume.value);
usbHardwareVolume.addListener(changed);
changed();
ref.onDispose(() {
usbHardwareVolume.removeListener(changed);
unawaited(events.close());
});
return events.stream;
});
/// A single shared subscription for the visible player, including hardware
/// volume-button changes. This controls system volume, not ReplayGain gain.
@@ -29,6 +42,7 @@ final systemVolumeWriterProvider = Provider<Future<void> Function(double)>((
ref,
) {
return (value) {
if (usbHardwareVolume.value != null) return setUsbHardwareVolume(value);
final volume = VolumeController.instance;
volume.showSystemUI = false;
return volume.setVolume(value.clamp(0, 1));
@@ -60,10 +74,15 @@ class _MornyeVolumeControlState extends ConsumerState<MornyeVolumeControl> {
Future<void> _flushVolume() async {
_writing = true;
final write = ref.read(systemVolumeWriterProvider);
final usbToken = usbHardwareVolume.value?.token;
try {
// Send changes during the gesture. If the platform is still processing
// a write, keep only the newest value instead of queuing stale positions.
while (mounted && _pendingVolume != null) {
if (usbHardwareVolume.value?.token != usbToken) {
_pendingVolume = null;
break;
}
final value = _pendingVolume!;
_pendingVolume = null;
try {
@@ -80,11 +99,18 @@ class _MornyeVolumeControlState extends ConsumerState<MornyeVolumeControl> {
@override
Widget build(BuildContext context) {
final volume = ref.watch(systemVolumeProvider).value;
final usb = ref.watch(usbVolumeProvider).value ?? usbHardwareVolume.value;
final volume = usb == null
? ref.watch(systemVolumeProvider).value
: usb.available
? usb.fraction
: null;
final value = (_preview ?? volume ?? 0).clamp(0.0, 1.0);
String percentage(double volume) => '${(volume * 100).round()}%';
return Semantics(
label: context.l10n.nowPlayingVolume,
label: usb == null
? context.l10n.nowPlayingVolume
: context.l10n.usbHardwareVolume,
slider: true,
enabled: volume != null,
excludeSemantics: true,
+2
View File
@@ -112,6 +112,8 @@ flutter:
- assets/images/
- assets/fonts/Inter-LICENSE.txt
- assets/licenses/usb.txt
- assets/licenses/oboe.txt
- assets/licenses/wavpack.txt
fonts:
- family: Inter
@@ -1,8 +1,9 @@
# Direct USB audio
Android USB host transport for local FLAC/WAV PCM and uncompressed DSF/DSDIFF.
Android USB host transport for local FLAC/WAV PCM, uncompressed DSF/DSDIFF and
lossless WavPack DSD.
The existing Android 14 preferred-mixer path remains available. Direct USB is
opt-in under Settings > Library > Playback > USB bit-perfect audio.
opt-in under Settings > Library > Playback > Hi-res and bit-perfect audio.
## Ownership and playback
@@ -23,13 +24,37 @@ opt-in under Settings > Library > Playback > USB bit-perfect audio.
completed frame. Disconnect pauses playback; it never redirects active USB
playback to the speaker. Pending permission requests can be cancelled by Next.
- ReplayGain, AutoMix, software volume and playback-rate processing are bypassed
while USB mode is selected. Volume is controlled at the DAC.
while USB mode is selected. Volume is controlled at the DAC's USB Feature Unit.
## Hardware volume
- Follow the selected playback terminal to a UAC1/UAC2 Feature Unit. Do not
modify capture controls, guess a mixer/selector route, or advertise volume
when only some channels are writable.
- Read GET_MIN/MAX/RES (UAC1) or RANGE (UAC2), then SET_CUR and read back CUR.
Master volume is preferred; otherwise all playback channels must be writable.
Values are signed 1/256 dB. Gaps/steps round down; positive gain is not offered.
- Before the first audio transfer, lower each channel to at most -40 dB, keeping
quieter or already silent channels. Reuse a user-selected volume between songs
on the same attached device, respecting a hardware knob lowered externally.
Remove remembered levels on detach. This is attenuation, not a guarantee of
safe acoustic output for every amplifier/headphone combination.
- The Mornye player slider and Library playback settings control verified DAC
volume. They never modify DSD/DoP samples or apply software gain. Phone volume
buttons may not affect direct USB output.
- Warn before enabling the mode. A DAC without verified hardware volume stays
paused unless the user explicitly enables fixed-volume output after a second
warning. A failure after direct-USB preparation never redirects to the speaker.
## DSD
`DsdFile.kt` normalizes DSF channel blocks/LSB ordering and DFF byte-interleaved
MSB data without loading the file into memory. Native U32 uses the device's
byte ordering. DoP uses 24-bit words, left-aligned when carried in 32-bit slots.
WavPack 5.9.0 decodes compressed DSD to the same MSB-first bitstream through a
small JNI bridge (`OPEN_DSD_NATIVE`, checksums enabled). It retains its own file
descriptor across SAF lease closure and never converts DSD into PCM. Reads and
seeks are bounded; only mono/stereo DSD64 through DSD512 are accepted.
The transport assigns alternating 05/FA markers across all frames, including
inserted DSD silence. Seeking retains channel/frame alignment.
@@ -46,9 +71,9 @@ settings; no product-name match or assumption that PCM support implies DSD:
DoP is a separate opt-in for a DAC explicitly known to support it; USB audio
descriptors do not announce DoP support. Keep it disabled for ordinary USB
headsets. Unsupported DSD is stopped rather than interpreted as PCM audio.
WavPack DSD, DST compression, DSD-to-PCM conversion and vendor DAP outputs are not
implemented. SAF scanning reads basic DSD format/duration and uses the filename
for metadata; embedded DSD artwork/tags are not yet imported.
DSD DST compression, DSD-to-PCM conversion and vendor-specific internal DAP DSD
commands are not implemented. SAF scanning reads basic DSD format/duration and
uses the filename for metadata; embedded DSD artwork/tags are not yet imported.
## Verification
@@ -57,13 +82,35 @@ for metadata; embedded DSD artwork/tags are not yet imported.
- JVM tests: DSF padding/bit order, DFF channel order, native LE/BE, DoP layout,
seeking, truncated containers and compressed-DST rejection.
- Android instrumentation: absent USB fallback, real native FFI error cleanup,
and integer WAV decode/seek after the original SAF descriptor is closed.
integer WAV decode/seek after the original SAF descriptor is closed, synthetic
WavPack DSD byte equality through native/DoP packing, seek and EOF behavior.
- Hardware volume host tests: UAC1/UAC2 ranges and read-back, failed verification,
silence preservation, independent channel attenuation and playback topology.
- Flutter tests: opt-in settings, transport options, cancellation, stale replies,
DSD failure without PCM fallback, existing playback/queue/DSP behavior.
Physical PCM/DoP/native-DSD output, DAC lock indication, hardware volume and long
playback stability still require real USB hardware. Host tests, emulator tests
and successful ARM32/ARM64 builds do not establish DAC compatibility.
The user reported working physical USB DSP playback with the previous direct
USB build (device model unspecified). New hardware volume, native DSD/DoP and
long playback stability still require device testing. Host tests, emulator
tests and successful ARM32/ARM64 builds do not establish DAC compatibility.
## DAP PCM hi-res
The separate **DAP hi-res (AAudio exclusive)** option uses Oboe 1.11.0 on Android
8.1+. It opens at the source rate/channels and sufficient precision, with Oboe
conversion disabled. Integer PCM is padded losslessly when necessary; float
output is allowed only for <=24-bit sources with exact representation. A bounded
single-producer/single-consumer queue feeds the audio callback without allocation,
locking or file I/O. Hardware timestamps exclude inserted silence from position.
Android may grant Shared when Exclusive was requested. Verify the actual API,
sharing mode, rate, channels and format before reporting active output. If no
matching exclusive stream is available, close it and use ordinary playback.
DSD is rejected before reaching this PCM engine. Disconnect after starting
pauses playback. This does not claim to bypass undocumented vendor DSP.
The current emulator only grants Shared; instrumentation verifies rejection.
Actual exclusive DAP playback and route-specific hardware behavior need a DAP.
## Reference and dependencies
@@ -71,4 +118,7 @@ USB identity/format facts were checked against
[Linux USB audio quirks](https://github.com/torvalds/linux/blob/master/sound/usb/quirks.c).
The Android-only `libusb1-sys` dependency builds its unmodified bundled libusb;
license texts and rebuild information are in `assets/licenses/usb.txt` and in
the app's license registry. No additional audio SDK or decoder library is added.
the app's license registry. Oboe (Apache-2.0) and WavPack (BSD-3-Clause) are built
statically into `libspotiflac_audio.so` for ARM32/ARM64. Their pinned release URLs
and SHA-256 checksums are in `android/app/src/main/cpp/CMakeLists.txt`; licenses
are included in `assets/licenses/oboe.txt` and `assets/licenses/wavpack.txt`.
@@ -8,6 +8,8 @@ mod framing;
#[cfg(target_os = "android")]
#[allow(unsafe_code)] // libusb ownership is confined to this worker module.
mod transport;
#[cfg(any(test, target_os = "android"))]
mod volume;
use std::sync::Arc;
@@ -34,6 +36,17 @@ pub struct UsbOutputFormat {
pub encoding: String,
}
#[derive(uniffi::Record, Clone, Default)]
pub struct UsbHardwareVolume {
pub available: bool,
pub min_db: f64,
pub max_db: f64,
pub current_db: f64,
/// Quietest channel before startup attenuation; restoring a previous UI
/// setting must respect a DAC volume knob that was lowered externally.
pub restore_limit_db: f64,
}
#[derive(uniffi::Object)]
pub struct UsbDirectOutput {
#[cfg(target_os = "android")]
@@ -118,4 +131,21 @@ impl UsbDirectOutput {
#[cfg(target_os = "android")]
self.output.close();
}
pub fn volume(&self) -> UsbHardwareVolume {
#[cfg(target_os = "android")]
return self.output.volume();
#[cfg(not(target_os = "android"))]
unreachable!("Android-only constructor")
}
pub fn set_volume(&self, db: f64) -> Result<UsbHardwareVolume, UsbAudioError> {
#[cfg(target_os = "android")]
return self.output.set_volume(db).map_err(Into::into);
#[cfg(not(target_os = "android"))]
{
let _ = db;
unreachable!("Android-only constructor")
}
}
}
@@ -1,8 +1,9 @@
//! All raw libusb pointers belong to one thread. Transfer buffers remain at
//! stable addresses until callbacks complete, including cancellation on drop.
use super::{
UsbOutputFormat,
UsbHardwareVolume, UsbOutputFormat,
descriptors::{self, Alternate, Endpoint, PacketClock},
volume::{self, Volume},
};
use libusb1_sys as usb;
use std::{
@@ -22,6 +23,13 @@ struct State {
flush: u64,
flushed: u64,
error: Option<String>,
volume: UsbHardwareVolume,
volume_requests: VecDeque<VolumeRequest>,
}
struct VolumeRequest {
db: f64,
deadline: Instant,
reply: mpsc::SyncSender<Result<UsbHardwareVolume, String>>,
}
type Shared = Arc<(Mutex<State>, Condvar)>;
@@ -61,9 +69,14 @@ impl Output {
}
// SAF/device descriptors are opened by Android and stay owned by
// Kotlin until this worker has joined. No device discovery/root.
let result = Session::open(fd, candidates);
let result = Session::open(fd, candidates, &raw);
match result {
Ok(mut session) => {
state.0.lock().unwrap().volume = session
.volume
.as_ref()
.map(Volume::snapshot)
.unwrap_or_default();
let _ = tx.send(Ok(session.format.clone()));
if let Err(error) = session.run(&state) {
let mut s = state.0.lock().unwrap();
@@ -157,6 +170,30 @@ impl Output {
let _ = t.join();
}
}
pub fn volume(&self) -> UsbHardwareVolume {
self.shared.0.lock().unwrap().volume.clone()
}
pub fn set_volume(&self, db: f64) -> Result<UsbHardwareVolume, String> {
let (reply, result) = mpsc::sync_channel(1);
{
let mut s = self.shared.0.lock().unwrap();
if s.closed || s.error.is_some() || !s.volume.available {
return Err("USB hardware volume unavailable".into());
}
if s.volume_requests.len() >= 8 {
return Err("USB volume requests busy".into());
}
s.volume_requests.push_back(VolumeRequest {
db,
deadline: Instant::now() + Duration::from_secs(2),
reply,
});
self.shared.1.notify_all();
}
result
.recv_timeout(Duration::from_secs(4))
.map_err(|_| "USB volume request timed out".to_string())?
}
}
impl Drop for Output {
fn drop(&mut self) {
@@ -270,9 +307,26 @@ struct Session {
slots: Vec<Box<Slot>>,
feedback_slot: Option<Box<Slot>>,
format: UsbOutputFormat,
volume: Option<Volume>,
}
impl volume::Control for Session {
fn transfer(
&self,
input: bool,
request: u8,
value: u16,
index: u16,
data: &mut [u8],
) -> Result<(), String> {
self.control(input, request, value, index, data, false)
}
}
impl Session {
fn open(fd: i32, candidates: Vec<(Alternate, UsbOutputFormat)>) -> Result<Self, String> {
fn open(
fd: i32,
candidates: Vec<(Alternate, UsbOutputFormat)>,
raw: &[u8],
) -> Result<Self, String> {
let mut s = Self {
context: ptr::null_mut(),
handle: ptr::null_mut(),
@@ -285,6 +339,7 @@ impl Session {
slots: vec![],
feedback_slot: None,
format: candidates[0].1.clone(),
volume: None,
};
unsafe {
// NO_DEVICE_DISCOVERY is required on Android; the USB permission
@@ -313,6 +368,13 @@ impl Session {
if let Some(ep) = &s.feedback {
s.feedback_slot = Some(Slot::new(1, ep.max_packet)?);
}
s.volume = volume::feature(
raw,
s.alternate.control,
s.alternate.terminal,
s.format.channels,
)
.and_then(|feature| Volume::open(&s, feature).ok());
return Ok(s);
}
Err(failure)
@@ -472,6 +534,28 @@ impl Session {
if s.closed {
break;
}
if let Some(request) = s.volume_requests.pop_front() {
drop(s);
if Instant::now() > request.deadline {
let _ = request.reply.send(Err("USB volume request expired".into()));
} else {
let mut volume = self
.volume
.take()
.ok_or("USB hardware volume unavailable")?;
let result = volume.set(self, request.db);
self.volume = Some(volume);
if let Ok(snapshot) = &result {
shared.0.lock().unwrap().volume = snapshot.clone();
}
let error = result.as_ref().err().cloned();
let _ = request.reply.send(result);
if let Some(error) = error {
return Err(error);
}
}
s = shared.0.lock().unwrap();
}
if s.flush != s.flushed {
drop(s);
self.drain();
@@ -0,0 +1,460 @@
//! USB Audio Feature Unit volume. Values are signed 1/256 dB, not PCM gain.
use super::UsbHardwareVolume;
use std::collections::HashMap;
pub trait Control {
fn transfer(
&self,
input: bool,
request: u8,
value: u16,
index: u16,
data: &mut [u8],
) -> Result<(), String>;
}
#[derive(Clone, Debug, PartialEq)]
pub struct Feature {
pub unit: u8,
pub interface: u8,
pub uac2: bool,
pub channels: Vec<u8>,
}
/// Follow only an unambiguous playback terminal chain. Never change a capture
/// Feature Unit or guess which input of a mixer/selector is currently routed.
pub fn feature(raw: &[u8], interface: u8, terminal: u8, channels: u8) -> Option<Feature> {
let mut active = false;
let mut uac2 = false;
let mut sources = HashMap::new();
let mut features = HashMap::new();
let mut outputs = Vec::new();
let mut offset = 0;
while offset + 2 <= raw.len() {
let len = raw[offset] as usize;
if len < 2 || len > raw.len() - offset {
return None;
}
let d = &raw[offset..offset + len];
offset += len;
if d[1] == 4 && len >= 9 {
active = d[2] == interface && d[5] == 1 && d[6] == 1;
uac2 = d[7] == 0x20;
} else if active && d[1] == 0x24 && len >= 4 {
match d[2] {
3 if len >= 9 => {
sources.insert(d[3], d[7]);
outputs.push(d[3]);
}
6 if len >= 7 => {
sources.insert(d[3], d[4]);
let size = if uac2 { 4 } else { d[5] as usize };
let start = if uac2 { 5 } else { 6 };
if size == 0 || size > 4 || len < start + size + 1 {
continue;
}
let writable = |channel: usize| {
let pos = start + channel * size;
if pos + size >= len {
return false;
}
if uac2 {
(d[pos] >> 2) & 3 == 3
} else {
d[pos] & 2 != 0
}
};
let controlled = if writable(0) {
vec![0]
} else if (1..=channels as usize).all(writable) {
(1..=channels).collect()
} else {
continue;
};
features.insert(
d[3],
Feature {
unit: d[3],
interface,
uac2,
channels: controlled,
},
);
}
_ => {}
}
}
}
let mut matches = Vec::new();
for mut id in outputs {
let mut seen = Vec::new();
let mut candidate = None;
while id != terminal && !seen.contains(&id) {
seen.push(id);
if candidate.is_none() {
candidate = features.get(&id).cloned();
}
id = match sources.get(&id) {
Some(source) => *source,
None => break,
};
}
if id == terminal
&& let Some(f) = candidate
{
matches.push(f);
}
}
matches.dedup();
if matches.len() == 1 {
matches.pop()
} else {
None
}
}
#[derive(Clone, Copy, Debug)]
struct Range {
min: i16,
max: i16,
step: i16,
}
impl Range {
fn parse(bytes: &[u8]) -> Result<Self, String> {
let r = Self {
min: i16::from_le_bytes([bytes[0], bytes[1]]),
max: i16::from_le_bytes([bytes[2], bytes[3]]),
step: i16::from_le_bytes([bytes[4], bytes[5]]),
};
if r.min == i16::MIN || r.min > r.max || r.step < 0 {
return Err("Invalid USB volume range".into());
}
Ok(r)
}
fn floor(self, value: i32) -> i16 {
let value = value.clamp(self.min as i32, self.max as i32);
let step = i32::from(self.step).max(1);
(self.min as i32 + (value - self.min as i32) / step * step) as i16
}
}
pub struct Volume {
feature: Feature,
ranges: Vec<Vec<Range>>,
snapshot: UsbHardwareVolume,
}
impl Volume {
pub fn open(control: &impl Control, feature: Feature) -> Result<Self, String> {
let index = (feature.unit as u16) << 8 | feature.interface as u16;
let mut ranges = Vec::new();
let mut current = i16::MIN;
let mut startup = Vec::new();
let mut restore_limit = i16::MAX;
for channel in &feature.channels {
let selector = 0x200 | *channel as u16;
let mut data = [0; 2];
control.transfer(
true,
if feature.uac2 { 1 } else { 0x81 },
selector,
index,
&mut data,
)?;
current = current.max(i16::from_le_bytes(data));
restore_limit = restore_limit.min(i16::from_le_bytes(data));
startup.push(i32::from(i16::from_le_bytes(data).min(-40 * 256)));
let r = if feature.uac2 {
control.transfer(true, 2, selector, index, &mut data)?;
let count = u16::from_le_bytes(data) as usize;
if !(1..=16).contains(&count) {
return Err("Unsupported USB volume ranges".into());
}
let mut data = vec![0; 2 + 6 * count];
control.transfer(true, 2, selector, index, &mut data)?;
if u16::from_le_bytes([data[0], data[1]]) as usize != count {
return Err("USB volume range changed".into());
}
data[2..]
.as_chunks::<6>()
.0
.iter()
.map(|bytes| Range::parse(bytes))
.collect::<Result<Vec<_>, _>>()?
} else {
let mut data = [0; 6];
for (request, bytes) in (0x82..=0x84).zip(data.as_chunks_mut::<2>().0) {
control.transfer(true, request, selector, index, bytes)?;
}
vec![Range::parse(&data)?]
};
ranges.push(r);
}
let min = ranges
.iter()
.map(|r| r.iter().map(|r| r.min).min().unwrap())
.max()
.unwrap();
let max = ranges
.iter()
.map(|r| r.iter().map(|r| r.max).max().unwrap())
.min()
.unwrap()
.min(0);
if min >= max || min > -40 * 256 {
return Err("USB volume cannot provide startup attenuation".into());
}
let mut volume = Self {
feature,
ranges,
snapshot: UsbHardwareVolume {
available: true,
min_db: min as f64 / 256.0,
max_db: max as f64 / 256.0,
current_db: current as f64 / 256.0,
restore_limit_db: restore_limit as f64 / 256.0,
},
};
// Never raise the device's current volume on connection. Read-back is
// mandatory before any audio transfer can start.
volume.set_targets(control, &startup)?;
Ok(volume)
}
pub fn snapshot(&self) -> UsbHardwareVolume {
self.snapshot.clone()
}
pub fn set(&mut self, control: &impl Control, db: f64) -> Result<UsbHardwareVolume, String> {
if !db.is_finite() || db > 0.0 || db < -128.0 {
return Err("Invalid USB volume".into());
}
let target = (db.min(self.snapshot.max_db) * 256.0).floor() as i32;
self.set_targets(control, &vec![target; self.feature.channels.len()])
}
fn set_targets(
&mut self,
control: &impl Control,
targets: &[i32],
) -> Result<UsbHardwareVolume, String> {
let index = (self.feature.unit as u16) << 8 | self.feature.interface as u16;
let mut loudest = i16::MIN;
for ((channel, ranges), target) in self
.feature
.channels
.iter()
.zip(&self.ranges)
.zip(targets.iter().copied())
{
// -32768 is USB's negative-infinity value. Preserve a channel that
// was already silent; startup must never raise a quieter channel.
let value = if target == i16::MIN as i32 {
i16::MIN
} else {
ranges
.iter()
.filter(|r| r.min as i32 <= target)
.map(|r| r.floor(target))
.max()
.ok_or("USB volume below supported range")?
};
let selector = 0x200 | *channel as u16;
control.transfer(false, 1, selector, index, &mut value.to_le_bytes())?;
let mut readback = [0; 2];
control.transfer(
true,
if self.feature.uac2 { 1 } else { 0x81 },
selector,
index,
&mut readback,
)?;
let actual = i16::from_le_bytes(readback);
if actual != value {
return Err("USB volume read-back mismatch".into());
}
loudest = loudest.max(actual);
}
self.snapshot.current_db = loudest as f64 / 256.0;
Ok(self.snapshot())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::cell::Cell;
struct Fake {
db: Cell<i16>,
mismatch: bool,
}
impl Control for Fake {
fn transfer(
&self,
input: bool,
request: u8,
_: u16,
_: u16,
data: &mut [u8],
) -> Result<(), String> {
if !input {
if !self.mismatch {
self.db.set(i16::from_le_bytes([data[0], data[1]]));
}
} else if request == 2 {
let range = [1, 0, 0, 0xa0, 0, 0, 0, 1]; // -96..0 dB, 1 dB steps.
data.copy_from_slice(&range[..data.len()]);
} else {
data.copy_from_slice(&self.db.get().to_le_bytes());
}
Ok(())
}
}
#[test]
fn startup_attenuates_without_raising_and_rounds_down() {
let io = Fake {
db: Cell::new(0),
mismatch: false,
};
let f = Feature {
unit: 2,
interface: 0,
uac2: true,
channels: vec![0],
};
let mut volume = Volume::open(&io, f.clone()).unwrap();
assert_eq!(volume.snapshot().current_db, -40.0);
assert_eq!(volume.set(&io, -25.2).unwrap().current_db, -26.0);
io.db.set(-70 * 256);
assert_eq!(Volume::open(&io, f).unwrap().snapshot().current_db, -70.0);
}
#[test]
fn failed_readback_cannot_report_safe_volume() {
let io = Fake {
db: Cell::new(0),
mismatch: true,
};
assert!(
Volume::open(
&io,
Feature {
unit: 2,
interface: 0,
uac2: true,
channels: vec![0]
}
)
.is_err()
);
}
#[test]
fn startup_preserves_silent_channels() {
let io = Fake {
db: Cell::new(i16::MIN),
mismatch: false,
};
let v = Volume::open(
&io,
Feature {
unit: 2,
interface: 0,
uac2: true,
channels: vec![0],
},
)
.unwrap();
assert_eq!(v.snapshot().current_db, -128.0);
assert_eq!(io.db.get(), i16::MIN);
}
#[test]
fn topology_selects_playback_not_capture() {
let raw = [
9, 4, 0, 0, 0, 1, 1, 0x20, 0, 18, 0x24, 6, 2, 1, 12, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0,
0, 12, 0x24, 3, 3, 1, 3, 0, 2, 1, 0, 0, 0,
];
assert_eq!(feature(&raw, 0, 1, 2).unwrap().unit, 2);
assert!(feature(&raw, 0, 9, 2).is_none());
assert!(feature(&raw, 1, 1, 2).is_none());
}
#[test]
fn uac1_control_uses_min_max_resolution_and_verified_current() {
struct Uac1(Cell<i16>);
impl Control for Uac1 {
fn transfer(
&self,
input: bool,
request: u8,
value: u16,
index: u16,
data: &mut [u8],
) -> Result<(), String> {
assert_eq!((value, index), (0x200, 0x200));
if !input {
assert_eq!(request, 1);
self.0.set(i16::from_le_bytes([data[0], data[1]]));
} else {
let value: i16 = match request {
0x81 => self.0.get(),
0x82 => -80 * 256,
0x83 => 0,
0x84 => 128,
_ => panic!("Unexpected UAC1 request"),
};
data.copy_from_slice(&value.to_le_bytes());
}
Ok(())
}
}
let raw = [
9, 4, 0, 0, 0, 1, 1, 0, 0, 10, 0x24, 6, 2, 1, 1, 3, 0, 0, 0, 9, 0x24, 3, 3, 1, 3, 0, 2,
0,
];
let control = Uac1(Cell::new(-10 * 256));
let mut volume = Volume::open(&control, feature(&raw, 0, 1, 2).unwrap()).unwrap();
assert_eq!(volume.snapshot().current_db, -40.0);
assert_eq!(volume.set(&control, -23.1).unwrap().current_db, -23.5);
assert!(volume.set(&control, f64::NAN).is_err());
assert!(volume.set(&control, 2.0).is_err());
}
#[test]
fn startup_keeps_independent_channels_at_or_below_their_old_levels() {
struct Stereo([Cell<i16>; 2]);
impl Control for Stereo {
fn transfer(
&self,
input: bool,
request: u8,
value: u16,
_: u16,
data: &mut [u8],
) -> Result<(), String> {
let channel = (value & 255) as usize - 1;
if !input {
self.0[channel].set(i16::from_le_bytes([data[0], data[1]]));
} else if request == 2 {
data.copy_from_slice(&[1, 0, 0, 0xa0, 0, 0, 0, 1][..data.len()]);
} else {
data.copy_from_slice(&self.0[channel].get().to_le_bytes());
}
Ok(())
}
}
let io = Stereo([Cell::new(-70 * 256), Cell::new(-10 * 256)]);
let volume = Volume::open(
&io,
Feature {
unit: 2,
interface: 0,
uac2: true,
channels: vec![1, 2],
},
)
.unwrap();
assert_eq!(io.0[0].get(), -70 * 256);
assert_eq!(io.0[1].get(), -40 * 256);
assert_eq!(volume.snapshot().restore_limit_db, -70.0);
}
}
+112 -1
View File
@@ -180,10 +180,18 @@ void main() {
expect(defaults.usbBitPerfect, isFalse);
expect(defaults.usbDirect, isFalse);
expect(defaults.usbDsdOverPcm, isFalse);
expect(defaults.usbAllowFixedVolume, isFalse);
expect(defaults.dapExclusive, isFalse);
final upgraded = AppSettings.fromJson({
'usbBitPerfect': true,
'usbDirect': true,
});
expect(upgraded.usbAllowFixedVolume, isFalse);
final selected = defaults.copyWith(
usbBitPerfect: true,
usbDirect: true,
usbDsdOverPcm: true,
usbAllowFixedVolume: true,
autoMix: true,
playbackNormalization: true,
);
@@ -191,6 +199,13 @@ void main() {
expect(restored.usbBitPerfect, isTrue);
expect(restored.usbDirect, isTrue);
expect(restored.usbDsdOverPcm, isTrue);
expect(restored.usbAllowFixedVolume, isTrue);
expect(
AppSettings.fromJson(
defaults.copyWith(dapExclusive: true).toJson(),
).dapExclusive,
isTrue,
);
expect(restored.autoMix, isTrue);
expect(restored.playbackNormalization, isTrue);
},
@@ -218,6 +233,8 @@ void main() {
late String? fallbackReason;
late bool failResume;
late bool fatal;
late bool volumeBlocked;
Map<String, dynamic>? hardwareVolume;
late String transport;
late Map<dynamic, dynamic> prepareArguments;
Completer<void>? pauseReply;
@@ -240,6 +257,8 @@ void main() {
fallbackReason = null;
failResume = false;
fatal = false;
volumeBlocked = false;
hardwareVolume = null;
transport = 'pcm';
prepareArguments = {};
pauseReply = null;
@@ -263,6 +282,19 @@ void main() {
'sampleRate': 96000,
'bitDepth': 24,
'transport': transport,
'driver': prepareArguments['direct'] == true
? 'usb_direct'
: 'android',
'volumeBlocked': volumeBlocked,
if (hardwareVolume != null)
'volume': {...hardwareVolume!, 'token': token},
};
case 'volume':
expect((call.arguments as Map)['token'], token);
return {
...hardwareVolume!,
'token': token,
'currentDb': (call.arguments as Map)['db'],
};
case 'position':
return position;
@@ -293,6 +325,69 @@ void main() {
expect(prepareArguments['allowDop'], isTrue);
});
test('hardware volume changes the DAC, never PCM gain', () async {
hardwareVolume = {
'available': true,
'minDb': -90.0,
'maxDb': 0.0,
'currentDb': -40.0,
};
await deck.setSource(
DeviceFileSource('/one.flac'),
preferBitPerfect: true,
directUsb: true,
);
expect(usbHardwareVolume.value?.currentDb, -40);
await setUsbHardwareVolume(0.5);
expect(usbHardwareVolume.value?.currentDb, -45);
expect(usbCalls, ['prepare', 'volume']);
expect(native.calls.where((call) => call.$2 == 'setVolume'), isEmpty);
await deck.stop();
expect(usbHardwareVolume.value, isNull);
});
test(
'uncontrolled DAC is blocked without ordinary full-volume fallback',
() async {
volumeBlocked = true;
hardwareVolume = {'available': false};
await deck.setSource(
DeviceFileSource('/one.flac'),
preferBitPerfect: true,
directUsb: true,
);
await expectLater(deck.resume(), throwsA(isA<UsbOutputUnavailable>()));
expect(usbCalls, ['prepare']);
expect(usbAudioStatus.value.reason, 'volume_unavailable');
expect(native.sources['usb-test'], isNull);
await setUsbHardwareVolume(1);
expect(usbCalls, ['prepare']);
},
);
test(
'AAudio exclusive and fixed volume require explicit options',
() async {
await deck.setSource(
DeviceFileSource('/one.wav'),
preferBitPerfect: true,
dapExclusive: true,
);
expect(prepareArguments['dapExclusive'], isTrue);
expect(prepareArguments['allowFixedVolume'], isFalse);
expect(prepareArguments['direct'], isFalse);
fallbackReason = 'exclusive_unavailable';
await deck.setSource(
DeviceFileSource('/one.wav'),
preferBitPerfect: true,
dapExclusive: true,
);
expect(usbAudioStatus.value.reason, 'exclusive_unavailable');
expect(deck.isDirect, isFalse);
expect(native.sources['usb-test'], '/one.wav');
},
);
test(
'changing tracks cancels a pending USB permission without fallback',
() async {
@@ -337,7 +432,7 @@ void main() {
directUsb: true,
);
failResume = true;
await expectLater(deck.resume(), throwsA(isA<UsbDsdUnavailable>()));
await expectLater(deck.resume(), throwsA(isA<UsbOutputUnavailable>()));
expect(native.sources['usb-test'], isNull);
expect(deck.needsSourceReload, isTrue);
});
@@ -437,6 +532,22 @@ void main() {
expect(usbAudioStatus.value.reason, 'unsupported');
});
test(
'direct USB failure before first sound never redirects to speaker',
() async {
failResume = true;
await deck.setSource(
DeviceFileSource('/one.flac'),
preferBitPerfect: true,
directUsb: true,
);
await expectLater(deck.resume(), throwsA(isA<UsbOutputUnavailable>()));
expect(native.sources['usb-test'], isNull);
expect(native.playing, isEmpty);
expect(usbAudioStatus.value.reason, 'route_changed');
},
);
test(
'route loss pauses and never starts the speaker automatically',
() async {