From 4e20102bdd1ff51a7df907498955327920811b0b Mon Sep 17 00:00:00 2001 From: zarzet <42882290+zarzet@users.noreply.github.com> Date: Sun, 4 Oct 2026 01:40:59 +0700 Subject: [PATCH] perf(usb): reuse PCM buffers and respect native ring capacity --- .../com/zarz/spotiflac/BitPerfectPcm.kt | 9 ++++-- .../com/zarz/spotiflac/UsbBitPerfectPlugin.kt | 13 +++++---- .../com/zarz/spotiflac/UsbDirectPlayback.kt | 14 ++++++--- .../kotlin/com/zarz/spotiflac/UsbPcmSource.kt | 6 +++- .../com/zarz/spotiflac/BitPerfectPcmTest.kt | 29 +++++++++++++++++++ .../crates/mobile/src/usb_audio/mod.rs | 8 +++++ .../crates/mobile/src/usb_audio/transport.rs | 29 ++++++++++++++++--- 7 files changed, 90 insertions(+), 18 deletions(-) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/BitPerfectPcm.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/BitPerfectPcm.kt index 0fc8fa98..b1618a3b 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/BitPerfectPcm.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/BitPerfectPcm.kt @@ -8,14 +8,17 @@ internal object BitPerfectPcm { fun outputBits(sourceBits: Int, supported: List): 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 { + fun convert(input: ByteBuffer, inputBits: Int, sourceBits: Int, outputBits: Int, floating: Boolean, reuse: ByteBuffer? = null): 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) + val size = input.remaining() / bytes * (outputBits / 8) + val output = (reuse?.takeIf { it.isDirect && !it.isReadOnly && it.capacity() >= size } + ?: ByteBuffer.allocateDirect(size)).order(ByteOrder.LITTLE_ENDIAN) + output.clear() + output.limit(size) while (input.hasRemaining()) { val sample = if (floating) { val scaled = input.float.toDouble() * (1L shl (sourceBits - 1)) diff --git a/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt b/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt index d67dce84..566c7082 100644 --- a/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt +++ b/android/app/src/main/kotlin/com/zarz/spotiflac/UsbBitPerfectPlugin.kt @@ -43,11 +43,6 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even 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) } - } - } } override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { @@ -65,6 +60,9 @@ class UsbBitPerfectPlugin : FlutterPlugin, MethodChannel.MethodCallHandler, Even main.post { sink?.success(event) } } } + if (!useHiRes && !useDirect && Build.VERSION.SDK_INT >= 34 && engine == null) { + engine = UsbPcmPlayback(appContext) { event -> main.post { sink?.success(event) } } + } } if (useHiRes) { hiRes?.command(call) { value, error -> @@ -147,6 +145,7 @@ private class UsbPcmPlayback(context: Context, private val emit: (Map 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) + // The playback worker consumes this result before requesting another buffer. + return BitPerfectPcm.convert(buffer, inputBits, bits, outputBits, floating, converted) + .also { converted = it } } fun read(outputBits: Int): ByteBuffer? { @@ -147,6 +150,7 @@ internal class UsbPcmSource(path: String) : Closeable { override fun close() { runCatching { codec?.release() } codec = null + converted = null runCatching { extractor.release() } } } diff --git a/android/app/src/test/kotlin/com/zarz/spotiflac/BitPerfectPcmTest.kt b/android/app/src/test/kotlin/com/zarz/spotiflac/BitPerfectPcmTest.kt index ba1b9aa3..67a0c4de 100644 --- a/android/app/src/test/kotlin/com/zarz/spotiflac/BitPerfectPcmTest.kt +++ b/android/app/src/test/kotlin/com/zarz/spotiflac/BitPerfectPcmTest.kt @@ -6,6 +6,8 @@ import kotlin.random.Random import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertSame +import org.junit.Assert.assertNotSame import org.junit.Test class BitPerfectPcmTest { @@ -37,6 +39,33 @@ class BitPerfectPcmTest { assertEquals(-65536, result.int) } + @Test + fun reusesWritableDirectCapacityAndResetsBoundsForShorterChunks() { + val reusable = ByteBuffer.allocateDirect(32) + reusable.position(10) + reusable.limit(12) + val input = ByteBuffer.wrap(byteArrayOf(1, 0, -1, -1)) + val result = BitPerfectPcm.convert(input, 16, 16, 24, false, reusable) + assertSame(reusable, result) + assertArrayEquals(byteArrayOf(0, 1, 0, 0, -1, -1), bytes(result)) + val shorter = BitPerfectPcm.convert(ByteBuffer.wrap(byteArrayOf(2, 0)), 16, 16, 16, false, result) + assertSame(result, shorter) + assertArrayEquals(byteArrayOf(2, 0), bytes(shorter)) + } + + @Test + fun growsCapacityAndRejectsReadOnlyOrHeapBuffersForReuse() { + val small = ByteBuffer.allocateDirect(2) + val grown = BitPerfectPcm.convert(ByteBuffer.wrap(byteArrayOf(1, 0)), 16, 16, 32, false, small) + assertNotSame(small, grown) + assertArrayEquals(byteArrayOf(0, 0, 1, 0), bytes(grown)) + for (invalid in listOf(ByteBuffer.allocate(8), ByteBuffer.allocateDirect(8).asReadOnlyBuffer())) { + val result = BitPerfectPcm.convert(ByteBuffer.wrap(byteArrayOf(3, 0)), 16, 16, 16, false, invalid) + assertNotSame(invalid, result) + assertArrayEquals(byteArrayOf(3, 0), bytes(result)) + } + } + @Test fun decoderFloatRoundTripPreservesEveryBitOf24BitSamples() { val random = Random(17) diff --git a/rust_backend/crates/mobile/src/usb_audio/mod.rs b/rust_backend/crates/mobile/src/usb_audio/mod.rs index b850c127..1410bfa0 100644 --- a/rust_backend/crates/mobile/src/usb_audio/mod.rs +++ b/rust_backend/crates/mobile/src/usb_audio/mod.rs @@ -104,6 +104,14 @@ impl UsbDirectOutput { } } + /// Advisory, frame-aligned capacity before copying a buffer across FFI. + pub fn available_bytes(&self) -> Result { + #[cfg(target_os = "android")] + return self.output.available_bytes().map_err(Into::into); + #[cfg(not(target_os = "android"))] + unreachable!("Android-only constructor") + } + pub fn start(&self) -> Result<(), UsbAudioError> { #[cfg(target_os = "android")] return self.output.start().map_err(Into::into); diff --git a/rust_backend/crates/mobile/src/usb_audio/transport.rs b/rust_backend/crates/mobile/src/usb_audio/transport.rs index 68997c2a..d86a72c0 100644 --- a/rust_backend/crates/mobile/src/usb_audio/transport.rs +++ b/rust_backend/crates/mobile/src/usb_audio/transport.rs @@ -121,6 +121,18 @@ impl Output { self.shared.1.notify_all(); Ok(n as u32) } + pub fn available_bytes(&self) -> Result { + let frame = self.format.subslot as usize * self.format.channels as usize; + let s = self.shared.0.lock().unwrap(); + if let Some(e) = &s.error { + return Err(e.clone()); + } + if s.closed { + return Err("USB output closed".into()); + } + let capacity = (self.format.sample_rate as usize * frame / 5).max(256 * 1024); + Ok((capacity.saturating_sub(s.bytes.len()) / frame * frame) as u32) + } pub fn start(&self) -> Result<(), String> { let mut s = self.shared.0.lock().unwrap(); if let Some(e) = &s.error { @@ -609,15 +621,24 @@ impl Session { s.frames += slot.music_frames; slot.music_frames = 0; } - let lengths: Vec<_> = (0..packets).map(|_| clock.next() * frame).collect(); + let mut packet_lengths = [0usize; 32]; + let lengths = &mut packet_lengths[..packets]; + for length in lengths.iter_mut() { + *length = clock.next() * frame; + } if lengths.iter().any(|n| *n > self.endpoint.max_packet) { return Err("USB feedback exceeded endpoint capacity".into()); } let total: usize = lengths.iter().sum(); let available = total.min(s.bytes.len()) / frame * frame; - for b in &mut slot.bytes[..available] { - *b = s.bytes.pop_front().unwrap(); + { + let (first, second) = s.bytes.as_slices(); + let first_len = available.min(first.len()); + slot.bytes[..first_len].copy_from_slice(&first[..first_len]); + slot.bytes[first_len..available] + .copy_from_slice(&second[..available - first_len]); } + s.bytes.drain(..available); super::framing::finish_transfer( &mut slot.bytes[..total], available, @@ -625,7 +646,7 @@ impl Session { &mut marker, ); slot.music_frames = (available / frame) as u64; - slot.submit(self.handle, self.endpoint.address, &lengths)?; + slot.submit(self.handle, self.endpoint.address, lengths)?; } } self.drain();