davidliu
Committed by GitHub

SVC Codec support (#304)

* SDP munging

* track bitrates

* Switch to using android-jain-sip-ri for sdp munging

* Update protocol version

* add support for svc codec

* Backup codec support

* handle pong response

* multi codec publishing

* test for multicodec

* Add LiveKit.init convenience function for e2e testing

* spotless apply
正在显示 32 个修改的文件 包含 1506 行增加114 行删除
@@ -29,7 +29,7 @@ jobs: @@ -29,7 +29,7 @@ jobs:
29 path: ./client-sdk-android 29 path: ./client-sdk-android
30 submodules: recursive 30 submodules: recursive
31 31
32 - - name: set up JDK 12 32 + - name: set up JDK 17
33 uses: actions/setup-java@v3.12.0 33 uses: actions/setup-java@v3.12.0
34 with: 34 with:
35 java-version: '17' 35 java-version: '17'
1 <component name="ProjectDictionaryState"> 1 <component name="ProjectDictionaryState">
2 <dictionary name="davidliu"> 2 <dictionary name="davidliu">
3 <words> 3 <words>
  4 + <w>bitrates</w>
4 <w>capturer</w> 5 <w>capturer</w>
  6 + <w>exts</w>
  7 + <w>msid</w>
5 </words> 8 </words>
6 </dictionary> 9 </dictionary>
7 </component> 10 </component>
@@ -22,3 +22,27 @@ See the License for the specific language governing permissions and @@ -22,3 +22,27 @@ See the License for the specific language governing permissions and
22 limitations under the License. 22 limitations under the License.
23 23
24 ##################################################################################### 24 #####################################################################################
  25 +
  26 +The following modifications follow MIT License from https://github.com/ggarber/sdpparser
  27 +
  28 +MIT License
  29 +
  30 +Copyright (c) 2023 Gustavo Garcia
  31 +
  32 +Permission is hereby granted, free of charge, to any person obtaining a copy
  33 +of this software and associated documentation files (the "Software"), to deal
  34 +in the Software without restriction, including without limitation the rights
  35 +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
  36 +copies of the Software, and to permit persons to whom the Software is
  37 +furnished to do so, subject to the following conditions:
  38 +
  39 +The above copyright notice and this permission notice shall be included in all
  40 +copies or substantial portions of the Software.
  41 +
  42 +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
  43 +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
  44 +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
  45 +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
  46 +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
  47 +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
  48 +SOFTWARE.
@@ -150,6 +150,8 @@ dependencies { @@ -150,6 +150,8 @@ dependencies {
150 implementation "androidx.core:core:${versions.androidx_core}" 150 implementation "androidx.core:core:${versions.androidx_core}"
151 implementation "com.google.protobuf:protobuf-javalite:${versions.protobuf}" 151 implementation "com.google.protobuf:protobuf-javalite:${versions.protobuf}"
152 152
  153 + implementation 'javax.sip:android-jain-sip-ri:1.3.0-91'
  154 +
153 implementation "com.google.dagger:dagger:${versions.dagger}" 155 implementation "com.google.dagger:dagger:${versions.dagger}"
154 kapt "com.google.dagger:dagger-compiler:${versions.dagger}" 156 kapt "com.google.dagger:dagger-compiler:${versions.dagger}"
155 157
@@ -19,6 +19,7 @@ package io.livekit.android @@ -19,6 +19,7 @@ package io.livekit.android
19 import android.app.Application 19 import android.app.Application
20 import android.content.Context 20 import android.content.Context
21 import io.livekit.android.dagger.DaggerLiveKitComponent 21 import io.livekit.android.dagger.DaggerLiveKitComponent
  22 +import io.livekit.android.dagger.RTCModule
22 import io.livekit.android.dagger.create 23 import io.livekit.android.dagger.create
23 import io.livekit.android.room.ProtocolVersion 24 import io.livekit.android.room.ProtocolVersion
24 import io.livekit.android.room.Room 25 import io.livekit.android.room.Room
@@ -60,6 +61,16 @@ class LiveKit { @@ -60,6 +61,16 @@ class LiveKit {
60 var enableWebRTCLogging: Boolean = false 61 var enableWebRTCLogging: Boolean = false
61 62
62 /** 63 /**
  64 + * Certain WebRTC classes need to be initialized prior to use.
  65 + *
  66 + * This does not need to be called under normal circumstances, as [LiveKit.create]
  67 + * will handle this for you.
  68 + */
  69 + fun init(appContext: Context) {
  70 + RTCModule.libWebrtcInitialization(appContext)
  71 + }
  72 +
  73 + /**
63 * Create a Room object. 74 * Create a Room object.
64 */ 75 */
65 fun create( 76 fun create(
@@ -93,6 +104,7 @@ class LiveKit { @@ -93,6 +104,7 @@ class LiveKit {
93 room.videoTrackPublishDefaults = it 104 room.videoTrackPublishDefaults = it
94 } 105 }
95 room.adaptiveStream = options.adaptiveStream 106 room.adaptiveStream = options.adaptiveStream
  107 + room.dynacast = options.dynacast
96 108
97 return room 109 return room
98 } 110 }
@@ -110,7 +122,7 @@ class LiveKit { @@ -110,7 +122,7 @@ class LiveKit {
110 options: ConnectOptions = ConnectOptions(), 122 options: ConnectOptions = ConnectOptions(),
111 roomOptions: RoomOptions = RoomOptions(), 123 roomOptions: RoomOptions = RoomOptions(),
112 listener: RoomListener? = null, 124 listener: RoomListener? = null,
113 - overrides: LiveKitOverrides = LiveKitOverrides() 125 + overrides: LiveKitOverrides = LiveKitOverrides(),
114 ): Room { 126 ): Room {
115 val room = create(appContext, roomOptions, overrides) 127 val room = create(appContext, roomOptions, overrides)
116 128
@@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
17 package io.livekit.android.dagger 17 package io.livekit.android.dagger
18 18
19 import android.content.Context 19 import android.content.Context
  20 +import android.javax.sdp.SdpFactory
20 import android.media.AudioAttributes 21 import android.media.AudioAttributes
21 import android.media.MediaRecorder 22 import android.media.MediaRecorder
22 import android.os.Build 23 import android.os.Build
@@ -257,6 +258,9 @@ object RTCModule { @@ -257,6 +258,9 @@ object RTCModule {
257 @Provides 258 @Provides
258 @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL) 259 @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL)
259 fun videoHwAccel() = true 260 fun videoHwAccel() = true
  261 +
  262 + @Provides
  263 + fun sdpFactory() = SdpFactory.getInstance()
260 } 264 }
261 265
262 /** 266 /**
@@ -16,6 +16,8 @@ @@ -16,6 +16,8 @@
16 16
17 package io.livekit.android.room 17 package io.livekit.android.room
18 18
  19 +import android.javax.sdp.MediaDescription
  20 +import android.javax.sdp.SdpFactory
19 import dagger.assisted.Assisted 21 import dagger.assisted.Assisted
20 import dagger.assisted.AssistedFactory 22 import dagger.assisted.AssistedFactory
21 import dagger.assisted.AssistedInject 23 import dagger.assisted.AssistedInject
@@ -24,6 +26,12 @@ import io.livekit.android.room.util.* @@ -24,6 +26,12 @@ import io.livekit.android.room.util.*
24 import io.livekit.android.util.Either 26 import io.livekit.android.util.Either
25 import io.livekit.android.util.LKLog 27 import io.livekit.android.util.LKLog
26 import io.livekit.android.util.debounce 28 import io.livekit.android.util.debounce
  29 +import io.livekit.android.webrtc.SdpExt
  30 +import io.livekit.android.webrtc.SdpFmtp
  31 +import io.livekit.android.webrtc.getExts
  32 +import io.livekit.android.webrtc.getFmtps
  33 +import io.livekit.android.webrtc.getMsid
  34 +import io.livekit.android.webrtc.getRtps
27 import kotlinx.coroutines.CoroutineDispatcher 35 import kotlinx.coroutines.CoroutineDispatcher
28 import kotlinx.coroutines.CoroutineScope 36 import kotlinx.coroutines.CoroutineScope
29 import kotlinx.coroutines.SupervisorJob 37 import kotlinx.coroutines.SupervisorJob
@@ -33,6 +41,7 @@ import kotlinx.coroutines.sync.withLock @@ -33,6 +41,7 @@ import kotlinx.coroutines.sync.withLock
33 import org.webrtc.* 41 import org.webrtc.*
34 import org.webrtc.PeerConnection.RTCConfiguration 42 import org.webrtc.PeerConnection.RTCConfiguration
35 import javax.inject.Named 43 import javax.inject.Named
  44 +import kotlin.math.roundToLong
36 45
37 /** 46 /**
38 * @suppress 47 * @suppress
@@ -40,12 +49,13 @@ import javax.inject.Named @@ -40,12 +49,13 @@ import javax.inject.Named
40 internal class PeerConnectionTransport 49 internal class PeerConnectionTransport
41 @AssistedInject 50 @AssistedInject
42 constructor( 51 constructor(
43 - @Assisted config: PeerConnection.RTCConfiguration, 52 + @Assisted config: RTCConfiguration,
44 @Assisted pcObserver: PeerConnection.Observer, 53 @Assisted pcObserver: PeerConnection.Observer,
45 @Assisted private val listener: Listener?, 54 @Assisted private val listener: Listener?,
46 @Named(InjectionNames.DISPATCHER_IO) 55 @Named(InjectionNames.DISPATCHER_IO)
47 private val ioDispatcher: CoroutineDispatcher, 56 private val ioDispatcher: CoroutineDispatcher,
48 connectionFactory: PeerConnectionFactory, 57 connectionFactory: PeerConnectionFactory,
  58 + private val sdpFactory: SdpFactory,
49 ) { 59 ) {
50 private val coroutineScope = CoroutineScope(ioDispatcher + SupervisorJob()) 60 private val coroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
51 internal val peerConnection: PeerConnection = connectionFactory.createPeerConnection( 61 internal val peerConnection: PeerConnection = connectionFactory.createPeerConnection(
@@ -59,6 +69,8 @@ constructor( @@ -59,6 +69,8 @@ constructor(
59 69
60 private val mutex = Mutex() 70 private val mutex = Mutex()
61 71
  72 + private var trackBitrates = mutableMapOf<TrackBitrateInfoKey, TrackBitrateInfo>()
  73 +
62 interface Listener { 74 interface Listener {
63 fun onOffer(sd: SessionDescription) 75 fun onOffer(sd: SessionDescription)
64 } 76 }
@@ -139,9 +151,82 @@ constructor( @@ -139,9 +151,82 @@ constructor(
139 } 151 }
140 } 152 }
141 153
142 - LKLog.v { "sdp offer = $sdpOffer, description: ${sdpOffer.description}, type: ${sdpOffer.type}" }  
143 - peerConnection.setLocalDescription(sdpOffer)  
144 - listener?.onOffer(sdpOffer) 154 + // munge sdp
  155 + val sdpDescription = sdpFactory.createSessionDescription(sdpOffer.description)
  156 +
  157 + val mediaDescs = sdpDescription.getMediaDescriptions(true)
  158 + for (mediaDesc in mediaDescs) {
  159 + if (mediaDesc !is MediaDescription) {
  160 + continue
  161 + }
  162 + if (mediaDesc.media.mediaType == "audio") {
  163 + // TODO
  164 + } else if (mediaDesc.media.mediaType == "video") {
  165 + ensureVideoDDExtensionForSVC(mediaDesc)
  166 + ensureCodecBitrates(mediaDesc, trackBitrates = trackBitrates)
  167 + }
  168 + }
  169 +
  170 + val finalSdp = setMungedSdp(sdpOffer, sdpDescription.toString())
  171 + listener.onOffer(finalSdp)
  172 + }
  173 +
  174 + private suspend fun setMungedSdp(sdp: SessionDescription, mungedDescription: String, remote: Boolean = false): SessionDescription {
  175 + val mungedSdp = SessionDescription(sdp.type, mungedDescription)
  176 +
  177 + LKLog.v { "sdp type: ${sdp.type}\ndescription:\n${sdp.description}" }
  178 + LKLog.v { "munged sdp type: ${mungedSdp.type}\ndescription:\n${mungedSdp.description}" }
  179 + val mungedResult = if (remote) {
  180 + peerConnection.setRemoteDescription(mungedSdp)
  181 + } else {
  182 + peerConnection.setLocalDescription(mungedSdp)
  183 + }
  184 +
  185 + val mungedErrorMessage = when (mungedResult) {
  186 + is Either.Left -> {
  187 + // munged sdp set successfully.
  188 + return mungedSdp
  189 + }
  190 +
  191 + is Either.Right -> {
  192 + if (mungedResult.value.isNullOrBlank()) {
  193 + "unknown sdp error"
  194 + } else {
  195 + mungedResult.value
  196 + }
  197 + }
  198 + }
  199 +
  200 + // munged sdp setting failed
  201 + LKLog.w {
  202 + "setting munged sdp for " +
  203 + "${if (remote) "remote" else "local"} description, " +
  204 + "${mungedSdp.type} type failed, falling back to unmodified."
  205 + }
  206 + LKLog.w { "error: $mungedErrorMessage" }
  207 +
  208 + val result = if (remote) {
  209 + peerConnection.setRemoteDescription(sdp)
  210 + } else {
  211 + peerConnection.setLocalDescription(sdp)
  212 + }
  213 +
  214 + if (result is Either.Right) {
  215 + val errorMessage = if (result.value.isNullOrBlank()) {
  216 + "unknown sdp error"
  217 + } else {
  218 + result.value
  219 + }
  220 +
  221 + // sdp setting failed
  222 + LKLog.w {
  223 + "setting original sdp for " +
  224 + "${if (remote) "remote" else "local"} description, " +
  225 + "${sdp.type} type failed!"
  226 + }
  227 + LKLog.w { "error: $errorMessage" }
  228 + }
  229 + return sdp
145 } 230 }
146 231
147 fun prepareForIceRestart() { 232 fun prepareForIceRestart() {
@@ -156,12 +241,132 @@ constructor( @@ -156,12 +241,132 @@ constructor(
156 peerConnection.setConfiguration(config) 241 peerConnection.setConfiguration(config)
157 } 242 }
158 243
  244 + fun registerTrackBitrateInfo(cid: String, trackBitrateInfo: TrackBitrateInfo) {
  245 + trackBitrates[TrackBitrateInfoKey.Cid(cid)] = trackBitrateInfo
  246 + }
  247 +
  248 + fun registerTrackBitrateInfo(transceiver: RtpTransceiver, trackBitrateInfo: TrackBitrateInfo) {
  249 + trackBitrates[TrackBitrateInfoKey.Transceiver(transceiver)] = trackBitrateInfo
  250 + }
  251 +
159 @AssistedFactory 252 @AssistedFactory
160 interface Factory { 253 interface Factory {
161 fun create( 254 fun create(
162 - config: PeerConnection.RTCConfiguration, 255 + config: RTCConfiguration,
163 pcObserver: PeerConnection.Observer, 256 pcObserver: PeerConnection.Observer,
164 listener: Listener?, 257 listener: Listener?,
165 ): PeerConnectionTransport 258 ): PeerConnectionTransport
166 } 259 }
167 } 260 }
  261 +
  262 +private const val DD_EXTENSION_URI = "https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension"
  263 +
  264 +internal fun ensureVideoDDExtensionForSVC(mediaDesc: MediaDescription) {
  265 + val codec = mediaDesc.getRtps()
  266 + .firstOrNull()
  267 + ?.second
  268 + ?.codec ?: return
  269 + if (!isSVCCodec(codec)) {
  270 + return
  271 + }
  272 +
  273 + var maxId = 0L
  274 +
  275 + val ddFound = mediaDesc.getExts().any { (_, ext) ->
  276 + if (ext.uri == DD_EXTENSION_URI) {
  277 + return@any true
  278 + }
  279 + if (ext.value > maxId) {
  280 + maxId = ext.value
  281 + }
  282 + false
  283 + }
  284 +
  285 + // Not found, add manually
  286 + if (!ddFound) {
  287 + mediaDesc.addAttribute(
  288 + SdpExt(
  289 + value = maxId + 1,
  290 + uri = DD_EXTENSION_URI,
  291 + config = null,
  292 + direction = null,
  293 + encryptUri = null,
  294 + ).toAttributeField(),
  295 + )
  296 + }
  297 +}
  298 +
  299 +/* The svc codec (av1/vp9) would use a very low bitrate at the begining and
  300 +increase slowly by the bandwidth estimator until it reach the target bitrate. The
  301 +process commonly cost more than 10 seconds cause subscriber will get blur video at
  302 +the first few seconds. So we use a 70% of target bitrate here as the start bitrate to
  303 +eliminate this issue.
  304 +*/
  305 +private const val startBitrateForSVC = 0.7
  306 +
  307 +internal fun ensureCodecBitrates(
  308 + media: MediaDescription,
  309 + trackBitrates: Map<TrackBitrateInfoKey, TrackBitrateInfo>,
  310 +) {
  311 + val msid = media.getMsid()?.value ?: return
  312 + for ((key, trackBr) in trackBitrates) {
  313 + if (key !is TrackBitrateInfoKey.Cid) {
  314 + continue
  315 + }
  316 +
  317 + val (cid) = key
  318 + if (!msid.contains(cid)) {
  319 + continue
  320 + }
  321 +
  322 + val (_, rtp) = media.getRtps()
  323 + .firstOrNull { (_, rtp) -> rtp.codec.equals(trackBr.codec, ignoreCase = true) }
  324 + ?: continue
  325 + val codecPayload = rtp.payload
  326 +
  327 + val fmtps = media.getFmtps()
  328 + var fmtpFound = false
  329 + for ((attribute, fmtp) in fmtps) {
  330 + if (fmtp.payload == codecPayload) {
  331 + fmtpFound = true
  332 + var newFmtpConfig = fmtp.config
  333 + if (!fmtp.config.contains("x-google-start-bitrate")) {
  334 + newFmtpConfig = "$newFmtpConfig;x-google-start-bitrate=${(trackBr.maxBitrate * startBitrateForSVC).roundToLong()}"
  335 + }
  336 + if (!fmtp.config.contains("x-google-max-bitrate")) {
  337 + newFmtpConfig = "$newFmtpConfig;x-google-max-bitrate=${trackBr.maxBitrate}"
  338 + }
  339 + if (fmtp.config != newFmtpConfig) {
  340 + attribute.value = "${fmtp.payload} $newFmtpConfig"
  341 + break
  342 + }
  343 + }
  344 + }
  345 +
  346 + if (!fmtpFound) {
  347 + media.addAttribute(
  348 + SdpFmtp(
  349 + payload = codecPayload,
  350 + config = "x-google-start-bitrate=${trackBr.maxBitrate * startBitrateForSVC};" +
  351 + "x-google-max-bitrate=${trackBr.maxBitrate}",
  352 + ).toAttributeField(),
  353 + )
  354 + }
  355 + }
  356 +}
  357 +
  358 +internal fun isSVCCodec(codec: String?): Boolean {
  359 + return codec != null &&
  360 + ("av1".equals(codec, ignoreCase = true) ||
  361 + "vp9".equals(codec, ignoreCase = true))
  362 +}
  363 +
  364 +internal data class TrackBitrateInfo(
  365 + val codec: String,
  366 + val maxBitrate: Long,
  367 +)
  368 +
  369 +sealed class TrackBitrateInfoKey {
  370 + data class Cid(val value: String) : TrackBitrateInfoKey()
  371 + data class Transceiver(val value: RtpTransceiver) : TrackBitrateInfoKey()
  372 +}
@@ -43,6 +43,7 @@ import livekit.LivekitRtc.JoinResponse @@ -43,6 +43,7 @@ import livekit.LivekitRtc.JoinResponse
43 import livekit.LivekitRtc.ReconnectResponse 43 import livekit.LivekitRtc.ReconnectResponse
44 import org.webrtc.* 44 import org.webrtc.*
45 import org.webrtc.PeerConnection.RTCConfiguration 45 import org.webrtc.PeerConnection.RTCConfiguration
  46 +import org.webrtc.RtpTransceiver.RtpTransceiverInit
46 import java.net.ConnectException 47 import java.net.ConnectException
47 import java.nio.ByteBuffer 48 import java.nio.ByteBuffer
48 import javax.inject.Inject 49 import javax.inject.Inject
@@ -278,6 +279,13 @@ internal constructor( @@ -278,6 +279,13 @@ internal constructor(
278 } 279 }
279 } 280 }
280 281
  282 + internal fun createSenderTransceiver(
  283 + rtcTrack: MediaStreamTrack,
  284 + transInit: RtpTransceiverInit,
  285 + ): RtpTransceiver? {
  286 + return publisher.peerConnection.addTransceiver(rtcTrack, transInit)
  287 + }
  288 +
281 fun updateSubscriptionPermissions( 289 fun updateSubscriptionPermissions(
282 allParticipants: Boolean, 290 allParticipants: Boolean,
283 participantTrackPermissions: List<ParticipantTrackPermission>, 291 participantTrackPermissions: List<ParticipantTrackPermission>,
@@ -152,8 +152,15 @@ constructor( @@ -152,8 +152,15 @@ constructor(
152 * significantly reducing publishing CPU and bandwidth usage. 152 * significantly reducing publishing CPU and bandwidth usage.
153 * 153 *
154 * Defaults to false. 154 * Defaults to false.
  155 + *
  156 + * Will be enabled if SVC codecs (i.e. VP9/AV1) are used. Multi-codec simulcast
  157 + * requires dynacast.
155 */ 158 */
156 - var dynacast: Boolean = false 159 + var dynacast: Boolean
  160 + get() = localParticipant.dynacast
  161 + set(value) {
  162 + localParticipant.dynacast = value
  163 + }
157 164
158 /** 165 /**
159 * Default options to use when creating an audio track. 166 * Default options to use when creating an audio track.
@@ -175,7 +182,7 @@ constructor( @@ -175,7 +182,7 @@ constructor(
175 */ 182 */
176 var videoTrackPublishDefaults: VideoTrackPublishDefaults by defaultsManager::videoTrackPublishDefaults 183 var videoTrackPublishDefaults: VideoTrackPublishDefaults by defaultsManager::videoTrackPublishDefaults
177 184
178 - val localParticipant: LocalParticipant = localParticipantFactory.create(dynacast = dynacast).apply { 185 + val localParticipant: LocalParticipant = localParticipantFactory.create(dynacast = false).apply {
179 internalListener = this@Room 186 internalListener = this@Room
180 } 187 }
181 188
@@ -34,7 +34,6 @@ import kotlinx.serialization.decodeFromString @@ -34,7 +34,6 @@ import kotlinx.serialization.decodeFromString
34 import kotlinx.serialization.encodeToString 34 import kotlinx.serialization.encodeToString
35 import kotlinx.serialization.json.Json 35 import kotlinx.serialization.json.Json
36 import livekit.LivekitModels 36 import livekit.LivekitModels
37 -import livekit.LivekitModels.Encryption  
38 import livekit.LivekitRtc 37 import livekit.LivekitRtc
39 import livekit.LivekitRtc.JoinResponse 38 import livekit.LivekitRtc.JoinResponse
40 import livekit.LivekitRtc.ReconnectResponse 39 import livekit.LivekitRtc.ReconnectResponse
@@ -95,6 +94,7 @@ constructor( @@ -95,6 +94,7 @@ constructor(
95 private var pongJob: Job? = null 94 private var pongJob: Job? = null
96 private var pingTimeoutDurationMillis: Long = 0 95 private var pingTimeoutDurationMillis: Long = 0
97 private var pingIntervalDurationMillis: Long = 0 96 private var pingIntervalDurationMillis: Long = 0
  97 + private var rtt: Long = 0
98 98
99 var connectionState: ConnectionState = ConnectionState.DISCONNECTED 99 var connectionState: ConnectionState = ConnectionState.DISCONNECTED
100 100
@@ -491,6 +491,28 @@ constructor( @@ -491,6 +491,28 @@ constructor(
491 sendRequest(request) 491 sendRequest(request)
492 } 492 }
493 493
  494 + fun sendPing(): Long {
  495 + val time = Date().time
  496 + sendRequest(
  497 + with(LivekitRtc.SignalRequest.newBuilder()) {
  498 + ping = time
  499 + build()
  500 + },
  501 + )
  502 + sendRequest(
  503 + with(LivekitRtc.SignalRequest.newBuilder()) {
  504 + pingReq = with(LivekitRtc.Ping.newBuilder()) {
  505 + rtt = this@SignalClient.rtt
  506 + timestamp = time
  507 + build()
  508 + }
  509 + build()
  510 + },
  511 + )
  512 +
  513 + return time
  514 + }
  515 +
494 private fun sendRequest(request: LivekitRtc.SignalRequest) { 516 private fun sendRequest(request: LivekitRtc.SignalRequest) {
495 val skipQueue = skipQueueTypes.contains(request.messageCase) 517 val skipQueue = skipQueueTypes.contains(request.messageCase)
496 518
@@ -647,7 +669,8 @@ constructor( @@ -647,7 +669,8 @@ constructor(
647 } 669 }
648 670
649 LivekitRtc.SignalResponse.MessageCase.PONG_RESP -> { 671 LivekitRtc.SignalResponse.MessageCase.PONG_RESP -> {
650 - // TODO 672 + rtt = Date().time - response.pongResp.lastPingTimestamp
  673 + resetPingTimeout()
651 } 674 }
652 675
653 LivekitRtc.SignalResponse.MessageCase.RECONNECT -> { 676 LivekitRtc.SignalResponse.MessageCase.RECONNECT -> {
@@ -671,13 +694,7 @@ constructor( @@ -671,13 +694,7 @@ constructor(
671 pingJob = coroutineScope.launch { 694 pingJob = coroutineScope.launch {
672 while (true) { 695 while (true) {
673 delay(pingIntervalDurationMillis) 696 delay(pingIntervalDurationMillis)
674 -  
675 - val pingTimestamp = Date().time  
676 - val pingRequest = LivekitRtc.SignalRequest.newBuilder()  
677 - .setPing(pingTimestamp)  
678 - .build()  
679 - LKLog.v { "Sending ping: $pingTimestamp" }  
680 - sendRequest(pingRequest) 697 + val pingTimestamp = sendPing()
681 startPingTimeout(pingTimestamp) 698 startPingTimeout(pingTimestamp)
682 } 699 }
683 } 700 }
@@ -29,15 +29,21 @@ import io.livekit.android.events.ParticipantEvent @@ -29,15 +29,21 @@ import io.livekit.android.events.ParticipantEvent
29 import io.livekit.android.room.ConnectionState 29 import io.livekit.android.room.ConnectionState
30 import io.livekit.android.room.DefaultsManager 30 import io.livekit.android.room.DefaultsManager
31 import io.livekit.android.room.RTCEngine 31 import io.livekit.android.room.RTCEngine
  32 +import io.livekit.android.room.TrackBitrateInfo
  33 +import io.livekit.android.room.isSVCCodec
32 import io.livekit.android.room.track.* 34 import io.livekit.android.room.track.*
33 import io.livekit.android.room.util.EncodingUtils 35 import io.livekit.android.room.util.EncodingUtils
34 import io.livekit.android.util.LKLog 36 import io.livekit.android.util.LKLog
35 import io.livekit.android.webrtc.createStatsGetter 37 import io.livekit.android.webrtc.createStatsGetter
  38 +import io.livekit.android.webrtc.sortVideoCodecPreferences
36 import kotlinx.coroutines.CoroutineDispatcher 39 import kotlinx.coroutines.CoroutineDispatcher
  40 +import kotlinx.coroutines.launch
37 import livekit.LivekitModels 41 import livekit.LivekitModels
38 import livekit.LivekitRtc 42 import livekit.LivekitRtc
  43 +import livekit.LivekitRtc.AddTrackRequest
  44 +import livekit.LivekitRtc.SimulcastCodec
39 import org.webrtc.* 45 import org.webrtc.*
40 -import org.webrtc.RtpCapabilities.CodecCapability 46 +import org.webrtc.RtpTransceiver.RtpTransceiverInit
41 import javax.inject.Named 47 import javax.inject.Named
42 import kotlin.math.max 48 import kotlin.math.max
43 49
@@ -45,7 +51,7 @@ class LocalParticipant @@ -45,7 +51,7 @@ class LocalParticipant
45 @AssistedInject 51 @AssistedInject
46 internal constructor( 52 internal constructor(
47 @Assisted 53 @Assisted
48 - private val dynacast: Boolean, 54 + internal var dynacast: Boolean,
49 internal val engine: RTCEngine, 55 internal val engine: RTCEngine,
50 private val peerConnectionFactory: PeerConnectionFactory, 56 private val peerConnectionFactory: PeerConnectionFactory,
51 private val context: Context, 57 private val context: Context,
@@ -180,7 +186,6 @@ internal constructor( @@ -180,7 +186,6 @@ internal constructor(
180 source: Track.Source, 186 source: Track.Source,
181 enabled: Boolean, 187 enabled: Boolean,
182 mediaProjectionPermissionResultData: Intent? = null, 188 mediaProjectionPermissionResultData: Intent? = null,
183 -  
184 ) { 189 ) {
185 val pub = getTrackPublication(source) 190 val pub = getTrackPublication(source)
186 if (enabled) { 191 if (enabled) {
@@ -267,9 +272,23 @@ internal constructor( @@ -267,9 +272,23 @@ internal constructor(
267 options: VideoTrackPublishOptions = VideoTrackPublishOptions(null, videoTrackPublishDefaults), 272 options: VideoTrackPublishOptions = VideoTrackPublishOptions(null, videoTrackPublishDefaults),
268 publishListener: PublishListener? = null, 273 publishListener: PublishListener? = null,
269 ) { 274 ) {
  275 + val isSVC = isSVCCodec(options.videoCodec)
  276 +
  277 + @Suppress("NAME_SHADOWING") var options = options
  278 + if (isSVC) {
  279 + dynacast = true
  280 +
  281 + // Ensure backup codec and scalability for svc codecs.
  282 + if (options.backupCodec == null) {
  283 + options = options.copy(backupCodec = BackupVideoCodec())
  284 + }
  285 + if (options.scalabilityMode == null) {
  286 + options = options.copy(scalabilityMode = "L3T3_KEY")
  287 + }
  288 + }
270 val encodings = computeVideoEncodings(track.dimensions, options) 289 val encodings = computeVideoEncodings(track.dimensions, options)
271 val videoLayers = 290 val videoLayers =
272 - EncodingUtils.videoLayersFromEncodings(track.dimensions.width, track.dimensions.height, encodings) 291 + EncodingUtils.videoLayersFromEncodings(track.dimensions.width, track.dimensions.height, encodings, isSVC)
273 292
274 publishTrackImpl( 293 publishTrackImpl(
275 track = track, 294 track = track,
@@ -283,6 +302,24 @@ internal constructor( @@ -283,6 +302,24 @@ internal constructor(
283 LivekitModels.TrackSource.CAMERA 302 LivekitModels.TrackSource.CAMERA
284 } 303 }
285 addAllLayers(videoLayers) 304 addAllLayers(videoLayers)
  305 +
  306 + addSimulcastCodecs(
  307 + with(SimulcastCodec.newBuilder()) {
  308 + codec = options.videoCodec
  309 + cid = track.rtcTrack.id()
  310 + build()
  311 + },
  312 + )
  313 + // set up backup codec
  314 + if (options.backupCodec?.codec != null && options.videoCodec != options.backupCodec?.codec) {
  315 + addSimulcastCodecs(
  316 + with(SimulcastCodec.newBuilder()) {
  317 + codec = options.backupCodec!!.codec
  318 + cid = ""
  319 + build()
  320 + },
  321 + )
  322 + }
286 }, 323 },
287 encodings = encodings, 324 encodings = encodings,
288 publishListener = publishListener, 325 publishListener = publishListener,
@@ -295,17 +332,21 @@ internal constructor( @@ -295,17 +332,21 @@ internal constructor(
295 private suspend fun publishTrackImpl( 332 private suspend fun publishTrackImpl(
296 track: Track, 333 track: Track,
297 options: TrackPublishOptions, 334 options: TrackPublishOptions,
298 - requestConfig: LivekitRtc.AddTrackRequest.Builder.() -> Unit, 335 + requestConfig: AddTrackRequest.Builder.() -> Unit,
299 encodings: List<RtpParameters.Encoding> = emptyList(), 336 encodings: List<RtpParameters.Encoding> = emptyList(),
300 publishListener: PublishListener? = null, 337 publishListener: PublishListener? = null,
301 ): Boolean { 338 ): Boolean {
  339 + @Suppress("NAME_SHADOWING") var options = options
  340 +
  341 + @Suppress("NAME_SHADOWING") var encodings = encodings
  342 +
302 if (localTrackPublications.any { it.track == track }) { 343 if (localTrackPublications.any { it.track == track }) {
303 publishListener?.onPublishFailure(TrackException.PublishException("Track has already been published")) 344 publishListener?.onPublishFailure(TrackException.PublishException("Track has already been published"))
304 return false 345 return false
305 } 346 }
306 347
307 val cid = track.rtcTrack.id() 348 val cid = track.rtcTrack.id()
308 - val builder = LivekitRtc.AddTrackRequest.newBuilder().apply { 349 + val builder = AddTrackRequest.newBuilder().apply {
309 this.requestConfig() 350 this.requestConfig()
310 } 351 }
311 val trackInfo = engine.addTrack( 352 val trackInfo = engine.addTrack(
@@ -314,12 +355,30 @@ internal constructor( @@ -314,12 +355,30 @@ internal constructor(
314 kind = track.kind.toProto(), 355 kind = track.kind.toProto(),
315 builder = builder, 356 builder = builder,
316 ) 357 )
317 - val transInit = RtpTransceiver.RtpTransceiverInit( 358 +
  359 + if (options is VideoTrackPublishOptions) {
  360 + // server might not support the codec the client has requested, in that case, fallback
  361 + // to a supported codec
  362 + val primaryCodecMime = trackInfo.codecsList.firstOrNull()?.mimeType
  363 +
  364 + if (primaryCodecMime != null) {
  365 + val updatedCodec = primaryCodecMime.mimeTypeToVideoCodec()
  366 + if (updatedCodec != options.videoCodec) {
  367 + LKLog.d { "falling back to server selected codec: $updatedCodec" }
  368 + }
  369 + options = options.copy(videoCodec = updatedCodec)
  370 +
  371 + // recompute encodings since bitrates/etc could have changed
  372 + encodings = computeVideoEncodings((track as LocalVideoTrack).dimensions, options)
  373 + }
  374 + }
  375 +
  376 + val transInit = RtpTransceiverInit(
318 RtpTransceiver.RtpTransceiverDirection.SEND_ONLY, 377 RtpTransceiver.RtpTransceiverDirection.SEND_ONLY,
319 listOf(this.sid), 378 listOf(this.sid),
320 encodings, 379 encodings,
321 ) 380 )
322 - val transceiver = engine.publisher.peerConnection.addTransceiver(track.rtcTrack, transInit) 381 + val transceiver = engine.createSenderTransceiver(track.rtcTrack, transInit)
323 382
324 when (track) { 383 when (track) {
325 is LocalVideoTrack -> track.transceiver = transceiver 384 is LocalVideoTrack -> track.transceiver = transceiver
@@ -336,43 +395,23 @@ internal constructor( @@ -336,43 +395,23 @@ internal constructor(
336 395
337 track.statsGetter = createStatsGetter(engine.publisher.peerConnection, transceiver.sender) 396 track.statsGetter = createStatsGetter(engine.publisher.peerConnection, transceiver.sender)
338 397
339 - if (options is VideoTrackPublishOptions && options.videoCodec != null) {  
340 - val targetCodec = options.videoCodec.lowercase()  
341 - val capabilities = capabilitiesGetter(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO)  
342 - LKLog.v { "capabilities:" }  
343 - capabilities.codecs.forEach { codec ->  
344 - LKLog.v { "codec: ${codec.name}, ${codec.kind}, ${codec.mimeType}, ${codec.parameters}, ${codec.preferredPayloadType}" } 398 + // Handle trackBitrates
  399 + if (encodings.isNotEmpty()) {
  400 + if (options is VideoTrackPublishOptions && isSVCCodec(options.videoCodec) && encodings.firstOrNull()?.maxBitrateBps != null) {
  401 + engine.publisher.registerTrackBitrateInfo(
  402 + cid = cid,
  403 + TrackBitrateInfo(
  404 + codec = options.videoCodec,
  405 + maxBitrate = (encodings.first().maxBitrateBps?.div(1000) ?: 0).toLong(),
  406 + ),
  407 + )
345 } 408 }
346 -  
347 - val matched = mutableListOf<CodecCapability>()  
348 - val partialMatched = mutableListOf<CodecCapability>()  
349 - val unmatched = mutableListOf<CodecCapability>()  
350 -  
351 - for (codec in capabilities.codecs) {  
352 - val mimeType = codec.mimeType.lowercase()  
353 - if (mimeType == "audio/opus") {  
354 - matched.add(codec)  
355 - continue  
356 } 409 }
357 410
358 - if (mimeType != "video/$targetCodec") {  
359 - unmatched.add(codec)  
360 - continue  
361 - }  
362 - // for h264 codecs that have sdpFmtpLine available, use only if the  
363 - // profile-level-id is 42e01f for cross-browser compatibility  
364 - if (targetCodec == "h264") {  
365 - if (codec.parameters["profile-level-id"] == "42e01f") {  
366 - matched.add(codec)  
367 - } else {  
368 - partialMatched.add(codec)  
369 - }  
370 - continue  
371 - } else {  
372 - matched.add(codec)  
373 - }  
374 - }  
375 - transceiver.setCodecPreferences(matched.plus(partialMatched).plus(unmatched)) 411 + // Set preferred video codec order
  412 + if (options is VideoTrackPublishOptions) {
  413 + transceiver.sortVideoCodecPreferences(options.videoCodec, capabilitiesGetter)
  414 + (track as LocalVideoTrack).codec = options.videoCodec
376 } 415 }
377 416
378 val publication = LocalTrackPublication( 417 val publication = LocalTrackPublication(
@@ -397,6 +436,7 @@ internal constructor( @@ -397,6 +436,7 @@ internal constructor(
397 val (width, height) = dimensions 436 val (width, height) = dimensions
398 var encoding = options.videoEncoding 437 var encoding = options.videoEncoding
399 val simulcast = options.simulcast 438 val simulcast = options.simulcast
  439 + val scalabilityMode = options.scalabilityMode
400 440
401 if ((encoding == null && !simulcast) || width == 0 || height == 0) { 441 if ((encoding == null && !simulcast) || width == 0 || height == 0) {
402 return emptyList() 442 return emptyList()
@@ -408,7 +448,13 @@ internal constructor( @@ -408,7 +448,13 @@ internal constructor(
408 } 448 }
409 449
410 val encodings = mutableListOf<RtpParameters.Encoding>() 450 val encodings = mutableListOf<RtpParameters.Encoding>()
411 - if (simulcast) { 451 +
  452 + if (scalabilityMode != null && isSVCCodec(options.videoCodec)) {
  453 + val rtpEncoding = encoding.toRtpEncoding()
  454 + rtpEncoding.scalabilityMode = scalabilityMode
  455 + encodings.add(rtpEncoding)
  456 + return encodings
  457 + } else if (simulcast) {
412 val presets = EncodingUtils.presetsForResolution(width, height) 458 val presets = EncodingUtils.presetsForResolution(width, height)
413 val midPreset = presets[1] 459 val midPreset = presets[1]
414 val lowPreset = presets[0] 460 val lowPreset = presets[0]
@@ -454,6 +500,27 @@ internal constructor( @@ -454,6 +500,27 @@ internal constructor(
454 return encodings 500 return encodings
455 } 501 }
456 502
  503 + private fun computeTrackBackupOptionsAndEncodings(
  504 + track: LocalVideoTrack,
  505 + videoCodec: VideoCodec,
  506 + options: VideoTrackPublishOptions,
  507 + ): Pair<VideoTrackPublishOptions, List<RtpParameters.Encoding>>? {
  508 + if (!options.hasBackupCodec()) {
  509 + return null
  510 + }
  511 +
  512 + if (videoCodec.codecName != options.backupCodec?.codec) {
  513 + LKLog.w { "Server requested different codec than specified backup. server: $videoCodec, specified: ${options.backupCodec?.codec}" }
  514 + }
  515 +
  516 + val backupOptions = options.copy(
  517 + videoCodec = videoCodec.codecName,
  518 + videoEncoding = options.backupCodec!!.encoding,
  519 + )
  520 + val backupEncodings = computeVideoEncodings(track.dimensions, backupOptions)
  521 + return backupOptions to backupEncodings
  522 + }
  523 +
457 /** 524 /**
458 * Control who can subscribe to LocalParticipant's published tracks. 525 * Control who can subscribe to LocalParticipant's published tracks.
459 * 526 *
@@ -590,30 +657,87 @@ internal constructor( @@ -590,30 +657,87 @@ internal constructor(
590 } 657 }
591 658
592 val trackSid = subscribedQualityUpdate.trackSid 659 val trackSid = subscribedQualityUpdate.trackSid
  660 + val subscribedCodecs = subscribedQualityUpdate.subscribedCodecsList
593 val qualities = subscribedQualityUpdate.subscribedQualitiesList 661 val qualities = subscribedQualityUpdate.subscribedQualitiesList
594 - val pub = tracks[trackSid] ?: return 662 + val pub = tracks[trackSid] as? LocalTrackPublication ?: return
595 val track = pub.track as? LocalVideoTrack ?: return 663 val track = pub.track as? LocalVideoTrack ?: return
  664 + val options = pub.options as? VideoTrackPublishOptions ?: return
596 665
597 - val sender = track.transceiver?.sender ?: return  
598 - val parameters = sender.parameters ?: return  
599 - val encodings = parameters.encodings ?: return 666 + if (subscribedCodecs.isNotEmpty()) {
  667 + val newCodecs = track.setPublishingCodecs(subscribedCodecs)
  668 + for (codec in newCodecs) {
  669 + if (isBackupCodec(codec.codecName)) {
  670 + LKLog.d { "publish $codec for $trackSid" }
  671 + publishAdditionalCodecForTrack(track, codec, options)
  672 + }
  673 + }
  674 + }
  675 + if (qualities.isNotEmpty()) {
  676 + track.setPublishingLayers(qualities)
  677 + }
  678 + }
600 679
601 - var hasChanged = false  
602 - for (quality in qualities) {  
603 - val rid = EncodingUtils.ridForVideoQuality(quality.quality) ?: continue  
604 - val encoding = encodings.firstOrNull { it.rid == rid }  
605 - // use low quality layer settings for non-simulcasted streams  
606 - ?: encodings.takeIf { it.size == 1 && quality.quality == LivekitModels.VideoQuality.LOW }?.first()  
607 - ?: continue  
608 - if (encoding.active != quality.enabled) {  
609 - hasChanged = true  
610 - encoding.active = quality.enabled  
611 - LKLog.v { "setting layer ${quality.quality} to ${quality.enabled}" } 680 + private fun publishAdditionalCodecForTrack(track: LocalVideoTrack, codec: VideoCodec, options: VideoTrackPublishOptions) {
  681 + val existingPublication = tracks[track.sid] ?: run {
  682 + LKLog.w { "attempting to publish additional codec for non-published track?!" }
  683 + return
612 } 684 }
  685 +
  686 + val result = computeTrackBackupOptionsAndEncodings(track, codec, options) ?: run {
  687 + LKLog.i { "backup codec has been disabled, ignoring request to add additional codec for track" }
  688 + return
613 } 689 }
  690 + val (newOptions, newEncodings) = result
  691 + val simulcastTrack = track.addSimulcastTrack(codec, newEncodings)
614 692
615 - if (hasChanged) {  
616 - sender.parameters = parameters 693 + val transceiverInit = RtpTransceiverInit(
  694 + RtpTransceiver.RtpTransceiverDirection.SEND_ONLY,
  695 + listOf(this.sid),
  696 + newEncodings,
  697 + )
  698 +
  699 + scope.launch {
  700 + val transceiver = engine.createSenderTransceiver(track.rtcTrack, transceiverInit)
  701 + if (transceiver == null) {
  702 + LKLog.w { "couldn't create new transceiver! $codec" }
  703 + return@launch
  704 + }
  705 + transceiver.sortVideoCodecPreferences(newOptions.videoCodec, capabilitiesGetter)
  706 + simulcastTrack.sender = transceiver.sender
  707 +
  708 + val trackRequest = AddTrackRequest.newBuilder().apply {
  709 + cid = transceiver.sender.id()
  710 + sid = existingPublication.sid
  711 + type = track.kind.toProto()
  712 + muted = !track.enabled
  713 + source = existingPublication.source.toProto()
  714 + addSimulcastCodecs(
  715 + with(SimulcastCodec.newBuilder()) {
  716 + this@with.codec = codec.codecName
  717 + this@with.cid = transceiver.sender.id()
  718 + build()
  719 + },
  720 + )
  721 + addAllLayers(
  722 + EncodingUtils.videoLayersFromEncodings(
  723 + track.dimensions.width,
  724 + track.dimensions.height,
  725 + newEncodings,
  726 + isSVCCodec(codec.codecName),
  727 + ),
  728 + )
  729 + }
  730 +
  731 + val trackInfo = engine.addTrack(
  732 + cid = simulcastTrack.rtcTrack.id(),
  733 + name = existingPublication.name,
  734 + kind = existingPublication.kind.toProto(),
  735 + builder = trackRequest,
  736 + )
  737 +
  738 + engine.negotiatePublisher()
  739 +
  740 + LKLog.d { "published $codec for track ${track.sid}, $trackInfo" }
617 } 741 }
618 } 742 }
619 743
@@ -713,21 +837,37 @@ abstract class BaseVideoTrackPublishOptions { @@ -713,21 +837,37 @@ abstract class BaseVideoTrackPublishOptions {
713 837
714 /** 838 /**
715 * The video codec to use if available. 839 * The video codec to use if available.
  840 + *
  841 + * Defaults to VP8.
  842 + *
  843 + * @see [VideoCodec]
716 */ 844 */
717 - abstract val videoCodec: String? 845 + abstract val videoCodec: String
  846 +
  847 + /**
  848 + * scalability mode for svc codecs, defaults to 'L3T3'.
  849 + * for svc codecs, simulcast is disabled.
  850 + */
  851 + abstract val scalabilityMode: String?
  852 +
  853 + abstract val backupCodec: BackupVideoCodec?
718 } 854 }
719 855
720 data class VideoTrackPublishDefaults( 856 data class VideoTrackPublishDefaults(
721 override val videoEncoding: VideoEncoding? = null, 857 override val videoEncoding: VideoEncoding? = null,
722 override val simulcast: Boolean = true, 858 override val simulcast: Boolean = true,
723 - override val videoCodec: String? = null, 859 + override val videoCodec: String = VideoCodec.VP8.codecName,
  860 + override val scalabilityMode: String? = null,
  861 + override val backupCodec: BackupVideoCodec? = null,
724 ) : BaseVideoTrackPublishOptions() 862 ) : BaseVideoTrackPublishOptions()
725 863
726 data class VideoTrackPublishOptions( 864 data class VideoTrackPublishOptions(
727 override val name: String? = null, 865 override val name: String? = null,
728 override val videoEncoding: VideoEncoding? = null, 866 override val videoEncoding: VideoEncoding? = null,
729 override val simulcast: Boolean = true, 867 override val simulcast: Boolean = true,
730 - override val videoCodec: String? = null, 868 + override val videoCodec: String = VideoCodec.VP8.codecName,
  869 + override val scalabilityMode: String? = null,
  870 + override val backupCodec: BackupVideoCodec? = null,
731 ) : BaseVideoTrackPublishOptions(), TrackPublishOptions { 871 ) : BaseVideoTrackPublishOptions(), TrackPublishOptions {
732 constructor( 872 constructor(
733 name: String? = null, 873 name: String? = null,
@@ -737,9 +877,28 @@ data class VideoTrackPublishOptions( @@ -737,9 +877,28 @@ data class VideoTrackPublishOptions(
737 base.videoEncoding, 877 base.videoEncoding,
738 base.simulcast, 878 base.simulcast,
739 base.videoCodec, 879 base.videoCodec,
  880 + base.scalabilityMode,
  881 + base.backupCodec,
740 ) 882 )
  883 +
  884 + fun createBackupOptions(): VideoTrackPublishOptions? {
  885 + return if (hasBackupCodec()) {
  886 + copy(
  887 + videoCodec = backupCodec!!.codec,
  888 + videoEncoding = backupCodec.encoding,
  889 + )
  890 + } else {
  891 + null
  892 + }
  893 + }
741 } 894 }
742 895
  896 +data class BackupVideoCodec(
  897 + val codec: String = "vp8",
  898 + val encoding: VideoEncoding? = null,
  899 + val simulcast: Boolean = true,
  900 +)
  901 +
743 abstract class BaseAudioTrackPublishOptions { 902 abstract class BaseAudioTrackPublishOptions {
744 abstract val audioBitrate: Int? 903 abstract val audioBitrate: Int?
745 904
@@ -813,14 +972,9 @@ data class ParticipantTrackPermission( @@ -813,14 +972,9 @@ data class ParticipantTrackPermission(
813 } 972 }
814 } 973 }
815 974
816 -sealed class PublishRecord() {  
817 - data class AudioTrackPublishRecord(  
818 - val track: LocalAudioTrack,  
819 - val options: AudioTrackPublishOptions,  
820 - )  
821 -  
822 - data class VideoTrackPublishRecord(  
823 - val track: LocalVideoTrack,  
824 - val options: VideoTrackPublishOptions,  
825 - ) 975 +internal fun VideoTrackPublishOptions.hasBackupCodec(): Boolean {
  976 + return backupCodec?.codec != null && videoCodec != backupCodec.codec
826 } 977 }
  978 +
  979 +private val backupCodecs = listOf(VideoCodec.VP8.codecName, VideoCodec.H264.codecName)
  980 +private fun isBackupCodec(codecName: String) = backupCodecs.contains(codecName)
@@ -173,6 +173,8 @@ open class Participant( @@ -173,6 +173,8 @@ open class Participant(
173 get() = participantInfo != null 173 get() = participantInfo != null
174 174
175 /** 175 /**
  176 + * Maps track sids to their track publications.
  177 + *
176 * Changes can be observed by using [io.livekit.android.util.flow] 178 * Changes can be observed by using [io.livekit.android.util.flow]
177 */ 179 */
178 @FlowObservable 180 @FlowObservable
  1 +/*
  2 + * Copyright 2023 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.participant
  18 +
  19 +fun String.mimeTypeToVideoCodec(): String {
  20 + return split("/")[1].lowercase()
  21 +}
@@ -26,16 +26,33 @@ import dagger.assisted.AssistedInject @@ -26,16 +26,33 @@ import dagger.assisted.AssistedInject
26 import io.livekit.android.memory.CloseableManager 26 import io.livekit.android.memory.CloseableManager
27 import io.livekit.android.memory.SurfaceTextureHelperCloser 27 import io.livekit.android.memory.SurfaceTextureHelperCloser
28 import io.livekit.android.room.DefaultsManager 28 import io.livekit.android.room.DefaultsManager
29 -import io.livekit.android.room.track.video.* 29 +import io.livekit.android.room.track.video.CameraCapturerUtils
30 import io.livekit.android.room.track.video.CameraCapturerUtils.createCameraEnumerator 30 import io.livekit.android.room.track.video.CameraCapturerUtils.createCameraEnumerator
31 import io.livekit.android.room.track.video.CameraCapturerUtils.findCamera 31 import io.livekit.android.room.track.video.CameraCapturerUtils.findCamera
32 import io.livekit.android.room.track.video.CameraCapturerUtils.getCameraPosition 32 import io.livekit.android.room.track.video.CameraCapturerUtils.getCameraPosition
  33 +import io.livekit.android.room.track.video.CameraCapturerWithSize
  34 +import io.livekit.android.room.track.video.VideoCapturerWithSize
  35 +import io.livekit.android.room.util.EncodingUtils
33 import io.livekit.android.util.FlowObservable 36 import io.livekit.android.util.FlowObservable
34 import io.livekit.android.util.LKLog 37 import io.livekit.android.util.LKLog
35 import io.livekit.android.util.flowDelegate 38 import io.livekit.android.util.flowDelegate
36 -import org.webrtc.* 39 +import livekit.LivekitModels
  40 +import livekit.LivekitModels.VideoQuality
  41 +import livekit.LivekitRtc
  42 +import livekit.LivekitRtc.SubscribedCodec
  43 +import org.webrtc.CameraVideoCapturer
37 import org.webrtc.CameraVideoCapturer.CameraEventsHandler 44 import org.webrtc.CameraVideoCapturer.CameraEventsHandler
38 -import java.util.* 45 +import org.webrtc.EglBase
  46 +import org.webrtc.MediaStreamTrack
  47 +import org.webrtc.PeerConnectionFactory
  48 +import org.webrtc.RtpParameters
  49 +import org.webrtc.RtpSender
  50 +import org.webrtc.RtpTransceiver
  51 +import org.webrtc.SurfaceTextureHelper
  52 +import org.webrtc.VideoCapturer
  53 +import org.webrtc.VideoProcessor
  54 +import org.webrtc.VideoSource
  55 +import java.util.UUID
39 56
40 /** 57 /**
41 * A representation of a local video track (generally input coming from camera or screen). 58 * A representation of a local video track (generally input coming from camera or screen).
@@ -60,6 +77,10 @@ constructor( @@ -60,6 +77,10 @@ constructor(
60 override var rtcTrack: org.webrtc.VideoTrack = rtcTrack 77 override var rtcTrack: org.webrtc.VideoTrack = rtcTrack
61 internal set 78 internal set
62 79
  80 + internal var codec: String? = null
  81 + private var subscribedCodecs: List<SubscribedCodec>? = null
  82 + private val simulcastCodecs = mutableMapOf<VideoCodec, SimulcastTrackInfo>()
  83 +
63 @FlowObservable 84 @FlowObservable
64 @get:FlowObservable 85 @get:FlowObservable
65 var options: LocalVideoTrackOptions by flowDelegate(options) 86 var options: LocalVideoTrackOptions by flowDelegate(options)
@@ -69,7 +90,7 @@ constructor( @@ -69,7 +90,7 @@ constructor(
69 (capturer as? VideoCapturerWithSize)?.let { capturerWithSize -> 90 (capturer as? VideoCapturerWithSize)?.let { capturerWithSize ->
70 val size = capturerWithSize.findCaptureFormat( 91 val size = capturerWithSize.findCaptureFormat(
71 options.captureParams.width, 92 options.captureParams.width,
72 - options.captureParams.height 93 + options.captureParams.height,
73 ) 94 )
74 return Dimensions(size.width, size.height) 95 return Dimensions(size.width, size.height)
75 } 96 }
@@ -86,7 +107,7 @@ constructor( @@ -86,7 +107,7 @@ constructor(
86 capturer.startCapture( 107 capturer.startCapture(
87 options.captureParams.width, 108 options.captureParams.width,
88 options.captureParams.height, 109 options.captureParams.height,
89 - options.captureParams.maxFps 110 + options.captureParams.maxFps,
90 ) 111 )
91 } 112 }
92 113
@@ -140,7 +161,7 @@ constructor( @@ -140,7 +161,7 @@ constructor(
140 fun updateCameraOptions() { 161 fun updateCameraOptions() {
141 val newOptions = options.copy( 162 val newOptions = options.copy(
142 deviceId = targetDeviceId, 163 deviceId = targetDeviceId,
143 - position = enumerator.getCameraPosition(targetDeviceId) 164 + position = enumerator.getCameraPosition(targetDeviceId),
144 ) 165 )
145 options = newOptions 166 options = newOptions
146 } 167 }
@@ -150,7 +171,8 @@ constructor( @@ -150,7 +171,8 @@ constructor(
150 // For cameras we control, wait until the first frame to ensure everything is okay. 171 // For cameras we control, wait until the first frame to ensure everything is okay.
151 if (cameraCapturer is CameraCapturerWithSize) { 172 if (cameraCapturer is CameraCapturerWithSize) {
152 cameraCapturer.cameraEventsDispatchHandler 173 cameraCapturer.cameraEventsDispatchHandler
153 - .registerHandler(object : CameraEventsHandler { 174 + .registerHandler(
  175 + object : CameraEventsHandler {
154 override fun onFirstFrameAvailable() { 176 override fun onFirstFrameAvailable() {
155 updateCameraOptions() 177 updateCameraOptions()
156 cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this) 178 cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
@@ -173,7 +195,8 @@ constructor( @@ -173,7 +195,8 @@ constructor(
173 override fun onCameraClosed() { 195 override fun onCameraClosed() {
174 cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this) 196 cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
175 } 197 }
176 - }) 198 + },
  199 + )
177 } else { 200 } else {
178 updateCameraOptions() 201 updateCameraOptions()
179 } 202 }
@@ -216,7 +239,7 @@ constructor( @@ -216,7 +239,7 @@ constructor(
216 name, 239 name,
217 options, 240 options,
218 eglBase, 241 eglBase,
219 - trackFactory 242 + trackFactory,
220 ) 243 )
221 244
222 // migrate video sinks to the new track 245 // migrate video sinks to the new track
@@ -233,6 +256,122 @@ constructor( @@ -233,6 +256,122 @@ constructor(
233 sender?.setTrack(newTrack.rtcTrack, true) 256 sender?.setTrack(newTrack.rtcTrack, true)
234 } 257 }
235 258
  259 + internal fun setPublishingLayers(
  260 + qualities: List<LivekitRtc.SubscribedQuality>,
  261 + ) {
  262 + val sender = transceiver?.sender ?: return
  263 +
  264 + setPublishingLayersForSender(sender, qualities)
  265 + }
  266 +
  267 + private fun setPublishingLayersForSender(
  268 + sender: RtpSender,
  269 + qualities: List<LivekitRtc.SubscribedQuality>,
  270 + ) {
  271 + val parameters = sender.parameters ?: return
  272 + val encodings = parameters.encodings ?: return
  273 + var hasChanged = false
  274 +
  275 + if (encodings.firstOrNull()?.scalabilityMode != null) {
  276 + val encoding = encodings.first()
  277 + var maxQuality = VideoQuality.OFF
  278 + for (quality in qualities) {
  279 + if (quality.enabled && (maxQuality == VideoQuality.OFF || quality.quality.number > maxQuality.number)) {
  280 + maxQuality = quality.quality
  281 + }
  282 + }
  283 +
  284 + if (maxQuality == VideoQuality.OFF) {
  285 + if (encoding.active) {
  286 + LKLog.v { "setting svc track to disabled" }
  287 + encoding.active = false
  288 + hasChanged = true
  289 + }
  290 + } else if (!encoding.active) {
  291 + LKLog.v { "setting svc track to enabled" }
  292 + encoding.active = true
  293 + hasChanged = true
  294 + }
  295 + } else {
  296 + // simulcast dynacast encodings
  297 + for (quality in qualities) {
  298 + val rid = EncodingUtils.ridForVideoQuality(quality.quality) ?: continue
  299 + val encoding = encodings.firstOrNull { it.rid == rid }
  300 + // use low quality layer settings for non-simulcasted streams
  301 + ?: encodings.takeIf { it.size == 1 && quality.quality == LivekitModels.VideoQuality.LOW }?.first()
  302 + ?: continue
  303 + if (encoding.active != quality.enabled) {
  304 + hasChanged = true
  305 + encoding.active = quality.enabled
  306 + LKLog.v { "setting layer ${quality.quality} to ${quality.enabled}" }
  307 + }
  308 + }
  309 + }
  310 +
  311 + if (hasChanged) {
  312 + // This refeshes the native code with the new information
  313 + sender.parameters = sender.parameters
  314 + }
  315 + }
  316 +
  317 + fun setPublishingCodecs(codecs: List<SubscribedCodec>): List<VideoCodec> {
  318 + LKLog.v { "setting publishing codecs: $codecs" }
  319 +
  320 + // only enable simulcast codec for preferred codec set
  321 + if (this.codec == null && codecs.isNotEmpty()) {
  322 + setPublishingLayers(codecs.first().qualitiesList)
  323 + return emptyList()
  324 + }
  325 +
  326 + this.subscribedCodecs = codecs
  327 +
  328 + val newCodecs = mutableListOf<VideoCodec>()
  329 +
  330 + for (codec in codecs) {
  331 + if (this.codec == codec.codec) {
  332 + setPublishingLayers(codec.qualitiesList)
  333 + } else {
  334 + val videoCodec = try {
  335 + VideoCodec.fromCodecName(codec.codec)
  336 + } catch (e: Exception) {
  337 + LKLog.w { "unknown publishing codec ${codec.codec}!" }
  338 + continue
  339 + }
  340 +
  341 + LKLog.d { "try setPublishingCodec for ${codec.codec}" }
  342 + val simulcastInfo = this.simulcastCodecs[videoCodec]
  343 + if (simulcastInfo?.sender == null) {
  344 + for (q in codec.qualitiesList) {
  345 + if (q.enabled) {
  346 + newCodecs.add(videoCodec)
  347 + break
  348 + }
  349 + }
  350 + } else {
  351 + LKLog.d { "try setPublishingLayersForSender ${codec.codec}" }
  352 + setPublishingLayersForSender(
  353 + simulcastInfo.sender!!,
  354 + codec.qualitiesList,
  355 + )
  356 + }
  357 + }
  358 + }
  359 + return newCodecs
  360 + }
  361 +
  362 + internal fun addSimulcastTrack(codec: VideoCodec, encodings: List<RtpParameters.Encoding>): SimulcastTrackInfo {
  363 + if (this.simulcastCodecs.containsKey(codec)) {
  364 + throw IllegalStateException("$codec already added!")
  365 + }
  366 + val simulcastTrackInfo = SimulcastTrackInfo(
  367 + codec = codec.codecName,
  368 + rtcTrack = rtcTrack,
  369 + encodings = encodings,
  370 + )
  371 + simulcastCodecs[codec] = simulcastTrackInfo
  372 + return simulcastTrackInfo
  373 + }
  374 +
236 @AssistedFactory 375 @AssistedFactory
237 interface Factory { 376 interface Factory {
238 fun create( 377 fun create(
@@ -262,7 +401,7 @@ constructor( @@ -262,7 +401,7 @@ constructor(
262 capturer.initialize( 401 capturer.initialize(
263 surfaceTextureHelper, 402 surfaceTextureHelper,
264 context, 403 context,
265 - source.capturerObserver 404 + source.capturerObserver,
266 ) 405 )
267 val rtcTrack = peerConnectionFactory.createVideoTrack(UUID.randomUUID().toString(), source) 406 val rtcTrack = peerConnectionFactory.createVideoTrack(UUID.randomUUID().toString(), source)
268 407
@@ -271,12 +410,12 @@ constructor( @@ -271,12 +410,12 @@ constructor(
271 source = source, 410 source = source,
272 options = options, 411 options = options,
273 name = name, 412 name = name,
274 - rtcTrack = rtcTrack 413 + rtcTrack = rtcTrack,
275 ) 414 )
276 415
277 track.closeableManager.registerResource( 416 track.closeableManager.registerResource(
278 rtcTrack, 417 rtcTrack,
279 - SurfaceTextureHelperCloser(surfaceTextureHelper) 418 + SurfaceTextureHelperCloser(surfaceTextureHelper),
280 ) 419 )
281 return track 420 return track
282 } 421 }
@@ -303,7 +442,7 @@ constructor( @@ -303,7 +442,7 @@ constructor(
303 capturer.initialize( 442 capturer.initialize(
304 surfaceTextureHelper, 443 surfaceTextureHelper,
305 context, 444 context,
306 - source.capturerObserver 445 + source.capturerObserver,
307 ) 446 )
308 val rtcTrack = peerConnectionFactory.createVideoTrack(UUID.randomUUID().toString(), source) 447 val rtcTrack = peerConnectionFactory.createVideoTrack(UUID.randomUUID().toString(), source)
309 448
@@ -312,15 +451,22 @@ constructor( @@ -312,15 +451,22 @@ constructor(
312 source = source, 451 source = source,
313 options = newOptions, 452 options = newOptions,
314 name = name, 453 name = name,
315 - rtcTrack = rtcTrack 454 + rtcTrack = rtcTrack,
316 ) 455 )
317 456
318 track.closeableManager.registerResource( 457 track.closeableManager.registerResource(
319 rtcTrack, 458 rtcTrack,
320 - SurfaceTextureHelperCloser(surfaceTextureHelper) 459 + SurfaceTextureHelperCloser(surfaceTextureHelper),
321 ) 460 )
322 461
323 return track 462 return track
324 } 463 }
325 } 464 }
326 } 465 }
  466 +
  467 +internal data class SimulcastTrackInfo(
  468 + var codec: String,
  469 + var rtcTrack: MediaStreamTrack,
  470 + var sender: RtpSender? = null,
  471 + var encodings: List<RtpParameters.Encoding>? = null,
  472 +)
@@ -63,6 +63,14 @@ data class VideoEncoding( @@ -63,6 +63,14 @@ data class VideoEncoding(
63 enum class VideoCodec(val codecName: String) { 63 enum class VideoCodec(val codecName: String) {
64 VP8("vp8"), 64 VP8("vp8"),
65 H264("h264"), 65 H264("h264"),
  66 + VP9("vp9"),
  67 + AV1("av1");
  68 +
  69 + companion object {
  70 + fun fromCodecName(codecName: String): VideoCodec {
  71 + return VideoCodec.values().first { it.codecName.equals(codecName, ignoreCase = true) }
  72 + }
  73 + }
66 } 74 }
67 75
68 enum class CameraPosition { 76 enum class CameraPosition {
  1 +/*
  2 + * Copyright 2023 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.track.video
  18 +
  19 +data class ScalabilityMode(val spatial: Int, val temporal: Int, val suffix: String) {
  20 + companion object {
  21 + private val REGEX = """L(\d)T(\d)(h|_KEY|_KEY_SHIFT)?""".toRegex()
  22 + fun parseFromString(mode: String): ScalabilityMode {
  23 + val match = REGEX.matchEntire(mode) ?: throw IllegalArgumentException("can't parse scalability mode: $mode")
  24 + val (spatial, temporal, suffix) = match.destructured
  25 +
  26 + return ScalabilityMode(spatial.toInt(), temporal.toInt(), suffix)
  27 + }
  28 + }
  29 +}
@@ -72,7 +72,7 @@ open class CoroutineSdpObserver : SdpObserver { @@ -72,7 +72,7 @@ open class CoroutineSdpObserver : SdpObserver {
72 setOutcome = Either.Right(message) 72 setOutcome = Either.Right(message)
73 } 73 }
74 74
75 - suspend fun awaitCreate() = suspendCoroutine<Either<SessionDescription, String?>> { cont -> 75 + suspend fun awaitCreate() = suspendCoroutine { cont ->
76 val curOutcome = createOutcome 76 val curOutcome = createOutcome
77 if (curOutcome != null) { 77 if (curOutcome != null) {
78 cont.resume(curOutcome) 78 cont.resume(curOutcome)
@@ -81,7 +81,7 @@ open class CoroutineSdpObserver : SdpObserver { @@ -81,7 +81,7 @@ open class CoroutineSdpObserver : SdpObserver {
81 } 81 }
82 } 82 }
83 83
84 - suspend fun awaitSet() = suspendCoroutine<Either<Unit, String?>> { cont -> 84 + suspend fun awaitSet() = suspendCoroutine { cont ->
85 val curOutcome = setOutcome 85 val curOutcome = setOutcome
86 if (curOutcome != null) { 86 if (curOutcome != null) {
87 cont.resume(curOutcome) 87 cont.resume(curOutcome)
@@ -20,11 +20,15 @@ import io.livekit.android.room.track.VideoEncoding @@ -20,11 +20,15 @@ import io.livekit.android.room.track.VideoEncoding
20 import io.livekit.android.room.track.VideoPreset 20 import io.livekit.android.room.track.VideoPreset
21 import io.livekit.android.room.track.VideoPreset169 21 import io.livekit.android.room.track.VideoPreset169
22 import io.livekit.android.room.track.VideoPreset43 22 import io.livekit.android.room.track.VideoPreset43
  23 +import io.livekit.android.room.track.video.ScalabilityMode
23 import livekit.LivekitModels 24 import livekit.LivekitModels
24 import org.webrtc.RtpParameters 25 import org.webrtc.RtpParameters
25 import kotlin.math.abs 26 import kotlin.math.abs
  27 +import kotlin.math.ceil
26 import kotlin.math.max 28 import kotlin.math.max
27 import kotlin.math.min 29 import kotlin.math.min
  30 +import kotlin.math.pow
  31 +import kotlin.math.roundToInt
28 32
29 internal object EncodingUtils { 33 internal object EncodingUtils {
30 34
@@ -83,6 +87,7 @@ internal object EncodingUtils { @@ -83,6 +87,7 @@ internal object EncodingUtils {
83 trackWidth: Int, 87 trackWidth: Int,
84 trackHeight: Int, 88 trackHeight: Int,
85 encodings: List<RtpParameters.Encoding>, 89 encodings: List<RtpParameters.Encoding>,
  90 + isSVC: Boolean,
86 ): List<LivekitModels.VideoLayer> { 91 ): List<LivekitModels.VideoLayer> {
87 return if (encodings.isEmpty()) { 92 return if (encodings.isEmpty()) {
88 listOf( 93 listOf(
@@ -94,6 +99,19 @@ internal object EncodingUtils { @@ -94,6 +99,19 @@ internal object EncodingUtils {
94 ssrc = 0 99 ssrc = 0
95 }.build(), 100 }.build(),
96 ) 101 )
  102 + } else if (isSVC) {
  103 + val encodingSM = encodings.first().scalabilityMode!!
  104 + val scalabilityMode = ScalabilityMode.parseFromString(encodingSM)
  105 + val maxBitrate = encodings.first().maxBitrateBps ?: 0
  106 + (0 until scalabilityMode.spatial).map { index ->
  107 + LivekitModels.VideoLayer.newBuilder().apply {
  108 + width = ceil(trackWidth / (2f.pow(index))).roundToInt()
  109 + height = ceil(trackHeight / (2f.pow(index))).roundToInt()
  110 + quality = LivekitModels.VideoQuality.forNumber(LivekitModels.VideoQuality.HIGH.number - index)
  111 + bitrate = ceil(maxBitrate / 3f.pow(index)).roundToInt()
  112 + ssrc = 0
  113 + }.build()
  114 + }
97 } else { 115 } else {
98 encodings.map { encoding -> 116 encodings.map { encoding ->
99 val scaleDownBy = encoding.scaleResolutionDownBy ?: 1.0 117 val scaleDownBy = encoding.scaleResolutionDownBy ?: 1.0
@@ -17,6 +17,6 @@ @@ -17,6 +17,6 @@
17 package io.livekit.android.util 17 package io.livekit.android.util
18 18
19 sealed class Either<out A, out B> { 19 sealed class Either<out A, out B> {
20 - class Left<A>(val value: A) : Either<A, Nothing>()  
21 - class Right<B>(val value: B) : Either<Nothing, B>() 20 + class Left<out A>(val value: A) : Either<A, Nothing>()
  21 + class Right<out B>(val value: B) : Either<Nothing, B>()
22 } 22 }
@@ -23,7 +23,7 @@ import org.webrtc.VideoDecoder @@ -23,7 +23,7 @@ import org.webrtc.VideoDecoder
23 import org.webrtc.VideoDecoderFactory 23 import org.webrtc.VideoDecoderFactory
24 import org.webrtc.WrappedVideoDecoderFactory 24 import org.webrtc.WrappedVideoDecoderFactory
25 25
26 -class CustomVideoDecoderFactory( 26 +open class CustomVideoDecoderFactory(
27 sharedContext: EglBase.Context?, 27 sharedContext: EglBase.Context?,
28 private var forceSWCodec: Boolean = false, 28 private var forceSWCodec: Boolean = false,
29 private var forceSWCodecs: List<String> = listOf("VP9"), 29 private var forceSWCodecs: List<String> = listOf("VP9"),
@@ -22,7 +22,7 @@ import org.webrtc.VideoCodecInfo @@ -22,7 +22,7 @@ import org.webrtc.VideoCodecInfo
22 import org.webrtc.VideoEncoder 22 import org.webrtc.VideoEncoder
23 import org.webrtc.VideoEncoderFactory 23 import org.webrtc.VideoEncoderFactory
24 24
25 -class CustomVideoEncoderFactory( 25 +open class CustomVideoEncoderFactory(
26 sharedContext: EglBase.Context?, 26 sharedContext: EglBase.Context?,
27 enableIntelVp8Encoder: Boolean, 27 enableIntelVp8Encoder: Boolean,
28 enableH264HighProfile: Boolean, 28 enableH264HighProfile: Boolean,
  1 +/*
  2 + * Copyright 2023 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.webrtc
  18 +
  19 +import android.gov.nist.javax.sdp.fields.AttributeField
  20 +import android.javax.sdp.MediaDescription
  21 +import io.livekit.android.util.LKLog
  22 +
  23 +data class SdpRtp(val payload: Long, val codec: String, val rate: Long?, val encoding: String?)
  24 +
  25 +fun MediaDescription.getRtps(): List<Pair<AttributeField, SdpRtp>> {
  26 + return getAttributes(true)
  27 + .filterIsInstance<AttributeField>()
  28 + .filter { it.attribute.name == "rtpmap" }
  29 + .mapNotNull {
  30 + val rtp = tryParseRtp(it.value)
  31 + if (rtp == null) {
  32 + LKLog.w { "could not parse rtpmap: ${it.encode()}" }
  33 + return@mapNotNull null
  34 + }
  35 + it to rtp
  36 + }
  37 +}
  38 +
  39 +private val RTP = """(\d*) ([\w\-.]*)(?:\s*/(\d*)(?:\s*/(\S*))?)?""".toRegex()
  40 +fun tryParseRtp(string: String): SdpRtp? {
  41 + val match = RTP.matchEntire(string) ?: return null
  42 + val (payload, codec, rate, encoding) = match.destructured
  43 + return SdpRtp(payload.toLong(), codec, toOptionalLong(rate), toOptionalString(encoding))
  44 +}
  45 +
  46 +data class SdpMsid(
  47 + /** holds the msid-id (and msid-appdata if available) */
  48 + val value: String,
  49 +)
  50 +
  51 +fun MediaDescription.getMsid(): SdpMsid? {
  52 + val attribute = getAttribute("msid") ?: return null
  53 + return SdpMsid(attribute)
  54 +}
  55 +
  56 +data class SdpFmtp(val payload: Long, val config: String) {
  57 + fun toAttributeField(): AttributeField {
  58 + return AttributeField().apply {
  59 + name = "fmtp"
  60 + value = "$payload $config"
  61 + }
  62 + }
  63 +}
  64 +
  65 +fun MediaDescription.getFmtps(): List<Pair<AttributeField, SdpFmtp>> {
  66 + return getAttributes(true)
  67 + .filterIsInstance<AttributeField>()
  68 + .filter { it.attribute.name == "fmtp" }
  69 + .mapNotNull {
  70 + val fmtp = tryParseFmtp(it.value)
  71 + if (fmtp == null) {
  72 + LKLog.w { "could not parse fmtp: ${it.encode()}" }
  73 + return@mapNotNull null
  74 + }
  75 + it to fmtp
  76 + }
  77 +}
  78 +
  79 +private val FMTP = """(\d*) ([\S| ]*)""".toRegex()
  80 +fun tryParseFmtp(string: String): SdpFmtp? {
  81 + val match = FMTP.matchEntire(string) ?: return null
  82 + val (payload, config) = match.destructured
  83 + return SdpFmtp(payload.toLong(), config)
  84 +}
  85 +
  86 +data class SdpExt(val value: Long, val direction: String?, val encryptUri: String?, val uri: String, val config: String?) {
  87 + fun toAttributeField(): AttributeField {
  88 + return AttributeField().apply {
  89 + name = "extmap"
  90 + value = buildString {
  91 + append(this@SdpExt.value)
  92 + if (direction != null) {
  93 + append(" $direction")
  94 + }
  95 + if (encryptUri != null) {
  96 + append(" $encryptUri")
  97 + }
  98 + append(" $uri")
  99 + if (config != null) {
  100 + append(" $config")
  101 + }
  102 + }
  103 + }
  104 + }
  105 +}
  106 +
  107 +fun MediaDescription.getExts(): List<Pair<AttributeField, SdpExt>> {
  108 + return getAttributes(true)
  109 + .filterIsInstance<AttributeField>()
  110 + .filter { it.attribute.name == "extmap" }
  111 + .mapNotNull {
  112 + val ext = tryParseExt(it.value)
  113 + if (ext == null) {
  114 + LKLog.w { "could not parse extmap: ${it.encode()}" }
  115 + return@mapNotNull null
  116 + }
  117 + it to ext
  118 + }
  119 +}
  120 +
  121 +private val EXT = """(\d+)(?:/(\w+))?(?: (urn:ietf:params:rtp-hdrext:encrypt))? (\S*)(?: (\S*))?""".toRegex()
  122 +fun tryParseExt(string: String): SdpExt? {
  123 + val match = EXT.matchEntire(string) ?: return null
  124 + val (value, direction, encryptUri, uri, config) = match.destructured
  125 + return SdpExt(value.toLong(), toOptionalString(direction), toOptionalString(encryptUri), uri, toOptionalString(config))
  126 +}
  127 +
  128 +fun toOptionalLong(str: String): Long? = if (str.isEmpty()) null else str.toLong()
  129 +fun toOptionalString(str: String): String? = str.ifEmpty { null }
  1 +/*
  2 + * Copyright 2023 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.webrtc
  18 +
  19 +import io.livekit.android.dagger.CapabilitiesGetter
  20 +import io.livekit.android.util.LKLog
  21 +import org.webrtc.MediaStreamTrack
  22 +import org.webrtc.RtpCapabilities
  23 +import org.webrtc.RtpTransceiver
  24 +
  25 +internal fun RtpTransceiver.sortVideoCodecPreferences(targetCodec: String, capabilitiesGetter: CapabilitiesGetter) {
  26 + val capabilities = capabilitiesGetter(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO)
  27 + LKLog.v { "capabilities:" }
  28 + capabilities.codecs.forEach { codec ->
  29 + LKLog.v { "codec: ${codec.name}, ${codec.kind}, ${codec.mimeType}, ${codec.parameters}, ${codec.preferredPayloadType}" }
  30 + }
  31 +
  32 + val matched = mutableListOf<RtpCapabilities.CodecCapability>()
  33 + val partialMatched = mutableListOf<RtpCapabilities.CodecCapability>()
  34 + val unmatched = mutableListOf<RtpCapabilities.CodecCapability>()
  35 +
  36 + for (codec in capabilities.codecs) {
  37 + val mimeType = codec.mimeType.lowercase()
  38 + if (mimeType == "audio/opus") {
  39 + matched.add(codec)
  40 + continue
  41 + }
  42 +
  43 + if (mimeType != "video/$targetCodec") {
  44 + unmatched.add(codec)
  45 + continue
  46 + }
  47 + // for h264 codecs that have sdpFmtpLine available, use only if the
  48 + // profile-level-id is 42e01f for cross-browser compatibility
  49 + if (targetCodec == "h264") {
  50 + if (codec.parameters["profile-level-id"] == "42e01f") {
  51 + matched.add(codec)
  52 + } else {
  53 + partialMatched.add(codec)
  54 + }
  55 + continue
  56 + } else {
  57 + matched.add(codec)
  58 + }
  59 + }
  60 + setCodecPreferences(matched.plus(partialMatched).plus(unmatched))
  61 +}
@@ -18,9 +18,10 @@ package io.livekit.android.mock @@ -18,9 +18,10 @@ package io.livekit.android.mock
18 18
19 import org.webrtc.VideoSink 19 import org.webrtc.VideoSink
20 import org.webrtc.VideoTrack 20 import org.webrtc.VideoTrack
  21 +import java.util.UUID
21 22
22 class MockVideoStreamTrack( 23 class MockVideoStreamTrack(
23 - val id: String = "id", 24 + val id: String = UUID.randomUUID().toString(),
24 val kind: String = VIDEO_TRACK_KIND, 25 val kind: String = VIDEO_TRACK_KIND,
25 var enabled: Boolean = true, 26 var enabled: Boolean = true,
26 var state: State = State.LIVE, 27 var state: State = State.LIVE,
@@ -23,6 +23,7 @@ import livekit.LivekitRtc @@ -23,6 +23,7 @@ import livekit.LivekitRtc
23 import okhttp3.Request 23 import okhttp3.Request
24 import okhttp3.WebSocket 24 import okhttp3.WebSocket
25 import okhttp3.WebSocketListener 25 import okhttp3.WebSocketListener
  26 +import okio.ByteString
26 27
27 class MockWebSocketFactory : WebSocket.Factory { 28 class MockWebSocketFactory : WebSocket.Factory {
28 /** 29 /**
@@ -67,4 +68,8 @@ class MockWebSocketFactory : WebSocket.Factory { @@ -67,4 +68,8 @@ class MockWebSocketFactory : WebSocket.Factory {
67 } 68 }
68 69
69 var onOpen: ((MockWebSocketFactory) -> Unit)? = null 70 var onOpen: ((MockWebSocketFactory) -> Unit)? = null
  71 +
  72 + fun receiveMessage(byteString: ByteString) {
  73 + listener.onMessage(ws, byteString)
  74 + }
70 } 75 }
@@ -17,6 +17,7 @@ @@ -17,6 +17,7 @@
17 package io.livekit.android.mock.dagger 17 package io.livekit.android.mock.dagger
18 18
19 import android.content.Context 19 import android.content.Context
  20 +import android.javax.sdp.SdpFactory
20 import dagger.Module 21 import dagger.Module
21 import dagger.Provides 22 import dagger.Provides
22 import io.livekit.android.dagger.CapabilitiesGetter 23 import io.livekit.android.dagger.CapabilitiesGetter
@@ -59,4 +60,7 @@ object TestRTCModule { @@ -59,4 +60,7 @@ object TestRTCModule {
59 @Provides 60 @Provides
60 @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL) 61 @Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL)
61 fun videoHwAccel() = true 62 fun videoHwAccel() = true
  63 +
  64 + @Provides
  65 + fun sdpFactory() = SdpFactory.getInstance()
62 } 66 }
  1 +/*
  2 + * Copyright 2023 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room
  18 +
  19 +import android.javax.sdp.MediaDescription
  20 +import android.javax.sdp.SdpFactory
  21 +import io.livekit.android.webrtc.JainSdpUtilsTest
  22 +import io.livekit.android.webrtc.getExts
  23 +import io.livekit.android.webrtc.getFmtps
  24 +import org.junit.Assert.assertEquals
  25 +import org.junit.Assert.assertNotNull
  26 +import org.junit.Assert.assertNull
  27 +import org.junit.Test
  28 +
  29 +class SdpMungingTest {
  30 +
  31 + @Test
  32 + fun ensureVideoDDExtensionForSVCTest() {
  33 + val sdp = SdpFactory.getInstance().createSessionDescription(NO_DD_DESCRIPTION)
  34 + val mediaDescription = sdp.getMediaDescriptions(true).filterIsInstance<MediaDescription>()[1]
  35 +
  36 + ensureVideoDDExtensionForSVC(mediaDescription)
  37 +
  38 + val exts = mediaDescription.getExts()
  39 +
  40 + assertEquals(12, exts.size)
  41 +
  42 + val ddExtPair = exts.find { it.second.value == 12L }
  43 +
  44 + assertNotNull(ddExtPair)
  45 + val (_, ext) = ddExtPair!!
  46 +
  47 + assertEquals(12, ext.value)
  48 + assertNull(ext.direction)
  49 + assertNull(ext.encryptUri)
  50 + assertEquals("https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension", ext.uri)
  51 + assertNull(ext.config)
  52 + }
  53 +
  54 + @Test
  55 + fun ensureCodecBitratesTest() {
  56 + val sdp = SdpFactory.getInstance().createSessionDescription(JainSdpUtilsTest.DESCRIPTION)
  57 + val mediaDescription = sdp.getMediaDescriptions(true).filterIsInstance<MediaDescription>()[1]
  58 +
  59 + ensureCodecBitrates(
  60 + mediaDescription,
  61 + mapOf(
  62 + TrackBitrateInfoKey.Cid("PA_Qwqk4y9fcD3G") to
  63 + TrackBitrateInfo(
  64 + "VP9",
  65 + 1000000L,
  66 + ),
  67 + ),
  68 + )
  69 +
  70 + val (_, vp9fmtp) = mediaDescription.getFmtps()
  71 + .filter { (_, fmtp) -> fmtp.payload == 98L }
  72 + .first()
  73 +
  74 + assertEquals("profile-id=0;x-google-start-bitrate=700000;x-google-max-bitrate=1000000", vp9fmtp.config)
  75 + }
  76 +
  77 + companion object {
  78 + const val NO_DD_DESCRIPTION = "v=0\n" +
  79 + "o=- 3682890773448528616 3 IN IP4 127.0.0.1\n" +
  80 + "s=-\n" +
  81 + "t=0 0\n" +
  82 + "a=group:BUNDLE 0 1\n" +
  83 + "a=extmap-allow-mixed\n" +
  84 + "a=msid-semantic: WMS PA_Qwqk4y9fcD3G\n" +
  85 + "m=application 24436 UDP/DTLS/SCTP webrtc-datachannel\n" +
  86 + "c=IN IP4 45.76.222.83\n" +
  87 + "a=candidate:1660983843 1 udp 2122194687 192.168.0.22 37324 typ host generation 0 network-id 5 network-cost 10\n" +
  88 + "a=candidate:901011812 1 udp 2122262783 2400:4050:28c2:200:9ce6:9cff:fe22:a74d 48779 typ host generation 0 network-id 6 network-cost 10\n" +
  89 + "a=candidate:3061937641 1 udp 1685987071 123.222.220.24 37324 typ srflx raddr 192.168.0.22 rport 37324 generation 0 network-id 5 network-cost 10\n" +
  90 + "a=candidate:689824452 1 tcp 1518083839 10.244.232.137 9 typ host tcptype active generation 0 network-id 3 network-cost 900\n" +
  91 + "a=candidate:2149072150 1 tcp 1518151935 2407:5300:1029:7a1f:96b6:6f1e:ec66:64d8 9 typ host tcptype active generation 0 network-id 4 network-cost 900\n" +
  92 + "a=candidate:3396018872 1 udp 41820671 45.76.222.83 24436 typ relay raddr 123.222.220.24 rport 37324 generation 0 network-id 5 network-cost 10\n" +
  93 + "a=ice-ufrag:+2SN\n" +
  94 + "a=ice-pwd:cdmp3JptAqdOA9VRHrNsdKE9\n" +
  95 + "a=ice-options:trickle renomination\n" +
  96 + "a=fingerprint:sha-256 44:C7:59:DD:54:91:AC:EA:93:07:8E:4F:78:C5:A6:9B:FB:C3:16:2B:95:1C:9E:DB:3B:AE:8A:E5:76:37:6F:A2\n" +
  97 + "a=setup:actpass\n" +
  98 + "a=mid:0\n" +
  99 + "a=sctp-port:5000\n" +
  100 + "a=max-message-size:262144\n" +
  101 + "m=video 9 UDP/TLS/RTP/SAVPF 96 97 127 103 104 105 39 40 98 99 106 107 108\n" +
  102 + "c=IN IP4 0.0.0.0\n" +
  103 + "a=rtcp:9 IN IP4 0.0.0.0\n" +
  104 + "a=ice-ufrag:+2SN\n" +
  105 + "a=ice-pwd:cdmp3JptAqdOA9VRHrNsdKE9\n" +
  106 + "a=ice-options:trickle renomination\n" +
  107 + "a=fingerprint:sha-256 44:C7:59:DD:54:91:AC:EA:93:07:8E:4F:78:C5:A6:9B:FB:C3:16:2B:95:1C:9E:DB:3B:AE:8A:E5:76:37:6F:A2\n" +
  108 + "a=setup:actpass\n" +
  109 + "a=mid:1\n" +
  110 + "a=extmap:1 urn:ietf:params:rtp-hdrext:toffset\n" +
  111 + "a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\n" +
  112 + "a=extmap:3 urn:3gpp:video-orientation\n" +
  113 + "a=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" +
  114 + "a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\n" +
  115 + "a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\n" +
  116 + "a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\n" +
  117 + "a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\n" +
  118 + "a=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\n" +
  119 + "a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\n" +
  120 + "a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\n" +
  121 + "a=sendonly\n" +
  122 + "a=msid:PA_Qwqk4y9fcD3G 42dd9185-4ea2-4bf1-b964-1dc0eb739c6c\n" +
  123 + "a=rtcp-mux\n" +
  124 + "a=rtcp-rsize\n" +
  125 + "a=rtpmap:98 VP9/90000\n" +
  126 + "a=rtcp-fb:98 goog-remb\n" +
  127 + "a=rtcp-fb:98 transport-cc\n" +
  128 + "a=rtcp-fb:98 ccm fir\n" +
  129 + "a=rtcp-fb:98 nack\n" +
  130 + "a=rtcp-fb:98 nack pli\n" +
  131 + "a=fmtp:98 profile-id=0\n" +
  132 + "a=rtpmap:96 VP8/90000\n" +
  133 + "a=rtcp-fb:96 goog-remb\n" +
  134 + "a=rtcp-fb:96 transport-cc\n" +
  135 + "a=rtcp-fb:96 ccm fir\n" +
  136 + "a=rtcp-fb:96 nack\n" +
  137 + "a=rtcp-fb:96 nack pli\n" +
  138 + "a=rtpmap:97 rtx/90000\n" +
  139 + "a=fmtp:97 apt=96\n" +
  140 + "a=rtpmap:127 H264/90000\n" +
  141 + "a=rtcp-fb:127 goog-remb\n" +
  142 + "a=rtcp-fb:127 transport-cc\n" +
  143 + "a=rtcp-fb:127 ccm fir\n" +
  144 + "a=rtcp-fb:127 nack\n" +
  145 + "a=rtcp-fb:127 nack pli\n" +
  146 + "a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\n" +
  147 + "a=rtpmap:103 rtx/90000\n" +
  148 + "a=fmtp:103 apt=127\n" +
  149 + "a=rtpmap:104 H265/90000\n" +
  150 + "a=rtcp-fb:104 goog-remb\n" +
  151 + "a=rtcp-fb:104 transport-cc\n" +
  152 + "a=rtcp-fb:104 ccm fir\n" +
  153 + "a=rtcp-fb:104 nack\n" +
  154 + "a=rtcp-fb:104 nack pli\n" +
  155 + "a=rtpmap:105 rtx/90000\n" +
  156 + "a=fmtp:105 apt=104\n" +
  157 + "a=rtpmap:39 AV1/90000\n" +
  158 + "a=rtcp-fb:39 goog-remb\n" +
  159 + "a=rtcp-fb:39 transport-cc\n" +
  160 + "a=rtcp-fb:39 ccm fir\n" +
  161 + "a=rtcp-fb:39 nack\n" +
  162 + "a=rtcp-fb:39 nack pli\n" +
  163 + "a=rtpmap:40 rtx/90000\n" +
  164 + "a=fmtp:40 apt=39\n" +
  165 + "a=rtpmap:99 rtx/90000\n" +
  166 + "a=fmtp:99 apt=98\n" +
  167 + "a=rtpmap:106 red/90000\n" +
  168 + "a=rtpmap:107 rtx/90000\n" +
  169 + "a=fmtp:107 apt=106\n" +
  170 + "a=rtpmap:108 ulpfec/90000\n" +
  171 + "a=rid:h send\n" +
  172 + "a=rid:q send\n" +
  173 + "a=simulcast:send h;q"
  174 + }
  175 +}
@@ -23,6 +23,7 @@ import io.livekit.android.events.ParticipantEvent @@ -23,6 +23,7 @@ import io.livekit.android.events.ParticipantEvent
23 import io.livekit.android.events.RoomEvent 23 import io.livekit.android.events.RoomEvent
24 import io.livekit.android.mock.MockAudioStreamTrack 24 import io.livekit.android.mock.MockAudioStreamTrack
25 import io.livekit.android.mock.MockEglBase 25 import io.livekit.android.mock.MockEglBase
  26 +import io.livekit.android.mock.MockPeerConnection
26 import io.livekit.android.mock.MockVideoCapturer 27 import io.livekit.android.mock.MockVideoCapturer
27 import io.livekit.android.mock.MockVideoStreamTrack 28 import io.livekit.android.mock.MockVideoStreamTrack
28 import io.livekit.android.room.DefaultsManager 29 import io.livekit.android.room.DefaultsManager
@@ -31,10 +32,14 @@ import io.livekit.android.room.track.LocalAudioTrack @@ -31,10 +32,14 @@ import io.livekit.android.room.track.LocalAudioTrack
31 import io.livekit.android.room.track.LocalVideoTrack 32 import io.livekit.android.room.track.LocalVideoTrack
32 import io.livekit.android.room.track.LocalVideoTrackOptions 33 import io.livekit.android.room.track.LocalVideoTrackOptions
33 import io.livekit.android.room.track.VideoCaptureParameter 34 import io.livekit.android.room.track.VideoCaptureParameter
  35 +import io.livekit.android.room.track.VideoCodec
34 import io.livekit.android.util.toOkioByteString 36 import io.livekit.android.util.toOkioByteString
35 import io.livekit.android.util.toPBByteString 37 import io.livekit.android.util.toPBByteString
36 import kotlinx.coroutines.ExperimentalCoroutinesApi 38 import kotlinx.coroutines.ExperimentalCoroutinesApi
  39 +import livekit.LivekitModels
37 import livekit.LivekitRtc 40 import livekit.LivekitRtc
  41 +import livekit.LivekitRtc.SubscribedCodec
  42 +import livekit.LivekitRtc.SubscribedQuality
38 import org.junit.Assert.* 43 import org.junit.Assert.*
39 import org.junit.Test 44 import org.junit.Test
40 import org.junit.runner.RunWith 45 import org.junit.runner.RunWith
@@ -200,4 +205,108 @@ class LocalParticipantMockE2ETest : MockE2ETest() { @@ -200,4 +205,108 @@ class LocalParticipantMockE2ETest : MockE2ETest() {
200 }, 205 },
201 ) 206 )
202 } 207 }
  208 +
  209 + @Test
  210 + fun publishSvcCodec() = runTest {
  211 + room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy(
  212 + videoCodec = VideoCodec.VP9.codecName,
  213 + scalabilityMode = "L3T3",
  214 + backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName),
  215 + )
  216 +
  217 + connect()
  218 + wsFactory.ws.clearRequests()
  219 + room.localParticipant.publishVideoTrack(track = createLocalTrack())
  220 +
  221 + // Expect add track request to contain both primary and backup
  222 + assertEquals(1, wsFactory.ws.sentRequests.size)
  223 +
  224 + val requestString = wsFactory.ws.sentRequests[0]
  225 + val signalRequest = LivekitRtc.SignalRequest.newBuilder()
  226 + .mergeFrom(requestString.toPBByteString())
  227 + .build()
  228 + assertTrue(signalRequest.hasAddTrack())
  229 +
  230 + val addTrackRequest = signalRequest.addTrack
  231 + assertEquals(2, addTrackRequest.simulcastCodecsList.size)
  232 +
  233 + val vp9Codec = addTrackRequest.simulcastCodecsList[0]
  234 + assertEquals("vp9", vp9Codec.codec)
  235 +
  236 + val vp8Codec = addTrackRequest.simulcastCodecsList[1]
  237 + assertEquals("vp8", vp8Codec.codec)
  238 +
  239 + val publisherConn = component.rtcEngine().publisher.peerConnection as MockPeerConnection
  240 +
  241 + assertEquals(1, publisherConn.transceivers.size)
  242 + Mockito.verify(publisherConn.transceivers.first()).setCodecPreferences(
  243 + argThat { codecs ->
  244 + val preferredCodec = codecs.first()
  245 + return@argThat preferredCodec.name.lowercase() == "vp9"
  246 + },
  247 + )
  248 +
  249 + // Ensure the newly subscribed vp8 codec gets added as a new transceiver.
  250 + wsFactory.receiveMessage(
  251 + with(LivekitRtc.SignalResponse.newBuilder()) {
  252 + subscribedQualityUpdate = with(LivekitRtc.SubscribedQualityUpdate.newBuilder()) {
  253 + trackSid = room.localParticipant.videoTracks.first().first.sid
  254 + addAllSubscribedCodecs(
  255 + listOf(
  256 + with(SubscribedCodec.newBuilder()) {
  257 + codec = "vp9"
  258 + addAllQualities(
  259 + listOf(
  260 + SubscribedQuality.newBuilder()
  261 + .setQuality(LivekitModels.VideoQuality.HIGH)
  262 + .setEnabled(true)
  263 + .build(),
  264 + SubscribedQuality.newBuilder()
  265 + .setQuality(LivekitModels.VideoQuality.MEDIUM)
  266 + .setEnabled(true)
  267 + .build(),
  268 + SubscribedQuality.newBuilder()
  269 + .setQuality(LivekitModels.VideoQuality.LOW)
  270 + .setEnabled(true)
  271 + .build(),
  272 + ),
  273 + )
  274 + build()
  275 + },
  276 + with(SubscribedCodec.newBuilder()) {
  277 + codec = "vp8"
  278 + addAllQualities(
  279 + listOf(
  280 + SubscribedQuality.newBuilder()
  281 + .setQuality(LivekitModels.VideoQuality.HIGH)
  282 + .setEnabled(true)
  283 + .build(),
  284 + SubscribedQuality.newBuilder()
  285 + .setQuality(LivekitModels.VideoQuality.MEDIUM)
  286 + .setEnabled(true)
  287 + .build(),
  288 + SubscribedQuality.newBuilder()
  289 + .setQuality(LivekitModels.VideoQuality.LOW)
  290 + .setEnabled(true)
  291 + .build(),
  292 + ),
  293 + )
  294 + build()
  295 + },
  296 + ),
  297 + )
  298 + build()
  299 + }
  300 + build().toOkioByteString()
  301 + },
  302 + )
  303 +
  304 + assertEquals(2, publisherConn.transceivers.size)
  305 + Mockito.verify(publisherConn.transceivers.last()).setCodecPreferences(
  306 + argThat { codecs ->
  307 + val preferredCodec = codecs.first()
  308 + return@argThat preferredCodec.name.lowercase() == "vp8"
  309 + },
  310 + )
  311 + }
203 } 312 }
  1 +/*
  2 + * Copyright 2023 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.room.track.video
  18 +
  19 +import org.junit.Assert.assertEquals
  20 +import org.junit.Test
  21 +
  22 +class ScalabilityModeTest {
  23 +
  24 + @Test
  25 + fun testL1T3() {
  26 + val mode = ScalabilityMode.parseFromString("L1T3")
  27 +
  28 + assertEquals(1, mode.spatial)
  29 + assertEquals(3, mode.temporal)
  30 + assertEquals("", mode.suffix)
  31 + }
  32 +
  33 + @Test
  34 + fun testL3T3_KEY() {
  35 + val mode = ScalabilityMode.parseFromString("L3T3_KEY")
  36 +
  37 + assertEquals(3, mode.spatial)
  38 + assertEquals(3, mode.temporal)
  39 + assertEquals("_KEY", mode.suffix)
  40 + }
  41 +}
  1 +/*
  2 + * Copyright 2023 LiveKit, Inc.
  3 + *
  4 + * Licensed under the Apache License, Version 2.0 (the "License");
  5 + * you may not use this file except in compliance with the License.
  6 + * You may obtain a copy of the License at
  7 + *
  8 + * http://www.apache.org/licenses/LICENSE-2.0
  9 + *
  10 + * Unless required by applicable law or agreed to in writing, software
  11 + * distributed under the License is distributed on an "AS IS" BASIS,
  12 + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  13 + * See the License for the specific language governing permissions and
  14 + * limitations under the License.
  15 + */
  16 +
  17 +package io.livekit.android.webrtc
  18 +
  19 +import android.javax.sdp.MediaDescription
  20 +import android.javax.sdp.SdpFactory
  21 +import android.javax.sdp.SessionDescription
  22 +import org.junit.Assert.assertEquals
  23 +import org.junit.Assert.assertNotNull
  24 +import org.junit.Assert.assertNull
  25 +import org.junit.Test
  26 +
  27 +class JainSdpUtilsTest {
  28 +
  29 + private val sdpFactory = SdpFactory.getInstance()
  30 + private fun createSessionDescription(): SessionDescription {
  31 + return sdpFactory.createSessionDescription(DESCRIPTION)
  32 + }
  33 +
  34 + @Test
  35 + fun getRtpAttributes() {
  36 + val sdp = createSessionDescription()
  37 + val mediaDescriptions = sdp.getMediaDescriptions(true)
  38 + .filterIsInstance<MediaDescription>()
  39 + val mediaDesc = mediaDescriptions[1]
  40 + val rtps = mediaDesc.getRtps()
  41 + assertEquals(13, rtps.size)
  42 +
  43 + val (_, vp8Rtp) = rtps[0]
  44 +
  45 + assertEquals(96, vp8Rtp.payload)
  46 + assertEquals("VP8", vp8Rtp.codec)
  47 + assertEquals(90000L, vp8Rtp.rate)
  48 + assertNull(vp8Rtp.encoding)
  49 + }
  50 +
  51 + @Test
  52 + fun getExtmapAttributes() {
  53 + val sdp = createSessionDescription()
  54 + val mediaDescriptions = sdp.getMediaDescriptions(true)
  55 + .filterIsInstance<MediaDescription>()
  56 + val mediaDesc = mediaDescriptions[1]
  57 + val exts = mediaDesc.getExts()
  58 +
  59 + assertEquals(12, exts.size)
  60 +
  61 + val (_, ext) = exts[0]
  62 + assertEquals(1, ext.value)
  63 + assertNull(ext.direction)
  64 + assertNull(ext.encryptUri)
  65 + assertEquals("urn:ietf:params:rtp-hdrext:toffset", ext.uri)
  66 + assertNull(ext.config)
  67 + }
  68 +
  69 + @Test
  70 + fun getMsid() {
  71 + val sdp = createSessionDescription()
  72 + val mediaDescriptions = sdp.getMediaDescriptions(true)
  73 + .filterIsInstance<MediaDescription>()
  74 + val mediaDesc = mediaDescriptions[1]
  75 +
  76 + val msid = mediaDesc.getMsid()
  77 + assertNotNull(msid)
  78 + assertEquals("PA_Qwqk4y9fcD3G 42dd9185-4ea2-4bf1-b964-1dc0eb739c6c", msid!!.value)
  79 + }
  80 +
  81 + @Test
  82 + fun getFmtps() {
  83 + val sdp = createSessionDescription()
  84 + val mediaDescriptions = sdp.getMediaDescriptions(true)
  85 + .filterIsInstance<MediaDescription>()
  86 + val mediaDesc = mediaDescriptions[1]
  87 +
  88 + val fmtps = mediaDesc.getFmtps()
  89 + .filter { (_, fmtp) -> fmtp.payload == 97L }
  90 + assertEquals(1, fmtps.size)
  91 +
  92 + val (_, fmtp) = fmtps[0]
  93 + assertEquals("apt=96", fmtp.config)
  94 + }
  95 +
  96 + companion object {
  97 + const val DESCRIPTION = "v=0\n" +
  98 + "o=- 3682890773448528616 3 IN IP4 127.0.0.1\n" +
  99 + "s=-\n" +
  100 + "t=0 0\n" +
  101 + "a=group:BUNDLE 0 1\n" +
  102 + "a=extmap-allow-mixed\n" +
  103 + "a=msid-semantic: WMS PA_Qwqk4y9fcD3G\n" +
  104 + "m=application 24436 UDP/DTLS/SCTP webrtc-datachannel\n" +
  105 + "c=IN IP4 45.76.222.83\n" +
  106 + "a=candidate:1660983843 1 udp 2122194687 192.168.0.22 37324 typ host generation 0 network-id 5 network-cost 10\n" +
  107 + "a=candidate:901011812 1 udp 2122262783 2400:4050:28c2:200:9ce6:9cff:fe22:a74d 48779 typ host generation 0 network-id 6 network-cost 10\n" +
  108 + "a=candidate:3061937641 1 udp 1685987071 123.222.220.24 37324 typ srflx raddr 192.168.0.22 rport 37324 generation 0 network-id 5 network-cost 10\n" +
  109 + "a=candidate:689824452 1 tcp 1518083839 10.244.232.137 9 typ host tcptype active generation 0 network-id 3 network-cost 900\n" +
  110 + "a=candidate:2149072150 1 tcp 1518151935 2407:5300:1029:7a1f:96b6:6f1e:ec66:64d8 9 typ host tcptype active generation 0 network-id 4 network-cost 900\n" +
  111 + "a=candidate:3396018872 1 udp 41820671 45.76.222.83 24436 typ relay raddr 123.222.220.24 rport 37324 generation 0 network-id 5 network-cost 10\n" +
  112 + "a=ice-ufrag:+2SN\n" +
  113 + "a=ice-pwd:cdmp3JptAqdOA9VRHrNsdKE9\n" +
  114 + "a=ice-options:trickle renomination\n" +
  115 + "a=fingerprint:sha-256 44:C7:59:DD:54:91:AC:EA:93:07:8E:4F:78:C5:A6:9B:FB:C3:16:2B:95:1C:9E:DB:3B:AE:8A:E5:76:37:6F:A2\n" +
  116 + "a=setup:actpass\n" +
  117 + "a=mid:0\n" +
  118 + "a=sctp-port:5000\n" +
  119 + "a=max-message-size:262144\n" +
  120 + "m=video 9 UDP/TLS/RTP/SAVPF 96 97 127 103 104 105 39 40 98 99 106 107 108\n" +
  121 + "c=IN IP4 0.0.0.0\n" +
  122 + "a=rtcp:9 IN IP4 0.0.0.0\n" +
  123 + "a=ice-ufrag:+2SN\n" +
  124 + "a=ice-pwd:cdmp3JptAqdOA9VRHrNsdKE9\n" +
  125 + "a=ice-options:trickle renomination\n" +
  126 + "a=fingerprint:sha-256 44:C7:59:DD:54:91:AC:EA:93:07:8E:4F:78:C5:A6:9B:FB:C3:16:2B:95:1C:9E:DB:3B:AE:8A:E5:76:37:6F:A2\n" +
  127 + "a=setup:actpass\n" +
  128 + "a=mid:1\n" +
  129 + "a=extmap:1 urn:ietf:params:rtp-hdrext:toffset\n" +
  130 + "a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\n" +
  131 + "a=extmap:3 urn:3gpp:video-orientation\n" +
  132 + "a=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" +
  133 + "a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\n" +
  134 + "a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\n" +
  135 + "a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\n" +
  136 + "a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\n" +
  137 + "a=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\n" +
  138 + "a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\n" +
  139 + "a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\n" +
  140 + "a=extmap:12 https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension\n" +
  141 + "a=sendonly\n" +
  142 + "a=msid:PA_Qwqk4y9fcD3G 42dd9185-4ea2-4bf1-b964-1dc0eb739c6c\n" +
  143 + "a=rtcp-mux\n" +
  144 + "a=rtcp-rsize\n" +
  145 + "a=rtpmap:96 VP8/90000\n" +
  146 + "a=rtcp-fb:96 goog-remb\n" +
  147 + "a=rtcp-fb:96 transport-cc\n" +
  148 + "a=rtcp-fb:96 ccm fir\n" +
  149 + "a=rtcp-fb:96 nack\n" +
  150 + "a=rtcp-fb:96 nack pli\n" +
  151 + "a=rtpmap:97 rtx/90000\n" +
  152 + "a=fmtp:97 apt=96\n" +
  153 + "a=rtpmap:127 H264/90000\n" +
  154 + "a=rtcp-fb:127 goog-remb\n" +
  155 + "a=rtcp-fb:127 transport-cc\n" +
  156 + "a=rtcp-fb:127 ccm fir\n" +
  157 + "a=rtcp-fb:127 nack\n" +
  158 + "a=rtcp-fb:127 nack pli\n" +
  159 + "a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\n" +
  160 + "a=rtpmap:103 rtx/90000\n" +
  161 + "a=fmtp:103 apt=127\n" +
  162 + "a=rtpmap:104 H265/90000\n" +
  163 + "a=rtcp-fb:104 goog-remb\n" +
  164 + "a=rtcp-fb:104 transport-cc\n" +
  165 + "a=rtcp-fb:104 ccm fir\n" +
  166 + "a=rtcp-fb:104 nack\n" +
  167 + "a=rtcp-fb:104 nack pli\n" +
  168 + "a=rtpmap:105 rtx/90000\n" +
  169 + "a=fmtp:105 apt=104\n" +
  170 + "a=rtpmap:39 AV1/90000\n" +
  171 + "a=rtcp-fb:39 goog-remb\n" +
  172 + "a=rtcp-fb:39 transport-cc\n" +
  173 + "a=rtcp-fb:39 ccm fir\n" +
  174 + "a=rtcp-fb:39 nack\n" +
  175 + "a=rtcp-fb:39 nack pli\n" +
  176 + "a=rtpmap:40 rtx/90000\n" +
  177 + "a=fmtp:40 apt=39\n" +
  178 + "a=rtpmap:98 VP9/90000\n" +
  179 + "a=rtcp-fb:98 goog-remb\n" +
  180 + "a=rtcp-fb:98 transport-cc\n" +
  181 + "a=rtcp-fb:98 ccm fir\n" +
  182 + "a=rtcp-fb:98 nack\n" +
  183 + "a=rtcp-fb:98 nack pli\n" +
  184 + "a=fmtp:98 profile-id=0\n" +
  185 + "a=rtpmap:99 rtx/90000\n" +
  186 + "a=fmtp:99 apt=98\n" +
  187 + "a=rtpmap:106 red/90000\n" +
  188 + "a=rtpmap:107 rtx/90000\n" +
  189 + "a=fmtp:107 apt=106\n" +
  190 + "a=rtpmap:108 ulpfec/90000\n" +
  191 + "a=rid:h send\n" +
  192 + "a=rid:q send\n" +
  193 + "a=simulcast:send h;q"
  194 + }
  195 +}
@@ -63,6 +63,18 @@ class MockPeerConnectionFactory : PeerConnectionFactory(1L) { @@ -63,6 +63,18 @@ class MockPeerConnectionFactory : PeerConnectionFactory(1L) {
63 kind = MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO 63 kind = MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO
64 parameters = mapOf("profile-level-id" to "42e01f") 64 parameters = mapOf("profile-level-id" to "42e01f")
65 }, 65 },
  66 + RtpCapabilities.CodecCapability().apply {
  67 + name = "AV1"
  68 + mimeType = "video/AV1"
  69 + kind = MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO
  70 + parameters = emptyMap()
  71 + },
  72 + RtpCapabilities.CodecCapability().apply {
  73 + name = "VP9"
  74 + mimeType = "video/VP9"
  75 + kind = MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO
  76 + parameters = mapOf("profile-id" to "0")
  77 + },
66 ), 78 ),
67 emptyList(), 79 emptyList(),
68 ) 80 )
1 -Subproject commit 519c96683da8b98f214d42810c1608ddb794cf2e 1 +Subproject commit a187ed4f546b4f6881313ec4813268d53cb070f8