Skip to Content
InternalsMedia negotiation

Media negotiation

The gap between “WebRTC connects” and “WebRTC connects at 4K H.265 stereo” is almost entirely SDP work.

Codec preference

AppSettings.videoCodec is one of AV1, H265, H264 - there is no “Auto”. Auto used to let the SDK’s native order decide (and pushed HEVC to the back), but the outcome varied by device and build and made calls unpredictable, so it was removed. The default is H.264: universal hardware support, and the only codec a browser peer can receive.

WebRtcClient.setPreferredCodec(name) stores an uppercase codec name (default "H264") and calls applyCodecPreference():

private fun applyCodecPreference() { val transceiver = pc.transceivers.first { it.mediaType == MEDIA_TYPE_VIDEO } val caps = pcf.getRtpSenderCapabilities(MEDIA_TYPE_VIDEO) val usable = caps.codecs.filterNot { it.name.uppercase() in setOf("VP8", "VP9") } // chosen codec first, then the rest by efficiency as a fallback val ordered = usable.sortedByDescending { c -> val n = c.name.uppercase() when { n == pref -> 100 n in codecRank -> 50 - codecRank.indexOf(n) // AV1 > H265 > H264 else -> -1 } } transceiver.setCodecPreferences(ordered) }
  • VP8 / VP9 are always stripped, both directions. H.264 is the universal fallback and covers everything VPx would, so the app doesn’t carry the older codecs.
  • setCodecPreferences only affects offer ordering - the peer still has to support the codec for it to be selected.
  • It runs again after any renegotiation / ICE restart.

webrtc-sdk M144 advertises H.265 with level-id=93 in its a=fmtp line. That is HEVC Level 3.1 - roughly a 720p@30 budget. A strict hardware decoder (MediaTek Codec2, Tensor) will reject any frame that exceeds the negotiated level, so a 4K stream negotiated at Level 3.1 simply never decodes.

The fix rewrites every H.265 fmtp line before setLocalDescription:

a=fmtp:<pt> level-id=93 → a=fmtp:<pt> level-id=153

level-id=153 is Level 5.1 - 4K@60, Main tier. If a payload type has an rtpmap but no fmtp, one is synthesised (a=fmtp:<pt> level-id=153). Both peers run Viora, so both sides raise the ceiling and the negotiation lands at 5.1.

private fun raiseH265Level(sdp: String): String { /* per-m-section fmtp rewrite */ }

Failures are swallowed - if the rewrite throws, the original SDP is used.

tuneOpus - stereo, FEC, no DTX

The default Opus offer is mono, ~40 kbps, DTX on. tuneOpus rewrites the Opus a=fmtp line with:

ParamValueWhy
stereo / sprop-stereo1Full stereo capture and playback (both directions declared)
maxaveragebitrate262144 (256 kbps)128 kbps/channel VBR ceiling - headroom for shared media audio and FEC
useinbandfec1In-band forward error correction - lost packets are partially reconstructed from the next one
usedtx0DTX (comfort-noise on silence) off - it interacts badly with the loudness probe and music

The regex is careful to match stereo=1 at a param boundary and not the stereo=1 inside sprop-stereo=1.

The SDP munge is paired with an RtpSender parameter change (applyAudioBitrate() sets encoding.maxBitrateBps = 262144) - the SDP sets the codec ceiling, the sender parameters set the transport ceiling.

Bitrate seeding for strict HEVC encoders

Some hardware HEVC encoders (again MediaTek Codec2, Tensor) honour the initial bandwidth estimate very literally. WebRTC’s BWE starts low and ramps, so a 4K HEVC stream can spend its first seconds cranked to QP ~37 (a smeary mess) or starved to ~0.2 Mbps before the estimate climbs.

WebRtcClient seeds the estimate with PeerConnection.setBitrate(min, start, max) right after the tracks are added, so the encoder starts near its target instead of ramping from the floor.

withRaisedLevel

Everything is composed in one call applied to both offer and answer:

private fun withRaisedLevel(desc: SessionDescription) = SessionDescription(desc.type, tuneOpus(raiseH265Level(desc.description)))

Call sites:

createOffer → onCreateSuccess { d -> pc.setLocalDescription(obs, withRaisedLevel(d)) } createAnswer → onCreateSuccess { d -> pc.setLocalDescription(obs, withRaisedLevel(d)) }

Video quality → capture format

AppSettings.VideoQuality maps to a concrete capture request, not a label:

PresetCaptureEncoder cap
Auto960×540@30, WebRTC adaptsuncapped (BWE-driven)
Data Saver640×480@30900 kbps
HD1280×720@302,500 kbps
Full HD1920×1080@306,000 kbps
2K2560×1440@3012,000 kbps
4K3840×2160@3020,000 kbps

An explicit preset locks the resolution and lets FPS give under load; Auto keeps WebRTC’s BALANCED degradation (drop resolution, hold FPS). See video capture.