Android音視頻開發(fā)框架(下)

前言

前文講到Android音視頻開發(fā)框架中的上半段:音視頻的創(chuàng)建旺芽,編碼,保存辐啄,這個屬于音視頻資源生產端的過程采章。在消費端,還需要經歷讀取壶辜,解碼悯舟,播放這三個節(jié)點。

音視頻讀取

在前文中砸民,我們可以打通從攝像頭+麥克風-編碼數(shù)據(jù)-保存文件這個過程抵怎,假如一切順利,那么可以在磁盤中保存一個MP4文件岭参。但是想要消費這段影片反惕,首先要做的就是提取文件里的編碼過的音頻和視頻信息。這個工作主要依賴于MediaExtractor類演侯。

MediaExtractor的主要方法如下:

// 設置數(shù)據(jù)源
mediaExtractor.setDataSource()
// 獲取軌道數(shù)(音頻軌道承璃,視頻軌道,字幕軌道等)
mediaExtractor.getTrackCount()
// 獲取該軌道的格式類型(是音頻還是視頻)
mediaExtractor.getTrackFormat()
// 選擇軌道(確定讀取哪個軌道的數(shù)據(jù))
mediaExtractor.selectTrack()
// 讀取采樣數(shù)據(jù)到數(shù)組
mediaExtractor.readSampleData()
// 進入下一個采樣,readSampleData之后需要調用advance推動指針往前挪動
mediaExtractor.advance()
// 返回當前軌道索引
mediaExtractor.getSampleTrackIndex()
// 返回當前采樣的顯示時間
mediaExtractor.getSampleTime()
// seek到對應時間
mediaExtractor.seekTo()
// 釋放資源
mediaExtractor.release()

我們可以把MediaExtrtactor看作是MediaMuxer的逆過程蚌本,后者是把音頻視頻封裝寫入文件,前者是讀取文件隘梨,解封裝獲取獨立的音頻和視頻程癌。

音頻和視頻分別是獨立線程編解碼的,那么讀取自然在分在兩個線程中分別讀取互不干擾轴猎。而且由于操作的相似性嵌莉,我們可以對它的操作進行一定的封裝:

class MExtractor(filePath:String) {
    companion object{
        val EXTRACTOR_TAG = "extractor_tag"
    }
    private var audioTrackIndex = -1
    private var videoTrackIndex = -1
    private val mediaExtractor:MediaExtractor by lazy {
        MediaExtractor()
    }

    init {
        try {
            mediaExtractor.setDataSource(filePath)
        }catch (e:IOException){
            e.printStackTrace()
            Log.e(EXTRACTOR_TAG,"${e.message}")
        }
    }
    // 選擇音頻軌道
    fun selectAudioTrack(){
        val index = getAudioTrack()
        if (index == -1) return
        mediaExtractor.selectTrack(index)
    }
     // 選擇視頻軌道
    fun selectVideoTrack(){
        val index = getVideoTrack()
        if (index == -1) return
        mediaExtractor.selectTrack(index)
    }
    // 讀取(對應軌道的)數(shù)據(jù)
    fun readSampleData(byteBuf: ByteBuffer,  offset:Int):Pair<Int,Long>{
        //讀取一塊數(shù)據(jù)
        val readSize = mediaExtractor.readSampleData(byteBuf, offset)
        // 獲取這塊數(shù)據(jù)對應的時間錯
        val sampleTimeValue = mediaExtractor.sampleTime
        //指針往前移動
        mediaExtractor.advance()
        return Pair(readSize,sampleTimeValue)
    }
    ...
    ...
    fun getAudioTrack():Int{
        if (audioTrackIndex != -1){
            return audioTrackIndex
        }
        for (i in 0..mediaExtractor.trackCount) {
            val format = mediaExtractor.getTrackFormat(i)
            if (format.getString(MediaFormat.KEY_MIME)?.startsWith("audio/") == true){
                Log.i(EXTRACTOR_TAG,"selected format: $format  track: $i")
                audioTrackIndex = i
                return i
            }
        }
        return -1;
    }

    fun getVideoTrack():Int{
        if (mediaExtractor.trackCount == 0){
            return -1
        }
        if (videoTrackIndex != -1){
            return videoTrackIndex
        }
        for (i in 0..mediaExtractor.trackCount) {
            val format = mediaExtractor.getTrackFormat(i)
            Log.i(EXTRACTOR_TAG,"video index: $i  format: $format")

            if (format.getString(MediaFormat.KEY_MIME)?.startsWith("video/") == true){
                Log.i(EXTRACTOR_TAG,"format: $format")
                videoTrackIndex = i
                return i
            }
        }
        return -1
    }
}

以上基本上就是MediaExtractor的全部了捻脖,他往往需要配合其他的組件使用锐峭。

音視頻解碼

有了MediaExtractor的幫助中鼠,我們已經可以 從文件中獲取數(shù)據(jù)源,接著我們還是使用異步模式來開啟解碼過程

視頻

private val videoHandlerThread: HandlerThread = HandlerThread("video-thread").apply { start() }
private val videoHandler = Handler(videoHandlerThread.looper)

private val mediaExtractor: MExtractor by lazy {
    MExtractor(fileData.filePath)
}

// 異步模式的回調
private val videoCallback = object : CodecCallback() {
    override fun onInputBufferAvailableWrapper(codec: MediaCodec, index: Int) {
        if (isSignalEOF || mediaExtractor.getSampleTrackIndex() == -1) {
            return
        }
        pauseIfNeed()

        val inputBuffer = codec.getInputBuffer(index) ?: return
        inputBuffer.clear()
        // 選擇視頻軌道
        mediaExtractor.selectVideoTrack()
        //讀取數(shù)據(jù)
        // sampleTime 視頻的PTS
        var (readSize, sampleTime) = mediaExtractor.readSampleData(inputBuffer, 0)
        if (readSize < 0) {
            inputBuffer.limit(0)
            codec.queueInputBuffer(index, 0, 0, 0, 0)
            isSignalEOF = true
        } else {
            codec.queueInputBuffer(index, 0, readSize, sampleTime, 0)
        }


    }

    override fun onOutputBufferAvailableWrapper(
        codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo
    ) {
        if (isOutputEOF) {
            return
        }
        isOutputEOF = (info.flags and MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0
        trySleep(info.presentationTimeUs)
        // index 是解碼后的數(shù)據(jù)緩存空間下標
        // 第二個參數(shù)表示是否渲染(如果提前設置了輸出端的Surface的話沿癞,填true)
        codec.releaseOutputBuffer(index, true)

    }
    ...
    ...
}


...
// configure
mediaExtractor.getVideoFormat()?.let {
    val mime = it.getString(MediaFormat.KEY_MIME)
    mime?.let {m->
        videoDecoder = MediaCodec.createDecoderByType(m)
        // 這個surface來自于播放器(SurfaceView或者TextureView)
        videoDecoder?.configure(it, surface, null, 0)
        videoDecoder?.setCallback(videoCallback, videoHandler)
    }

}
// 開始解碼
videoDecoder?.start()
...
...
// release
videoDecoder?.stop()
videoDecoder?.release()

對于視頻的解碼過程援雇,輸出端我們仍然可以使用Surface來簡化我們的輸出操作,MediaCodec提供了直接輸出數(shù)據(jù)到Surface的過程椎扬,因此我們把播放端的SurfaceView或者TextureView中的surface傳入進來惫搏,那么數(shù)據(jù)就可以直接打通了。

音頻

音頻的解碼過程和視頻解碼大差不差

private val audioHandlerThread: HandlerThread = HandlerThread("audio-thread").apply { start() }
private val audioHandler = Handler(audioHandlerThread.looper)

private val mediaExtractor: MExtractor by lazy {
    MExtractor(fileData.filePath)
}

// 解碼異步模式回調
    private val audioCallback = object : CodecCallback() {
        override fun onInputBufferAvailableWrapper(codec: MediaCodec, index: Int) {
            if (isEOF || mediaExtractor.getSampleTrackIndex() == -1) {
                return
            }
            pauseIfNeed()
            val inputBuffer = codec.getInputBuffer(index) ?: return
            inputBuffer.clear()
            mediaExtractor.selectAudioTrack()
            // 讀取采樣數(shù)據(jù)到buffer蚕涤,獲取采樣時間筐赔,同時指針向前推進
            // sampleTimeValue就是當前數(shù)據(jù)的PTS,這個直接從mediaExtractor中獲取,從0開始
            val (readSize, sampleTimeValue) = mediaExtractor.readSampleData(inputBuffer, 0)
            if (readSize < 0) {
                codec.queueInputBuffer(index, 0, 0, 0, MediaCodec.BUFFER_FLAG_END_OF_STREAM)
                isEOF = true
            } else {
                codec.queueInputBuffer(index, 0, readSize, sampleTimeValue, 0)
            }

        }

        override fun onOutputBufferAvailableWrapper(
            codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo
        ) {
            val outputBuffer = codec.getOutputBuffer(index)
            outputBuffer?.let {
                it.position(info.offset)
                it.limit(info.offset + info.size)
                ...
                // 向音頻播放設備寫入數(shù)據(jù)
                ...
            }

            trySleep(info.presentationTimeUs)
            codec.releaseOutputBuffer(index, false) // 重要
        }
    ...
    ...
    }


// configure
mediaExtractor.getAudioFormat()?.let {
    val mime = it.getString(MediaFormat.KEY_MIME) ?: ""
    audioDecoder = MediaCodec.createDecoderByType(mime)
    audioDecoder?.configure(it, null, null, 0)
    audioDecoder?.setCallback(audioCallback, audioHandler)
    Log.i(TAG, "audio inputbuffer mime: $mime")

}
// start
audioDecoder?.start()
...
...
// release
audioDecoder?.stop()
audioDecoder?.release()

音視頻播放

音視頻播放其實是完全不同的路徑揖铜,視頻播放依賴TextureView等的view展示茴丰,而音頻播放則是依賴音頻設備。

對于視頻而言天吓,我們需要在UI中插入TextureView(SurfaceView也一樣)贿肩,然后在TextureView中設置SurfaceTextureListener,等待SUrface的創(chuàng)建成功失仁,接著把SUrface傳入解碼器

dataBinding.textureview.surfaceTextureListener = object :SurfaceTextureListener{
    override fun onSurfaceTextureAvailable(
        surfaceTexture: SurfaceTexture,
        width: Int,
        height: Int
    ) {
        Log.i(TAG,"onSurfaceTextureAvailable  $width $height $surfaceTexture")
        val surface = Surface(surfaceTexture)
        startDecodeVideo(surface) // 傳入解碼模塊
        startDecodeAudio() // 一般也可以在此時觸發(fā)音頻的解碼

    }

    override fun onSurfaceTextureSizeChanged(
        surface: SurfaceTexture,
        width: Int,
        height: Int
    ) {
        Log.i(TAG,"onSurfaceTextureSizeChanged  $width $height $surface")

    }

    override fun onSurfaceTextureDestroyed(surfaceTexture: SurfaceTexture): Boolean {
        curSurface?.release()
        Log.i(TAG,"onSurfaceTextureDestroyed   $surfaceTexture")
        return true
    }

    override fun onSurfaceTextureUpdated(surfaceTexture: SurfaceTexture) {
        Log.i(TAG,"onSurfaceTextureUpdated   $surfaceTexture")

    }
}

這樣尸曼,解碼的視頻幀就可以顯示在textureView上了。

但是音頻的播放過程則完全在后臺進行

//  創(chuàng)建音頻播放設備
mediaExtractor.getAudioFormat()?.let {
// 初始化配置
    val audioAttr = AudioAttributes.Builder()
        .setContentType(CONTENT_TYPE_MOVIE)
        .setLegacyStreamType(AudioManager.STREAM_MUSIC)
        .setUsage(USAGE_MEDIA)
        .build()
    val sampleRate = it.getInteger(MediaFormat.KEY_SAMPLE_RATE)
    var channelMask = if (it.containsKey(MediaFormat.KEY_CHANNEL_MASK)) {
        it.getInteger(MediaFormat.KEY_CHANNEL_MASK)
    } else {
        null
    }
    var channelCount = 1
    if (it.containsKey(MediaFormat.KEY_CHANNEL_COUNT)) {
        channelCount = it.getInteger(MediaFormat.KEY_CHANNEL_COUNT)

    }
    val channelConfig =
        if (channelCount == 1) AudioFormat.CHANNEL_OUT_MONO else AudioFormat.CHANNEL_OUT_STEREO
    if (channelMask == null) {
        channelMask = channelConfig
    }
    val formatInt = if (it.containsKey(MediaFormat.KEY_PCM_ENCODING)) {
        it.getInteger(MediaFormat.KEY_PCM_ENCODING)
    } else {
        AudioFormat.ENCODING_PCM_16BIT
    }
    val audioFormat = AudioFormat.Builder()
        .setChannelMask(channelMask)
        .setEncoding(formatInt)
        .setSampleRate(sampleRate)
        .build()

    bufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, formatInt)
    // 創(chuàng)建音頻播放設備
    audioTrack = AudioTrack(
        audioAttr,
        audioFormat,
        bufferSize,
        AudioTrack.MODE_STREAM,
        audioManager.generateAudioSessionId()
    )
}


//開始播放萄焦,和audioDecode.start同時調用即可
audioTrack?.play()

// 在合適的時機寫入音頻數(shù)據(jù)(一般就放在解碼完成輸出之后寫入即可)
audioTrack?.write(...)


// 釋放資源
audioTrack?.stop()
audioTrack?.release()

以上就是音頻播放設備的使用方式控轿。

你以為這樣就結束了么?天真拂封。

如果按照正常操作視頻的解碼速度會很快茬射,你會發(fā)現(xiàn)視頻像走馬燈一樣播放完了,音頻還在播放冒签,因此我們需要對音視頻進行同步在抛。

音視頻同步

由于每一幀音頻或者視頻數(shù)據(jù)都有PTS,也就是說已經設定好了這一幀數(shù)據(jù)應該播放的時間點,而音視頻同步要做的就是萧恕,當解碼出來的幀的時間戳還沒到播放的時間節(jié)點時刚梭,我們需要等待,一直等到播放的時間節(jié)點到來票唆。

音視頻同步的方法不止一種朴读,我選擇大家比較容易理解的一種來描述:選擇一條獨立的時間軸,每次音頻或者視頻解碼出來之后的時間戳與獨立時間軸的當前時間戳進行比較走趋,如果大于當前時間戳衅金,表示該幀數(shù)據(jù)還沒有到展示的時候,需要等待,否則就直接展示氮唯。

如何實現(xiàn)呢鉴吹?比較簡單,在開始解碼時的時間設為獨立時間軸的起點startPresentationTimeUs惩琉,后續(xù)的解碼回調中和這個時間起點進行比較即可

// 開始解碼時調用豆励,并記錄一下時間起點
@CallSuper
 override fun start() {
    if (startPresentationTimeUs == -1L){
        startPresentationTimeUs = getMicroSecondTime()
    }
}

protected fun getMicroSecondTime():Long{
    return System.nanoTime()/1000L
}

// 每次準備播放音頻或者視頻時調用一次,
protected fun trySleep(sampleTime:Long){
    val standAlonePassTime = getMicroSecondTime()-startPresentationTimeUs
    if (sampleTime>standAlonePassTime){
        try {
            val sleepTime = (sampleTime-standAlonePassTime)/1000
            Log.i(TAG,"sleep time $sampleTime  ${sleepTime}ms  $this")
            // 如果時間不夠琳水,就休眠
            Thread.sleep(sleepTime)
        }catch (e:InterruptedException){
            e.printStackTrace()
        }
    }
}

這就實現(xiàn)了一個簡單的音視頻同步的邏輯了肆糕,我相信理解起來沒有太大的難度。當然在孝,如果系統(tǒng)有支持的方法我們自然不必親自實現(xiàn)同步邏輯诚啃,在Android體系中,有MediaSync可以幫助我們實現(xiàn)音視頻播放同步的邏輯私沮,使用起來不算太復雜始赎,不過它也同樣深度嵌套到音視頻的解碼過程中去了,這個留給大家去熟悉吧仔燕。

除了音視頻同步這個重要內容外造垛,其實還有播放/暫停,這個過程也會影響到音視頻同步的邏輯晰搀,因為播放暫停時五辽,每幀數(shù)據(jù)的顯示時間戳PTS不會變,但是我們建立的獨立時間軸的時間會繼續(xù)流逝外恕,等恢復之后杆逗,在比較時間戳就完全錯誤了,因此我們需要在暫停和恢復時記錄一下暫停的時長鳞疲,然后在比較時減去這段時間罪郊,又或者直接把獨立時間軸的起點時間往后挪動暫停時長即可。

此外尚洽,播放過程中獲取預覽圖悔橄,播放進度條等內容也是基本內容,我認為它們并沒有比音視頻同步更難以理解腺毫,因此不一一說明了癣疟。

Android當然有支持較好的播放器可以同時播放音頻和視頻,而且還能自動幫助我們解碼數(shù)據(jù)潮酒,這些我相信大家是更了解的睛挚。

總結

到此,Android的音視頻開發(fā)框架基本描述完整了澈灼,它涵蓋了音視頻的創(chuàng)建,編碼,保存叁熔,提取委乌,解碼,播放的全過程荣回,當然每個部分只是囫圇吞棗的介紹遭贸,代碼也不是完整,其實這里里面很多內容都可以單列一章來講心软,細節(jié)頗多壕吹,不過我認為作為一個簡介性質的文章深度是夠了的,主要側重于介紹概念和使用方法删铃。后續(xù)深入研究還靠自己耳贬,本身的水平也有限。

?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末猎唁,一起剝皮案震驚了整個濱河市咒劲,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌诫隅,老刑警劉巖腐魂,帶你破解...
    沈念sama閱讀 219,427評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異逐纬,居然都是意外死亡蛔屹,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,551評論 3 395
  • 文/潘曉璐 我一進店門豁生,熙熙樓的掌柜王于貴愁眉苦臉地迎上來兔毒,“玉大人,你說我怎么就攤上這事沛硅⊙廴校” “怎么了?”我有些...
    開封第一講書人閱讀 165,747評論 0 356
  • 文/不壞的土叔 我叫張陵摇肌,是天一觀的道長擂红。 經常有香客問我,道長围小,這世上最難降的妖魔是什么昵骤? 我笑而不...
    開封第一講書人閱讀 58,939評論 1 295
  • 正文 為了忘掉前任,我火速辦了婚禮肯适,結果婚禮上变秦,老公的妹妹穿的比我還像新娘。我一直安慰自己框舔,他們只是感情好蹦玫,可當我...
    茶點故事閱讀 67,955評論 6 392
  • 文/花漫 我一把揭開白布赎婚。 她就那樣靜靜地躺著,像睡著了一般樱溉。 火紅的嫁衣襯著肌膚如雪挣输。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 51,737評論 1 305
  • 那天福贞,我揣著相機與錄音撩嚼,去河邊找鬼。 笑死挖帘,一個胖子當著我的面吹牛完丽,可吹牛的內容都是我干的。 我是一名探鬼主播拇舀,決...
    沈念sama閱讀 40,448評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼逻族,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了你稚?” 一聲冷哼從身側響起瓷耙,我...
    開封第一講書人閱讀 39,352評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎刁赖,沒想到半個月后搁痛,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經...
    沈念sama閱讀 45,834評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡宇弛,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 37,992評論 3 338
  • 正文 我和宋清朗相戀三年鸡典,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片枪芒。...
    茶點故事閱讀 40,133評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡彻况,死狀恐怖,靈堂內的尸體忽然破棺而出舅踪,到底是詐尸還是另有隱情纽甘,我是刑警寧澤,帶...
    沈念sama閱讀 35,815評論 5 346
  • 正文 年R本政府宣布抽碌,位于F島的核電站悍赢,受9級特大地震影響,放射性物質發(fā)生泄漏货徙。R本人自食惡果不足惜左权,卻給世界環(huán)境...
    茶點故事閱讀 41,477評論 3 331
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望痴颊。 院中可真熱鬧赏迟,春花似錦、人聲如沸蠢棱。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,022評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至糕再,卻和暖如春谤职,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背亿鲜。 一陣腳步聲響...
    開封第一講書人閱讀 33,147評論 1 272
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留冤吨,地道東北人蒿柳。 一個月前我還...
    沈念sama閱讀 48,398評論 3 373
  • 正文 我出身青樓,卻偏偏與公主長得像漩蟆,于是被迫代替她去往敵國和親垒探。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 45,077評論 2 355

推薦閱讀更多精彩內容