feat(player): add direct USB audio and DSD transport

This commit is contained in:
zarzet committed 2026-09-27 15:26:10 +07:00
1 parent 4e464b5b6b
commit 608675a255
31 files changed
+2875 -12

No files matched your search

@@ -2,6 +2,11 @@ package com.zarz.spotiflac
import android.media.AudioDeviceInfo
import android.media.AudioManager
import android.os.ParcelFileDescriptor
import com.spotiflac.backend.UsbDirectOutput
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import io.flutter.embedding.engine.FlutterEngine
@@ -12,12 +17,66 @@ import java.util.concurrent.TimeUnit
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Assert.assertThrows
import org.junit.Assume.assumeTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class UsbBitPerfectPluginTest {
@Test
fun directNativeBridgeRejectsNonUsbFdWithoutClosingItsOwner() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val file = File.createTempFile("usb-bridge-", ".bin", context.cacheDir)
try {
file.writeBytes(ByteArray(64))
ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
val descriptors = byteArrayOf(
9, 4, 0, 0, 0, 1, 1, 0, 0,
9, 4, 1, 1, 1, 1, 2, 0, 0,
7, 0x24, 1, 1, 1, 1, 0,
11, 0x24, 2, 1, 2, 2, 16, 1, 0x80.toByte(), 0xbb.toByte(), 0,
9, 5, 1, 0x0d, 0xc0.toByte(), 0, 1, 0, 0,
)
assertThrows(Exception::class.java) {
UsbDirectOutput.open(fd.fd, descriptors, 48000u, 2u, 16u, false, false)
}
assertEquals(64L, fd.statSize)
}
} finally { file.delete() }
}
@Test
fun pcmSourceRetainsSafDescriptorAndSeeksWithoutChangingSamples() {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val file = File.createTempFile("usb-pcm-", ".wav", context.cacheDir)
val audioSize = 4800 * 4
val wav = ByteBuffer.allocate(44 + audioSize).order(ByteOrder.LITTLE_ENDIAN)
wav.put("RIFF".toByteArray()).putInt(36 + audioSize).put("WAVEfmt ".toByteArray())
.putInt(16).putShort(1).putShort(2).putInt(48000).putInt(192000).putShort(4).putShort(16)
.put("data".toByteArray()).putInt(audioSize)
repeat(4800) { wav.putShort(1234).putShort(-1234) }
try {
file.writeBytes(wav.array())
val source = ParcelFileDescriptor.open(file, ParcelFileDescriptor.MODE_READ_ONLY).use { fd ->
UsbPcmSource("/proc/self/fd/${fd.fd}")
}
source.use {
assertEquals(48000, it.rate)
assertEquals(16, it.bits)
for (target in listOf(0L, 50000L)) {
it.seek(target)
val decoded = requireNotNull(it.read(32)).order(ByteOrder.LITTLE_ENDIAN)
assertTrue(decoded.remaining() > 0)
while (decoded.hasRemaining()) {
assertEquals(1234 shl 16, decoded.int)
assertEquals(-1234 shl 16, decoded.int)
}
}
}
} finally { file.delete() }
}
@Test
fun unsupportedRouteReturnsFallbackAndEngineDetachesCleanly() {
val instrumentation = InstrumentationRegistry.getInstrumentation()
@@ -56,6 +115,10 @@ class UsbBitPerfectPluginTest {
assertEquals(if (android.os.Build.VERSION.SDK_INT >= 34) "no_usb" else "android_version", result["reason"])
assertNull(result["ready"])
invoke("stop")
val direct = invoke("prepare", mapOf("path" to "/unopened.dsf", "token" to 2, "direct" to true)) as Map<*, *>
assertEquals("no_usb", direct["reason"])
assertNull(direct["ready"])
invoke("stop")
} finally {
instrumentation.runOnMainSync { engine.destroy() }
}
+2
View File
@@ -2,6 +2,8 @@
xmlns:tools="http://schemas.android.com/tools"
package="com.zarz.spotiflac">
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
<!-- Permissions -->
<!-- Playback never records audio. -->
<uses-permission android:name="android.permission.RECORD_AUDIO" tools:node="remove" />
@@ -0,0 +1,185 @@
package com.zarz.spotiflac
import java.io.Closeable
import java.io.RandomAccessFile
import java.nio.ByteBuffer
import java.nio.ByteOrder
/** Bounded DSF/uncompressed DSDIFF reader. Samples are normalized to MSB first. */
internal class DsdFile private constructor(private val file: RandomAccessFile) : Closeable {
var rate = 0
private set
var channels = 0
private set
private var dataOffset = 0L
private var bytesPerChannel = 0L
private var blockSize = 0
private var reverseBits = false
private var cursor = 0L
private var cachedBlock = -1L
private var block = ByteArray(0)
val durationUs: Long get() = bytesPerChannel * 8_000_000 / rate
companion object {
fun open(path: String): DsdFile? {
val file = RandomAccessFile(path, "r")
try {
val magic = ByteArray(4)
if (file.read(magic) != 4 || (!magic.contentEquals("DSD ".toByteArray()) &&
!magic.contentEquals("FRM8".toByteArray()))) { file.close(); return null }
file.seek(0)
return DsdFile(file).apply {
if (String(magic, Charsets.US_ASCII) == "DSD ") readDsf() else readDff()
require(channels in 1..2 && rate in listOf(2822400, 5644800, 11289600, 22579200)) {
"Unsupported DSD channels or rate"
}
require(bytesPerChannel > 0 && dataOffset > 0) { "Missing DSD audio" }
}
} catch (error: Exception) { file.close(); throw error }
}
}
private fun read(length: Int): ByteArray = ByteArray(length).also { file.readFully(it) }
private fun le64(): Long = ByteBuffer.wrap(read(8)).order(ByteOrder.LITTLE_ENDIAN).long
private fun be64(): Long = ByteBuffer.wrap(read(8)).order(ByteOrder.BIG_ENDIAN).long
private fun id(): String = String(read(4), Charsets.US_ASCII)
private fun end(start: Long, size: Long, limit: Long): Long {
require(size >= 0 && start <= limit && size <= limit - start) { "Truncated DSD chunk" }
return start + size
}
private fun readDsf() {
require(id() == "DSD " && le64() == 28L)
val length = le64()
le64() // Optional ID3 pointer; never needed to play samples.
require(length in 28..file.length())
while (file.filePointer + 12 <= length) {
val name = id()
val size = le64()
require(size >= 12)
val next = end(file.filePointer, size - 12, length)
when (name) {
"fmt " -> {
require(size >= 52)
val f = ByteBuffer.wrap(read(40)).order(ByteOrder.LITTLE_ENDIAN)
require(f.int == 1 && f.int == 0) { "Unsupported DSF format" }
f.int // Channel type.
channels = f.int
rate = f.int
val order = f.int
require(order == 1 || order == 8)
reverseBits = order == 1
val samples = f.long
require(samples > 0 && samples <= Long.MAX_VALUE - 7)
bytesPerChannel = (samples + 7) / 8
blockSize = f.int
require(blockSize in 1..65536 && channels in 1..2)
}
"data" -> {
require(blockSize > 0 && bytesPerChannel > 0)
val blocks = (bytesPerChannel + blockSize - 1) / blockSize
require(blocks <= (size - 12) / blockSize / channels) { "Truncated DSF data" }
dataOffset = file.filePointer
block = ByteArray(blockSize * channels)
return
}
}
file.seek(next)
}
}
private fun readDff() {
require(id() == "FRM8")
val limit = end(12, be64(), file.length())
require(id() == "DSD ")
var compression = ""
while (file.filePointer + 12 <= limit) {
val name = id()
val size = be64()
val next = end(file.filePointer, size, limit)
when (name) {
"PROP" -> {
require(size >= 4 && id() == "SND ")
while (file.filePointer + 12 <= next) {
val field = id()
val fieldSize = be64()
val fieldEnd = end(file.filePointer, fieldSize, next)
when (field) {
"FS " -> { require(fieldSize >= 4); rate = file.readInt() }
"CHNL" -> { require(fieldSize >= 2); channels = file.readUnsignedShort() }
"CMPR" -> { require(fieldSize >= 4); compression = id() }
}
file.seek(fieldEnd + (fieldSize and 1))
}
}
"DSD " -> {
require(channels in 1..2 && compression == "DSD ") { "Compressed DST is not supported" }
require(size % channels == 0L)
dataOffset = file.filePointer
bytesPerChannel = size / channels
return
}
"DST " -> error("Compressed DST is not supported")
}
file.seek(next + (size and 1))
}
}
fun seek(positionUs: Long, encoding: String): Long {
val group = if (encoding == "dop") 2 else 4
cursor = (positionUs.coerceIn(0, durationUs) * rate / 8_000_000 / group * group)
.coerceAtMost(bytesPerChannel)
return cursor * 8_000_000 / rate
}
fun readFrames(encoding: String, subslot: Int, maxFrames: Int = 4096): ByteArray? {
if (cursor >= bytesPerChannel) return null
require(encoding in listOf("dop", "dsd_be", "dsd_le"))
require(subslot in 3..4)
val group = if (encoding == "dop") 2 else 4
val frames = minOf(maxFrames.toLong(), (bytesPerChannel - cursor + group - 1) / group).toInt()
val output = ByteArray(frames * channels * subslot)
// DFF is interleaved; read a bounded batch instead of seeking per byte.
val interleaved = if (blockSize == 0) {
file.seek(dataOffset + cursor * channels)
read(minOf(frames.toLong() * group, bytesPerChannel - cursor).toInt() * channels)
} else null
var out = 0
val dsd = ByteArray(group)
for (frame in 0 until frames) {
for (channel in 0 until channels) {
for (index in 0 until group) {
val sample = cursor + frame * group + index
var value = 0x69
if (sample < bytesPerChannel) {
value = if (interleaved != null) {
interleaved[(frame * group + index) * channels + channel].toInt() and 255
} else {
val blockIndex = sample / blockSize
if (cachedBlock != blockIndex) {
file.seek(dataOffset + blockIndex * block.size)
file.readFully(block)
cachedBlock = blockIndex
}
block[channel * blockSize + (sample % blockSize).toInt()].toInt() and 255
}
if (reverseBits) value = Integer.reverse(value) ushr 24
}
dsd[index] = value.toByte()
}
if (encoding == "dop") {
if (subslot == 4) output[out++] = 0
output[out++] = dsd[1]
output[out++] = dsd[0]
output[out++] = 5 // Transport assigns continuous 05/FA markers.
} else {
for (index in 0 until 4) output[out++] = dsd[if (encoding == "dsd_le") 3 - index else index]
}
}
}
cursor = minOf(bytesPerChannel, cursor + frames * group)
return output
}
override fun close() { file.close() }
}
@@ -100,6 +100,8 @@ internal fun MainActivity.extFromFileName(name: String): String {
name.endsWith(".aiff") -> ".aiff"
name.endsWith(".aifc") -> ".aifc"
name.endsWith(".aif") -> ".aif"
name.endsWith(".dsf") -> ".dsf"
name.endsWith(".dff") -> ".dff"
else -> ""
}
}
@@ -293,6 +295,21 @@ internal fun MainActivity.readAudioMetadataFromUri(
uri, displayNameHint, fallbackExt,
acceptDirect = { !it.optBoolean("metadataFromFilename", false) },
) { path, name ->
if (name.endsWith(".dsf", true) || name.endsWith(".dff", true)) {
DsdFile.open(path)?.use { source ->
return@readMetadataFromUri JSONObject().apply {
put("trackName", name.substringBeforeLast('.'))
put("artistName", "Unknown Artist")
put("albumName", "Unknown Album")
put("filePath", uri.toString())
put("format", name.substringAfterLast('.').lowercase(Locale.ROOT))
put("sampleRate", source.rate)
put("bitDepth", 1)
put("duration", source.durationUs / 1000000)
put("hasLyrics", false)
}
}
}
val obj = JSONObject(coreBackend.readAudioMetadata(
path, name, coverCacheKey,
))
@@ -549,7 +549,7 @@ internal fun MainActivity.extractCueAudioFileName(cueTempPath: String): String?
// CUE files are handled separately.
private val libraryScanAudioExtensions = setOf(
".flac", ".m4a", ".mp4", ".aac", ".mp3", ".opus", ".ogg",
".ape", ".wv", ".mpc", ".wav", ".aiff", ".aif"
".ape", ".wv", ".mpc", ".wav", ".aiff", ".aif", ".dsf", ".dff"
)
internal fun MainActivity.getSafChildFileLookup(
@@ -29,6 +29,9 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even
private lateinit var methods: MethodChannel
private lateinit var events: EventChannel
private var engine: UsbPcmPlayback? = null
private var direct: UsbDirectPlayback? = null
private var useDirect = false
private lateinit var appContext: Context
private var sink: EventChannel.EventSink? = null
private val main = Handler(Looper.getMainLooper())
@@ -37,6 +40,7 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even
events = EventChannel(binding.binaryMessenger, "com.zarz.spotiflac/usb_pcm/events")
methods.setMethodCallHandler(this)
events.setStreamHandler(this)
appContext = binding.applicationContext
if (Build.VERSION.SDK_INT >= 34) {
engine = UsbPcmPlayback(binding.applicationContext) { event ->
main.post { sink?.success(event) }
@@ -45,6 +49,25 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
if (call.method == "prepare") {
if (useDirect) direct?.command(MethodCall("stop", null)) { _, _ -> }
else if (Build.VERSION.SDK_INT >= 34) engine?.command(MethodCall("stop", null)) { _, _ -> }
useDirect = call.argument<Boolean>("direct") == true
if (useDirect && direct == null) {
direct = UsbDirectPlayback(appContext) { event ->
main.post { sink?.success(event) }
}
}
}
if (useDirect) {
direct?.command(call) { value, error ->
main.post {
if (error == null) result.success(value)
else result.error("usb_direct", error, null)
}
}
return
}
val playback = engine
if (Build.VERSION.SDK_INT < 34 || playback == null) {
result.success(if (call.method == "prepare") mapOf("reason" to "android_version") else null)
@@ -65,6 +88,8 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even
methods.setMethodCallHandler(null)
events.setStreamHandler(null)
sink = null
direct?.dispose()
direct = null
if (Build.VERSION.SDK_INT >= 34) engine?.dispose()
engine = null
}
@@ -0,0 +1,296 @@
package com.zarz.spotiflac
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.hardware.usb.UsbDevice
import android.hardware.usb.UsbDeviceConnection
import android.hardware.usb.UsbManager
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import android.os.PowerManager
import com.spotiflac.backend.UsbDirectOutput
import com.spotiflac.backend.UsbOutputFormat
import io.flutter.plugin.common.MethodCall
import java.nio.ByteBuffer
/** Android owns permission/connection; the Rust worker owns isochronous I/O. */
internal class UsbDirectPlayback(private val context: Context, private val emit: (Map<String, Any>) -> Unit) {
private val manager = context.getSystemService(UsbManager::class.java)
private val main = Handler(Looper.getMainLooper())
private val thread = HandlerThread("SpotiFLAC-USB-source", android.os.Process.THREAD_PRIORITY_AUDIO).apply { start() }
private val worker = Handler(thread.looper)
private val wakeLock = context.getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SpotiFLAC:UsbDirect")
private val permissionAction = "${context.packageName}.USB_AUDIO_PERMISSION"
private data class Permission(val device: UsbDevice, val call: MethodCall, val revision: Int, val done: (Any?, String?) -> Unit)
private var permission: Permission? = null
@Volatile private var revision = 0
private var output: UsbDirectOutput? = null
private var connection: UsbDeviceConnection? = null
@Volatile private var deviceId: Int? = null
private var format: UsbOutputFormat? = null
private var pcm: UsbPcmSource? = null
private var dsd: DsdFile? = null
private var pending: ByteBuffer? = null
private var playing = false
private var ended = false
private var failed = false
private var token = 0
private var baseUs = 0L
private var durationUs = 0L
private var writtenFrames = 0L
private var pausedPositionUs = 0L
private var needsSeek = false
private val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
@Suppress("DEPRECATION")
val device = intent.getParcelableExtra<UsbDevice>(UsbManager.EXTRA_DEVICE) ?: return
if (intent.action == UsbManager.ACTION_USB_DEVICE_DETACHED && device.deviceId == deviceId) {
worker.post { fail("USB disconnected") }
} else if (intent.action == permissionAction) {
val request = permission ?: return
if (request.device.deviceId != device.deviceId) return
if (intent.getIntExtra("revision", -1) != request.revision) return
permission = null
if (request.revision != revision) { request.done(mapOf("reason" to "cancelled"), null); return }
if (manager.hasPermission(device)) prepare(request)
else request.done(mapOf("reason" to "permission_denied"), null)
}
}
}
init {
val filter = IntentFilter(permissionAction).apply { addAction(UsbManager.ACTION_USB_DEVICE_DETACHED) }
if (Build.VERSION.SDK_INT >= 33) context.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
else { @Suppress("DEPRECATION") context.registerReceiver(receiver, filter) }
}
fun command(call: MethodCall, done: (Any?, String?) -> Unit) {
if (call.method == "prepare" || call.method == "stop") {
revision++
permission?.done?.invoke(mapOf("reason" to "cancelled"), null)
permission = null
}
if (call.method == "prepare") {
worker.post { closeSource() }
val device = manager.deviceList.values.sortedBy { it.deviceId }.firstOrNull { candidate ->
(0 until candidate.interfaceCount).any {
candidate.getInterface(it).interfaceClass == 1 && candidate.getInterface(it).interfaceSubclass == 2
}
}
if (device == null) {
worker.post { done(mapOf("reason" to "no_usb"), null) }
return
}
val request = Permission(device, call, revision, done)
if (manager.hasPermission(device)) prepare(request)
else {
permission = request
val flags = PendingIntent.FLAG_CANCEL_CURRENT or if (Build.VERSION.SDK_INT >= 31) PendingIntent.FLAG_MUTABLE else 0
val intent = PendingIntent.getBroadcast(context, revision, Intent(permissionAction).setPackage(context.packageName).putExtra("revision", revision), flags)
try { manager.requestPermission(device, intent) }
catch (error: Exception) { permission = null; done(mapOf("reason" to "permission_denied"), null) }
main.postDelayed({
if (permission === request) { permission = null; done(mapOf("reason" to "permission_denied"), null) }
}, 60000)
}
return
}
worker.post {
try {
val value: Any? = when (call.method) {
"resume" -> { resume(); null }
"pause" -> { pause(); null }
"seek" -> { seek((call.argument<Number>("position")?.toLong() ?: 0) * 1000); null }
"position" -> positionUs() / 1000
"stop" -> { closeSource(); null }
else -> error("Unknown USB command")
}
done(value, null)
} catch (error: Exception) { done(null, error.message ?: "USB output failed") }
}
}
private fun prepare(request: Permission) {
worker.post {
var isDsd = false
try {
if (request.revision != revision) { request.done(mapOf("reason" to "cancelled"), null); return@post }
closeSource()
token = request.call.argument<Int>("token") ?: 0
val path = requireNotNull(request.call.argument<String>("path"))
dsd = DsdFile.open(path)
isDsd = dsd != null
if (!isDsd) pcm = UsbPcmSource(path)
val rate = dsd?.rate ?: requireNotNull(pcm).rate
val channels = dsd?.channels ?: requireNotNull(pcm).channels
val bits = if (isDsd) 1 else requireNotNull(pcm).bits
durationUs = dsd?.durationUs ?: requireNotNull(pcm).durationUs
val usb = manager.openDevice(request.device) ?: error("Cannot open USB device")
connection = usb
deviceId = request.device.deviceId
val audio = UsbDirectOutput.open(usb.fileDescriptor, usb.rawDescriptors, rate.toUInt(), channels.toUByte(), bits.toUByte(), isDsd, request.call.argument<Boolean>("allowDop") == true)
output = audio
format = audio.format()
if (request.revision != revision) { closeSource(); request.done(mapOf("reason" to "cancelled"), null); return@post }
val deadline = android.os.SystemClock.elapsedRealtime() + 5000
while (pending == null && !ended) {
require(request.revision == revision) { "USB prepare cancelled" }
require(android.os.SystemClock.elapsedRealtime() < deadline) { "Decoder timed out" }
decode()
if (pending == null) Thread.sleep(2)
}
val f = requireNotNull(format)
request.done(mapOf("ready" to true, "device" to (request.device.productName ?: "USB audio"),
"sampleRate" to rate, "bitDepth" to bits, "duration" to durationUs / 1000,
"transport" to f.encoding, "carrierRate" to f.sampleRate.toLong(), "driver" to "usb_direct"), null)
} catch (error: Exception) {
android.util.Log.i("UsbDirect", "Direct output unavailable: ${error.message}")
closeSource()
request.done(mapOf("reason" to if (isDsd) "dsd_unsupported" else "unsupported", "fatal" to isDsd), null)
}
}
}
private fun decode() {
if (pending != null || ended) return
val f = requireNotNull(format)
val dsdFile = dsd
if (dsdFile != null) {
val data = dsdFile.readFrames(f.encoding, f.subslot.toInt())
if (data == null) ended = true else pending = ByteBuffer.wrap(data)
} else {
pending = requireNotNull(pcm).read(f.subslot.toInt() * 8)
ended = requireNotNull(pcm).ended
}
}
private fun resume() {
check(!failed) { "USB route changed; reopen track" }
val audio = requireNotNull(output)
if (needsSeek) resetSource(pausedPositionUs)
fill()
audio.start()
playing = true
if (!wakeLock.isHeld) wakeLock.acquire()
emit(mapOf("token" to token, "event" to "active"))
worker.removeCallbacks(pump)
worker.post(pump)
}
private fun fill() {
val f = requireNotNull(format)
val frameBytes = f.subslot.toInt() * f.channels.toInt()
repeat(8) {
decode()
val buffer = pending ?: return
val count = minOf(buffer.remaining(), 65536) / frameBytes * frameBytes
if (count == 0) { pending = null; return@repeat }
val data = ByteArray(count)
buffer.duplicate().get(data)
val written = requireNotNull(output).write(data).toInt()
buffer.position(buffer.position() + written)
writtenFrames += written / frameBytes
if (buffer.hasRemaining()) return
pending = null
}
}
private val pump = object : Runnable {
override fun run() {
if (!playing) return
try {
fill()
val frames = requireNotNull(output).frames().toLong()
if (ended && pending == null && frames >= writtenFrames) {
pause()
emit(mapOf("token" to token, "event" to "complete"))
} else worker.postDelayed(this, 5)
} catch (error: Exception) { fail(error.message ?: "USB transfer failed") }
}
}
private fun positionUs(): Long {
if (needsSeek) return pausedPositionUs
val f = format ?: return 0
val position = baseUs + (output?.frames()?.toLong() ?: 0) * 1000000 / f.sampleRate.toLong()
pausedPositionUs = if (durationUs > 0) position.coerceAtMost(durationUs) else position
return pausedPositionUs
}
private fun pause() {
if (output != null && !needsSeek) pausedPositionUs = runCatching { positionUs() }.getOrDefault(pausedPositionUs.coerceAtLeast(baseUs))
playing = false
worker.removeCallbacks(pump)
if (wakeLock.isHeld) wakeLock.release()
needsSeek = true
output?.flush()
}
private fun resetSource(positionUs: Long) {
val f = requireNotNull(format)
val target = positionUs.coerceIn(0, durationUs.coerceAtLeast(0))
baseUs = dsd?.seek(target, f.encoding) ?: target.also { pcm?.seek(it) }
pending = null
writtenFrames = 0
ended = false
needsSeek = false
}
private fun seek(positionUs: Long) {
val wasPlaying = playing
pause()
resetSource(positionUs)
pausedPositionUs = baseUs
if (wasPlaying) resume()
}
private fun fail(reason: String) {
android.util.Log.w("UsbDirect", reason)
runCatching { pause() }
failed = true
output?.shutdown()
output?.destroy()
output = null
connection?.close()
connection = null
emit(mapOf("token" to token, "event" to "paused", "reason" to "route_changed"))
}
private fun closeSource() {
runCatching { pause() }
output?.shutdown()
output?.destroy()
output = null
connection?.close()
connection = null
deviceId = null
pcm?.close()
pcm = null
dsd?.close()
dsd = null
format = null
pending = null
ended = false
failed = false
needsSeek = false
baseUs = 0
durationUs = 0
pausedPositionUs = 0
writtenFrames = 0
}
fun dispose() {
revision++
permission?.done?.invoke(mapOf("reason" to "cancelled"), null)
permission = null
context.unregisterReceiver(receiver)
worker.post { closeSource(); thread.quitSafely() }
}
}
@@ -0,0 +1,152 @@
package com.zarz.spotiflac
import android.media.AudioFormat
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import java.io.Closeable
import java.io.FileInputStream
import java.nio.ByteBuffer
/** A source for the direct driver; decoding never passes through AudioTrack. */
internal class UsbPcmSource(path: String) : Closeable {
private val extractor = MediaExtractor()
private var codec: MediaCodec? = null
private val info = MediaCodec.BufferInfo()
private val raw = ByteBuffer.allocateDirect(256 * 1024)
private var inputEnded = false
var ended = false
private set
private var inputBits = 0
private var floating = false
private var targetUs = 0L
val rate: Int
val channels: Int
val bits: Int
val durationUs: Long
init {
try {
FileInputStream(path).use { extractor.setDataSource(it.fd) }
val index = (0 until extractor.trackCount).firstOrNull {
extractor.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
} ?: error("No audio stream")
extractor.selectTrack(index)
val format = extractor.getTrackFormat(index)
rate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0
when (val mime = format.getString(MediaFormat.KEY_MIME)) {
"audio/flac" -> {
val header = ByteArray(42)
FileInputStream(path).use { input ->
var n = 0
while (n < header.size) {
val count = input.read(header, n, header.size - n)
if (count <= 0) break
n += count
}
}
val source = requireNotNull(BitPerfectPcm.flacFormat(header))
require(source.first == rate && source.second == channels)
bits = source.third
format.setInteger(MediaFormat.KEY_PCM_ENCODING, when (bits) {
16 -> AudioFormat.ENCODING_PCM_16BIT
24 -> AudioFormat.ENCODING_PCM_FLOAT
32 -> AudioFormat.ENCODING_PCM_32BIT
else -> error("Unsupported precision")
})
codec = MediaCodec.createDecoderByType(mime).also {
it.configure(format, null, null, 0)
it.start()
}
}
"audio/raw" -> {
bits = precision(format.getInteger(MediaFormat.KEY_PCM_ENCODING))
inputBits = bits
}
else -> error("Direct USB supports integer FLAC/WAV and DSF/DFF")
}
require(channels in 1..2 && bits in listOf(16, 24, 32))
} catch (error: Exception) { close(); throw error }
}
private fun precision(encoding: Int) = when (encoding) {
AudioFormat.ENCODING_PCM_16BIT -> 16
AudioFormat.ENCODING_PCM_24BIT_PACKED -> 24
AudioFormat.ENCODING_PCM_32BIT -> 32
else -> 0
}
private fun accept(format: MediaFormat) {
require(format.getInteger(MediaFormat.KEY_SAMPLE_RATE) == rate && format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) == channels)
val encoding = if (format.containsKey(MediaFormat.KEY_PCM_ENCODING)) format.getInteger(MediaFormat.KEY_PCM_ENCODING) else AudioFormat.ENCODING_PCM_16BIT
floating = encoding == AudioFormat.ENCODING_PCM_FLOAT
inputBits = if (floating) 32 else precision(encoding)
require(inputBits >= bits && !(floating && bits > 24)) { "Decoder reduced precision" }
}
private fun convert(buffer: ByteBuffer, timeUs: Long, outputBits: Int): ByteBuffer {
val frame = channels * (inputBits / 8)
require(frame > 0 && buffer.remaining() % frame == 0)
val skip = if (timeUs < targetUs) ((targetUs - timeUs) * rate + 999999) / 1000000 else 0
buffer.position(buffer.position() + minOf(skip, (buffer.remaining() / frame).toLong()).toInt() * frame)
return BitPerfectPcm.convert(buffer, inputBits, bits, outputBits, floating)
}
fun read(outputBits: Int): ByteBuffer? {
if (ended) return null
val decoder = codec
if (decoder == null) {
raw.clear()
val size = extractor.readSampleData(raw, 0)
if (size < 0) { ended = true; return null }
raw.limit(size)
raw.position(0)
val output = convert(raw, extractor.sampleTime, outputBits)
extractor.advance()
return output
}
repeat(8) {
if (!inputEnded) {
val slot = decoder.dequeueInputBuffer(0)
if (slot >= 0) {
val size = extractor.readSampleData(requireNotNull(decoder.getInputBuffer(slot)), 0)
if (size < 0) {
decoder.queueInputBuffer(slot, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
inputEnded = true
} else {
decoder.queueInputBuffer(slot, 0, size, extractor.sampleTime, 0)
extractor.advance()
}
}
}
}
val slot = decoder.dequeueOutputBuffer(info, 0)
if (slot == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) accept(decoder.outputFormat)
if (slot < 0) return null
try {
ended = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
if (info.size == 0 || info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0) return null
if (inputBits == 0) accept(decoder.outputFormat)
val buffer = requireNotNull(decoder.getOutputBuffer(slot))
buffer.position(info.offset)
buffer.limit(info.offset + info.size)
return convert(buffer, info.presentationTimeUs, outputBits)
} finally { decoder.releaseOutputBuffer(slot, false) }
}
fun seek(positionUs: Long) {
targetUs = positionUs.coerceAtLeast(0)
codec?.flush()
extractor.seekTo(targetUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
inputEnded = false
ended = false
}
override fun close() {
runCatching { codec?.release() }
codec = null
runCatching { extractor.release() }
}
}
@@ -0,0 +1,88 @@
package com.zarz.spotiflac
import java.io.ByteArrayOutputStream
import java.io.DataOutputStream
import java.io.File
import java.nio.ByteBuffer
import java.nio.ByteOrder
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Assert.assertThrows
import org.junit.Test
class DsdFileTest {
private fun dsf(): ByteArray {
val buffer = ByteBuffer.allocate(28 + 52 + 12 + 16).order(ByteOrder.LITTLE_ENDIAN)
buffer.put("DSD ".toByteArray()).putLong(28).putLong(buffer.capacity().toLong()).putLong(0)
buffer.put("fmt ".toByteArray()).putLong(52).putInt(1).putInt(0).putInt(2)
.putInt(2).putInt(2822400).putInt(1).putLong(48).putInt(4).putInt(0)
buffer.put("data".toByteArray()).putLong(28)
// Two DSF channel blocks per group, with padding in the final group.
buffer.put(byteArrayOf(1, 2, 4, 8, 16, 32, 64, -128, 3, 5, 0, 0, 6, 10, 0, 0))
return buffer.array()
}
private fun chunk(name: String, data: ByteArray): ByteArray {
val out = ByteArrayOutputStream()
DataOutputStream(out).use {
it.writeBytes(name)
it.writeLong(data.size.toLong())
it.write(data)
if (data.size % 2 == 1) it.writeByte(0)
}
return out.toByteArray()
}
private fun dff(compression: String = "DSD "): ByteArray {
val rate = ByteBuffer.allocate(4).putInt(2822400).array()
val property = "SND ".toByteArray() + chunk("FS ", rate) +
chunk("CHNL", byteArrayOf(0, 2) + "SLFTSRGT".toByteArray()) + chunk("CMPR", compression.toByteArray())
val data = byteArrayOf(1, 11, 2, 12, 3, 13, 4, 14, 5, 15, 6, 16)
return chunk("FRM8", "DSD ".toByteArray() + chunk("PROP", property) + chunk("DSD ", data))
}
private fun <T> withFile(data: ByteArray, run: (String) -> T): T {
val file = File.createTempFile("spotiflac-dsd-", ".audio")
try { file.writeBytes(data); return run(file.absolutePath) } finally { file.delete() }
}
@Test fun dsfNormalizesLsbBitsAndDoesNotReadChannelPadding() = withFile(dsf()) { path ->
DsdFile.open(path)!!.use {
assertEquals(2822400, it.rate)
assertEquals(2, it.channels)
assertArrayEquals(byteArrayOf(64, -128, 5, 4, 8, 5), it.readFrames("dop", 3, 1))
assertArrayEquals(byteArrayOf(16, 32, 5, 1, 2, 5), it.readFrames("dop", 3, 1))
assertArrayEquals(byteArrayOf(-96, -64, 5, 80, 96, 5), it.readFrames("dop", 3, 1))
assertNull(it.readFrames("dop", 3))
}
}
@Test fun nativeDsdOrderAndSeekRemainChannelAligned() = withFile(dff()) { path ->
DsdFile.open(path)!!.use {
assertArrayEquals(byteArrayOf(1, 2, 3, 4, 11, 12, 13, 14), it.readFrames("dsd_be", 4, 1))
it.seek(0, "dsd_le")
assertArrayEquals(byteArrayOf(4, 3, 2, 1, 14, 13, 12, 11), it.readFrames("dsd_le", 4, 1))
assertArrayEquals(byteArrayOf(0x69, 0x69, 6, 5, 0x69, 0x69, 16, 15), it.readFrames("dsd_le", 4, 1))
assertNull(it.readFrames("dsd_le", 4))
}
}
@Test fun dopUsesLeftJustified24BitWordsIn32BitSlots() = withFile(dff()) { path ->
DsdFile.open(path)!!.use {
assertArrayEquals(byteArrayOf(0, 2, 1, 5, 0, 12, 11, 5), it.readFrames("dop", 4, 1))
}
}
@Test fun rejectsDstAndTruncatedOrOversizedChunks() {
for (data in listOf(dff("DST "), dsf().copyOf(99), dff().apply { for (i in 4..11) this[i] = 127 })) {
withFile(data) { path ->
assertThrows(Exception::class.java) { DsdFile.open(path) }
}
}
}
@Test fun nonDsdFilesFallBackWithoutUsingTheExtension() = withFile("fLaC1234".toByteArray()) { path ->
assertNull(DsdFile.open(path))
}
}