
【声 明】
首先,这一系列文章均基于自己的理解和实践,可能有不对的地方,欢迎大家指正。
其次,这是一个入门系列,涉及的知识也仅限于够用,深入的知识网上也有许许多多的博文供大家学习了。
最后,写文章过程中,会借鉴参考其他人分享的文章,会在文章最后列出,感谢这些作者的分享。
码字不易,转载请注明出处!
教程代码:【Github传送门】 |
---|
目录
一、Android音视频硬解码篇:
- 1,音视频基础知识
- 2,音视频硬解码流程
- 3,音视频播放:音视频同步
- 4,音视频解封和封装:生成一个MP4
二、使用OpenGL渲染视频画面篇
- 1,初步了解OpenGL ES
- 2,使用OpenGL渲染视频画面
- 3,OpenGL渲染多视频,实现画中画
- 4,深入了解OpenGL之EGL
- 5,OpenGL FBO数据缓冲区
- 6,Android音视频硬编码:生成一个MP4
三、Android FFmpeg音视频解码篇
- 1,FFmpeg so库编译
- 2,Android 引入FFmpeg
- 3,Android FFmpeg视频解码播放
- 4,Android FFmpeg+OpenSL ES音频解码播放
- 5,Android FFmpeg+OpenGL ES播放视频
- 6,Android FFmpeg简单合成MP4:视屏解封与重新封装
- 7,Android FFmpeg视频编码
本文你可以了解到
上一篇文章,主要讲了Android MediaCodec实现音视频硬解码的流程,搭建了基础解码框架。本文将讲解具体的音视频渲染,包括MediaCodec初始化、Surface初始化,AudioTrack初始化、音视频数据流分离提取等,以及非常重要的音视频同步。
在上一篇文章定义的解码流程框架基类中,预留了几个虚函数,留给子类初始化自己的东西,本篇,就来看看如何实现。
一、音视频数据流分离提取器
上篇文章,多次提到音视频数据分离提取器,在实现音视频解码器子类之前,先把这个实现了。
封装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
- SurfaceView应该是大家比较熟悉的View了,最常使用的就是用来做MediaPlayer的显示。当然也可以绘制图片、动画等。
- 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"> <SurfaceView android:id="@+id/sfv" app:layout_constraintTop_toTopOf="parent" android:layout_width="match_parent" android:layout_height="200dp"/></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,否则直接渲染。
- 考虑暂停情况下的时间矫正
在进入暂停以后,由于系统时间一直在走,而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()}
至此,从解码到播放,再到音视频同步,一个简单的播放器就做完了。
下一篇,将会简单介绍如何使用Android提供的MediaMuxer封装Mp4,不会涉及到编码和解码,只涉及数据的解封和封装,为后面的【解封装->解码->编辑->编码->封装】全流程作准备。

原著是一个有趣的人,若有侵权,请通知删除
还没有人抢沙发呢~