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 个修改的文件 包含 1532 行增加140 行删除
@@ -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>
@@ -21,4 +21,28 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -21,4 +21,28 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
21 See the License for the specific language governing permissions and 21 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 }
  409 + }
346 410
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 - }  
357 -  
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
596 -  
597 - val sender = track.transceiver?.sender ?: return  
598 - val parameters = sender.parameters ?: return  
599 - val encodings = parameters.encodings ?: return  
600 -  
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}" } 664 + val options = pub.options as? VideoTrackPublishOptions ?: return
  665 +
  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 + }
612 } 673 }
613 } 674 }
  675 + if (qualities.isNotEmpty()) {
  676 + track.setPublishingLayers(qualities)
  677 + }
  678 + }
614 679
615 - if (hasChanged) {  
616 - sender.parameters = parameters 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
  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
  689 + }
  690 + val (newOptions, newEncodings) = result
  691 + val simulcastTrack = track.addSimulcastTrack(codec, newEncodings)
  692 +
  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,30 +171,32 @@ constructor( @@ -150,30 +171,32 @@ 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 {  
154 - override fun onFirstFrameAvailable() {  
155 - updateCameraOptions()  
156 - cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)  
157 - }  
158 -  
159 - override fun onCameraError(p0: String?) {  
160 - cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)  
161 - }  
162 -  
163 - override fun onCameraDisconnected() {  
164 - cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)  
165 - }  
166 -  
167 - override fun onCameraFreezed(p0: String?) {  
168 - }  
169 -  
170 - override fun onCameraOpening(p0: String?) {  
171 - }  
172 -  
173 - override fun onCameraClosed() {  
174 - cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)  
175 - }  
176 - }) 174 + .registerHandler(
  175 + object : CameraEventsHandler {
  176 + override fun onFirstFrameAvailable() {
  177 + updateCameraOptions()
  178 + cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
  179 + }
  180 +
  181 + override fun onCameraError(p0: String?) {
  182 + cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
  183 + }
  184 +
  185 + override fun onCameraDisconnected() {
  186 + cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
  187 + }
  188 +
  189 + override fun onCameraFreezed(p0: String?) {
  190 + }
  191 +
  192 + override fun onCameraOpening(p0: String?) {
  193 + }
  194 +
  195 + override fun onCameraClosed() {
  196 + cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
  197 + }
  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