feat(player): add verified USB bit-perfect PCM output

This commit is contained in:
zarzet
2026-09-27 12:22:42 +07:00
parent 5f0463a7a4
commit 4e464b5b6b
17 changed files with 1262 additions and 13 deletions
@@ -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<String, Any>? = 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() }
}
}
}
+1
View File
@@ -23,6 +23,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:label="SpotiFLAC Mobile"
@@ -0,0 +1,61 @@
package com.zarz.spotiflac
import java.nio.ByteBuffer
import java.nio.ByteOrder
/** Lossless PCM representation changes only: no gain, dithering or resampling. */
internal object BitPerfectPcm {
fun outputBits(sourceBits: Int, supported: List<Int>): 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<Int, Int, Int>? {
if (header.size < 42 || header.take(4) != listOf<Byte>(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)
}
}
@@ -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") {
@@ -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<String, Any>) -> 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<Int>("token") ?: 0
try {
prepare(requireNotNull(call.argument<String>("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<Number>("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<String, Any> {
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()
}
}
}
@@ -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)))
}
}
+6
View File
@@ -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.",
+6
View File
@@ -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.",
+4
View File
@@ -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,
+2
View File
@@ -23,6 +23,7 @@ AppSettings _$AppSettingsFromJson(Map<String, dynamic> 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<String, dynamic> _$AppSettingsToJson(
'embedReplayGain': instance.embedReplayGain,
'playbackNormalization': instance.playbackNormalization,
'autoMix': instance.autoMix,
'usbBitPerfect': instance.usbBitPerfect,
'autoplay': instance.autoplay,
'isFirstLaunch': instance.isFirstLaunch,
'checkForUpdates': instance.checkForUpdates,
+5
View File
@@ -561,6 +561,11 @@ class SettingsNotifier extends Notifier<AppSettings> {
_saveSettings();
}
void setUsbBitPerfect(bool enabled) {
state = state.copyWith(usbBitPerfect: enabled);
_saveSettings();
}
void setEmbedMetadata(bool enabled) {
state = state.copyWith(embedMetadata: enabled);
_saveSettings();
+4
View File
@@ -92,6 +92,7 @@ class _MainShellState extends ConsumerState<MainShell>
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<MainShell>
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),
@@ -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<LibrarySettingsPage> {
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<LibrarySettingsPage> {
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<UsbAudioStatus>(
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,
),
),
],
),
),
+277
View File
@@ -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<UsbAudioStatus>(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<dynamic>? _nativeEvents;
static int _nextToken = 0;
final AudioPlayer _ordinary;
final bool _nativeAvailable;
final _states = StreamController<PlayerState>.broadcast();
final _durations = StreamController<Duration>.broadcast();
final _positions = StreamController<Duration>.broadcast();
final _completions = StreamController<void>.broadcast();
final _subscriptions = <StreamSubscription<dynamic>>[];
StreamSubscription<dynamic>? _nativeSubscription;
StreamSubscription<Duration>? _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<PlayerState> get onPlayerStateChanged => _states.stream;
Stream<Duration> get onDurationChanged => _durations.stream;
Stream<Duration> get onPositionChanged => _positions.stream;
Stream<void> 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<void> setReleaseMode(ReleaseMode mode) =>
_ordinary.setReleaseMode(mode);
Future<void> setAudioContext(AudioContext context) =>
_ordinary.setAudioContext(context);
Future<void> setVolume(double volume) async {
if (!_direct) await _ordinary.setVolume(volume);
}
Future<void> setPlaybackRate(double rate) async {
if (!_direct) await _ordinary.setPlaybackRate(rate);
}
Future<void> 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<String, dynamic>(
'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<void> 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<void>('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<void> pause() async {
if (!_direct) return _ordinary.pause();
final token = _token;
_timer?.cancel();
await _channel.invokeMethod<void>('pause');
if (token == _token && _direct) _setState(PlayerState.paused);
}
Future<void> seek(Duration position) async {
if (!_direct) return _ordinary.seek(position);
final token = _token;
await _channel.invokeMethod<void>('seek', {
'position': position.inMilliseconds,
});
if (!_disposed && token == _token && _direct) _positions.add(position);
}
Future<Duration?> getCurrentPosition() async {
if (!_direct) return _ordinary.getCurrentPosition();
final milliseconds = await _channel.invokeMethod<int>('position');
return milliseconds == null ? null : Duration(milliseconds: milliseconds);
}
Future<Duration?> getDuration() =>
_direct ? Future.value(_duration) : _ordinary.getDuration();
Future<void> stop() async {
_timer?.cancel();
if (_direct) {
_token = ++_nextToken;
await _channel.invokeMethod<void>('stop');
_setState(PlayerState.stopped);
_direct = false;
usbAudioStatus.value = const UsbAudioStatus();
}
await _ordinary.stop();
}
Future<void> 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();
}
}
+6 -4
View File
@@ -9,8 +9,8 @@ class _MusicAutoMix {
final AutoMixAnalyzer analyzer;
final _preparationPins = <int, Set<String>>{};
final _mixPins = <String>{};
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 = <String>{};
_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}',
);
+44 -8
View File
@@ -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<void> 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 =
<AudioPlayer, List<StreamSubscription<dynamic>>>{};
<MusicPlaybackDeck, List<StreamSubscription<dynamic>>>{};
AudioSession? _audioSession;
final List<PlayableMedia> _media = [];
final List<MediaItem> _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<void> _disposeDeck(AudioPlayer player) async {
Future<void> _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);
}
+220
View File
@@ -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<void>.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<void>.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<String> usbCalls;
late int token;
late String? fallbackReason;
late bool failResume;
Completer<void>? pauseReply;
var position = 0;
Future<void> 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<void>();
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<PlatformException>()));
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<void>.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 {