mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
feat(player): add verified USB bit-perfect PCM output
This commit is contained in:
1 parent
5f0463a7a4
commit
4e464b5b6b
17 files changed
+1262
-13
No files matched your search
@@ -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() }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user