From 4bb1d15fe9d134103ce511823bd69d73648ee733 Mon Sep 17 00:00:00 2001 From: Adrian Niculescu <15037449+adrian-niculescu@users.noreply.github.com> Date: Sun, 26 Jul 2026 05:01:17 +0300 Subject: [PATCH 1/3] Fixed ScreenAudioCapturer audio thread crash and stale audio replay after MediaProjection stops --- .../screen-audio-capturer-projection-loss.md | 5 + .../android/audio/ScreenAudioCapturer.kt | 93 ++++++++++++------- 2 files changed, 64 insertions(+), 34 deletions(-) create mode 100644 .changeset/screen-audio-capturer-projection-loss.md diff --git a/.changeset/screen-audio-capturer-projection-loss.md b/.changeset/screen-audio-capturer-projection-loss.md new file mode 100644 index 000000000..8a2881dc1 --- /dev/null +++ b/.changeset/screen-audio-capturer-projection-loss.md @@ -0,0 +1,5 @@ +--- +"client-sdk-android": patch +--- + +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. diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt b/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt index 248f6d9fe..8fe23b8ab 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt @@ -1,5 +1,5 @@ /* - * Copyright 2024-2025 LiveKit, Inc. + * Copyright 2024-2026 LiveKit, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -131,7 +131,15 @@ constructor( val audioRecord = this.audioRecord ?: return null val recordBuffer = this.byteBuffer ?: return null - audioRecord.read(recordBuffer, recordBuffer.capacity()) + val readResult = audioRecord.read(recordBuffer, recordBuffer.capacity()) + if (readResult != recordBuffer.capacity()) { + // On a short or failed read, the buffer contents are stale; skip mixing rather than replay them. + if (readResult < 0) { + LKLog.w { "AudioRecord.read failed: $readResult. Stopping screen share audio capture." } + releaseAudioResources() + } + return null + } if (abs(gain - DEFAULT_GAIN) > MIN_GAIN_CHANGE) { recordBuffer.position(0) @@ -158,45 +166,61 @@ constructor( return BufferResponse(recordBuffer) } + /** + * Initializes the [AudioRecord] used to capture the playback audio. + * + * This is handled automatically when used as an audio buffer callback, + * and does not need to be called manually. + * + * @return true if the audio record was successfully created and started. Returns false + * if audio capture is unavailable (for example, if the media projection has been stopped), + * in which case no audio will be mixed. + */ @SuppressLint("MissingPermission") fun initAudioRecord(audioFormat: Int, channelCount: Int, sampleRate: Int): Boolean { - val audioCaptureConfig = AudioPlaybackCaptureConfiguration.Builder(mediaProjection) - .apply(captureConfigurator) - .build() - val channelMask = if (channelCount == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO - - val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, audioFormat) - if (minBufferSize == AudioRecord.ERROR || minBufferSize == AudioRecord.ERROR_BAD_VALUE) { - throw IllegalStateException("minBuffer size error: $minBufferSize") - } - LKLog.v { "AudioRecord.getMinBufferSize: $minBufferSize" } + val audioRecord = try { + val audioCaptureConfig = AudioPlaybackCaptureConfiguration.Builder(mediaProjection) + .apply(captureConfigurator) + .build() + val channelMask = if (channelCount == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO + + val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, audioFormat) + if (minBufferSize == AudioRecord.ERROR || minBufferSize == AudioRecord.ERROR_BAD_VALUE) { + LKLog.e { "AudioRecord.getMinBufferSize error: $minBufferSize" } + return false + } + LKLog.v { "AudioRecord.getMinBufferSize: $minBufferSize" } + + val bytesPerFrame = channelCount * getBytesPerSample(audioFormat) + val framesPerBuffer = sampleRate / 100 + val readBufferCapacity = bytesPerFrame * framesPerBuffer + val byteBuffer = ByteBuffer.allocateDirect(readBufferCapacity) + .order(ByteOrder.nativeOrder()) - val bytesPerFrame = channelCount * getBytesPerSample(audioFormat) - val framesPerBuffer = sampleRate / 100 - val readBufferCapacity = bytesPerFrame * framesPerBuffer - val byteBuffer = ByteBuffer.allocateDirect(readBufferCapacity) - .order(ByteOrder.nativeOrder()) + if (!byteBuffer.hasArray()) { + LKLog.e { "ByteBuffer does not have backing array." } + return false + } - if (!byteBuffer.hasArray()) { - LKLog.e { "ByteBuffer does not have backing array." } + this.byteBuffer = byteBuffer + val bufferSizeInBytes: Int = max(BUFFER_SIZE_FACTOR * minBufferSize, readBufferCapacity) + + AudioRecord.Builder() + .setAudioFormat( + AudioFormat.Builder() + .setEncoding(audioFormat) + .setSampleRate(sampleRate) + .setChannelMask(channelMask) + .build(), + ) + .setBufferSizeInBytes(bufferSizeInBytes) + .setAudioPlaybackCaptureConfig(audioCaptureConfig) + .build() + } catch (e: Exception) { + LKLog.e(e) { "Failed to create AudioRecord for screen share audio capture:" } return false } - this.byteBuffer = byteBuffer - val bufferSizeInBytes: Int = max(BUFFER_SIZE_FACTOR * minBufferSize, readBufferCapacity) - - val audioRecord = AudioRecord.Builder() - .setAudioFormat( - AudioFormat.Builder() - .setEncoding(audioFormat) - .setSampleRate(sampleRate) - .setChannelMask(channelMask) - .build(), - ) - .setBufferSizeInBytes(bufferSizeInBytes) - .setAudioPlaybackCaptureConfig(audioCaptureConfig) - .build() - try { audioRecord.startRecording() } catch (e: Exception) { @@ -208,6 +232,7 @@ constructor( LKLog.e { "AudioRecord.startRecording failed - incorrect state: ${audioRecord.recordingState}" } + audioRecord.release() return false } From 80760fc3b99abe5213c1ab50c40fa579190af108 Mon Sep 17 00:00:00 2001 From: Adrian Niculescu <15037449+adrian-niculescu@users.noreply.github.com> Date: Sun, 26 Jul 2026 11:14:22 +0300 Subject: [PATCH 2/3] 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. --- .../screen-audio-capturer-projection-loss.md | 2 + .../android/audio/ScreenAudioCapturer.kt | 40 +++++++++++++++---- 2 files changed, 35 insertions(+), 7 deletions(-) diff --git a/.changeset/screen-audio-capturer-projection-loss.md b/.changeset/screen-audio-capturer-projection-loss.md index 8a2881dc1..2a8470884 100644 --- a/.changeset/screen-audio-capturer-projection-loss.md +++ b/.changeset/screen-audio-capturer-projection-loss.md @@ -3,3 +3,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. + +`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. diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt b/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt index 8fe23b8ab..5a1f2bf98 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt @@ -111,9 +111,25 @@ constructor( */ private val captureConfigurator: AudioPlaybackCaptureConfigurator = DEFAULT_CONFIGURATOR, ) : MixerAudioBufferCallback() { + /** + * Guards publication and release of [audioRecord]. [initAudioRecord] runs on WebRTC's audio + * record thread, while [releaseAudioResources] is called by the app from a thread of its + * choosing. + */ + private val audioRecordLock = Any() + + @Volatile private var audioRecord: AudioRecord? = null + /** + * Terminal once set: releasing is what an app does when it is done capturing, and a capturer + * is tied to the single [MediaProjection] it was constructed with. + */ + private var isReleased = false + private var hasInitialized = false + + @Volatile private var byteBuffer: ByteBuffer? = null /** @@ -173,8 +189,8 @@ constructor( * and does not need to be called manually. * * @return true if the audio record was successfully created and started. Returns false - * if audio capture is unavailable (for example, if the media projection has been stopped), - * in which case no audio will be mixed. + * if audio capture is unavailable (for example, if the media projection has been stopped, + * or the capturer has been released), in which case no audio will be mixed. */ @SuppressLint("MissingPermission") fun initAudioRecord(audioFormat: Int, channelCount: Int, sampleRate: Int): Boolean { @@ -236,7 +252,14 @@ constructor( return false } - this.audioRecord = audioRecord + synchronized(audioRecordLock) { + if (isReleased) { + LKLog.w { "Screen share audio capturer was released while starting up. Discarding the AudioRecord." } + audioRecord.release() + return false + } + this.audioRecord = audioRecord + } return true } @@ -245,12 +268,15 @@ constructor( * Release any audio resources associated with this capturer. * This is not managed by LiveKit, so you must call this function * when finished to prevent memory leaks. + * + * Safe to call from any thread, and at any point relative to [initAudioRecord]. Once released, + * the capturer stays released and no further audio is mixed in. */ fun releaseAudioResources() { - val audioRecord = this.audioRecord - if (audioRecord != null) { - audioRecord.release() - this.audioRecord = null + synchronized(audioRecordLock) { + isReleased = true + audioRecord?.release() + audioRecord = null } } From 048ae62b9bda2c7c59a3443682bd320bbbe19f7f Mon Sep 17 00:00:00 2001 From: Adrian Niculescu <15037449+adrian-niculescu@users.noreply.github.com> Date: Mon, 27 Jul 2026 12:20:18 +0300 Subject: [PATCH 3/3] Validated channel count and simplified the min buffer size check in initAudioRecord --- .../java/io/livekit/android/audio/ScreenAudioCapturer.kt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt b/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt index 5a1f2bf98..a18c9fe4b 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/audio/ScreenAudioCapturer.kt @@ -194,6 +194,10 @@ constructor( */ @SuppressLint("MissingPermission") fun initAudioRecord(audioFormat: Int, channelCount: Int, sampleRate: Int): Boolean { + if (channelCount != 1 && channelCount != 2) { + LKLog.e { "Unsupported channel count: $channelCount" } + return false + } val audioRecord = try { val audioCaptureConfig = AudioPlaybackCaptureConfiguration.Builder(mediaProjection) .apply(captureConfigurator) @@ -201,7 +205,7 @@ constructor( val channelMask = if (channelCount == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO val minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, audioFormat) - if (minBufferSize == AudioRecord.ERROR || minBufferSize == AudioRecord.ERROR_BAD_VALUE) { + if (minBufferSize <= 0) { LKLog.e { "AudioRecord.getMinBufferSize error: $minBufferSize" } return false }