Skip to content

Commit 4892817

Browse files
Fixed ScreenAudioCapturer crash, stale audio replay, and AudioRecord leak (#982)
* Fixed ScreenAudioCapturer audio thread crash and stale audio replay after MediaProjection stops * Fixed AudioRecord leak when releaseAudioResources races initAudioRecord initAudioRecord runs on WebRTC's audio record thread, while releaseAudioResources is called by the app from a thread of its choosing. A release landing between AudioRecord creation and its assignment to the field saw a null audioRecord, did nothing, and left init to publish a recording AudioRecord that nothing owned. Publication and release now share a lock, and a released capturer stays released, so an init that finishes after a release discards its AudioRecord instead of stranding it. Leaked recorders hold the playback capture input open: with 100 racing release/init pairs on an Android 17 emulator, 9 recorders were stranded and the remaining 91 creation attempts failed with "could not open input for device AUDIO_DEVICE_IN_REMOTE_SUBMIX". After the fix all 100 discard cleanly and no creation fails. * Validated channel count and simplified the min buffer size check in initAudioRecord
1 parent b8ba92a commit 4892817

2 files changed

Lines changed: 100 additions & 38 deletions

File tree

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
---
2+
"client-sdk-android": patch
3+
---
4+
5+
Fix `ScreenAudioCapturer` crashing the audio thread when its `MediaProjection` is revoked (for example via the system "stop sharing" chip) before the first microphone buffer arrives. `initAudioRecord` now returns false when the `AudioRecord` cannot be created instead of throwing inside WebRTC's audio record thread, where an unhandled exception kills the process; only `startRecording()` was guarded before. `AudioRecord.read` failures are also handled now: the return value was ignored, so once the projection died the buffer's stale contents (the last captured frame) were mixed into the microphone track on every callback, an audible loop until the callback was detached. On a read error the capturer releases its `AudioRecord` and degrades to mic-only audio.
6+
7+
`releaseAudioResources` is also safe to call while `initAudioRecord` is still running. It runs on the app's thread while init runs on the audio record thread, and it used to observe a null `audioRecord` and do nothing, so the recorder that init went on to publish stayed running until finalization. Leaked recorders hold the playback capture input open, and later capture attempts fail once enough of them accumulate.

livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt

Lines changed: 93 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/*
2-
* Copyright 2024-2025 LiveKit, Inc.
2+
* Copyright 2024-2026 LiveKit, Inc.
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -111,9 +111,25 @@ constructor(
111111
*/
112112
private val captureConfigurator: AudioPlaybackCaptureConfigurator = DEFAULT_CONFIGURATOR,
113113
) : MixerAudioBufferCallback() {
114+
/**
115+
* Guards publication and release of [audioRecord]. [initAudioRecord] runs on WebRTC's audio
116+
* record thread, while [releaseAudioResources] is called by the app from a thread of its
117+
* choosing.
118+
*/
119+
private val audioRecordLock = Any()
120+
121+
@Volatile
114122
private var audioRecord: AudioRecord? = null
115123

124+
/**
125+
* Terminal once set: releasing is what an app does when it is done capturing, and a capturer
126+
* is tied to the single [MediaProjection] it was constructed with.
127+
*/
128+
private var isReleased = false
129+
116130
private var hasInitialized = false
131+
132+
@Volatile
117133
private var byteBuffer: ByteBuffer? = null
118134

119135
/**
@@ -131,7 +147,15 @@ constructor(
131147

132148
val audioRecord = this.audioRecord ?: return null
133149
val recordBuffer = this.byteBuffer ?: return null
134-
audioRecord.read(recordBuffer, recordBuffer.capacity())
150+
val readResult = audioRecord.read(recordBuffer, recordBuffer.capacity())
151+
if (readResult != recordBuffer.capacity()) {
152+
// On a short or failed read, the buffer contents are stale; skip mixing rather than replay them.
153+
if (readResult < 0) {
154+
LKLog.w { "AudioRecord.read failed: $readResult. Stopping screen share audio capture." }
155+
releaseAudioResources()
156+
}
157+
return null
158+
}
135159

136160
if (abs(gain - DEFAULT_GAIN) > MIN_GAIN_CHANGE) {
137161
recordBuffer.position(0)
@@ -158,45 +182,65 @@ constructor(
158182
return BufferResponse(recordBuffer)
159183
}
160184

185+
/**
186+
* Initializes the [AudioRecord] used to capture the playback audio.
187+
*
188+
* This is handled automatically when used as an audio buffer callback,
189+
* and does not need to be called manually.
190+
*
191+
* @return true if the audio record was successfully created and started. Returns false
192+
* if audio capture is unavailable (for example, if the media projection has been stopped,
193+
* or the capturer has been released), in which case no audio will be mixed.
194+
*/
161195
@SuppressLint("MissingPermission")
162196
fun initAudioRecord(audioFormat: Int, channelCount: Int, sampleRate: Int): Boolean {
163-
val audioCaptureConfig = AudioPlaybackCaptureConfiguration.Builder(mediaProjection)
164-
.apply(captureConfigurator)
165-
.build()
166-
val channelMask = if (channelCount == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
167-
168-
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, audioFormat)
169-
if (minBufferSize == AudioRecord.ERROR || minBufferSize == AudioRecord.ERROR_BAD_VALUE) {
170-
throw IllegalStateException("minBuffer size error: $minBufferSize")
197+
if (channelCount != 1 && channelCount != 2) {
198+
LKLog.e { "Unsupported channel count: $channelCount" }
199+
return false
171200
}
172-
LKLog.v { "AudioRecord.getMinBufferSize: $minBufferSize" }
201+
val audioRecord = try {
202+
val audioCaptureConfig = AudioPlaybackCaptureConfiguration.Builder(mediaProjection)
203+
.apply(captureConfigurator)
204+
.build()
205+
val channelMask = if (channelCount == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO
206+
207+
val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, audioFormat)
208+
if (minBufferSize <= 0) {
209+
LKLog.e { "AudioRecord.getMinBufferSize error: $minBufferSize" }
210+
return false
211+
}
212+
LKLog.v { "AudioRecord.getMinBufferSize: $minBufferSize" }
173213

174-
val bytesPerFrame = channelCount * getBytesPerSample(audioFormat)
175-
val framesPerBuffer = sampleRate / 100
176-
val readBufferCapacity = bytesPerFrame * framesPerBuffer
177-
val byteBuffer = ByteBuffer.allocateDirect(readBufferCapacity)
178-
.order(ByteOrder.nativeOrder())
214+
val bytesPerFrame = channelCount * getBytesPerSample(audioFormat)
215+
val framesPerBuffer = sampleRate / 100
216+
val readBufferCapacity = bytesPerFrame * framesPerBuffer
217+
val byteBuffer = ByteBuffer.allocateDirect(readBufferCapacity)
218+
.order(ByteOrder.nativeOrder())
179219

180-
if (!byteBuffer.hasArray()) {
181-
LKLog.e { "ByteBuffer does not have backing array." }
220+
if (!byteBuffer.hasArray()) {
221+
LKLog.e { "ByteBuffer does not have backing array." }
222+
return false
223+
}
224+
225+
this.byteBuffer = byteBuffer
226+
val bufferSizeInBytes: Int = max(BUFFER_SIZE_FACTOR * minBufferSize, readBufferCapacity)
227+
228+
AudioRecord.Builder()
229+
.setAudioFormat(
230+
AudioFormat.Builder()
231+
.setEncoding(audioFormat)
232+
.setSampleRate(sampleRate)
233+
.setChannelMask(channelMask)
234+
.build(),
235+
)
236+
.setBufferSizeInBytes(bufferSizeInBytes)
237+
.setAudioPlaybackCaptureConfig(audioCaptureConfig)
238+
.build()
239+
} catch (e: Exception) {
240+
LKLog.e(e) { "Failed to create AudioRecord for screen share audio capture:" }
182241
return false
183242
}
184243

185-
this.byteBuffer = byteBuffer
186-
val bufferSizeInBytes: Int = max(BUFFER_SIZE_FACTOR * minBufferSize, readBufferCapacity)
187-
188-
val audioRecord = AudioRecord.Builder()
189-
.setAudioFormat(
190-
AudioFormat.Builder()
191-
.setEncoding(audioFormat)
192-
.setSampleRate(sampleRate)
193-
.setChannelMask(channelMask)
194-
.build(),
195-
)
196-
.setBufferSizeInBytes(bufferSizeInBytes)
197-
.setAudioPlaybackCaptureConfig(audioCaptureConfig)
198-
.build()
199-
200244
try {
201245
audioRecord.startRecording()
202246
} catch (e: Exception) {
@@ -208,10 +252,18 @@ constructor(
208252
LKLog.e {
209253
"AudioRecord.startRecording failed - incorrect state: ${audioRecord.recordingState}"
210254
}
255+
audioRecord.release()
211256
return false
212257
}
213258

214-
this.audioRecord = audioRecord
259+
synchronized(audioRecordLock) {
260+
if (isReleased) {
261+
LKLog.w { "Screen share audio capturer was released while starting up. Discarding the AudioRecord." }
262+
audioRecord.release()
263+
return false
264+
}
265+
this.audioRecord = audioRecord
266+
}
215267

216268
return true
217269
}
@@ -220,12 +272,15 @@ constructor(
220272
* Release any audio resources associated with this capturer.
221273
* This is not managed by LiveKit, so you must call this function
222274
* when finished to prevent memory leaks.
275+
*
276+
* Safe to call from any thread, and at any point relative to [initAudioRecord]. Once released,
277+
* the capturer stays released and no further audio is mixed in.
223278
*/
224279
fun releaseAudioResources() {
225-
val audioRecord = this.audioRecord
226-
if (audioRecord != null) {
227-
audioRecord.release()
228-
this.audioRecord = null
280+
synchronized(audioRecordLock) {
281+
isReleased = true
282+
audioRecord?.release()
283+
audioRecord = null
229284
}
230285
}
231286

0 commit comments

Comments
 (0)