Skip to content
Merged
Show file tree
Hide file tree
Changes from 31 commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f2edf99
feat(core): runtime control for the platform noise suppressor
aleksandar-apostolov Aug 26, 2026
996dff2
feat(core): runtime control for WebRTC's software audio processing
aleksandar-apostolov Aug 27, 2026
02fec0b
feat(demo-app): debug toggles for the microphone processing controls
aleksandar-apostolov Aug 27, 2026
f2fec06
feat(core): runtime audio bitrate control, and the stats to verify it
aleksandar-apostolov Aug 27, 2026
b997d47
Added toggle for changing mode betweeb in_communication and mode_norm…
PratimMallick Aug 27, 2026
749d655
feat(core): one-call audio profile switch for a running call
aleksandar-apostolov Sep 3, 2026
2572ae6
refactor(core): make setAudioBitrateProfile the only audio profile API
aleksandar-apostolov Sep 3, 2026
ce22c65
build(core): give the unit test JVM enough heap for Robolectric
aleksandar-apostolov Sep 3, 2026
8b097f3
fix(core): only publish an audio profile the audio actually made
aleksandar-apostolov Sep 4, 2026
4717c16
fix(core): put the profile-derived state back when a switch does not …
aleksandar-apostolov Sep 4, 2026
589a9ba
fix(core): report a rejected audio bitrate update instead of assuming…
aleksandar-apostolov Sep 7, 2026
05b3479
fix(core): keep the requested audio mode across AudioSwitch route cha…
aleksandar-apostolov Sep 7, 2026
aa356bf
fix(core): report the audio codec per direction
aleksandar-apostolov Sep 7, 2026
aea9e14
fix(core): carry the live track's state across an audio source swap
aleksandar-apostolov Sep 7, 2026
baed7e7
test(core): cover the audio profile stages and the stats behind them
aleksandar-apostolov Sep 7, 2026
4734586
Merge remote-tracking branch 'origin/develop' into feature/runtime-mi…
aleksandar-apostolov Sep 7, 2026
81c40ef
test(core): stop nesting the session injection inside a stubbing block
aleksandar-apostolov Sep 7, 2026
b2eeeb8
test(core): cover the audio branches Sonar's gate counts
aleksandar-apostolov Sep 7, 2026
a0cbabb
Merge branch 'develop' of github.com:GetStream/stream-video-android i…
PratimMallick Sep 8, 2026
53d89bf
fix(core): leaving music restores the noise cancellation that was there
aleksandar-apostolov Sep 8, 2026
6064f24
feat(demo-app): name the audio profile toggle Music mode
aleksandar-apostolov Sep 8, 2026
7909b1a
Merge branch 'feature/runtime-mic-processing-controls' of github.com:…
PratimMallick Sep 8, 2026
3b04686
test(core): drop the duplicate assertTrue import in PublisherTest
aleksandar-apostolov Sep 8, 2026
ed7f782
fix(core): keep a muted audio-profile switch pending until capture st…
PratimMallick Sep 10, 2026
d30987a
Merge remote-tracking branch 'origin/feature/runtime-mic-processing-c…
PratimMallick Sep 10, 2026
b3a4255
style(core): apply Spotless formatting after the muted-profile merge
PratimMallick Sep 10, 2026
b68036c
build: resolve WebRTC 145.18.0-SNAPSHOT from Sonatype
PratimMallick Sep 10, 2026
fa8697c
Move to proper version
PratimMallick Sep 11, 2026
df4abd9
Merge branch 'develop' into feature/runtime-mic-processing-controls
aleksandar-apostolov Sep 11, 2026
146511a
refactor(core): return Result<Unit> from setAudioBitrateProfile
aleksandar-apostolov Sep 11, 2026
7c3f758
fix(core): apply the source constraints after every refusable stage
aleksandar-apostolov Sep 11, 2026
a9b0f65
Merge branch 'develop' into feature/runtime-mic-processing-controls
PratimMallick Sep 11, 2026
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
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,13 @@ fun CallStats(call: Call) {
val publisherCodecLabel = if (publisherVideoCodec.isNotEmpty()) "($publisherVideoCodec)" else ""
val subscriberVideoCodec by call.state.stats.subscriber.videoCodec.collectAsStateWithLifecycle()
val subscriberCodecLabel = if (subscriberVideoCodec.isNotEmpty()) "($subscriberVideoCodec)" else ""
val publisherAudioBitrate by call.state.stats.publisher.audioBitrateKbps
.collectAsStateWithLifecycle()
val subscriberAudioBitrate by call.state.stats.subscriber.audioBitrateKbps
.collectAsStateWithLifecycle()
val audioCodec by call.state.stats.publisher.audioCodec.collectAsStateWithLifecycle()
val audioTargetBitrate by call.state.stats.publisher.audioTargetBitrateKbps
.collectAsStateWithLifecycle()

LatencyOrJitter(title = "Latency", value = latency)
Spacer(modifier = Modifier.size(16.dp))
Expand All @@ -199,9 +206,19 @@ fun CallStats(call: Call) {
value = subscriberResolution,
)
Spacer(modifier = Modifier.size(16.dp))
StatItem(title = "Publish bitrate", value = "$publisherBitrate Kbps")
// These two are the connection's bandwidth estimate, not a transmitted rate — named
// accordingly so they stop being read as "what we are sending".
StatItem(title = "Available outgoing bitrate", value = "$publisherBitrate Kbps")
Spacer(modifier = Modifier.size(16.dp))
StatItem(title = "Receiving bitrate", value = "$subscriberBitrate Kbps")
StatItem(title = "Available incoming bitrate", value = "$subscriberBitrate Kbps")
Spacer(modifier = Modifier.size(16.dp))
StatItem(title = "Audio target", value = "%.0f Kbps".format(audioTargetBitrate))
Spacer(modifier = Modifier.size(16.dp))
StatItem(title = "Audio sent", value = "%.1f Kbps".format(publisherAudioBitrate))
Spacer(modifier = Modifier.size(16.dp))
StatItem(title = "Audio received", value = "%.1f Kbps".format(subscriberAudioBitrate))
Spacer(modifier = Modifier.size(16.dp))
StatItem(title = "Audio codec", value = audioCodec)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ import androidx.compose.material.icons.filled.ClosedCaptionOff
import androidx.compose.material.icons.filled.Crop
import androidx.compose.material.icons.filled.CropFree
import androidx.compose.material.icons.filled.Feedback
import androidx.compose.material.icons.filled.MusicNote
import androidx.compose.material.icons.filled.Person
import androidx.compose.material.icons.filled.RadioButtonChecked
import androidx.compose.material.icons.filled.RawOff
Expand Down Expand Up @@ -96,6 +97,8 @@ fun defaultStreamMenu(
onToggleAudioUsage: () -> Unit = {},
selectedRecordingTypes: Set<RecordingType> = emptySet(),
onSelectRecordingType: (RecordingType) -> Unit = {},
isMusicAudioProfile: Boolean = false,
onToggleAudioProfile: () -> Unit = {},
) = buildList<MenuItem> {
if (noiseCancellationFeatureEnabled) {
add(
Expand Down Expand Up @@ -175,6 +178,8 @@ fun defaultStreamMenu(
onToggleAudioUsage,
selectedRecordingTypes,
onSelectRecordingType,
isMusicAudioProfile,
onToggleAudioProfile,
),
),
)
Expand Down Expand Up @@ -358,7 +363,15 @@ fun debugSubmenu(
onToggleAudioUsage: () -> Unit,
selectedRecordingTypes: Set<RecordingType>,
onSelectRecordingType: (RecordingType) -> Unit,
isMusicAudioProfile: Boolean = false,
onToggleAudioProfile: () -> Unit = {},
) = listOf(
ActionMenuItem(
title = if (isMusicAudioProfile) "Music mode: On" else "Music mode: Off",
icon = Icons.Default.MusicNote,
highlight = isMusicAudioProfile,
action = onToggleAudioProfile,
),
DynamicSubMenuItem(
title = "List Transcriptions",
icon = Icons.AutoMirrored.Filled.ReceiptLong,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ import io.getstream.video.android.ui.menu.base.MenuItem
import io.getstream.video.android.ui.menu.transcriptions.TranscriptionUiStateManager
import io.getstream.video.android.util.filters.SampleAudioFilter
import kotlinx.coroutines.launch
import stream.video.sfu.models.AudioBitrateProfile
import java.nio.ByteBuffer

@OptIn(ExperimentalPermissionsApi::class)
Expand Down Expand Up @@ -114,6 +115,36 @@ internal fun SettingsMenu(
call.speaker.setAudioUsage(newAudioUsage)
}

val audioBitrateProfile by call.microphone.audioBitrateProfile.collectAsStateWithLifecycle()
val isMusicAudioProfile =
audioBitrateProfile == AudioBitrateProfile.AUDIO_BITRATE_PROFILE_MUSIC_HIGH_QUALITY

val onToggleAudioProfile: () -> Unit = {
val next = if (isMusicAudioProfile) {
AudioBitrateProfile.AUDIO_BITRATE_PROFILE_VOICE_STANDARD_UNSPECIFIED
} else {
AudioBitrateProfile.AUDIO_BITRATE_PROFILE_MUSIC_HIGH_QUALITY
}
val turningOn = !isMusicAudioProfile
scope.launch {
call.microphone.setAudioBitrateProfile(next)
.onSuccess {
Toast.makeText(
context,
if (turningOn) "Music mode on" else "Music mode off",
Toast.LENGTH_LONG,
).show()
}
.onFailure {
Toast.makeText(
context,
"Music mode not changed: ${it.message}",
Toast.LENGTH_LONG,
).show()
}
}
}

val onToggleAudioFilterClick: () -> Unit = {
if (call.audioFilter == null) {
call.audioFilter = object : InputAudioFilter {
Expand Down Expand Up @@ -360,6 +391,8 @@ internal fun SettingsMenu(
onToggleAudioUsage = onToggleAudioUsage,
selectedRecordingTypes = enabledRecordingTypes,
onSelectRecordingType = onSelectRecordingType,
isMusicAudioProfile = isMusicAudioProfile,
onToggleAudioProfile = onToggleAudioProfile,
),
)
}
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ tink = "1.9.0"
turbine = "0.13.0"
itu = "1.7.3"

streamWebRTC = "145.17.0"
streamWebRTC = "145.19.0"
streamNoiseCancellation = "3.0.0"
streamResult = "1.3.0"
streamChat = "6.10.0"
Expand Down
3 changes: 3 additions & 0 deletions stream-video-android-core/api/stream-video-android-core.api
Original file line number Diff line number Diff line change
Expand Up @@ -9657,6 +9657,9 @@ public final class io/getstream/video/android/core/ParticipantState$Video : io/g

public final class io/getstream/video/android/core/PeerConnectionStats {
public fun <init> (Lkotlinx/coroutines/CoroutineScope;)V
public final fun getAudioBitrateKbps ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getAudioCodec ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getAudioTargetBitrateKbps ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getBitrateKbps ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getJitterInMs ()Lkotlinx/coroutines/flow/StateFlow;
public final fun getLatency ()Lkotlinx/coroutines/flow/StateFlow;
Expand Down
10 changes: 10 additions & 0 deletions stream-video-android-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -93,6 +93,16 @@ android {
unitTests {
isIncludeAndroidResources = true
isReturnDefaultValues = true

all {
// Robolectric builds a full Android sandbox per SDK level named in @Config, and
// this module's suite spans six of them in one JVM — nothing sets forkEvery here,
// so they accumulate. On CI that runs out of heap while loading an android-all
// jar, and which class reports it depends on execution order, which is why the
// telecom and notification tests kept getting blamed. The Xmx in
// gradle.properties is the daemon's, not the test JVM's, so it never applied.
it.maxHeapSize = "2g"
}
}

managedDevices {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright (c) 2014-2026 Stream.io Inc. All rights reserved.
*
* Licensed under the Stream License;
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://github.com/GetStream/stream-video-android/blob/main/LICENSE
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/

package io.getstream.video.android.core

import stream.video.sfu.models.AudioBitrateProfile

/**
* What [MicrophoneManager.setAudioBitrateProfile] managed to change, for logging and for naming
* the stages in a failure. Internal: callers get the profile back on
* [MicrophoneManager.audioBitrateProfile] and a failure when it did not take.
*
* A stage reports true when it matches [profile] *or* when there is nothing for it to move —
* no live capture, no sender, no such hardware. False means a live stage refused, and the reason
* is logged.
*
* @property audioMaxBitrateBps The bitrate now on the live audio sender, or null when the
* SFU-negotiated one stands.
*/
internal data class AudioProfileResult(
val profile: AudioBitrateProfile,
val audioMaxBitrateBps: Int?,
val noiseCancellationApplied: Boolean,
val platformNoiseSuppressorApplied: Boolean,
val platformAcousticEchoCancelerApplied: Boolean,
val softwareAudioProcessingApplied: Boolean,
val audioMaxBitrateApplied: Boolean,
val captureAudioSourceApplied: Boolean,
) {
/** Stages that are still processing audio the previous profile's way. */
val missedStages: List<String>
get() = buildList {
if (!noiseCancellationApplied) add("noise cancellation")
if (!platformNoiseSuppressorApplied) add("hardware noise suppressor")
if (!platformAcousticEchoCancelerApplied) add("hardware echo canceller")
if (!softwareAudioProcessingApplied) add("software audio processing")
if (!audioMaxBitrateApplied) add("max bitrate")
if (!captureAudioSourceApplied) add("capture audio source")
}

/** Every stage reached. */
val complete: Boolean get() = missedStages.isEmpty()
}
Original file line number Diff line number Diff line change
Expand Up @@ -121,7 +121,7 @@
"fastReconnectDeadlineSeconds. This constant will be removed in a future release.",
level = DeprecationLevel.WARNING,
)
const val sfuReconnectTimeoutMillis = 30_000

Check warning on line 124 in stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-android&issues=AaBnv2o2-z-Lmp4GmqYv&open=AaBnv2o2-z-Lmp4GmqYv&pullRequest=1787

/**
* The call class gives you access to all call level API calls
Expand Down Expand Up @@ -160,7 +160,7 @@
* `StreamVideoClient`, `ActiveStateGate`, the media session controller and [Debug]. Components
* under `call.components` take [CallSessionManager] directly rather than reading it from here.
*/
// TODO(v2): hand those consumers the CallSessionManager and drop this accessor. Blocked on

Check warning on line 163 in stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-android&issues=AaBnv2o2-z-Lmp4GmqYx&open=AaBnv2o2-z-Lmp4GmqYx&pullRequest=1787
// binary compatibility today: CallStats' constructor is published ABI, and adding a parameter
// replaces it rather than overloading it (defaults are source-level only). CallSessionManager
// is also internal, so it cannot appear in a public signature at all.
Expand All @@ -173,7 +173,7 @@
}

// Unit-test only hook for replacing RtcSession construction.
// TODO(v2): replace this with a proper dependency injection boundary.

Check warning on line 176 in stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-android&issues=AaBnv2o2-z-Lmp4GmqYy&open=AaBnv2o2-z-Lmp4GmqYy&pullRequest=1787
internal var unitTestRtcSessionFactory: (() -> RtcSession)? = null

/**
Expand Down Expand Up @@ -264,9 +264,9 @@
/** Delegate that owns the event flow, subscriptions and event dispatch. */
private val eventManager = CallEventManager(type, id, scope, reconnector = { reconnector })

// Must be initialized before `state` — CallState → SortedParticipantsState
// launches a coroutine that reads `call.events` (leaking-this race).
val events: MutableSharedFlow<VideoEvent> = eventManager.events

Check warning on line 269 in stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Don't expose mutable flow types.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-android&issues=AaBnv2o2-z-Lmp4GmqYu&open=AaBnv2o2-z-Lmp4GmqYu&pullRequest=1787

/** The call state contains all state such as the participant list, reactions etc */
val state = CallState(client, this, user, scope)
Expand Down Expand Up @@ -853,7 +853,7 @@
message = "Deprecated in favor of the `events` flow.",
replaceWith = ReplaceWith("events.collect { }"),
)
public fun unsubscribe(eventSubscription: EventSubscription) =

Check warning on line 856 in stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Do not forget to remove this deprecated code someday.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-android&issues=AaBnv2o2-z-Lmp4GmqYw&open=AaBnv2o2-z-Lmp4GmqYw&pullRequest=1787
eventManager.unsubscribe(eventSubscription)

public suspend fun blockUser(userId: String): Result<BlockUserResponse> =
Expand Down Expand Up @@ -921,7 +921,7 @@
}

@VisibleForTesting
internal suspend fun joinRequest(

Check warning on line 924 in stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

This function has 8 parameters, which is greater than the 7 authorized.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-android&issues=AaBnv2o2-z-Lmp4GmqYt&open=AaBnv2o2-z-Lmp4GmqYt&pullRequest=1787
create: CreateCallOptions? = null,
location: String,
migratingFrom: String? = null,
Expand Down Expand Up @@ -963,7 +963,7 @@
/**
* Should outlive both the call scope and the service scope and needs to be executed in the client-level scope.
* Because the call scope or service scope may be cancelled or finished while the network request is still in flight
* TODO: Run this in clientImpl.scope internally

Check warning on line 966 in stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/Call.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Complete the task associated to this TODO comment.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-android&issues=AaBnv2o2-z-Lmp4GmqYz&open=AaBnv2o2-z-Lmp4GmqYz&pullRequest=1787
*/
suspend fun reject(reason: RejectReason? = null): Result<RejectCallResponse> =
apiClient.reject(reason)
Expand Down Expand Up @@ -1040,6 +1040,53 @@
notifyNoiseCancellationState(media.isAudioProcessingEnabledIfCreated())
}

// Audio bitrate profile bridges. [MicrophoneManager.setAudioBitrateProfile] is the public
// entry point and can reach neither the media component nor the session from there.

internal fun setHardwareNoiseSuppressorEnabled(enabled: Boolean): Boolean =
media.setHardwareNoiseSuppressorEnabled(enabled)

internal fun setHardwareAcousticEchoCancelerEnabled(enabled: Boolean): Boolean =
media.setHardwareAcousticEchoCancelerEnabled(enabled)

/** Must not be called from the main thread: the module rebuilds AudioRecord. */
internal fun setCaptureAudioSource(audioSource: Int): Boolean =
media.setCaptureAudioSource(audioSource)

/**
* Rebuilds the audio source and track so audio-source constraints take effect mid-call. With
* no session the source is built lazily from current constraints, so the change already holds.
*/
internal fun rebuildAudioCapturePipeline(): Boolean =
session.value?.rebuildAudioCapturePipeline() ?: true

internal fun setAudioMaxBitrate(maxBitrateBps: Int): Boolean =
session.value?.setAudioMaxBitrate(maxBitrateBps) ?: false

/** Whether an audio sender exists to take a live profile change. */
internal fun hasLiveAudioSender(): Boolean =
session.value?.hasLiveAudioSender() ?: false

internal fun audioMaxBitrate(): Int? = session.value?.audioMaxBitrate()

/** The audio bitrate the SFU negotiated at join, or null when nothing publishes audio. */
internal fun negotiatedAudioBitrate(): Int? = session.value?.negotiatedAudioBitrate()

/** The bitrate the SFU offers for [profile], or null when it named none. */
internal fun audioBitrateFor(profile: stream.video.sfu.models.AudioBitrateProfile): Int? =
session.value?.audioBitrateFor(profile)

/** Whether a noise-cancellation processor is wired in and can be turned on or off. */
internal fun isAudioProcessingReachable(): Boolean = media.isAudioProcessingReachable()

// Absent hardware is not the same as hardware that refused, so these are asked separately.

internal fun isHardwareNoiseSuppressorSupported(): Boolean =
media.isHardwareNoiseSuppressorSupported()

internal fun isHardwareAcousticEchoCancelerSupported(): Boolean =
media.isHardwareAcousticEchoCancelerSupported()

fun toggleAudioProcessing(): Boolean {
// Reads without building a factory: the gate runs before join, and a factory created
// there would capture the pre-join audio bitrate profile.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,57 @@

internal val _videoCodec: MutableStateFlow<String> = MutableStateFlow("")
val videoCodec: StateFlow<String> = _videoCodec

internal val _audioBitrateKbps: MutableStateFlow<Float> = MutableStateFlow(0F)

Check warning on line 73 in stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallStats.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Don't expose mutable flow types.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-android&issues=AaBnv2qj-z-Lmp4GmqY0&open=AaBnv2qj-z-Lmp4GmqY0&pullRequest=1787

/**
* Audio bitrate actually sent or received, measured from the RTP byte counters between two
* stats polls.
*
* Distinct from [bitrateKbps], which reports the connection's *available* bandwidth estimate
* rather than anything that was transmitted. This one moves when the encoder does.
*/
val audioBitrateKbps: StateFlow<Float> = _audioBitrateKbps

internal val _audioCodec: MutableStateFlow<String> = MutableStateFlow("")

Check warning on line 84 in stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallStats.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Don't expose mutable flow types.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-android&issues=AaBnv2qj-z-Lmp4GmqY1&open=AaBnv2qj-z-Lmp4GmqY1&pullRequest=1787
val audioCodec: StateFlow<String> = _audioCodec

internal val _audioTargetBitrateKbps: MutableStateFlow<Float> = MutableStateFlow(0F)

Check warning on line 87 in stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallStats.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Don't expose mutable flow types.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-android&issues=AaBnv2qj-z-Lmp4GmqY2&open=AaBnv2qj-z-Lmp4GmqY2&pullRequest=1787

/**
* The bitrate the audio encoder is aiming for, as reported by `outbound-rtp`.
*
* The requested value, where [audioBitrateKbps] is what actually went out. Comparing the two
* is how you tell "we asked for more" from "we are sending more".
*/
val audioTargetBitrateKbps: StateFlow<Float> = _audioTargetBitrateKbps

/** Byte counter and its timestamp from the previous poll, to derive a rate. */
internal var lastAudioBytes: Long? = null
internal var lastAudioTimestampUs: Double? = null

/**
* Derives the audio bitrate from the change in [bytes] since the previous poll.
*
* The first sample only seeds the baseline — a rate needs two points. A counter that went
* backwards means the stream was replaced, so the baseline is reset rather than reported as a
* negative rate.
*/
internal fun updateAudioBitrate(bytes: Long?, timestampUs: Double) {
if (bytes == null) return
val previousBytes = lastAudioBytes
val previousTimestampUs = lastAudioTimestampUs
lastAudioBytes = bytes
lastAudioTimestampUs = timestampUs

if (previousBytes == null || previousTimestampUs == null) return
val elapsedUs = timestampUs - previousTimestampUs
val deltaBytes = bytes - previousBytes
if (elapsedUs <= 0 || deltaBytes < 0) return

val bitsPerSecond = deltaBytes * 8.0 * 1_000_000.0 / elapsedUs
_audioBitrateKbps.value = (bitsPerSecond / 1000).toFloat()
}
}

public data class LocalStats(
Expand Down Expand Up @@ -193,6 +244,55 @@
subscriber._resolution.value = "$width x $height @ $fps fps"
}
}
// Audio send/receive rate, derived from the RTP byte counters. Nothing else reports
// what the audio encoder is actually doing — the candidate-pair numbers below are a
// bandwidth estimate, not a transmitted rate.
statGroups["outbound-rtp:audio"]?.firstOrNull()?.let {
publisher.updateAudioBitrate(
bytes = it.members["bytesSent"] as? Long,
timestampUs = it.timestampUs,
)
(it.members["targetBitrate"] as? Double)?.let { target ->
publisher._audioTargetBitrateKbps.value = (target / 1000).toFloat()
}
}
statGroups["inbound-rtp:audio"]?.firstOrNull()?.let {
subscriber.updateAudioBitrate(
bytes = it.members["bytesReceived"] as? Long,
timestampUs = it.timestampUs,
)
}
// The publisher and the subscriber report are both fed through here, and each one
// describes only its own direction. Writing both sides from either report leaves
// whichever ran last showing on both, so the codec is resolved through the matching
// RTP statistic's own codecId — which also picks the right entry when a report
// carries more than one audio codec — and only that direction is updated.
val audioRtp = if (isPublisher) {
statGroups["outbound-rtp:audio"]
} else {
statGroups["inbound-rtp:audio"]
}?.firstOrNull()
val audioCodecStat = (audioRtp?.members?.get("codecId") as? String)
?.let { codecId -> stats.origin.statsMap[codecId] }
?: statGroups["codec:audio"]?.firstOrNull()
audioCodecStat?.let {
val mimeType = it.members["mimeType"] as? String
val clockRate = it.members["clockRate"] as? Long
val channels = it.members["channels"] as? Long
val fmtp = it.members["sdpFmtpLine"] as? String
val codec = listOfNotNull(
mimeType,
clockRate?.let { rate -> "$rate Hz" },
channels?.let { count -> if (count > 1) "stereo" else "mono" },
fmtp,
).joinToString(" ")
if (isPublisher) {
publisher._audioCodec.value = codec
} else {
subscriber._audioCodec.value = codec
}
}

statGroups["candidate-pair"]?.firstOrNull()?.let {
val latency = it.members["currentRoundTripTime"] as? Double
val outgoingBitrate = it.members["availableOutgoingBitrate"] as? Double
Expand Down Expand Up @@ -243,7 +343,7 @@
}

fun updateLocalStats() {
val displayingAt = call.session.value?.subscriber?.value?.viewportDimensions() ?: emptyMap()

Check warning on line 346 in stream-video-android-core/src/main/kotlin/io/getstream/video/android/core/CallStats.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove this unused "displayingAt" local variable.

See more on https://sonarcloud.io/project/issues?id=GetStream_stream-video-android&issues=AaBnv2qj-z-Lmp4GmqY3&open=AaBnv2qj-z-Lmp4GmqY3&pullRequest=1787
val resolution = call.camera.resolution.value
val availableResolutions = call.camera.availableResolutions.value
val maxResolution = availableResolutions.maxByOrNull { it.width * it.height }
Expand Down
Loading
Loading