diff --git a/android/app/src/androidTest/kotlin/com/zarz/spotiflac/UsbBitPerfectPluginTest.kt b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/UsbBitPerfectPluginTest.kt new file mode 100644 index 00000000..5f220188 --- /dev/null +++ b/android/app/src/androidTest/kotlin/com/zarz/spotiflac/UsbBitPerfectPluginTest.kt @@ -0,0 +1,63 @@ +package com.zarz.spotiflac + +import android.media.AudioDeviceInfo +import android.media.AudioManager +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodCall +import io.flutter.plugin.common.MethodChannel +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Assume.assumeTrue +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class UsbBitPerfectPluginTest { + @Test + fun unsupportedRouteReturnsFallbackAndEngineDetachesCleanly() { + val instrumentation = InstrumentationRegistry.getInstrumentation() + val context = instrumentation.targetContext + val manager = context.getSystemService(AudioManager::class.java) + assumeTrue(manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).none { + it.type == AudioDeviceInfo.TYPE_USB_DEVICE || it.type == AudioDeviceInfo.TYPE_USB_HEADSET + }) + lateinit var engine: FlutterEngine + lateinit var plugin: UsbBitPerfectPlugin + instrumentation.runOnMainSync { + engine = FlutterEngine(context) + plugin = UsbBitPerfectPlugin() + engine.plugins.add(plugin) + } + try { + fun invoke(method: String, arguments: Map? = null): Any? { + val done = CountDownLatch(1) + var value: Any? = null + var failure: String? = null + instrumentation.runOnMainSync { + plugin.onMethodCall(MethodCall(method, arguments), object : MethodChannel.Result { + override fun success(result: Any?) { value = result; done.countDown() } + override fun error(code: String, message: String?, details: Any?) { + failure = "$code: $message" + done.countDown() + } + override fun notImplemented() { failure = "not implemented"; done.countDown() } + }) + } + assertTrue("Native method timed out", done.await(10, TimeUnit.SECONDS)) + assertNull(failure) + return value + } + val result = invoke("prepare", mapOf("path" to "/unopened.flac", "token" to 1)) as Map<*, *> + assertEquals(if (android.os.Build.VERSION.SDK_INT >= 34) "no_usb" else "android_version", result["reason"]) + assertNull(result["ready"]) + invoke("stop") + } finally { + instrumentation.runOnMainSync { engine.destroy() } + } + } +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index fa41a31e..237f6971 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -23,6 +23,7 @@ + ): Int? = + supported.filter { it in listOf(16, 24, 32) && it >= sourceBits }.minOrNull() + + fun convert(input: ByteBuffer, inputBits: Int, sourceBits: Int, outputBits: Int, floating: Boolean): ByteBuffer { + require(sourceBits in listOf(16, 24, 32) && outputBits in listOf(16, 24, 32) && outputBits >= sourceBits) + require(if (floating) inputBits == 32 && sourceBits <= 24 else inputBits >= sourceBits) + val bytes = inputBits / 8 + require(input.remaining() % bytes == 0) + input.order(ByteOrder.LITTLE_ENDIAN) + val output = ByteBuffer.allocateDirect(input.remaining() / bytes * (outputBits / 8)) + .order(ByteOrder.LITTLE_ENDIAN) + while (input.hasRemaining()) { + val sample = if (floating) { + val scaled = input.float.toDouble() * (1L shl (sourceBits - 1)) + require(scaled.isFinite() && scaled >= -(1L shl (sourceBits - 1)) && + scaled < (1L shl (sourceBits - 1)) && scaled == scaled.toLong().toDouble()) + scaled.toInt() shl (32 - sourceBits) + } else { + when (inputBits) { + 16 -> input.short.toInt() shl 16 + 24 -> ((input.get().toInt() and 255) or + ((input.get().toInt() and 255) shl 8) or + ((input.get().toInt() and 255) shl 16)) shl 8 + 32 -> input.int + else -> error("Unsupported PCM encoding") + } + } + // Reject a decoder output containing more precision than the source contract. + require(sourceBits == 32 || (sample shl sourceBits) == 0) + when (outputBits) { + 16 -> output.putShort((sample shr 16).toShort()) + 24 -> { + output.put((sample shr 8).toByte()) + output.put((sample shr 16).toByte()) + output.put((sample shr 24).toByte()) + } + 32 -> output.putInt(sample) + } + } + output.flip() + return output + } + + fun flacFormat(header: ByteArray): Triple? { + if (header.size < 42 || header.take(4) != listOf(102, 76, 97, 67) || + header[4].toInt() and 127 != 0 || header[5].toInt() != 0 || + header[6].toInt() != 0 || header[7].toInt() != 34) return null + fun byte(i: Int) = header[i].toInt() and 255 + val rate = (byte(18) shl 12) or (byte(19) shl 4) or (byte(20) shr 4) + val channels = ((byte(20) shr 1) and 7) + 1 + val bits = (((byte(20) and 1) shl 4) or (byte(21) shr 4)) + 1 + return Triple(rate, channels, bits) + } +} diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt index 7a3942d6..c1a07a67 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/MainActivity.kt @@ -976,6 +976,9 @@ class MainActivity: FlutterFragmentActivity() { onBackPressedDispatcher.addCallback(this, flutterBackCallback!!) val messenger = flutterEngine.dartExecutor.binaryMessenger + if (!flutterEngine.plugins.has(UsbBitPerfectPlugin::class.java)) { + flutterEngine.plugins.add(UsbBitPerfectPlugin()) + } concertCalendarChannel = MethodChannel(messenger, "com.zarz.spotiflac/concert_calendar").also { channel -> channel.setMethodCallHandler { call, result -> if (call.method != "add") { diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt new file mode 100644 index 00000000..1745f2c1 --- /dev/null +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt @@ -0,0 +1,446 @@ +package com.zarz.spotiflac + +import android.content.Context +import android.media.AudioAttributes +import android.media.AudioDeviceInfo +import android.media.AudioFormat +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 +import android.os.Looper +import android.os.PowerManager +import androidx.annotation.RequiresApi +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. */ +class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler { + private lateinit var methods: MethodChannel + private lateinit var events: EventChannel + private var engine: UsbPcmPlayback? = null + private var sink: EventChannel.EventSink? = null + private val main = Handler(Looper.getMainLooper()) + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methods = MethodChannel(binding.binaryMessenger, "com.zarz.spotiflac/usb_pcm") + events = EventChannel(binding.binaryMessenger, "com.zarz.spotiflac/usb_pcm/events") + methods.setMethodCallHandler(this) + events.setStreamHandler(this) + if (Build.VERSION.SDK_INT >= 34) { + engine = UsbPcmPlayback(binding.applicationContext) { event -> + main.post { sink?.success(event) } + } + } + } + + override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { + val playback = engine + if (Build.VERSION.SDK_INT < 34 || playback == null) { + result.success(if (call.method == "prepare") mapOf("reason" to "android_version") else null) + return + } + playback.command(call) { value, error -> + main.post { + if (error == null) result.success(value) + else result.error("usb_pcm", error, null) + } + } + } + + override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { sink = events } + override fun onCancel(arguments: Any?) { sink = null } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + methods.setMethodCallHandler(null) + events.setStreamHandler(null) + sink = null + if (Build.VERSION.SDK_INT >= 34) engine?.dispose() + engine = null + } +} + +@RequiresApi(34) +private class UsbPcmPlayback(context: Context, private val emit: (Map) -> Unit) { + private val manager = context.getSystemService(AudioManager::class.java) + private val thread = HandlerThread("SpotiFLAC-USB-PCM").apply { start() } + private val worker = Handler(thread.looper) + private val wakeLock = context.getSystemService(PowerManager::class.java) + .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 track: AudioTrack? = null + private var device: AudioDeviceInfo? = null + private var mixer: AudioMixerAttributes? = null + private var token = 0 + 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 + private var seekTargetUs = 0L + private var needTimestamp = true + 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 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() + } + private val mixerListener = AudioManager.OnPreferredMixerAttributesChangedListener { attr, changedDevice, value -> + if (attr.usage == AudioAttributes.USAGE_MEDIA && changedDevice.id == device?.id && value != mixer) { + suspendRoute() + } + } + + init { + manager.addOnPreferredMixerAttributesChangedListener({ runnable -> worker.post(runnable) }, mixerListener) + } + + fun command(call: MethodCall, done: (Any?, String?) -> Unit) { + val commandRevision = if (call.method == "prepare" || call.method == "stop") ++revision else revision + worker.post { + try { + val value: Any? = when (call.method) { + "prepare" -> { + closeSource() + token = call.argument("token") ?: 0 + try { + prepare(requireNotNull(call.argument("path")), commandRevision) + } catch (error: Exception) { + closeSource() + android.util.Log.i("UsbPcm", "Using normal output: ${error.message}") + mapOf("reason" to (error as? Unavailable)?.reason.orEmpty().ifEmpty { "format" }) + } + } + "resume" -> { resume(); null } + "pause" -> { pause(); null } + "seek" -> { seek((call.argument("position")?.toLong() ?: 0) * 1000); null } + "stop" -> { closeSource(); null } + "position" -> positionUs() / 1000 + else -> error("Unknown USB PCM command") + } + done(value, null) + } catch (error: Exception) { done(null, error.message ?: "USB playback failed") } + } + } + + private class Unavailable(val reason: String) : Exception(reason) + + private fun prepare(path: String, expectedRevision: Int): Map { + val usb = manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull { + it.type == AudioDeviceInfo.TYPE_USB_DEVICE || it.type == AudioDeviceInfo.TYPE_USB_HEADSET + } ?: throw Unavailable("no_usb") + val supported = manager.getSupportedMixerAttributes(usb).filter { + 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 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") + val selected = matching.first { bits(it.format.encoding) == outputBits } + device = usb + mixer = selected + if (!manager.setPreferredMixerAttributes(attributes, usb, selected)) throw Unavailable("unsupported") + val minimum = AudioTrack.getMinBufferSize(rate, mask, selected.format.encoding) + if (minimum <= 0) throw Unavailable("format") + val audio = AudioTrack.Builder().setAudioAttributes(attributes).setAudioFormat(selected.format) + .setTransferMode(AudioTrack.MODE_STREAM) + .setBufferSizeInBytes(maxOf(minimum * 2, rate / 5 * channels * (outputBits / 8))) + .build().also { track = it } + if (audio.state != AudioTrack.STATE_INITIALIZED || !audio.setPreferredDevice(usb) || + audio.sampleRate != rate || audio.audioFormat != selected.format.encoding || + audio.channelCount != channels) throw Unavailable("format") + 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) + } + } + return mapOf("ready" to true, "device" to usb.productName.toString(), "sampleRate" to rate, + "bitDepth" to outputBits, "sourceBitDepth" to sourceBits, "duration" to durationUs / 1000) + } + + private fun bits(encoding: Int): Int = when (encoding) { + AudioFormat.ENCODING_PCM_16BIT -> 16 + AudioFormat.ENCODING_PCM_24BIT_PACKED -> 24 + AudioFormat.ENCODING_PCM_32BIT -> 32 + 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) + } + + private fun decode() { + val media = extractor ?: 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 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) } + } + } + + private fun verifiedRoute(): Boolean = track?.routedDevice?.id == device?.id && + device?.let { manager.getPreferredMixerAttributes(attributes, it) == mixer } == true + + private fun resume() { + val audio = track ?: error("No USB source") + check(!invalidRoute) { "USB route changed; reopen the track" } + audio.play() + // Do not write any samples until the route actually resolves to our DAC. + if (!verifiedRoute()) { + audio.pause() + invalidRoute = true + emit(mapOf("token" to token, "event" to "paused", "reason" to "route_changed")) + error("USB route could not be verified") + } + playing = true + if (!wakeLock.isHeld) wakeLock.acquire() + emit(mapOf("token" to token, "event" to "active")) + worker.removeCallbacks(pump) + worker.post(pump) + } + + private val pump = object : Runnable { + override fun run() { + if (!playing) return + try { + val now = android.os.SystemClock.elapsedRealtime() + if (now >= nextRouteCheck) { + if (!verifiedRoute()) { suspendRoute(); return } + nextRouteCheck = now + 250 + } + // Bounded nonblocking writes keep transport commands responsive + // even when a DAC stops consuming samples. + for (attempt in 0 until 8) { + decode() + val buffer = pending ?: break + val written = requireNotNull(track).write(buffer, buffer.remaining(), AudioTrack.WRITE_NON_BLOCKING) + check(written >= 0) { "USB write failed: $written" } + framesWritten += written / (channels * (outputBits / 8)) + if (buffer.hasRemaining()) break + pending = null + } + if (outputEnded && pending == null && headFrames() >= framesWritten) { + pause() + emit(mapOf("token" to token, "event" to "complete")) + } else worker.postDelayed(this, 5) + } catch (error: Exception) { + android.util.Log.w("UsbPcm", "PCM playback stopped", error) + suspendRoute() + } + } + } + + private fun pause() { + playing = false + worker.removeCallbacks(pump) + if (wakeLock.isHeld) wakeLock.release() + runCatching { + if (track?.playState == AudioTrack.PLAYSTATE_PLAYING) track?.pause() + } + } + + private fun suspendRoute() { + pause() + invalidRoute = true + emit(mapOf("token" to token, "event" to "paused", "reason" to "route_changed")) + } + + private fun headFrames(): Long { + val head = (track?.playbackHeadPosition?.toLong() ?: 0) and 0xffffffffL + if (head < previousHead) headWrap += 1L shl 32 + previousHead = head + return headWrap + head + } + private fun positionUs(): Long = positionBaseUs + if (rate > 0) headFrames() * 1000000 / rate else 0 + + private fun seek(positionUs: Long) { + 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) + positionBaseUs = seekTargetUs + framesWritten = 0 + previousHead = 0 + headWrap = 0 + needTimestamp = true + pending = null + inputEnded = false + outputEnded = false + if (wasPlaying) resume() + } + + private fun closeSource() { + pause() + track?.let { audio -> + runCatching { audio.removeOnRoutingChangedListener(routing) } + runCatching { audio.release() } + } + track = null + runCatching { decoder?.release() } + decoder = null + runCatching { extractor?.release() } + extractor = null + device?.let { runCatching { manager.clearPreferredMixerAttributes(attributes, it) } } + device = null + mixer = null + pending = null + framesWritten = 0 + previousHead = 0 + headWrap = 0 + positionBaseUs = 0 + seekTargetUs = 0 + durationUs = 0 + inputEnded = false + outputEnded = false + needTimestamp = true + invalidRoute = false + } + + fun dispose() { + revision++ + worker.post { + closeSource() + manager.removeOnPreferredMixerAttributesChangedListener(mixerListener) + thread.quitSafely() + } + } +} diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/BitPerfectPcmTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/BitPerfectPcmTest.kt new file mode 100644 index 00000000..ba1b9aa3 --- /dev/null +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/BitPerfectPcmTest.kt @@ -0,0 +1,82 @@ +package com.zarz.spotiflac + +import java.nio.ByteBuffer +import java.nio.ByteOrder +import kotlin.random.Random +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class BitPerfectPcmTest { + private fun bytes(buffer: ByteBuffer): ByteArray = ByteArray(buffer.remaining()).also { buffer.get(it) } + + @Test + fun selectsOnlyAnIntegerFormatWithEnoughPrecision() { + assertEquals(24, BitPerfectPcm.outputBits(24, listOf(16, 32, 24))) + assertEquals(32, BitPerfectPcm.outputBits(24, listOf(16, 32))) + assertNull(BitPerfectPcm.outputBits(24, listOf(16))) + assertNull(BitPerfectPcm.outputBits(32, listOf(16, 24))) + } + + @Test + fun preservesPacked24BitSamplesIncludingSignExtremes() { + val samples = byteArrayOf(0, 0, -128, -1, -1, 127, -1, -1, -1, 1, 0, 0) + val input = ByteBuffer.wrap(samples) + assertArrayEquals(samples, bytes(BitPerfectPcm.convert(input, 24, 24, 24, false))) + } + + @Test + fun padsLeastSignificantBitsWithoutChangingSampleAmplitude() { + val input = ByteBuffer.allocate(6).order(ByteOrder.LITTLE_ENDIAN) + .putShort(Short.MIN_VALUE).putShort(Short.MAX_VALUE).putShort(-1) + input.flip() + val result = BitPerfectPcm.convert(input, 16, 16, 32, false) + assertEquals(Int.MIN_VALUE, result.int) + assertEquals(0x7fff0000, result.int) + assertEquals(-65536, result.int) + } + + @Test + fun decoderFloatRoundTripPreservesEveryBitOf24BitSamples() { + val random = Random(17) + val samples = listOf(-8388608, 8388607, -1, 0, 1) + List(10000) { random.nextInt(-8388608, 8388608) } + val input = ByteBuffer.allocate(samples.size * 4).order(ByteOrder.LITTLE_ENDIAN) + for (sample in samples) input.putFloat(sample / 8388608f) + input.flip() + val result = BitPerfectPcm.convert(input, 32, 24, 32, true) + for (sample in samples) assertEquals(sample shl 8, result.int) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsDecoderDownconversion() { + BitPerfectPcm.convert(ByteBuffer.allocate(2), 16, 24, 24, false) + } + + @Test(expected = IllegalArgumentException::class) + fun rejectsFloatingPointFor32BitSource() { + BitPerfectPcm.convert(ByteBuffer.allocate(4), 32, 32, 32, true) + } + + @Test(expected = IllegalArgumentException::class) + fun refusesRoundingOfNonRepresentableFloatSamples() { + val input = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putFloat(0.00000001f) + input.flip() + BitPerfectPcm.convert(input, 32, 24, 24, true) + } + + @Test + fun readsFlacPrecisionFromStreaminfoInsteadOfLibraryTags() { + val header = ByteArray(42) + "fLaC".toByteArray().copyInto(header) + header[4] = -128 + header[7] = 34 + val rate = 96000 + val packed = (rate.toLong() shl 44) or (1L shl 41) or (23L shl 36) + for (i in 0..7) header[18 + i] = (packed ushr (56 - i * 8)).toByte() + assertEquals(Triple(96000, 2, 24), BitPerfectPcm.flacFormat(header)) + header[7] = 33 + assertNull(BitPerfectPcm.flacFormat(header)) + assertNull(BitPerfectPcm.flacFormat(ByteArray(4))) + } +} diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index aba017f7..7b48bcba 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -1,4 +1,10 @@ { + "usbBitPerfect": "USB bit-perfect audio", + "usbBitPerfectDescription": "Preserve the original PCM audio on compatible USB DACs with Android 14 or later. Applies from the next track, bypassing AutoMix and normalization. Control volume on your DAC; system volume may not apply.", + "usbBitPerfectPending": "Waiting for the next track and a compatible USB output.", + "usbBitPerfectNoDevice": "No USB DAC connected. Using normal audio output.", + "usbBitPerfectFallback": "This device or audio format does not support bit-perfect output. Using normal playback.", + "usbBitPerfectRouteChanged": "The USB audio route changed. Playback paused; select a track to reconnect.", "upgradeIntroWelcome": "Welcome to\nSpotiFLAC-Mobile 5.0", "upgradeIntroThemeTitle": "Make it\nyour own.", "upgradeIntroThemeBody": "Choose a look. Your choice applies immediately, and you can change it later in Appearance.", diff --git a/lib/l10n/arb/app_id.arb b/lib/l10n/arb/app_id.arb index 6dff92a0..a84f5371 100644 --- a/lib/l10n/arb/app_id.arb +++ b/lib/l10n/arb/app_id.arb @@ -1,4 +1,10 @@ { + "usbBitPerfect": "Audio USB bit-perfect", + "usbBitPerfectDescription": "Pertahankan PCM asli pada DAC USB yang kompatibel dengan Android 14 atau lebih baru. Berlaku mulai lagu berikutnya, tanpa AutoMix dan normalisasi. Atur volume melalui DAC; volume sistem mungkin tidak berlaku.", + "usbBitPerfectPending": "Menunggu lagu berikutnya dan keluaran USB yang kompatibel.", + "usbBitPerfectNoDevice": "DAC USB tidak terhubung. Menggunakan keluaran audio biasa.", + "usbBitPerfectFallback": "Perangkat atau format audio ini tidak mendukung keluaran bit-perfect. Menggunakan pemutaran biasa.", + "usbBitPerfectRouteChanged": "Keluaran audio USB berubah. Pemutaran dijeda; pilih lagu untuk menyambungkan kembali.", "upgradeIntroWelcome": "Selamat datang di\nSpotiFLAC-Mobile 5.0", "upgradeIntroThemeTitle": "Pilih\ntampilanmu.", "upgradeIntroThemeBody": "Pilih tema yang kamu suka. Pilihan langsung diterapkan dan bisa diubah lagi lewat Tampilan.", diff --git a/lib/models/settings.dart b/lib/models/settings.dart index 6723df25..d1da4b81 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -32,6 +32,7 @@ class AppSettings { // Apply ReplayGain/R128 tags as volume normalization in the built-in player. final bool playbackNormalization; final bool autoMix; + final bool usbBitPerfect; final bool autoplay; final bool isFirstLaunch; final bool checkForUpdates; @@ -145,6 +146,7 @@ class AppSettings { this.embedReplayGain = false, this.playbackNormalization = false, this.autoMix = false, + this.usbBitPerfect = false, this.autoplay = false, this.isFirstLaunch = true, this.checkForUpdates = true, @@ -233,6 +235,7 @@ class AppSettings { bool? embedReplayGain, bool? playbackNormalization, bool? autoMix, + bool? usbBitPerfect, bool? autoplay, bool? isFirstLaunch, bool? checkForUpdates, @@ -317,6 +320,7 @@ class AppSettings { playbackNormalization: playbackNormalization ?? this.playbackNormalization, autoMix: autoMix ?? this.autoMix, + usbBitPerfect: usbBitPerfect ?? this.usbBitPerfect, autoplay: autoplay ?? this.autoplay, isFirstLaunch: isFirstLaunch ?? this.isFirstLaunch, checkForUpdates: checkForUpdates ?? this.checkForUpdates, diff --git a/lib/models/settings.g.dart b/lib/models/settings.g.dart index 86d03044..e4382ea5 100644 --- a/lib/models/settings.g.dart +++ b/lib/models/settings.g.dart @@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map json) => AppSettings( embedReplayGain: json['embedReplayGain'] as bool? ?? false, playbackNormalization: json['playbackNormalization'] as bool? ?? false, autoMix: json['autoMix'] as bool? ?? false, + usbBitPerfect: json['usbBitPerfect'] as bool? ?? false, autoplay: json['autoplay'] as bool? ?? false, isFirstLaunch: json['isFirstLaunch'] as bool? ?? true, checkForUpdates: json['checkForUpdates'] as bool? ?? true, @@ -123,6 +124,7 @@ Map _$AppSettingsToJson( 'embedReplayGain': instance.embedReplayGain, 'playbackNormalization': instance.playbackNormalization, 'autoMix': instance.autoMix, + 'usbBitPerfect': instance.usbBitPerfect, 'autoplay': instance.autoplay, 'isFirstLaunch': instance.isFirstLaunch, 'checkForUpdates': instance.checkForUpdates, diff --git a/lib/providers/settings_provider.dart b/lib/providers/settings_provider.dart index 2fd02ad4..08d493c0 100644 --- a/lib/providers/settings_provider.dart +++ b/lib/providers/settings_provider.dart @@ -561,6 +561,11 @@ class SettingsNotifier extends Notifier { _saveSettings(); } + void setUsbBitPerfect(bool enabled) { + state = state.copyWith(usbBitPerfect: enabled); + _saveSettings(); + } + void setEmbedMetadata(bool enabled) { state = state.copyWith(embedMetadata: enabled); _saveSettings(); diff --git a/lib/screens/main_shell.dart b/lib/screens/main_shell.dart index e4d9c1db..b5f61fc6 100644 --- a/lib/screens/main_shell.dart +++ b/lib/screens/main_shell.dart @@ -92,6 +92,7 @@ class _MainShellState extends ConsumerState ref.read(settingsProvider).playbackNormalization, ); setAutoMixEnabled(ref.read(settingsProvider).autoMix); + setUsbBitPerfectEnabled(ref.read(settingsProvider).usbBitPerfect); setAutoplayEnabled( ref.read(settingsProvider).autoplay, includeLocal: ref.read(settingsProvider).localLibraryEnabled, @@ -742,6 +743,9 @@ class _MainShellState extends ConsumerState ref.listen(settingsProvider.select((s) => s.autoMix), (_, enabled) { setAutoMixEnabled(enabled); }); + ref.listen(settingsProvider.select((s) => s.usbBitPerfect), (_, enabled) { + setUsbBitPerfectEnabled(enabled); + }); ref.listen( settingsProvider.select((s) => (s.autoplay, s.localLibraryEnabled)), (_, value) => setAutoplayEnabled(value.$1, includeLocal: value.$2), diff --git a/lib/screens/settings/library_settings_page.dart b/lib/screens/settings/library_settings_page.dart index 51b2f9e4..0fde1e89 100644 --- a/lib/screens/settings/library_settings_page.dart +++ b/lib/screens/settings/library_settings_page.dart @@ -11,6 +11,7 @@ import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/providers/settings_provider.dart'; import 'package:spotiflac_android/providers/local_library_provider.dart'; import 'package:spotiflac_android/services/library_database.dart'; +import 'package:spotiflac_android/services/music_playback_deck.dart'; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/utils/adaptive_layout.dart'; import 'package:spotiflac_android/widgets/duplicate_review_sheet.dart'; @@ -880,6 +881,7 @@ class _LibrarySettingsPageState extends ConsumerState { title: context.l10n.libraryPlaybackNormalization, subtitle: context.l10n.libraryPlaybackNormalizationSubtitle, value: settings.playbackNormalization, + enabled: !settings.usbBitPerfect, onChanged: (v) => ref .read(settingsProvider.notifier) .setPlaybackNormalization(v), @@ -889,10 +891,39 @@ class _LibrarySettingsPageState extends ConsumerState { title: 'AutoMix', subtitle: context.l10n.autoMixDescription, value: settings.autoMix, + enabled: !settings.usbBitPerfect, onChanged: (value) => ref.read(settingsProvider.notifier).setAutoMix(value), - showDivider: false, + showDivider: Platform.isAndroid, ), + if (Platform.isAndroid) + ValueListenableBuilder( + valueListenable: usbAudioStatus, + builder: (context, status, _) => SettingsSwitchItem( + icon: Icons.usb, + title: context.l10n.usbBitPerfect, + subtitle: [ + context.l10n.usbBitPerfectDescription, + if (settings.usbBitPerfect) + switch (status.reason) { + 'active' => + '${status.device} · ${status.bitDepth}-bit · ${(status.sampleRate! / 1000).toStringAsFixed(1)} kHz', + 'no_usb' => context.l10n.usbBitPerfectNoDevice, + 'route_changed' => + context.l10n.usbBitPerfectRouteChanged, + 'android_version' || + 'unsupported' || + 'format' => context.l10n.usbBitPerfectFallback, + _ => context.l10n.usbBitPerfectPending, + }, + ].join('\n\n'), + value: settings.usbBitPerfect, + onChanged: (value) => ref + .read(settingsProvider.notifier) + .setUsbBitPerfect(value), + showDivider: false, + ), + ), ], ), ), diff --git a/lib/services/music_playback_deck.dart b/lib/services/music_playback_deck.dart new file mode 100644 index 00000000..3a7c1e02 --- /dev/null +++ b/lib/services/music_playback_deck.dart @@ -0,0 +1,277 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:audioplayers/audioplayers.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +/// Only verified native playback reports active. File metadata alone never does. +final usbAudioStatus = ValueNotifier(const UsbAudioStatus()); + +class UsbAudioStatus { + const UsbAudioStatus({ + this.reason = 'idle', + this.device, + this.sampleRate, + this.bitDepth, + }); + + final String reason; + final String? device; + final int? sampleRate; + final int? bitDepth; +} + +/// Keeps the existing transport and AutoMix API while adding a local USB path. +/// Each prepare owns a token so delayed native events cannot affect a new song. +class MusicPlaybackDeck { + MusicPlaybackDeck({required String playerId, bool? nativeAvailable}) + : _ordinary = AudioPlayer(playerId: playerId), + _nativeAvailable = nativeAvailable ?? Platform.isAndroid { + _subscriptions.addAll([ + _ordinary.onPlayerStateChanged.listen((value) { + if (!_direct) _states.add(value); + }), + _ordinary.onDurationChanged.listen((value) { + if (!_direct) _durations.add(value); + }), + _ordinary.onPlayerComplete.listen((_) { + if (!_direct) _completions.add(null); + }), + ]); + } + + static const _channel = MethodChannel('com.zarz.spotiflac/usb_pcm'); + static const _events = EventChannel('com.zarz.spotiflac/usb_pcm/events'); + static Stream? _nativeEvents; + static int _nextToken = 0; + final AudioPlayer _ordinary; + final bool _nativeAvailable; + final _states = StreamController.broadcast(); + final _durations = StreamController.broadcast(); + final _positions = StreamController.broadcast(); + final _completions = StreamController.broadcast(); + final _subscriptions = >[]; + StreamSubscription? _nativeSubscription; + StreamSubscription? _positionSubscription; + Timer? _timer; + bool _polling = false; + bool _direct = false; + bool _disposed = false; + int _token = 0; + Duration? _duration; + DeviceFileSource? _source; + bool _routeLost = false; + bool _nativeStarted = false; + PlayerState _directState = PlayerState.stopped; + UsbAudioStatus _preparedStatus = const UsbAudioStatus(); + + bool get isDirect => _direct; + bool get needsSourceReload => _direct && _routeLost; + PlayerState get state => _direct ? _directState : _ordinary.state; + Stream get onPlayerStateChanged => _states.stream; + Stream get onDurationChanged => _durations.stream; + Stream get onPositionChanged => _positions.stream; + Stream get onPlayerComplete => _completions.stream; + + set positionUpdater(PositionUpdater updater) { + _ordinary.positionUpdater = updater; + unawaited(_positionSubscription?.cancel()); + _positionSubscription = _ordinary.onPositionChanged.listen((value) { + if (!_direct) _positions.add(value); + }); + } + + Future setReleaseMode(ReleaseMode mode) => + _ordinary.setReleaseMode(mode); + Future setAudioContext(AudioContext context) => + _ordinary.setAudioContext(context); + Future setVolume(double volume) async { + if (!_direct) await _ordinary.setVolume(volume); + } + + Future setPlaybackRate(double rate) async { + if (!_direct) await _ordinary.setPlaybackRate(rate); + } + + Future setSource( + DeviceFileSource source, { + bool preferBitPerfect = false, + }) async { + await stop(); + _source = source; + _routeLost = false; + _nativeStarted = false; + if (preferBitPerfect && _nativeAvailable) { + _token = ++_nextToken; + _nativeSubscription ??= + (_nativeEvents ??= _events.receiveBroadcastStream()).listen( + _onNativeEvent, + onError: (Object error) { + if (_direct && !_disposed) { + _routeLost = true; + unawaited(pause().catchError((Object _) {})); + usbAudioStatus.value = const UsbAudioStatus( + reason: 'route_changed', + ); + } + }, + ); + try { + final response = await _channel.invokeMapMethod( + 'prepare', + {'path': source.path, 'token': _token}, + ); + if (response?['ready'] == true) { + _direct = true; + _directState = PlayerState.stopped; + _duration = Duration( + milliseconds: (response?['duration'] as num?)?.toInt() ?? 0, + ); + _preparedStatus = UsbAudioStatus( + reason: 'active', + device: response?['device'] as String?, + sampleRate: (response?['sampleRate'] as num?)?.toInt(), + bitDepth: (response?['bitDepth'] as num?)?.toInt(), + ); + usbAudioStatus.value = const UsbAudioStatus(reason: 'ready'); + _durations.add(_duration!); + return; + } + usbAudioStatus.value = UsbAudioStatus( + reason: response?['reason'] as String? ?? 'unsupported', + ); + } on PlatformException { + usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported'); + } on MissingPluginException { + usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported'); + } + } + await _ordinary.setSource(source); + } + + void _onNativeEvent(dynamic event) { + if (_disposed || !_direct || event is! Map || event['token'] != _token) { + return; + } + switch (event['event']) { + case 'active': + usbAudioStatus.value = _preparedStatus; + case 'paused': + _routeLost = true; + _timer?.cancel(); + _setState(PlayerState.paused); + usbAudioStatus.value = const UsbAudioStatus(reason: 'route_changed'); + case 'complete': + _timer?.cancel(); + _setState(PlayerState.completed); + _completions.add(null); + } + } + + void _setState(PlayerState value) { + _directState = value; + if (!_disposed) _states.add(value); + } + + Future resume() async { + if (!_direct) return _ordinary.resume(); + final token = _token; + // Before first playback the SAF lease is still open. After playback starts, + // reconnect through the handler to obtain a fresh lease for the same song. + try { + if (_routeLost) throw PlatformException(code: 'route_changed'); + await _channel.invokeMethod('resume'); + } on PlatformException { + if (token != _token || !_direct || _disposed) return; + if (_nativeStarted) { + _routeLost = true; + _setState(PlayerState.paused); + usbAudioStatus.value = const UsbAudioStatus(reason: 'route_changed'); + rethrow; + } + final source = _source; + final position = await getCurrentPosition() ?? Duration.zero; + await stop(); + usbAudioStatus.value = const UsbAudioStatus(reason: 'unsupported'); + if (source == null || _disposed) return; + await _ordinary.setVolume(1); + await _ordinary.setSource(source); + if (position > Duration.zero) await _ordinary.seek(position); + return _ordinary.resume(); + } + if (_disposed || token != _token || !_direct) return; + _nativeStarted = true; + _setState(PlayerState.playing); + usbAudioStatus.value = _preparedStatus; + _timer?.cancel(); + _timer = Timer.periodic(const Duration(milliseconds: 200), (_) async { + if (_polling || _disposed) return; + _polling = true; + final token = _token; + try { + final position = await getCurrentPosition(); + if (!_disposed && _direct && token == _token && position != null) { + _positions.add(position); + } + } on PlatformException { + // Route failures arrive through the native event stream. + } finally { + _polling = false; + } + }); + } + + Future pause() async { + if (!_direct) return _ordinary.pause(); + final token = _token; + _timer?.cancel(); + await _channel.invokeMethod('pause'); + if (token == _token && _direct) _setState(PlayerState.paused); + } + + Future seek(Duration position) async { + if (!_direct) return _ordinary.seek(position); + final token = _token; + await _channel.invokeMethod('seek', { + 'position': position.inMilliseconds, + }); + if (!_disposed && token == _token && _direct) _positions.add(position); + } + + Future getCurrentPosition() async { + if (!_direct) return _ordinary.getCurrentPosition(); + final milliseconds = await _channel.invokeMethod('position'); + return milliseconds == null ? null : Duration(milliseconds: milliseconds); + } + + Future getDuration() => + _direct ? Future.value(_duration) : _ordinary.getDuration(); + + Future stop() async { + _timer?.cancel(); + if (_direct) { + _token = ++_nextToken; + await _channel.invokeMethod('stop'); + _setState(PlayerState.stopped); + _direct = false; + usbAudioStatus.value = const UsbAudioStatus(); + } + await _ordinary.stop(); + } + + Future dispose() async { + await stop(); + _disposed = true; + await _nativeSubscription?.cancel(); + await _positionSubscription?.cancel(); + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + await _ordinary.dispose(); + await _states.close(); + await _durations.close(); + await _positions.close(); + await _completions.close(); + } +} diff --git a/lib/services/music_player_automix.dart b/lib/services/music_player_automix.dart index c1bcf56b..8b535623 100644 --- a/lib/services/music_player_automix.dart +++ b/lib/services/music_player_automix.dart @@ -9,8 +9,8 @@ class _MusicAutoMix { final AutoMixAnalyzer analyzer; final _preparationPins = >{}; final _mixPins = {}; - AudioPlayer? _prepared; - AudioPlayer? _outgoing; + MusicPlaybackDeck? _prepared; + MusicPlaybackDeck? _outgoing; AutoMixPlan? _plan; PlayableMedia? _next; String? _nextPath; @@ -37,6 +37,8 @@ class _MusicAutoMix { bool get _canMix => _autoMixEnabled && + !_usbBitPerfectEnabled && + !handler._player.isDirect && !handler._disposed && handler._sourceReady && !handler._userPaused && @@ -91,7 +93,7 @@ class _MusicAutoMix { : -1; final pins = {}; _preparationPins[generation] = pins; - AudioPlayer? deck; + MusicPlaybackDeck? deck; try { if (nextIndex < 0 || nextIndex == index || index < 0) return; final current = handler._media[index]; @@ -102,7 +104,7 @@ class _MusicAutoMix { final nextPath = await handler._resolveSource(next); if (nextPath == null || !_current(generation)) return; pins.add(nextPath); - deck = AudioPlayer( + deck = MusicPlaybackDeck( playerId: 'music-mix-$generation-${DateTime.now().microsecondsSinceEpoch}', ); diff --git a/lib/services/music_player_service.dart b/lib/services/music_player_service.dart index 9ba23813..23101dda 100644 --- a/lib/services/music_player_service.dart +++ b/lib/services/music_player_service.dart @@ -16,6 +16,7 @@ import 'package:spotiflac_android/services/sqlite_helpers.dart' show normalizeLookupText; import 'package:spotiflac_android/services/platform_bridge.dart'; import 'package:spotiflac_android/services/playback_normalization.dart'; +import 'package:spotiflac_android/services/music_playback_deck.dart'; import 'package:spotiflac_android/services/automix_analysis.dart'; import 'package:spotiflac_android/services/automix_analyzer.dart'; import 'package:spotiflac_android/utils/int_utils.dart'; @@ -42,6 +43,7 @@ void updateMusicPlayerStrings({ bool _playbackNormalizationEnabled = false; bool _autoMixEnabled = false; +bool _usbBitPerfectEnabled = false; MusicPlayerHandler? _activeMusicPlayerHandler; PlaybackNotification _notificationPresentation = const PlaybackNotification(); Future Function(MediaItem)? _toggleNotificationFavorite; @@ -99,6 +101,14 @@ void setAutoMixEnabled(bool enabled) { if (handler != null) unawaited(handler._autoMix.cancel()); } +void setUsbBitPerfectEnabled(bool enabled) { + if (_usbBitPerfectEnabled == enabled) return; + _usbBitPerfectEnabled = enabled; + final handler = _activeMusicPlayerHandler; + if (handler != null) unawaited(handler._autoMix.cancel()); + // Apply at the next source boundary; never raise a playing track's volume. +} + /// Refreshes gain tags after a successful file update, including SAF copies. void refreshPlaybackNormalization(String source) { final handler = _activeMusicPlayerHandler; @@ -352,13 +362,13 @@ Duration normalizedPlaybackResumePosition( class MusicPlayerHandler extends BaseAudioHandler with QueueHandler, SeekHandler { - AudioPlayer _player = AudioPlayer(playerId: 'music-player'); + MusicPlaybackDeck _player = MusicPlaybackDeck(playerId: 'music-player'); late final _MusicAutoMix _autoMix; late final _MusicAutoplay _autoplay; bool _completing = false; double _normalizationVolume = 1; final _playerSubscriptions = - >>{}; + >>{}; AudioSession? _audioSession; final List _media = []; final List _queueItems = []; @@ -433,7 +443,7 @@ class MusicPlayerHandler extends BaseAudioHandler _listenToPlayer(_player); } - void _listenToPlayer(AudioPlayer player) { + void _listenToPlayer(MusicPlaybackDeck player) { // Position updates must continue with the display asleep. UI progress is // interpolated between updates; querying native playback every frame is // unnecessary and would stop preparing AutoMix when there are no frames. @@ -444,6 +454,10 @@ class MusicPlayerHandler extends BaseAudioHandler _playerSubscriptions[player] = [ player.onPlayerStateChanged.listen((state) { if (!identical(player, _player)) return; + if (state == PlayerState.paused && player.needsSourceReload) { + _userPaused = true; + _pausedByInterruption = false; + } if (_switchingGeneration != 0 && (state == PlayerState.stopped || state == PlayerState.completed || @@ -474,7 +488,7 @@ class MusicPlayerHandler extends BaseAudioHandler ]; } - Future _disposeDeck(AudioPlayer player) async { + Future _disposeDeck(MusicPlaybackDeck player) async { final subscriptions = _playerSubscriptions.remove(player); if (subscriptions == null) return; for (final subscription in subscriptions) { @@ -764,7 +778,11 @@ class MusicPlayerHandler extends BaseAudioHandler String? cacheKey, String? displayName, }) async { - if (!_playbackNormalizationEnabled) return 1.0; + if (!_playbackNormalizationEnabled || + _usbBitPerfectEnabled || + _player.isDirect) { + return 1.0; + } return _normalizationCache.volumeFor( path, cacheKey: cacheKey, @@ -1347,7 +1365,10 @@ class MusicPlayerHandler extends BaseAudioHandler // AudioPlayer.play combines prepare/seek/resume without a cancellation // boundary. A late prepare from an old request could resume the wrong // source. Serialize preparation and recheck before making it audible. - await _player.setSource(DeviceFileSource(path)); + await _player.setSource( + DeviceFileSource(path), + preferBitPerfect: _usbBitPerfectEnabled, + ); if (!_isCurrentPlayRequest(generation, media)) return; if (position != null) { await _player.seek(position); @@ -1494,7 +1515,7 @@ class MusicPlayerHandler extends BaseAudioHandler mediaItem.add(media.toMediaItem(resolvedSource: resolved)); } _broadcastPosition(effectiveStartPosition, force: true); - _broadcastState(playerState: PlayerState.playing); + _broadcastState(); _lastPeriodicPersistAt = DateTime.now(); unawaited(_persistSession(position: effectiveStartPosition)); // Some files do not emit onDurationChanged reliably (stuck at 0:00); @@ -1613,6 +1634,15 @@ class MusicPlayerHandler extends BaseAudioHandler _pausedByInterruption = false; _interruptionActive = false; _userPaused = false; + if (_player.needsSourceReload) { + final generation = _playRequestGeneration; + final index = _index; + final position = + await _player.getCurrentPosition() ?? playbackState.value.position; + if (generation != _playRequestGeneration || _disposed) return; + await _playIndex(index, recordHistory: false, startPosition: position); + return; + } if ((!_sourceReady || _player.state == PlayerState.stopped || _player.state == PlayerState.completed) && @@ -1627,7 +1657,13 @@ class MusicPlayerHandler extends BaseAudioHandler } await _activateAudioSession(); await _claimHardwareMediaButtons(); - await _player.resume(); + try { + await _player.resume(); + } on PlatformException { + if (!_player.needsSourceReload) rethrow; + _broadcastState(playerState: PlayerState.paused); + return; + } _broadcastState(playerState: PlayerState.playing); } diff --git a/test/music_player_automix_test.dart b/test/music_player_automix_test.dart index d6ba8684..6dab99b9 100644 --- a/test/music_player_automix_test.dart +++ b/test/music_player_automix_test.dart @@ -1,12 +1,15 @@ import 'dart:async'; import 'package:audio_service/audio_service.dart'; +import 'package:audioplayers/audioplayers.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:spotiflac_android/services/automix_analysis.dart'; import 'package:spotiflac_android/services/automix_analyzer.dart'; import 'package:spotiflac_android/services/music_player_service.dart'; +import 'package:spotiflac_android/services/music_playback_deck.dart'; +import 'package:spotiflac_android/models/settings.dart'; import 'package:spotiflac_android/services/playback_notification.dart'; class _Analyzer extends AutoMixAnalyzer { @@ -144,6 +147,7 @@ void main() { setUp(() { setAutoMixEnabled(false); + setUsbBitPerfectEnabled(false); setAutoplayEnabled(false); native = _AudioNative()..install(); analyzer = _Analyzer(); @@ -162,12 +166,228 @@ void main() { ); analyzer.pending?.complete(null); setAutoMixEnabled(false); + setUsbBitPerfectEnabled(false); setAutoplayEnabled(false); await Future.delayed(const Duration(milliseconds: 30)); expect(native.live, isEmpty); expect(native.playing, isEmpty); }); + test( + 'USB preference is opt-in and persists with existing DSP preferences', + () { + const defaults = AppSettings(); + expect(defaults.usbBitPerfect, isFalse); + final selected = defaults.copyWith( + usbBitPerfect: true, + autoMix: true, + playbackNormalization: true, + ); + final restored = AppSettings.fromJson(selected.toJson()); + expect(restored.usbBitPerfect, isTrue); + expect(restored.autoMix, isTrue); + expect(restored.playbackNormalization, isTrue); + }, + ); + + test( + 'USB mode prevents a second DSP deck even when AutoMix is enabled', + () async { + setAutoMixEnabled(true); + setUsbBitPerfectEnabled(true); + await handler.setQueueAndPlay(_tracks); + await Future.delayed(const Duration(milliseconds: 450)); + expect(native.live, {'music-player'}); + expect(native.lastVolume('music-player'), 1); + expect(analyzer.calls, isEmpty); + }, + ); + + group('USB playback transport', () { + const methods = MethodChannel('com.zarz.spotiflac/usb_pcm'); + const events = MethodChannel('com.zarz.spotiflac/usb_pcm/events'); + late MusicPlaybackDeck deck; + late List usbCalls; + late int token; + late String? fallbackReason; + late bool failResume; + Completer? pauseReply; + var position = 0; + + Future event(String name, {int? withToken}) => + native.messenger.handlePlatformMessage( + events.name, + const StandardMethodCodec().encodeSuccessEnvelope({ + 'event': name, + 'token': withToken ?? token, + }), + (_) {}, + ); + + setUp(() { + usbCalls = []; + token = 0; + fallbackReason = null; + failResume = false; + pauseReply = null; + position = 0; + native.messenger.setMockMethodCallHandler(events, (_) async => null); + native.messenger.setMockMethodCallHandler(methods, (call) async { + usbCalls.add(call.method); + switch (call.method) { + case 'prepare': + token = (call.arguments as Map)['token'] as int; + if (fallbackReason != null) return {'reason': fallbackReason}; + return { + 'ready': true, + 'duration': 60000, + 'device': 'Test DAC', + 'sampleRate': 96000, + 'bitDepth': 24, + }; + case 'position': + return position; + case 'seek': + position = (call.arguments as Map)['position'] as int; + case 'resume': + if (failResume) throw PlatformException(code: 'route_changed'); + case 'pause': + await pauseReply?.future; + } + return null; + }); + deck = MusicPlaybackDeck(playerId: 'usb-test', nativeAvailable: true); + }); + + tearDown(() async { + await deck.dispose(); + native.messenger.setMockMethodCallHandler(methods, null); + native.messenger.setMockMethodCallHandler(events, null); + }); + + test( + 'verified route plays, pauses and seeks without software gain or rate changes', + () async { + await deck.setSource( + DeviceFileSource('/one.flac'), + preferBitPerfect: true, + ); + expect(usbAudioStatus.value.reason, 'ready'); + await deck.resume(); + await deck.setVolume(0.4); + await deck.setPlaybackRate(1.1); + expect(native.playing, isEmpty); + expect(usbAudioStatus.value.reason, 'active'); + expect(usbAudioStatus.value.sampleRate, 96000); + await deck.seek(const Duration(seconds: 21)); + expect(await deck.getCurrentPosition(), const Duration(seconds: 21)); + await deck.pause(); + expect(deck.state, PlayerState.paused); + expect(usbCalls, ['prepare', 'resume', 'seek', 'position', 'pause']); + expect( + native.calls.where( + (c) => + c.$1 == 'usb-test' && + ['setVolume', 'setPlaybackRate'].contains(c.$2), + ), + isEmpty, + ); + }, + ); + + test('late pause reply does not pause a newly prepared source', () async { + await deck.setSource( + DeviceFileSource('/one.flac'), + preferBitPerfect: true, + ); + await deck.resume(); + pauseReply = Completer(); + final pausing = deck.pause(); + await _until(() => usbCalls.contains('pause')); + await deck.setSource( + DeviceFileSource('/two.flac'), + preferBitPerfect: true, + ); + await deck.resume(); + pauseReply!.complete(); + await pausing; + expect(deck.state, PlayerState.playing); + expect(usbAudioStatus.value.reason, 'active'); + }); + + test('missing support falls back without reporting bit-perfect', () async { + fallbackReason = 'no_usb'; + await deck.setSource( + DeviceFileSource('/one.flac'), + preferBitPerfect: true, + ); + await deck.resume(); + expect(deck.isDirect, isFalse); + expect(native.playing, {'usb-test'}); + expect(usbAudioStatus.value.reason, 'no_usb'); + }); + + test('unverified route falls back before starting USB audio', () async { + failResume = true; + await deck.setSource( + DeviceFileSource('/one.flac'), + preferBitPerfect: true, + ); + await deck.resume(); + expect(deck.isDirect, isFalse); + expect(native.sources['usb-test'], '/one.flac'); + expect(native.playing, {'usb-test'}); + expect(usbAudioStatus.value.reason, 'unsupported'); + }); + + test( + 'route loss pauses and never starts the speaker automatically', + () async { + await deck.setSource( + DeviceFileSource('/one.flac'), + preferBitPerfect: true, + ); + await deck.resume(); + await event('paused'); + await _until(() => deck.state == PlayerState.paused); + expect(native.playing, isEmpty); + expect(usbAudioStatus.value.reason, 'route_changed'); + position = 19000; + expect(deck.needsSourceReload, isTrue); + await expectLater(deck.resume(), throwsA(isA())); + expect(native.playing, isEmpty); + expect(await deck.getCurrentPosition(), const Duration(seconds: 19)); + }, + ); + + test( + 'events from an old song cannot pause or complete the new song', + () async { + await deck.setSource( + DeviceFileSource('/one.flac'), + preferBitPerfect: true, + ); + final oldToken = token; + await deck.setSource( + DeviceFileSource('/two.flac'), + preferBitPerfect: true, + ); + await deck.resume(); + var completed = 0; + final subscription = deck.onPlayerComplete.listen((_) => completed++); + await event('complete', withToken: oldToken); + await event('paused', withToken: oldToken); + await Future.delayed(const Duration(milliseconds: 20)); + expect(deck.state, PlayerState.playing); + expect(completed, 0); + await event('complete'); + await _until(() => completed == 1); + expect(deck.state, PlayerState.completed); + await subscription.cancel(); + }, + ); + }); + test( 'Autoplay fills a single-track queue and follows its visible order', () async {