基于SRS服务器实现Android-Web端视频通话(3):Android端向SRS服务器推送WebRTC流

基于SRS服务器实现Android-Web端视频通话(1):SRS服务器启用HTTPS
基于SRS服务器实现Android-Web端视频通话(2):Android端从SRS服务器拉取WebRTC流
基于SRS服务器实现Android-Web端视频通话(3):Android端向SRS服务器推送WebRTC流

实现效果

publish.gif

引库

implementation 'org.webrtc:google-webrtc:1.0.32006'

其他版本,详见

推流流程

createPeerConnectionFactory -> createPeerConnection(addTransceiver) -> createOffer -> setLocalDescription(OFFER) -> get remote sdp(network requset) -> setRemoteDescription(ANSWER)

代码实现

初始化

//加载并初始化 WebRTC,在创建 PeerConnectionFactory 之前必须至少调用一次
PeerConnectionFactory.initialize(
    PeerConnectionFactory.InitializationOptions
        .builder(applicationContext).createInitializationOptions()
)

private val eglBaseContext = EglBase.create().eglBaseContext

createPeerConnectionFactory

private val peerConnectionFactory: PeerConnectionFactory = createPeerConnectionFactory()

private fun createPeerConnectionFactory(): PeerConnectionFactory {
    //先做默认配置,后面可能会遇到坑
    val options = PeerConnectionFactory.Options()
    val encoderFactory = DefaultVideoEncoderFactory(eglBaseContext, true, true)
    val decoderFactory = DefaultVideoDecoderFactory(eglBaseContext)
    return PeerConnectionFactory.builder()
        .setOptions(options)
        .setVideoEncoderFactory(encoderFactory)
        .setVideoDecoderFactory(decoderFactory)
        .createPeerConnectionFactory()
}

createPeerConnection(addTransceiver)

private fun initPeerConnection() {
    val createAudioSource = peerConnectionFactory.createAudioSource(createAudioConstraints())
    val audioTrack =
        peerConnectionFactory.createAudioTrack("local_audio_track", createAudioSource)

    cameraVideoCapturer = createVideoCapture(this)
    cameraVideoCapturer?.let { capture ->
        val videoSource = peerConnectionFactory.createVideoSource(capture.isScreencast)
        videoTrack =
            peerConnectionFactory.createVideoTrack("local_video_track", videoSource).apply {
                //显示到本地画面上
                addSink(mBinding.svr)
            }
        surfaceTextureHelper =
            SurfaceTextureHelper.create("surface_texture_thread", eglBaseContext)
        capture.initialize(surfaceTextureHelper, this, videoSource.capturerObserver)
        //开始捕帧,宽、高、帧率。
        capture.startCapture(640, 480, 20)
    }

    val rtcConfig = PeerConnection.RTCConfiguration(emptyList())
    /*
    <p>For users who wish to send multiple audio/video streams and need to stay interoperable with legacy WebRTC implementations, specify PLAN_B.
    <p>For users who wish to send multiple audio/video streams and/or wish to use the new RtpTransceiver API, specify UNIFIED_PLAN.
    */
    //使用PeerConnection.SdpSemantics.UNIFIED_PLAN
    rtcConfig.sdpSemantics = PeerConnection.SdpSemantics.UNIFIED_PLAN

    peerConnection = peerConnectionFactory.createPeerConnection(
        rtcConfig,
        PeerConnectionObserver()
    )?.apply {
        //不可使用addStream()的方式,否则会报错
        //addTransceiver()调用顺序会影响到生成sdp信息中video、audio信息顺序,对应关系,下面会提到;
        videoTrack?.let {
            //添加视频轨,设置仅发送即可
            addTransceiver(
                it,
                RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_ONLY)
            )
        }
        //添加音频轨,设置仅发送即可
        addTransceiver(
            audioTrack,
            RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_ONLY)
        )
    }
}

private fun createAudioConstraints(): MediaConstraints {
    val audioConstraints = MediaConstraints()
    //回声消除
    audioConstraints.mandatory.add(
        MediaConstraints.KeyValuePair(
            "googEchoCancellation",
            "true"
        )
    )
    //自动增益
    audioConstraints.mandatory.add(MediaConstraints.KeyValuePair("googAutoGainControl", "true"))
    //高音过滤
    audioConstraints.mandatory.add(MediaConstraints.KeyValuePair("googHighpassFilter", "true"))
    //噪音处理
    audioConstraints.mandatory.add(
        MediaConstraints.KeyValuePair(
            "googNoiseSuppression",
            "true"
        )
    )
    return audioConstraints
}

private fun createVideoCapture(context: Context): CameraVideoCapturer? {
    val enumerator: CameraEnumerator = if (Camera2Enumerator.isSupported(context)) {
        Camera2Enumerator(context)
    } else {
        Camera1Enumerator()
    }
    for (name in enumerator.deviceNames) {
        if (enumerator.isFrontFacing(name)) {
            return enumerator.createCapturer(name, null)
        }
    }
    for (name in enumerator.deviceNames) {
        if (enumerator.isBackFacing(name)) {
            return enumerator.createCapturer(name, null)
        }
    }
    return null
}

createOffer && setLocalDescription

peerConnection.createOffer(object : SdpAdapter("createOffer") {
    override fun onCreateSuccess(description: SessionDescription?) {
        description?.let {
            if (it.type == SessionDescription.Type.OFFER) {
                connection.setLocalDescription(SdpAdapter("setLocalDescription"), it)
                //这个offerSdp将用于向SRS服务进行网络请求
                val offerSdp = it.description
                getRemoteSdp(offerSdp)
            }
        }
    }
}, MediaConstraints())

get remote sdp(netword requset)

基本配置,根据自己实际情况进行调整

object Constant {
    /**
     * SRS服务器IP
     */
    const val SRS_SERVER_IP = "192.168.2.91"

    /**
     * SRS服务http请求端口,默认1985
     */
    const val SRS_SERVER_HTTP_PORT = "1985"

    /**
     * SRS服务https请求端口,默认1990
     */
    const val SRS_SERVER_HTTPS_PORT = "1990"

    const val SRS_SERVER_HTTP = "$SRS_SERVER_IP:$SRS_SERVER_HTTP_PORT"

    const val SRS_SERVER_HTTPS = "$SRS_SERVER_IP:$SRS_SERVER_HTTPS_PORT"
}

Request Body (application/json)

data class SrsRequestBean(
    /**
     * [PeerConnection.createOffer]返回的sdp
     */
    @Json(name = "sdp")
    val sdp: String?,
    /**
     * 拉取的WebRTC流地址
     */
    @Json(name = "streamurl")
    val streamUrl: String?
)

Response Body (application/json)

data class SrsResponseBean(
    /**
     * 0:成功
     */
    @Json(name = "code")
    val code: Int,
    /**
     * 用于设置[PeerConnection.setRemoteDescription]
     */
    @Json(name = "sdp") val sdp: String?,
    @Json(name = "server")
    val server: String?,
    @Json(name = "sessionid")
    val sessionId: String?
)

网络请求地址
http请求:http://ip:port/rtc/v1/publish/
https请求:https://ip:port/rtc/v1/publish/
Method:POST

在Android P(28)系统的设备上,禁止应用使用的是非加密的明文流量的HTTP 网络请求。

retrofit事例

interface ApiService {

    @POST("/rtc/v1/publish/")
    suspend fun publish(@Body body: SrsRequestBean): SrsResponseBean
}

getRemoteSdp

private fun getRemoteSdp(offerSdp: String){
    //webrtc流地址
    val webrtcUrl="webrtc://${Constant.SRS_SERVER_IP}/live/camera"
    val srsBean = SrsRequestBean(offerSdp, webrtcUrl)
    lifecycleScope.launch {
        val result = try {
            withContext(Dispatchers.IO) {
                retrofitClient.apiService.publish(srsBean)
            }
        } catch (e: Exception) {
            println("网络请求出错:${e.printStackTrace()}")
            toastError("网络请求出错:${e.printStackTrace()}")
            null
        }

        result?.let { bean ->
            if (bean.code == 0) {
                println("网络请求成功,code:${bean.code}")
                setRemoteDescription(bean.sdp)
            } else {
                println("网络请求失败,code:${bean.code}")
            }
        }
    }
}

注意:这一步接口可能会返回code:400的情况,如果没有可跳过。

publish api failure.png

原因可能是:

1、推流地址被占用,解决方案换个推流地址即可;
2、服务器中打印create session : create session : add publisher : publish negotiate : no found valid H.264 payload type,原因:就是接口请求里offer sdpm=videoH.264相关信息,即WebRTC在createOffer时,返回的sdp没有H.264相关信息;SRS中使用WebRTC,视频编码仅支持H.264。

server warning.png

针对第二种情况,解决方案可参考我的另外一篇博客
这样createPeerConnectionFactory就要改一下:
引库WebRTCExtension

private val peerConnectionFactory: PeerConnectionFactory = createPeerConnectionFactory()

private fun createPeerConnectionFactory(): PeerConnectionFactory {
    val options = PeerConnectionFactory.Options()
    //调整VideoEncoderFactory
    val encoderFactory = createCustomVideoEncoderFactory(eglBaseContext, true, true,
            object : VideoEncoderSupportedCallback {
                override fun isSupportedH264(info: MediaCodecInfo): Boolean {
                    //这里自行添加支持H.264编码的MediaCodecInfo
                    return true
                }

            })
    val decoderFactory = DefaultVideoDecoderFactory(eglBaseContext)
    return PeerConnectionFactory.builder()
        .setOptions(options)
        .setVideoEncoderFactory(encoderFactory)
        .setVideoDecoderFactory(decoderFactory)
        .createPeerConnectionFactory()
}

setRemoteDescription

private fun setRemoteDescription(answerSdp: String){
    val remoteSdp = SessionDescription(SessionDescription.Type.ANSWER, /*关键点*/answerSdp)
    //注意这一步,可能会报错:Failed to set remote answer sdp: The order of m-lines in answer doesn't match order in offer. Rejecting answer.
    peerConnection.setRemoteDescription(SdpAdapter("setRemoteDescription"), remoteSdp)
}

如果你遇到这个错误:
Failed to set remote answer sdp: The order of m-lines in answer doesn't match order in offer. Rejecting answer.

可以看下我的另外一篇博客
具体原因就是offer中sdp的video、audio和answer中sdp的video、audio顺序不一致,详见#3179
解决方案目前大致有三种:
1、升级SRS服务器版本,截止2022-09-16,升级到v4.0.265及以上,即可解决,云服务器这个版本做了兼容处理;
2、如果无法升级SRS,可以调整上面提到的调整addTransceiver()调用顺序,确保顺序与SRS接口返回的sdp中video、audio顺序一致;

ps: 上面两种方案解决方式还要非常感谢SRS-杨成立指点,十分感谢!

3、使用博客中第一种原因的解决方式,我们需要手动调换下位置(当然这个方法也可以作为最后的保护手段):

/**
 * 转换AnswerSdp
 * @param offerSdp offerSdp:创建offer时生成的sdp
 * @param answerSdp answerSdp:网络请求srs服务器返回的sdp
 * @return 转换后的AnswerSdp
 */
private fun convertAnswerSdp(offerSdp: String, answerSdp: String?): String {
    if (answerSdp.isNullOrBlank()){
        return ""
    }
    val indexOfOfferVideo = offerSdp.indexOf("m=video")
    val indexOfOfferAudio = offerSdp.indexOf("m=audio")
    if (indexOfOfferVideo == -1 || indexOfOfferAudio == -1) {
        return answerSdp
    }
    val indexOfAnswerVideo = answerSdp.indexOf("m=video")
    val indexOfAnswerAudio = answerSdp.indexOf("m=audio")
    if (indexOfAnswerVideo == -1 || indexOfAnswerAudio == -1) {
        return answerSdp
    }

    val isFirstOfferVideo = indexOfOfferVideo < indexOfOfferAudio
    val isFirstAnswerVideo = indexOfAnswerVideo < indexOfAnswerAudio
    return if (isFirstOfferVideo == isFirstAnswerVideo) {
        //顺序一致
        answerSdp
    } else {
        //需要调换顺序
        buildString {
            append(answerSdp.substring(0, indexOfAnswerVideo.coerceAtMost(indexOfAnswerAudio)))
            append(
                answerSdp.substring(
                    indexOfAnswerVideo.coerceAtLeast(indexOfOfferVideo),
                    answerSdp.length
                )
            )
            append(
                answerSdp.substring(
                    indexOfAnswerVideo.coerceAtMost(indexOfAnswerAudio),
                    indexOfAnswerVideo.coerceAtLeast(indexOfOfferVideo)
                )
            )
        }
    }
}

修改方法:

private fun setRemoteDescription(offerSdp: String, answerSdp: String){
    val remoteSdp = SessionDescription(SessionDescription.Type.ANSWER, /*关键点*/convertAnswerSdp(offerSdp, answerSdp))
    peerConnection.setRemoteDescription(SdpAdapter("setRemoteDescription"), remoteSdp)
}

关闭

释放资源,避免内存泄漏

mBinding.svr.release()
cameraVideoCapturer?.dispose()
surfaceTextureHelper?.dispose()
videoTrack?.dispose()
peerConnection?.dispose()
peerConnectionFactory.dispose()

至此,推流流程结束。如有错误欢迎指正。

Github传送门

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,524评论 5 460
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,869评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,813评论 0 320
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,210评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,085评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,117评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,533评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,219评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,487评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,582评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,362评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,218评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,589评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,899评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,176评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,503评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,707评论 2 335

推荐阅读更多精彩内容