From fe05d1c6306149feb9e8d2e3350fb5061ec19ff0 Mon Sep 17 00:00:00 2001 From: Adrian Niculescu <15037449+adrian-niculescu@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:16:35 +0300 Subject: [PATCH] Added stopMicrophoneOnMute option to release the microphone while muted Also fixed local track publication jobs leaking on full reconnect. --- .changeset/stop-microphone-on-mute.md | 7 ++ .../room/participant/LocalParticipant.kt | 64 +++++++++- .../room/track/LocalAudioTrackOptions.kt | 19 +++ .../room/track/LocalTrackPublication.kt | 9 +- .../android/test/mock/MockPeerConnection.kt | 9 +- .../LocalParticipantMockE2ETest.kt | 111 ++++++++++++++++++ 6 files changed, 215 insertions(+), 4 deletions(-) create mode 100644 .changeset/stop-microphone-on-mute.md diff --git a/.changeset/stop-microphone-on-mute.md b/.changeset/stop-microphone-on-mute.md new file mode 100644 index 000000000..74b99ef14 --- /dev/null +++ b/.changeset/stop-microphone-on-mute.md @@ -0,0 +1,7 @@ +--- +"client-sdk-android": minor +--- + +Add `LocalAudioTrackOptions.stopMicrophoneOnMute`, which stops microphone capture while the audio track is muted. This releases the microphone for other apps and turns off the OS microphone-in-use indicator, without unpublishing the track. Capture restarts automatically on unmute. + +Also fix local track publications leaking their jobs on every full reconnect, which left the audio feature collectors of the old publications running and sending feature updates for stale track sids. diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt index a1fe88614..b71c37840 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt @@ -474,8 +474,17 @@ internal constructor( if (publication != null) { val job = scope.launch { - track::features.flow.collect { - engine.updateLocalAudioTrack(publication.sid, it + options.getFeaturesList()) + launch { + track::features.flow.collect { + engine.updateLocalAudioTrack(publication.sid, it + options.getFeaturesList()) + } + } + launch { + // Collection starts with the current value, reconciling on publish and + // whenever applyOptions changes stopMicrophoneOnMute while published. + track::options.flow.collect { + updateAudioRecordingState() + } } } jobs[publication] = job @@ -484,6 +493,46 @@ internal constructor( return publication != null } + /** + * Tracks whether microphone recording has been stopped through + * `PeerConnection.setAudioRecording`, so recording is never touched unless + * [LocalAudioTrackOptions.stopMicrophoneOnMute] is in use. Only accessed + * from within `withPeerConnection` blocks, which serialize on the RTC thread. + */ + private var audioRecordingStopped = false + + /** + * Starts or stops microphone recording to match the current mute state of + * the published audio tracks. + * + * Recording is controlled through the factory-scoped native AudioState, so + * the setting survives peer connection recreations, and a republish while + * recording is stopped will not reopen the microphone. + */ + internal fun updateAudioRecordingState() { + scope.launch { + engine.publisher?.withPeerConnection { + // Evaluate here so rapid mute toggles converge on the latest state + // regardless of coroutine dispatch order. + val stopRecording = shouldStopAudioRecording() + if (stopRecording != audioRecordingStopped) { + setAudioRecording(!stopRecording) + audioRecordingStopped = stopRecording + } + } + } + } + + private fun shouldStopAudioRecording(): Boolean { + val audioTracks = localTrackPublications.mapNotNull { publication -> + val track = publication.track as? LocalAudioTrack ?: return@mapNotNull null + publication to track + } + return audioTracks.isNotEmpty() && + audioTracks.all { (publication, _) -> publication.muted } && + audioTracks.any { (_, track) -> track.options.stopMicrophoneOnMute } + } + /** * Publishes an video track. * @@ -971,6 +1020,10 @@ internal constructor( if (stopOnUnpublish) { track.stop() } + if (track is LocalAudioTrack) { + // Restores microphone recording if it was stopped on account of this track. + updateAudioRecordingState() + } internalListener?.onTrackUnpublished(publication, this) eventBus.postEvent(ParticipantEvent.LocalTrackUnpublished(this, publication), scope) } @@ -1278,6 +1331,13 @@ internal constructor( republishes = pubs } + // The publications are cleared below, so unpublishTrack can't cancel these + // jobs during republishing. Republishing creates fresh jobs for the new + // publications. + for (publication in pubs) { + jobs.remove(publication)?.cancel() + } + trackPublications = trackPublications.toMutableMap().apply { clear() } for (publication in pubs) { diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalAudioTrackOptions.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalAudioTrackOptions.kt index 25be2db8b..675ed9fff 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalAudioTrackOptions.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalAudioTrackOptions.kt @@ -24,6 +24,25 @@ data class LocalAudioTrackOptions( val autoGainControl: Boolean = true, val highPassFilter: Boolean = true, val typingNoiseDetection: Boolean = true, + /** + * Whether to stop microphone capture while this track's publication is muted. + * + * Stopping capture releases the microphone so other apps can record from it, + * and turns off the OS microphone-in-use indicator. Capture automatically + * restarts when the publication is unmuted. + * + * Microphone capture is shared by all local audio tracks, so it is only + * stopped while every published local audio track is muted. + * + * Defaults to false, which keeps the microphone open while muted for + * instant unmutes. + * + * Example: + * ``` + * room.audioTrackCaptureDefaults = room.audioTrackCaptureDefaults.copy(stopMicrophoneOnMute = true) + * ``` + */ + val stopMicrophoneOnMute: Boolean = false, ) internal fun LocalAudioTrackOptions.toAudioProcessingOptions(): AudioProcessingOptions { diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalTrackPublication.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalTrackPublication.kt index 2d9eeb83d..3eab85e00 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalTrackPublication.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/track/LocalTrackPublication.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2024 LiveKit, Inc. + * Copyright 2023-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. @@ -30,6 +30,9 @@ class LocalTrackPublication( /** * Mute or unmute the current track. Muting the track would stop audio or video from being * transmitted to the server, and notify other participants in the room. + * + * Muting an audio track with [LocalAudioTrackOptions.stopMicrophoneOnMute] enabled also + * stops the microphone capture, releasing the microphone while muted. */ override var muted: Boolean get() = super.muted @@ -48,6 +51,10 @@ class LocalTrackPublication( participant.engine.updateMuteStatus(sid, muted) + if (mediaTrack is LocalAudioTrack) { + participant.updateAudioRecordingState() + } + if (muted) { participant.onTrackMuted(this) } else { diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockPeerConnection.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockPeerConnection.kt index e29924242..02021ea90 100644 --- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockPeerConnection.kt +++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/MockPeerConnection.kt @@ -1,5 +1,5 @@ /* - * Copyright 2023-2025 LiveKit, Inc. + * Copyright 2023-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. @@ -121,7 +121,14 @@ class MockPeerConnection( override fun setAudioPlayout(playout: Boolean) { } + var audioRecording = true + private set + var setAudioRecordingCallCount = 0 + private set + override fun setAudioRecording(recording: Boolean) { + setAudioRecordingCallCount++ + audioRecording = recording } override fun setConfiguration(config: RTCConfiguration): Boolean { diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt index 1f300ca7f..1fc4e7ee1 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/participant/LocalParticipantMockE2ETest.kt @@ -26,6 +26,8 @@ import io.livekit.android.events.RoomEvent import io.livekit.android.room.DefaultsManager import io.livekit.android.room.RTCEngine import io.livekit.android.room.RoomException +import io.livekit.android.room.track.LocalAudioTrackOptions +import io.livekit.android.room.track.LocalTrackPublication import io.livekit.android.room.track.LocalVideoTrack import io.livekit.android.room.track.LocalVideoTrackOptions import io.livekit.android.room.track.ScreenSharePresets @@ -38,6 +40,7 @@ import io.livekit.android.test.MockE2ETest import io.livekit.android.test.assert.assertIsClassList import io.livekit.android.test.coroutines.toListUntilSignal import io.livekit.android.test.events.EventCollector +import io.livekit.android.test.mock.MockAudioStreamTrack import io.livekit.android.test.mock.MockDataChannel import io.livekit.android.test.mock.MockEglBase import io.livekit.android.test.mock.MockRTCThreadToken @@ -249,6 +252,114 @@ class LocalParticipantMockE2ETest : MockE2ETest() { transceivers.forEach { Mockito.verify(it).stopInternal() } } + @Test + fun muteStopsMicrophoneRecordingWhenOptedIn() = runTest { + connect() + room.localParticipant.publishAudioTrack( + track = createMockLocalAudioTrack(options = LocalAudioTrackOptions(stopMicrophoneOnMute = true)), + ) + val publication = room.localParticipant.trackPublications.values.first() as LocalTrackPublication + val publisherConnection = getPublisherPeerConnection() + + publication.muted = true + assertFalse(publisherConnection.audioRecording) + + publication.muted = false + assertTrue(publisherConnection.audioRecording) + } + + @Test + fun muteKeepsMicrophoneRecordingByDefault() = runTest { + connect() + room.localParticipant.publishAudioTrack(track = createMockLocalAudioTrack()) + val publication = room.localParticipant.trackPublications.values.first() as LocalTrackPublication + + publication.muted = true + publication.muted = false + room.localParticipant.unpublishTrack(publication.track!!) + + val publisherConnection = getPublisherPeerConnection() + assertTrue(publisherConnection.audioRecording) + assertEquals(0, publisherConnection.setAudioRecordingCallCount) + } + + @Test + fun microphoneRecordingStopsOnlyWhenAllAudioTracksAreMuted() = runTest { + connect() + room.localParticipant.publishAudioTrack( + track = createMockLocalAudioTrack(options = LocalAudioTrackOptions(stopMicrophoneOnMute = true)), + ) + + val secondCid = "second_audio_cid" + val secondSid = "TR_second_audio" + wsFactory.registerSignalRequestHandler { request -> + if (request.hasAddTrack() && request.addTrack.cid == secondCid) { + wsFactory.receiveMessage( + with(LivekitRtc.SignalResponse.newBuilder()) { + trackPublished = with(LivekitRtc.TrackPublishedResponse.newBuilder()) { + cid = secondCid + track = TestData.LOCAL_AUDIO_TRACK.toBuilder().setSid(secondSid).build() + build() + } + build() + }, + ) + true + } else { + false + } + } + room.localParticipant.publishAudioTrack( + track = createMockLocalAudioTrack(mediaTrack = MockAudioStreamTrack(id = secondCid)), + ) + + val micPublication = room.localParticipant.trackPublications[TestData.LOCAL_AUDIO_TRACK.sid] as LocalTrackPublication + val secondPublication = room.localParticipant.trackPublications[secondSid] as LocalTrackPublication + val publisherConnection = getPublisherPeerConnection() + + micPublication.muted = true + assertTrue(publisherConnection.audioRecording) + + secondPublication.muted = true + assertFalse(publisherConnection.audioRecording) + + secondPublication.muted = false + assertTrue(publisherConnection.audioRecording) + } + + @Test + fun applyOptionsWhileMutedUpdatesMicrophoneRecording() = runTest { + connect() + val audioTrack = createMockLocalAudioTrack() + room.localParticipant.publishAudioTrack(track = audioTrack) + val publication = room.localParticipant.trackPublications.values.first() as LocalTrackPublication + val publisherConnection = getPublisherPeerConnection() + + publication.muted = true + assertTrue(publisherConnection.audioRecording) + + assertTrue(audioTrack.applyOptions(audioTrack.options.copy(stopMicrophoneOnMute = true)).isSuccess) + assertFalse(publisherConnection.audioRecording) + + assertTrue(audioTrack.applyOptions(audioTrack.options.copy(stopMicrophoneOnMute = false)).isSuccess) + assertTrue(publisherConnection.audioRecording) + } + + @Test + fun unpublishWhileMutedRestoresMicrophoneRecording() = runTest { + connect() + val audioTrack = createMockLocalAudioTrack(options = LocalAudioTrackOptions(stopMicrophoneOnMute = true)) + room.localParticipant.publishAudioTrack(track = audioTrack) + val publication = room.localParticipant.trackPublications.values.first() as LocalTrackPublication + val publisherConnection = getPublisherPeerConnection() + + publication.muted = true + assertFalse(publisherConnection.audioRecording) + + room.localParticipant.unpublishTrack(audioTrack) + assertTrue(publisherConnection.audioRecording) + } + @Test fun republishAfterBackupCodecUnpublishCreatesNewBackupTransceiver() = runTest { room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy(