Skip to content

Commit 6b896eb

Browse files
authored
Optimize audio renderer payloads (#988)
This PR changes audio renderer events to send raw PCM bytes instead of boxed sample arrays, which reduces codec overhead and GC pressure between native and Flutter.
1 parent ac709cd commit 6b896eb

8 files changed

Lines changed: 221 additions & 197 deletions

File tree

.changes/raw-bytes-audio-renderer

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
patch type="performance" "Send raw PCM bytes in audio renderer instead of boxed int arrays"

android/src/main/kotlin/io/livekit/plugin/AudioRenderer.kt

Lines changed: 140 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ package io.livekit.plugin
1818

1919
import android.os.Handler
2020
import android.os.Looper
21+
import android.util.Log
2122
import io.flutter.plugin.common.BinaryMessenger
2223
import io.flutter.plugin.common.EventChannel
2324
import org.webrtc.AudioTrackSink
@@ -34,10 +35,14 @@ class AudioRenderer(
3435
private val rendererId: String,
3536
private val targetFormat: RendererAudioFormat
3637
) : EventChannel.StreamHandler, AudioTrackSink {
38+
companion object {
39+
private const val TAG = "LKAudioRenderer"
40+
}
3741

3842
private var eventChannel: EventChannel? = null
3943
private var eventSink: EventChannel.EventSink? = null
4044
private var isAttached = false
45+
private var droppedFrameCount = 0L
4146

4247
private val handler: Handler by lazy {
4348
Handler(Looper.getMainLooper())
@@ -79,201 +84,221 @@ class AudioRenderer(
7984
absoluteCaptureTimestampMs: Long
8085
) {
8186
eventSink?.let { sink ->
82-
try {
87+
val convertedData = try {
8388
// Convert audio data to the target format
84-
val convertedData = convertAudioData(
89+
convertAudioData(
8590
audioData,
8691
bitsPerSample,
8792
sampleRate,
8893
numberOfChannels,
8994
numberOfFrames
9095
)
91-
92-
// Send to Flutter on the main thread
93-
handler.post {
94-
sink.success(convertedData)
95-
}
9696
} catch (e: Exception) {
97-
handler.post {
98-
sink.error(
99-
"AUDIO_CONVERSION_ERROR",
100-
"Failed to convert audio data: ${e.message}",
101-
null
102-
)
103-
}
97+
logDroppedFrame("Audio conversion exception: ${e.message}")
98+
null
99+
}
100+
101+
if (convertedData == null) {
102+
return@let
103+
}
104+
105+
// Send to Flutter on the main thread
106+
handler.post {
107+
sink.success(convertedData)
104108
}
105109
}
106110
}
107111

112+
/**
113+
* Converts audio data to raw interleaved bytes.
114+
*
115+
* If source and target channel counts match, data is copied directly.
116+
* If target requests fewer channels, the first channels are kept and interleaved.
117+
*
118+
* Sends raw byte arrays instead of boxed sample lists.
119+
*/
108120
private fun convertAudioData(
109121
audioData: ByteBuffer,
110122
bitsPerSample: Int,
111123
sampleRate: Int,
112124
numberOfChannels: Int,
113125
numberOfFrames: Int
114-
): Map<String, Any> {
115-
// Create result similar to iOS implementation
126+
): Map<String, Any>? {
127+
if (bitsPerSample != 16 && bitsPerSample != 32) {
128+
logDroppedFrame("Unsupported bitsPerSample: $bitsPerSample")
129+
return null
130+
}
131+
if (numberOfChannels <= 0) {
132+
logDroppedFrame("Invalid numberOfChannels: $numberOfChannels")
133+
return null
134+
}
135+
if (numberOfFrames <= 0) {
136+
logDroppedFrame("Invalid numberOfFrames: $numberOfFrames")
137+
return null
138+
}
139+
140+
val bytesPerSample = bitsPerSample / 8
141+
val bytesPerFrame = numberOfChannels * bytesPerSample
142+
if (bytesPerFrame <= 0) {
143+
logDroppedFrame("Invalid bytesPerFrame: $bytesPerFrame")
144+
return null
145+
}
146+
147+
val requestedChannels = targetFormat.numberOfChannels.coerceAtLeast(1)
148+
val outChannels = requestedChannels.coerceAtMost(numberOfChannels)
149+
150+
val buffer = audioData.duplicate()
151+
buffer.order(ByteOrder.LITTLE_ENDIAN)
152+
buffer.rewind()
153+
154+
val availableBytes = buffer.remaining()
155+
if (availableBytes <= 0) {
156+
logDroppedFrame("No audio payload bytes available")
157+
return null
158+
}
159+
160+
val expectedBytes = numberOfFrames.toLong() * bytesPerFrame.toLong()
161+
val frameLength = if (expectedBytes <= availableBytes.toLong()) {
162+
numberOfFrames
163+
} else {
164+
val availableFrames = availableBytes / bytesPerFrame
165+
if (availableFrames <= 0) {
166+
logDroppedFrame(
167+
"Insufficient bytes for one frame (available=$availableBytes, bytesPerFrame=$bytesPerFrame)"
168+
)
169+
return null
170+
}
171+
logDroppedFrame("Short audio payload; truncating frames from $numberOfFrames to $availableFrames")
172+
availableFrames
173+
}
174+
116175
val result = mutableMapOf<String, Any>(
117176
"sampleRate" to sampleRate,
118-
"channels" to numberOfChannels,
119-
"frameLength" to numberOfFrames
177+
"channels" to outChannels,
178+
"frameLength" to frameLength,
120179
)
121180

122-
// Convert based on target format
123181
when (targetFormat.commonFormat) {
124182
"int16" -> {
125183
result["commonFormat"] = "int16"
126-
result["data"] =
127-
convertToInt16(audioData, bitsPerSample, numberOfChannels, numberOfFrames)
184+
result["data"] = extractAsInt16Bytes(buffer, bitsPerSample, numberOfChannels, outChannels, frameLength)
128185
}
129-
130186
"float32" -> {
131187
result["commonFormat"] = "float32"
132-
result["data"] =
133-
convertToFloat32(audioData, bitsPerSample, numberOfChannels, numberOfFrames)
188+
result["data"] = extractAsFloat32Bytes(buffer, bitsPerSample, numberOfChannels, outChannels, frameLength)
134189
}
135-
136190
else -> {
137-
result["commonFormat"] = "int16" // Default fallback
138-
result["data"] =
139-
convertToInt16(audioData, bitsPerSample, numberOfChannels, numberOfFrames)
191+
result["commonFormat"] = "int16"
192+
result["data"] = extractAsInt16Bytes(buffer, bitsPerSample, numberOfChannels, outChannels, frameLength)
140193
}
141194
}
142195

143196
return result
144197
}
145198

146-
private fun convertToInt16(
147-
audioData: ByteBuffer,
199+
private fun logDroppedFrame(reason: String) {
200+
droppedFrameCount += 1
201+
if (droppedFrameCount <= 5 || droppedFrameCount % 100 == 0L) {
202+
Log.w(TAG, "Dropping audio frame #$droppedFrameCount for rendererId=$rendererId: $reason")
203+
}
204+
}
205+
206+
private fun extractAsInt16Bytes(
207+
buffer: ByteBuffer,
148208
bitsPerSample: Int,
149-
numberOfChannels: Int,
209+
srcChannels: Int,
210+
outChannels: Int,
150211
numberOfFrames: Int
151-
): List<List<Int>> {
152-
val channelsData = mutableListOf<List<Int>>()
212+
): ByteArray {
213+
// Fast path for int16 with matching channel count.
214+
if (bitsPerSample == 16 && srcChannels == outChannels) {
215+
val totalBytes = numberOfFrames * outChannels * 2
216+
val out = ByteArray(totalBytes)
217+
buffer.get(out, 0, totalBytes.coerceAtMost(buffer.remaining()))
218+
return out
219+
}
153220

154-
// Prepare buffer for reading
155-
val buffer = audioData.duplicate()
156-
buffer.order(ByteOrder.LITTLE_ENDIAN)
157-
buffer.rewind()
221+
val out = ByteArray(numberOfFrames * outChannels * 2)
222+
val outBuf = ByteBuffer.wrap(out).order(ByteOrder.LITTLE_ENDIAN)
158223

159224
when (bitsPerSample) {
160225
16 -> {
161-
// Already 16-bit, just reformat by channels
162-
for (channel in 0 until numberOfChannels) {
163-
val channelData = mutableListOf<Int>()
164-
buffer.position(0) // Start from beginning for each channel
165-
166-
for (frame in 0 until numberOfFrames) {
167-
val sampleIndex = frame * numberOfChannels + channel
168-
val byteIndex = sampleIndex * 2
169-
226+
for (frame in 0 until numberOfFrames) {
227+
val srcOffset = frame * srcChannels * 2
228+
for (ch in 0 until outChannels) {
229+
val byteIndex = srcOffset + ch * 2
170230
if (byteIndex + 1 < buffer.capacity()) {
171231
buffer.position(byteIndex)
172-
val sample = buffer.short.toInt()
173-
channelData.add(sample)
232+
outBuf.putShort((frame * outChannels + ch) * 2, buffer.short)
174233
}
175234
}
176-
channelsData.add(channelData)
177235
}
178236
}
179-
180237
32 -> {
181-
// Convert from 32-bit to 16-bit
182-
for (channel in 0 until numberOfChannels) {
183-
val channelData = mutableListOf<Int>()
184-
buffer.position(0)
185-
186-
for (frame in 0 until numberOfFrames) {
187-
val sampleIndex = frame * numberOfChannels + channel
188-
val byteIndex = sampleIndex * 4
189-
238+
for (frame in 0 until numberOfFrames) {
239+
val srcOffset = frame * srcChannels * 4
240+
for (ch in 0 until outChannels) {
241+
val byteIndex = srcOffset + ch * 4
190242
if (byteIndex + 3 < buffer.capacity()) {
191243
buffer.position(byteIndex)
192-
val sample32 = buffer.int
193-
// Convert 32-bit to 16-bit by right-shifting
194-
val sample16 = (sample32 shr 16).toShort().toInt()
195-
channelData.add(sample16)
244+
val sample16 = (buffer.int shr 16).toShort()
245+
outBuf.putShort((frame * outChannels + ch) * 2, sample16)
196246
}
197247
}
198-
channelsData.add(channelData)
199-
}
200-
}
201-
202-
else -> {
203-
// Unsupported format, return empty data
204-
repeat(numberOfChannels) {
205-
channelsData.add(emptyList())
206248
}
207249
}
208250
}
209251

210-
return channelsData
252+
return out
211253
}
212254

213-
private fun convertToFloat32(
214-
audioData: ByteBuffer,
255+
private fun extractAsFloat32Bytes(
256+
buffer: ByteBuffer,
215257
bitsPerSample: Int,
216-
numberOfChannels: Int,
258+
srcChannels: Int,
259+
outChannels: Int,
217260
numberOfFrames: Int
218-
): List<List<Float>> {
219-
val channelsData = mutableListOf<List<Float>>()
261+
): ByteArray {
262+
// Fast path for float32 with matching channel count.
263+
if (bitsPerSample == 32 && srcChannels == outChannels) {
264+
val totalBytes = numberOfFrames * outChannels * 4
265+
val out = ByteArray(totalBytes)
266+
buffer.get(out, 0, totalBytes.coerceAtMost(buffer.remaining()))
267+
return out
268+
}
220269

221-
val buffer = audioData.duplicate()
222-
buffer.order(ByteOrder.LITTLE_ENDIAN)
223-
buffer.rewind()
270+
val out = ByteArray(numberOfFrames * outChannels * 4)
271+
val outBuf = ByteBuffer.wrap(out).order(ByteOrder.LITTLE_ENDIAN)
224272

225273
when (bitsPerSample) {
226274
16 -> {
227-
// Convert from 16-bit to float32
228-
for (channel in 0 until numberOfChannels) {
229-
val channelData = mutableListOf<Float>()
230-
buffer.position(0)
231-
232-
for (frame in 0 until numberOfFrames) {
233-
val sampleIndex = frame * numberOfChannels + channel
234-
val byteIndex = sampleIndex * 2
235-
275+
for (frame in 0 until numberOfFrames) {
276+
val srcOffset = frame * srcChannels * 2
277+
for (ch in 0 until outChannels) {
278+
val byteIndex = srcOffset + ch * 2
236279
if (byteIndex + 1 < buffer.capacity()) {
237280
buffer.position(byteIndex)
238-
val sample16 = buffer.short
239-
// Convert to float (-1.0 to 1.0)
240-
val sampleFloat = sample16.toFloat() / Short.MAX_VALUE
241-
channelData.add(sampleFloat)
281+
val sampleFloat = buffer.short.toFloat() / Short.MAX_VALUE
282+
outBuf.putFloat((frame * outChannels + ch) * 4, sampleFloat)
242283
}
243284
}
244-
channelsData.add(channelData)
245285
}
246286
}
247-
248287
32 -> {
249-
// Assume 32-bit float input
250-
for (channel in 0 until numberOfChannels) {
251-
val channelData = mutableListOf<Float>()
252-
buffer.position(0)
253-
254-
for (frame in 0 until numberOfFrames) {
255-
val sampleIndex = frame * numberOfChannels + channel
256-
val byteIndex = sampleIndex * 4
257-
288+
for (frame in 0 until numberOfFrames) {
289+
val srcOffset = frame * srcChannels * 4
290+
for (ch in 0 until outChannels) {
291+
val byteIndex = srcOffset + ch * 4
258292
if (byteIndex + 3 < buffer.capacity()) {
259293
buffer.position(byteIndex)
260-
val sampleFloat = buffer.float
261-
channelData.add(sampleFloat)
294+
outBuf.putFloat((frame * outChannels + ch) * 4, buffer.float)
262295
}
263296
}
264-
channelsData.add(channelData)
265-
}
266-
}
267-
268-
else -> {
269-
// Unsupported format
270-
repeat(numberOfChannels) {
271-
channelsData.add(emptyList())
272297
}
273298
}
274299
}
275300

276-
return channelsData
301+
return out
277302
}
278303
}
279304

lib/livekit_client_web.dart

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414

1515
import 'dart:async';
1616

17-
import 'package:flutter/services.dart';
17+
import 'package:flutter/services.dart' show MethodChannel, StandardMethodCodec, PlatformException, MethodCall;
1818

1919
import 'package:flutter_web_plugins/flutter_web_plugins.dart';
2020

0 commit comments

Comments
 (0)