From 3dbe09b91c3bb574c24ff24243b49400d6b42b4e Mon Sep 17 00:00:00 2001 From: shijing xian Date: Mon, 29 Jun 2026 18:05:19 +0800 Subject: [PATCH 1/6] fix: improve initial video quality by setting x-google-start-bitrate for all video codecs - Apply x-google-start-bitrate SDP hint to all video codecs (VP8, VP9, AV1, H264, H265), not just SVC codecs - Use 90% of target bitrate as start bitrate to prevent initial blurriness - Default degradationPreference to MAINTAIN_RESOLUTION for video tracks to prefer frame drops over resolution reduction when bandwidth is constrained This addresses the issue where video starts blurry for several seconds before improving, by telling WebRTC's bandwidth estimator to start at a higher bitrate instead of ramping up from ~300kbps. Co-Authored-By: Claude Opus 4.5 --- .../android/room/PeerConnectionTransport.kt | 31 +- .../room/participant/LocalParticipant.kt | 11 +- munging.patch | 591 ++++++++++++++++++ protocol | 2 +- 4 files changed, 621 insertions(+), 14 deletions(-) create mode 100644 munging.patch diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt index efae279a4..04f405bd3 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt @@ -437,13 +437,17 @@ fun ensureVideoDDExtensionForSVC(mediaDesc: MediaDescription) { } } -/* The svc codec (av1/vp9) would use a very low bitrate at the beginning and -increase slowly by the bandwidth estimator until it reach the target bitrate. The -process commonly cost more than 10 seconds cause subscriber will get blur video at -the first few seconds. So we use a 70% of target bitrate here as the start bitrate to -eliminate this issue. -*/ -private const val startBitrateForSVC = 0.7 +/* + * Video codecs use a very low bitrate at the beginning and increase slowly by + * the bandwidth estimator until they reach the target bitrate. The process commonly + * costs more than 10 seconds causing subscribers to get blurry video at the first + * few seconds. We use x-google-start-bitrate to hint the BWE to start higher. + * + * Why 90%: Gives ~10% headroom for bandwidth estimation while starting close to target. + * Why same for all codecs: Target bitrate already accounts for codec efficiency + * (e.g., users set lower targets for VP9/AV1 knowing they're more efficient). + */ +private const val startBitrateMultiplier = 0.9 /** * @suppress @@ -476,7 +480,7 @@ fun ensureCodecBitrates( fmtpFound = true var newFmtpConfig = fmtp.config if (!fmtp.config.contains("x-google-start-bitrate")) { - newFmtpConfig = "$newFmtpConfig;x-google-start-bitrate=${(trackBr.maxBitrate * startBitrateForSVC).roundToLong()}" + newFmtpConfig = "$newFmtpConfig;x-google-start-bitrate=${(trackBr.maxBitrate * startBitrateMultiplier).roundToLong()}" } if (!fmtp.config.contains("x-google-max-bitrate")) { newFmtpConfig = "$newFmtpConfig;x-google-max-bitrate=${trackBr.maxBitrate}" @@ -492,7 +496,7 @@ fun ensureCodecBitrates( media.addAttribute( SdpFmtp( payload = codecPayload, - config = "x-google-start-bitrate=${trackBr.maxBitrate * startBitrateForSVC};" + + config = "x-google-start-bitrate=${trackBr.maxBitrate * startBitrateMultiplier};" + "x-google-max-bitrate=${trackBr.maxBitrate}", ).toAttributeField(), ) @@ -506,6 +510,15 @@ internal fun isSVCCodec(codec: String?): Boolean { "vp9".equals(codec, ignoreCase = true)) } +internal fun isVideoCodec(codec: String?): Boolean { + return codec != null && + ("vp8".equals(codec, ignoreCase = true) || + "vp9".equals(codec, ignoreCase = true) || + "av1".equals(codec, ignoreCase = true) || + "h264".equals(codec, ignoreCase = true) || + "h265".equals(codec, ignoreCase = true)) +} + /** * @suppress */ diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt index 5daa840db..ee70db5e8 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt @@ -38,6 +38,7 @@ import io.livekit.android.room.Room import io.livekit.android.room.TrackBitrateInfo import io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager import io.livekit.android.room.isSVCCodec +import io.livekit.android.room.isVideoCodec import io.livekit.android.room.rpc.RpcClientManager import io.livekit.android.room.rpc.RpcManager import io.livekit.android.room.rpc.RpcServerManager @@ -697,9 +698,9 @@ internal constructor( track.statsGetter = engine.createStatsGetter(transceiver.sender) val finalOptions = options - // Handle trackBitrates + // Handle trackBitrates - apply start bitrate for all video codecs to prevent initial blurriness if (encodings.isNotEmpty()) { - if (finalOptions is VideoTrackPublishOptions && isSVCCodec(finalOptions.videoCodec) && encodings.firstOrNull()?.maxBitrateBps != null) { + if (finalOptions is VideoTrackPublishOptions && isVideoCodec(finalOptions.videoCodec) && encodings.firstOrNull()?.maxBitrateBps != null) { engine.registerTrackBitrateInfo( cid = cid, TrackBitrateInfo( @@ -1481,7 +1482,8 @@ data class VideoTrackPublishDefaults( override val videoCodec: String = VideoCodec.VP8.codecName, override val scalabilityMode: String? = null, override val backupCodec: BackupVideoCodec? = null, - override val degradationPreference: RtpParameters.DegradationPreference? = null, + // Default to MAINTAIN_RESOLUTION to prevent initial video blurriness + override val degradationPreference: RtpParameters.DegradationPreference? = RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION, override val simulcastLayers: List? = null, ) : BaseVideoTrackPublishOptions() @@ -1494,7 +1496,8 @@ data class VideoTrackPublishOptions( override val backupCodec: BackupVideoCodec? = null, override val source: Track.Source? = null, override val stream: String? = null, - override val degradationPreference: RtpParameters.DegradationPreference? = null, + // Default to MAINTAIN_RESOLUTION to prevent initial video blurriness + override val degradationPreference: RtpParameters.DegradationPreference? = RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION, override val simulcastLayers: List? = null, ) : BaseVideoTrackPublishOptions(), TrackPublishOptions { constructor( diff --git a/munging.patch b/munging.patch new file mode 100644 index 000000000..1fb38448a --- /dev/null +++ b/munging.patch @@ -0,0 +1,591 @@ +diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml +index 3501a8d..df624b7 100644 +--- a/gradle/libs.versions.toml ++++ b/gradle/libs.versions.toml +@@ -1,7 +1,6 @@ + [versions] + webrtc = "137.7151.05" + +-androidJainSipRi = "1.3.0-91" + androidx-activity = "1.9.0" + androidx-camera = "1.4.2" + androidx-core = "1.13.1" +@@ -31,7 +30,6 @@ noise = "2.0.0" + lifecycleProcess = "2.8.7" + + [libraries] +-android-jain-sip-ri = { module = "javax.sip:android-jain-sip-ri", version.ref = "androidJainSipRi" } + androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" } + androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" } + androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidx-camera" } +@@ -111,4 +109,3 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" + lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" } + + [plugins] +- +diff --git a/livekit-android-sdk/build.gradle b/livekit-android-sdk/build.gradle +index 45a3f69..2c019e1 100644 +--- a/livekit-android-sdk/build.gradle ++++ b/livekit-android-sdk/build.gradle +@@ -154,8 +154,6 @@ dependencies { + implementation libs.androidx.core + implementation libs.protobuf.javalite + +- implementation libs.android.jain.sip.ri +- + implementation libs.dagger.lib + kapt libs.dagger.compiler + +diff --git a/livekit-android-sdk/consumer-rules.pro b/livekit-android-sdk/consumer-rules.pro +index a3d6542..6ae7318 100644 +--- a/livekit-android-sdk/consumer-rules.pro ++++ b/livekit-android-sdk/consumer-rules.pro +@@ -28,13 +28,6 @@ + @livekit.**.CalledByNativeUnchecked ; + } + +-# NIST sdp parser +-######################################### +-# Preserve reflection used for Parser registrations +--keep class android.gov.nist.javax.sdp.parser.*Parser { *; } +--keep class android.gov.nist.javax.sdp.parser.ParserFactory { *; } +--keep class android.gov.nist.javax.sdp.parser.SDPParser { *; } +- + # Protobuf + ######################################### + -keep class * extends com.google.protobuf.GeneratedMessageLite { *; } +diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt +index 9f6da92..075f139 100644 +--- a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt ++++ b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt +@@ -17,7 +17,6 @@ + package io.livekit.android.dagger + + import android.content.Context +-import android.javax.sdp.SdpFactory + import android.media.AudioAttributes + import android.media.MediaRecorder + import android.os.Build +@@ -408,8 +407,6 @@ internal object RTCModule { + @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL) + fun videoHwAccel() = true + +- @Provides +- fun sdpFactory() = SdpFactory.getInstance() + } + + /** +diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt +index fb6f5a0..f1e533b 100644 +--- a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt ++++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt +@@ -16,8 +16,6 @@ + + package io.livekit.android.room + +-import android.javax.sdp.MediaDescription +-import android.javax.sdp.SdpFactory + import androidx.annotation.VisibleForTesting + import dagger.assisted.Assisted + import dagger.assisted.AssistedFactory +@@ -38,6 +36,8 @@ import io.livekit.android.webrtc.getFmtps + import io.livekit.android.webrtc.getMsid + import io.livekit.android.webrtc.getRtps + import io.livekit.android.webrtc.isConnected ++import io.livekit.android.webrtc.parseSdpSections ++import io.livekit.android.webrtc.SdpMediaSection + import io.livekit.android.webrtc.peerconnection.RTCThreadToken + import io.livekit.android.webrtc.peerconnection.executeBlockingOnRTCThread + import io.livekit.android.webrtc.peerconnection.launchBlockingOnRTCThread +@@ -76,7 +76,6 @@ constructor( + @Named(InjectionNames.DISPATCHER_IO) + private val ioDispatcher: CoroutineDispatcher, + connectionFactory: PeerConnectionFactory, +- private val sdpFactory: SdpFactory, + private val rtcThreadToken: RTCThreadToken, + ) { + private val coroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) +@@ -203,21 +202,16 @@ constructor( + return@launchRTCIfNotClosed + } + // munge sdp +- val sdpDescription = sdpFactory.createSessionDescription(sdpOffer.description) +- +- val mediaDescs = sdpDescription.getMediaDescriptions(true) +- for (mediaDesc in mediaDescs) { +- if (mediaDesc !is MediaDescription) { +- continue +- } +- if (mediaDesc.media.mediaType == "audio") { ++ val sdpSections = parseSdpSections(sdpOffer.description) ++ for (mediaDesc in sdpSections.mediaSections) { ++ if (mediaDesc.mediaType == "audio") { + // TODO +- } else if (mediaDesc.media.mediaType == "video") { ++ } else if (mediaDesc.mediaType == "video") { + ensureVideoDDExtensionForSVC(mediaDesc) + ensureCodecBitrates(mediaDesc, trackBitrates = trackBitrates) + } + } +- finalSdp = setMungedSdp(sdpOffer, sdpDescription.toString()) ++ finalSdp = setMungedSdp(sdpOffer, sdpSections.toSdpString()) + } + + finalSdp?.let { sdp -> +@@ -402,7 +396,7 @@ private const val DD_EXTENSION_URI = "https://aomediacodec.github.io/av1-rtp-spe + * @suppress + */ + @VisibleForTesting +-fun ensureVideoDDExtensionForSVC(mediaDesc: MediaDescription) { ++fun ensureVideoDDExtensionForSVC(mediaDesc: SdpMediaSection) { + val codec = mediaDesc.getRtps() + .firstOrNull() + ?.second +@@ -426,13 +420,14 @@ fun ensureVideoDDExtensionForSVC(mediaDesc: MediaDescription) { + // Not found, add manually + if (!ddFound) { + mediaDesc.addAttribute( ++ "extmap", + SdpExt( + value = maxId + 1, + uri = DD_EXTENSION_URI, + config = null, + direction = null, + encryptUri = null, +- ).toAttributeField(), ++ ).toAttributeValue(), + ) + } + } +@@ -450,7 +445,7 @@ private const val startBitrateForSVC = 0.7 + */ + @VisibleForTesting + fun ensureCodecBitrates( +- media: MediaDescription, ++ media: SdpMediaSection, + trackBitrates: Map, + ) { + val msid = media.getMsid()?.value ?: return +@@ -482,7 +477,7 @@ fun ensureCodecBitrates( + newFmtpConfig = "$newFmtpConfig;x-google-max-bitrate=${trackBr.maxBitrate}" + } + if (fmtp.config != newFmtpConfig) { +- attribute.value = "${fmtp.payload} $newFmtpConfig" ++ attribute.mutableValue = "${fmtp.payload} $newFmtpConfig" + break + } + } +@@ -490,11 +485,12 @@ fun ensureCodecBitrates( + + if (!fmtpFound) { + media.addAttribute( ++ "fmtp", + SdpFmtp( + payload = codecPayload, + config = "x-google-start-bitrate=${trackBr.maxBitrate * startBitrateForSVC};" + + "x-google-max-bitrate=${trackBr.maxBitrate}", +- ).toAttributeField(), ++ ).toAttributeValue(), + ) + } + } +diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/JainSdpUtils.kt b/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/JainSdpUtils.kt +index c6e91c1..371669e 100644 +--- a/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/JainSdpUtils.kt ++++ b/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/JainSdpUtils.kt +@@ -16,8 +16,6 @@ + + package io.livekit.android.webrtc + +-import android.gov.nist.javax.sdp.fields.AttributeField +-import android.javax.sdp.MediaDescription + import io.livekit.android.util.LKLog + + /** +@@ -25,17 +23,142 @@ import io.livekit.android.util.LKLog + */ + data class SdpRtp(val payload: Long, val codec: String, val rate: Long?, val encoding: String?) + ++data class SdpAttributeRef( ++ val name: String, ++ val value: String, ++ private val onSetValue: (String) -> Unit, ++) { ++ var mutableValue: String = value ++ set(newValue) { ++ field = newValue ++ onSetValue(newValue) ++ } ++} ++ ++/** ++ * Represents a single `m=` section in SDP. ++ * ++ * @suppress ++ */ ++class SdpMediaSection internal constructor( ++ private val lines: MutableList, ++) { ++ internal fun toLines(): List = lines.toList() ++ ++ val mediaType: String? ++ get() { ++ val mediaLine = lines.firstOrNull()?.trim() ?: return null ++ if (!mediaLine.startsWith("m=")) return null ++ return mediaLine.removePrefix("m=") ++ .substringBefore(' ') ++ .ifEmpty { null } ++ } ++ ++ fun addAttribute(name: String, value: String? = null) { ++ if (value == null) { ++ lines.add("a=$name") ++ } else { ++ lines.add("a=$name:$value") ++ } ++ } ++ ++ fun getAttribute(name: String): String? { ++ return getAttributes() ++ .firstOrNull { it.name == name } ++ ?.value ++ } ++ ++ fun getAttributes(): List { ++ val output = mutableListOf() ++ lines.forEachIndexed { index, rawLine -> ++ if (!rawLine.startsWith("a=")) { ++ return@forEachIndexed ++ } ++ ++ val content = rawLine.removePrefix("a=") ++ val separator = content.indexOf(':') ++ val (attributeName, attributeValue) = if (separator < 0) { ++ content to "" ++ } else { ++ content.substring(0, separator) to content.substring(separator + 1) ++ } ++ ++ output.add( ++ SdpAttributeRef( ++ name = attributeName, ++ value = attributeValue, ++ ) { newValue -> ++ lines[index] = "a=$attributeName:$newValue" ++ }, ++ ) ++ } ++ return output ++ } ++} ++ + /** + * @suppress + */ +-fun MediaDescription.getRtps(): List> { +- return getAttributes(true) +- .filterIsInstance() +- .filter { it.attribute.name == "rtpmap" } ++data class SdpSections( ++ val sessionLines: List, ++ val mediaSections: List, ++) { ++ fun toSdpString(): String { ++ return buildString { ++ (sessionLines + mediaSections.flatMap { it.toLines() }).forEachIndexed { index, line -> ++ if (index > 0) append("\r\n") ++ append(line) ++ } ++ append("\r\n") ++ } ++ } ++} ++ ++/** ++ * @suppress ++ */ ++fun parseSdpSections(description: String): SdpSections { ++ val normalizedLines = description ++ .replace("\r\n", "\n") ++ .replace('\r', '\n') ++ .split('\n') ++ .filter { it.isNotEmpty() } ++ ++ val sessionLines = mutableListOf() ++ val mediaSections = mutableListOf() ++ ++ var currentMediaSection: MutableList? = null ++ for (line in normalizedLines) { ++ if (line.startsWith("m=")) { ++ currentMediaSection?.let { mediaSections.add(SdpMediaSection(it)) } ++ currentMediaSection = mutableListOf(line) ++ continue ++ } ++ ++ if (currentMediaSection == null) { ++ sessionLines.add(line) ++ } else { ++ currentMediaSection.add(line) ++ } ++ } ++ currentMediaSection?.let { mediaSections.add(SdpMediaSection(it)) } ++ ++ return SdpSections( ++ sessionLines = sessionLines, ++ mediaSections = mediaSections, ++ ) ++} ++ ++/** ++ * @suppress ++ */ ++fun SdpMediaSection.getRtps(): List> { ++ return getAttributes() ++ .filter { it.name == "rtpmap" } + .mapNotNull { +- val rtp = tryParseRtp(it.value) ++ val rtp = tryParseRtp(it.mutableValue) + if (rtp == null) { +- LKLog.w { "could not parse rtpmap: ${it.encode()}" } ++ LKLog.w { "could not parse rtpmap: a=${it.name}:${it.mutableValue}" } + return@mapNotNull null + } + it to rtp +@@ -60,7 +183,7 @@ data class SdpMsid( + /** + * @suppress + */ +-fun MediaDescription.getMsid(): SdpMsid? { ++fun SdpMediaSection.getMsid(): SdpMsid? { + val attribute = getAttribute("msid") ?: return null + return SdpMsid(attribute) + } +@@ -69,25 +192,21 @@ fun MediaDescription.getMsid(): SdpMsid? { + * @suppress + */ + data class SdpFmtp(val payload: Long, val config: String) { +- fun toAttributeField(): AttributeField { +- return AttributeField().apply { +- name = "fmtp" +- value = "$payload $config" +- } ++ fun toAttributeValue(): String { ++ return "$payload $config" + } + } + + /** + * @suppress + */ +-fun MediaDescription.getFmtps(): List> { +- return getAttributes(true) +- .filterIsInstance() +- .filter { it.attribute.name == "fmtp" } ++fun SdpMediaSection.getFmtps(): List> { ++ return getAttributes() ++ .filter { it.name == "fmtp" } + .mapNotNull { +- val fmtp = tryParseFmtp(it.value) ++ val fmtp = tryParseFmtp(it.mutableValue) + if (fmtp == null) { +- LKLog.w { "could not parse fmtp: ${it.encode()}" } ++ LKLog.w { "could not parse fmtp: a=${it.name}:${it.mutableValue}" } + return@mapNotNull null + } + it to fmtp +@@ -105,21 +224,18 @@ internal fun tryParseFmtp(string: String): SdpFmtp? { + * @suppress + */ + data class SdpExt(val value: Long, val direction: String?, val encryptUri: String?, val uri: String, val config: String?) { +- fun toAttributeField(): AttributeField { +- return AttributeField().apply { +- name = "extmap" +- value = buildString { +- append(this@SdpExt.value) +- if (direction != null) { +- append(" $direction") +- } +- if (encryptUri != null) { +- append(" $encryptUri") +- } +- append(" $uri") +- if (config != null) { +- append(" $config") +- } ++ fun toAttributeValue(): String { ++ return buildString { ++ append(this@SdpExt.value) ++ if (direction != null) { ++ append(" $direction") ++ } ++ if (encryptUri != null) { ++ append(" $encryptUri") ++ } ++ append(" $uri") ++ if (config != null) { ++ append(" $config") + } + } + } +@@ -128,14 +244,13 @@ data class SdpExt(val value: Long, val direction: String?, val encryptUri: Strin + /** + * @suppress + */ +-fun MediaDescription.getExts(): List> { +- return getAttributes(true) +- .filterIsInstance() +- .filter { it.attribute.name == "extmap" } ++fun SdpMediaSection.getExts(): List> { ++ return getAttributes() ++ .filter { it.name == "extmap" } + .mapNotNull { +- val ext = tryParseExt(it.value) ++ val ext = tryParseExt(it.mutableValue) + if (ext == null) { +- LKLog.w { "could not parse extmap: ${it.encode()}" } ++ LKLog.w { "could not parse extmap: a=${it.name}:${it.mutableValue}" } + return@mapNotNull null + } + it to ext +diff --git a/livekit-android-test/build.gradle b/livekit-android-test/build.gradle +index 30d84c2..a117d63 100644 +--- a/livekit-android-test/build.gradle ++++ b/livekit-android-test/build.gradle +@@ -82,7 +82,6 @@ dependencies { + api libs.audioswitch + implementation libs.androidx.annotation + api libs.protobuf.javalite +- implementation libs.android.jain.sip.ri + implementation libs.junit + implementation libs.robolectric + implementation libs.mockito.core +diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt +index 0ed771c..5eb2761 100644 +--- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt ++++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt +@@ -17,7 +17,6 @@ + package io.livekit.android.test.mock.dagger + + import android.content.Context +-import android.javax.sdp.SdpFactory + import dagger.Module + import dagger.Provides + import dagger.Reusable +@@ -129,9 +128,6 @@ object TestRTCModule { + @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL) + fun videoHwAccel() = true + +- @Provides +- fun sdpFactory() = SdpFactory.getInstance() +- + @Provides + fun dataPacketCryptorManagerFactory(): DataPacketCryptorManager.Factory = object : DataPacketCryptorManager.Factory { + override fun create(keyProvider: KeyProvider): DataPacketCryptorManager { +diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/SdpMungingTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/SdpMungingTest.kt +index bfecd60..bd9db73 100644 +--- a/livekit-android-test/src/test/java/io/livekit/android/room/SdpMungingTest.kt ++++ b/livekit-android-test/src/test/java/io/livekit/android/room/SdpMungingTest.kt +@@ -16,11 +16,10 @@ + + package io.livekit.android.room + +-import android.javax.sdp.MediaDescription +-import android.javax.sdp.SdpFactory + import io.livekit.android.webrtc.JainSdpUtilsTest + import io.livekit.android.webrtc.getExts + import io.livekit.android.webrtc.getFmtps ++import io.livekit.android.webrtc.parseSdpSections + import org.junit.Assert.assertEquals + import org.junit.Assert.assertNotNull + import org.junit.Assert.assertNull +@@ -30,8 +29,7 @@ class SdpMungingTest { + + @Test + fun ensureVideoDDExtensionForSVCTest() { +- val sdp = SdpFactory.getInstance().createSessionDescription(NO_DD_DESCRIPTION) +- val mediaDescription = sdp.getMediaDescriptions(true).filterIsInstance()[1] ++ val mediaDescription = parseSdpSections(NO_DD_DESCRIPTION).mediaSections[1] + + ensureVideoDDExtensionForSVC(mediaDescription) + +@@ -53,8 +51,7 @@ class SdpMungingTest { + + @Test + fun ensureCodecBitratesTest() { +- val sdp = SdpFactory.getInstance().createSessionDescription(JainSdpUtilsTest.DESCRIPTION) +- val mediaDescription = sdp.getMediaDescriptions(true).filterIsInstance()[1] ++ val mediaDescription = parseSdpSections(JainSdpUtilsTest.DESCRIPTION).mediaSections[1] + + ensureCodecBitrates( + mediaDescription, +diff --git a/livekit-android-test/src/test/java/io/livekit/android/webrtc/JainSdpUtilsTest.kt b/livekit-android-test/src/test/java/io/livekit/android/webrtc/JainSdpUtilsTest.kt +index 18870af..f9f9a77 100644 +--- a/livekit-android-test/src/test/java/io/livekit/android/webrtc/JainSdpUtilsTest.kt ++++ b/livekit-android-test/src/test/java/io/livekit/android/webrtc/JainSdpUtilsTest.kt +@@ -16,9 +16,6 @@ + + package io.livekit.android.webrtc + +-import android.javax.sdp.MediaDescription +-import android.javax.sdp.SdpFactory +-import android.javax.sdp.SessionDescription + import org.junit.Assert.assertEquals + import org.junit.Assert.assertNotNull + import org.junit.Assert.assertNull +@@ -26,17 +23,13 @@ import org.junit.Test + + class JainSdpUtilsTest { + +- private val sdpFactory = SdpFactory.getInstance() +- private fun createSessionDescription(): SessionDescription { +- return sdpFactory.createSessionDescription(DESCRIPTION) ++ private fun createMediaDescription(): SdpMediaSection { ++ return parseSdpSections(DESCRIPTION).mediaSections[1] + } + + @Test + fun getRtpAttributes() { +- val sdp = createSessionDescription() +- val mediaDescriptions = sdp.getMediaDescriptions(true) +- .filterIsInstance() +- val mediaDesc = mediaDescriptions[1] ++ val mediaDesc = createMediaDescription() + val rtps = mediaDesc.getRtps() + assertEquals(13, rtps.size) + +@@ -50,10 +43,7 @@ class JainSdpUtilsTest { + + @Test + fun getExtmapAttributes() { +- val sdp = createSessionDescription() +- val mediaDescriptions = sdp.getMediaDescriptions(true) +- .filterIsInstance() +- val mediaDesc = mediaDescriptions[1] ++ val mediaDesc = createMediaDescription() + val exts = mediaDesc.getExts() + + assertEquals(12, exts.size) +@@ -68,10 +58,7 @@ class JainSdpUtilsTest { + + @Test + fun getMsid() { +- val sdp = createSessionDescription() +- val mediaDescriptions = sdp.getMediaDescriptions(true) +- .filterIsInstance() +- val mediaDesc = mediaDescriptions[1] ++ val mediaDesc = createMediaDescription() + + val msid = mediaDesc.getMsid() + assertNotNull(msid) +@@ -80,10 +67,7 @@ class JainSdpUtilsTest { + + @Test + fun getFmtps() { +- val sdp = createSessionDescription() +- val mediaDescriptions = sdp.getMediaDescriptions(true) +- .filterIsInstance() +- val mediaDesc = mediaDescriptions[1] ++ val mediaDesc = createMediaDescription() + + val fmtps = mediaDesc.getFmtps() + .filter { (_, fmtp) -> fmtp.payload == 97L } diff --git a/protocol b/protocol index 8381f2180..4c05a3325 160000 --- a/protocol +++ b/protocol @@ -1 +1 @@ -Subproject commit 8381f2180c45ab926b3ebf19df0608f1dadcac1e +Subproject commit 4c05a3325ec35760bee1c0bfe57b7011604a124f From 2c6a0ca096597ba3316f24ab9992bde9be7cbbe6 Mon Sep 17 00:00:00 2001 From: shijing xian Date: Wed, 1 Jul 2026 07:55:36 +0800 Subject: [PATCH 2/6] fix: revert to SVC-only bitrate setting to avoid simulcast issues - Revert isVideoCodec back to isSVCCodec for bitrate registration - For simulcast, encodings are ordered smallest-to-largest, so encodings.first() returns the lowest layer's bitrate (e.g., 160kbps for H180), which would incorrectly cap all layers at that low value - SVC codecs (VP9, AV1) have a single encoding with the full bitrate, so this logic is safe for them - Remove unused isVideoCodec function - Remove accidentally committed munging.patch file Co-Authored-By: Claude Opus 4.5 --- .../android/room/PeerConnectionTransport.kt | 9 - .../room/participant/LocalParticipant.kt | 9 +- munging.patch | 591 ------------------ 3 files changed, 6 insertions(+), 603 deletions(-) delete mode 100644 munging.patch diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt index 04f405bd3..d37ec7d67 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt @@ -510,15 +510,6 @@ internal fun isSVCCodec(codec: String?): Boolean { "vp9".equals(codec, ignoreCase = true)) } -internal fun isVideoCodec(codec: String?): Boolean { - return codec != null && - ("vp8".equals(codec, ignoreCase = true) || - "vp9".equals(codec, ignoreCase = true) || - "av1".equals(codec, ignoreCase = true) || - "h264".equals(codec, ignoreCase = true) || - "h265".equals(codec, ignoreCase = true)) -} - /** * @suppress */ diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt index ee70db5e8..5800d2f09 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt @@ -38,7 +38,6 @@ import io.livekit.android.room.Room import io.livekit.android.room.TrackBitrateInfo import io.livekit.android.room.datastream.outgoing.OutgoingDataStreamManager import io.livekit.android.room.isSVCCodec -import io.livekit.android.room.isVideoCodec import io.livekit.android.room.rpc.RpcClientManager import io.livekit.android.room.rpc.RpcManager import io.livekit.android.room.rpc.RpcServerManager @@ -698,9 +697,13 @@ internal constructor( track.statsGetter = engine.createStatsGetter(transceiver.sender) val finalOptions = options - // Handle trackBitrates - apply start bitrate for all video codecs to prevent initial blurriness + // Handle trackBitrates - apply start bitrate for SVC codecs to prevent initial blurriness. + // Only SVC codecs (VP9, AV1) are supported here because they have a single encoding with + // the full bitrate. Simulcast codecs (VP8, H264) have multiple encodings ordered + // smallest-to-largest, so encodings.first() would incorrectly return the lowest layer's + // bitrate, which would cap all layers at that low value. if (encodings.isNotEmpty()) { - if (finalOptions is VideoTrackPublishOptions && isVideoCodec(finalOptions.videoCodec) && encodings.firstOrNull()?.maxBitrateBps != null) { + if (finalOptions is VideoTrackPublishOptions && isSVCCodec(finalOptions.videoCodec) && encodings.firstOrNull()?.maxBitrateBps != null) { engine.registerTrackBitrateInfo( cid = cid, TrackBitrateInfo( diff --git a/munging.patch b/munging.patch deleted file mode 100644 index 1fb38448a..000000000 --- a/munging.patch +++ /dev/null @@ -1,591 +0,0 @@ -diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml -index 3501a8d..df624b7 100644 ---- a/gradle/libs.versions.toml -+++ b/gradle/libs.versions.toml -@@ -1,7 +1,6 @@ - [versions] - webrtc = "137.7151.05" - --androidJainSipRi = "1.3.0-91" - androidx-activity = "1.9.0" - androidx-camera = "1.4.2" - androidx-core = "1.13.1" -@@ -31,7 +30,6 @@ noise = "2.0.0" - lifecycleProcess = "2.8.7" - - [libraries] --android-jain-sip-ri = { module = "javax.sip:android-jain-sip-ri", version.ref = "androidJainSipRi" } - androidx-activity-ktx = { module = "androidx.activity:activity-ktx", version.ref = "androidx-activity" } - androidx-camera-core = { module = "androidx.camera:camera-core", version.ref = "androidx-camera" } - androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "androidx-camera" } -@@ -111,4 +109,3 @@ androidx-material3 = { group = "androidx.compose.material3", name = "material3" - lifecycle-process = { group = "androidx.lifecycle", name = "lifecycle-process", version.ref = "lifecycleProcess" } - - [plugins] -- -diff --git a/livekit-android-sdk/build.gradle b/livekit-android-sdk/build.gradle -index 45a3f69..2c019e1 100644 ---- a/livekit-android-sdk/build.gradle -+++ b/livekit-android-sdk/build.gradle -@@ -154,8 +154,6 @@ dependencies { - implementation libs.androidx.core - implementation libs.protobuf.javalite - -- implementation libs.android.jain.sip.ri -- - implementation libs.dagger.lib - kapt libs.dagger.compiler - -diff --git a/livekit-android-sdk/consumer-rules.pro b/livekit-android-sdk/consumer-rules.pro -index a3d6542..6ae7318 100644 ---- a/livekit-android-sdk/consumer-rules.pro -+++ b/livekit-android-sdk/consumer-rules.pro -@@ -28,13 +28,6 @@ - @livekit.**.CalledByNativeUnchecked ; - } - --# NIST sdp parser --######################################### --# Preserve reflection used for Parser registrations ---keep class android.gov.nist.javax.sdp.parser.*Parser { *; } ---keep class android.gov.nist.javax.sdp.parser.ParserFactory { *; } ---keep class android.gov.nist.javax.sdp.parser.SDPParser { *; } -- - # Protobuf - ######################################### - -keep class * extends com.google.protobuf.GeneratedMessageLite { *; } -diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt -index 9f6da92..075f139 100644 ---- a/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt -+++ b/livekit-android-sdk/src/main/java/io/livekit/android/dagger/RTCModule.kt -@@ -17,7 +17,6 @@ - package io.livekit.android.dagger - - import android.content.Context --import android.javax.sdp.SdpFactory - import android.media.AudioAttributes - import android.media.MediaRecorder - import android.os.Build -@@ -408,8 +407,6 @@ internal object RTCModule { - @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL) - fun videoHwAccel() = true - -- @Provides -- fun sdpFactory() = SdpFactory.getInstance() - } - - /** -diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt -index fb6f5a0..f1e533b 100644 ---- a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt -+++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt -@@ -16,8 +16,6 @@ - - package io.livekit.android.room - --import android.javax.sdp.MediaDescription --import android.javax.sdp.SdpFactory - import androidx.annotation.VisibleForTesting - import dagger.assisted.Assisted - import dagger.assisted.AssistedFactory -@@ -38,6 +36,8 @@ import io.livekit.android.webrtc.getFmtps - import io.livekit.android.webrtc.getMsid - import io.livekit.android.webrtc.getRtps - import io.livekit.android.webrtc.isConnected -+import io.livekit.android.webrtc.parseSdpSections -+import io.livekit.android.webrtc.SdpMediaSection - import io.livekit.android.webrtc.peerconnection.RTCThreadToken - import io.livekit.android.webrtc.peerconnection.executeBlockingOnRTCThread - import io.livekit.android.webrtc.peerconnection.launchBlockingOnRTCThread -@@ -76,7 +76,6 @@ constructor( - @Named(InjectionNames.DISPATCHER_IO) - private val ioDispatcher: CoroutineDispatcher, - connectionFactory: PeerConnectionFactory, -- private val sdpFactory: SdpFactory, - private val rtcThreadToken: RTCThreadToken, - ) { - private val coroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) -@@ -203,21 +202,16 @@ constructor( - return@launchRTCIfNotClosed - } - // munge sdp -- val sdpDescription = sdpFactory.createSessionDescription(sdpOffer.description) -- -- val mediaDescs = sdpDescription.getMediaDescriptions(true) -- for (mediaDesc in mediaDescs) { -- if (mediaDesc !is MediaDescription) { -- continue -- } -- if (mediaDesc.media.mediaType == "audio") { -+ val sdpSections = parseSdpSections(sdpOffer.description) -+ for (mediaDesc in sdpSections.mediaSections) { -+ if (mediaDesc.mediaType == "audio") { - // TODO -- } else if (mediaDesc.media.mediaType == "video") { -+ } else if (mediaDesc.mediaType == "video") { - ensureVideoDDExtensionForSVC(mediaDesc) - ensureCodecBitrates(mediaDesc, trackBitrates = trackBitrates) - } - } -- finalSdp = setMungedSdp(sdpOffer, sdpDescription.toString()) -+ finalSdp = setMungedSdp(sdpOffer, sdpSections.toSdpString()) - } - - finalSdp?.let { sdp -> -@@ -402,7 +396,7 @@ private const val DD_EXTENSION_URI = "https://aomediacodec.github.io/av1-rtp-spe - * @suppress - */ - @VisibleForTesting --fun ensureVideoDDExtensionForSVC(mediaDesc: MediaDescription) { -+fun ensureVideoDDExtensionForSVC(mediaDesc: SdpMediaSection) { - val codec = mediaDesc.getRtps() - .firstOrNull() - ?.second -@@ -426,13 +420,14 @@ fun ensureVideoDDExtensionForSVC(mediaDesc: MediaDescription) { - // Not found, add manually - if (!ddFound) { - mediaDesc.addAttribute( -+ "extmap", - SdpExt( - value = maxId + 1, - uri = DD_EXTENSION_URI, - config = null, - direction = null, - encryptUri = null, -- ).toAttributeField(), -+ ).toAttributeValue(), - ) - } - } -@@ -450,7 +445,7 @@ private const val startBitrateForSVC = 0.7 - */ - @VisibleForTesting - fun ensureCodecBitrates( -- media: MediaDescription, -+ media: SdpMediaSection, - trackBitrates: Map, - ) { - val msid = media.getMsid()?.value ?: return -@@ -482,7 +477,7 @@ fun ensureCodecBitrates( - newFmtpConfig = "$newFmtpConfig;x-google-max-bitrate=${trackBr.maxBitrate}" - } - if (fmtp.config != newFmtpConfig) { -- attribute.value = "${fmtp.payload} $newFmtpConfig" -+ attribute.mutableValue = "${fmtp.payload} $newFmtpConfig" - break - } - } -@@ -490,11 +485,12 @@ fun ensureCodecBitrates( - - if (!fmtpFound) { - media.addAttribute( -+ "fmtp", - SdpFmtp( - payload = codecPayload, - config = "x-google-start-bitrate=${trackBr.maxBitrate * startBitrateForSVC};" + - "x-google-max-bitrate=${trackBr.maxBitrate}", -- ).toAttributeField(), -+ ).toAttributeValue(), - ) - } - } -diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/JainSdpUtils.kt b/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/JainSdpUtils.kt -index c6e91c1..371669e 100644 ---- a/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/JainSdpUtils.kt -+++ b/livekit-android-sdk/src/main/java/io/livekit/android/webrtc/JainSdpUtils.kt -@@ -16,8 +16,6 @@ - - package io.livekit.android.webrtc - --import android.gov.nist.javax.sdp.fields.AttributeField --import android.javax.sdp.MediaDescription - import io.livekit.android.util.LKLog - - /** -@@ -25,17 +23,142 @@ import io.livekit.android.util.LKLog - */ - data class SdpRtp(val payload: Long, val codec: String, val rate: Long?, val encoding: String?) - -+data class SdpAttributeRef( -+ val name: String, -+ val value: String, -+ private val onSetValue: (String) -> Unit, -+) { -+ var mutableValue: String = value -+ set(newValue) { -+ field = newValue -+ onSetValue(newValue) -+ } -+} -+ -+/** -+ * Represents a single `m=` section in SDP. -+ * -+ * @suppress -+ */ -+class SdpMediaSection internal constructor( -+ private val lines: MutableList, -+) { -+ internal fun toLines(): List = lines.toList() -+ -+ val mediaType: String? -+ get() { -+ val mediaLine = lines.firstOrNull()?.trim() ?: return null -+ if (!mediaLine.startsWith("m=")) return null -+ return mediaLine.removePrefix("m=") -+ .substringBefore(' ') -+ .ifEmpty { null } -+ } -+ -+ fun addAttribute(name: String, value: String? = null) { -+ if (value == null) { -+ lines.add("a=$name") -+ } else { -+ lines.add("a=$name:$value") -+ } -+ } -+ -+ fun getAttribute(name: String): String? { -+ return getAttributes() -+ .firstOrNull { it.name == name } -+ ?.value -+ } -+ -+ fun getAttributes(): List { -+ val output = mutableListOf() -+ lines.forEachIndexed { index, rawLine -> -+ if (!rawLine.startsWith("a=")) { -+ return@forEachIndexed -+ } -+ -+ val content = rawLine.removePrefix("a=") -+ val separator = content.indexOf(':') -+ val (attributeName, attributeValue) = if (separator < 0) { -+ content to "" -+ } else { -+ content.substring(0, separator) to content.substring(separator + 1) -+ } -+ -+ output.add( -+ SdpAttributeRef( -+ name = attributeName, -+ value = attributeValue, -+ ) { newValue -> -+ lines[index] = "a=$attributeName:$newValue" -+ }, -+ ) -+ } -+ return output -+ } -+} -+ - /** - * @suppress - */ --fun MediaDescription.getRtps(): List> { -- return getAttributes(true) -- .filterIsInstance() -- .filter { it.attribute.name == "rtpmap" } -+data class SdpSections( -+ val sessionLines: List, -+ val mediaSections: List, -+) { -+ fun toSdpString(): String { -+ return buildString { -+ (sessionLines + mediaSections.flatMap { it.toLines() }).forEachIndexed { index, line -> -+ if (index > 0) append("\r\n") -+ append(line) -+ } -+ append("\r\n") -+ } -+ } -+} -+ -+/** -+ * @suppress -+ */ -+fun parseSdpSections(description: String): SdpSections { -+ val normalizedLines = description -+ .replace("\r\n", "\n") -+ .replace('\r', '\n') -+ .split('\n') -+ .filter { it.isNotEmpty() } -+ -+ val sessionLines = mutableListOf() -+ val mediaSections = mutableListOf() -+ -+ var currentMediaSection: MutableList? = null -+ for (line in normalizedLines) { -+ if (line.startsWith("m=")) { -+ currentMediaSection?.let { mediaSections.add(SdpMediaSection(it)) } -+ currentMediaSection = mutableListOf(line) -+ continue -+ } -+ -+ if (currentMediaSection == null) { -+ sessionLines.add(line) -+ } else { -+ currentMediaSection.add(line) -+ } -+ } -+ currentMediaSection?.let { mediaSections.add(SdpMediaSection(it)) } -+ -+ return SdpSections( -+ sessionLines = sessionLines, -+ mediaSections = mediaSections, -+ ) -+} -+ -+/** -+ * @suppress -+ */ -+fun SdpMediaSection.getRtps(): List> { -+ return getAttributes() -+ .filter { it.name == "rtpmap" } - .mapNotNull { -- val rtp = tryParseRtp(it.value) -+ val rtp = tryParseRtp(it.mutableValue) - if (rtp == null) { -- LKLog.w { "could not parse rtpmap: ${it.encode()}" } -+ LKLog.w { "could not parse rtpmap: a=${it.name}:${it.mutableValue}" } - return@mapNotNull null - } - it to rtp -@@ -60,7 +183,7 @@ data class SdpMsid( - /** - * @suppress - */ --fun MediaDescription.getMsid(): SdpMsid? { -+fun SdpMediaSection.getMsid(): SdpMsid? { - val attribute = getAttribute("msid") ?: return null - return SdpMsid(attribute) - } -@@ -69,25 +192,21 @@ fun MediaDescription.getMsid(): SdpMsid? { - * @suppress - */ - data class SdpFmtp(val payload: Long, val config: String) { -- fun toAttributeField(): AttributeField { -- return AttributeField().apply { -- name = "fmtp" -- value = "$payload $config" -- } -+ fun toAttributeValue(): String { -+ return "$payload $config" - } - } - - /** - * @suppress - */ --fun MediaDescription.getFmtps(): List> { -- return getAttributes(true) -- .filterIsInstance() -- .filter { it.attribute.name == "fmtp" } -+fun SdpMediaSection.getFmtps(): List> { -+ return getAttributes() -+ .filter { it.name == "fmtp" } - .mapNotNull { -- val fmtp = tryParseFmtp(it.value) -+ val fmtp = tryParseFmtp(it.mutableValue) - if (fmtp == null) { -- LKLog.w { "could not parse fmtp: ${it.encode()}" } -+ LKLog.w { "could not parse fmtp: a=${it.name}:${it.mutableValue}" } - return@mapNotNull null - } - it to fmtp -@@ -105,21 +224,18 @@ internal fun tryParseFmtp(string: String): SdpFmtp? { - * @suppress - */ - data class SdpExt(val value: Long, val direction: String?, val encryptUri: String?, val uri: String, val config: String?) { -- fun toAttributeField(): AttributeField { -- return AttributeField().apply { -- name = "extmap" -- value = buildString { -- append(this@SdpExt.value) -- if (direction != null) { -- append(" $direction") -- } -- if (encryptUri != null) { -- append(" $encryptUri") -- } -- append(" $uri") -- if (config != null) { -- append(" $config") -- } -+ fun toAttributeValue(): String { -+ return buildString { -+ append(this@SdpExt.value) -+ if (direction != null) { -+ append(" $direction") -+ } -+ if (encryptUri != null) { -+ append(" $encryptUri") -+ } -+ append(" $uri") -+ if (config != null) { -+ append(" $config") - } - } - } -@@ -128,14 +244,13 @@ data class SdpExt(val value: Long, val direction: String?, val encryptUri: Strin - /** - * @suppress - */ --fun MediaDescription.getExts(): List> { -- return getAttributes(true) -- .filterIsInstance() -- .filter { it.attribute.name == "extmap" } -+fun SdpMediaSection.getExts(): List> { -+ return getAttributes() -+ .filter { it.name == "extmap" } - .mapNotNull { -- val ext = tryParseExt(it.value) -+ val ext = tryParseExt(it.mutableValue) - if (ext == null) { -- LKLog.w { "could not parse extmap: ${it.encode()}" } -+ LKLog.w { "could not parse extmap: a=${it.name}:${it.mutableValue}" } - return@mapNotNull null - } - it to ext -diff --git a/livekit-android-test/build.gradle b/livekit-android-test/build.gradle -index 30d84c2..a117d63 100644 ---- a/livekit-android-test/build.gradle -+++ b/livekit-android-test/build.gradle -@@ -82,7 +82,6 @@ dependencies { - api libs.audioswitch - implementation libs.androidx.annotation - api libs.protobuf.javalite -- implementation libs.android.jain.sip.ri - implementation libs.junit - implementation libs.robolectric - implementation libs.mockito.core -diff --git a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt -index 0ed771c..5eb2761 100644 ---- a/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt -+++ b/livekit-android-test/src/main/java/io/livekit/android/test/mock/dagger/TestRTCModule.kt -@@ -17,7 +17,6 @@ - package io.livekit.android.test.mock.dagger - - import android.content.Context --import android.javax.sdp.SdpFactory - import dagger.Module - import dagger.Provides - import dagger.Reusable -@@ -129,9 +128,6 @@ object TestRTCModule { - @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL) - fun videoHwAccel() = true - -- @Provides -- fun sdpFactory() = SdpFactory.getInstance() -- - @Provides - fun dataPacketCryptorManagerFactory(): DataPacketCryptorManager.Factory = object : DataPacketCryptorManager.Factory { - override fun create(keyProvider: KeyProvider): DataPacketCryptorManager { -diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/SdpMungingTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/SdpMungingTest.kt -index bfecd60..bd9db73 100644 ---- a/livekit-android-test/src/test/java/io/livekit/android/room/SdpMungingTest.kt -+++ b/livekit-android-test/src/test/java/io/livekit/android/room/SdpMungingTest.kt -@@ -16,11 +16,10 @@ - - package io.livekit.android.room - --import android.javax.sdp.MediaDescription --import android.javax.sdp.SdpFactory - import io.livekit.android.webrtc.JainSdpUtilsTest - import io.livekit.android.webrtc.getExts - import io.livekit.android.webrtc.getFmtps -+import io.livekit.android.webrtc.parseSdpSections - import org.junit.Assert.assertEquals - import org.junit.Assert.assertNotNull - import org.junit.Assert.assertNull -@@ -30,8 +29,7 @@ class SdpMungingTest { - - @Test - fun ensureVideoDDExtensionForSVCTest() { -- val sdp = SdpFactory.getInstance().createSessionDescription(NO_DD_DESCRIPTION) -- val mediaDescription = sdp.getMediaDescriptions(true).filterIsInstance()[1] -+ val mediaDescription = parseSdpSections(NO_DD_DESCRIPTION).mediaSections[1] - - ensureVideoDDExtensionForSVC(mediaDescription) - -@@ -53,8 +51,7 @@ class SdpMungingTest { - - @Test - fun ensureCodecBitratesTest() { -- val sdp = SdpFactory.getInstance().createSessionDescription(JainSdpUtilsTest.DESCRIPTION) -- val mediaDescription = sdp.getMediaDescriptions(true).filterIsInstance()[1] -+ val mediaDescription = parseSdpSections(JainSdpUtilsTest.DESCRIPTION).mediaSections[1] - - ensureCodecBitrates( - mediaDescription, -diff --git a/livekit-android-test/src/test/java/io/livekit/android/webrtc/JainSdpUtilsTest.kt b/livekit-android-test/src/test/java/io/livekit/android/webrtc/JainSdpUtilsTest.kt -index 18870af..f9f9a77 100644 ---- a/livekit-android-test/src/test/java/io/livekit/android/webrtc/JainSdpUtilsTest.kt -+++ b/livekit-android-test/src/test/java/io/livekit/android/webrtc/JainSdpUtilsTest.kt -@@ -16,9 +16,6 @@ - - package io.livekit.android.webrtc - --import android.javax.sdp.MediaDescription --import android.javax.sdp.SdpFactory --import android.javax.sdp.SessionDescription - import org.junit.Assert.assertEquals - import org.junit.Assert.assertNotNull - import org.junit.Assert.assertNull -@@ -26,17 +23,13 @@ import org.junit.Test - - class JainSdpUtilsTest { - -- private val sdpFactory = SdpFactory.getInstance() -- private fun createSessionDescription(): SessionDescription { -- return sdpFactory.createSessionDescription(DESCRIPTION) -+ private fun createMediaDescription(): SdpMediaSection { -+ return parseSdpSections(DESCRIPTION).mediaSections[1] - } - - @Test - fun getRtpAttributes() { -- val sdp = createSessionDescription() -- val mediaDescriptions = sdp.getMediaDescriptions(true) -- .filterIsInstance() -- val mediaDesc = mediaDescriptions[1] -+ val mediaDesc = createMediaDescription() - val rtps = mediaDesc.getRtps() - assertEquals(13, rtps.size) - -@@ -50,10 +43,7 @@ class JainSdpUtilsTest { - - @Test - fun getExtmapAttributes() { -- val sdp = createSessionDescription() -- val mediaDescriptions = sdp.getMediaDescriptions(true) -- .filterIsInstance() -- val mediaDesc = mediaDescriptions[1] -+ val mediaDesc = createMediaDescription() - val exts = mediaDesc.getExts() - - assertEquals(12, exts.size) -@@ -68,10 +58,7 @@ class JainSdpUtilsTest { - - @Test - fun getMsid() { -- val sdp = createSessionDescription() -- val mediaDescriptions = sdp.getMediaDescriptions(true) -- .filterIsInstance() -- val mediaDesc = mediaDescriptions[1] -+ val mediaDesc = createMediaDescription() - - val msid = mediaDesc.getMsid() - assertNotNull(msid) -@@ -80,10 +67,7 @@ class JainSdpUtilsTest { - - @Test - fun getFmtps() { -- val sdp = createSessionDescription() -- val mediaDescriptions = sdp.getMediaDescriptions(true) -- .filterIsInstance() -- val mediaDesc = mediaDescriptions[1] -+ val mediaDesc = createMediaDescription() - - val fmtps = mediaDesc.getFmtps() - .filter { (_, fmtp) -> fmtp.payload == 97L } From 6364aee69e1c6b2773842c04f300b77dbecb49f9 Mon Sep 17 00:00:00 2001 From: shijing xian Date: Thu, 16 Jul 2026 18:02:12 +0800 Subject: [PATCH 3/6] update the code to match the latest Rust / JS behavior --- .../android/room/PeerConnectionTransport.kt | 12 +++- .../room/participant/LocalParticipant.kt | 57 ++++++++++++++----- 2 files changed, 52 insertions(+), 17 deletions(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt index d37ec7d67..f74454a64 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt @@ -446,9 +446,13 @@ fun ensureVideoDDExtensionForSVC(mediaDesc: MediaDescription) { * Why 90%: Gives ~10% headroom for bandwidth estimation while starting close to target. * Why same for all codecs: Target bitrate already accounts for codec efficiency * (e.g., users set lower targets for VP9/AV1 knowing they're more efficient). + * Why cap at 1 Mbps: Prevents BWE from starting too aggressively on high bitrate tracks. */ private const val startBitrateMultiplier = 0.9 +/** Maximum x-google-start-bitrate in kbps. 1 Mbps prevents BWE from starting too aggressively. */ +private const val maxStartBitrateKbps = 1000L + /** * @suppress */ @@ -480,7 +484,9 @@ fun ensureCodecBitrates( fmtpFound = true var newFmtpConfig = fmtp.config if (!fmtp.config.contains("x-google-start-bitrate")) { - newFmtpConfig = "$newFmtpConfig;x-google-start-bitrate=${(trackBr.maxBitrate * startBitrateMultiplier).roundToLong()}" + // Use 90% of target bitrate, capped at 1 Mbps to prevent BWE from starting too aggressively + val startBitrate = minOf((trackBr.maxBitrate * startBitrateMultiplier).roundToLong(), maxStartBitrateKbps) + newFmtpConfig = "$newFmtpConfig;x-google-start-bitrate=$startBitrate" } if (!fmtp.config.contains("x-google-max-bitrate")) { newFmtpConfig = "$newFmtpConfig;x-google-max-bitrate=${trackBr.maxBitrate}" @@ -493,10 +499,12 @@ fun ensureCodecBitrates( } if (!fmtpFound) { + // Use 90% of target bitrate, capped at 1 Mbps to prevent BWE from starting too aggressively + val startBitrate = minOf((trackBr.maxBitrate * startBitrateMultiplier).roundToLong(), maxStartBitrateKbps) media.addAttribute( SdpFmtp( payload = codecPayload, - config = "x-google-start-bitrate=${trackBr.maxBitrate * startBitrateMultiplier};" + + config = "x-google-start-bitrate=$startBitrate;" + "x-google-max-bitrate=${trackBr.maxBitrate}", ).toAttributeField(), ) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt index 5800d2f09..4a4cca83a 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt @@ -697,18 +697,16 @@ internal constructor( track.statsGetter = engine.createStatsGetter(transceiver.sender) val finalOptions = options - // Handle trackBitrates - apply start bitrate for SVC codecs to prevent initial blurriness. - // Only SVC codecs (VP9, AV1) are supported here because they have a single encoding with - // the full bitrate. Simulcast codecs (VP8, H264) have multiple encodings ordered - // smallest-to-largest, so encodings.first() would incorrectly return the lowest layer's - // bitrate, which would cap all layers at that low value. - if (encodings.isNotEmpty()) { - if (finalOptions is VideoTrackPublishOptions && isSVCCodec(finalOptions.videoCodec) && encodings.firstOrNull()?.maxBitrateBps != null) { + // Handle trackBitrates - apply start bitrate for all video codecs to prevent initial blurriness. + // Sum all encoding bitrates for simulcast (BWE needs to handle all layers combined). + if (encodings.isNotEmpty() && finalOptions is VideoTrackPublishOptions) { + val totalBitrateBps = encodings.sumOf { it.maxBitrateBps ?: 0 } + if (totalBitrateBps > 0) { engine.registerTrackBitrateInfo( cid = cid, TrackBitrateInfo( codec = finalOptions.videoCodec, - maxBitrate = (encodings.first().maxBitrateBps?.div(1000) ?: 0).toLong(), + maxBitrate = totalBitrateBps / 1000, ), ) } @@ -720,7 +718,12 @@ internal constructor( (track as LocalVideoTrack).codec = finalOptions.videoCodec val rtpParameters = transceiver.sender.parameters + // Use provided degradation preference, or default based on track source: + // - Camera: MAINTAIN_FRAMERATE (smoother video for real-time communication) + // - Screen share: MAINTAIN_RESOLUTION (clarity is critical for text/UI) + // - Other/unknown: BALANCED rtpParameters.degradationPreference = finalOptions.degradationPreference + ?: getDefaultDegradationPreference(trackSource) transceiver.sender.parameters = rtpParameters } @@ -1460,10 +1463,17 @@ abstract class BaseVideoTrackPublishOptions { abstract val backupCodec: BackupVideoCodec? /** - * When bandwidth is constrained, this preference indicates which is preferred - * between degrading resolution vs. framerate. + * Controls how the encoder trades off between resolution and framerate + * when bandwidth is constrained. * - * null value indicates default value (maintain framerate). + * - MAINTAIN_FRAMERATE: Prioritizes framerate, reduces resolution if needed + * - MAINTAIN_RESOLUTION: Prioritizes resolution, drops frames if needed + * - BALANCED: Balances between both + * + * If not set (null), the SDK uses defaults based on track source: + * - Camera: MAINTAIN_FRAMERATE (smoother video for real-time communication) + * - Screen share: MAINTAIN_RESOLUTION (clarity is critical for text/UI) + * - Other/unknown: BALANCED */ abstract val degradationPreference: RtpParameters.DegradationPreference? @@ -1485,8 +1495,9 @@ data class VideoTrackPublishDefaults( override val videoCodec: String = VideoCodec.VP8.codecName, override val scalabilityMode: String? = null, override val backupCodec: BackupVideoCodec? = null, - // Default to MAINTAIN_RESOLUTION to prevent initial video blurriness - override val degradationPreference: RtpParameters.DegradationPreference? = RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION, + // Default is null - SDK applies source-based defaults at runtime: + // Camera: MAINTAIN_FRAMERATE, Screen share: MAINTAIN_RESOLUTION, Other: BALANCED + override val degradationPreference: RtpParameters.DegradationPreference? = null, override val simulcastLayers: List? = null, ) : BaseVideoTrackPublishOptions() @@ -1499,8 +1510,9 @@ data class VideoTrackPublishOptions( override val backupCodec: BackupVideoCodec? = null, override val source: Track.Source? = null, override val stream: String? = null, - // Default to MAINTAIN_RESOLUTION to prevent initial video blurriness - override val degradationPreference: RtpParameters.DegradationPreference? = RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION, + // Default is null - SDK applies source-based defaults at runtime: + // Camera: MAINTAIN_FRAMERATE, Screen share: MAINTAIN_RESOLUTION, Other: BALANCED + override val degradationPreference: RtpParameters.DegradationPreference? = null, override val simulcastLayers: List? = null, ) : BaseVideoTrackPublishOptions(), TrackPublishOptions { constructor( @@ -1667,6 +1679,21 @@ internal fun VideoTrackPublishOptions.hasBackupCodec(): Boolean { private val backupCodecs = listOf(VideoCodec.VP8.codecName, VideoCodec.H264.codecName) private fun isBackupCodec(codecName: String) = backupCodecs.contains(codecName) +/** + * Returns the appropriate degradation preference for a video track based on its source. + * + * - Camera: MAINTAIN_FRAMERATE (smoother video for real-time communication) + * - Screen share: MAINTAIN_RESOLUTION (clarity is critical for reading text/UI) + * - Other/unknown: BALANCED + */ +private fun getDefaultDegradationPreference(source: Track.Source): RtpParameters.DegradationPreference { + return when (source) { + Track.Source.CAMERA -> RtpParameters.DegradationPreference.MAINTAIN_FRAMERATE + Track.Source.SCREEN_SHARE -> RtpParameters.DegradationPreference.MAINTAIN_RESOLUTION + else -> RtpParameters.DegradationPreference.BALANCED + } +} + /** * A handler that processes an RPC request and returns a string * that will be sent back to the requester. The payload must From 67d21f1e79a812e4827fc031af7abea678feaccf Mon Sep 17 00:00:00 2001 From: shijing xian Date: Sat, 18 Jul 2026 09:35:40 +0800 Subject: [PATCH 4/6] update the code to match latest Rust / JS SDKs behaviors --- .../android/room/PeerConnectionTransport.kt | 15 +++++++++++---- .../android/room/participant/LocalParticipant.kt | 14 ++++++++++---- 2 files changed, 21 insertions(+), 8 deletions(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt index f74454a64..d678531d7 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt @@ -478,14 +478,22 @@ fun ensureCodecBitrates( val codecPayload = rtp.payload val fmtps = media.getFmtps() + // Use 90% of target bitrate, capped at 1 Mbps for camera to prevent BWE from starting too aggressively + // Screen share is not capped since text/UI clarity requires high bitrate from the start + // TODO: dynamically adjust start bitrate based on network conditions (e.g., use previous BWE estimate) + val calculatedStartBitrate = (trackBr.maxBitrate * startBitrateMultiplier).roundToLong() + val startBitrate = if (trackBr.isScreenShare) { + calculatedStartBitrate + } else { + minOf(calculatedStartBitrate, maxStartBitrateKbps) + } + var fmtpFound = false for ((attribute, fmtp) in fmtps) { if (fmtp.payload == codecPayload) { fmtpFound = true var newFmtpConfig = fmtp.config if (!fmtp.config.contains("x-google-start-bitrate")) { - // Use 90% of target bitrate, capped at 1 Mbps to prevent BWE from starting too aggressively - val startBitrate = minOf((trackBr.maxBitrate * startBitrateMultiplier).roundToLong(), maxStartBitrateKbps) newFmtpConfig = "$newFmtpConfig;x-google-start-bitrate=$startBitrate" } if (!fmtp.config.contains("x-google-max-bitrate")) { @@ -499,8 +507,6 @@ fun ensureCodecBitrates( } if (!fmtpFound) { - // Use 90% of target bitrate, capped at 1 Mbps to prevent BWE from starting too aggressively - val startBitrate = minOf((trackBr.maxBitrate * startBitrateMultiplier).roundToLong(), maxStartBitrateKbps) media.addAttribute( SdpFmtp( payload = codecPayload, @@ -524,6 +530,7 @@ internal fun isSVCCodec(codec: String?): Boolean { data class TrackBitrateInfo( val codec: String, val maxBitrate: Long, + val isScreenShare: Boolean = false, ) /** diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt index 4a4cca83a..caaa82c93 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt @@ -698,15 +698,21 @@ internal constructor( val finalOptions = options // Handle trackBitrates - apply start bitrate for all video codecs to prevent initial blurriness. - // Sum all encoding bitrates for simulcast (BWE needs to handle all layers combined). + // - SVC codecs: use first encoding's bitrate (single stream with built-in layers) + // - Simulcast: sum all encoding bitrates (independent streams, BWE needs total) if (encodings.isNotEmpty() && finalOptions is VideoTrackPublishOptions) { - val totalBitrateBps = encodings.sumOf { it.maxBitrateBps ?: 0 } - if (totalBitrateBps > 0) { + val targetBitrateBps = if (isSVCCodec(finalOptions.videoCodec)) { + encodings.firstOrNull()?.maxBitrateBps ?: 0 + } else { + encodings.sumOf { it.maxBitrateBps ?: 0 } + } + if (targetBitrateBps > 0) { engine.registerTrackBitrateInfo( cid = cid, TrackBitrateInfo( codec = finalOptions.videoCodec, - maxBitrate = totalBitrateBps / 1000, + maxBitrate = targetBitrateBps / 1000, + isScreenShare = trackSource == Track.Source.SCREEN_SHARE, ), ) } From 8d2e18879482c36156610cd53f040e505d122e7b Mon Sep 17 00:00:00 2001 From: shijing xian Date: Sat, 18 Jul 2026 14:33:36 +0800 Subject: [PATCH 5/6] fixed the build and cap the hint with 300kbps --- .../io/livekit/android/room/PeerConnectionTransport.kt | 8 ++++++++ .../livekit/android/room/participant/LocalParticipant.kt | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt index d678531d7..b114d58d1 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/PeerConnectionTransport.kt @@ -453,6 +453,9 @@ private const val startBitrateMultiplier = 0.9 /** Maximum x-google-start-bitrate in kbps. 1 Mbps prevents BWE from starting too aggressively. */ private const val maxStartBitrateKbps = 1000L +/** Minimum target bitrate in kbps to apply start bitrate hint. Below this, the hint hurts more than it helps. */ +private const val minTargetBitrateKbps = 300L + /** * @suppress */ @@ -477,6 +480,11 @@ fun ensureCodecBitrates( ?: continue val codecPayload = rtp.payload + // Skip start bitrate hint for very low bitrate tracks - the hint hurts more than it helps + if (trackBr.maxBitrate < minTargetBitrateKbps) { + continue + } + val fmtps = media.getFmtps() // Use 90% of target bitrate, capped at 1 Mbps for camera to prevent BWE from starting too aggressively // Screen share is not capped since text/UI clarity requires high bitrate from the start diff --git a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt index caaa82c93..60b9781ba 100644 --- a/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt +++ b/livekit-android-sdk/src/main/java/io/livekit/android/room/participant/LocalParticipant.kt @@ -701,10 +701,10 @@ internal constructor( // - SVC codecs: use first encoding's bitrate (single stream with built-in layers) // - Simulcast: sum all encoding bitrates (independent streams, BWE needs total) if (encodings.isNotEmpty() && finalOptions is VideoTrackPublishOptions) { - val targetBitrateBps = if (isSVCCodec(finalOptions.videoCodec)) { - encodings.firstOrNull()?.maxBitrateBps ?: 0 + val targetBitrateBps: Long = if (isSVCCodec(finalOptions.videoCodec)) { + (encodings.firstOrNull()?.maxBitrateBps ?: 0).toLong() } else { - encodings.sumOf { it.maxBitrateBps ?: 0 } + encodings.sumOf { (it.maxBitrateBps ?: 0).toLong() } } if (targetBitrateBps > 0) { engine.registerTrackBitrateInfo( From 99ee7a75adddad422592378914da00dca5fa20c2 Mon Sep 17 00:00:00 2001 From: shijing xian Date: Tue, 21 Jul 2026 14:15:13 +0800 Subject: [PATCH 6/6] fix the tests --- .../src/test/java/io/livekit/android/room/SdpMungingTest.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/livekit-android-test/src/test/java/io/livekit/android/room/SdpMungingTest.kt b/livekit-android-test/src/test/java/io/livekit/android/room/SdpMungingTest.kt index bfecd6012..211c02302 100644 --- a/livekit-android-test/src/test/java/io/livekit/android/room/SdpMungingTest.kt +++ b/livekit-android-test/src/test/java/io/livekit/android/room/SdpMungingTest.kt @@ -56,13 +56,15 @@ class SdpMungingTest { val sdp = SdpFactory.getInstance().createSessionDescription(JainSdpUtilsTest.DESCRIPTION) val mediaDescription = sdp.getMediaDescriptions(true).filterIsInstance()[1] + // Use realistic bitrate: 1000 kbps (1 Mbps) + // With 0.9 multiplier: startBitrate = 900 kbps (below 1 Mbps cap) ensureCodecBitrates( mediaDescription, mapOf( TrackBitrateInfoKey.Cid("PA_Qwqk4y9fcD3G") to TrackBitrateInfo( "VP9", - 1000000L, + 1000L, ), ), ) @@ -71,7 +73,7 @@ class SdpMungingTest { .filter { (_, fmtp) -> fmtp.payload == 98L } .first() - assertEquals("profile-id=0;x-google-start-bitrate=700000;x-google-max-bitrate=1000000", vp9fmtp.config) + assertEquals("profile-id=0;x-google-start-bitrate=900;x-google-max-bitrate=1000", vp9fmtp.config) } companion object {