From 9f1125bdf2b7d25ea4453cf64479cb64899aa5bb Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 24 Feb 2026 02:33:36 +0900 Subject: [PATCH 1/2] harden native audio renderers --- .../kotlin/io/livekit/plugin/AudioRenderer.kt | 103 +++++++----------- shared_swift/AudioRenderer.swift | 3 + 2 files changed, 40 insertions(+), 66 deletions(-) diff --git a/android/src/main/kotlin/io/livekit/plugin/AudioRenderer.kt b/android/src/main/kotlin/io/livekit/plugin/AudioRenderer.kt index 7dca37ba..1138723b 100644 --- a/android/src/main/kotlin/io/livekit/plugin/AudioRenderer.kt +++ b/android/src/main/kotlin/io/livekit/plugin/AudioRenderer.kt @@ -124,8 +124,9 @@ class AudioRenderer( numberOfChannels: Int, numberOfFrames: Int ): Map? { - if (bitsPerSample != 16 && bitsPerSample != 32) { - logDroppedFrame("Unsupported bitsPerSample: $bitsPerSample") + // WebRTC AudioTrackSink always delivers 16-bit signed int16 PCM. + if (bitsPerSample != 16) { + logDroppedFrame("Unsupported bitsPerSample: $bitsPerSample (expected 16)") return null } if (numberOfChannels <= 0) { @@ -137,7 +138,7 @@ class AudioRenderer( return null } - val bytesPerSample = bitsPerSample / 8 + val bytesPerSample = 2 // 16-bit val bytesPerFrame = numberOfChannels * bytesPerSample if (bytesPerFrame <= 0) { logDroppedFrame("Invalid bytesPerFrame: $bytesPerFrame") @@ -181,15 +182,15 @@ class AudioRenderer( when (targetFormat.commonFormat) { "int16" -> { result["commonFormat"] = "int16" - result["data"] = extractAsInt16Bytes(buffer, bitsPerSample, numberOfChannels, outChannels, frameLength) + result["data"] = extractAsInt16Bytes(buffer, numberOfChannels, outChannels, frameLength) } "float32" -> { result["commonFormat"] = "float32" - result["data"] = extractAsFloat32Bytes(buffer, bitsPerSample, numberOfChannels, outChannels, frameLength) + result["data"] = extractAsFloat32Bytes(buffer, numberOfChannels, outChannels, frameLength) } else -> { result["commonFormat"] = "int16" - result["data"] = extractAsInt16Bytes(buffer, bitsPerSample, numberOfChannels, outChannels, frameLength) + result["data"] = extractAsInt16Bytes(buffer, numberOfChannels, outChannels, frameLength) } } @@ -203,48 +204,37 @@ class AudioRenderer( } } + /** + * Extracts int16 PCM bytes from an int16 source buffer. + * + * Fast path when channel counts match (direct copy). + * Otherwise keeps only the first [outChannels] channels, interleaved. + */ private fun extractAsInt16Bytes( buffer: ByteBuffer, - bitsPerSample: Int, srcChannels: Int, outChannels: Int, numberOfFrames: Int ): ByteArray { - // Fast path for int16 with matching channel count. - if (bitsPerSample == 16 && srcChannels == outChannels) { + // Fast path: matching channel count — bulk copy. + if (srcChannels == outChannels) { val totalBytes = numberOfFrames * outChannels * 2 val out = ByteArray(totalBytes) buffer.get(out, 0, totalBytes.coerceAtMost(buffer.remaining())) return out } + // Channel reduction: keep first outChannels. val out = ByteArray(numberOfFrames * outChannels * 2) val outBuf = ByteBuffer.wrap(out).order(ByteOrder.LITTLE_ENDIAN) - when (bitsPerSample) { - 16 -> { - for (frame in 0 until numberOfFrames) { - val srcOffset = frame * srcChannels * 2 - for (ch in 0 until outChannels) { - val byteIndex = srcOffset + ch * 2 - if (byteIndex + 1 < buffer.capacity()) { - buffer.position(byteIndex) - outBuf.putShort((frame * outChannels + ch) * 2, buffer.short) - } - } - } - } - 32 -> { - for (frame in 0 until numberOfFrames) { - val srcOffset = frame * srcChannels * 4 - for (ch in 0 until outChannels) { - val byteIndex = srcOffset + ch * 4 - if (byteIndex + 3 < buffer.capacity()) { - buffer.position(byteIndex) - val sample16 = (buffer.int shr 16).toShort() - outBuf.putShort((frame * outChannels + ch) * 2, sample16) - } - } + for (frame in 0 until numberOfFrames) { + val srcOffset = frame * srcChannels * 2 + for (ch in 0 until outChannels) { + val byteIndex = srcOffset + ch * 2 + if (byteIndex + 1 < buffer.capacity()) { + buffer.position(byteIndex) + outBuf.putShort((frame * outChannels + ch) * 2, buffer.short) } } } @@ -252,48 +242,29 @@ class AudioRenderer( return out } + /** + * Converts int16 PCM source to float32 bytes. + * + * Each int16 sample is scaled to the [-1.0, 1.0] range. + * Only the first [outChannels] channels are kept. + */ private fun extractAsFloat32Bytes( buffer: ByteBuffer, - bitsPerSample: Int, srcChannels: Int, outChannels: Int, numberOfFrames: Int ): ByteArray { - // Fast path for float32 with matching channel count. - if (bitsPerSample == 32 && srcChannels == outChannels) { - val totalBytes = numberOfFrames * outChannels * 4 - val out = ByteArray(totalBytes) - buffer.get(out, 0, totalBytes.coerceAtMost(buffer.remaining())) - return out - } - val out = ByteArray(numberOfFrames * outChannels * 4) val outBuf = ByteBuffer.wrap(out).order(ByteOrder.LITTLE_ENDIAN) - when (bitsPerSample) { - 16 -> { - for (frame in 0 until numberOfFrames) { - val srcOffset = frame * srcChannels * 2 - for (ch in 0 until outChannels) { - val byteIndex = srcOffset + ch * 2 - if (byteIndex + 1 < buffer.capacity()) { - buffer.position(byteIndex) - val sampleFloat = buffer.short.toFloat() / Short.MAX_VALUE - outBuf.putFloat((frame * outChannels + ch) * 4, sampleFloat) - } - } - } - } - 32 -> { - for (frame in 0 until numberOfFrames) { - val srcOffset = frame * srcChannels * 4 - for (ch in 0 until outChannels) { - val byteIndex = srcOffset + ch * 4 - if (byteIndex + 3 < buffer.capacity()) { - buffer.position(byteIndex) - outBuf.putFloat((frame * outChannels + ch) * 4, buffer.float) - } - } + for (frame in 0 until numberOfFrames) { + val srcOffset = frame * srcChannels * 2 + for (ch in 0 until outChannels) { + val byteIndex = srcOffset + ch * 2 + if (byteIndex + 1 < buffer.capacity()) { + buffer.position(byteIndex) + val sampleFloat = buffer.short.toFloat() / Short.MAX_VALUE + outBuf.putFloat((frame * outChannels + ch) * 4, sampleFloat) } } } diff --git a/shared_swift/AudioRenderer.swift b/shared_swift/AudioRenderer.swift index 84b9f525..493d81e0 100644 --- a/shared_swift/AudioRenderer.swift +++ b/shared_swift/AudioRenderer.swift @@ -56,6 +56,9 @@ public class AudioRenderer: NSObject { func detach() { _track?.remove(audioRenderer: self) + channel?.setStreamHandler(nil) + channel = nil + eventSink = nil } deinit { From a242bc2b1f0b97e0d2571b0e3b823a0f16889bbe Mon Sep 17 00:00:00 2001 From: Hiroshi Horie <548776+hiroshihorie@users.noreply.github.com> Date: Tue, 24 Feb 2026 03:19:01 +0900 Subject: [PATCH 2/2] add changeset --- .changes/harden-audio-renderers | 1 + 1 file changed, 1 insertion(+) create mode 100644 .changes/harden-audio-renderers diff --git a/.changes/harden-audio-renderers b/.changes/harden-audio-renderers new file mode 100644 index 00000000..00c4da5c --- /dev/null +++ b/.changes/harden-audio-renderers @@ -0,0 +1 @@ +patch type="fixed" "Fix iOS audio renderer resource leak and remove Android 32-bit dead code"