From a39b074d6ef1aefc20475d4eed848bb130159886 Mon Sep 17 00:00:00 2001 From: Siavash Sameni Date: Mon, 6 Apr 2026 22:22:09 +0400 Subject: [PATCH] =?UTF-8?q?fix:=20DirectByteBuffer=20as=20class=20field=20?= =?UTF-8?q?=E2=80=94=20survives=20ART=20JIT=20OSR?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previous attempt allocated DirectByteBuffer as local variables inside runCapture/runPlayout. ART's JIT On-Stack Replacement nulled them when recompiling the hot loop mid-execution. Fix: allocate as class fields on AudioPipeline (captureDirectBuf, playoutDirectBuf). Object fields live on the heap, immune to OSR stack frame replacement. Eliminates JNI array copies (GetShortArrayRegion/SetShortArrayRegion) from the audio hot path, preventing ART GC SIGBUS crashes on Android 16 with concurrent mark-compact GC. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../main/java/com/wzp/audio/AudioPipeline.kt | 21 +++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/android/app/src/main/java/com/wzp/audio/AudioPipeline.kt b/android/app/src/main/java/com/wzp/audio/AudioPipeline.kt index 9223e50..841194f 100644 --- a/android/app/src/main/java/com/wzp/audio/AudioPipeline.kt +++ b/android/app/src/main/java/com/wzp/audio/AudioPipeline.kt @@ -60,6 +60,16 @@ class AudioPipeline(private val context: Context) { var debugRecording: Boolean = true private var captureThread: Thread? = null private var playoutThread: Thread? = null + + // DirectByteBuffers for zero-copy JNI audio transfer. + // Allocated as class fields (NOT locals) because ART's JIT OSR + // can null local variables when it replaces the stack frame mid-loop. + // These survive OSR because they're on the heap. + private val captureDirectBuf: ByteBuffer = + ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN) + private val playoutDirectBuf: ByteBuffer = + ByteBuffer.allocateDirect(FRAME_SAMPLES * 2).order(ByteOrder.LITTLE_ENDIAN) + /** Latch counted down by each audio thread after exiting its loop. * stop() does NOT wait on this — teardown waits via awaitDrain(). */ private var drainLatch: CountDownLatch? = null @@ -224,7 +234,10 @@ class AudioPipeline(private val context: Context) { val read = recorder.read(pcm, 0, FRAME_SAMPLES) if (read > 0) { applyGain(pcm, read, captureGainDb) - engine.writeAudio(pcm) + // Zero-copy write via DirectByteBuffer (class field, survives JIT OSR) + captureDirectBuf.clear() + captureDirectBuf.asShortBuffer().put(pcm, 0, read) + engine.writeAudioDirect(captureDirectBuf, read) // Debug: write raw PCM + RMS if (pcmOut != null) { @@ -303,8 +316,12 @@ class AudioPipeline(private val context: Context) { } try { while (running) { - val read = engine.readAudio(pcm) + // Zero-copy read via DirectByteBuffer (class field, survives JIT OSR) + playoutDirectBuf.clear() + val read = engine.readAudioDirect(playoutDirectBuf, FRAME_SAMPLES) if (read >= FRAME_SAMPLES) { + playoutDirectBuf.rewind() + playoutDirectBuf.asShortBuffer().get(pcm, 0, read) applyGain(pcm, read, playoutGainDb) track.write(pcm, 0, read)