Skip to content

fix x-google-start-bitrate for vp8 and other codex and make it adaptive to network#1226

Open
xianshijing-lk wants to merge 7 commits into
mainfrom
sxian/CLT-3106/adaptive-x-google-start-bitrate-based-on-network-conditions
Open

fix x-google-start-bitrate for vp8 and other codex and make it adaptive to network#1226
xianshijing-lk wants to merge 7 commits into
mainfrom
sxian/CLT-3106/adaptive-x-google-start-bitrate-based-on-network-conditions

Conversation

@xianshijing-lk

Copy link
Copy Markdown
Contributor

Summary

Improve initial video quality on constrained networks by adaptively setting x-google-start-bitrate based on detected network conditions.


Problem

On constrained networks, initial video quality is poor and takes a long time (30+ seconds) to stabilize due to several issues:

  1. Aggressive initial bitrate: WebRTC's Bandwidth Estimator (BWE) starts probing at 4500 kbps (90% of 5 Mbps target), which overwhelms poor networks and causes packet loss/congestion.
  2. Network hint lost during full restart: When connection failures trigger a full restart (new session), the conservative bitrate hint set on the old session is lost. The new session starts fresh with no hint.
  3. Initial offer not munged: In single PC mode, create_initial_offer() didn't apply x-google-start-bitrate munging, so the first offer after restart ignored network constraints.
  4. VP8/VP9 missing fmtp lines: These codecs don't have fmtp lines by default, so x-google-start-bitrate wasn't being applied to them at all.
  5. v0 fallback path not covered: When signal client falls back to v0 path, a different PeerTransport is created that doesn't receive the network hint.

Solution

  1. Detect constrained networks via:
    - Total connection time > 5 seconds (slow ICE)
    - Reconnection triggered (network instability indicator)
    - Full restart initiated
  2. Set conservative hint (300 kbps) when poor network is detected, which persists across all subsequent offers.
  3. Pass hint to new sessions via connect_with_hint() during full restart, so the new session respects network constraints from the start.
  4. Apply bitrate munging to initial offers in create_initial_offer(), not just renegotiation offers.
  5. Create fmtp lines for VP8/VP9 that don't have them by default, ensuring all video codecs get the start bitrate hint.
  6. Set hint after publisher_pc finalized to cover both v1 (single PC) and v0 (fallback) connection paths.

Changes

  • peer_transport.rs:
    • Add network_quality_start_bitrate_kbps field and setter
    • Update compute_start_bitrate_kbps() to handle hint when ultimate_bps is None
    • Apply x-google-start-bitrate munging in create_initial_offer()
    • Create fmtp lines for codecs (VP8/VP9) that don't have them
  • rtc_session.rs:
    • Add SLOW_CONNECTION_THRESHOLD (5s) and CONSTRAINED_NETWORK_START_BITRATE_KBPS (300 kbps) constants
    • Add connect_with_hint() method to accept initial network hint
    • Track connect_start_time for slow network detection
    • Set hint in both v1 and v0 connection paths
  • mod.rs:
    • Set network hint immediately when reconnection is triggered
    • Use connect_with_hint() in try_restart_connection() with 300 kbps hint

How to Test

Manual testing on constrained network:

  1. Use network throttling (e.g., Network Link Conditioner on macOS, or browser DevTools)
  2. Set bandwidth to ~500 kbps with 200ms latency
  3. Run the publisher example and observe:
    - Logs should show Applying x-google-start-bitrate=300 kbps after reconnection/restart
    - Video should stabilize faster compared to before (seconds vs 30+ seconds)
    - Resolution starts at 480p or lower and can ramp up if bandwidth improves

Expected log sequence on poor network:
Reconnection triggered: setting constrained network hint to 300 kbps
Full restart: creating new session with network hint 300 kbps
Setting network hint to 300 kbps on publisher_pc (covers v0 fallback path)
Applying x-google-start-bitrate=300 kbps (ultimate_bps=Some(5000000), network_hint=Some(300))

Good network (no throttling):

  • First connection should use normal bitrate (4500 kbps for 5 Mbps target)
  • No constrained network hint unless connection takes >5 seconds

Breaking Changes

None. This is an additive change that improves behavior on constrained networks without affecting good network scenarios.


Compatibility

  • Backward compatible: connect() still works as before (calls connect_with_hint() with None)
  • Server compatible: x-google-start-bitrate is a standard WebRTC SDP attribute, no server changes needed
  • Codec compatible: Works with VP8, VP9, H264, H265, AV1

@github-actions

github-actions Bot commented Jul 7, 2026

Copy link
Copy Markdown
Contributor

Changeset

The following package versions will be affected by this PR:

Package Bump
"livekit" patch

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

Open in Devin Review

Comment thread livekit/src/rtc_engine/rtc_session.rs Outdated
Comment on lines +477 to +518
// 3) For video codecs without fmtp lines (VP8/VP9), create new fmtp lines
// This is needed because VP8/VP9 don't have fmtp lines by default
let pts_with_fmtp: std::collections::HashSet<String> = out
.iter()
.filter_map(|line| {
let l = line.trim();
if let Some(rest) = l.strip_prefix("a=fmtp:") {
rest.split_whitespace().next().map(|s| s.to_string())
} else {
None
}
})
.collect();

// Find rtpmap lines and insert fmtp after them for codecs without fmtp
let mut final_out: Vec<String> = Vec::with_capacity(out.len() + target_pts.len());
for line in out.iter() {
final_out.push(line.clone());

// Check if this is an rtpmap line for a video codec without fmtp
let l = line.trim();
if let Some(rest) = l.strip_prefix("a=rtpmap:") {
let mut it = rest.split_whitespace();
let pt = it.next().unwrap_or("");
let codec = it.next().unwrap_or("");
if Self::is_video_codec(codec)
&& !pt.is_empty()
&& !pts_with_fmtp.contains(pt)
{
// Create fmtp line with x-google-start-bitrate
let fmtp_line =
format!("a=fmtp:{pt} x-google-start-bitrate={start_bitrate_kbps}");
log::debug!(
"Creating fmtp line for {} (pt={}): {}",
codec,
pt,
fmtp_line
);
final_out.push(fmtp_line);
}
}
}

@devin-ai-integration devin-ai-integration Bot Jul 7, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Existing test now contradicts the new behavior that creates format lines for codecs missing them, causing test failure

A new fmtp line is now synthesized for video codecs without one (step 3 added at livekit/src/rtc_engine/peer_transport.rs:403-446), but the pre-existing test asserts the SDP is unchanged when no fmtp line exists (livekit/src/rtc_engine/peer_transport.rs:652-655), so the test suite will fail.

Impact: The test suite cannot pass, blocking CI and merging.

New code path creates fmtp lines that the old test explicitly forbids

The test vp9_without_fmtp_line_is_noop at line 642 calls munge_x_google_start_bitrate with an SDP containing a=rtpmap:98 VP9/90000 but no a=fmtp:98 ... line, and asserts out == sdp (no modification). However, the new code at lines 407-446 builds a pts_with_fmtp set, finds that pt 98 has no fmtp, and inserts a=fmtp:98 x-google-start-bitrate=3200 after the rtpmap line. This makes out != sdp, failing the assert_eq! at line 652.

The test comment says "function intentionally does not insert a new fmtp line" which was the old behavior. The test needs to be updated to match the new intentional behavior.

Open in Devin Review

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

Comment thread livekit/src/rtc_engine/peer_transport.rs Outdated

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 2 new potential issues.

Open in Devin Review

Comment thread livekit/src/rtc_engine/rtc_session.rs
Comment on lines +413 to +419
Err(WsError::Io(ref io_err))
if io_err.kind() == std::io::ErrorKind::UnexpectedEof =>
{
// TLS connection closed without close_notify - treat as normal close
// This happens when the server closes the connection abruptly
log::debug!("websocket closed with unexpected EOF (TLS close without close_notify)");
break;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Matching on Err(WsError::Io(ref io_err)) may not match wrapped TLS errors

The pattern Err(WsError::Io(ref io_err)) if io_err.kind() == UnexpectedEof at livekit-api/src/signal_client/signal_stream.rs:413-419 assumes the TLS close-without-close_notify surfaces as a direct io::Error with UnexpectedEof kind. Depending on the tungstenite version and TLS backend (native-tls vs rustls), TLS errors may be wrapped differently — e.g., as WsError::Tls(...) rather than WsError::Io(...). If the TLS error doesn't surface as WsError::Io, this handler won't match and the error will fall through to the catch-all _ => branch (which still breaks, just with an error log). Not a correctness bug, but the handler may be less effective than intended.

Open in Devin Review

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

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 5 new potential issues.

⚠️ 1 issue in files not directly in the diff

⚠️ Changeset omits the livekit crate despite behavioral changes to degradation defaults and SDP munging (.changeset/agent-deployment-docs.md:1-5)

The changeset file (.changeset/agent-deployment-docs.md) only lists livekit-api for bumping, but the PR also changes the livekit crate's behavior (degradation preference defaults in livekit/src/room/options.rs:194-198 and SDP munging in livekit/src/rtc_engine/peer_transport.rs), so the livekit crate version won't be bumped.

Impact: Downstream consumers won't know the livekit crate behavior changed, violating semantic versioning expectations.

Open in Devin Review

Comment thread livekit/src/rtc_engine/peer_transport.rs
Comment on lines +193 to +198
// Default based on track source
match options.source {
TrackSource::Camera => DegradationPreference::MaintainFramerate,
TrackSource::Screenshare => DegradationPreference::MaintainResolution,
_ => DegradationPreference::Balanced,
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 Degradation preference default change is a significant behavioral shift for camera tracks

The default degradation preference for camera tracks changed from MaintainResolution (old code at the base, which returned MaintainResolution for all sources) to MaintainFramerate (livekit/src/room/options.rs:195). This means existing users who relied on the previous default will now see their camera tracks prioritize framerate over resolution during bandwidth constraints — the opposite of the previous behavior. While the new default may be more appropriate for real-time communication, this is a silent behavioral change that could surprise users who were satisfied with the previous clarity-preserving behavior. The _height parameter is now fully unused (livekit/src/room/options.rs:186), confirming the resolution-based logic was removed.

Open in Devin Review

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

Comment on lines +214 to +232
/// Maximum x-google-start-bitrate (kbps).
/// 1 Mbps is a reasonable ceiling that prevents BWE from starting too aggressively.
const MAX_START_BITRATE_KBPS: u32 = 1000;

/// Compute the x-google-start-bitrate value for SDP munging.
///
/// Returns min(90% of target, 1 Mbps). Returns None if no target bitrate is set
/// (initial offer before track publish) or if the target is too low.
fn compute_start_bitrate_kbps(target_bps: Option<u64>) -> Option<u32> {
let target_bps = target_bps?;
let target_kbps = (target_bps / 1000) as u32;

if target_kbps == 0 || target_kbps < 300 {
return None;
}

Some(start_kbps.min(ultimate_kbps))
// Use 90% of target bitrate as start bitrate, capped at 1 Mbps
let start_kbps = (target_kbps as f64 * 0.9).round() as u32;
Some(start_kbps.min(target_kbps).min(Self::MAX_START_BITRATE_KBPS))

@devin-ai-integration devin-ai-integration Bot Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 New 1 Mbps cap on start bitrate may cause slow ramp-up for high-resolution streams

The new MAX_START_BITRATE_KBPS = 1000 constant at livekit/src/rtc_engine/peer_transport.rs:216 caps the x-google-start-bitrate hint at 1 Mbps. Previously, the start bitrate was simply 90% of the target with no upper cap. For high-resolution streams (e.g., 4K at 8 Mbps target), the old code would set start bitrate to ~7200 kbps, while the new code caps it at 1000 kbps. This means WebRTC's bandwidth estimator will start much lower and need to ramp up, potentially causing initial quality degradation for high-bitrate use cases. The comment says this "prevents BWE from starting too aggressively" but the tradeoff is slower convergence to target quality for high-bitrate streams.

Open in Devin Review

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

Comment on lines +403 to +446
// 3) For video codecs that don't already have fmtp lines, create new ones
// with x-google-start-bitrate. This handles cases where the browser/WebRTC
// didn't generate an fmtp line for a particular payload type (e.g., VP8
// typically has no fmtp, while H.264/VP9-SVC/AV1 usually do).
let pts_with_fmtp: std::collections::HashSet<String> = out
.iter()
.filter_map(|line| {
let l = line.trim();
if let Some(rest) = l.strip_prefix("a=fmtp:") {
rest.split_whitespace().next().map(|s| s.to_string())
} else {
None
}
})
.collect();

// Find rtpmap lines and insert fmtp after them for video codecs without existing fmtp
let mut final_out: Vec<String> = Vec::with_capacity(out.len() + target_pts.len());
for line in out.iter() {
final_out.push(line.clone());

// Check if this is an rtpmap line for a video codec without an existing fmtp line
let l = line.trim();
if let Some(rest) = l.strip_prefix("a=rtpmap:") {
let mut it = rest.split_whitespace();
let pt = it.next().unwrap_or("");
let codec = it.next().unwrap_or("");
if Self::is_video_codec(codec)
&& !pt.is_empty()
&& !pts_with_fmtp.contains(pt)
{
// Create fmtp line with x-google-start-bitrate
let fmtp_line =
format!("a=fmtp:{pt} x-google-start-bitrate={start_bitrate_kbps}");
log::debug!(
"Creating fmtp line for {} (pt={}): {}",
codec,
pt,
fmtp_line
);
final_out.push(fmtp_line);
}
}
}

@devin-ai-integration devin-ai-integration Bot Jul 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔍 New fmtp line creation for codecs without existing fmtp may produce non-standard SDP

The new step 3 at livekit/src/rtc_engine/peer_transport.rs:403-446 creates a=fmtp:<pt> x-google-start-bitrate=<value> lines for video codecs that don't already have fmtp lines (e.g., VP8 which typically has none). While x-google-start-bitrate is a Chrome/libwebrtc-specific hint, creating an fmtp line with ONLY this non-standard parameter (no standard codec parameters) is unusual. Some SDP parsers or SFUs might not expect an fmtp line for VP8 at all. The inserted fmtp line is placed immediately after the rtpmap line, which is correct per SDP ordering conventions, but the overall approach should be validated against the target SFU.

Open in Devin Review

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

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.

1 participant