搭建直播平台中Android 音视频如何做到音视频同步

一、音视频数据流分离提取器

上篇文章,多次提到音视频数据分离提取器,在实现音视频解码器子类之前,先把这个实现了。

封装Android原生提取器

之前提过,Android原生自带有一个MediaExtractor,用于音视频数据分离和提取,接来下就基于这个,做一个支持音视频提取的工具类MMExtractor:

class MMExtractor(path: String?) {    /**音视频分离器*/    private var mExtractor: MediaExtractor? = null        /**音频通道索引*/    private var mAudioTrack = -1        /**视频通道索引*/    private var mVideoTrack = -1        /**当前帧时间戳*/    private var mCurSampleTime: Long = 0        /**开始解码时间点*/    private var mStartPos: Long = 0    init {        //【1,初始化】        mExtractor = MediaExtractor()        mExtractor?.setDataSource(path)    }    /**     * 获取视频格式参数     */    fun getVideoFormat(): MediaFormat? {        //【2.1,获取视频多媒体格式】        for (i in 0 until mExtractor!!.trackCount) {            val mediaFormat = mExtractor!!.getTrackFormat(i)            val mime = mediaFormat.getString(MediaFormat.KEY_MIME)            if (mime.startsWith("video/")) {                mVideoTrack = i                break            }        }        return if (mVideoTrack >= 0)            mExtractor!!.getTrackFormat(mVideoTrack)        else null    }    /**     * 获取音频格式参数     */    fun getAudioFormat(): MediaFormat? {        //【2.2,获取音频频多媒体格式】        for (i in 0 until mExtractor!!.trackCount) {            val mediaFormat = mExtractor!!.getTrackFormat(i)            val mime = mediaFormat.getString(MediaFormat.KEY_MIME)            if (mime.startsWith("audio/")) {                mAudioTrack = i                break            }        }        return if (mAudioTrack >= 0) {            mExtractor!!.getTrackFormat(mAudioTrack)        } else null    }    /**     * 读取视频数据     */    fun readBuffer(byteBuffer: ByteBuffer): Int {        //【3,提取数据】        byteBuffer.clear()        selectSourceTrack()        var readSampleCount = mExtractor!!.readSampleData(byteBuffer, 0)        if (readSampleCount < 0) {            return -1        }        mCurSampleTime = mExtractor!!.sampleTime        mExtractor!!.advance()        return readSampleCount    }    /**     * 选择通道     */    private fun selectSourceTrack() {        if (mVideoTrack >= 0) {            mExtractor!!.selectTrack(mVideoTrack)        } else if (mAudioTrack >= 0) {            mExtractor!!.selectTrack(mAudioTrack)        }    }    /**     * Seek到指定位置,并返回实际帧的时间戳     */    fun seek(pos: Long): Long {        mExtractor!!.seekTo(pos, MediaExtractor.SEEK_TO_PREVIOUS_SYNC)        return mExtractor!!.sampleTime    }    /**     * 停止读取数据     */    fun stop() {        //【4,释放提取器】        mExtractor?.release()        mExtractor = null    }    fun getVideoTrack(): Int {        return mVideoTrack    }    fun getAudioTrack(): Int {        return mAudioTrack    }    fun setStartPos(pos: Long) {        mStartPos = pos    }    /**     * 获取当前帧时间     */    fun getCurrentTimestamp(): Long {        return mCurSampleTime    }}复制代码

比较简单,直接把代码贴出来了。

关键部分有5个,做一下简单讲解:

  • 【1,初始化】

很简单,两句代码:新建,然后设置音视频文件路径

mExtractor = MediaExtractor()mExtractor?.setDataSource(path)复制代码
  • 【2.1/2.2,获取音视频多媒体格式】

音频和视频是一样的:

1)遍历视频文件中所有的通道,一般是音频和视频两个通道;

2) 然后获取对应通道的编码格式,判断是否包含"video/"或者"audio/"开头的编码格式;

3)最后通过获取的索引,返回对应的音视频多媒体格式信息。

  • 【3,提取数据】

重点看看如何提取数据:

1)readBuffer(byteBuffer: ByteBuffer)中的参数就是解码器传进来的,用于存放待解码数据的缓冲区。

2)selectSourceTrack()方法中,根据当前选择的通道(同时只选择一个音/视频通道),调用mExtractor!!.selectTrack(mAudioTrack)将通道切换正确。

3)然后读取数据:

var readSampleCount = mExtractor!!.readSampleData(byteBuffer, 0)复制代码

此时,将返回读取到的音视频数据流的大小,小于0表示数据已经读完。

4)进入下一帧:先记录当前帧的时间戳,然后调用advance进入下一帧,这时读取指针将自动移动到下一帧开头。

//记录当前帧的时间戳mCurSampleTime = mExtractor!!.sampleTime//进入下一帧mExtractor!!.advance()复制代码
  • 【4,释放提取器】

客户端退出解码的时候,需要调用stop是否提取器相关资源。

说明:seek(pos: Long)方法,主要用于跳播,快速将数据定位到指定的播放位置,但是,由于视频中,除了I帧以外,PB帧都需要依赖其他的帧进行解码,所以,通常只能seek到I帧,但是I帧通常和指定的播放位置有一定误差,因此需要指定seek靠近哪个关键帧,有以下三种类型:
SEEK_TO_PREVIOUS_SYNC:跳播位置的上一个关键帧
SEEK_TO_NEXT_SYNC:跳播位置的下一个关键帧
SEEK_TO_CLOSEST_SYNC:距离跳播位置的最近的关键帧

到这里你就可以明白,为什么我们平时在看视频时,拖动进度条释放以后,视频通常会在你释放的位置往前一点

封装音频和视频提取器

上面封装的工具中,可以支持音频和视频的数据提取,下面我们将利用这个工具,用于分别提取音频和视频的数据。

先回顾一下,上篇文章定义的提取器模型:

interface IExtractor {    fun getFormat(): MediaFormat?    /**     * 读取音视频数据     */    fun readBuffer(byteBuffer: ByteBuffer): Int    /**     * 获取当前帧时间     */    fun getCurrentTimestamp(): Long    /**     * Seek到指定位置,并返回实际帧的时间戳     */    fun seek(pos: Long): Long    fun setStartPos(pos: Long)    /**     * 停止读取数据     */    fun stop()}复制代码

有了上面封装的工具,一切就变得很简单了,做一个代理转接就行了。

  • 视频提取器
class VideoExtractor(path: String): IExtractor {    private val mMediaExtractor = MMExtractor(path)    override fun getFormat(): MediaFormat? {        return mMediaExtractor.getVideoFormat()    }    override fun readBuffer(byteBuffer: ByteBuffer): Int {        return mMediaExtractor.readBuffer(byteBuffer)    }    override fun getCurrentTimestamp(): Long {        return mMediaExtractor.getCurrentTimestamp()    }    override fun seek(pos: Long): Long {        return mMediaExtractor.seek(pos)    }    override fun setStartPos(pos: Long) {        return mMediaExtractor.setStartPos(pos)    }    override fun stop() {        mMediaExtractor.stop()    }}复制代码
  • 音频提取器
class AudioExtractor(path: String): IExtractor {    private val mMediaExtractor = MMExtractor(path)    override fun getFormat(): MediaFormat? {        return mMediaExtractor.getAudioFormat()    }    override fun readBuffer(byteBuffer: ByteBuffer): Int {        return mMediaExtractor.readBuffer(byteBuffer)    }    override fun getCurrentTimestamp(): Long {        return mMediaExtractor.getCurrentTimestamp()    }    override fun seek(pos: Long): Long {        return mMediaExtractor.seek(pos)    }    override fun setStartPos(pos: Long) {        return mMediaExtractor.setStartPos(pos)    }    override fun stop() {        mMediaExtractor.stop()    }}复制代码

二、视频播放

我们先来定义一个视频解码器子类,继承BaseDecoder

class VideoDecoder(path: String,                   sfv: SurfaceView?,                   surface: Surface?): BaseDecoder(path) {    private val TAG = "VideoDecoder"        private val mSurfaceView = sfv    private var mSurface = surface        override fun check(): Boolean {        if (mSurfaceView == null && mSurface == null) {            Log.w(TAG, "SurfaceView和Surface都为空,至少需要一个不为空")            mStateListener?.decoderError(this, "显示器为空")            return false        }        return true    }    override fun initExtractor(path: String): IExtractor {        return VideoExtractor(path)    }    override fun initSpecParams(format: MediaFormat) {    }    override fun configCodec(codec: MediaCodec, format: MediaFormat): Boolean {        if (mSurface != null) {            codec.configure(format, mSurface , null, 0)            notifyDecode()        } else {            mSurfaceView?.holder?.addCallback(object : SurfaceHolder.Callback2 {                override fun surfaceRedrawNeeded(holder: SurfaceHolder) {                }                override fun surfaceChanged(holder: SurfaceHolder, format: Int, width: Int, height: Int) {                }                override fun surfaceDestroyed(holder: SurfaceHolder) {                }                override fun surfaceCreated(holder: SurfaceHolder) {                    mSurface = holder.surface                    configCodec(codec, format)                }            })            return false        }        return true    }    override fun initRender(): Boolean {        return true    }    override fun render(outputBuffers: ByteBuffer,                        bufferInfo: MediaCodec.BufferInfo) {    }    override fun doneDecode() {    }}

上篇文章中,定义好了解码流程框架,子类定义就很简单清晰了,只需按部就班,填写基类中预留的虚函数即可。

  • 检查参数

可以看到,视频解码支持两种类型渲染表面,一个是SurfaceView,一个Surface。当其实最后都是传递Surface给MediaCodec

  1. SurfaceView应该是大家比较熟悉的View了,最常使用的就是用来做MediaPlayer的显示。当然也可以绘制图片、动画等。
  2. Surface应该不是很常用了,这里为了支持后续使用OpenGL来渲染视频,所以预先做了支持。
  • 生成数据提取器
override fun initExtractor(path: String): IExtractor {    return VideoExtractor(path)}复制代码

配置解码器

解码器的配置只需一句代码:

codec.configure(format, mSurface , null, 0)复制代码

不知道在上一篇文章,你有没有发现,在BaseDecoder初始化解码器的方法initCodec()中, 调用了configCodec方法后,会进入waitDecode方法,将线程挂起。

abstract class BaseDecoder(private val mFilePath: String): IDecoder {    //省略其他    ......        private fun initCodec(): Boolean {        try {            val type = mExtractor!!.getFormat()!!.getString(MediaFormat.KEY_MIME)            mCodec = MediaCodec.createDecoderByType(type)            if (!configCodec(mCodec!!, mExtractor!!.getFormat()!!)) {                waitDecode()            }            mCodec!!.start()                    mInputBuffers = mCodec?.inputBuffers            mOutputBuffers = mCodec?.outputBuffers        } catch (e: Exception) {            return false        }        return true    }}复制代码

初始化Surface

就是因为考虑到一个问题,SurfaceView的创建是有一个时间过程的,并非马上可以使用,需要通过CallBack来监听它的状态。

在surface初始化完毕后,再配置MediaCodec。

override fun surfaceCreated(holder: SurfaceHolder) {    mSurface = holder.surface    configCodec(codec, format)}复制代码

如果使用OpenGL直接传递surface进来,直接配置MediaCodec即可。

渲染

上文提到过,视频的渲染并不需要客户端手动去渲染,只需提供绘制表面surface,调用releaseOutputBuffer,将2个参数设置为true即可。所以,这里也不用在做什么操作了。

mCodec!!.releaseOutputBuffer(index, true)复制代码

三、音频播放

有了上面视频播放器的基础以后,音频播放器也是分分钟搞定的事了。

class AudioDecoder(path: String): BaseDecoder(path) {    /**采样率*/    private var mSampleRate = -1        /**声音通道数量*/    private var mChannels = 1    /**PCM采样位数*/    private var mPCMEncodeBit = AudioFormat.ENCODING_PCM_16BIT    /**音频播放器*/    private var mAudioTrack: AudioTrack? = null    /**音频数据缓存*/    private var mAudioOutTempBuf: ShortArray? = null        override fun check(): Boolean {        return true    }    override fun initExtractor(path: String): IExtractor {        return AudioExtractor(path)    }    override fun initSpecParams(format: MediaFormat) {        try {            mChannels = format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)            mSampleRate = format.getInteger(MediaFormat.KEY_SAMPLE_RATE)            mPCMEncodeBit = if (format.containsKey(MediaFormat.KEY_PCM_ENCODING)) {                format.getInteger(MediaFormat.KEY_PCM_ENCODING)            } else {                //如果没有这个参数,默认为16位采样                AudioFormat.ENCODING_PCM_16BIT            }        } catch (e: Exception) {        }    }    override fun configCodec(codec: MediaCodec, format: MediaFormat): Boolean {        codec.configure(format, null , null, 0)        return true    }    override fun initRender(): Boolean {        val channel = if (mChannels == 1) {            //单声道            AudioFormat.CHANNEL_OUT_MONO        } else {            //双声道            AudioFormat.CHANNEL_OUT_STEREO        }        //获取最小缓冲区        val minBufferSize = AudioTrack.getMinBufferSize(mSampleRate, channel, mPCMEncodeBit)        mAudioOutTempBuf = ShortArray(minBufferSize/2)        mAudioTrack = AudioTrack(            AudioManager.STREAM_MUSIC,//播放类型:音乐            mSampleRate, //采样率            channel, //通道            mPCMEncodeBit, //采样位数            minBufferSize, //缓冲区大小            AudioTrack.MODE_STREAM) //播放模式:数据流动态写入,另一种是一次性写入                    mAudioTrack!!.play()        return true    }    override fun render(outputBuffer: ByteBuffer,                        bufferInfo: MediaCodec.BufferInfo) {        if (mAudioOutTempBuf!!.size < bufferInfo.size / 2) {            mAudioOutTempBuf = ShortArray(bufferInfo.size / 2)        }        outputBuffer.position(0)        outputBuffer.asShortBuffer().get(mAudioOutTempBuf, 0, bufferInfo.size/2)        mAudioTrack!!.write(mAudioOutTempBuf!!, 0, bufferInfo.size / 2)    }    override fun doneDecode() {        mAudioTrack?.stop()        mAudioTrack?.release()    }}复制代码

初始化流程和视频是一样的,不一样的地方有三个:

1. 初始化解码器

音频不需要surface,直接传null

codec.configure(format, null , null, 0)复制代码

2. 获取参数不一样

音频播放需要获取采样率,通道数,采样位数等

3. 需要初始化一个音频渲染器:AudioTrack

由于解码出来的数据是PCM数据,所以直接使用AudioTrack播放即可。在initRender() 中对其进行初始化。

  • 根据通道数量配置单声道和双声道
  • 根据采样率、通道数、采样位数计算获取最小缓冲区
AudioTrack.getMinBufferSize(mSampleRate, channel, mPCMEncodeBit)复制代码
  • 创建AudioTrack,并启动
mAudioTrack = AudioTrack(            AudioManager.STREAM_MUSIC,//播放类型:音乐            mSampleRate, //采样率            channel, //通道            mPCMEncodeBit, //采样位数            minBufferSize, //缓冲区大小            AudioTrack.MODE_STREAM) //播放模式:数据流动态写入,另一种是一次性写入            mAudioTrack!!.play()复制代码

4. 手动渲染音频数据,实现播放

最后就是将解码出来的数据写入AudioTrack,实现播放。

有一点注意的点是,需要把解码数据由ByteBuffer类型转换为ShortBuffer,这时Short数据类型的长度要减半。

四、调用并播放

以上,基本实现了音视频的播放流程,如无意外,在页面上调用以上音视频解码器,就可以实现播放了。

简单看下页面和相关调用。

main_activity.xml

<?xml version="1.0" encoding="utf-8"?>    复制代码

MainActivity.kt

class MainActivity : AppCompatActivity() {    override fun onCreate(savedInstanceState: Bundle?) {        super.onCreate(savedInstanceState)        setContentView(R.layout.activity_main)        initPlayer()    }    private fun initPlayer() {        val path = Environment.getExternalStorageDirectory().absolutePath + "/mvtest.mp4"                //创建线程池        val threadPool = Executors.newFixedThreadPool(2)                //创建视频解码器        val videoDecoder = VideoDecoder(path, sfv, null)        threadPool.execute(videoDecoder)        //创建音频解码器        val audioDecoder = AudioDecoder(path)        threadPool.execute(audioDecoder)                //开启播放        videoDecoder.goOn()        audioDecoder.goOn()    }}复制代码

至此,基本上实现音视频的解码和播放。但是如果你真正把代码跑起来的话,你会发现:视频和音频为什么不同步啊,视频就像倍速播放一样,一下就播完了,但是音频却很正常。

这就要引出下一个不可避免的问题了,那就是音视频同步。

五、音视频同步

同步信号来源

由于视频和音频是两个独立的任务在运行,视频和音频的解码速度也不一样,解码出来的数据也不一定马上就可以显示出来。

在第一篇文章的时候有说过,解码有两个重要的时间参数:PTS和DTS,分别用于表示渲染的时间和解码时间,这里就需要用到PTS。

播放器中一般存在三个时间,音频的时间,视频的时间,还有另外一个就是系统时间。这样可以用来实现同步的时间源就有三个:

  • 视频时间戳
  • 音频时间戳
  • 外部时间戳
  • 视频PTS

通常情况下,由于人类对声音比较敏感,并且视频解码的PTS通常不是连续,而音频的PTS是比较连续的,如果以视频为同步信号源的话,基本上声音都会出现异常,而画面的播放也会像倍速播放一样。

  • 音频PTS

那么剩下的两个选择中,以音频的PTS作为同步源,让画面适配音频是比较不错的一种选择。

但是这里不采用,而是使用系统时间作为同步信号源。因为如果以音频PTS作为同步源的话,需要比较复杂的同步机制,音频和视频两者之间也有比较多的耦合。

  • 系统时间

而系统时间作为统一信号源则非常适合,音视频彼此独立互不干扰,同时又可以保证基本一致。

实现音视频同步

要实现音视频之间的同步,这里需要考虑的有两个点:

1. 比对

在解码数据出来以后,检查PTS时间戳和当前系统流过的时间差距,快则延时,慢则

直接播放

2. 矫正

在进入暂停或解码结束,重新恢复播放时,需要将系统流过的时间做一下矫正,将暂停的时间减去,恢复真正的流逝时间,即已播放时间。

重新看回BaseDecoder解码流程:

abstract class BaseDecoder(private val mFilePath: String): IDecoder {    //省略其他    ......        /**     * 开始解码时间,用于音视频同步     */    private var mStartTimeForSync = -1L    final override fun run() {        if (mState == DecodeState.STOP) {            mState = DecodeState.START        }        mStateListener?.decoderPrepare(this)        //【解码步骤:1. 初始化,并启动解码器】        if (!init()) return        Log.i(TAG, "开始解码")        while (mIsRunning) {            if (mState != DecodeState.START &&                mState != DecodeState.DECODING &&                mState != DecodeState.SEEKING) {                Log.i(TAG, "进入等待:$mState")                                waitDecode()                                // ---------【同步时间矫正】-------------                //恢复同步的起始时间,即去除等待流失的时间                mStartTimeForSync = System.currentTimeMillis() - getCurTimeStamp()            }            if (!mIsRunning ||                mState == DecodeState.STOP) {                mIsRunning = false                break            }            if (mStartTimeForSync == -1L) {                mStartTimeForSync = System.currentTimeMillis()            }            //如果数据没有解码完毕,将数据推入解码器解码            if (!mIsEOS) {                //【解码步骤:2. 见数据压入解码器输入缓冲】                mIsEOS = pushBufferToDecoder()            }            //【解码步骤:3. 将解码好的数据从缓冲区拉取出来】            val index = pullBufferFromDecoder()            if (index >= 0) {                // ---------【音视频同步】-------------                if (mState == DecodeState.DECODING) {                    sleepRender()                }                //【解码步骤:4. 渲染】                render(mOutputBuffers!![index], mBufferInfo)                //【解码步骤:5. 释放输出缓冲】                mCodec!!.releaseOutputBuffer(index, true)                if (mState == DecodeState.START) {                    mState = DecodeState.PAUSE                }            }            //【解码步骤:6. 判断解码是否完成】            if (mBufferInfo.flags == MediaCodec.BUFFER_FLAG_END_OF_STREAM) {                Log.i(TAG, "解码结束")                mState = DecodeState.FINISH                mStateListener?.decoderFinish(this)            }        }        doneDecode()        release()    }}复制代码
  • 在不考虑暂停、恢复的情况下,什么时候进行时间同步呢?

答案是:数据解码出来以后,渲染之前。

解码器进入解码状态以后,来到【解码步骤:3. 将解码好的数据从缓冲区拉取出来】,这时如果数据是有效的,那么进入比对。

// ---------【音视频同步】-------------final override fun run() {        //......        //【解码步骤:3. 将解码好的数据从缓冲区拉取出来】    val index = pullBufferFromDecoder()    if (index >= 0) {        // ---------【音视频同步】-------------        if (mState == DecodeState.DECODING) {            sleepRender()        }        //【解码步骤:4. 渲染】        render(mOutputBuffers!![index], mBufferInfo)        //【解码步骤:5. 释放输出缓冲】        mCodec!!.releaseOutputBuffer(index, true)        if (mState == DecodeState.START) {            mState = DecodeState.PAUSE        }    }        //......}private fun sleepRender() {    val passTime = System.currentTimeMillis() - mStartTimeForSync    val curTime = getCurTimeStamp()    if (curTime > passTime) {        Thread.sleep(curTime - passTime)    }}override fun getCurTimeStamp(): Long {    return mBufferInfo.presentationTimeUs / 1000}复制代码

同步的原理如下:

进入解码前,获取当前系统时间,存放在mStartTimeForSync,一帧数据解码出来以后,计算当前系统时间和mStartTimeForSync的距离,也就是已经播放的时间,如果当前帧的PTS大于流失的时间,进入sleep,否则直接渲染。

  • 考虑暂停情况下的时间矫正

在进入暂停以后,由于系统时间一直在走,而mStartTimeForSync并没有随着系统时间累加,所以当恢复播放以后,重新将mStartTimeForSync加上这段暂停的时间段。

只不过计算方法有多种:

一种是记录暂停的时间,恢复时用系统时间减去暂停时间,就是暂停的时间段,然后用mStartTimeForSync加上这段暂停的时间段,就是新的mStartTimeForSync;

另一个种是用恢复播放时的系统时间,减去当前正要播放的帧的PTS,得出的值就是mStartTimeForSync。

这里采用第二种

if (mState != DecodeState.START &&    mState != DecodeState.DECODING &&    mState != DecodeState.SEEKING) {    Log.i(TAG, "进入等待:$mState")    waitDecode()    // ---------【同步时间矫正】-------------    //恢复同步的起始时间,即去除等待流失的时间    mStartTimeForSync = System.currentTimeMillis() - getCurTimeStamp()}复制代码

至此,从解码到播放,再到音视频同步,一个简单的播放器就做完了。

 

更多相关文章

  1. “罗永浩抖音首秀”销售数据的可视化大屏是怎么做出来的呢?
  2. Nginx系列教程(三)| 一文带你读懂Nginx的负载均衡
  3. 不吹不黑!GitHub 上帮助人们学习编码的 12 个资源,错过血亏...
  4. Android(安卓)Studio自带数据库SQLite的用法部分总结
  5. android的hashmap 原理以及源码探究
  6. Android之SQLite分页表格
  7. android oauth 微博客户端 架构一
  8. SharedPreferences初学~个人备忘录以及对进入APP的次数进行计数
  9. android 录音事件

随机推荐

  1. 谈中型项目下的编码技巧二
  2. Android(安卓)LinearLayout 点击背景颜色
  3. Android(安卓)使用AES/CBC/PKCS7Padding
  4. android ListView向上滑动隐藏标题,下拉显
  5. Android中使用Geocoding API
  6. Android实现的视频背景
  7. Android(安卓)开发学习笔记(一)—— 四大组
  8. OkHttp得拦截机制
  9. Android(安卓)AMS(一) App启动过程之Task
  10. 多个项目Module全局配置