diff --git a/android/app/src/androidTest/assets/README.md b/android/app/src/androidTest/assets/README.md index 2c0ab47e..7f1421e5 100644 --- a/android/app/src/androidTest/assets/README.md +++ b/android/app/src/androidTest/assets/README.md @@ -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 diff --git a/android/app/src/androidTest/assets/pcm24-pattern.flac b/android/app/src/androidTest/assets/pcm24-pattern.flac new file mode 100644 index 00000000..afc3b062 Binary files /dev/null and b/android/app/src/androidTest/assets/pcm24-pattern.flac differ diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/UsbBitPerfectPluginTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/UsbBitPerfectPluginTest.kt index 59b62d04..6be84401 100644 --- a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/UsbBitPerfectPluginTest.kt +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/UsbBitPerfectPluginTest.kt @@ -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() diff --git a/android/app/src/main/cpp/CMakeLists.txt b/android/app/src/main/cpp/CMakeLists.txt index d93e796e..2f058b00 100644 --- a/android/app/src/main/cpp/CMakeLists.txt +++ b/android/app/src/main/cpp/CMakeLists.txt @@ -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 diff --git a/android/app/src/main/cpp/flac_pcm_source.cpp b/android/app/src/main/cpp/flac_pcm_source.cpp new file mode 100644 index 00000000..4563288c --- /dev/null +++ b/android/app/src/main/cpp/flac_pcm_source.cpp @@ -0,0 +1,114 @@ +#include "flac_pcm_source.h" + +#include +#include +#include + +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(std::numeric_limits::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(data); + const auto &info = value->data.stream_info; + source.rate = static_cast(info.sample_rate); + source.channels = static_cast(info.channels); + source.bits = static_cast(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(data); + const auto &header = frame->header; + if (header.sample_rate != static_cast(source.rate) || + header.channels != static_cast(source.channels) || + header.bits_per_sample != static_cast(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(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(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(block[offset + i]) << (outputBits - bits); + for (size_t byte = 0; byte < bytes; ++byte) { + output[i * bytes + byte] = static_cast(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"); + } +} diff --git a/android/app/src/main/cpp/flac_pcm_source.h b/android/app/src/main/cpp/flac_pcm_source.h new file mode 100644 index 00000000..170743a5 --- /dev/null +++ b/android/app/src/main/cpp/flac_pcm_source.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +#include +#include +#include +#include + +// 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 decoder; + std::vector 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); +}; diff --git a/android/app/src/main/cpp/native_audio.cpp b/android/app/src/main/cpp/native_audio.cpp index 415d8226..55822b4b 100644 --- a/android/app/src/main/cpp/native_audio.cpp +++ b/android/app/src/main/cpp/native_audio.cpp @@ -1,6 +1,7 @@ #include #include #include +#include "flac_pcm_source.h" #include #include @@ -209,6 +210,7 @@ public: }; Handles outputs; Handles decoders; +Handles 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(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(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(env->GetDirectBufferAddress(buffer)); + const auto capacity = env->GetDirectBufferCapacity(buffer); + require(data && capacity > 0 && capacity <= 262144, "Invalid FLAC PCM buffer"); + return static_cast(flacSources.get(handle)->read(data, static_cast(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(sample)); + } catch (const std::exception &e) { report(env, e); } +} +JNIEXPORT void JNICALL JNI_AUDIO(closeFlac)(JNIEnv *, jobject, jlong handle) { flacSources.remove(handle); } } diff --git a/android/app/src/main/cpp/native_audio.map b/android/app/src/main/cpp/native_audio.map index 276e3fc7..93faf6bc 100644 --- a/android/app/src/main/cpp/native_audio.map +++ b/android/app/src/main/cpp/native_audio.map @@ -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: *; }; diff --git a/android/app/src/main/cpp/tests/flac_pcm_source_test.cpp b/android/app/src/main/cpp/tests/flac_pcm_source_test.cpp new file mode 100644 index 00000000..fdff4645 --- /dev/null +++ b/android/app/src/main/cpp/tests/flac_pcm_source_test.cpp @@ -0,0 +1,132 @@ +#include "../flac_pcm_source.h" +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace { +void check(bool value, const char *message) { + if (!value) throw std::runtime_error(message); +} +struct Fixture { + std::string path; + std::vector 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((uint32_t{1} << (bits - 1)) - 1); + const int32_t minimum = -maximum - 1; + const std::array 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 data{}; + size_t sample = static_cast(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(fixture.input[sample]) << (outputBits - source.bits); + for (size_t byte = 0; byte < bytes; ++byte) { + check(data[offset + byte] == static_cast(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(bits) && source.rate == static_cast(rate) && + source.channels == static_cast(channels) && source.samples == fixture.frames, + "source format changed"); + for (int output : {16, 24, 32}) { + if (output < static_cast(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 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 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; + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/HiResPlayback.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/HiResPlayback.kt index ba3ad43d..5f9d4891 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/HiResPlayback.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/HiResPlayback.kt @@ -93,7 +93,8 @@ internal class HiResPlayback(context: Context, private val emit: (Map - 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 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 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 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 { - 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 } } diff --git a/assets/licenses/flac.txt b/assets/licenses/flac.txt new file mode 100644 index 00000000..f42f48b3 --- /dev/null +++ b/assets/licenses/flac.txt @@ -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. diff --git a/lib/main.dart b/lib/main.dart index 768c8d8f..3c2a9fcf 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(); diff --git a/pubspec.yaml b/pubspec.yaml index 64492eae..4d3cae5c 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -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 diff --git a/rust_backend/crates/mobile/src/usb_audio/README.md b/rust_backend/crates/mobile/src/usb_audio/README.md index b76eaea7..4c48ba73 100644 --- a/rust_backend/crates/mobile/src/usb_audio/README.md +++ b/rust_backend/crates/mobile/src/usb_audio/README.md @@ -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`.