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

This commit is contained in:
zarzet committed 2026-09-27 12:22:42 +07:00
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() }
}
}
}
+1
View File
@@ -23,6 +23,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.WAKE_LOCK" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<application
android:label="SpotiFLAC Mobile"
@@ -0,0 +1,61 @@
package com.zarz.spotiflac
import java.nio.ByteBuffer
import java.nio.ByteOrder
/** Lossless PCM representation changes only: no gain, dithering or resampling. */
internal object BitPerfectPcm {
fun outputBits(sourceBits: Int, supported: List<Int>): Int? =
supported.filter { it in listOf(16, 24, 32) && it >= sourceBits }.minOrNull()
fun convert(input: ByteBuffer, inputBits: Int, sourceBits: Int, outputBits: Int, floating: Boolean): ByteBuffer {
require(sourceBits in listOf(16, 24, 32) && outputBits in listOf(16, 24, 32) && outputBits >= sourceBits)
require(if (floating) inputBits == 32 && sourceBits <= 24 else inputBits >= sourceBits)
val bytes = inputBits / 8
require(input.remaining() % bytes == 0)
input.order(ByteOrder.LITTLE_ENDIAN)
val output = ByteBuffer.allocateDirect(input.remaining() / bytes * (outputBits / 8))
.order(ByteOrder.LITTLE_ENDIAN)
while (input.hasRemaining()) {
val sample = if (floating) {
val scaled = input.float.toDouble() * (1L shl (sourceBits - 1))
require(scaled.isFinite() && scaled >= -(1L shl (sourceBits - 1)) &&
scaled < (1L shl (sourceBits - 1)) && scaled == scaled.toLong().toDouble())
scaled.toInt() shl (32 - sourceBits)
} else {
when (inputBits) {
16 -> input.short.toInt() shl 16
24 -> ((input.get().toInt() and 255) or
((input.get().toInt() and 255) shl 8) or
((input.get().toInt() and 255) shl 16)) shl 8
32 -> input.int
else -> error("Unsupported PCM encoding")
}
}
// Reject a decoder output containing more precision than the source contract.
require(sourceBits == 32 || (sample shl sourceBits) == 0)
when (outputBits) {
16 -> output.putShort((sample shr 16).toShort())
24 -> {
output.put((sample shr 8).toByte())
output.put((sample shr 16).toByte())
output.put((sample shr 24).toByte())
}
32 -> output.putInt(sample)
}
}
output.flip()
return output
}
fun flacFormat(header: ByteArray): Triple<Int, Int, Int>? {
if (header.size < 42 || header.take(4) != listOf<Byte>(102, 76, 97, 67) ||
header[4].toInt() and 127 != 0 || header[5].toInt() != 0 ||
header[6].toInt() != 0 || header[7].toInt() != 34) return null
fun byte(i: Int) = header[i].toInt() and 255
val rate = (byte(18) shl 12) or (byte(19) shl 4) or (byte(20) shr 4)
val channels = ((byte(20) shr 1) and 7) + 1
val bits = (((byte(20) and 1) shl 4) or (byte(21) shr 4)) + 1
return Triple(rate, channels, bits)
}
}
@@ -976,6 +976,9 @@ class MainActivity: FlutterFragmentActivity() {
onBackPressedDispatcher.addCallback(this, flutterBackCallback!!)
val messenger = flutterEngine.dartExecutor.binaryMessenger
if (!flutterEngine.plugins.has(UsbBitPerfectPlugin::class.java)) {
flutterEngine.plugins.add(UsbBitPerfectPlugin())
}
concertCalendarChannel = MethodChannel(messenger, "com.zarz.spotiflac/concert_calendar").also { channel ->
channel.setMethodCallHandler { call, result ->
if (call.method != "add") {
@@ -0,0 +1,446 @@
package com.zarz.spotiflac
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioDeviceInfo
import android.media.AudioFormat
import android.media.AudioManager
import android.media.AudioMixerAttributes
import android.media.AudioRouting
import android.media.AudioTrack
import android.media.MediaCodec
import android.media.MediaExtractor
import android.media.MediaFormat
import android.os.Build
import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import android.os.PowerManager
import androidx.annotation.RequiresApi
import io.flutter.embedding.engine.plugins.FlutterPlugin
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodCall
import io.flutter.plugin.common.MethodChannel
import java.io.FileInputStream
import java.nio.ByteBuffer
/** Engine-owned (not Activity-owned), so playback survives backgrounding. */
class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, EventChannel.StreamHandler {
private lateinit var methods: MethodChannel
private lateinit var events: EventChannel
private var engine: UsbPcmPlayback? = null
private var sink: EventChannel.EventSink? = null
private val main = Handler(Looper.getMainLooper())
override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methods = MethodChannel(binding.binaryMessenger, "com.zarz.spotiflac/usb_pcm")
events = EventChannel(binding.binaryMessenger, "com.zarz.spotiflac/usb_pcm/events")
methods.setMethodCallHandler(this)
events.setStreamHandler(this)
if (Build.VERSION.SDK_INT >= 34) {
engine = UsbPcmPlayback(binding.applicationContext) { event ->
main.post { sink?.success(event) }
}
}
}
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
val playback = engine
if (Build.VERSION.SDK_INT < 34 || playback == null) {
result.success(if (call.method == "prepare") mapOf("reason" to "android_version") else null)
return
}
playback.command(call) { value, error ->
main.post {
if (error == null) result.success(value)
else result.error("usb_pcm", error, null)
}
}
}
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { sink = events }
override fun onCancel(arguments: Any?) { sink = null }
override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) {
methods.setMethodCallHandler(null)
events.setStreamHandler(null)
sink = null
if (Build.VERSION.SDK_INT >= 34) engine?.dispose()
engine = null
}
}
@RequiresApi(34)
private class UsbPcmPlayback(context: Context, private val emit: (Map<String, Any>) -> Unit) {
private val manager = context.getSystemService(AudioManager::class.java)
private val thread = HandlerThread("SpotiFLAC-USB-PCM").apply { start() }
private val worker = Handler(thread.looper)
private val wakeLock = context.getSystemService(PowerManager::class.java)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "SpotiFLAC:UsbPcm")
private val attributes = AudioAttributes.Builder().setUsage(AudioAttributes.USAGE_MEDIA)
.setContentType(AudioAttributes.CONTENT_TYPE_MUSIC).build()
private var extractor: MediaExtractor? = null
private var decoder: MediaCodec? = null
private var track: AudioTrack? = null
private var device: AudioDeviceInfo? = null
private var mixer: AudioMixerAttributes? = null
private var token = 0
private var rate = 0
private var channels = 0
private var sourceBits = 0
private var inputBits = 0
private var floatInput = false
private var outputBits = 0
private var durationUs = 0L
private var positionBaseUs = 0L
private var seekTargetUs = 0L
private var needTimestamp = true
private var framesWritten = 0L
private var previousHead = 0L
private var headWrap = 0L
private var inputEnded = false
private var outputEnded = false
private var playing = false
private var invalidRoute = false
private var nextRouteCheck = 0L
private var pending: ByteBuffer? = null
private val info = MediaCodec.BufferInfo()
private val rawBuffer = ByteBuffer.allocateDirect(256 * 1024)
@Volatile private var revision = 0
private val routing = AudioRouting.OnRoutingChangedListener {
if (playing && track?.routedDevice?.id != device?.id) suspendRoute()
}
private val mixerListener = AudioManager.OnPreferredMixerAttributesChangedListener { attr, changedDevice, value ->
if (attr.usage == AudioAttributes.USAGE_MEDIA && changedDevice.id == device?.id && value != mixer) {
suspendRoute()
}
}
init {
manager.addOnPreferredMixerAttributesChangedListener({ runnable -> worker.post(runnable) }, mixerListener)
}
fun command(call: MethodCall, done: (Any?, String?) -> Unit) {
val commandRevision = if (call.method == "prepare" || call.method == "stop") ++revision else revision
worker.post {
try {
val value: Any? = when (call.method) {
"prepare" -> {
closeSource()
token = call.argument<Int>("token") ?: 0
try {
prepare(requireNotNull(call.argument<String>("path")), commandRevision)
} catch (error: Exception) {
closeSource()
android.util.Log.i("UsbPcm", "Using normal output: ${error.message}")
mapOf("reason" to (error as? Unavailable)?.reason.orEmpty().ifEmpty { "format" })
}
}
"resume" -> { resume(); null }
"pause" -> { pause(); null }
"seek" -> { seek((call.argument<Number>("position")?.toLong() ?: 0) * 1000); null }
"stop" -> { closeSource(); null }
"position" -> positionUs() / 1000
else -> error("Unknown USB PCM command")
}
done(value, null)
} catch (error: Exception) { done(null, error.message ?: "USB playback failed") }
}
}
private class Unavailable(val reason: String) : Exception(reason)
private fun prepare(path: String, expectedRevision: Int): Map<String, Any> {
val usb = manager.getDevices(AudioManager.GET_DEVICES_OUTPUTS).firstOrNull {
it.type == AudioDeviceInfo.TYPE_USB_DEVICE || it.type == AudioDeviceInfo.TYPE_USB_HEADSET
} ?: throw Unavailable("no_usb")
val supported = manager.getSupportedMixerAttributes(usb).filter {
it.mixerBehavior == AudioMixerAttributes.MIXER_BEHAVIOR_BIT_PERFECT
}
if (supported.isEmpty()) throw Unavailable("unsupported")
// The owned descriptor is duplicated by MediaExtractor; a SAF lease may
// close as soon as prepare returns without keeping a full-file copy.
val media = MediaExtractor().also { extractor = it }
FileInputStream(path).use { media.setDataSource(it.fd) }
val index = (0 until media.trackCount).firstOrNull {
media.getTrackFormat(it).getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true
} ?: throw Unavailable("format")
media.selectTrack(index)
val format = media.getTrackFormat(index)
val mime = format.getString(MediaFormat.KEY_MIME)
rate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)
channels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)
if (channels !in 1..2) throw Unavailable("format")
val mask = if (channels == 1) AudioFormat.CHANNEL_OUT_MONO else AudioFormat.CHANNEL_OUT_STEREO
durationUs = if (format.containsKey(MediaFormat.KEY_DURATION)) format.getLong(MediaFormat.KEY_DURATION) else 0
if (mime == "audio/flac") {
val header = ByteArray(42)
FileInputStream(path).use { input ->
var offset = 0
while (offset < header.size) {
val read = input.read(header, offset, header.size - offset)
if (read <= 0) break
offset += read
}
}
val source = BitPerfectPcm.flacFormat(header) ?: throw Unavailable("format")
if (source.first != rate || source.second != channels) throw Unavailable("format")
sourceBits = source.third
} else if (mime == "audio/raw" && format.containsKey(MediaFormat.KEY_PCM_ENCODING)) {
sourceBits = bits(format.getInteger(MediaFormat.KEY_PCM_ENCODING))
} else throw Unavailable("format")
if (sourceBits !in listOf(16, 24, 32)) throw Unavailable("format")
val matching = supported.filter { it.format.sampleRate == rate && it.format.channelMask == mask }
outputBits = BitPerfectPcm.outputBits(sourceBits, matching.map { bits(it.format.encoding) })
?: throw Unavailable("format")
val selected = matching.first { bits(it.format.encoding) == outputBits }
device = usb
mixer = selected
if (!manager.setPreferredMixerAttributes(attributes, usb, selected)) throw Unavailable("unsupported")
val minimum = AudioTrack.getMinBufferSize(rate, mask, selected.format.encoding)
if (minimum <= 0) throw Unavailable("format")
val audio = AudioTrack.Builder().setAudioAttributes(attributes).setAudioFormat(selected.format)
.setTransferMode(AudioTrack.MODE_STREAM)
.setBufferSizeInBytes(maxOf(minimum * 2, rate / 5 * channels * (outputBits / 8)))
.build().also { track = it }
if (audio.state != AudioTrack.STATE_INITIALIZED || !audio.setPreferredDevice(usb) ||
audio.sampleRate != rate || audio.audioFormat != selected.format.encoding ||
audio.channelCount != channels) throw Unavailable("format")
audio.addOnRoutingChangedListener(routing, worker)
// Never change STREAM_MUSIC volume: the DAC owns volume in this mode.
audio.setVolume(1f)
if (mime == "audio/raw") {
inputBits = sourceBits
floatInput = false
} else {
// 24-bit integers are represented exactly by IEEE float. Converting
// them back is checked sample-by-sample, with no rounding allowed.
format.setInteger(MediaFormat.KEY_PCM_ENCODING, when (sourceBits) {
16 -> AudioFormat.ENCODING_PCM_16BIT
24 -> AudioFormat.ENCODING_PCM_FLOAT
else -> AudioFormat.ENCODING_PCM_32BIT
})
val codec = MediaCodec.createDecoderByType(requireNotNull(mime)).also { decoder = it }
codec.configure(format, null, null, 0)
codec.start()
inputBits = 0
val deadline = android.os.SystemClock.elapsedRealtime() + 5000
while (pending == null && !outputEnded) {
if (revision != expectedRevision) throw Unavailable("cancelled")
if (android.os.SystemClock.elapsedRealtime() > deadline) throw Unavailable("format")
decode()
if (pending == null) Thread.sleep(2)
}
}
return mapOf("ready" to true, "device" to usb.productName.toString(), "sampleRate" to rate,
"bitDepth" to outputBits, "sourceBitDepth" to sourceBits, "duration" to durationUs / 1000)
}
private fun bits(encoding: Int): Int = when (encoding) {
AudioFormat.ENCODING_PCM_16BIT -> 16
AudioFormat.ENCODING_PCM_24BIT_PACKED -> 24
AudioFormat.ENCODING_PCM_32BIT -> 32
else -> 0
}
private fun acceptFormat(format: MediaFormat) {
if (format.getInteger(MediaFormat.KEY_SAMPLE_RATE) != rate ||
format.getInteger(MediaFormat.KEY_CHANNEL_COUNT) != channels) throw Unavailable("format")
val encoding = if (format.containsKey(MediaFormat.KEY_PCM_ENCODING))
format.getInteger(MediaFormat.KEY_PCM_ENCODING) else AudioFormat.ENCODING_PCM_16BIT
floatInput = encoding == AudioFormat.ENCODING_PCM_FLOAT
inputBits = if (floatInput) 32 else bits(encoding)
if (inputBits < sourceBits || (floatInput && sourceBits > 24)) throw Unavailable("format")
}
private fun pcm(input: ByteBuffer, timeUs: Long) {
val frameBytes = channels * (inputBits / 8)
require(frameBytes > 0 && input.remaining() % frameBytes == 0)
val skipFrames = if (timeUs < seekTargetUs)
((seekTargetUs - timeUs) * rate + 999999) / 1000000 else 0
val skipped = minOf(skipFrames, (input.remaining() / frameBytes).toLong())
input.position(input.position() + skipped.toInt() * frameBytes)
if (!input.hasRemaining()) return
if (needTimestamp) {
positionBaseUs = timeUs + skipped * 1000000 / rate
needTimestamp = false
}
pending = BitPerfectPcm.convert(input, inputBits, sourceBits, outputBits, floatInput)
}
private fun decode() {
val media = extractor ?: return
if (pending != null || outputEnded) return
val codec = decoder
if (codec == null) {
rawBuffer.clear()
val size = media.readSampleData(rawBuffer, 0)
if (size < 0) { outputEnded = true; return }
rawBuffer.position(0)
rawBuffer.limit(size)
pcm(rawBuffer, media.sampleTime)
media.advance()
return
}
// Keep the codec fed independently of FLAC block size. Processing only
// one tiny frame per pump would starve a high-rate DAC.
for (attempt in 0 until 8) {
if (inputEnded) break
val slot = codec.dequeueInputBuffer(0)
if (slot < 0) break
val buffer = requireNotNull(codec.getInputBuffer(slot))
val size = media.readSampleData(buffer, 0)
if (size < 0) {
codec.queueInputBuffer(slot, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
inputEnded = true
} else {
codec.queueInputBuffer(slot, 0, size, media.sampleTime, 0)
media.advance()
}
}
val slot = codec.dequeueOutputBuffer(info, 0)
if (slot == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) acceptFormat(codec.outputFormat)
if (slot >= 0) {
try {
if (info.size > 0 && info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG == 0) {
if (inputBits == 0) acceptFormat(codec.outputFormat)
val buffer = requireNotNull(codec.getOutputBuffer(slot))
buffer.position(info.offset)
buffer.limit(info.offset + info.size)
pcm(buffer, info.presentationTimeUs)
}
outputEnded = info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM != 0
} finally { codec.releaseOutputBuffer(slot, false) }
}
}
private fun verifiedRoute(): Boolean = track?.routedDevice?.id == device?.id &&
device?.let { manager.getPreferredMixerAttributes(attributes, it) == mixer } == true
private fun resume() {
val audio = track ?: error("No USB source")
check(!invalidRoute) { "USB route changed; reopen the track" }
audio.play()
// Do not write any samples until the route actually resolves to our DAC.
if (!verifiedRoute()) {
audio.pause()
invalidRoute = true
emit(mapOf("token" to token, "event" to "paused", "reason" to "route_changed"))
error("USB route could not be verified")
}
playing = true
if (!wakeLock.isHeld) wakeLock.acquire()
emit(mapOf("token" to token, "event" to "active"))
worker.removeCallbacks(pump)
worker.post(pump)
}
private val pump = object : Runnable {
override fun run() {
if (!playing) return
try {
val now = android.os.SystemClock.elapsedRealtime()
if (now >= nextRouteCheck) {
if (!verifiedRoute()) { suspendRoute(); return }
nextRouteCheck = now + 250
}
// Bounded nonblocking writes keep transport commands responsive
// even when a DAC stops consuming samples.
for (attempt in 0 until 8) {
decode()
val buffer = pending ?: break
val written = requireNotNull(track).write(buffer, buffer.remaining(), AudioTrack.WRITE_NON_BLOCKING)
check(written >= 0) { "USB write failed: $written" }
framesWritten += written / (channels * (outputBits / 8))
if (buffer.hasRemaining()) break
pending = null
}
if (outputEnded && pending == null && headFrames() >= framesWritten) {
pause()
emit(mapOf("token" to token, "event" to "complete"))
} else worker.postDelayed(this, 5)
} catch (error: Exception) {
android.util.Log.w("UsbPcm", "PCM playback stopped", error)
suspendRoute()
}
}
}
private fun pause() {
playing = false
worker.removeCallbacks(pump)
if (wakeLock.isHeld) wakeLock.release()
runCatching {
if (track?.playState == AudioTrack.PLAYSTATE_PLAYING) track?.pause()
}
}
private fun suspendRoute() {
pause()
invalidRoute = true
emit(mapOf("token" to token, "event" to "paused", "reason" to "route_changed"))
}
private fun headFrames(): Long {
val head = (track?.playbackHeadPosition?.toLong() ?: 0) and 0xffffffffL
if (head < previousHead) headWrap += 1L shl 32
previousHead = head
return headWrap + head
}
private fun positionUs(): Long = positionBaseUs + if (rate > 0) headFrames() * 1000000 / rate else 0
private fun seek(positionUs: Long) {
val wasPlaying = playing
pause()
track?.flush()
decoder?.flush()
seekTargetUs = if (durationUs > 0) positionUs.coerceIn(0, durationUs - 1)
else positionUs.coerceAtLeast(0)
extractor?.seekTo(seekTargetUs, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)
positionBaseUs = seekTargetUs
framesWritten = 0
previousHead = 0
headWrap = 0
needTimestamp = true
pending = null
inputEnded = false
outputEnded = false
if (wasPlaying) resume()
}
private fun closeSource() {
pause()
track?.let { audio ->
runCatching { audio.removeOnRoutingChangedListener(routing) }
runCatching { audio.release() }
}
track = null
runCatching { decoder?.release() }
decoder = null
runCatching { extractor?.release() }
extractor = null
device?.let { runCatching { manager.clearPreferredMixerAttributes(attributes, it) } }
device = null
mixer = null
pending = null
framesWritten = 0
previousHead = 0
headWrap = 0
positionBaseUs = 0
seekTargetUs = 0
durationUs = 0
inputEnded = false
outputEnded = false
needTimestamp = true
invalidRoute = false
}
fun dispose() {
revision++
worker.post {
closeSource()
manager.removeOnPreferredMixerAttributesChangedListener(mixerListener)
thread.quitSafely()
}
}
}
@@ -0,0 +1,82 @@
package com.zarz.spotiflac
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.random.Random
import org.junit.Assert.assertArrayEquals
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
import org.junit.Test
class BitPerfectPcmTest {
private fun bytes(buffer: ByteBuffer): ByteArray = ByteArray(buffer.remaining()).also { buffer.get(it) }
@Test
fun selectsOnlyAnIntegerFormatWithEnoughPrecision() {
assertEquals(24, BitPerfectPcm.outputBits(24, listOf(16, 32, 24)))
assertEquals(32, BitPerfectPcm.outputBits(24, listOf(16, 32)))
assertNull(BitPerfectPcm.outputBits(24, listOf(16)))
assertNull(BitPerfectPcm.outputBits(32, listOf(16, 24)))
}
@Test
fun preservesPacked24BitSamplesIncludingSignExtremes() {
val samples = byteArrayOf(0, 0, -128, -1, -1, 127, -1, -1, -1, 1, 0, 0)
val input = ByteBuffer.wrap(samples)
assertArrayEquals(samples, bytes(BitPerfectPcm.convert(input, 24, 24, 24, false)))
}
@Test
fun padsLeastSignificantBitsWithoutChangingSampleAmplitude() {
val input = ByteBuffer.allocate(6).order(ByteOrder.LITTLE_ENDIAN)
.putShort(Short.MIN_VALUE).putShort(Short.MAX_VALUE).putShort(-1)
input.flip()
val result = BitPerfectPcm.convert(input, 16, 16, 32, false)
assertEquals(Int.MIN_VALUE, result.int)
assertEquals(0x7fff0000, result.int)
assertEquals(-65536, result.int)
}
@Test
fun decoderFloatRoundTripPreservesEveryBitOf24BitSamples() {
val random = Random(17)
val samples = listOf(-8388608, 8388607, -1, 0, 1) + List(10000) { random.nextInt(-8388608, 8388608) }
val input = ByteBuffer.allocate(samples.size * 4).order(ByteOrder.LITTLE_ENDIAN)
for (sample in samples) input.putFloat(sample / 8388608f)
input.flip()
val result = BitPerfectPcm.convert(input, 32, 24, 32, true)
for (sample in samples) assertEquals(sample shl 8, result.int)
}
@Test(expected = IllegalArgumentException::class)
fun rejectsDecoderDownconversion() {
BitPerfectPcm.convert(ByteBuffer.allocate(2), 16, 24, 24, false)
}
@Test(expected = IllegalArgumentException::class)
fun rejectsFloatingPointFor32BitSource() {
BitPerfectPcm.convert(ByteBuffer.allocate(4), 32, 32, 32, true)
}
@Test(expected = IllegalArgumentException::class)
fun refusesRoundingOfNonRepresentableFloatSamples() {
val input = ByteBuffer.allocate(4).order(ByteOrder.LITTLE_ENDIAN).putFloat(0.00000001f)
input.flip()
BitPerfectPcm.convert(input, 32, 24, 24, true)
}
@Test
fun readsFlacPrecisionFromStreaminfoInsteadOfLibraryTags() {
val header = ByteArray(42)
"fLaC".toByteArray().copyInto(header)
header[4] = -128
header[7] = 34
val rate = 96000
val packed = (rate.toLong() shl 44) or (1L shl 41) or (23L shl 36)
for (i in 0..7) header[18 + i] = (packed ushr (56 - i * 8)).toByte()
assertEquals(Triple(96000, 2, 24), BitPerfectPcm.flacFormat(header))
header[7] = 33
assertNull(BitPerfectPcm.flacFormat(header))
assertNull(BitPerfectPcm.flacFormat(ByteArray(4)))
}
}