mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-03 22:56:53 +02:00
feat(player): add AAudio hi-res, WavPack DSD and DAC volume
This commit is contained in:
1 parent
6decce0f8b
commit
d92bcf7716
32 files changed
+2118
-55
No files matched your search
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
|
||||
Reference in new issue
Block a user