Conversation
|
I fixed similar problems for JS and Rust, JS PR is livekit/client-sdk-js#1987 Can we do something similar ? |
|
@xianshijing-lk I'm happy to follow your pattern and make a similar implementation here! I wanted to be more conservative and have it as an opt-in option — but since you already have this logic applied to all streams automatically in JS/Rust, I'm more than happy to do the same thing here Let me rework my PR, I'll re-submit it later |
5d3c996 to
572feb8
Compare
x-google-start-bitrate)
|
@xianshijing-lk I reworked the implementation to align it with the existing Rust and JS implementation. Let me know if you have any other comments or suggestions. Thanks in advance! |
572feb8 to
dd8e0b1
Compare
|
Hi @sergeyphi, thanks for updating the PR. Over the weekend, I recalled a few more technical details around the relevant WebRTC behavior, which also came up during a fairly deep review of a related Android PR: livekit/client-sdk-android#973 One structural point emerged from that review that I think applies directly here, along with a few other details we were able to confirm. I’m sharing them since they’re fairly deep in the libwebrtc internals and took us a couple of review rounds to fully pin down. The short version: x-google-start-bitrate is a connection level knob, not a per sender (or track) one. libwebrtc reads it per m-section, but doesn't apply it per m-section. In That lands in RtpBitrateConfigurator, which holds one BitrateConstraints for the entire PeerConnection. Every video m-section writes the same slot, last writer wins — and the order is just SDP m-section order. Two consequences for the current implementation:
libwebrtc guards re-application three ways:
The gap: if the value changes between offers and the codec changed too, it does restart a converged estimator. With per-sender values, publishing a screen share mid-session changes the last-writer value — so you can reset BWE on an established connection. Writing once removes the whole class of problem. The part that makes "once" clearly correct. And some more webrtc implementation details: On Android that's a one-shot latch on the transport, set only after setLocalDescription accepts the munged SDP (so a rejected munge retries on the next offer rather than silently burning the one chance). x-google-max-bitrate : you're right to leave it out, and it's worth writing down why, because it looks like the natural companion and someone will propose adding it. Same Call-level promotion turns a per-track cap into a ceiling on total send bandwidth: a default camera publish emits x-google-max-bitrate=2310, which then starves a concurrent 3 Mbps screen share. libwebrtc has a TODO conceding the behavior is wrong — "codec max bitrate should probably not affect global call max bitrate" (webrtc_video_engine.cc:1327). Per-track and per-layer caps belong in RTCRtpEncodingParameters.maxBitrateBps, which is genuinely scoped per encoding. Android used to write it and removed it in this PR; JS and Rust never did. On which codecs: libwebrtc only reads GetBitrateConfigForCodec(send_codec()->codec) — the selected send codec. Writing to all of VP8/VP9/AV1/H264/H265 is harmless, but note it slightly widens guard #1: if the codec later switches (backup codec), the new codec's fmtp also carries a hint, so changed_params.send_codec is true and it re-reads. Harmless with a uniform value; a genuine BWE restart with per-sender ones. Another reason the single value matters more than the codec set. Your open question on the 300 kbps floor: we kept it, same as Rust. Below that, seeding above real capacity costs more than the ramp it saves. I'd match Rust rather than JS here. Sorry for the long msg, the tech is more complex than what I expected. Happy to walk through any of this. |
|
I am planning to work on the rust and JS PRs to align with Android today or tomorrow |
|
@xianshijing-lk thank you for the context, this is very helpful. Your concern makes sense. I guess it's a good timing that you were reviewing a very similar PR for Android. Since you're going to align JS and Rust implementation, it only makes sense to align current PR with Android as well. Let me re-work this implementation to adopt the Android design. |
|
Thanks. FYI, I am also working on the corresponding Rust and JS PRs: |
| for index in document.mediaSections.indices where isSendingVideoSection(document.mediaSections[index]) { | ||
| for rtpmap in document.mediaSections[index].rtpmaps where startBitrateCodecs.contains(rtpmap.codec.uppercased()) { | ||
| if document.mediaSections[index].setFmtpParameter("x-google-start-bitrate", value: "\(kbps)", forPayload: rtpmap.payload) { |
There was a problem hiding this comment.
🟡 Bundled video loses start bitrate
When bundled send and receive sections share payload types, mungeVideoStartBitrate edits only send sections. Their fmtp parameters then conflict, so libwebrtc can reject the hint and retry without it.
Learn more
BUNDLE uses one RTP payload-type namespace across its media sections. Sections sharing a payload type must advertise identical codec parameters. This loop adds x-google-start-bitrate only to sending sections, while single-PC and pre-populated publisher layouts can contain receive-only video sections with the same payload types. The resulting descriptions disagree on fmtp for one payload type. The fallback in set(localDescription:munging:) then drops the start-bitrate munge if libwebrtc rejects that description, leaving the affected publication at the default ramp-up behavior.
Example: A bundled send-only camera section and recv-only placeholder both advertise VP8 as payload 96. The munge adds a=fmtp:96 x-google-start-bitrate=1000 only to the camera section. The placeholder still has no fmtp for payload 96, so the bundled payload configurations differ and the hint can be rejected.
Recommended fix: After computing the connection-level value, conform matching video codec fmtp across every non-rejected section in the BUNDLE group, including recv-only and inactive placeholders. Keep placeholder sections excluded only from choosing the bitrate. Add a real-peer-connection test containing one sending and one recv-only video section with a shared payload type.
Was this helpful? React with 👍 or 👎 to provide feedback.
…-bitrate libwebrtc starts every new video send stream at roughly 300 kbps and ramps up from there regardless of the encodings' maxBitrate, so the first seconds of a published track are visibly blurry (measured: QP 39-42 for ~12 s, the 2.3 Mbps ceiling reached only after ~30 s on a healthy network). Match what client-sdk-js (livekit/client-sdk-js#1987) and rust-sdks (livekit/rust-sdks#1197, #1226) already do: when a video sender is added, derive a start bitrate from its encodings — the sum of the active layers' maxBitrate, times 0.9, capped at 1 Mbps unless the track is a screen share, and no hint under 300 kbps — and declare `x-google-start-bitrate=<kbps>` on every video codec's fmtp (VP8/VP9/AV1/H264/H265) of that sender's section in the publisher's offer, matched through the `a=msid` track id. Not opt-in and no public API, as in the sibling SDKs. - `SDPMediaSection.setFmtpParameter(_:value:forPayload:)`: replaces an existing value, appends to an existing fmtp line, or inserts one for payloads that have none (VP8), keeping every other parameter verbatim. - `Transport.startBitrateKbps(targetBps:isScreenShare:)` / `startBitrateKbps(for:isScreenShare:)`: the shared formula. - `Transport.mungeVideoStartBitrate(_:kbpsBySenderId:)`, appended as the last (optional) munge of `set(localDescription:munging:)` (livekit#1068) in both single- and dual-PC negotiation, on the SDP module from livekit#1078. - `Transport.addTransceiver(with:transceiverInit:startBitrateKbps:)` records the hint against the new sender before returning, so the offer libwebrtc requests in response cannot be created without it; `remove(track:)` clears it. Both `LocalParticipant` video publish paths (primary and backup codec) pass the derived value. - The backup-codec publish path now computes its encodings with `isScreenShare`, as the primary path does; it had defaulted to the camera encoding since livekit#275, which the screen-share branch of the new formula made visible. Tests: the formula, the encodings sum, the per-sender munge (codec case, replace/append/insert, rtx/audio/unmapped sections untouched, no-op identity), `setFmtpParameter`, the backup codec's screen-share encodings, and — against the shipped libwebrtc — that a send-only video section's msid carries the sender id and that the munged offer is accepted by `setLocalDescription` rather than rejected as disallowed munging. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Follow-up to the review on client-sdk-android#973, matching what merged in client-sdk-js#2102 and rust-sdks#1430. The formula is unchanged. libwebrtc reads x-google-start-bitrate per m-section but applies it to the shared Call (WebRtcVideoSendChannel::ApplyChangedParams -> SetSdpBitrateParameters), where RtpBitrateConfigurator holds one config for the whole peer connection. Per-sender values were therefore last-writer-wins in m-section order: a camera capped at 1 Mbps and an uncapped screen share in one offer seeded the estimator from whichever section libwebrtc applied last. Every sending video section now gets the same value, the largest hint among the sending senders in the offer. The hint is also written only on the first offer that carries local video. libwebrtc re-reads it when the send codec changes and restarts a converged estimator if the value changed, so rewriting a per-sender value on a later publish could reset BWE mid-call; and it retains start_bitrate_bps and re-applies it on network route changes, so once is sufficient. The latch is set only after libwebrtc accepts the munged offer, so a rejected munge retries on the next one. A full reconnect builds a new Transport and seeds the new estimator again. Receive-only and inactive video sections neither contribute to the value nor receive it, so a section left behind by an unpublished track cannot carry a stale target or consume the one-shot hint. Tests: the connection-level max and the sending filter, the munge on every sending section, the latch condition, and — against the shipped libwebrtc — two offers on one peer connection answered by a second local peer connection, where only the first carries the hint. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
7461bf2 to
f009793
Compare
|
@xianshijing-lk Pushed a follow-up commit aligned with #973 and what merged in livekit/client-sdk-js#2102 / livekit/rust-sdks#1430:
Tests cover the connection-level max and the sending filter, the munge on every sending section, the latch condition, and two offers on one peer connection (answered by a second local PC) where only the first carries the hint. |
Swift counterpart of livekit/client-sdk-js#1987 and livekit/rust-sdks#1226, reworked from the earlier opt-in draft after the review comment, then aligned (commit 2) with the connection-level, write-once design from livekit/client-sdk-android#973 that merged in livekit/client-sdk-js#2102 and livekit/rust-sdks#1430.
Problem
libwebrtc starts every new video send stream at roughly 300 kbps and ramps up from there, regardless of the encodings'
maxBitrate. On a healthy network we measured about 30 s to reach a 2.3 Mbps ceiling, with the first 10-12 s at QP 39-42 — a visibly blocky picture. The ObjC API exposes no way to set that starting point, so the handle is libwebrtc's own SDP-levelx-google-start-bitratefmtp parameter.Change
Same behaviour as JS and Rust, no public API:
maxBitrate, × 0.9, capped at 1 Mbps unless the track is a screen share, and no hint under 300 kbps (Transport.startBitrateKbps(targetBps:isScreenShare:)).x-google-start-bitrateper m-section but applies it to the sharedCall(WebRtcVideoSendChannel::ApplyChangedParams→SetSdpBitrateParameters), whereRtpBitrateConfiguratorholds one config per peer connection, so per-sender values were last-writer-wins in m-section order. The offer's value is the largest hint among its sending video sections whosea=msidtrack id is a recorded sender (Transport.connectionStartBitrateKbps(for:kbpsBySenderId:)), and it is declared on every video codec's fmtp (VP8/VP9/AV1/H264/H265) of every sending video section. Receive-only / inactive sections neither contribute nor receive it. An fmtp line is inserted for payloads that have none (VP8); an existing value is replaced.start_bitrate_bpsacross network route changes (RtpTransportControllerSend::OnNetworkRouteChanged), so the munge runs only until the first offer carrying the hint is accepted bysetLocalDescription(_hasAppliedVideoStartBitrate). A rejected munge retries on the next offer; a full reconnect builds a newTransport. Offers before any video is published (audio / data only) find no target and leave the latch unset.set(localDescription:munging:)composition from Negotiate Opus stereo in the subscriber answer so stereo publications are no longer downmixed to mono when using custom renderers #1068, in both single- and dual-PC negotiation, built on theSDPmodule from Encapsulate SDP munging in a tested, line-preserving SDP module #1078.Transport.addTransceiver(with:transceiverInit:startBitrateKbps:)records each sender's hint before returning, so the offer libwebrtc requests can't be created without it;remove(track:)clears it. BothLocalParticipantpublish paths (primary and backup codec) pass it.x-google-max-bitrateis deliberately not written (sameCall-level promotion would cap the connection's total send bandwidth; libwebrtc's own TODO concedes it). Per-track caps stay inmaxBitrateBps.SDPMediaSection.setFmtpParameter(_:value:forPayload:)(replace / append / insert, other parameters kept verbatim).degradationPreferenceby source, the other half of the JS PR, is already inmain(Resolve default video degradation preference by track source, including the backup codec #1083), so nothing to do there.isScreenShare, as the primary path does. It had defaulted to the camera encoding since Multi-codec v2 #275, so a screen share's backup sender got camera bitrates; the new formula's screen-share branch made that visible.Tests
SDPTests,TransportSDPMungeTests(formula, encodings sum, connection-level max with the sending filter, the munge on every sending section: codec case, replace/append/insert, rtx/audio/recvonly untouched, no-op identity, latch condition),VideoEncodingsTests(backup codec's screen-share encodings) and, against the shipped libwebrtc inTransportMungeFallbackTests: a send-only video section's msid carries the sender id,setLocalDescriptionaccepts the munged offer (including the inserted VP8 line), and two offers on one peer connection — answered by a second local peer connection — where only the first carries the hint.Results
These numbers are from the earlier, answer-side version of this branch (H.264 only, hint fixed at 1000 kbps), measured on iOS with a software H.264 encoder, 800x1120@30, single layer,
maxBitrate2.3 Mbps: the estimate opened at ~1000 kbps instead of ~370, peak QP dropped from ~42 to ~28, and the 2.3 Mbps ceiling was reached in ~13 s instead of ~31 s. Zero NACKs. The offer-side variant derives the identical 1000 kbps for that track, but I haven't re-measured it on a device yet — will add a log run.Open questions