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/stop-microphone-on-mute.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
*
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -48,6 +51,10 @@ class LocalTrackPublication(

participant.engine.updateMuteStatus(sid, muted)

if (mediaTrack is LocalAudioTrack) {
participant.updateAudioRecordingState()
}

if (muted) {
participant.onTrackMuted(this)
} else {
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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(
Expand Down
Loading