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:
path: ./client-sdk-android
submodules: recursive
- name: set up JDK 12
- name: set up JDK 17
uses: actions/setup-java@v3.12.0
with:
java-version: '17'
... ...
<component name="ProjectDictionaryState">
<dictionary name="davidliu">
<words>
<w>bitrates</w>
<w>capturer</w>
<w>exts</w>
<w>msid</w>
</words>
</dictionary>
</component>
\ No newline at end of file
... ...
... ... @@ -21,4 +21,28 @@ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
#####################################################################################
\ No newline at end of file
#####################################################################################
The following modifications follow MIT License from https://github.com/ggarber/sdpparser
MIT License
Copyright (c) 2023 Gustavo Garcia
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
... ...
... ... @@ -150,6 +150,8 @@ dependencies {
implementation "androidx.core:core:${versions.androidx_core}"
implementation "com.google.protobuf:protobuf-javalite:${versions.protobuf}"
implementation 'javax.sip:android-jain-sip-ri:1.3.0-91'
implementation "com.google.dagger:dagger:${versions.dagger}"
kapt "com.google.dagger:dagger-compiler:${versions.dagger}"
... ...
... ... @@ -19,6 +19,7 @@ package io.livekit.android
import android.app.Application
import android.content.Context
import io.livekit.android.dagger.DaggerLiveKitComponent
import io.livekit.android.dagger.RTCModule
import io.livekit.android.dagger.create
import io.livekit.android.room.ProtocolVersion
import io.livekit.android.room.Room
... ... @@ -60,6 +61,16 @@ class LiveKit {
var enableWebRTCLogging: Boolean = false
/**
* Certain WebRTC classes need to be initialized prior to use.
*
* This does not need to be called under normal circumstances, as [LiveKit.create]
* will handle this for you.
*/
fun init(appContext: Context) {
RTCModule.libWebrtcInitialization(appContext)
}
/**
* Create a Room object.
*/
fun create(
... ... @@ -93,6 +104,7 @@ class LiveKit {
room.videoTrackPublishDefaults = it
}
room.adaptiveStream = options.adaptiveStream
room.dynacast = options.dynacast
return room
}
... ... @@ -110,7 +122,7 @@ class LiveKit {
options: ConnectOptions = ConnectOptions(),
roomOptions: RoomOptions = RoomOptions(),
listener: RoomListener? = null,
overrides: LiveKitOverrides = LiveKitOverrides()
overrides: LiveKitOverrides = LiveKitOverrides(),
): Room {
val room = create(appContext, roomOptions, overrides)
... ...
... ... @@ -17,6 +17,7 @@
package io.livekit.android.dagger
import android.content.Context
import android.javax.sdp.SdpFactory
import android.media.AudioAttributes
import android.media.MediaRecorder
import android.os.Build
... ... @@ -257,6 +258,9 @@ object RTCModule {
@Provides
@Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL)
fun videoHwAccel() = true
@Provides
fun sdpFactory() = SdpFactory.getInstance()
}
/**
... ...
... ... @@ -16,6 +16,8 @@
package io.livekit.android.room
import android.javax.sdp.MediaDescription
import android.javax.sdp.SdpFactory
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
... ... @@ -24,6 +26,12 @@ import io.livekit.android.room.util.*
import io.livekit.android.util.Either
import io.livekit.android.util.LKLog
import io.livekit.android.util.debounce
import io.livekit.android.webrtc.SdpExt
import io.livekit.android.webrtc.SdpFmtp
import io.livekit.android.webrtc.getExts
import io.livekit.android.webrtc.getFmtps
import io.livekit.android.webrtc.getMsid
import io.livekit.android.webrtc.getRtps
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
... ... @@ -33,6 +41,7 @@ import kotlinx.coroutines.sync.withLock
import org.webrtc.*
import org.webrtc.PeerConnection.RTCConfiguration
import javax.inject.Named
import kotlin.math.roundToLong
/**
* @suppress
... ... @@ -40,12 +49,13 @@ import javax.inject.Named
internal class PeerConnectionTransport
@AssistedInject
constructor(
@Assisted config: PeerConnection.RTCConfiguration,
@Assisted config: RTCConfiguration,
@Assisted pcObserver: PeerConnection.Observer,
@Assisted private val listener: Listener?,
@Named(InjectionNames.DISPATCHER_IO)
private val ioDispatcher: CoroutineDispatcher,
connectionFactory: PeerConnectionFactory,
private val sdpFactory: SdpFactory,
) {
private val coroutineScope = CoroutineScope(ioDispatcher + SupervisorJob())
internal val peerConnection: PeerConnection = connectionFactory.createPeerConnection(
... ... @@ -59,6 +69,8 @@ constructor(
private val mutex = Mutex()
private var trackBitrates = mutableMapOf<TrackBitrateInfoKey, TrackBitrateInfo>()
interface Listener {
fun onOffer(sd: SessionDescription)
}
... ... @@ -139,9 +151,82 @@ constructor(
}
}
LKLog.v { "sdp offer = $sdpOffer, description: ${sdpOffer.description}, type: ${sdpOffer.type}" }
peerConnection.setLocalDescription(sdpOffer)
listener?.onOffer(sdpOffer)
// munge sdp
val sdpDescription = sdpFactory.createSessionDescription(sdpOffer.description)
val mediaDescs = sdpDescription.getMediaDescriptions(true)
for (mediaDesc in mediaDescs) {
if (mediaDesc !is MediaDescription) {
continue
}
if (mediaDesc.media.mediaType == "audio") {
// TODO
} else if (mediaDesc.media.mediaType == "video") {
ensureVideoDDExtensionForSVC(mediaDesc)
ensureCodecBitrates(mediaDesc, trackBitrates = trackBitrates)
}
}
val finalSdp = setMungedSdp(sdpOffer, sdpDescription.toString())
listener.onOffer(finalSdp)
}
private suspend fun setMungedSdp(sdp: SessionDescription, mungedDescription: String, remote: Boolean = false): SessionDescription {
val mungedSdp = SessionDescription(sdp.type, mungedDescription)
LKLog.v { "sdp type: ${sdp.type}\ndescription:\n${sdp.description}" }
LKLog.v { "munged sdp type: ${mungedSdp.type}\ndescription:\n${mungedSdp.description}" }
val mungedResult = if (remote) {
peerConnection.setRemoteDescription(mungedSdp)
} else {
peerConnection.setLocalDescription(mungedSdp)
}
val mungedErrorMessage = when (mungedResult) {
is Either.Left -> {
// munged sdp set successfully.
return mungedSdp
}
is Either.Right -> {
if (mungedResult.value.isNullOrBlank()) {
"unknown sdp error"
} else {
mungedResult.value
}
}
}
// munged sdp setting failed
LKLog.w {
"setting munged sdp for " +
"${if (remote) "remote" else "local"} description, " +
"${mungedSdp.type} type failed, falling back to unmodified."
}
LKLog.w { "error: $mungedErrorMessage" }
val result = if (remote) {
peerConnection.setRemoteDescription(sdp)
} else {
peerConnection.setLocalDescription(sdp)
}
if (result is Either.Right) {
val errorMessage = if (result.value.isNullOrBlank()) {
"unknown sdp error"
} else {
result.value
}
// sdp setting failed
LKLog.w {
"setting original sdp for " +
"${if (remote) "remote" else "local"} description, " +
"${sdp.type} type failed!"
}
LKLog.w { "error: $errorMessage" }
}
return sdp
}
fun prepareForIceRestart() {
... ... @@ -156,12 +241,132 @@ constructor(
peerConnection.setConfiguration(config)
}
fun registerTrackBitrateInfo(cid: String, trackBitrateInfo: TrackBitrateInfo) {
trackBitrates[TrackBitrateInfoKey.Cid(cid)] = trackBitrateInfo
}
fun registerTrackBitrateInfo(transceiver: RtpTransceiver, trackBitrateInfo: TrackBitrateInfo) {
trackBitrates[TrackBitrateInfoKey.Transceiver(transceiver)] = trackBitrateInfo
}
@AssistedFactory
interface Factory {
fun create(
config: PeerConnection.RTCConfiguration,
config: RTCConfiguration,
pcObserver: PeerConnection.Observer,
listener: Listener?,
): PeerConnectionTransport
}
}
private const val DD_EXTENSION_URI = "https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension"
internal fun ensureVideoDDExtensionForSVC(mediaDesc: MediaDescription) {
val codec = mediaDesc.getRtps()
.firstOrNull()
?.second
?.codec ?: return
if (!isSVCCodec(codec)) {
return
}
var maxId = 0L
val ddFound = mediaDesc.getExts().any { (_, ext) ->
if (ext.uri == DD_EXTENSION_URI) {
return@any true
}
if (ext.value > maxId) {
maxId = ext.value
}
false
}
// Not found, add manually
if (!ddFound) {
mediaDesc.addAttribute(
SdpExt(
value = maxId + 1,
uri = DD_EXTENSION_URI,
config = null,
direction = null,
encryptUri = null,
).toAttributeField(),
)
}
}
/* The svc codec (av1/vp9) would use a very low bitrate at the begining and
increase slowly by the bandwidth estimator until it reach the target bitrate. The
process commonly cost more than 10 seconds cause subscriber will get blur video at
the first few seconds. So we use a 70% of target bitrate here as the start bitrate to
eliminate this issue.
*/
private const val startBitrateForSVC = 0.7
internal fun ensureCodecBitrates(
media: MediaDescription,
trackBitrates: Map<TrackBitrateInfoKey, TrackBitrateInfo>,
) {
val msid = media.getMsid()?.value ?: return
for ((key, trackBr) in trackBitrates) {
if (key !is TrackBitrateInfoKey.Cid) {
continue
}
val (cid) = key
if (!msid.contains(cid)) {
continue
}
val (_, rtp) = media.getRtps()
.firstOrNull { (_, rtp) -> rtp.codec.equals(trackBr.codec, ignoreCase = true) }
?: continue
val codecPayload = rtp.payload
val fmtps = media.getFmtps()
var fmtpFound = false
for ((attribute, fmtp) in fmtps) {
if (fmtp.payload == codecPayload) {
fmtpFound = true
var newFmtpConfig = fmtp.config
if (!fmtp.config.contains("x-google-start-bitrate")) {
newFmtpConfig = "$newFmtpConfig;x-google-start-bitrate=${(trackBr.maxBitrate * startBitrateForSVC).roundToLong()}"
}
if (!fmtp.config.contains("x-google-max-bitrate")) {
newFmtpConfig = "$newFmtpConfig;x-google-max-bitrate=${trackBr.maxBitrate}"
}
if (fmtp.config != newFmtpConfig) {
attribute.value = "${fmtp.payload} $newFmtpConfig"
break
}
}
}
if (!fmtpFound) {
media.addAttribute(
SdpFmtp(
payload = codecPayload,
config = "x-google-start-bitrate=${trackBr.maxBitrate * startBitrateForSVC};" +
"x-google-max-bitrate=${trackBr.maxBitrate}",
).toAttributeField(),
)
}
}
}
internal fun isSVCCodec(codec: String?): Boolean {
return codec != null &&
("av1".equals(codec, ignoreCase = true) ||
"vp9".equals(codec, ignoreCase = true))
}
internal data class TrackBitrateInfo(
val codec: String,
val maxBitrate: Long,
)
sealed class TrackBitrateInfoKey {
data class Cid(val value: String) : TrackBitrateInfoKey()
data class Transceiver(val value: RtpTransceiver) : TrackBitrateInfoKey()
}
... ...
... ... @@ -43,6 +43,7 @@ import livekit.LivekitRtc.JoinResponse
import livekit.LivekitRtc.ReconnectResponse
import org.webrtc.*
import org.webrtc.PeerConnection.RTCConfiguration
import org.webrtc.RtpTransceiver.RtpTransceiverInit
import java.net.ConnectException
import java.nio.ByteBuffer
import javax.inject.Inject
... ... @@ -278,6 +279,13 @@ internal constructor(
}
}
internal fun createSenderTransceiver(
rtcTrack: MediaStreamTrack,
transInit: RtpTransceiverInit,
): RtpTransceiver? {
return publisher.peerConnection.addTransceiver(rtcTrack, transInit)
}
fun updateSubscriptionPermissions(
allParticipants: Boolean,
participantTrackPermissions: List<ParticipantTrackPermission>,
... ...
... ... @@ -152,8 +152,15 @@ constructor(
* significantly reducing publishing CPU and bandwidth usage.
*
* Defaults to false.
*
* Will be enabled if SVC codecs (i.e. VP9/AV1) are used. Multi-codec simulcast
* requires dynacast.
*/
var dynacast: Boolean = false
var dynacast: Boolean
get() = localParticipant.dynacast
set(value) {
localParticipant.dynacast = value
}
/**
* Default options to use when creating an audio track.
... ... @@ -175,7 +182,7 @@ constructor(
*/
var videoTrackPublishDefaults: VideoTrackPublishDefaults by defaultsManager::videoTrackPublishDefaults
val localParticipant: LocalParticipant = localParticipantFactory.create(dynacast = dynacast).apply {
val localParticipant: LocalParticipant = localParticipantFactory.create(dynacast = false).apply {
internalListener = this@Room
}
... ...
... ... @@ -34,7 +34,6 @@ import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import livekit.LivekitModels
import livekit.LivekitModels.Encryption
import livekit.LivekitRtc
import livekit.LivekitRtc.JoinResponse
import livekit.LivekitRtc.ReconnectResponse
... ... @@ -95,6 +94,7 @@ constructor(
private var pongJob: Job? = null
private var pingTimeoutDurationMillis: Long = 0
private var pingIntervalDurationMillis: Long = 0
private var rtt: Long = 0
var connectionState: ConnectionState = ConnectionState.DISCONNECTED
... ... @@ -491,6 +491,28 @@ constructor(
sendRequest(request)
}
fun sendPing(): Long {
val time = Date().time
sendRequest(
with(LivekitRtc.SignalRequest.newBuilder()) {
ping = time
build()
},
)
sendRequest(
with(LivekitRtc.SignalRequest.newBuilder()) {
pingReq = with(LivekitRtc.Ping.newBuilder()) {
rtt = this@SignalClient.rtt
timestamp = time
build()
}
build()
},
)
return time
}
private fun sendRequest(request: LivekitRtc.SignalRequest) {
val skipQueue = skipQueueTypes.contains(request.messageCase)
... ... @@ -647,7 +669,8 @@ constructor(
}
LivekitRtc.SignalResponse.MessageCase.PONG_RESP -> {
// TODO
rtt = Date().time - response.pongResp.lastPingTimestamp
resetPingTimeout()
}
LivekitRtc.SignalResponse.MessageCase.RECONNECT -> {
... ... @@ -671,13 +694,7 @@ constructor(
pingJob = coroutineScope.launch {
while (true) {
delay(pingIntervalDurationMillis)
val pingTimestamp = Date().time
val pingRequest = LivekitRtc.SignalRequest.newBuilder()
.setPing(pingTimestamp)
.build()
LKLog.v { "Sending ping: $pingTimestamp" }
sendRequest(pingRequest)
val pingTimestamp = sendPing()
startPingTimeout(pingTimestamp)
}
}
... ...
... ... @@ -29,15 +29,21 @@ import io.livekit.android.events.ParticipantEvent
import io.livekit.android.room.ConnectionState
import io.livekit.android.room.DefaultsManager
import io.livekit.android.room.RTCEngine
import io.livekit.android.room.TrackBitrateInfo
import io.livekit.android.room.isSVCCodec
import io.livekit.android.room.track.*
import io.livekit.android.room.util.EncodingUtils
import io.livekit.android.util.LKLog
import io.livekit.android.webrtc.createStatsGetter
import io.livekit.android.webrtc.sortVideoCodecPreferences
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.launch
import livekit.LivekitModels
import livekit.LivekitRtc
import livekit.LivekitRtc.AddTrackRequest
import livekit.LivekitRtc.SimulcastCodec
import org.webrtc.*
import org.webrtc.RtpCapabilities.CodecCapability
import org.webrtc.RtpTransceiver.RtpTransceiverInit
import javax.inject.Named
import kotlin.math.max
... ... @@ -45,7 +51,7 @@ class LocalParticipant
@AssistedInject
internal constructor(
@Assisted
private val dynacast: Boolean,
internal var dynacast: Boolean,
internal val engine: RTCEngine,
private val peerConnectionFactory: PeerConnectionFactory,
private val context: Context,
... ... @@ -180,7 +186,6 @@ internal constructor(
source: Track.Source,
enabled: Boolean,
mediaProjectionPermissionResultData: Intent? = null,
) {
val pub = getTrackPublication(source)
if (enabled) {
... ... @@ -267,9 +272,23 @@ internal constructor(
options: VideoTrackPublishOptions = VideoTrackPublishOptions(null, videoTrackPublishDefaults),
publishListener: PublishListener? = null,
) {
val isSVC = isSVCCodec(options.videoCodec)
@Suppress("NAME_SHADOWING") var options = options
if (isSVC) {
dynacast = true
// Ensure backup codec and scalability for svc codecs.
if (options.backupCodec == null) {
options = options.copy(backupCodec = BackupVideoCodec())
}
if (options.scalabilityMode == null) {
options = options.copy(scalabilityMode = "L3T3_KEY")
}
}
val encodings = computeVideoEncodings(track.dimensions, options)
val videoLayers =
EncodingUtils.videoLayersFromEncodings(track.dimensions.width, track.dimensions.height, encodings)
EncodingUtils.videoLayersFromEncodings(track.dimensions.width, track.dimensions.height, encodings, isSVC)
publishTrackImpl(
track = track,
... ... @@ -283,6 +302,24 @@ internal constructor(
LivekitModels.TrackSource.CAMERA
}
addAllLayers(videoLayers)
addSimulcastCodecs(
with(SimulcastCodec.newBuilder()) {
codec = options.videoCodec
cid = track.rtcTrack.id()
build()
},
)
// set up backup codec
if (options.backupCodec?.codec != null && options.videoCodec != options.backupCodec?.codec) {
addSimulcastCodecs(
with(SimulcastCodec.newBuilder()) {
codec = options.backupCodec!!.codec
cid = ""
build()
},
)
}
},
encodings = encodings,
publishListener = publishListener,
... ... @@ -295,17 +332,21 @@ internal constructor(
private suspend fun publishTrackImpl(
track: Track,
options: TrackPublishOptions,
requestConfig: LivekitRtc.AddTrackRequest.Builder.() -> Unit,
requestConfig: AddTrackRequest.Builder.() -> Unit,
encodings: List<RtpParameters.Encoding> = emptyList(),
publishListener: PublishListener? = null,
): Boolean {
@Suppress("NAME_SHADOWING") var options = options
@Suppress("NAME_SHADOWING") var encodings = encodings
if (localTrackPublications.any { it.track == track }) {
publishListener?.onPublishFailure(TrackException.PublishException("Track has already been published"))
return false
}
val cid = track.rtcTrack.id()
val builder = LivekitRtc.AddTrackRequest.newBuilder().apply {
val builder = AddTrackRequest.newBuilder().apply {
this.requestConfig()
}
val trackInfo = engine.addTrack(
... ... @@ -314,12 +355,30 @@ internal constructor(
kind = track.kind.toProto(),
builder = builder,
)
val transInit = RtpTransceiver.RtpTransceiverInit(
if (options is VideoTrackPublishOptions) {
// server might not support the codec the client has requested, in that case, fallback
// to a supported codec
val primaryCodecMime = trackInfo.codecsList.firstOrNull()?.mimeType
if (primaryCodecMime != null) {
val updatedCodec = primaryCodecMime.mimeTypeToVideoCodec()
if (updatedCodec != options.videoCodec) {
LKLog.d { "falling back to server selected codec: $updatedCodec" }
}
options = options.copy(videoCodec = updatedCodec)
// recompute encodings since bitrates/etc could have changed
encodings = computeVideoEncodings((track as LocalVideoTrack).dimensions, options)
}
}
val transInit = RtpTransceiverInit(
RtpTransceiver.RtpTransceiverDirection.SEND_ONLY,
listOf(this.sid),
encodings,
)
val transceiver = engine.publisher.peerConnection.addTransceiver(track.rtcTrack, transInit)
val transceiver = engine.createSenderTransceiver(track.rtcTrack, transInit)
when (track) {
is LocalVideoTrack -> track.transceiver = transceiver
... ... @@ -336,43 +395,23 @@ internal constructor(
track.statsGetter = createStatsGetter(engine.publisher.peerConnection, transceiver.sender)
if (options is VideoTrackPublishOptions && options.videoCodec != null) {
val targetCodec = options.videoCodec.lowercase()
val capabilities = capabilitiesGetter(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO)
LKLog.v { "capabilities:" }
capabilities.codecs.forEach { codec ->
LKLog.v { "codec: ${codec.name}, ${codec.kind}, ${codec.mimeType}, ${codec.parameters}, ${codec.preferredPayloadType}" }
// Handle trackBitrates
if (encodings.isNotEmpty()) {
if (options is VideoTrackPublishOptions && isSVCCodec(options.videoCodec) && encodings.firstOrNull()?.maxBitrateBps != null) {
engine.publisher.registerTrackBitrateInfo(
cid = cid,
TrackBitrateInfo(
codec = options.videoCodec,
maxBitrate = (encodings.first().maxBitrateBps?.div(1000) ?: 0).toLong(),
),
)
}
}
val matched = mutableListOf<CodecCapability>()
val partialMatched = mutableListOf<CodecCapability>()
val unmatched = mutableListOf<CodecCapability>()
for (codec in capabilities.codecs) {
val mimeType = codec.mimeType.lowercase()
if (mimeType == "audio/opus") {
matched.add(codec)
continue
}
if (mimeType != "video/$targetCodec") {
unmatched.add(codec)
continue
}
// for h264 codecs that have sdpFmtpLine available, use only if the
// profile-level-id is 42e01f for cross-browser compatibility
if (targetCodec == "h264") {
if (codec.parameters["profile-level-id"] == "42e01f") {
matched.add(codec)
} else {
partialMatched.add(codec)
}
continue
} else {
matched.add(codec)
}
}
transceiver.setCodecPreferences(matched.plus(partialMatched).plus(unmatched))
// Set preferred video codec order
if (options is VideoTrackPublishOptions) {
transceiver.sortVideoCodecPreferences(options.videoCodec, capabilitiesGetter)
(track as LocalVideoTrack).codec = options.videoCodec
}
val publication = LocalTrackPublication(
... ... @@ -397,6 +436,7 @@ internal constructor(
val (width, height) = dimensions
var encoding = options.videoEncoding
val simulcast = options.simulcast
val scalabilityMode = options.scalabilityMode
if ((encoding == null && !simulcast) || width == 0 || height == 0) {
return emptyList()
... ... @@ -408,7 +448,13 @@ internal constructor(
}
val encodings = mutableListOf<RtpParameters.Encoding>()
if (simulcast) {
if (scalabilityMode != null && isSVCCodec(options.videoCodec)) {
val rtpEncoding = encoding.toRtpEncoding()
rtpEncoding.scalabilityMode = scalabilityMode
encodings.add(rtpEncoding)
return encodings
} else if (simulcast) {
val presets = EncodingUtils.presetsForResolution(width, height)
val midPreset = presets[1]
val lowPreset = presets[0]
... ... @@ -454,6 +500,27 @@ internal constructor(
return encodings
}
private fun computeTrackBackupOptionsAndEncodings(
track: LocalVideoTrack,
videoCodec: VideoCodec,
options: VideoTrackPublishOptions,
): Pair<VideoTrackPublishOptions, List<RtpParameters.Encoding>>? {
if (!options.hasBackupCodec()) {
return null
}
if (videoCodec.codecName != options.backupCodec?.codec) {
LKLog.w { "Server requested different codec than specified backup. server: $videoCodec, specified: ${options.backupCodec?.codec}" }
}
val backupOptions = options.copy(
videoCodec = videoCodec.codecName,
videoEncoding = options.backupCodec!!.encoding,
)
val backupEncodings = computeVideoEncodings(track.dimensions, backupOptions)
return backupOptions to backupEncodings
}
/**
* Control who can subscribe to LocalParticipant's published tracks.
*
... ... @@ -590,30 +657,87 @@ internal constructor(
}
val trackSid = subscribedQualityUpdate.trackSid
val subscribedCodecs = subscribedQualityUpdate.subscribedCodecsList
val qualities = subscribedQualityUpdate.subscribedQualitiesList
val pub = tracks[trackSid] ?: return
val pub = tracks[trackSid] as? LocalTrackPublication ?: return
val track = pub.track as? LocalVideoTrack ?: return
val sender = track.transceiver?.sender ?: return
val parameters = sender.parameters ?: return
val encodings = parameters.encodings ?: return
var hasChanged = false
for (quality in qualities) {
val rid = EncodingUtils.ridForVideoQuality(quality.quality) ?: continue
val encoding = encodings.firstOrNull { it.rid == rid }
// use low quality layer settings for non-simulcasted streams
?: encodings.takeIf { it.size == 1 && quality.quality == LivekitModels.VideoQuality.LOW }?.first()
?: continue
if (encoding.active != quality.enabled) {
hasChanged = true
encoding.active = quality.enabled
LKLog.v { "setting layer ${quality.quality} to ${quality.enabled}" }
val options = pub.options as? VideoTrackPublishOptions ?: return
if (subscribedCodecs.isNotEmpty()) {
val newCodecs = track.setPublishingCodecs(subscribedCodecs)
for (codec in newCodecs) {
if (isBackupCodec(codec.codecName)) {
LKLog.d { "publish $codec for $trackSid" }
publishAdditionalCodecForTrack(track, codec, options)
}
}
}
if (qualities.isNotEmpty()) {
track.setPublishingLayers(qualities)
}
}
if (hasChanged) {
sender.parameters = parameters
private fun publishAdditionalCodecForTrack(track: LocalVideoTrack, codec: VideoCodec, options: VideoTrackPublishOptions) {
val existingPublication = tracks[track.sid] ?: run {
LKLog.w { "attempting to publish additional codec for non-published track?!" }
return
}
val result = computeTrackBackupOptionsAndEncodings(track, codec, options) ?: run {
LKLog.i { "backup codec has been disabled, ignoring request to add additional codec for track" }
return
}
val (newOptions, newEncodings) = result
val simulcastTrack = track.addSimulcastTrack(codec, newEncodings)
val transceiverInit = RtpTransceiverInit(
RtpTransceiver.RtpTransceiverDirection.SEND_ONLY,
listOf(this.sid),
newEncodings,
)
scope.launch {
val transceiver = engine.createSenderTransceiver(track.rtcTrack, transceiverInit)
if (transceiver == null) {
LKLog.w { "couldn't create new transceiver! $codec" }
return@launch
}
transceiver.sortVideoCodecPreferences(newOptions.videoCodec, capabilitiesGetter)
simulcastTrack.sender = transceiver.sender
val trackRequest = AddTrackRequest.newBuilder().apply {
cid = transceiver.sender.id()
sid = existingPublication.sid
type = track.kind.toProto()
muted = !track.enabled
source = existingPublication.source.toProto()
addSimulcastCodecs(
with(SimulcastCodec.newBuilder()) {
this@with.codec = codec.codecName
this@with.cid = transceiver.sender.id()
build()
},
)
addAllLayers(
EncodingUtils.videoLayersFromEncodings(
track.dimensions.width,
track.dimensions.height,
newEncodings,
isSVCCodec(codec.codecName),
),
)
}
val trackInfo = engine.addTrack(
cid = simulcastTrack.rtcTrack.id(),
name = existingPublication.name,
kind = existingPublication.kind.toProto(),
builder = trackRequest,
)
engine.negotiatePublisher()
LKLog.d { "published $codec for track ${track.sid}, $trackInfo" }
}
}
... ... @@ -713,21 +837,37 @@ abstract class BaseVideoTrackPublishOptions {
/**
* The video codec to use if available.
*
* Defaults to VP8.
*
* @see [VideoCodec]
*/
abstract val videoCodec: String?
abstract val videoCodec: String
/**
* scalability mode for svc codecs, defaults to 'L3T3'.
* for svc codecs, simulcast is disabled.
*/
abstract val scalabilityMode: String?
abstract val backupCodec: BackupVideoCodec?
}
data class VideoTrackPublishDefaults(
override val videoEncoding: VideoEncoding? = null,
override val simulcast: Boolean = true,
override val videoCodec: String? = null,
override val videoCodec: String = VideoCodec.VP8.codecName,
override val scalabilityMode: String? = null,
override val backupCodec: BackupVideoCodec? = null,
) : BaseVideoTrackPublishOptions()
data class VideoTrackPublishOptions(
override val name: String? = null,
override val videoEncoding: VideoEncoding? = null,
override val simulcast: Boolean = true,
override val videoCodec: String? = null,
override val videoCodec: String = VideoCodec.VP8.codecName,
override val scalabilityMode: String? = null,
override val backupCodec: BackupVideoCodec? = null,
) : BaseVideoTrackPublishOptions(), TrackPublishOptions {
constructor(
name: String? = null,
... ... @@ -737,9 +877,28 @@ data class VideoTrackPublishOptions(
base.videoEncoding,
base.simulcast,
base.videoCodec,
base.scalabilityMode,
base.backupCodec,
)
fun createBackupOptions(): VideoTrackPublishOptions? {
return if (hasBackupCodec()) {
copy(
videoCodec = backupCodec!!.codec,
videoEncoding = backupCodec.encoding,
)
} else {
null
}
}
}
data class BackupVideoCodec(
val codec: String = "vp8",
val encoding: VideoEncoding? = null,
val simulcast: Boolean = true,
)
abstract class BaseAudioTrackPublishOptions {
abstract val audioBitrate: Int?
... ... @@ -813,14 +972,9 @@ data class ParticipantTrackPermission(
}
}
sealed class PublishRecord() {
data class AudioTrackPublishRecord(
val track: LocalAudioTrack,
val options: AudioTrackPublishOptions,
)
data class VideoTrackPublishRecord(
val track: LocalVideoTrack,
val options: VideoTrackPublishOptions,
)
internal fun VideoTrackPublishOptions.hasBackupCodec(): Boolean {
return backupCodec?.codec != null && videoCodec != backupCodec.codec
}
private val backupCodecs = listOf(VideoCodec.VP8.codecName, VideoCodec.H264.codecName)
private fun isBackupCodec(codecName: String) = backupCodecs.contains(codecName)
... ...
... ... @@ -173,6 +173,8 @@ open class Participant(
get() = participantInfo != null
/**
* Maps track sids to their track publications.
*
* Changes can be observed by using [io.livekit.android.util.flow]
*/
@FlowObservable
... ...
/*
* Copyright 2023 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.livekit.android.room.participant
fun String.mimeTypeToVideoCodec(): String {
return split("/")[1].lowercase()
}
... ...
... ... @@ -26,16 +26,33 @@ import dagger.assisted.AssistedInject
import io.livekit.android.memory.CloseableManager
import io.livekit.android.memory.SurfaceTextureHelperCloser
import io.livekit.android.room.DefaultsManager
import io.livekit.android.room.track.video.*
import io.livekit.android.room.track.video.CameraCapturerUtils
import io.livekit.android.room.track.video.CameraCapturerUtils.createCameraEnumerator
import io.livekit.android.room.track.video.CameraCapturerUtils.findCamera
import io.livekit.android.room.track.video.CameraCapturerUtils.getCameraPosition
import io.livekit.android.room.track.video.CameraCapturerWithSize
import io.livekit.android.room.track.video.VideoCapturerWithSize
import io.livekit.android.room.util.EncodingUtils
import io.livekit.android.util.FlowObservable
import io.livekit.android.util.LKLog
import io.livekit.android.util.flowDelegate
import org.webrtc.*
import livekit.LivekitModels
import livekit.LivekitModels.VideoQuality
import livekit.LivekitRtc
import livekit.LivekitRtc.SubscribedCodec
import org.webrtc.CameraVideoCapturer
import org.webrtc.CameraVideoCapturer.CameraEventsHandler
import java.util.*
import org.webrtc.EglBase
import org.webrtc.MediaStreamTrack
import org.webrtc.PeerConnectionFactory
import org.webrtc.RtpParameters
import org.webrtc.RtpSender
import org.webrtc.RtpTransceiver
import org.webrtc.SurfaceTextureHelper
import org.webrtc.VideoCapturer
import org.webrtc.VideoProcessor
import org.webrtc.VideoSource
import java.util.UUID
/**
* A representation of a local video track (generally input coming from camera or screen).
... ... @@ -60,6 +77,10 @@ constructor(
override var rtcTrack: org.webrtc.VideoTrack = rtcTrack
internal set
internal var codec: String? = null
private var subscribedCodecs: List<SubscribedCodec>? = null
private val simulcastCodecs = mutableMapOf<VideoCodec, SimulcastTrackInfo>()
@FlowObservable
@get:FlowObservable
var options: LocalVideoTrackOptions by flowDelegate(options)
... ... @@ -69,7 +90,7 @@ constructor(
(capturer as? VideoCapturerWithSize)?.let { capturerWithSize ->
val size = capturerWithSize.findCaptureFormat(
options.captureParams.width,
options.captureParams.height
options.captureParams.height,
)
return Dimensions(size.width, size.height)
}
... ... @@ -86,7 +107,7 @@ constructor(
capturer.startCapture(
options.captureParams.width,
options.captureParams.height,
options.captureParams.maxFps
options.captureParams.maxFps,
)
}
... ... @@ -140,7 +161,7 @@ constructor(
fun updateCameraOptions() {
val newOptions = options.copy(
deviceId = targetDeviceId,
position = enumerator.getCameraPosition(targetDeviceId)
position = enumerator.getCameraPosition(targetDeviceId),
)
options = newOptions
}
... ... @@ -150,30 +171,32 @@ constructor(
// For cameras we control, wait until the first frame to ensure everything is okay.
if (cameraCapturer is CameraCapturerWithSize) {
cameraCapturer.cameraEventsDispatchHandler
.registerHandler(object : CameraEventsHandler {
override fun onFirstFrameAvailable() {
updateCameraOptions()
cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
}
override fun onCameraError(p0: String?) {
cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
}
override fun onCameraDisconnected() {
cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
}
override fun onCameraFreezed(p0: String?) {
}
override fun onCameraOpening(p0: String?) {
}
override fun onCameraClosed() {
cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
}
})
.registerHandler(
object : CameraEventsHandler {
override fun onFirstFrameAvailable() {
updateCameraOptions()
cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
}
override fun onCameraError(p0: String?) {
cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
}
override fun onCameraDisconnected() {
cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
}
override fun onCameraFreezed(p0: String?) {
}
override fun onCameraOpening(p0: String?) {
}
override fun onCameraClosed() {
cameraCapturer.cameraEventsDispatchHandler.unregisterHandler(this)
}
},
)
} else {
updateCameraOptions()
}
... ... @@ -216,7 +239,7 @@ constructor(
name,
options,
eglBase,
trackFactory
trackFactory,
)
// migrate video sinks to the new track
... ... @@ -233,6 +256,122 @@ constructor(
sender?.setTrack(newTrack.rtcTrack, true)
}
internal fun setPublishingLayers(
qualities: List<LivekitRtc.SubscribedQuality>,
) {
val sender = transceiver?.sender ?: return
setPublishingLayersForSender(sender, qualities)
}
private fun setPublishingLayersForSender(
sender: RtpSender,
qualities: List<LivekitRtc.SubscribedQuality>,
) {
val parameters = sender.parameters ?: return
val encodings = parameters.encodings ?: return
var hasChanged = false
if (encodings.firstOrNull()?.scalabilityMode != null) {
val encoding = encodings.first()
var maxQuality = VideoQuality.OFF
for (quality in qualities) {
if (quality.enabled && (maxQuality == VideoQuality.OFF || quality.quality.number > maxQuality.number)) {
maxQuality = quality.quality
}
}
if (maxQuality == VideoQuality.OFF) {
if (encoding.active) {
LKLog.v { "setting svc track to disabled" }
encoding.active = false
hasChanged = true
}
} else if (!encoding.active) {
LKLog.v { "setting svc track to enabled" }
encoding.active = true
hasChanged = true
}
} else {
// simulcast dynacast encodings
for (quality in qualities) {
val rid = EncodingUtils.ridForVideoQuality(quality.quality) ?: continue
val encoding = encodings.firstOrNull { it.rid == rid }
// use low quality layer settings for non-simulcasted streams
?: encodings.takeIf { it.size == 1 && quality.quality == LivekitModels.VideoQuality.LOW }?.first()
?: continue
if (encoding.active != quality.enabled) {
hasChanged = true
encoding.active = quality.enabled
LKLog.v { "setting layer ${quality.quality} to ${quality.enabled}" }
}
}
}
if (hasChanged) {
// This refeshes the native code with the new information
sender.parameters = sender.parameters
}
}
fun setPublishingCodecs(codecs: List<SubscribedCodec>): List<VideoCodec> {
LKLog.v { "setting publishing codecs: $codecs" }
// only enable simulcast codec for preferred codec set
if (this.codec == null && codecs.isNotEmpty()) {
setPublishingLayers(codecs.first().qualitiesList)
return emptyList()
}
this.subscribedCodecs = codecs
val newCodecs = mutableListOf<VideoCodec>()
for (codec in codecs) {
if (this.codec == codec.codec) {
setPublishingLayers(codec.qualitiesList)
} else {
val videoCodec = try {
VideoCodec.fromCodecName(codec.codec)
} catch (e: Exception) {
LKLog.w { "unknown publishing codec ${codec.codec}!" }
continue
}
LKLog.d { "try setPublishingCodec for ${codec.codec}" }
val simulcastInfo = this.simulcastCodecs[videoCodec]
if (simulcastInfo?.sender == null) {
for (q in codec.qualitiesList) {
if (q.enabled) {
newCodecs.add(videoCodec)
break
}
}
} else {
LKLog.d { "try setPublishingLayersForSender ${codec.codec}" }
setPublishingLayersForSender(
simulcastInfo.sender!!,
codec.qualitiesList,
)
}
}
}
return newCodecs
}
internal fun addSimulcastTrack(codec: VideoCodec, encodings: List<RtpParameters.Encoding>): SimulcastTrackInfo {
if (this.simulcastCodecs.containsKey(codec)) {
throw IllegalStateException("$codec already added!")
}
val simulcastTrackInfo = SimulcastTrackInfo(
codec = codec.codecName,
rtcTrack = rtcTrack,
encodings = encodings,
)
simulcastCodecs[codec] = simulcastTrackInfo
return simulcastTrackInfo
}
@AssistedFactory
interface Factory {
fun create(
... ... @@ -262,7 +401,7 @@ constructor(
capturer.initialize(
surfaceTextureHelper,
context,
source.capturerObserver
source.capturerObserver,
)
val rtcTrack = peerConnectionFactory.createVideoTrack(UUID.randomUUID().toString(), source)
... ... @@ -271,12 +410,12 @@ constructor(
source = source,
options = options,
name = name,
rtcTrack = rtcTrack
rtcTrack = rtcTrack,
)
track.closeableManager.registerResource(
rtcTrack,
SurfaceTextureHelperCloser(surfaceTextureHelper)
SurfaceTextureHelperCloser(surfaceTextureHelper),
)
return track
}
... ... @@ -303,7 +442,7 @@ constructor(
capturer.initialize(
surfaceTextureHelper,
context,
source.capturerObserver
source.capturerObserver,
)
val rtcTrack = peerConnectionFactory.createVideoTrack(UUID.randomUUID().toString(), source)
... ... @@ -312,15 +451,22 @@ constructor(
source = source,
options = newOptions,
name = name,
rtcTrack = rtcTrack
rtcTrack = rtcTrack,
)
track.closeableManager.registerResource(
rtcTrack,
SurfaceTextureHelperCloser(surfaceTextureHelper)
SurfaceTextureHelperCloser(surfaceTextureHelper),
)
return track
}
}
}
internal data class SimulcastTrackInfo(
var codec: String,
var rtcTrack: MediaStreamTrack,
var sender: RtpSender? = null,
var encodings: List<RtpParameters.Encoding>? = null,
)
... ...
... ... @@ -63,6 +63,14 @@ data class VideoEncoding(
enum class VideoCodec(val codecName: String) {
VP8("vp8"),
H264("h264"),
VP9("vp9"),
AV1("av1");
companion object {
fun fromCodecName(codecName: String): VideoCodec {
return VideoCodec.values().first { it.codecName.equals(codecName, ignoreCase = true) }
}
}
}
enum class CameraPosition {
... ...
/*
* Copyright 2023 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.livekit.android.room.track.video
data class ScalabilityMode(val spatial: Int, val temporal: Int, val suffix: String) {
companion object {
private val REGEX = """L(\d)T(\d)(h|_KEY|_KEY_SHIFT)?""".toRegex()
fun parseFromString(mode: String): ScalabilityMode {
val match = REGEX.matchEntire(mode) ?: throw IllegalArgumentException("can't parse scalability mode: $mode")
val (spatial, temporal, suffix) = match.destructured
return ScalabilityMode(spatial.toInt(), temporal.toInt(), suffix)
}
}
}
... ...
... ... @@ -72,7 +72,7 @@ open class CoroutineSdpObserver : SdpObserver {
setOutcome = Either.Right(message)
}
suspend fun awaitCreate() = suspendCoroutine<Either<SessionDescription, String?>> { cont ->
suspend fun awaitCreate() = suspendCoroutine { cont ->
val curOutcome = createOutcome
if (curOutcome != null) {
cont.resume(curOutcome)
... ... @@ -81,7 +81,7 @@ open class CoroutineSdpObserver : SdpObserver {
}
}
suspend fun awaitSet() = suspendCoroutine<Either<Unit, String?>> { cont ->
suspend fun awaitSet() = suspendCoroutine { cont ->
val curOutcome = setOutcome
if (curOutcome != null) {
cont.resume(curOutcome)
... ...
... ... @@ -20,11 +20,15 @@ import io.livekit.android.room.track.VideoEncoding
import io.livekit.android.room.track.VideoPreset
import io.livekit.android.room.track.VideoPreset169
import io.livekit.android.room.track.VideoPreset43
import io.livekit.android.room.track.video.ScalabilityMode
import livekit.LivekitModels
import org.webrtc.RtpParameters
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.max
import kotlin.math.min
import kotlin.math.pow
import kotlin.math.roundToInt
internal object EncodingUtils {
... ... @@ -83,6 +87,7 @@ internal object EncodingUtils {
trackWidth: Int,
trackHeight: Int,
encodings: List<RtpParameters.Encoding>,
isSVC: Boolean,
): List<LivekitModels.VideoLayer> {
return if (encodings.isEmpty()) {
listOf(
... ... @@ -94,6 +99,19 @@ internal object EncodingUtils {
ssrc = 0
}.build(),
)
} else if (isSVC) {
val encodingSM = encodings.first().scalabilityMode!!
val scalabilityMode = ScalabilityMode.parseFromString(encodingSM)
val maxBitrate = encodings.first().maxBitrateBps ?: 0
(0 until scalabilityMode.spatial).map { index ->
LivekitModels.VideoLayer.newBuilder().apply {
width = ceil(trackWidth / (2f.pow(index))).roundToInt()
height = ceil(trackHeight / (2f.pow(index))).roundToInt()
quality = LivekitModels.VideoQuality.forNumber(LivekitModels.VideoQuality.HIGH.number - index)
bitrate = ceil(maxBitrate / 3f.pow(index)).roundToInt()
ssrc = 0
}.build()
}
} else {
encodings.map { encoding ->
val scaleDownBy = encoding.scaleResolutionDownBy ?: 1.0
... ...
... ... @@ -17,6 +17,6 @@
package io.livekit.android.util
sealed class Either<out A, out B> {
class Left<A>(val value: A) : Either<A, Nothing>()
class Right<B>(val value: B) : Either<Nothing, B>()
class Left<out A>(val value: A) : Either<A, Nothing>()
class Right<out B>(val value: B) : Either<Nothing, B>()
}
... ...
... ... @@ -23,7 +23,7 @@ import org.webrtc.VideoDecoder
import org.webrtc.VideoDecoderFactory
import org.webrtc.WrappedVideoDecoderFactory
class CustomVideoDecoderFactory(
open class CustomVideoDecoderFactory(
sharedContext: EglBase.Context?,
private var forceSWCodec: Boolean = false,
private var forceSWCodecs: List<String> = listOf("VP9"),
... ...
... ... @@ -22,7 +22,7 @@ import org.webrtc.VideoCodecInfo
import org.webrtc.VideoEncoder
import org.webrtc.VideoEncoderFactory
class CustomVideoEncoderFactory(
open class CustomVideoEncoderFactory(
sharedContext: EglBase.Context?,
enableIntelVp8Encoder: Boolean,
enableH264HighProfile: Boolean,
... ...
/*
* Copyright 2023 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.livekit.android.webrtc
import android.gov.nist.javax.sdp.fields.AttributeField
import android.javax.sdp.MediaDescription
import io.livekit.android.util.LKLog
data class SdpRtp(val payload: Long, val codec: String, val rate: Long?, val encoding: String?)
fun MediaDescription.getRtps(): List<Pair<AttributeField, SdpRtp>> {
return getAttributes(true)
.filterIsInstance<AttributeField>()
.filter { it.attribute.name == "rtpmap" }
.mapNotNull {
val rtp = tryParseRtp(it.value)
if (rtp == null) {
LKLog.w { "could not parse rtpmap: ${it.encode()}" }
return@mapNotNull null
}
it to rtp
}
}
private val RTP = """(\d*) ([\w\-.]*)(?:\s*/(\d*)(?:\s*/(\S*))?)?""".toRegex()
fun tryParseRtp(string: String): SdpRtp? {
val match = RTP.matchEntire(string) ?: return null
val (payload, codec, rate, encoding) = match.destructured
return SdpRtp(payload.toLong(), codec, toOptionalLong(rate), toOptionalString(encoding))
}
data class SdpMsid(
/** holds the msid-id (and msid-appdata if available) */
val value: String,
)
fun MediaDescription.getMsid(): SdpMsid? {
val attribute = getAttribute("msid") ?: return null
return SdpMsid(attribute)
}
data class SdpFmtp(val payload: Long, val config: String) {
fun toAttributeField(): AttributeField {
return AttributeField().apply {
name = "fmtp"
value = "$payload $config"
}
}
}
fun MediaDescription.getFmtps(): List<Pair<AttributeField, SdpFmtp>> {
return getAttributes(true)
.filterIsInstance<AttributeField>()
.filter { it.attribute.name == "fmtp" }
.mapNotNull {
val fmtp = tryParseFmtp(it.value)
if (fmtp == null) {
LKLog.w { "could not parse fmtp: ${it.encode()}" }
return@mapNotNull null
}
it to fmtp
}
}
private val FMTP = """(\d*) ([\S| ]*)""".toRegex()
fun tryParseFmtp(string: String): SdpFmtp? {
val match = FMTP.matchEntire(string) ?: return null
val (payload, config) = match.destructured
return SdpFmtp(payload.toLong(), config)
}
data class SdpExt(val value: Long, val direction: String?, val encryptUri: String?, val uri: String, val config: String?) {
fun toAttributeField(): AttributeField {
return AttributeField().apply {
name = "extmap"
value = buildString {
append(this@SdpExt.value)
if (direction != null) {
append(" $direction")
}
if (encryptUri != null) {
append(" $encryptUri")
}
append(" $uri")
if (config != null) {
append(" $config")
}
}
}
}
}
fun MediaDescription.getExts(): List<Pair<AttributeField, SdpExt>> {
return getAttributes(true)
.filterIsInstance<AttributeField>()
.filter { it.attribute.name == "extmap" }
.mapNotNull {
val ext = tryParseExt(it.value)
if (ext == null) {
LKLog.w { "could not parse extmap: ${it.encode()}" }
return@mapNotNull null
}
it to ext
}
}
private val EXT = """(\d+)(?:/(\w+))?(?: (urn:ietf:params:rtp-hdrext:encrypt))? (\S*)(?: (\S*))?""".toRegex()
fun tryParseExt(string: String): SdpExt? {
val match = EXT.matchEntire(string) ?: return null
val (value, direction, encryptUri, uri, config) = match.destructured
return SdpExt(value.toLong(), toOptionalString(direction), toOptionalString(encryptUri), uri, toOptionalString(config))
}
fun toOptionalLong(str: String): Long? = if (str.isEmpty()) null else str.toLong()
fun toOptionalString(str: String): String? = str.ifEmpty { null }
... ...
/*
* Copyright 2023 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.livekit.android.webrtc
import io.livekit.android.dagger.CapabilitiesGetter
import io.livekit.android.util.LKLog
import org.webrtc.MediaStreamTrack
import org.webrtc.RtpCapabilities
import org.webrtc.RtpTransceiver
internal fun RtpTransceiver.sortVideoCodecPreferences(targetCodec: String, capabilitiesGetter: CapabilitiesGetter) {
val capabilities = capabilitiesGetter(MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO)
LKLog.v { "capabilities:" }
capabilities.codecs.forEach { codec ->
LKLog.v { "codec: ${codec.name}, ${codec.kind}, ${codec.mimeType}, ${codec.parameters}, ${codec.preferredPayloadType}" }
}
val matched = mutableListOf<RtpCapabilities.CodecCapability>()
val partialMatched = mutableListOf<RtpCapabilities.CodecCapability>()
val unmatched = mutableListOf<RtpCapabilities.CodecCapability>()
for (codec in capabilities.codecs) {
val mimeType = codec.mimeType.lowercase()
if (mimeType == "audio/opus") {
matched.add(codec)
continue
}
if (mimeType != "video/$targetCodec") {
unmatched.add(codec)
continue
}
// for h264 codecs that have sdpFmtpLine available, use only if the
// profile-level-id is 42e01f for cross-browser compatibility
if (targetCodec == "h264") {
if (codec.parameters["profile-level-id"] == "42e01f") {
matched.add(codec)
} else {
partialMatched.add(codec)
}
continue
} else {
matched.add(codec)
}
}
setCodecPreferences(matched.plus(partialMatched).plus(unmatched))
}
... ...
... ... @@ -18,9 +18,10 @@ package io.livekit.android.mock
import org.webrtc.VideoSink
import org.webrtc.VideoTrack
import java.util.UUID
class MockVideoStreamTrack(
val id: String = "id",
val id: String = UUID.randomUUID().toString(),
val kind: String = VIDEO_TRACK_KIND,
var enabled: Boolean = true,
var state: State = State.LIVE,
... ...
... ... @@ -23,6 +23,7 @@ import livekit.LivekitRtc
import okhttp3.Request
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import okio.ByteString
class MockWebSocketFactory : WebSocket.Factory {
/**
... ... @@ -67,4 +68,8 @@ class MockWebSocketFactory : WebSocket.Factory {
}
var onOpen: ((MockWebSocketFactory) -> Unit)? = null
fun receiveMessage(byteString: ByteString) {
listener.onMessage(ws, byteString)
}
}
... ...
... ... @@ -17,6 +17,7 @@
package io.livekit.android.mock.dagger
import android.content.Context
import android.javax.sdp.SdpFactory
import dagger.Module
import dagger.Provides
import io.livekit.android.dagger.CapabilitiesGetter
... ... @@ -59,4 +60,7 @@ object TestRTCModule {
@Provides
@Named(InjectionNames.OPTIONS_VIDEO_HW_ACCEL)
fun videoHwAccel() = true
@Provides
fun sdpFactory() = SdpFactory.getInstance()
}
... ...
/*
* Copyright 2023 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.livekit.android.room
import android.javax.sdp.MediaDescription
import android.javax.sdp.SdpFactory
import io.livekit.android.webrtc.JainSdpUtilsTest
import io.livekit.android.webrtc.getExts
import io.livekit.android.webrtc.getFmtps
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class SdpMungingTest {
@Test
fun ensureVideoDDExtensionForSVCTest() {
val sdp = SdpFactory.getInstance().createSessionDescription(NO_DD_DESCRIPTION)
val mediaDescription = sdp.getMediaDescriptions(true).filterIsInstance<MediaDescription>()[1]
ensureVideoDDExtensionForSVC(mediaDescription)
val exts = mediaDescription.getExts()
assertEquals(12, exts.size)
val ddExtPair = exts.find { it.second.value == 12L }
assertNotNull(ddExtPair)
val (_, ext) = ddExtPair!!
assertEquals(12, ext.value)
assertNull(ext.direction)
assertNull(ext.encryptUri)
assertEquals("https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension", ext.uri)
assertNull(ext.config)
}
@Test
fun ensureCodecBitratesTest() {
val sdp = SdpFactory.getInstance().createSessionDescription(JainSdpUtilsTest.DESCRIPTION)
val mediaDescription = sdp.getMediaDescriptions(true).filterIsInstance<MediaDescription>()[1]
ensureCodecBitrates(
mediaDescription,
mapOf(
TrackBitrateInfoKey.Cid("PA_Qwqk4y9fcD3G") to
TrackBitrateInfo(
"VP9",
1000000L,
),
),
)
val (_, vp9fmtp) = mediaDescription.getFmtps()
.filter { (_, fmtp) -> fmtp.payload == 98L }
.first()
assertEquals("profile-id=0;x-google-start-bitrate=700000;x-google-max-bitrate=1000000", vp9fmtp.config)
}
companion object {
const val NO_DD_DESCRIPTION = "v=0\n" +
"o=- 3682890773448528616 3 IN IP4 127.0.0.1\n" +
"s=-\n" +
"t=0 0\n" +
"a=group:BUNDLE 0 1\n" +
"a=extmap-allow-mixed\n" +
"a=msid-semantic: WMS PA_Qwqk4y9fcD3G\n" +
"m=application 24436 UDP/DTLS/SCTP webrtc-datachannel\n" +
"c=IN IP4 45.76.222.83\n" +
"a=candidate:1660983843 1 udp 2122194687 192.168.0.22 37324 typ host generation 0 network-id 5 network-cost 10\n" +
"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" +
"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" +
"a=candidate:689824452 1 tcp 1518083839 10.244.232.137 9 typ host tcptype active generation 0 network-id 3 network-cost 900\n" +
"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" +
"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" +
"a=ice-ufrag:+2SN\n" +
"a=ice-pwd:cdmp3JptAqdOA9VRHrNsdKE9\n" +
"a=ice-options:trickle renomination\n" +
"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" +
"a=setup:actpass\n" +
"a=mid:0\n" +
"a=sctp-port:5000\n" +
"a=max-message-size:262144\n" +
"m=video 9 UDP/TLS/RTP/SAVPF 96 97 127 103 104 105 39 40 98 99 106 107 108\n" +
"c=IN IP4 0.0.0.0\n" +
"a=rtcp:9 IN IP4 0.0.0.0\n" +
"a=ice-ufrag:+2SN\n" +
"a=ice-pwd:cdmp3JptAqdOA9VRHrNsdKE9\n" +
"a=ice-options:trickle renomination\n" +
"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" +
"a=setup:actpass\n" +
"a=mid:1\n" +
"a=extmap:1 urn:ietf:params:rtp-hdrext:toffset\n" +
"a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\n" +
"a=extmap:3 urn:3gpp:video-orientation\n" +
"a=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" +
"a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\n" +
"a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\n" +
"a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\n" +
"a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\n" +
"a=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\n" +
"a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\n" +
"a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\n" +
"a=sendonly\n" +
"a=msid:PA_Qwqk4y9fcD3G 42dd9185-4ea2-4bf1-b964-1dc0eb739c6c\n" +
"a=rtcp-mux\n" +
"a=rtcp-rsize\n" +
"a=rtpmap:98 VP9/90000\n" +
"a=rtcp-fb:98 goog-remb\n" +
"a=rtcp-fb:98 transport-cc\n" +
"a=rtcp-fb:98 ccm fir\n" +
"a=rtcp-fb:98 nack\n" +
"a=rtcp-fb:98 nack pli\n" +
"a=fmtp:98 profile-id=0\n" +
"a=rtpmap:96 VP8/90000\n" +
"a=rtcp-fb:96 goog-remb\n" +
"a=rtcp-fb:96 transport-cc\n" +
"a=rtcp-fb:96 ccm fir\n" +
"a=rtcp-fb:96 nack\n" +
"a=rtcp-fb:96 nack pli\n" +
"a=rtpmap:97 rtx/90000\n" +
"a=fmtp:97 apt=96\n" +
"a=rtpmap:127 H264/90000\n" +
"a=rtcp-fb:127 goog-remb\n" +
"a=rtcp-fb:127 transport-cc\n" +
"a=rtcp-fb:127 ccm fir\n" +
"a=rtcp-fb:127 nack\n" +
"a=rtcp-fb:127 nack pli\n" +
"a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\n" +
"a=rtpmap:103 rtx/90000\n" +
"a=fmtp:103 apt=127\n" +
"a=rtpmap:104 H265/90000\n" +
"a=rtcp-fb:104 goog-remb\n" +
"a=rtcp-fb:104 transport-cc\n" +
"a=rtcp-fb:104 ccm fir\n" +
"a=rtcp-fb:104 nack\n" +
"a=rtcp-fb:104 nack pli\n" +
"a=rtpmap:105 rtx/90000\n" +
"a=fmtp:105 apt=104\n" +
"a=rtpmap:39 AV1/90000\n" +
"a=rtcp-fb:39 goog-remb\n" +
"a=rtcp-fb:39 transport-cc\n" +
"a=rtcp-fb:39 ccm fir\n" +
"a=rtcp-fb:39 nack\n" +
"a=rtcp-fb:39 nack pli\n" +
"a=rtpmap:40 rtx/90000\n" +
"a=fmtp:40 apt=39\n" +
"a=rtpmap:99 rtx/90000\n" +
"a=fmtp:99 apt=98\n" +
"a=rtpmap:106 red/90000\n" +
"a=rtpmap:107 rtx/90000\n" +
"a=fmtp:107 apt=106\n" +
"a=rtpmap:108 ulpfec/90000\n" +
"a=rid:h send\n" +
"a=rid:q send\n" +
"a=simulcast:send h;q"
}
}
... ...
... ... @@ -23,6 +23,7 @@ import io.livekit.android.events.ParticipantEvent
import io.livekit.android.events.RoomEvent
import io.livekit.android.mock.MockAudioStreamTrack
import io.livekit.android.mock.MockEglBase
import io.livekit.android.mock.MockPeerConnection
import io.livekit.android.mock.MockVideoCapturer
import io.livekit.android.mock.MockVideoStreamTrack
import io.livekit.android.room.DefaultsManager
... ... @@ -31,10 +32,14 @@ import io.livekit.android.room.track.LocalAudioTrack
import io.livekit.android.room.track.LocalVideoTrack
import io.livekit.android.room.track.LocalVideoTrackOptions
import io.livekit.android.room.track.VideoCaptureParameter
import io.livekit.android.room.track.VideoCodec
import io.livekit.android.util.toOkioByteString
import io.livekit.android.util.toPBByteString
import kotlinx.coroutines.ExperimentalCoroutinesApi
import livekit.LivekitModels
import livekit.LivekitRtc
import livekit.LivekitRtc.SubscribedCodec
import livekit.LivekitRtc.SubscribedQuality
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
... ... @@ -200,4 +205,108 @@ class LocalParticipantMockE2ETest : MockE2ETest() {
},
)
}
@Test
fun publishSvcCodec() = runTest {
room.videoTrackPublishDefaults = room.videoTrackPublishDefaults.copy(
videoCodec = VideoCodec.VP9.codecName,
scalabilityMode = "L3T3",
backupCodec = BackupVideoCodec(codec = VideoCodec.VP8.codecName),
)
connect()
wsFactory.ws.clearRequests()
room.localParticipant.publishVideoTrack(track = createLocalTrack())
// Expect add track request to contain both primary and backup
assertEquals(1, wsFactory.ws.sentRequests.size)
val requestString = wsFactory.ws.sentRequests[0]
val signalRequest = LivekitRtc.SignalRequest.newBuilder()
.mergeFrom(requestString.toPBByteString())
.build()
assertTrue(signalRequest.hasAddTrack())
val addTrackRequest = signalRequest.addTrack
assertEquals(2, addTrackRequest.simulcastCodecsList.size)
val vp9Codec = addTrackRequest.simulcastCodecsList[0]
assertEquals("vp9", vp9Codec.codec)
val vp8Codec = addTrackRequest.simulcastCodecsList[1]
assertEquals("vp8", vp8Codec.codec)
val publisherConn = component.rtcEngine().publisher.peerConnection as MockPeerConnection
assertEquals(1, publisherConn.transceivers.size)
Mockito.verify(publisherConn.transceivers.first()).setCodecPreferences(
argThat { codecs ->
val preferredCodec = codecs.first()
return@argThat preferredCodec.name.lowercase() == "vp9"
},
)
// Ensure the newly subscribed vp8 codec gets added as a new transceiver.
wsFactory.receiveMessage(
with(LivekitRtc.SignalResponse.newBuilder()) {
subscribedQualityUpdate = with(LivekitRtc.SubscribedQualityUpdate.newBuilder()) {
trackSid = room.localParticipant.videoTracks.first().first.sid
addAllSubscribedCodecs(
listOf(
with(SubscribedCodec.newBuilder()) {
codec = "vp9"
addAllQualities(
listOf(
SubscribedQuality.newBuilder()
.setQuality(LivekitModels.VideoQuality.HIGH)
.setEnabled(true)
.build(),
SubscribedQuality.newBuilder()
.setQuality(LivekitModels.VideoQuality.MEDIUM)
.setEnabled(true)
.build(),
SubscribedQuality.newBuilder()
.setQuality(LivekitModels.VideoQuality.LOW)
.setEnabled(true)
.build(),
),
)
build()
},
with(SubscribedCodec.newBuilder()) {
codec = "vp8"
addAllQualities(
listOf(
SubscribedQuality.newBuilder()
.setQuality(LivekitModels.VideoQuality.HIGH)
.setEnabled(true)
.build(),
SubscribedQuality.newBuilder()
.setQuality(LivekitModels.VideoQuality.MEDIUM)
.setEnabled(true)
.build(),
SubscribedQuality.newBuilder()
.setQuality(LivekitModels.VideoQuality.LOW)
.setEnabled(true)
.build(),
),
)
build()
},
),
)
build()
}
build().toOkioByteString()
},
)
assertEquals(2, publisherConn.transceivers.size)
Mockito.verify(publisherConn.transceivers.last()).setCodecPreferences(
argThat { codecs ->
val preferredCodec = codecs.first()
return@argThat preferredCodec.name.lowercase() == "vp8"
},
)
}
}
... ...
/*
* Copyright 2023 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.livekit.android.room.track.video
import org.junit.Assert.assertEquals
import org.junit.Test
class ScalabilityModeTest {
@Test
fun testL1T3() {
val mode = ScalabilityMode.parseFromString("L1T3")
assertEquals(1, mode.spatial)
assertEquals(3, mode.temporal)
assertEquals("", mode.suffix)
}
@Test
fun testL3T3_KEY() {
val mode = ScalabilityMode.parseFromString("L3T3_KEY")
assertEquals(3, mode.spatial)
assertEquals(3, mode.temporal)
assertEquals("_KEY", mode.suffix)
}
}
... ...
/*
* Copyright 2023 LiveKit, Inc.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.livekit.android.webrtc
import android.javax.sdp.MediaDescription
import android.javax.sdp.SdpFactory
import android.javax.sdp.SessionDescription
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Test
class JainSdpUtilsTest {
private val sdpFactory = SdpFactory.getInstance()
private fun createSessionDescription(): SessionDescription {
return sdpFactory.createSessionDescription(DESCRIPTION)
}
@Test
fun getRtpAttributes() {
val sdp = createSessionDescription()
val mediaDescriptions = sdp.getMediaDescriptions(true)
.filterIsInstance<MediaDescription>()
val mediaDesc = mediaDescriptions[1]
val rtps = mediaDesc.getRtps()
assertEquals(13, rtps.size)
val (_, vp8Rtp) = rtps[0]
assertEquals(96, vp8Rtp.payload)
assertEquals("VP8", vp8Rtp.codec)
assertEquals(90000L, vp8Rtp.rate)
assertNull(vp8Rtp.encoding)
}
@Test
fun getExtmapAttributes() {
val sdp = createSessionDescription()
val mediaDescriptions = sdp.getMediaDescriptions(true)
.filterIsInstance<MediaDescription>()
val mediaDesc = mediaDescriptions[1]
val exts = mediaDesc.getExts()
assertEquals(12, exts.size)
val (_, ext) = exts[0]
assertEquals(1, ext.value)
assertNull(ext.direction)
assertNull(ext.encryptUri)
assertEquals("urn:ietf:params:rtp-hdrext:toffset", ext.uri)
assertNull(ext.config)
}
@Test
fun getMsid() {
val sdp = createSessionDescription()
val mediaDescriptions = sdp.getMediaDescriptions(true)
.filterIsInstance<MediaDescription>()
val mediaDesc = mediaDescriptions[1]
val msid = mediaDesc.getMsid()
assertNotNull(msid)
assertEquals("PA_Qwqk4y9fcD3G 42dd9185-4ea2-4bf1-b964-1dc0eb739c6c", msid!!.value)
}
@Test
fun getFmtps() {
val sdp = createSessionDescription()
val mediaDescriptions = sdp.getMediaDescriptions(true)
.filterIsInstance<MediaDescription>()
val mediaDesc = mediaDescriptions[1]
val fmtps = mediaDesc.getFmtps()
.filter { (_, fmtp) -> fmtp.payload == 97L }
assertEquals(1, fmtps.size)
val (_, fmtp) = fmtps[0]
assertEquals("apt=96", fmtp.config)
}
companion object {
const val DESCRIPTION = "v=0\n" +
"o=- 3682890773448528616 3 IN IP4 127.0.0.1\n" +
"s=-\n" +
"t=0 0\n" +
"a=group:BUNDLE 0 1\n" +
"a=extmap-allow-mixed\n" +
"a=msid-semantic: WMS PA_Qwqk4y9fcD3G\n" +
"m=application 24436 UDP/DTLS/SCTP webrtc-datachannel\n" +
"c=IN IP4 45.76.222.83\n" +
"a=candidate:1660983843 1 udp 2122194687 192.168.0.22 37324 typ host generation 0 network-id 5 network-cost 10\n" +
"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" +
"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" +
"a=candidate:689824452 1 tcp 1518083839 10.244.232.137 9 typ host tcptype active generation 0 network-id 3 network-cost 900\n" +
"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" +
"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" +
"a=ice-ufrag:+2SN\n" +
"a=ice-pwd:cdmp3JptAqdOA9VRHrNsdKE9\n" +
"a=ice-options:trickle renomination\n" +
"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" +
"a=setup:actpass\n" +
"a=mid:0\n" +
"a=sctp-port:5000\n" +
"a=max-message-size:262144\n" +
"m=video 9 UDP/TLS/RTP/SAVPF 96 97 127 103 104 105 39 40 98 99 106 107 108\n" +
"c=IN IP4 0.0.0.0\n" +
"a=rtcp:9 IN IP4 0.0.0.0\n" +
"a=ice-ufrag:+2SN\n" +
"a=ice-pwd:cdmp3JptAqdOA9VRHrNsdKE9\n" +
"a=ice-options:trickle renomination\n" +
"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" +
"a=setup:actpass\n" +
"a=mid:1\n" +
"a=extmap:1 urn:ietf:params:rtp-hdrext:toffset\n" +
"a=extmap:2 http://www.webrtc.org/experiments/rtp-hdrext/abs-send-time\n" +
"a=extmap:3 urn:3gpp:video-orientation\n" +
"a=extmap:4 http://www.ietf.org/id/draft-holmer-rmcat-transport-wide-cc-extensions-01\n" +
"a=extmap:5 http://www.webrtc.org/experiments/rtp-hdrext/playout-delay\n" +
"a=extmap:6 http://www.webrtc.org/experiments/rtp-hdrext/video-content-type\n" +
"a=extmap:7 http://www.webrtc.org/experiments/rtp-hdrext/video-timing\n" +
"a=extmap:8 http://www.webrtc.org/experiments/rtp-hdrext/color-space\n" +
"a=extmap:9 urn:ietf:params:rtp-hdrext:sdes:mid\n" +
"a=extmap:10 urn:ietf:params:rtp-hdrext:sdes:rtp-stream-id\n" +
"a=extmap:11 urn:ietf:params:rtp-hdrext:sdes:repaired-rtp-stream-id\n" +
"a=extmap:12 https://aomediacodec.github.io/av1-rtp-spec/#dependency-descriptor-rtp-header-extension\n" +
"a=sendonly\n" +
"a=msid:PA_Qwqk4y9fcD3G 42dd9185-4ea2-4bf1-b964-1dc0eb739c6c\n" +
"a=rtcp-mux\n" +
"a=rtcp-rsize\n" +
"a=rtpmap:96 VP8/90000\n" +
"a=rtcp-fb:96 goog-remb\n" +
"a=rtcp-fb:96 transport-cc\n" +
"a=rtcp-fb:96 ccm fir\n" +
"a=rtcp-fb:96 nack\n" +
"a=rtcp-fb:96 nack pli\n" +
"a=rtpmap:97 rtx/90000\n" +
"a=fmtp:97 apt=96\n" +
"a=rtpmap:127 H264/90000\n" +
"a=rtcp-fb:127 goog-remb\n" +
"a=rtcp-fb:127 transport-cc\n" +
"a=rtcp-fb:127 ccm fir\n" +
"a=rtcp-fb:127 nack\n" +
"a=rtcp-fb:127 nack pli\n" +
"a=fmtp:127 level-asymmetry-allowed=1;packetization-mode=1;profile-level-id=42e01f\n" +
"a=rtpmap:103 rtx/90000\n" +
"a=fmtp:103 apt=127\n" +
"a=rtpmap:104 H265/90000\n" +
"a=rtcp-fb:104 goog-remb\n" +
"a=rtcp-fb:104 transport-cc\n" +
"a=rtcp-fb:104 ccm fir\n" +
"a=rtcp-fb:104 nack\n" +
"a=rtcp-fb:104 nack pli\n" +
"a=rtpmap:105 rtx/90000\n" +
"a=fmtp:105 apt=104\n" +
"a=rtpmap:39 AV1/90000\n" +
"a=rtcp-fb:39 goog-remb\n" +
"a=rtcp-fb:39 transport-cc\n" +
"a=rtcp-fb:39 ccm fir\n" +
"a=rtcp-fb:39 nack\n" +
"a=rtcp-fb:39 nack pli\n" +
"a=rtpmap:40 rtx/90000\n" +
"a=fmtp:40 apt=39\n" +
"a=rtpmap:98 VP9/90000\n" +
"a=rtcp-fb:98 goog-remb\n" +
"a=rtcp-fb:98 transport-cc\n" +
"a=rtcp-fb:98 ccm fir\n" +
"a=rtcp-fb:98 nack\n" +
"a=rtcp-fb:98 nack pli\n" +
"a=fmtp:98 profile-id=0\n" +
"a=rtpmap:99 rtx/90000\n" +
"a=fmtp:99 apt=98\n" +
"a=rtpmap:106 red/90000\n" +
"a=rtpmap:107 rtx/90000\n" +
"a=fmtp:107 apt=106\n" +
"a=rtpmap:108 ulpfec/90000\n" +
"a=rid:h send\n" +
"a=rid:q send\n" +
"a=simulcast:send h;q"
}
}
... ...
... ... @@ -63,6 +63,18 @@ class MockPeerConnectionFactory : PeerConnectionFactory(1L) {
kind = MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO
parameters = mapOf("profile-level-id" to "42e01f")
},
RtpCapabilities.CodecCapability().apply {
name = "AV1"
mimeType = "video/AV1"
kind = MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO
parameters = emptyMap()
},
RtpCapabilities.CodecCapability().apply {
name = "VP9"
mimeType = "video/VP9"
kind = MediaStreamTrack.MediaType.MEDIA_TYPE_VIDEO
parameters = mapOf("profile-id" to "0")
},
),
emptyList(),
)
... ...
Subproject commit 519c96683da8b98f214d42810c1608ddb794cf2e
Subproject commit a187ed4f546b4f6881313ec4813268d53cb070f8
... ...