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
11 changes: 11 additions & 0 deletions .changeset/video-start-bitrate-hint.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"client-sdk-android": patch
---

Seed the bandwidth estimator with `x-google-start-bitrate` for all video codecs, not just SVC, so published video reaches its target quality in the first second or two instead of ramping from ~300 kbps over 5-15 seconds. The hint is 90% of the track's target bitrate, capped at 1 Mbps for camera tracks (screen shares are exempt, since they are published at high bitrates for text legibility) and skipped below a 300 kbps target, where seeding high costs more than it gains.

Because libwebrtc applies these codec fmtp parameters to the whole peer connection rather than the m-section carrying them, the SDK now writes a single connection-level value to every video m-section, once per publisher connection. Re-seeding a converged estimator is avoided: the value persists in libwebrtc's bitrate configurator and is automatically re-applied on network route changes, and a full reconnect builds a new peer connection and seeds it again.

**Behavior change:** the SDK no longer writes `x-google-max-bitrate` into SDP. That value was promoted to a ceiling on total send bandwidth for the entire connection, so a camera publication could throttle a concurrent screen share. Per-track and per-layer limits continue to be enforced through `RtpParameters.Encoding.maxBitrateBps`, which is correctly scoped per encoding. Applications that relied on the SDP value as a connection-wide cap should set encoding bitrates instead. This matches client-sdk-js and the Rust SDK, neither of which writes it.

`TrackBitrateInfo` and `TrackBitrateInfoKey` are now `internal`. They were never intended as public API (both were `@suppress`ed and only reachable through a `@VisibleForTesting` helper) and were public only to be visible from the test module, which is no longer necessary.
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,11 @@ constructor(
private var renegotiate = false

private val trackBitrates = mutableMapOf<TrackBitrateInfoKey, TrackBitrateInfo>()

// x-google-start-bitrate is a connection-level BWE hint in libwebrtc. Keep it
// available through data-channel/audio-only offers and consume it only after a
// local video m-section successfully gets the hint.
private var hasAppliedVideoStartBitrate = false
private var isClosed = AtomicBoolean(false)

private val latestOfferId = AtomicInteger(0)
Expand Down Expand Up @@ -206,18 +211,37 @@ constructor(
val sdpDescription = sdpFactory.createSessionDescription(sdpOffer.description)

val mediaDescs = sdpDescription.getMediaDescriptions(true)
.filterIsInstance<MediaDescription>()
// The publisher PeerConnection may negotiate before any video is published
// (for example, data channel only or audio first). Those offers should not
// consume the video start hint. When the first video offer is created, use
// one connection-level value across all video m-sections so libwebrtc's
// last-writer-wins handling cannot depend on SDP m-section order.
val connectionStartBitrate = if (!hasAppliedVideoStartBitrate) {
computeConnectionStartBitrate(mediaDescs, trackBitrates)
} else {
null
}
var appliedVideoStartBitrate = false
for (mediaDesc in mediaDescs) {
if (mediaDesc !is MediaDescription) {
continue
}
if (mediaDesc.media.mediaType == "audio") {
// TODO
} else if (mediaDesc.media.mediaType == "video") {
ensureVideoDDExtensionForSVC(mediaDesc)
ensureCodecBitrates(mediaDesc, trackBitrates = trackBitrates)
appliedVideoStartBitrate = ensureCodecBitrates(
mediaDesc,
trackBitrates = trackBitrates,
connectionStartBitrate = connectionStartBitrate,
) || appliedVideoStartBitrate
}
}
finalSdp = setMungedSdp(sdpOffer, sdpDescription.toString())
val mungedDescription = sdpDescription.toString()
finalSdp = setMungedSdp(sdpOffer, mungedDescription)
// setMungedSdp may fall back to the original SDP. Only mark the one-shot
// hint as used after the SDP with the hint is accepted locally.
if (appliedVideoStartBitrate && finalSdp?.description == mungedDescription) {
hasAppliedVideoStartBitrate = true
}
}

finalSdp?.let { sdp ->
Expand Down Expand Up @@ -437,66 +461,157 @@ fun ensureVideoDDExtensionForSVC(mediaDesc: MediaDescription) {
}
}

/* The svc codec (av1/vp9) would use a very low bitrate at the beginning and
increase slowly by the bandwidth estimator until it reach the target bitrate. The
process commonly cost more than 10 seconds cause subscriber will get blur video at
the first few seconds. So we use a 70% of target bitrate here as the start bitrate to
eliminate this issue.
*/
private const val startBitrateForSVC = 0.7
/*
* Video codecs use a very low bitrate at the beginning and increase slowly by
* the bandwidth estimator until they reach the target bitrate. The process commonly
* costs more than 10 seconds causing subscribers to get blurry video at the first
* few seconds. We use x-google-start-bitrate to hint the BWE to start higher.
*
* Why 90%: Gives ~10% headroom for bandwidth estimation while starting close to target.
* Why same for all codecs: Target bitrate already accounts for codec efficiency
* (e.g., users set lower targets for VP9/AV1 knowing they're more efficient).
* Why cap camera at 1 Mbps: Prevents BWE from starting too aggressively on high bitrate tracks.
*/
private const val startBitrateMultiplier = 0.9

/** Maximum x-google-start-bitrate in kbps. 1 Mbps prevents BWE from starting too aggressively. */
private const val maxStartBitrateKbps = 1000L

/** Minimum target bitrate in kbps to apply start bitrate hint. Below this, the hint hurts more than it helps. */
private const val minTargetBitrateKbps = 300L

@VisibleForTesting
internal fun ensureCodecBitrates(
media: MediaDescription,
trackBitrates: Map<TrackBitrateInfoKey, TrackBitrateInfo>,
) {
ensureCodecBitrates(
media = media,
trackBitrates = trackBitrates,
connectionStartBitrate = computeConnectionStartBitrate(trackBitrates.values),
)
}

/*
* These codec fmtp params are connection-scoped, not m-section-scoped. libwebrtc reads them per
* m-section (WebRtcVideoSendChannel::ApplyChangedParams -> GetBitrateConfigForCodec) but pushes the
* result into the shared Call via SetSdpBitrateParameters, where RtpBitrateConfigurator stores one
* config for the whole peer connection. Two m-sections carrying different values is last-writer-wins.
*
* Hence: one value, written to every video m-section, once.
*
* Write it once because the value persists. RtpBitrateConfigurator keeps start_bitrate_bps in its
* stored config and re-applies it on every network route change (RtpTransportControllerSend::
* OnNetworkRouteChanged reads GetConfig()), so a WiFi-to-cellular handover re-seeds the estimator
* from this hint with no renegotiation. Rewriting it later is at best a no-op (libwebrtc ignores an
* unchanged value, and only re-reads it when the send codec changes) and at worst restarts a
* converged bandwidth estimator, so a full reconnect -- a new peer connection, a new estimator -- is
* the only thing that should seed it again.
*
* Never write x-google-max-bitrate. The same Call-level promotion turns a per-track cap into a
* ceiling on total send bandwidth, so a 2.3 Mbps camera would starve a concurrent 3 Mbps screen
* share. libwebrtc carries a TODO conceding this is wrong ("codec max bitrate should probably not
* affect global call max bitrate"). Per-track and per-layer caps belong in
* RtpParameters.Encoding.maxBitrateBps, which is genuinely scoped per encoding. client-sdk-js and
* the Rust SDK never write it either.
*/
@VisibleForTesting
internal fun ensureCodecBitrates(
media: MediaDescription,
trackBitrates: Map<TrackBitrateInfoKey, TrackBitrateInfo>,
connectionStartBitrate: Long?,
): Boolean {
// Returns true when this media section maps to a local video track and has or
// receives the connection-level start hint.
val startBitrate = connectionStartBitrate ?: return false
val (_, codecPayload) = findTrackCodecBitrateInfo(media, trackBitrates) ?: return false

val fmtps = media.getFmtps()
var fmtpFound = false
for ((attribute, fmtp) in fmtps) {
if (fmtp.payload == codecPayload) {
fmtpFound = true
if (fmtp.config.contains("x-google-start-bitrate")) {
return true
}
attribute.value = "${fmtp.payload} ${fmtp.config};x-google-start-bitrate=$startBitrate"
break
}
}

if (!fmtpFound) {
media.addAttribute(
SdpFmtp(
payload = codecPayload,
config = "x-google-start-bitrate=$startBitrate",
).toAttributeField(),
)
}
return true
}

private fun computeConnectionStartBitrate(
mediaDescriptions: Collection<MediaDescription>,
trackBitrates: Map<TrackBitrateInfoKey, TrackBitrateInfo>,
): Long? {
// Use only video m-sections in the current SDP. trackBitrates can contain
// stale entries after unpublish, and those must not affect the connection hint.
return mediaDescriptions
.asSequence()
.filter { media -> media.media.mediaType == "video" }
.mapNotNull { media -> findTrackCodecBitrateInfo(media, trackBitrates)?.trackBitrateInfo }
.mapNotNull(::computeTrackStartBitrate)
.maxOrNull()
}

/**
* @suppress
*/
@VisibleForTesting
fun ensureCodecBitrates(
internal fun computeConnectionStartBitrate(trackBitrates: Collection<TrackBitrateInfo>): Long? {
return trackBitrates.mapNotNull(::computeTrackStartBitrate).maxOrNull()
}

private data class TrackCodecBitrateInfo(
val trackBitrateInfo: TrackBitrateInfo,
val codecPayload: Long,
)

private fun findTrackCodecBitrateInfo(
media: MediaDescription,
trackBitrates: Map<TrackBitrateInfoKey, TrackBitrateInfo>,
) {
val msid = media.getMsid()?.value ?: return
for ((key, trackBr) in trackBitrates) {
): TrackCodecBitrateInfo? {
val msid = media.getMsid()?.value ?: return null
for ((key, trackBitrateInfo) in trackBitrates) {
if (key !is TrackBitrateInfoKey.Cid) {
continue
Comment thread
xianshijing-lk marked this conversation as resolved.
}

val (cid) = key
if (!msid.contains(cid)) {
if (!msid.contains(key.value)) {
continue
}

val (_, rtp) = media.getRtps()
.firstOrNull { (_, rtp) -> rtp.codec.equals(trackBr.codec, ignoreCase = true) }
.firstOrNull { (_, rtp) -> rtp.codec.equals(trackBitrateInfo.codec, ignoreCase = true) }
?: continue
val codecPayload = rtp.payload

val fmtps = media.getFmtps()
var fmtpFound = false
for ((attribute, fmtp) in fmtps) {
if (fmtp.payload == codecPayload) {
fmtpFound = true
var newFmtpConfig = fmtp.config
if (!fmtp.config.contains("x-google-start-bitrate")) {
newFmtpConfig = "$newFmtpConfig;x-google-start-bitrate=${(trackBr.maxBitrate * startBitrateForSVC).roundToLong()}"
}
if (!fmtp.config.contains("x-google-max-bitrate")) {
newFmtpConfig = "$newFmtpConfig;x-google-max-bitrate=${trackBr.maxBitrate}"
}
if (fmtp.config != newFmtpConfig) {
attribute.value = "${fmtp.payload} $newFmtpConfig"
break
}
}
}
return TrackCodecBitrateInfo(
trackBitrateInfo = trackBitrateInfo,
codecPayload = rtp.payload,
)
}
return null
}

if (!fmtpFound) {
media.addAttribute(
SdpFmtp(
payload = codecPayload,
config = "x-google-start-bitrate=${trackBr.maxBitrate * startBitrateForSVC};" +
"x-google-max-bitrate=${trackBr.maxBitrate}",
).toAttributeField(),
)
}
private fun computeTrackStartBitrate(trackBr: TrackBitrateInfo): Long? {
if (trackBr.targetBitrateKbps < minTargetBitrateKbps) {
return null
}

// TODO: dynamically adjust start bitrate based on network conditions, such as
// using the previous BWE estimate.
val calculatedStartBitrate = (trackBr.targetBitrateKbps * startBitrateMultiplier).roundToLong()
return if (trackBr.isScreenShare) {
calculatedStartBitrate
} else {
minOf(calculatedStartBitrate, maxStartBitrateKbps)
}
}

Expand All @@ -507,17 +622,30 @@ internal fun isSVCCodec(codec: String?): Boolean {
}

/**
* @suppress
* The bitrate a local video track was published at, used to derive the connection-level
* `x-google-start-bitrate` hint in [ensureCodecBitrates].
*
* Carries no max bitrate: per-track and per-layer caps belong in
* [livekit.org.webrtc.RtpParameters.Encoding.maxBitrateBps], not in SDP.
*
* @param codec The codec the track is published with, matched against the m-section's rtpmap.
* @param targetBitrateKbps The track's target bitrate in **kbps** (not bps). For SVC this is the
* single encoding's bitrate; for simulcast it is the sum across layers, since the bandwidth
* estimator has to carry all of them.
* @param isScreenShare Whether the track is a screen share. Screen shares are exempt from the
* [maxStartBitrateKbps] cap: they are typically published at high bitrates for text legibility,
* and unlike camera content a conservative start is more costly than a brief overshoot.
*/
data class TrackBitrateInfo(
internal data class TrackBitrateInfo(
val codec: String,
val maxBitrate: Long,
val targetBitrateKbps: Long,
val isScreenShare: Boolean = false,

@adrian-niculescu adrian-niculescu Jul 21, 2026

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.

TrackBitrateInfo.maxBitrate has always carried kbps, but nothing in the name or type says so, and neighboring fields like RtpParameters' maxBitrateBps set a bps expectation. The old test demonstrated the ambiguity: it passed 1000000 into the kbps field and asserted a roughly 1 Gbps fmtp value. Renaming to maxBitrateKbps closes the trap. While here: isScreenShare could use a KDoc, and the camera/screenshare/other defaults mapping is now written out in five places (both data class comments, the base KDoc, the publish-site comment, and getDefaultDegradationPreference); one authoritative copy in the public KDoc would keep them from drifting.

One consideration for both the new isScreenShare parameter and the rename: TrackBitrateInfo is a public JVM type in the shipped 2.27.0 AAR (@suppress only hides it from docs), and changing the primary constructor drops the compiled (String, long) constructor and copy descriptors. Exposure is low since PeerConnectionTransport is internal and the only public entry point taking the type is the @VisibleForTesting ensureCodecBitrates, but if its ABI is meant to be stable that should be a deliberate call rather than an accidental side effect.

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.

Renamed — to targetBitrateKbps rather than maxBitrateKbps, since after dropping the x-google-max-bitrate write the field is only ever a target for the start hint, never a cap.

Confirmed the unit against libwebrtc while I was in there: GetBitrateConfigForCodec does config.start_bitrate_bps = bitrate_kbps * 1000. So the old test asserting x-google-start-bitrate=700000 was claiming 700 Mbps.

isScreenShare now has a KDoc covering why screen shares are exempt from the 1 Mbps cap. The five-places duplication resolved itself — those were all degradation-preference docs, which moved out to #991.

On ABI: agreed it shouldn't be accidental, so I made it deliberate in the other direction — TrackBitrateInfo, TrackBitrateInfoKey and the two-arg ensureCodecBitrates are now internal. They were public only so the separate test module could reach them, and friendPaths (#925) made that unnecessary. Worth noting the visibility change is itself ABI-neutral, javap shows byte-identical signatures, since Kotlin records internal in @metadata only and doesn't mangle top-level declarations. The constructor and copy descriptor change from the rename still stands; it's now just unambiguously a change to something that was never API. Called out in the changeset.

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.

Correct, and making the types internal is the cleaner call. Resolving.

)

/**
* @suppress
* Identifies the local track a [TrackBitrateInfo] belongs to.
*/
sealed class TrackBitrateInfoKey {
internal sealed class TrackBitrateInfoKey {
data class Cid(val value: String) : TrackBitrateInfoKey()
data class Transceiver(val value: RtpTransceiver) : TrackBitrateInfoKey()
}
Original file line number Diff line number Diff line change
Expand Up @@ -750,14 +750,22 @@ internal constructor(
track.statsGetter = engine.createStatsGetter(transceiver.sender)

val finalOptions = options
// Handle trackBitrates
if (encodings.isNotEmpty()) {
if (finalOptions is VideoTrackPublishOptions && isSVCCodec(finalOptions.videoCodec) && encodings.firstOrNull()?.maxBitrateBps != null) {
// Handle trackBitrates - apply start bitrate for all video codecs to prevent initial blurriness.
// - SVC codecs: use first encoding's bitrate (single stream with built-in layers)
// - Simulcast: sum all encoding bitrates (independent streams, BWE needs total)
if (encodings.isNotEmpty() && finalOptions is VideoTrackPublishOptions) {

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.

A lifecycle gap this expansion widens: trackBitrates in PeerConnectionTransport is insert-only, and neither unpublishTrack nor RTCEngine.removeTrack removes the entry, so registrations outlive their publications for the transport's lifetime. Mostly that is just dead map entries scanned on every offer, but it becomes wrong munging on republish: cid is the RTC track id, so republishing the same track hits the same key, and if the new publish computes no encodings (videoEncoding == null with simulcast = false returns an empty list from computeVideoEncodings) this block is skipped and ensureCodecBitrates injects the previous publish's start/max values into the new offer. This existed for SVC registrations before, but the all-video path makes it reachable for every codec. An unregister call when the track is removed would close it.

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.

You're right that the map is insert-only, and it predates this PR. And I don't think an unregister is needed due to the following understanding.

Both halves of the concern are now gated on hasAppliedVideoStartBitrate. computeConnectionStartBitrate only runs while that latch is false, and ensureCodecBitrates returns before touching the map once it's set, so after the first video offer carries the hint, trackBitrates is never iterated again on that transport , the dead entries aren't scanned on every offer, they aren't scanned at all.

That also bounds the republish case. The latch is set by the first video publish itself, so reaching the stale entry additionally requires that first publish to have failed to set it , setMungedSdp falling back to the unmunged SDP, or a sub-300 kbps target (where the stale entry contributes null anyway). Only then do your conditions apply on top: same track object, and a republish that computes no encodings so no fresh entry overwrites the stale one.

The other half of the original concern is gone outright: the max-bitrate write was removed, so a stale entry can no longer cap the connection. What's left is one start-bitrate hint derived from the same track's previous target, bounded by the 300 kbps floor and the 1 Mbps cap, and self-correcting as the estimator converges.

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.

Agreed. Checked it against the current head: computeConnectionStartBitrate only runs while the latch is false, and ensureCodecBitrates returns before touching the map once connectionStartBitrate is null, so the stale entries are never read after the first video hint lands. What is left is a start hint bounded by the 300 kbps floor and the 1 Mbps cap. Resolving.

val targetBitrateBps: Long = if (isSVCCodec(finalOptions.videoCodec)) {
(encodings.firstOrNull()?.maxBitrateBps ?: 0).toLong()
} else {
encodings.sumOf { (it.maxBitrateBps ?: 0).toLong() }
}
if (targetBitrateBps > 0) {
engine.registerTrackBitrateInfo(
cid = cid,
TrackBitrateInfo(
codec = finalOptions.videoCodec,
maxBitrate = (encodings.first().maxBitrateBps?.div(1000) ?: 0).toLong(),
targetBitrateKbps = targetBitrateBps / 1000,
isScreenShare = trackSource == Track.Source.SCREEN_SHARE,
),
)
}
Expand Down
Loading
Loading