2 Commits

Author SHA1 Message Date
Siavash Sameni
a39b074d6e fix: DirectByteBuffer as class field — survives ART JIT OSR
Some checks failed
Mirror to GitHub / mirror (push) Failing after 36s
Build Release Binaries / build-amd64 (push) Failing after 3m58s
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) <noreply@anthropic.com>
2026-04-06 22:22:54 +04:00
Siavash Sameni
9cab6e2347 ci: skip build on CI-only file changes
Add paths-ignore for .gitea/** so build.yml doesn't waste runner time
when only workflow files are modified.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-06 22:13:29 +04:00
2 changed files with 21 additions and 2 deletions

View File

@@ -7,6 +7,8 @@ on:
- 'feat/*' - 'feat/*'
tags: tags:
- 'v*' - 'v*'
paths-ignore:
- '.gitea/**'
workflow_dispatch: workflow_dispatch:
env: env:

View File

@@ -60,6 +60,16 @@ class AudioPipeline(private val context: Context) {
var debugRecording: Boolean = true var debugRecording: Boolean = true
private var captureThread: Thread? = null private var captureThread: Thread? = null
private var playoutThread: 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. /** Latch counted down by each audio thread after exiting its loop.
* stop() does NOT wait on this — teardown waits via awaitDrain(). */ * stop() does NOT wait on this — teardown waits via awaitDrain(). */
private var drainLatch: CountDownLatch? = null private var drainLatch: CountDownLatch? = null
@@ -224,7 +234,10 @@ class AudioPipeline(private val context: Context) {
val read = recorder.read(pcm, 0, FRAME_SAMPLES) val read = recorder.read(pcm, 0, FRAME_SAMPLES)
if (read > 0) { if (read > 0) {
applyGain(pcm, read, captureGainDb) 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 // Debug: write raw PCM + RMS
if (pcmOut != null) { if (pcmOut != null) {
@@ -303,8 +316,12 @@ class AudioPipeline(private val context: Context) {
} }
try { try {
while (running) { 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) { if (read >= FRAME_SAMPLES) {
playoutDirectBuf.rewind()
playoutDirectBuf.asShortBuffer().get(pcm, 0, read)
applyGain(pcm, read, playoutGainDb) applyGain(pcm, read, playoutGainDb)
track.write(pcm, 0, read) track.write(pcm, 0, read)