Skip to content
Merged
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,9 @@
## [Unreleased]

### Added
* Added `audioSendJitterMs`, `audioReceiveJitterMs`, and `audioRttMs` to `ObservableMetric` for audio quality monitoring
* Added `realtimePlaybackMute()` and `realtimePlaybackUnmute()` to `RealtimeControllerFacade` for muting/unmuting audio output

## [0.25.3] - 2026-02-06

### Fixed
Expand Down
12 changes: 11 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -384,14 +384,24 @@ meetingSession.audioVideo.start(audioVideoConfiguration) // starts the audio vid

> Note: So far, you've added observers to receive device and session lifecycle events. In the following use cases, you'll use the real-time API methods to send and receive volume indicators and control mute state.

#### Use case 9. Mute and unmute an audio input.
#### Use case 9. Mute and unmute audio input and output.

To mute and unmute audio input:

```kotlin
val muted = meetingSession.audioVideo.realtimeLocalMute() // returns true if muted, false if failed

val unmuted = meetingSession.audioVideo.realtimeLocalUnmute() // returns true if unmuted, false if failed
```

To mute and unmute audio playback (output):

```kotlin
val muted = meetingSession.audioVideo.realtimePlaybackMute() // returns true if muted, false if failed

val unmuted = meetingSession.audioVideo.realtimePlaybackUnmute() // returns true if unmuted, false if failed
```

#### Use case 10. Add an observer to receive realtime events such as volume changes/signal change/muted status attendees.

You can use this to build real-time indicators UI and get them updated for changes delivered by the array.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,14 @@ class DefaultAudioVideoFacade(
return realtimeController.realtimeLocalUnmute()
}

override fun realtimePlaybackMute(): Boolean {
return realtimeController.realtimePlaybackMute()
}

override fun realtimePlaybackUnmute(): Boolean {
return realtimeController.realtimePlaybackUnmute()
}

override fun addRealtimeObserver(observer: RealtimeObserver) {
realtimeController.addRealtimeObserver(observer)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,21 @@ enum class ObservableMetric {
*/
audioSendPacketLossPercent,

/**
* Upstream audio jitter (ms)
*/
audioSendJitterMs,

/**
* Downstream audio jitter (ms)
*/
audioReceiveJitterMs,

/**
* Audio round-trip time (ms) between client and server
*/
audioRttMs,

/**
* Estimated uplink bandwidth from perspective of video client
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ interface AudioClientController {

fun stop()
fun setMute(isMuted: Boolean): Boolean
fun setPlaybackMute(isMuted: Boolean): Boolean
fun setVoiceFocusEnabled(enabled: Boolean): Boolean
fun isVoiceFocusEnabled(): Boolean
fun promoteToPrimaryMeeting(credentials: MeetingSessionCredentials, observer: PrimaryMeetingPromotionObserver)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,12 @@ class DefaultAudioClientController(
)
}

override fun setPlaybackMute(isMuted: Boolean): Boolean {
return audioClientState == AudioClientState.STARTED && AudioClient.AUDIO_CLIENT_OK == audioClient.setSpkMute(
isMuted
)
}

override fun setVoiceFocusEnabled(enabled: Boolean): Boolean {
if (audioClientState == AudioClientState.STARTED) {
val result = audioClient.setVoiceFocusNoiseSuppression(enabled)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ class DefaultClientMetricsCollector :
metrics[AudioClient.AUDIO_CLIENT_METRIC_POST_JB_SPK_1S_PACKETS_LOST_PERCENT]
cachedObservableMetrics[ObservableMetric.audioSendPacketLossPercent] =
metrics[AudioClient.AUDIO_SERVER_METRIC_POST_JB_MIC_1S_PACKETS_LOST_PERCENT]
cachedObservableMetrics[ObservableMetric.audioSendJitterMs] =
metrics[AudioClient.AUDIO_SERVER_METRIC_MIC_JITTER_MS]
cachedObservableMetrics[ObservableMetric.audioReceiveJitterMs] =
metrics[AudioClient.AUDIO_CLIENT_METRIC_SPK_JITTER_MS]
cachedObservableMetrics[ObservableMetric.audioRttMs] =
metrics[AudioClient.AUDIO_CLIENT_METRIC_RTT_MS]
maybeEmitMetrics()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@ class DefaultRealtimeController(
return audioClientController.setMute(false)
}

override fun realtimePlaybackMute(): Boolean {
return audioClientController.setPlaybackMute(true)
}

override fun realtimePlaybackUnmute(): Boolean {
return audioClientController.setPlaybackMute(false)
}

override fun addRealtimeObserver(observer: RealtimeObserver) {
audioClientObserver.subscribeToRealTimeEvents(observer)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,20 @@ interface RealtimeControllerFacade {
*/
fun realtimeLocalUnmute(): Boolean

/**
* Mutes the audio output (speaker).
*
* @return Boolean whether the mute action succeeded
*/
fun realtimePlaybackMute(): Boolean

/**
* Unmutes the audio output (speaker).
*
* @return Boolean whether the unmute action succeeded
*/
fun realtimePlaybackUnmute(): Boolean

/**
* Subscribes to real time events with an observer
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,20 @@ class DefaultRealtimeControllerTest {
verify { audioClientObserver.unsubscribeFromTranscriptEvent(mockTranscriptEventObserver) }
}

@Test
fun `realtimePlaybackMute should call audioClientController setPlaybackMute with true and return the status`() {
every { audioClientController.setPlaybackMute(true) } returns true
assertTrue(realtimeController.realtimePlaybackMute())
verify { audioClientController.setPlaybackMute(true) }
}

@Test
fun `realtimePlaybackUnmute should call audioClientController setPlaybackMute with false and return the status`() {
every { audioClientController.setPlaybackMute(false) } returns true
assertTrue(realtimeController.realtimePlaybackUnmute())
verify { audioClientController.setPlaybackMute(false) }
}

@Test
fun `realtimeSetVoiceFocusEnabled(true) should call audioClientController setVoiceFocusEnabled with true and return the status`() {
every { audioClientController.setVoiceFocusEnabled(true) } returns true
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -645,7 +645,8 @@ class MeetingFragment : Fragment(),
context?.getString(if (meetingModel.isUsingCpuVideoProcessor) R.string.disable_cpu_filter else R.string.enable_cpu_filter),
context?.getString(if (meetingModel.isUsingGpuVideoProcessor) R.string.disable_gpu_filter else R.string.enable_gpu_filter),
context?.getString(if (meetingModel.isUsingCameraCaptureSource) R.string.disable_custom_capture_source else R.string.enable_custom_capture_source),
context?.getString(R.string.video_configuration)
context?.getString(R.string.video_configuration),
context?.getString(if (meetingModel.isPlaybackMuted) R.string.unmute_playback else R.string.mute_playback)
)
if (inReplicaMeeting()) {
additionalToggles.add(context?.getString(R.string.demote_from_primary_meeting))
Expand All @@ -662,7 +663,8 @@ class MeetingFragment : Fragment(),
4 -> toggleGpuDemoFilter()
5 -> toggleCustomCaptureSource()
6 -> presentVideoConfigDialog()
7 -> { // May not be accessible
7 -> togglePlaybackMute()
8 -> { // May not be accessible
if (inReplicaMeeting()) {
demoteFromPrimaryMeeting()
} else {
Expand Down Expand Up @@ -1003,6 +1005,17 @@ class MeetingFragment : Fragment(),
}
}

private fun togglePlaybackMute() {
val mute = !meetingModel.isPlaybackMuted
val success = if (mute) audioVideo.realtimePlaybackMute() else audioVideo.realtimePlaybackUnmute()
if (success) {
meetingModel.isPlaybackMuted = mute
notifyHandler("Playback ${if (mute) "muted" else "unmuted"}")
} else {
notifyHandler("Failed to ${if (mute) "mute" else "unmute"} playback")
}
}

private fun toggleLiveTranscription(meetingId: String, meetingEndpointUrl: String) {
if (meetingModel.isLiveTranscriptionEnabled) {
uiScope.launch {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class MeetingModel : ViewModel() {
val currentCaptionIndices = mutableMapOf<String, Int>()

var isMuted = false
var isPlaybackMuted = false
var isCameraOn = false
var isDeviceListDialogOn = false
var isAdditionalOptionsDialogOn = false
Expand Down
2 changes: 2 additions & 0 deletions app/src/main/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,8 @@
<string name="disable_screen_capture_source">Stop sharing screen</string>
<string name="enable_voice_focus">Turn on Voice Focus (noise suppression)</string>
<string name="disable_voice_focus">Turn off Voice Focus (noise suppression)</string>
<string name="mute_playback">Mute Playback</string>
<string name="unmute_playback">Unmute Playback</string>
<string name="enable_live_transcription">Turn on Live Transcription</string>
<string name="disable_live_transcription">Turn off Live Transcription</string>
<string name="enable_flashlight">Turn on flashlight</string>
Expand Down
Loading