【Android 音视频开发打怪升级:音视频硬解码篇】三、音视频播放:音视频同步

/**

  • 读取音视频数据
    */
    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"?>

<android.support.constraint.ConstraintLayout
xmlns:android=“http://schemas.android.com/apk/res/android”
xmlns:tools=“http://schemas.android.com/tools”
xmlns:app=“http://schemas.android.com/apk/res-auto”
android:layout_width=“match_parent”
android:layout_height=“match_parent”
tools:context=“.MainActivity”>

</android.support.constraint.ConstraintLayout>

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,否则直接渲染。

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

最后

感觉现在好多人都在说什么安卓快凉了,工作越来越难找了。又是说什么程序员中年危机啥的,为啥我这年近30的老农根本没有这种感觉,反倒觉得那些贩卖焦虑的都是瞎j8扯谈。当然,职业危机意识确实是要有的,但根本没到那种草木皆兵的地步好吗?

Android凉了都是弱者的借口和说辞。虽然 Android 没有前几年火热了,已经过去了会四大组件就能找到高薪职位的时代了。这只能说明 Android 中级以下的岗位饱和了,现在高级工程师还是比较缺少的,很多高级职位给的薪资真的特别高(钱多也不一定能找到合适的),所以努力让自己成为高级工程师才是最重要的。

所以,最后这里放上我耗时两个月,将自己8年Android开发的知识笔记整理成的Android开发者必知必会系统学习资料笔记,上述知识点在笔记中都有详细的解读,里面还包含了腾讯、字节跳动、阿里、百度2019-2021面试真题解析,并且把每个技术点整理成了视频和PDF(知识脉络 + 诸多细节)。

以上全套学习笔记面试宝典,吃透一半保你可以吊打面试官,只有自己真正强大了,有核心竞争力,你才有拒绝offer的权力,所以,奋斗吧!骚年们!千里之行,始于足下。种下一颗树最好的时间是十年前,其次,就是现在。

最后,赠与大家一句诗,共勉!

不驰于空想,不骛于虚声。不忘初心,方得始终。
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!
但根本没到那种草木皆兵的地步好吗?

Android凉了都是弱者的借口和说辞。虽然 Android 没有前几年火热了,已经过去了会四大组件就能找到高薪职位的时代了。这只能说明 Android 中级以下的岗位饱和了,现在高级工程师还是比较缺少的,很多高级职位给的薪资真的特别高(钱多也不一定能找到合适的),所以努力让自己成为高级工程师才是最重要的。

所以,最后这里放上我耗时两个月,将自己8年Android开发的知识笔记整理成的Android开发者必知必会系统学习资料笔记,上述知识点在笔记中都有详细的解读,里面还包含了腾讯、字节跳动、阿里、百度2019-2021面试真题解析,并且把每个技术点整理成了视频和PDF(知识脉络 + 诸多细节)。

[外链图片转存中…(img-meF09je5-1715021845470)]

以上全套学习笔记面试宝典,吃透一半保你可以吊打面试官,只有自己真正强大了,有核心竞争力,你才有拒绝offer的权力,所以,奋斗吧!骚年们!千里之行,始于足下。种下一颗树最好的时间是十年前,其次,就是现在。

最后,赠与大家一句诗,共勉!

不驰于空想,不骛于虚声。不忘初心,方得始终。
《Android学习笔记总结+移动架构视频+大厂面试真题+项目实战源码》点击传送门,即可获取!

  • 34
    点赞
  • 35
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值