Skip to content

Write the video start bitrate hint as one connection-level value, once - #2102

Merged
xianshijing-lk merged 3 commits into
mainfrom
sxian/CLT-3068/video-start-bitrate-connection-level
Sep 22, 2026
Merged

xianshijing-lk merged 3 commits into
mainfrom
sxian/CLT-3068/video-start-bitrate-connection-level

Conversation

@xianshijing-lk

@xianshijing-lk xianshijing-lk commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #1987, bringing the JS start-bitrate munging in line with what came out of the review on client-sdk-android#973. No change to the formula: still 90% of the target, capped at 1 Mbps for camera, uncapped for screen share.

x-google-start-bitrate is connection-scoped, not per-section

libwebrtc reads it per m-section but doesn't apply it per m-section:

bitrate_config_ = GetBitrateConfigForCodec(send_codec()->codec);   // webrtc_video_engine.cc:1337
...
call_->GetTransportControllerSend()->SetSdpBitrateParameters(bitrate_config_);  // :1358

That lands in RtpBitrateConfigurator, which holds one BitrateConstraints for the entire RTCPeerConnection. Every video m-section writes the same slot, last writer wins, and the order is just m-section order.

Today each track writes its own value. The camera/screen-share split is what makes that bite: camera capped at 1000, screen share uncapped at e.g. 2700, both in the same offer. Whichever section applies last seeds the single estimator — so either the screen share overrides the camera cap for the whole connection, or the camera clobbers the screen share and the exemption does nothing. It depends on SDP layout, so it looks stable in testing and flips in the field.

Fix: computeConnectionStartBitrate takes the largest hint among the video m-sections that map to a published track, and every video section gets that same number. Only sections present in the current SDP count, since trackBitrates is append-only and outlives an unpublish.

Written once per connection, not on every offer

libwebrtc guards re-application three ways: it only re-reads the fmtp when the send codec changes (webrtc_video_engine.cc:1338-1342), ignores a value equal to the stored one (rtp_bitrate_configurator.cc:66-73"setting the same remote description twice shouldn't restart bandwidth estimation"), and returns -1 for "no change" while retaining the real value.

The gap: when the value changes between offers and the codec changed too, it really does restart a converged estimator. With per-track values, publishing a screen share mid-session changes the last-writer value.

The reason once is sufficient, not just safe: the seed persists. RtpBitrateConfigurator keeps start_bitrate_bps, and RtpTransportControllerSend::OnNetworkRouteChanged re-applies it from GetConfig() (rtp_transport_controller_send.cc:390). A WiFi→cellular handover re-seeds the estimator from this hint automatically, with no renegotiation. A full reconnect builds a new peer connection and seeds the new estimator again.

hasAppliedVideoStartBitrate latches only after setMungedSDP accepts the offer, so a rejected munge retries on the next one.

300 kbps target floor

Matches the Rust SDK. Below that, seeding above the real capacity costs more than the ramp it saves.

Refactor note

applyVideoStartBitrate was doing double duty as section-matcher, and the DD-extension munging depended on that match. Matching moved to findTrackCodecPayload, so DD extension still runs on every offer while the bitrate write happens once.

Tests

PCTransport.test.ts: 28 passing (6 new — the floor, the cap and its screen-share exemption, the connection-level max, stale-entry filtering, and section matching).

All libwebrtc references are against m144_release.

Note

13 test files plus lint and tsc fail on this branch from a missing machina package — reproduced on a clean checkout of main, unrelated to this change. All 4 tsc errors are in SignalClient*.ts.

x-google-start-bitrate is connection-scoped in libwebrtc: ApplyChangedParams
reads it per m-section but pushes it into the shared Call via
SetSdpBitrateParameters, where RtpBitrateConfigurator holds one config for the
whole peer connection. Differing per-section values were last-writer-wins on
m-section order, so a camera plus a screen share could seed the estimator from
either one depending on SDP layout. Every video section now carries the same
value: the largest hint among the sections that map to a published track.

Write it only on the first offer that carries local video. libwebrtc retains
start_bitrate_bps and re-applies it on network route changes, so rewriting it
later is at best a no-op and at worst a restart of a converged bandwidth
estimator. The latch is set only once the offer carrying the hint is accepted
locally, so a rejected munge retries on the next offer.

Add a 300 kbps target floor, matching the Rust SDK: below that, seeding above
the real capacity costs more than the ramp it saves.

applyVideoStartBitrate no longer matches the section to a track; that moves to
findTrackCodecPayload so the dependent DD extension munging still runs on every
offer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@changeset-bot

changeset-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 04f3556

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 1 package
Name Type
livekit-client Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@github-actions

github-actions Bot commented Sep 14, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
dist/livekit-client.esm.mjs 111.73 KB (+0.16% 🔺)
dist/livekit-client.umd.js 120.79 KB (+0.08% 🔺)

@xianshijing-lk
xianshijing-lk marked this pull request as ready for review September 14, 2026 17:31
devin-ai-integration[bot]

This comment was marked as resolved.

@1egoman 1egoman left a comment

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.

High level makes sense, I think @lukasIO once you are back it would be good for you to take a look at this one post merge as I am less familiar with this part of the sdk.

`trackBitrates` is append-only and an unpublished section keeps its
`a=msid`, so matching a section to a track by msid alone still paired a
stale entry with the section it used to occupy. Two consequences, both
reachable while `hasAppliedVideoStartBitrate` is still unset (a
superseded offer, or a munge the browser rejected): an uncapped
screen-share target could seed a connection that now carries only a
camera, and the unpublish renegotiation itself could consume the
one-shot hint on a section that sends nothing, leaving every later
publish with no hint at all.

`a=sendonly` separates a live send from an unpublished or pre-populated
section: every publish creates its transceiver with
`direction: 'sendonly'`, `unpublishTrack` sets it to `inactive` (and
`removeTrack` does the same transition for the simulcast senders), and
the pre-populated placeholders are `recvonly`.

The matching in the munge loop is left as it was: it now writes the
correct connection-level value, and suppressing it on reverted sections
could leave them without an fmtp line the live section has, which is the
bundled payload type collision `conformBundledCodecFmtp` works around.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
devin-ai-integration[bot]

This comment was marked as resolved.

Matching `a=sendonly` exactly dropped the legacy `addTrack` fallback:
`createSender` uses it when `addTransceiver` is unavailable, and
`addTrack` reuses a transceiver rather than creating a sendonly one, so
a published camera lands on a `sendrecv` section. A section with no
direction attribute has the same problem, since SDP defaults it to
sendrecv. Those clients got no start bitrate hint at all.

Invert the test: skip `recvonly` and `inactive`, the only two directions
that cannot carry local media, and count everything else. That still
excludes both shapes a dead section takes -- `inactive` from a sendonly
transceiver, `recvonly` from the sendrecv one `addTrack` reuses -- and
the pre-populated placeholders, which are `recvonly`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@xianshijing-lk
xianshijing-lk merged commit 5511c0d into main Sep 22, 2026
6 checks passed
@xianshijing-lk
xianshijing-lk deleted the sxian/CLT-3068/video-start-bitrate-connection-level branch September 22, 2026 00:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants