mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-30 13:19:44 +02:00
feat(player): add direct USB audio and DSD transport
This commit is contained in:
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,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))
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user