影像串流 WebRTC

      在〈影像串流 WebRTC〉中尚無留言

Android 中使用 WebRtc 將影片直播到 SRS,這方面的資訊非常少,也有很多坑,所以撰寫此篇詳細說明。

公用套件

接下來下載公用套件放在專案中。

請由如下網址下載 : webrtc_http.zip

G class

G 檔是儲存通用的資訊,請新增 G.kt,完整內容如下

object G {

    const val SRS_SERVER_IP = "mahalbot.ddns.net"
    const val SRS_SERVER_HTTP_PORT = "1985"
    const val SRS_SERVER_HTTPS_PORT = "8080"
    const val SRS_SERVER_HTTP = "${SRS_SERVER_IP}:$SRS_SERVER_HTTP_PORT"
    const val SRS_SERVER_HTTPS = "${SRS_SERVER_IP}:$SRS_SERVER_HTTPS_PORT"
}

onCreate

onCreate 需新增 globalInit,否則會連線失敗

import android.content.Context
import android.media.MediaCodecInfo
import android.os.Bundle
import android.view.View
import android.widget.Toast
import androidx.appcompat.app.AppCompatActivity
import com.shencoder.mvvmkit.ext.globalInit
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import net.ddns.mahaljsp.cameraw.databinding.ActivityMainBinding
import net.ddns.mahaljsp.cameraw.http.RetrofitClient
import net.ddns.mahaljsp.cameraw.http.SrsRequestBean
import org.koin.android.ext.android.inject
import org.koin.android.java.KoinAndroidApplication
import org.koin.core.logger.Level
import org.koin.dsl.module
import org.webrtc.AudioTrack
import org.webrtc.Camera1Enumerator
import org.webrtc.Camera2Enumerator
import org.webrtc.CameraEnumerator
import org.webrtc.CameraVideoCapturer
import org.webrtc.DefaultVideoDecoderFactory
import org.webrtc.EglBase
import org.webrtc.MediaConstraints
import org.webrtc.PeerConnection
import org.webrtc.PeerConnectionFactory
import org.webrtc.RendererCommon
import org.webrtc.RtpTransceiver
import org.webrtc.SessionDescription
import org.webrtc.SurfaceTextureHelper
import org.webrtc.VideoEncoderSupportedCallback
import org.webrtc.VideoTrack
import org.webrtc.createCustomVideoEncoderFactory
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import net.ddns.mahaljsp.cameraw.http.PeerConnectionObserver
import net.ddns.mahaljsp.cameraw.http.SdpAdapter
class MainActivity : AppCompatActivity() {
    lateinit var permission:MainPermission
    lateinit var ui:ActivityMainBinding
    lateinit var rtcConfig : PeerConnection.RTCConfiguration
    var surfaceTextureHelper: SurfaceTextureHelper? = null
    var videoTrack: VideoTrack? = null
    var audioTrack: AudioTrack? = null
    val eglBaseContext = EglBase.create().eglBaseContext
    lateinit var peerConnectionFactory: PeerConnectionFactory
    var isStreaming=false
    var peerConnection:PeerConnection?=null
    lateinit var context:Context
    val retrofitClient by inject<RetrofitClient>()
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        //enableEdgeToEdge()
        ui= ActivityMainBinding.inflate(layoutInflater)
        setContentView(ui.root)

        //無此段會連線失敗
        globalInit(false,"TravelX", KoinAndroidApplication
            .create(this, Level.ERROR)
            .modules(mutableListOf(module {single{ RetrofitClient() }}).apply {})
        )
        context=this
        permission= MainPermission(this)
    }
}

startStream

撰寫 startStream() 方法如下

    fun startStream(url:String){
        peerConnection = peerConnectionFactory.createPeerConnection(
            rtcConfig,
            PeerConnectionObserver()
        )

        peerConnection?.addTransceiver(
            videoTrack,
            RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_ONLY)
        )
        peerConnection?.addTransceiver(
            audioTrack,
            RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_ONLY)
        )

        peerConnection?.let { connection ->
            connection.createOffer(object : SdpAdapter("createOffer") {
                override fun onCreateSuccess(description: SessionDescription?) {
                    super.onCreateSuccess(description)
                    description?.let {
                        if (it.type == SessionDescription.Type.OFFER) {
                            val offerSdp = it.description
                            connection.setLocalDescription(SdpAdapter("setLocalDescription"), it)

                            val srsBean = SrsRequestBean(
                                it.description,
                                url
                            )

                            lifecycleScope.launch{
                                val result = try {
                                    withContext(Dispatchers.IO) {
                                        retrofitClient.apiService.publish(srsBean)
                                    }
                                } catch (e: Exception) {
                                    Toast.makeText(context,"網路連線失敗", Toast.LENGTH_SHORT).show()
                                    null
                                }
                                result?.let { bean ->
                                    if (bean.code == 0) {
                                        Toast.makeText(context,"網路連線成功", Toast.LENGTH_SHORT).show()
                                        val remoteSdp = SessionDescription(
                                            SessionDescription.Type.ANSWER,
                                            convertAnswerSdp(offerSdp, bean.sdp)
                                        )
                                        connection.setRemoteDescription(
                                            SdpAdapter("setRemoteDescription"),
                                            remoteSdp
                                        )
                                    } else {
                                        Toast.makeText(context,"網路連線失敗", Toast.LENGTH_SHORT).show()
                                    }
                                }
                            }
                        }
                    }
                }
            }, MediaConstraints()
            )
        }
        isStreaming=true
    }

stopStream

stopStream 方法如下

    fun stopStream(){
        if (peerConnection!=null) {
            peerConnection?.dispose()
            peerConnection = null
        }
        isStreaming=false
    }

convertAnswerSdp

convertAnswerSdp 方法如下

    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)
                    )
                )
            }
        }
    }

cbtnVideo_click

btnVideo_click 方法如下

    fun btnVideo_click(view:View){
    fun btnVideo_click(view: View){
        if(!isStreaming) {
            startStream("rtmp://mahalbot.ddns.net/live/video4")
            ui.btnVideo.setBackgroundResource(R.drawable.ic_video_stop)
        }
        else {
            stopStream()
            ui.btnVideo.setBackgroundResource(R.drawable.ic_video_start)
        }
    }

SRS 錄影中斷

WebRtc 有分辨率自適應調整的功能,當網路訊號不好時,自動將分辨率調小降低影像品質,訊號良好時自動將分辨率調高提升影像品質。這種機制雖說很先進,但一遇到 SRS 就麻煩了。

SRS 伺服器如果啟動 dvr 錄影時,會因為影像的寬高解析度變更而中斷錄影,日誌會出現如下錯誤

 dvr: ignore video error code=3085(Mp4AvccChange)(MP4 does not support video AVCC change)
: consume video : write video : encode video : write sample : doesn't support avcc change

而在 SRS 官網上也有說明

WebRTC recording is a challenge, this thing often changes sps/pps.

Android WebRtc 在啟動直播後,會開始由最低解析度一直往上調整,測試那一種方式最適合目前的網路。這也就造成了 SRS 只錄了約 10 秒左右的低品質解析度後就中斷了。為了解決這個惱人的問題,就把解析度固定成 1920 * 1080 即可。

請由下方藍色代碼著手,找到 videoSender 後,設定裏面的參數。

        peerConnection = peerConnectionFactory.createPeerConnection(
            rtcConfig,
            PeerConnectionObserver()
        )

        peerConnection?.addTransceiver(
            videoTrack,
            RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_ONLY)
        )
        peerConnection?.addTransceiver(
            audioTrack,
            RtpTransceiver.RtpTransceiverInit(RtpTransceiver.RtpTransceiverDirection.SEND_ONLY)
        )
        val videoSender = peerConnection?.senders?.find { it.track()?.kind() == "video" }
        videoSender?.let {
            val parameters = it.parameters

            // 禁用自動調整,設置固定的比特率和幀率
            parameters.encodings[0].maxBitrateBps = 4_500_000  // 設定固定的比特率 (例如 1.5 Mbps)
            parameters.encodings[0].minBitrateBps = 4_500_000  // 最小比特率也設為 1.5 Mbps
            parameters.encodings[0].maxFramerate = 25          // 固定幀率為 30 fps

            //scaleResolutionDownBy 範圍為 1.0~7.0, 3.0 會從640*360 跳 1920*1080,
            // 這樣 srs 就不會偵測到 avc change 而停止錄影
            parameters.encodings[0].scaleResolutionDownBy = 3.0
            it.parameters = parameters
        }

上面最重要的是 scaleResolutionDownBy,其值設為 1.0 時,會測試 640*480、1080*720、1920*1080。

若調整為 3.0 時,會快速的由 640*480 直接跳 1920*1080,這時 SRS 還沒收到 640*480 的影片,所以就可以避開錄影中斷的問題。

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *