Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/screen-audio-capturer-projection-loss.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"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.

`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.
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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

/**
Expand All @@ -131,7 +147,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()

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.

}
return null
}

if (abs(gain - DEFAULT_GAIN) > MIN_GAIN_CHANGE) {
recordBuffer.position(0)
Expand All @@ -158,45 +182,65 @@ 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,
* 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 {
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")
if (channelCount != 1 && channelCount != 2) {
LKLog.e { "Unsupported channel count: $channelCount" }
return false
}
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.

.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 minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelMask, audioFormat)
if (minBufferSize <= 0) {
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." }
if (!byteBuffer.hasArray()) {
LKLog.e { "ByteBuffer does not have backing array." }
return false
}

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) {
Expand All @@ -208,10 +252,18 @@ constructor(
LKLog.e {
"AudioRecord.startRecording failed - incorrect state: ${audioRecord.recordingState}"
}
audioRecord.release()
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
}
Expand All @@ -220,12 +272,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

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.

}
}

Expand Down