mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 06:52:18 +02:00
fix(audio): preserve FLAC precision in Android bit-perfect playback
This commit is contained in:
1 parent
ba737c625e
commit
3dc2d69051
18 files changed
+558
-238
No files matched your search
@@ -9,6 +9,19 @@ 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.
|
||||
|
||||
## Synthetic 24-bit PCM fixture
|
||||
|
||||
`pcm24-pattern.flac` contains 0.1 seconds of stereo 24-bit/96-kHz audio with
|
||||
constant integer samples `1193047` and `-1193047` in its left/right channels.
|
||||
The nonzero low bits detect truncation to 16-bit PCM. It tests native FLAC
|
||||
decoding, lossless padding to 32-bit output, seeking and SAF descriptor lifetime.
|
||||
Generate with FFmpeg:
|
||||
|
||||
```sh
|
||||
ffmpeg -f lavfi -i 'aevalsrc=1193047/8388608|-1193047/8388608:s=96000:d=0.1' \
|
||||
-c:a flac -sample_fmt s32 -bits_per_raw_sample 24 pcm24-pattern.flac
|
||||
```
|
||||
|
||||
## Synthetic motion artwork fixtures
|
||||
|
||||
`motion-probe-h264.mp4` and `motion-probe-hevc.mp4` contain one second of the
|
||||
|
||||
Binary file not shown.
@@ -77,6 +77,37 @@ class UsbBitPerfectPluginTest {
|
||||
} finally { file.delete() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun flacSourcePreserves24BitSamplesAfterSafLeaseClosesAndSeeks() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
val file = File.createTempFile("usb-flac-", ".flac", instrumentation.targetContext.cacheDir)
|
||||
try {
|
||||
instrumentation.context.assets.open("pcm24-pattern.flac").use { input ->
|
||||
file.outputStream().use { input.copyTo(it) }
|
||||
}
|
||||
val source = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
|
||||
UsbPcmSource("/proc/self/fd/${fd.fd}")
|
||||
}
|
||||
source.use {
|
||||
assertEquals(96000, it.rate)
|
||||
assertEquals(24, it.bits)
|
||||
assertEquals(2, it.channels)
|
||||
assertEquals(100000L, it.durationUs)
|
||||
for (target in listOf(0L, 50000L, 0L)) {
|
||||
it.seek(target)
|
||||
val decoded = requireNotNull(it.read(32)).order(ByteOrder.LITTLE_ENDIAN)
|
||||
assertEquals(target, it.bufferTimeUs)
|
||||
assertTrue(decoded.remaining() > 0)
|
||||
while (decoded.hasRemaining()) {
|
||||
assertEquals(1193047 shl 8, decoded.int)
|
||||
assertEquals(-1193047 shl 8, decoded.int)
|
||||
}
|
||||
}
|
||||
assertThrows(Exception::class.java) { it.read(16) }
|
||||
}
|
||||
} finally { file.delete() }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unsupportedRouteReturnsFallbackAndEngineDetachesCleanly() {
|
||||
val instrumentation = InstrumentationRegistry.getInstrumentation()
|
||||
|
||||
@@ -5,6 +5,46 @@ include(FetchContent)
|
||||
set(CMAKE_POSITION_INDEPENDENT_CODE ON)
|
||||
set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_TESTING OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_CXXLIBS OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_PROGRAMS OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_EXAMPLES OFF CACHE BOOL "" FORCE)
|
||||
set(BUILD_DOCS OFF CACHE BOOL "" FORCE)
|
||||
set(INSTALL_MANPAGES OFF CACHE BOOL "" FORCE)
|
||||
set(INSTALL_PKGCONFIG_MODULES OFF CACHE BOOL "" FORCE)
|
||||
set(INSTALL_CMAKE_CONFIG_MODULE OFF CACHE BOOL "" FORCE)
|
||||
set(WITH_OGG OFF CACHE BOOL "" FORCE)
|
||||
set(ENABLE_MULTITHREADING OFF CACHE BOOL "" FORCE)
|
||||
FetchContent_Declare(flac
|
||||
URL https://github.com/xiph/flac/archive/refs/tags/1.5.0.tar.gz
|
||||
URL_HASH SHA256=aea54ed186ad07a34750399cb27fc216a2b62d0ffcd6dc2e3064a3518c3146f8)
|
||||
function(spotiflac_add_flac)
|
||||
if(ANDROID)
|
||||
# The NDK's legacy toolchain reports CMAKE_SYSTEM_VERSION=1. libFLAC
|
||||
# uses it to gate 64-bit file seeks, which Android supports from API 24.
|
||||
set(CMAKE_SYSTEM_VERSION ${ANDROID_NATIVE_API_LEVEL})
|
||||
include(CheckSymbolExists)
|
||||
check_symbol_exists(fseeko "stdio.h" SPOTIFLAC_HAVE_FSEEKO)
|
||||
set(HAVE_FSEEKO ${SPOTIFLAC_HAVE_FSEEKO} CACHE INTERNAL "fseeko is declared" FORCE)
|
||||
endif()
|
||||
FetchContent_MakeAvailable(flac)
|
||||
endfunction()
|
||||
spotiflac_add_flac()
|
||||
add_library(spotiflac_flac STATIC flac_pcm_source.cpp)
|
||||
target_compile_features(spotiflac_flac PRIVATE cxx_std_17)
|
||||
target_compile_options(spotiflac_flac PRIVATE -Wall -Wextra -Werror -fvisibility=hidden)
|
||||
target_link_libraries(spotiflac_flac PUBLIC FLAC::FLAC)
|
||||
|
||||
option(SPOTIFLAC_AUDIO_HOST_TESTS "Test the lossless decoder without Android" OFF)
|
||||
if(SPOTIFLAC_AUDIO_HOST_TESTS)
|
||||
enable_testing()
|
||||
add_executable(flac_pcm_source_test tests/flac_pcm_source_test.cpp)
|
||||
target_compile_features(flac_pcm_source_test PRIVATE cxx_std_17)
|
||||
target_compile_options(flac_pcm_source_test PRIVATE -Wall -Wextra -Werror)
|
||||
target_link_libraries(flac_pcm_source_test PRIVATE spotiflac_flac)
|
||||
add_test(NAME flac_pcm_source COMMAND flac_pcm_source_test)
|
||||
return()
|
||||
endif()
|
||||
|
||||
set(OBOE_DISABLE_CONVERSION ON CACHE BOOL "" FORCE)
|
||||
set(WAVPACK_BUILD_PROGRAMS OFF CACHE BOOL "" FORCE)
|
||||
set(WAVPACK_ENABLE_DSD ON CACHE BOOL "" FORCE)
|
||||
@@ -23,7 +63,7 @@ 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_libraries(spotiflac_audio PRIVATE oboe WavPack::WavPack spotiflac_flac log)
|
||||
# Only JNI is public; static Oboe/WavPack/C++ implementation symbols must not
|
||||
# keep otherwise unused code alive. APS2 is supported by our minimum API 24.
|
||||
target_link_options(spotiflac_audio PRIVATE
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
#include "flac_pcm_source.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
void require(bool condition, const char *message) {
|
||||
if (!condition) throw std::runtime_error(message);
|
||||
}
|
||||
}
|
||||
|
||||
void FlacPcmSource::DeleteDecoder::operator()(FLAC__StreamDecoder *value) const {
|
||||
FLAC__stream_decoder_finish(value);
|
||||
FLAC__stream_decoder_delete(value);
|
||||
}
|
||||
|
||||
FlacPcmSource::FlacPcmSource(const char *path) : decoder(FLAC__stream_decoder_new()) {
|
||||
require(decoder != nullptr, "Cannot allocate FLAC decoder");
|
||||
require(FLAC__stream_decoder_init_file(decoder.get(), path, write, metadata, error, this) ==
|
||||
FLAC__STREAM_DECODER_INIT_STATUS_OK, "Cannot open FLAC file");
|
||||
require(FLAC__stream_decoder_process_until_end_of_metadata(decoder.get()) && !failure,
|
||||
"Cannot read FLAC metadata");
|
||||
require(rate >= 8000 && rate <= 768000 && channels >= 1 && channels <= 2 &&
|
||||
(bits == 16 || bits == 24 || bits == 32) &&
|
||||
samples <= static_cast<uint64_t>(std::numeric_limits<int64_t>::max()) / 1000000,
|
||||
"Unsupported FLAC PCM format");
|
||||
}
|
||||
|
||||
void FlacPcmSource::metadata(const FLAC__StreamDecoder *, const FLAC__StreamMetadata *value, void *data) {
|
||||
if (value->type != FLAC__METADATA_TYPE_STREAMINFO) return;
|
||||
auto &source = *static_cast<FlacPcmSource *>(data);
|
||||
const auto &info = value->data.stream_info;
|
||||
source.rate = static_cast<int>(info.sample_rate);
|
||||
source.channels = static_cast<int>(info.channels);
|
||||
source.bits = static_cast<int>(info.bits_per_sample);
|
||||
source.samples = info.total_samples;
|
||||
}
|
||||
|
||||
FLAC__StreamDecoderWriteStatus FlacPcmSource::write(
|
||||
const FLAC__StreamDecoder *, const FLAC__Frame *frame, const FLAC__int32 *const input[], void *data) {
|
||||
auto &source = *static_cast<FlacPcmSource *>(data);
|
||||
const auto &header = frame->header;
|
||||
if (header.sample_rate != static_cast<unsigned>(source.rate) ||
|
||||
header.channels != static_cast<unsigned>(source.channels) ||
|
||||
header.bits_per_sample != static_cast<unsigned>(source.bits) ||
|
||||
header.blocksize == 0 || header.blocksize > 65535) {
|
||||
source.failure = "FLAC format changed during decoding";
|
||||
return FLAC__STREAM_DECODER_WRITE_STATUS_ABORT;
|
||||
}
|
||||
try {
|
||||
source.block.resize(static_cast<size_t>(header.blocksize) * source.channels);
|
||||
source.offset = 0;
|
||||
for (size_t i = 0; i < header.blocksize; ++i) {
|
||||
for (int channel = 0; channel < source.channels; ++channel) {
|
||||
source.block[i * source.channels + channel] = input[channel][i];
|
||||
}
|
||||
}
|
||||
return FLAC__STREAM_DECODER_WRITE_STATUS_CONTINUE;
|
||||
} catch (...) {
|
||||
source.failure = "Cannot allocate FLAC block";
|
||||
return FLAC__STREAM_DECODER_WRITE_STATUS_ABORT;
|
||||
}
|
||||
}
|
||||
|
||||
void FlacPcmSource::error(const FLAC__StreamDecoder *, FLAC__StreamDecoderErrorStatus, void *data) {
|
||||
static_cast<FlacPcmSource *>(data)->failure = "Corrupt FLAC audio data";
|
||||
}
|
||||
|
||||
size_t FlacPcmSource::read(uint8_t *output, size_t capacity, int outputBits) {
|
||||
require(outputBits >= bits && (outputBits == 16 || outputBits == 24 || outputBits == 32),
|
||||
"FLAC output would reduce precision");
|
||||
const size_t bytes = outputBits / 8;
|
||||
const size_t frameBytes = channels * bytes;
|
||||
require(output && capacity >= frameBytes, "Invalid FLAC output buffer");
|
||||
if (end) return 0;
|
||||
while (offset == block.size()) {
|
||||
require(!failure, failure ? failure : "FLAC decode failed");
|
||||
if (FLAC__stream_decoder_get_state(decoder.get()) == FLAC__STREAM_DECODER_END_OF_STREAM) {
|
||||
require(samples == 0 || position == samples, "Truncated FLAC audio data");
|
||||
end = true;
|
||||
return 0;
|
||||
}
|
||||
require(FLAC__stream_decoder_process_single(decoder.get()) && !failure,
|
||||
failure ? failure : "FLAC decode failed");
|
||||
}
|
||||
const size_t frames = std::min(capacity / frameBytes, (block.size() - offset) / channels);
|
||||
const size_t count = frames * channels;
|
||||
for (size_t i = 0; i < count; ++i) {
|
||||
// Unsigned shifting preserves negative samples without signed-shift UB.
|
||||
const uint32_t value = static_cast<uint32_t>(block[offset + i]) << (outputBits - bits);
|
||||
for (size_t byte = 0; byte < bytes; ++byte) {
|
||||
output[i * bytes + byte] = static_cast<uint8_t>(value >> (byte * 8));
|
||||
}
|
||||
}
|
||||
offset += count;
|
||||
position += frames;
|
||||
return count * bytes;
|
||||
}
|
||||
|
||||
void FlacPcmSource::seek(uint64_t sample) {
|
||||
if (samples > 0) sample = std::min(sample, samples);
|
||||
block.clear();
|
||||
offset = 0;
|
||||
failure = nullptr;
|
||||
position = sample;
|
||||
end = samples > 0 && sample == samples;
|
||||
if (!end) {
|
||||
// libFLAC returns the partial target block through write(), preserving
|
||||
// the first requested sample even for seeks between frame boundaries.
|
||||
require(FLAC__stream_decoder_seek_absolute(decoder.get(), sample) && !failure,
|
||||
failure ? failure : "FLAC seek failed");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
#pragma once
|
||||
|
||||
#include <FLAC/stream_decoder.h>
|
||||
|
||||
#include <cstddef>
|
||||
#include <cstdint>
|
||||
#include <memory>
|
||||
#include <vector>
|
||||
|
||||
// Owns its file descriptor, decodes one FLAC block at a time, and never passes
|
||||
// samples through Android's extractor (which may silently emit 16-bit PCM).
|
||||
class FlacPcmSource final {
|
||||
struct DeleteDecoder {
|
||||
void operator()(FLAC__StreamDecoder *decoder) const;
|
||||
};
|
||||
std::unique_ptr<FLAC__StreamDecoder, DeleteDecoder> decoder;
|
||||
std::vector<int32_t> block;
|
||||
size_t offset = 0;
|
||||
uint64_t position = 0;
|
||||
bool end = false;
|
||||
const char *failure = nullptr;
|
||||
|
||||
static FLAC__StreamDecoderWriteStatus write(
|
||||
const FLAC__StreamDecoder *, const FLAC__Frame *, const FLAC__int32 *const[], void *);
|
||||
static void metadata(const FLAC__StreamDecoder *, const FLAC__StreamMetadata *, void *);
|
||||
static void error(const FLAC__StreamDecoder *, FLAC__StreamDecoderErrorStatus, void *);
|
||||
public:
|
||||
int rate = 0;
|
||||
int channels = 0;
|
||||
int bits = 0;
|
||||
uint64_t samples = 0;
|
||||
|
||||
explicit FlacPcmSource(const char *path);
|
||||
size_t read(uint8_t *output, size_t capacity, int outputBits);
|
||||
void seek(uint64_t sample);
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
#include <jni.h>
|
||||
#include <oboe/Oboe.h>
|
||||
#include <wavpack.h>
|
||||
#include "flac_pcm_source.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <atomic>
|
||||
@@ -209,6 +210,7 @@ public:
|
||||
};
|
||||
Handles<ExclusiveOutput> outputs;
|
||||
Handles<DsdDecoder> decoders;
|
||||
Handles<FlacPcmSource> flacSources;
|
||||
}
|
||||
|
||||
#define JNI_AUDIO(name) Java_com_zarz_spotiflac_NativeAudio_##name
|
||||
@@ -292,4 +294,41 @@ JNIEXPORT void JNICALL JNI_AUDIO(seekWavPack)(JNIEnv *env, jobject, jlong handle
|
||||
} catch (const std::exception &e) { report(env, e); }
|
||||
}
|
||||
JNIEXPORT void JNICALL JNI_AUDIO(closeWavPack)(JNIEnv *, jobject, jlong handle) { decoders.remove(handle); }
|
||||
JNIEXPORT jlong JNICALL JNI_AUDIO(openFlac)(JNIEnv *env, jobject, jstring path) {
|
||||
const char *text = env->GetStringUTFChars(path, nullptr);
|
||||
if (!text) return 0;
|
||||
try {
|
||||
auto source = std::make_shared<FlacPcmSource>(text);
|
||||
env->ReleaseStringUTFChars(path, text);
|
||||
text = nullptr;
|
||||
return flacSources.add(std::move(source));
|
||||
} catch (const std::exception &e) {
|
||||
if (text) env->ReleaseStringUTFChars(path, text);
|
||||
report(env, e); return 0;
|
||||
}
|
||||
}
|
||||
JNIEXPORT jlongArray JNICALL JNI_AUDIO(infoFlac)(JNIEnv *env, jobject, jlong handle) {
|
||||
try {
|
||||
auto source = flacSources.get(handle);
|
||||
jlong data[]{source->rate, source->channels, source->bits, static_cast<jlong>(source->samples)};
|
||||
auto result = env->NewLongArray(4);
|
||||
if (result) env->SetLongArrayRegion(result, 0, 4, data);
|
||||
return result;
|
||||
} catch (const std::exception &e) { report(env, e); return nullptr; }
|
||||
}
|
||||
JNIEXPORT jint JNICALL JNI_AUDIO(readFlac)(JNIEnv *env, jobject, jlong handle, jobject buffer, jint bits) {
|
||||
try {
|
||||
auto *data = static_cast<uint8_t *>(env->GetDirectBufferAddress(buffer));
|
||||
const auto capacity = env->GetDirectBufferCapacity(buffer);
|
||||
require(data && capacity > 0 && capacity <= 262144, "Invalid FLAC PCM buffer");
|
||||
return static_cast<jint>(flacSources.get(handle)->read(data, static_cast<size_t>(capacity), bits));
|
||||
} catch (const std::exception &e) { report(env, e); return 0; }
|
||||
}
|
||||
JNIEXPORT void JNICALL JNI_AUDIO(seekFlac)(JNIEnv *env, jobject, jlong handle, jlong sample) {
|
||||
try {
|
||||
require(sample >= 0, "Invalid FLAC seek position");
|
||||
flacSources.get(handle)->seek(static_cast<uint64_t>(sample));
|
||||
} catch (const std::exception &e) { report(env, e); }
|
||||
}
|
||||
JNIEXPORT void JNICALL JNI_AUDIO(closeFlac)(JNIEnv *, jobject, jlong handle) { flacSources.remove(handle); }
|
||||
}
|
||||
@@ -13,5 +13,10 @@
|
||||
Java_com_zarz_spotiflac_NativeAudio_readWavPack;
|
||||
Java_com_zarz_spotiflac_NativeAudio_seekWavPack;
|
||||
Java_com_zarz_spotiflac_NativeAudio_closeWavPack;
|
||||
Java_com_zarz_spotiflac_NativeAudio_openFlac;
|
||||
Java_com_zarz_spotiflac_NativeAudio_infoFlac;
|
||||
Java_com_zarz_spotiflac_NativeAudio_readFlac;
|
||||
Java_com_zarz_spotiflac_NativeAudio_seekFlac;
|
||||
Java_com_zarz_spotiflac_NativeAudio_closeFlac;
|
||||
local: *;
|
||||
};
|
||||
@@ -0,0 +1,132 @@
|
||||
#include "../flac_pcm_source.h"
|
||||
#include <FLAC/stream_encoder.h>
|
||||
|
||||
#include <array>
|
||||
#include <cstdio>
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <iostream>
|
||||
#include <stdexcept>
|
||||
#include <unistd.h>
|
||||
|
||||
namespace {
|
||||
void check(bool value, const char *message) {
|
||||
if (!value) throw std::runtime_error(message);
|
||||
}
|
||||
struct Fixture {
|
||||
std::string path;
|
||||
std::vector<int32_t> input;
|
||||
const unsigned frames = 15000;
|
||||
Fixture(unsigned bits, unsigned rate, unsigned channels) {
|
||||
path = (std::filesystem::temp_directory_path() / "spotiflac-flac-XXXXXX").string();
|
||||
const int fd = mkstemp(path.data());
|
||||
check(fd >= 0, "create fixture");
|
||||
close(fd);
|
||||
// Include signs, extrema, and nonzero low bits that 16-bit decoding
|
||||
// would discard. Different channels exercise FLAC decorrelation too.
|
||||
const int32_t maximum = static_cast<int32_t>((uint32_t{1} << (bits - 1)) - 1);
|
||||
const int32_t minimum = -maximum - 1;
|
||||
const std::array<int32_t, 8> values{0, 1, -1, maximum, minimum, 0x1235, -0x1235, 127};
|
||||
for (unsigned i = 0; i < frames * channels; ++i) input.push_back(values[i % values.size()]);
|
||||
auto *encoder = FLAC__stream_encoder_new();
|
||||
check(encoder != nullptr, "allocate encoder");
|
||||
check(FLAC__stream_encoder_set_channels(encoder, channels) &&
|
||||
FLAC__stream_encoder_set_bits_per_sample(encoder, bits) &&
|
||||
FLAC__stream_encoder_set_sample_rate(encoder, rate) &&
|
||||
FLAC__stream_encoder_set_blocksize(encoder, 4096), "configure fixture");
|
||||
check(FLAC__stream_encoder_init_file(encoder, path.c_str(), nullptr, nullptr) ==
|
||||
FLAC__STREAM_ENCODER_INIT_STATUS_OK, "open fixture");
|
||||
check(FLAC__stream_encoder_process_interleaved(encoder, input.data(), frames), "encode fixture");
|
||||
check(FLAC__stream_encoder_finish(encoder), "finish fixture");
|
||||
FLAC__stream_encoder_delete(encoder);
|
||||
}
|
||||
~Fixture() { std::remove(path.c_str()); }
|
||||
};
|
||||
|
||||
void compare(FlacPcmSource &source, const Fixture &fixture, uint64_t first, int outputBits) {
|
||||
// Deliberately split decoded blocks across many small, unaligned buffers.
|
||||
std::array<uint8_t, 269> data{};
|
||||
size_t sample = static_cast<size_t>(first) * source.channels;
|
||||
size_t count;
|
||||
while ((count = source.read(data.data(), data.size(), outputBits)) != 0) {
|
||||
const size_t bytes = outputBits / 8;
|
||||
check(count % (bytes * source.channels) == 0, "unaligned output");
|
||||
for (size_t offset = 0; offset < count; offset += bytes, ++sample) {
|
||||
check(sample < fixture.input.size(), "extra decoded samples");
|
||||
const uint32_t expected = static_cast<uint32_t>(fixture.input[sample]) << (outputBits - source.bits);
|
||||
for (size_t byte = 0; byte < bytes; ++byte) {
|
||||
check(data[offset + byte] == static_cast<uint8_t>(expected >> (byte * 8)),
|
||||
"sample precision changed");
|
||||
}
|
||||
}
|
||||
}
|
||||
check(sample == fixture.input.size(), "missing decoded samples");
|
||||
}
|
||||
|
||||
void preservesPrecisionAndSeeks(unsigned bits, unsigned rate, unsigned channels) {
|
||||
Fixture fixture(bits, rate, channels);
|
||||
FlacPcmSource source(fixture.path.c_str());
|
||||
check(source.bits == static_cast<int>(bits) && source.rate == static_cast<int>(rate) &&
|
||||
source.channels == static_cast<int>(channels) && source.samples == fixture.frames,
|
||||
"source format changed");
|
||||
for (int output : {16, 24, 32}) {
|
||||
if (output < static_cast<int>(bits)) continue;
|
||||
for (uint64_t first : {0u, 5001u, 14999u, 15000u, 17u, 4096u, 4097u}) {
|
||||
source.seek(first);
|
||||
compare(source, fixture, first, output);
|
||||
}
|
||||
}
|
||||
if (bits > 16) {
|
||||
bool rejected = false;
|
||||
std::array<uint8_t, 256> data{};
|
||||
try { source.read(data.data(), data.size(), 16); }
|
||||
catch (const std::exception &) { rejected = true; }
|
||||
check(rejected, "silently reduced output precision");
|
||||
}
|
||||
}
|
||||
|
||||
void rejectsTruncatedData() {
|
||||
Fixture fixture(24, 96000, 2);
|
||||
std::filesystem::resize_file(fixture.path, std::filesystem::file_size(fixture.path) / 2);
|
||||
bool rejected = false;
|
||||
try {
|
||||
FlacPcmSource source(fixture.path.c_str());
|
||||
std::array<uint8_t, 256> data{};
|
||||
while (source.read(data.data(), data.size(), 24) != 0) {}
|
||||
} catch (const std::exception &) { rejected = true; }
|
||||
check(rejected, "accepted truncated audio");
|
||||
}
|
||||
|
||||
void retainsDescriptor() {
|
||||
Fixture fixture(24, 96000, 2);
|
||||
auto *file = std::fopen(fixture.path.c_str(), "rb");
|
||||
check(file != nullptr, "open descriptor fixture");
|
||||
#ifdef __ANDROID__
|
||||
const auto path = "/proc/self/fd/" + std::to_string(fileno(file));
|
||||
#else
|
||||
const auto path = "/dev/fd/" + std::to_string(fileno(file));
|
||||
#endif
|
||||
FlacPcmSource source(path.c_str());
|
||||
std::fclose(file);
|
||||
compare(source, fixture, 0, 32);
|
||||
source.seek(5001);
|
||||
compare(source, fixture, 5001, 24);
|
||||
}
|
||||
}
|
||||
|
||||
int main() {
|
||||
try {
|
||||
for (unsigned channels : {1u, 2u}) {
|
||||
preservesPrecisionAndSeeks(16, 44100, channels);
|
||||
preservesPrecisionAndSeeks(24, 96000, channels);
|
||||
preservesPrecisionAndSeeks(32, 192000, channels);
|
||||
}
|
||||
rejectsTruncatedData();
|
||||
retainsDescriptor();
|
||||
std::cout << "FLAC integer precision, padding, seek, EOF and truncation checks passed\n";
|
||||
return EXIT_SUCCESS;
|
||||
} catch (const std::exception &error) {
|
||||
std::cerr << error.what() << '\n';
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
@@ -93,7 +93,8 @@ internal class HiResPlayback(context: Context, private val emit: (Map<String, An
|
||||
}
|
||||
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,
|
||||
"sampleRate" to pcm.rate, "bitDepth" to bits, "sourceBitDepth" to pcm.bits,
|
||||
"duration" to pcm.durationUs / 1000,
|
||||
"device" to (devices.firstOrNull { it.id == device }?.productName?.toString() ?: "AAudio"))
|
||||
} catch (error: Exception) {
|
||||
close()
|
||||
|
||||
@@ -18,4 +18,9 @@ internal object NativeAudio {
|
||||
external fun readWavPack(handle: Long, frames: Int): ByteArray
|
||||
external fun seekWavPack(handle: Long, sample: Long)
|
||||
external fun closeWavPack(handle: Long)
|
||||
external fun openFlac(path: String): Long
|
||||
external fun infoFlac(handle: Long): LongArray
|
||||
external fun readFlac(handle: Long, data: ByteBuffer, bits: Int): Int
|
||||
external fun seekFlac(handle: Long, sample: Long)
|
||||
external fun closeFlac(handle: Long)
|
||||
}
|
||||
@@ -8,9 +8,6 @@ import android.media.AudioManager
|
||||
import android.media.AudioMixerAttributes
|
||||
import android.media.AudioRouting
|
||||
import android.media.AudioTrack
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
@@ -21,7 +18,6 @@ import io.flutter.embedding.engine.plugins.FlutterPlugin
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import io.flutter.plugin.common.MethodCall
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
import java.io.FileInputStream
|
||||
import java.nio.ByteBuffer
|
||||
|
||||
/** Engine-owned (not Activity-owned), so playback survives backgrounding. */
|
||||
@@ -120,8 +116,7 @@ private class UsbPcmPlayback(context: Context, private val emit: (Map<String, An
|
||||
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SpotiFLAC:UsbPcm")
|
||||
private val attributes = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build()
|
||||
private var extractor: MediaExtractor? = null
|
||||
private var decoder: MediaCodec? = null
|
||||
private var source: UsbPcmSource? = null
|
||||
private var track: AudioTrack? = null
|
||||
private var device: AudioDeviceInfo? = null
|
||||
private var mixer: AudioMixerAttributes? = null
|
||||
@@ -129,8 +124,6 @@ private class UsbPcmPlayback(context: Context, private val emit: (Map<String, An
|
||||
private var rate = 0
|
||||
private var channels = 0
|
||||
private var sourceBits = 0
|
||||
private var inputBits = 0
|
||||
private var floatInput = false
|
||||
private var outputBits = 0
|
||||
private var durationUs = 0L
|
||||
private var positionBaseUs = 0L
|
||||
@@ -139,15 +132,11 @@ private class UsbPcmPlayback(context: Context, private val emit: (Map<String, An
|
||||
private var framesWritten = 0L
|
||||
private var previousHead = 0L
|
||||
private var headWrap = 0L
|
||||
private var inputEnded = false
|
||||
private var outputEnded = false
|
||||
private var playing = false
|
||||
private var invalidRoute = false
|
||||
private var nextRouteCheck = 0L
|
||||
private var pending: ByteBuffer? = null
|
||||
private var converted: ByteBuffer? = null
|
||||
private val info = MediaCodec.BufferInfo()
|
||||
private val rawBuffer = ByteBuffer.allocateDirect(256 * 1024)
|
||||
@Volatile private var revision = 0
|
||||
private val routing = AudioRouting.OnRoutingChangedListener {
|
||||
if (playing && track?.routedDevice?.id != device?.id) suspendRoute()
|
||||
@@ -200,38 +189,12 @@ private class UsbPcmPlayback(context: Context, private val emit: (Map<String, An
|
||||
it.mixerBehavior == AudioMixerAttributes.MIXER_BEHAVIOR_BIT_PERFECT
|
||||
}
|
||||
if (supported.isEmpty()) throw Unavailable("unsupported")
|
||||
// The owned descriptor is duplicated by MediaExtractor; a SAF lease may
|
||||
// close as soon as prepare returns without keeping a full-file copy.
|
||||
val media = MediaExtractor().also { extractor = it }
|
||||
FileInputStream(path).use { media.setDataSource(it.fd) }
|
||||
val index = (0 until media.trackCount).firstOrNull {
|
||||
media.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
|
||||
} ?: throw Unavailable("format")
|
||||
media.selectTrack(index)
|
||||
val format = media.getTrackFormat(index)
|
||||
val mime = format.getString(MediaFormat.KEY_MIME)
|
||||
rate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
if (channels !in 1..2) throw Unavailable("format")
|
||||
val pcm = UsbPcmSource(path).also { source = it }
|
||||
rate = pcm.rate
|
||||
channels = pcm.channels
|
||||
sourceBits = pcm.bits
|
||||
durationUs = pcm.durationUs
|
||||
val mask = if (channels == 1) AudioFormat.CHANNEL_OUT_MONO else AudioFormat.CHANNEL_OUT_STEREO
|
||||
durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0
|
||||
if (mime == "audio/flac") {
|
||||
val header = ByteArray(42)
|
||||
FileInputStream(path).use { input ->
|
||||
var offset = 0
|
||||
while (offset < header.size) {
|
||||
val read = input.read(header, offset, header.size - offset)
|
||||
if (read <= 0) break
|
||||
offset += read
|
||||
}
|
||||
}
|
||||
val source = BitPerfectPcm.flacFormat(header) ?: throw Unavailable("format")
|
||||
if (source.first != rate || source.second != channels) throw Unavailable("format")
|
||||
sourceBits = source.third
|
||||
} else if (mime == "audio/raw" && format.containsKey(MediaFormat.KEY_PCM_ENCODING)) {
|
||||
sourceBits = bits(format.getInteger(MediaFormat.KEY_PCM_ENCODING))
|
||||
} else throw Unavailable("format")
|
||||
if (sourceBits !in listOf(16, 24, 32)) throw Unavailable("format")
|
||||
val matching = supported.filter { it.format.sampleRate == rate && it.format.channelMask == mask }
|
||||
outputBits = BitPerfectPcm.outputBits(sourceBits, matching.map { bits(it.format.encoding) })
|
||||
?: throw Unavailable("format")
|
||||
@@ -251,28 +214,12 @@ private class UsbPcmPlayback(context: Context, private val emit: (Map<String, An
|
||||
audio.addOnRoutingChangedListener(routing, worker)
|
||||
// Never change STREAM_MUSIC volume: the DAC owns volume in this mode.
|
||||
audio.setVolume(1f)
|
||||
if (mime == "audio/raw") {
|
||||
inputBits = sourceBits
|
||||
floatInput = false
|
||||
} else {
|
||||
// 24-bit integers are represented exactly by IEEE float. Converting
|
||||
// them back is checked sample-by-sample, with no rounding allowed.
|
||||
format.setInteger(MediaFormat.KEY_PCM_ENCODING, when (sourceBits) {
|
||||
16 -> AudioFormat.ENCODING_PCM_16BIT
|
||||
24 -> AudioFormat.ENCODING_PCM_FLOAT
|
||||
else -> AudioFormat.ENCODING_PCM_32BIT
|
||||
})
|
||||
val codec = MediaCodec.createDecoderByType(requireNotNull(mime)).also { decoder = it }
|
||||
codec.configure(format, null, null, 0)
|
||||
codec.start()
|
||||
inputBits = 0
|
||||
val deadline = android.os.SystemClock.elapsedRealtime() + 5000
|
||||
while (pending == null && !outputEnded) {
|
||||
if (revision != expectedRevision) throw Unavailable("cancelled")
|
||||
if (android.os.SystemClock.elapsedRealtime() > deadline) throw Unavailable("format")
|
||||
decode()
|
||||
if (pending == null) Thread.sleep(2)
|
||||
}
|
||||
val deadline = android.os.SystemClock.elapsedRealtime() + 5000
|
||||
while (pending == null && !outputEnded) {
|
||||
if (revision != expectedRevision) throw Unavailable("cancelled")
|
||||
if (android.os.SystemClock.elapsedRealtime() > deadline) throw Unavailable("format")
|
||||
decode()
|
||||
if (pending == null) Thread.sleep(2)
|
||||
}
|
||||
return mapOf("ready" to true, "device" to usb.productName.toString(), "sampleRate" to rate,
|
||||
"bitDepth" to outputBits, "sourceBitDepth" to sourceBits, "duration" to durationUs / 1000)
|
||||
@@ -285,75 +232,17 @@ private class UsbPcmPlayback(context: Context, private val emit: (Map<String, An
|
||||
else -> 0
|
||||
}
|
||||
|
||||
private fun acceptFormat(format: MediaFormat) {
|
||||
if (format.getInteger(MediaFormat.KEY_SAMPLE_RATE) != rate ||
|
||||
format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) != channels) throw Unavailable("format")
|
||||
val encoding = if (format.containsKey(MediaFormat.KEY_PCM_ENCODING))
|
||||
format.getInteger(MediaFormat.KEY_PCM_ENCODING) else AudioFormat.ENCODING_PCM_16BIT
|
||||
floatInput = encoding == AudioFormat.ENCODING_PCM_FLOAT
|
||||
inputBits = if (floatInput) 32 else bits(encoding)
|
||||
if (inputBits < sourceBits || (floatInput && sourceBits > 24)) throw Unavailable("format")
|
||||
}
|
||||
|
||||
private fun pcm(input: ByteBuffer, timeUs: Long) {
|
||||
val frameBytes = channels * (inputBits / 8)
|
||||
require(frameBytes > 0 && input.remaining() % frameBytes == 0)
|
||||
val skipFrames = if (timeUs < seekTargetUs)
|
||||
((seekTargetUs - timeUs) * rate + 999999) / 1000000 else 0
|
||||
val skipped = minOf(skipFrames, (input.remaining() / frameBytes).toLong())
|
||||
input.position(input.position() + skipped.toInt() * frameBytes)
|
||||
if (!input.hasRemaining()) return
|
||||
if (needTimestamp) {
|
||||
positionBaseUs = timeUs + skipped * 1000000 / rate
|
||||
needTimestamp = false
|
||||
}
|
||||
pending = BitPerfectPcm.convert(input, inputBits, sourceBits, outputBits, floatInput, converted)
|
||||
.also { converted = it }
|
||||
}
|
||||
|
||||
private fun decode() {
|
||||
val media = extractor ?: return
|
||||
val pcm = source ?: return
|
||||
if (pending != null || outputEnded) return
|
||||
val codec = decoder
|
||||
if (codec == null) {
|
||||
rawBuffer.clear()
|
||||
val size = media.readSampleData(rawBuffer, 0)
|
||||
if (size < 0) { outputEnded = true; return }
|
||||
rawBuffer.position(0)
|
||||
rawBuffer.limit(size)
|
||||
pcm(rawBuffer, media.sampleTime)
|
||||
media.advance()
|
||||
return
|
||||
}
|
||||
// Keep the codec fed independently of FLAC block size. Processing only
|
||||
// one tiny frame per pump would starve a high-rate DAC.
|
||||
for (attempt in 0 until 8) {
|
||||
if (inputEnded) break
|
||||
val slot = codec.dequeueInputBuffer(0)
|
||||
if (slot < 0) break
|
||||
val buffer = requireNotNull(codec.getInputBuffer(slot))
|
||||
val size = media.readSampleData(buffer, 0)
|
||||
if (size < 0) {
|
||||
codec.queueInputBuffer(slot, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
inputEnded = true
|
||||
} else {
|
||||
codec.queueInputBuffer(slot, 0, size, media.sampleTime, 0)
|
||||
media.advance()
|
||||
val buffer = pcm.read(outputBits)
|
||||
outputEnded = pcm.ended
|
||||
if (buffer != null && buffer.hasRemaining()) {
|
||||
if (needTimestamp) {
|
||||
positionBaseUs = pcm.bufferTimeUs
|
||||
needTimestamp = false
|
||||
}
|
||||
}
|
||||
val slot = codec.dequeueOutputBuffer(info, 0)
|
||||
if (slot == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) acceptFormat(codec.outputFormat)
|
||||
if (slot >= 0) {
|
||||
try {
|
||||
if (info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
|
||||
if (inputBits == 0) acceptFormat(codec.outputFormat)
|
||||
val buffer = requireNotNull(codec.getOutputBuffer(slot))
|
||||
buffer.position(info.offset)
|
||||
buffer.limit(info.offset + info.size)
|
||||
pcm(buffer, info.presentationTimeUs)
|
||||
}
|
||||
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
} finally { codec.releaseOutputBuffer(slot, false) }
|
||||
pending = buffer
|
||||
}
|
||||
}
|
||||
|
||||
@@ -436,17 +325,15 @@ private class UsbPcmPlayback(context: Context, private val emit: (Map<String, An
|
||||
val wasPlaying = playing
|
||||
pause()
|
||||
track?.flush()
|
||||
decoder?.flush()
|
||||
seekTargetUs = if (durationUs > 0) positionUs.coerceIn(0, durationUs - 1)
|
||||
else positionUs.coerceAtLeast(0)
|
||||
extractor?.seekTo(seekTargetUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
|
||||
source?.seek(seekTargetUs)
|
||||
positionBaseUs = seekTargetUs
|
||||
framesWritten = 0
|
||||
previousHead = 0
|
||||
headWrap = 0
|
||||
needTimestamp = true
|
||||
pending = null
|
||||
inputEnded = false
|
||||
outputEnded = false
|
||||
if (wasPlaying) resume()
|
||||
}
|
||||
@@ -458,10 +345,8 @@ private class UsbPcmPlayback(context: Context, private val emit: (Map<String, An
|
||||
runCatching { audio.release() }
|
||||
}
|
||||
track = null
|
||||
runCatching { decoder?.release() }
|
||||
decoder = null
|
||||
runCatching { extractor?.release() }
|
||||
extractor = null
|
||||
runCatching { source?.close() }
|
||||
source = null
|
||||
device?.let { runCatching { manager.clearPreferredMixerAttributes(attributes, it) } }
|
||||
device = null
|
||||
mixer = null
|
||||
@@ -472,7 +357,6 @@ private class UsbPcmPlayback(context: Context, private val emit: (Map<String, An
|
||||
positionBaseUs = 0
|
||||
seekTargetUs = 0
|
||||
durationUs = 0
|
||||
inputEnded = false
|
||||
outputEnded = false
|
||||
needTimestamp = true
|
||||
invalidRoute = false
|
||||
@@ -482,7 +366,6 @@ private class UsbPcmPlayback(context: Context, private val emit: (Map<String, An
|
||||
revision++
|
||||
worker.post {
|
||||
closeSource()
|
||||
converted = null
|
||||
manager.removeOnPreferredMixerAttributesChangedListener(mixerListener)
|
||||
thread.quitSafely()
|
||||
}
|
||||
|
||||
@@ -171,7 +171,8 @@ 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,
|
||||
"sampleRate" to rate, "bitDepth" to f.bits.toInt(), "sourceBitDepth" to bits,
|
||||
"duration" to durationUs / 1000,
|
||||
"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)
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
package com.zarz.spotiflac
|
||||
|
||||
import android.media.AudioFormat
|
||||
import android.media.MediaCodec
|
||||
import android.media.MediaExtractor
|
||||
import android.media.MediaFormat
|
||||
import java.io.Closeable
|
||||
@@ -10,17 +9,17 @@ import java.nio.ByteBuffer
|
||||
|
||||
/** A source for the direct driver; decoding never passes through AudioTrack. */
|
||||
internal class UsbPcmSource(path: String) : Closeable {
|
||||
private val extractor = MediaExtractor()
|
||||
private var codec: MediaCodec? = null
|
||||
private val info = MediaCodec.BufferInfo()
|
||||
private var extractor: MediaExtractor? = null
|
||||
private var flac = 0L
|
||||
private val raw = ByteBuffer.allocateDirect(256 * 1024)
|
||||
private var converted: ByteBuffer? = null
|
||||
private var inputEnded = false
|
||||
var ended = false
|
||||
private set
|
||||
private var inputBits = 0
|
||||
private var floating = false
|
||||
private var targetUs = 0L
|
||||
private var flacFrame = 0L
|
||||
var bufferTimeUs = 0L
|
||||
private set
|
||||
val rate: Int
|
||||
val channels: Int
|
||||
val bits: Int
|
||||
@@ -28,45 +27,40 @@ internal class UsbPcmSource(path: String) : Closeable {
|
||||
|
||||
init {
|
||||
try {
|
||||
FileInputStream(path).use { extractor.setDataSource(it.fd) }
|
||||
val index = (0 until extractor.trackCount).firstOrNull {
|
||||
extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
|
||||
} ?: error("No audio stream")
|
||||
extractor.selectTrack(index)
|
||||
val format = extractor.getTrackFormat(index)
|
||||
rate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0
|
||||
when (val mime = format.getString(MediaFormat.KEY_MIME)) {
|
||||
"audio/flac" -> {
|
||||
val header = ByteArray(42)
|
||||
FileInputStream(path).use { input ->
|
||||
var n = 0
|
||||
while (n < header.size) {
|
||||
val count = input.read(header, n, header.size - n)
|
||||
if (count <= 0) break
|
||||
n += count
|
||||
}
|
||||
}
|
||||
val source = requireNotNull(BitPerfectPcm.flacFormat(header))
|
||||
require(source.first == rate && source.second == channels)
|
||||
bits = source.third
|
||||
format.setInteger(MediaFormat.KEY_PCM_ENCODING, when (bits) {
|
||||
16 -> AudioFormat.ENCODING_PCM_16BIT
|
||||
24 -> AudioFormat.ENCODING_PCM_FLOAT
|
||||
32 -> AudioFormat.ENCODING_PCM_32BIT
|
||||
else -> error("Unsupported precision")
|
||||
})
|
||||
codec = MediaCodec.createDecoderByType(mime).also {
|
||||
it.configure(format, null, null, 0)
|
||||
it.start()
|
||||
}
|
||||
val header = ByteArray(42)
|
||||
FileInputStream(path).use { input ->
|
||||
var n = 0
|
||||
while (n < header.size) {
|
||||
val count = input.read(header, n, header.size - n)
|
||||
if (count <= 0) break
|
||||
n += count
|
||||
}
|
||||
"audio/raw" -> {
|
||||
bits = precision(format.getInteger(MediaFormat.KEY_PCM_ENCODING))
|
||||
inputBits = bits
|
||||
}
|
||||
if (BitPerfectPcm.flacFormat(header) != null) {
|
||||
// Some vendor extractors report FLAC as audio/raw and silently
|
||||
// truncate it to 16-bit. Open the original file with libFLAC.
|
||||
flac = NativeAudio.openFlac(path)
|
||||
val format = NativeAudio.infoFlac(flac)
|
||||
rate = format[0].toInt()
|
||||
channels = format[1].toInt()
|
||||
bits = format[2].toInt()
|
||||
durationUs = format[3] * 1000000 / rate
|
||||
} else {
|
||||
val media = MediaExtractor().also { extractor = it }
|
||||
FileInputStream(path).use { media.setDataSource(it.fd) }
|
||||
val index = (0 until media.trackCount).firstOrNull {
|
||||
media.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
|
||||
} ?: error("No audio stream")
|
||||
media.selectTrack(index)
|
||||
val format = media.getTrackFormat(index)
|
||||
require(format.getString(MediaFormat.KEY_MIME) == "audio/raw") {
|
||||
"Direct USB supports integer FLAC/WAV and DSF/DFF"
|
||||
}
|
||||
else -> error("Direct USB supports integer FLAC/WAV and DSF/DFF")
|
||||
rate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
|
||||
channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
|
||||
durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0
|
||||
bits = precision(format.getInteger(MediaFormat.KEY_PCM_ENCODING))
|
||||
inputBits = bits
|
||||
}
|
||||
require(channels in 1..2 && bits in listOf(16, 24, 32))
|
||||
} catch (error: Exception) { close(); throw error }
|
||||
@@ -79,78 +73,53 @@ internal class UsbPcmSource(path: String) : Closeable {
|
||||
else -> 0
|
||||
}
|
||||
|
||||
private fun accept(format: MediaFormat) {
|
||||
require(format.getInteger(MediaFormat.KEY_SAMPLE_RATE) == rate && format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) == channels)
|
||||
val encoding = if (format.containsKey(MediaFormat.KEY_PCM_ENCODING)) format.getInteger(MediaFormat.KEY_PCM_ENCODING) else AudioFormat.ENCODING_PCM_16BIT
|
||||
floating = encoding == AudioFormat.ENCODING_PCM_FLOAT
|
||||
inputBits = if (floating) 32 else precision(encoding)
|
||||
require(inputBits >= bits && !(floating && bits > 24)) { "Decoder reduced precision" }
|
||||
}
|
||||
|
||||
private fun convert(buffer: ByteBuffer, timeUs: Long, outputBits: Int): ByteBuffer {
|
||||
val frame = channels * (inputBits / 8)
|
||||
require(frame > 0 && buffer.remaining() % frame == 0)
|
||||
val skip = if (timeUs < targetUs) ((targetUs - timeUs) * rate + 999999) / 1000000 else 0
|
||||
buffer.position(buffer.position() + minOf(skip, (buffer.remaining() / frame).toLong()).toInt() * frame)
|
||||
bufferTimeUs = maxOf(timeUs, targetUs)
|
||||
// The playback worker consumes this result before requesting another buffer.
|
||||
return BitPerfectPcm.convert(buffer, inputBits, bits, outputBits, floating, converted)
|
||||
return BitPerfectPcm.convert(buffer, inputBits, bits, outputBits, false, converted)
|
||||
.also { converted = it }
|
||||
}
|
||||
|
||||
fun read(outputBits: Int): ByteBuffer? {
|
||||
if (ended) return null
|
||||
val decoder = codec
|
||||
if (decoder == null) {
|
||||
if (flac != 0L) {
|
||||
raw.clear()
|
||||
val size = extractor.readSampleData(raw, 0)
|
||||
if (size < 0) { ended = true; return null }
|
||||
val size = NativeAudio.readFlac(flac, raw, outputBits)
|
||||
if (size == 0) { ended = true; return null }
|
||||
raw.limit(size)
|
||||
raw.position(0)
|
||||
val output = convert(raw, extractor.sampleTime, outputBits)
|
||||
extractor.advance()
|
||||
return output
|
||||
bufferTimeUs = flacFrame * 1000000 / rate
|
||||
flacFrame += size / (channels * (outputBits / 8))
|
||||
return raw
|
||||
}
|
||||
repeat(8) {
|
||||
if (!inputEnded) {
|
||||
val slot = decoder.dequeueInputBuffer(0)
|
||||
if (slot >= 0) {
|
||||
val size = extractor.readSampleData(requireNotNull(decoder.getInputBuffer(slot)), 0)
|
||||
if (size < 0) {
|
||||
decoder.queueInputBuffer(slot, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
|
||||
inputEnded = true
|
||||
} else {
|
||||
decoder.queueInputBuffer(slot, 0, size, extractor.sampleTime, 0)
|
||||
extractor.advance()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
val slot = decoder.dequeueOutputBuffer(info, 0)
|
||||
if (slot == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) accept(decoder.outputFormat)
|
||||
if (slot < 0) return null
|
||||
try {
|
||||
ended = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
|
||||
if (info.size == 0 || info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) return null
|
||||
if (inputBits == 0) accept(decoder.outputFormat)
|
||||
val buffer = requireNotNull(decoder.getOutputBuffer(slot))
|
||||
buffer.position(info.offset)
|
||||
buffer.limit(info.offset + info.size)
|
||||
return convert(buffer, info.presentationTimeUs, outputBits)
|
||||
} finally { decoder.releaseOutputBuffer(slot, false) }
|
||||
val media = requireNotNull(extractor)
|
||||
raw.clear()
|
||||
val size = media.readSampleData(raw, 0)
|
||||
if (size < 0) { ended = true; return null }
|
||||
raw.limit(size)
|
||||
raw.position(0)
|
||||
val output = convert(raw, media.sampleTime, outputBits)
|
||||
media.advance()
|
||||
return output
|
||||
}
|
||||
|
||||
fun seek(positionUs: Long) {
|
||||
targetUs = positionUs.coerceAtLeast(0)
|
||||
codec?.flush()
|
||||
extractor.seekTo(targetUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
|
||||
inputEnded = false
|
||||
if (flac != 0L) {
|
||||
flacFrame = targetUs / 1000000 * rate + (targetUs % 1000000 * rate + 999999) / 1000000
|
||||
NativeAudio.seekFlac(flac, flacFrame)
|
||||
} else extractor?.seekTo(targetUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
|
||||
ended = false
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
runCatching { codec?.release() }
|
||||
codec = null
|
||||
if (flac != 0L) runCatching { NativeAudio.closeFlac(flac) }
|
||||
flac = 0
|
||||
converted = null
|
||||
runCatching { extractor.release() }
|
||||
runCatching { extractor?.release() }
|
||||
extractor = null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
Copyright (C) 2000-2009 Josh Coalson
|
||||
Copyright (C) 2011-2025 Xiph.Org Foundation
|
||||
|
||||
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 Xiph.Org Foundation 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 FOUNDATION 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.
|
||||
@@ -70,6 +70,9 @@ void main() {
|
||||
yield LicenseEntryWithLineBreaks(const [
|
||||
'WavPack',
|
||||
], await rootBundle.loadString('assets/licenses/wavpack.txt'));
|
||||
yield LicenseEntryWithLineBreaks(const [
|
||||
'libFLAC',
|
||||
], await rootBundle.loadString('assets/licenses/flac.txt'));
|
||||
}
|
||||
});
|
||||
_startupBenchmark?.observeFirstFrame();
|
||||
|
||||
@@ -132,6 +132,7 @@ flutter:
|
||||
- assets/licenses/usb.txt
|
||||
- assets/licenses/oboe.txt
|
||||
- assets/licenses/wavpack.txt
|
||||
- assets/licenses/flac.txt
|
||||
- assets/licenses/libsmb2.txt
|
||||
- assets/licenses/native_palette.txt
|
||||
- assets/licenses/native_data_jobs.txt
|
||||
|
||||
@@ -114,11 +114,29 @@ Actual exclusive DAP playback and route-specific hardware behavior need a DAP.
|
||||
|
||||
## Reference and dependencies
|
||||
|
||||
All three Android PCM paths share `UsbPcmSource`. Native FLAC is decoded with
|
||||
libFLAC directly from the original file, rather than Android `MediaExtractor`:
|
||||
some vendor extractors expose 24-bit FLAC as already-decoded 16-bit `audio/raw`.
|
||||
The decoder keeps one bounded block, writes integer PCM into a reused direct
|
||||
buffer, and supports sample-accurate seeking and retained SAF descriptors.
|
||||
16/24/32-bit sources may be padded to a wider DAC format but never truncated.
|
||||
The reported bit depth is the negotiated output format.
|
||||
|
||||
Decoder sample/seek tests can run without building an APK:
|
||||
|
||||
```sh
|
||||
cmake -S android/app/src/main/cpp -B /tmp/spotiflac-flac-tests \
|
||||
-DSPOTIFLAC_AUDIO_HOST_TESTS=ON
|
||||
cmake --build /tmp/spotiflac-flac-tests
|
||||
ctest --test-dir /tmp/spotiflac-flac-tests --output-on-failure
|
||||
```
|
||||
|
||||
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. Oboe (Apache-2.0) and WavPack (BSD-3-Clause) are built
|
||||
the app's license registry. Oboe (Apache-2.0), WavPack and libFLAC (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`.
|
||||
are included in `assets/licenses/oboe.txt`, `assets/licenses/wavpack.txt` and
|
||||
`assets/licenses/flac.txt`.
|
||||
Reference in new issue
Block a user