Skip to content

Fixed ScreenAudioCapturer crash, stale audio replay, and AudioRecord leak - #982

Merged
davidliu merged 3 commits into
livekit:mainfrom
adrian-niculescu:fix/screen-audio-capturer-projection-loss
Jul 27, 2026
Merged

Fixed ScreenAudioCapturer crash, stale audio replay, and AudioRecord leak#982
davidliu merged 3 commits into
livekit:mainfrom
adrian-niculescu:fix/screen-audio-capturer-projection-loss

Conversation

@adrian-niculescu

@adrian-niculescu adrian-niculescu commented Jul 26, 2026

Copy link
Copy Markdown
Contributor

When the MediaProjection backing a ScreenAudioCapturer stops (the user ends the share from the status bar chip, or another app takes over the projection), two things go wrong:

  • If it stops before the first mic buffer callback, initAudioRecord throws on WebRTC's audio record thread: AudioRecord.Builder.build() fails with UnsupportedOperationException, and the playback capture config builder can throw IllegalStateException. Only startRecording() was guarded, and the audio record thread has no handler around the buffer callback, so the process dies.
  • Once capture is running, AudioRecord.read() starts failing, but the return value was ignored and the buffer still holds the previous frame. That frame kept getting mixed into the mic track on every callback, so listeners heard the last captured 10 ms looping until the callback was detached.

initAudioRecord now returns false on any creation failure, matching its existing degrade paths, and releases the record if it never reaches the recording state. onBufferRequest only mixes fully read buffers; on a read error it logs, releases the AudioRecord, and the track degrades to mic-only audio. Read errors are not always ERROR_DEAD_OBJECT (Android 17 returns ERROR_BAD_VALUE after the record restore fails), so any negative result is treated as terminal.

The second commit fixes a related problem in the teardown path rather than in projection loss. releaseAudioResources() is called by the app from a thread of its choosing, while initAudioRecord runs on the audio record thread. A release landing between AudioRecord.Builder.build() and the 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 finishing after a release discards its record instead of stranding it.

Verified with the screenshare-audio example on an Android 17 Pixel 6a and and an emulator: stopping the share from the status bar chip while the capturer is attached logs a single AudioRecord.read failed: -2. Stopping screen share audio capture. and the session continues on mic audio with no crash. For the teardown race, driving 100 racing release/init pairs against a real MediaProjection stranded 9 recorders before the fix, and the remaining 91 creation attempts then failed with could not open input for device AUDIO_DEVICE_IN_REMOTE_SUBMIX, so the leak also breaks later capture attempts. After the fix all 100 discard cleanly and none fail. ./gradlew test, spotless, and detekt pass.

@changeset-bot

changeset-bot Bot commented Jul 26, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 048ae62

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
client-sdk-android Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

✅ Devin Review: No Issues Found

Devin Review analyzed this PR and found no bugs or issues to report.

Open in Devin Review

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.
@adrian-niculescu adrian-niculescu changed the title Fixed ScreenAudioCapturer audio thread crash and stale audio replay after MediaProjection stops Fixed ScreenAudioCapturer crash, stale audio replay, and AudioRecord leak Jul 26, 2026
val audioCaptureConfig = AudioPlaybackCaptureConfiguration.Builder(mediaProjection)
.apply(captureConfigurator)
.build()
val channelMask = if (channelCount == 1) AudioFormat.CHANNEL_IN_MONO else AudioFormat.CHANNEL_IN_STEREO

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, can you add an assert in this function that channelCount is either 1 or 2 ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added a check, though as an explicit guard rather than an assert: assertions are normally disabled on Android, and when enabled the AssertionError would escape the existing catch (Exception) and take down the process from WebRTC's audio record thread - the same failure mode this PR removes. An invalid channel count now logs and returns false, same as the other degrade paths.

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) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit, will be be slightly less error prone to do
if (minBufferSize <= 0) {
LKLog.e { "AudioRecord.getMinBufferSize error: $minBufferSize" }
return false
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Indeed, changed to <= 0, since a successful minimum buffer size is always positive.

}
LKLog.v { "AudioRecord.getMinBufferSize: $minBufferSize" }
val audioRecord = try {
val audioCaptureConfig = AudioPlaybackCaptureConfiguration.Builder(mediaProjection)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think AudioPlaybackCaptureConfiguration requires secific Android version, any idea if that is higher than our SDK's minimal supported version ?

@adrian-niculescu adrian-niculescu Jul 27, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Playback capture requires Android 10: both AudioPlaybackCaptureConfiguration and AudioRecord.Builder.setAudioPlaybackCaptureConfig were added in API 29, while the SDK minimum is 21. ScreenAudioCapturer is already annotated @RequiresApi(Build.VERSION_CODES.Q), so lint requires callers whose minimum is below 29 to guard its use. The SDK does not invoke this feature automatically; consumers explicitly call createFromScreenShareTrack. Since Android has no public playback-capture API before API 29, @RequiresApi is the appropriate contract. The annotation predates this PR.

synchronized(audioRecordLock) {
isReleased = true
audioRecord?.release()
audioRecord = null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

rather than setting isReleased to true here, should we allow the app to auto recover from some read failures?

Like if (!isReleased) { hasInitialized = false }

So that the onBufferRequest() will recreate the buffer again.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think resetting hasInitialized on every negative read is a safe recovery policy. It retries immediately on the next callback, including after unrecoverable projection loss, and if recreation succeeds but reads keep failing it turns into repeated create/destroy churn. releaseAudioResources() is also the app's explicit terminal teardown path, and without the terminal flag a release racing initAudioRecord can strand a recording AudioRecord again, which this PR fixes. Reliable recovery would need a separate internal teardown path plus bounded retries with backoff. I would keep the deterministic mic-only fallback here and treat audioserver-crash recovery separately if we get a reproducible case for it.

// 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()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

From this code, it seems that one negative read will permanently disable screen share audio until the apps restart the screen audio capturer ?

What negative errors audioRecord.read() can give out ? should some of the errors be recoverable ?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct: any negative read makes this ScreenAudioCapturer instance terminal, so resuming screen audio requires installing a new capturer. read(ByteBuffer, int) documents ERROR_INVALID_OPERATION (-3), ERROR_BAD_VALUE (-2), ERROR_DEAD_OBJECT (-6), and ERROR (-1). ERROR_DEAD_OBJECT is the only one that explicitly calls for recreating the AudioRecord. However, the projection-stop case this PR fixes returned ERROR_BAD_VALUE on Android 17 after the platform's own record restore failed, so the error code does not reliably tell us whether the projection is still usable. For projection loss, falling back permanently to mic-only audio is intentional: a new share requires a new MediaProjection and therefore a new capturer.

@davidliu davidliu left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for the PR!

@davidliu
davidliu merged commit 4892817 into livekit:main Jul 27, 2026
6 checks passed
@davidliu davidliu mentioned this pull request Jul 27, 2026
@adrian-niculescu

Copy link
Copy Markdown
Contributor Author

Thank you for the swift review!

@adrian-niculescu
adrian-niculescu deleted the fix/screen-audio-capturer-projection-loss branch July 27, 2026 14:12
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants