Skip to content

Scale the video start bitrate hint by connection setup time - #1031

Merged
changt merged 3 commits into
mainfrom
senchang/clt-3380-scale-video-start-bitrate-by-connection-setup-time
Sep 27, 2026
Merged

changt merged 3 commits into
mainfrom
senchang/clt-3380-scale-video-start-bitrate-by-connection-setup-time

Conversation

@changt

@changt changt commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Linear: CLT-3380
Rust counterpart: livekit/rust-sdks#1467

Builds on #973, which introduced the connection-level 1 Mbps cap this change scales.

Problem

With #973 the x-google-start-bitrate hint is min(90% of target, 1 Mbps) for camera tracks, and uncapped for screen share, chosen with no information about the network. On a constrained uplink 1 Mbps overshoots: the estimator has to recover, which shows up as a freeze and a retransmit storm over the first 5–15 s. On a good network 1 Mbps is right. libwebrtc cannot probe the path before a video sender exists, so the SDK has to pick the seed without a measurement; connection setup time is the only network signal available before the first video offer.

Change

RTCEngine times each connection attempt from the top of joinImpl until the primary transport reports connected, and hands it to the publisher PeerConnectionTransport, which scales the hint's cap by it:

cap_kbps = clamp(1000 − (setup_ms − 1500) × 700 / 2000, 300, 1000)
  • ≤ 1.5 s → 1000 kbps (unchanged for healthy networks); 1.5–3.5 s → linear ramp; ≥ 3.5 s → 300 kbps, libwebrtc's own default starting estimate.
  • The existing 90%-of-target rule still applies on top, and the hint is still written at the floor rather than skipped.
  • Screen share stays uncapped while the cap is at the 1 Mbps ceiling; once the cap is below it, screen share is capped the same way.
  • Only a new peer connection is measured: the initial join and a full reconnect both go through joinImpl and build a new publisher, so each times itself from scratch. A resume (RESUMING → CONNECTED) keeps its peer connections and estimator and never records a time. fix: improve initial video quality by setting x-google-start-bitrate for all video codecs #973's write-once latch is unchanged. With no setup time recorded, the cap stays at 1 Mbps.

Measurements and validation

Setup-time measurements on shaped links and the camera validation runs (LK2/LK3/LK4 profiles, fixed cap vs ramp) are in CLT-3380 and in livekit/rust-sdks#1467; the anchors are the same as in the Rust SDK. In short: on constrained links (500 kbps and 300 kbps uplinks) the ramp removes the opening overshoot, with 40–90% fewer retransmits and the frame rate held from the first frame; on a 1 Mbps link the two are within run-to-run variance.

Tests

SdpMungingTest: 7 pass (5 existing + 2 new covering the ramp at the measured setup times and anchors, and the screen-share cap). spotlessCheck clean.

@changeset-bot

changeset-bot Bot commented Sep 25, 2026 •

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: fd44b10

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

This PR includes changesets to release 1 package
Name Type
client-sdk-android 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

devin-ai-integration[bot]

This comment was marked as resolved.

@xianshijing-lk

Copy link
Copy Markdown
Contributor

Nice, can you record some videos to show the impact in different network conditions ?

You can share it in slack if that is easier.

Thanks.

@changt

changt commented Sep 25, 2026

Copy link
Copy Markdown
Contributor Author

@xianshijing-lk
Observed after testing this change, compared to the base branch:
(1) The heuristic is effective at cutting start bitrate in a simulated bad network, and using the 1000kps rate in a good network.

2026-09-25 16:19:48.331 30218-30337 PeerConnec...dSendOffer io.livekit.android                   I  Applying x-google-start-bitrate=465 kbps (connection setup 3028 ms, still connecting)
2026-09-25 16:20:03.902 30218-30337 PeerConnec...dSendOffer io.livekit.android                   I  Applying x-google-start-bitrate=738 kbps (connection setup 2250 ms, still connecting)
2026-09-25 16:20:32.227 30218-30337 PeerConnec...dSendOffer io.livekit.android                   I  Applying x-google-start-bitrate=602 kbps (connection setup 2637 ms, still connecting)

2026-09-25 16:01:01.507  7275-7311  PeerConnec...dSendOffer io.livekit.android                   I  Applying x-google-start-bitrate=1000 kbps (connection setup 717 ms, still connecting)

(2) On both real 4G network and WiFi network, we connect fast enough to avoid this path.
(3) On a simulated bad network, the start-up freeze/stutter duration is significantly reduced with this change, from ~5 seconds to ~1 seconds. The actual stable bitrate is lower than the estimate, despite the estimate being lower than the network bandwidth limit.

@xianshijing-lk

Copy link
Copy Markdown
Contributor

@xianshijing-lk Observed after testing this change, compared to the base branch: (1) The heuristic is effective at cutting start bitrate in a simulated bad network, and using the 1000kps rate in a good network.

2026-09-25 16:19:48.331 30218-30337 PeerConnec...dSendOffer io.livekit.android                   I  Applying x-google-start-bitrate=465 kbps (connection setup 3028 ms, still connecting)
2026-09-25 16:20:03.902 30218-30337 PeerConnec...dSendOffer io.livekit.android                   I  Applying x-google-start-bitrate=738 kbps (connection setup 2250 ms, still connecting)
2026-09-25 16:20:32.227 30218-30337 PeerConnec...dSendOffer io.livekit.android                   I  Applying x-google-start-bitrate=602 kbps (connection setup 2637 ms, still connecting)

2026-09-25 16:01:01.507  7275-7311  PeerConnec...dSendOffer io.livekit.android                   I  Applying x-google-start-bitrate=1000 kbps (connection setup 717 ms, still connecting)

(2) On both real 4G network and WiFi network, we connect fast enough to avoid this path. (3) On a simulated bad network, the start-up freeze/stutter duration is significantly reduced with this change, from ~5 seconds to ~1 seconds. The actual stable bitrate is lower than the estimate, despite the estimate being lower than the network bandwidth limit.

Nice! I like the numbers.

How about the false positive and false negative rates? I tried something similar this summer while I was in China behind a VPN, and sometimes I saw high latency under good network conditions and low latency under poor network conditions.

I wouldn't think that is a blocker, but ideally it doesn't happen too frequently.

Base automatically changed from sxian/CLT-3068/fix-initial-video-quality-blurriness-by-setting-x-google-start-bitrate to main September 26, 2026 04:21
Cap x-google-start-bitrate at 1 Mbps for connections that set up within
1.5 s, ramping linearly down to 300 kbps at 3.5 s or slower. Screen share
is capped the same way once the cap is below 1 Mbps. Resumes keep their
estimator; only a new peer connection (join or full reconnect) is measured.
…ports connect

Room.connect returns after the signaling join, so an app that publishes
video right away creates its first offer before the primary transport
has connected and no setup time is recorded yet. The publisher now knows
when the attempt began and uses the time elapsed so far for that offer,
a lower bound on the eventual setup time, so it can only lean toward the
1 Mbps ceiling.
@changt
changt force-pushed the senchang/clt-3380-scale-video-start-bitrate-by-connection-setup-time branch from 4e31c85 to 9ddd7d7 Compare September 26, 2026 22:36

@devin-ai-integration devin-ai-integration Bot 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.

Devin Review found 1 new potential issue.

Devin Review

Comment on lines +149 to +151
if (oldVal != ConnectionState.RESUMING) {
recordConnectionSetupTime()
}

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.

🟡 Early resume leaves video setup timer running

When signaling drops before initial ICE connection, RESUMING skips recording setup time after recovery. connectStartedAtMs keeps aging, so a later first video offer gets a 300 kbps hint regardless of the recovered link.

Learn more

A soft resume reuses the existing peer connection. If signaling closes during the initial connection, reconnect sets RESUMING while the publisher still holds the timestamp from setConnectStartedAt. On recovery, the connection-state callback skips recording, leaving both engine and publisher without a completed setup time. A later first video offer then uses the ever-growing elapsed time in connectionSetupTimeForOffer, even long after the connection stabilized.

Example: An initial join starts at 0 ms, signaling drops before ICE connects, and soft resume completes at 2 s. A camera first published at 30 s gets the 300 kbps hint rather than a value based on the completed connection.

Recommended fix: On RESUMING → CONNECTED, finalize a pending initial setup timestamp for the existing publisher, while continuing to skip timing for resumes of previously connected sessions.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

detekt fails configure at its cyclomatic complexity threshold (15) with the
extra branch. joinImpl calls configure before it negotiates, so setting the
start time there is equally early.
@github-actions

Copy link
Copy Markdown
Contributor

Diffuse output:

OLD: diffuse-source-file
NEW: livekit-android-sdk-release.aar

 AAR      │ old      │ new      │ diff     
──────────┼──────────┼──────────┼──────────
      jar │    3 MiB │    3 MiB │ +3.2 KiB 
 manifest │  1.5 KiB │  1.5 KiB │      0 B 
 lint-jar │ 12.7 KiB │ 12.7 KiB │      0 B 
    other │  2.4 KiB │  2.4 KiB │      0 B 
──────────┼──────────┼──────────┼──────────
    total │    3 MiB │    3 MiB │ +3.2 KiB 

 JAR     │ old   │ new   │ diff        
─────────┼───────┼───────┼─────────────
 classes │  1706 │  1706 │  0 (+0 -0)  
 methods │ 21974 │ 21983 │ +9 (+17 -8) 
  fields │  5696 │  5702 │ +6 (+7 -1)
AAR
 size  │ diff     │ path          
───────┼──────────┼───────────────
 3 MiB │ +3.2 KiB │ ∆ classes.jar 
───────┼──────────┼───────────────
 3 MiB │ +3.2 KiB │ (total)
JAR
METHODS:

   old   │ new   │ diff        
  ───────┼───────┼─────────────
   21974 │ 21983 │ +9 (+17 -8) 
  
  + io.livekit.android.room.PeerConnectionTransport access_connectionSetupTimeForOffer-FghU774(PeerConnectionTransport) → Duration
  + io.livekit.android.room.PeerConnectionTransport access_getConnectionSetupTime_p(PeerConnectionTransport) → Duration
  + io.livekit.android.room.PeerConnectionTransport connectionSetupTimeForOffer-FghU774() → Duration
  + io.livekit.android.room.PeerConnectionTransport setConnectStartedAt(long)
  + io.livekit.android.room.PeerConnectionTransport setConnectionSetupTime-LRDsOJo(long)
  + io.livekit.android.room.PeerConnectionTransportKt <clinit>()
  + io.livekit.android.room.PeerConnectionTransportKt access_computeConnectionStartBitrate-moChb0s(Collection, Map, Duration) → Long
  + io.livekit.android.room.PeerConnectionTransportKt access_computeTrackStartBitrate-6Au4x4Y(TrackBitrateInfo, Duration) → Long
  + io.livekit.android.room.PeerConnectionTransportKt computeConnectionStartBitrate-6Au4x4Y(Collection, Duration) → Long
  + io.livekit.android.room.PeerConnectionTransportKt computeConnectionStartBitrate-6Au4x4Y_default(Collection, Duration, int, Object) → Long
  + io.livekit.android.room.PeerConnectionTransportKt computeConnectionStartBitrate-moChb0s(Collection, Map, Duration) → Long
  + io.livekit.android.room.PeerConnectionTransportKt computeTrackStartBitrate-6Au4x4Y(TrackBitrateInfo, Duration) → Long
  + io.livekit.android.room.PeerConnectionTransportKt_computeConnectionStartBitrate_3 <init>(Duration)
  + io.livekit.android.room.RTCEngine access_recordConnectionSetupTime(RTCEngine)
  + io.livekit.android.room.RTCEngine access_setConnectStartedAtMs_p(RTCEngine, Long)
  + io.livekit.android.room.RTCEngine recordConnectionSetupTime()
  + kotlin.ranges.RangesKt coerceIn(double, double, double) → double
  
  - io.livekit.android.room.PeerConnectionTransportKt access_computeConnectionStartBitrate(Collection, Map) → Long
  - io.livekit.android.room.PeerConnectionTransportKt access_computeTrackStartBitrate(TrackBitrateInfo) → Long
  - io.livekit.android.room.PeerConnectionTransportKt computeConnectionStartBitrate(Collection) → Long
  - io.livekit.android.room.PeerConnectionTransportKt computeConnectionStartBitrate(Collection, Map) → Long
  - io.livekit.android.room.PeerConnectionTransportKt computeTrackStartBitrate(TrackBitrateInfo) → Long
  - io.livekit.android.room.PeerConnectionTransportKt_computeConnectionStartBitrate_3 <clinit>()
  - io.livekit.android.room.PeerConnectionTransportKt_computeConnectionStartBitrate_3 <init>()
  - kotlin.jvm.internal.FunctionReferenceImpl <init>(int, Class, String, String, int)
  

FIELDS:

   old  │ new  │ diff       
  ──────┼──────┼────────────
   5696 │ 5702 │ +6 (+7 -1) 
  
  + io.livekit.android.room.PeerConnectionTransport connectStartedAtMs: Long
  + io.livekit.android.room.PeerConnectionTransport connectionSetupTime: Duration
  + io.livekit.android.room.PeerConnectionTransportKt setupTimeForMaxBitrate: long
  + io.livekit.android.room.PeerConnectionTransportKt setupTimeForMinBitrate: long
  + io.livekit.android.room.PeerConnectionTransportKt_computeConnectionStartBitrate_3 _connectionSetupTime: Duration
  + io.livekit.android.room.RTCEngine connectStartedAtMs: Long
  + io.livekit.android.room.RTCEngine_joinImpl_2 J_0: long
  
  - io.livekit.android.room.PeerConnectionTransportKt_computeConnectionStartBitrate_3 INSTANCE: PeerConnectionTransportKt_computeConnectionStartBitrate_3

@changt
changt merged commit f297220 into main Sep 27, 2026
6 checks passed
@changt
changt deleted the senchang/clt-3380-scale-video-start-bitrate-by-connection-setup-time branch September 27, 2026 21:09
@davidliu davidliu mentioned this pull request Sep 27, 2026
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