fix(audio): preserve FLAC precision in Android bit-perfect playback

This commit is contained in:
zarzet committed 2026-10-15 00:28:17 +07:00
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
@@ -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()
+41 -1
View File
@@ -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);
};
+39
View File
@@ -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
}
}
+29
View File
@@ -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.
+3
View File
@@ -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();
+1
View File
@@ -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`.