Camera開發系列之四-使用MediaMuxer封裝編碼後的音視訊到mp4容器

章節
Camera開發系列之四-使用MediaMuxer封裝編碼後的音視訊到mp4容器
Camera開發系列之五-使用MediaExtractor製作一個簡易播放器
Camera開發系列之七-使用GLSurfaceviw繪製Camera預覽畫面
前幾篇的文章中,我們已經能夠獲取到h264格式的視訊裸流和pcm格式的音訊資料了,而使用MediaMuxer這個工具,則可以將我們處理過的音視訊資料封裝到mp4容器裡。
1. MediaMuxer簡單介紹
學習一個從來沒接觸過的東西,當然先從官方文件給開始看啦,下面是MediaMuxer的主要方法:
-
int addTrack(@NonNull MediaFormat format):
一個視訊檔案是包含一個或多個音視訊軌道的,而這個方法就是用於新增一個視訊或視訊軌道,並返回對應的ID。之後我們可以通過這個ID向相應的軌道寫入資料。 -
void start():
當我們新增完所有音視訊軌道之後,需要呼叫這個方法告訴Muxer,我要開始寫入資料了。需要注意的是,呼叫了這個方法之後,我們是無法再次addTrack
了的。 -
void writeSampleData(int trackIndex, ByteBuffer byteBuf, MediaCodec.BufferInfo bufferInfo):
用於向Muxer寫入編碼後的音視訊資料。trackIndex是我們addTrack的時候返回的ID,byteBuf便是要寫入的資料,而bufferInfo是跟這一幀byteBuf相關的資訊,包括時間戳、資料長度和資料在ByteBuffer中的位移。 -
void stop() :
與start()
相對應,用於停止寫入資料。
MediaMuxer中使用的方法就介紹完了,真是個又短又實用的工具( ̄▽ ̄)/。那這玩意兒怎麼用呢?也很簡單,沒有繁瑣的呼叫方法,只需要四步就搞定:
- 初始化MediaMuxer
- 新增音訊軌/視訊軌
- 喂資料
- 處理完資料之後釋放物件
具體的程式碼如下:
MediaMuxer mMuxer = new MediaMuxer(path, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);//第一步,其中第一個引數為合成的mp4儲存路徑,第二個引數是格式為MP4 //第二步 public void addTrack(MediaFormat format,boolean isVideo){ Log.e( TAG,"新增音訊軌和視訊軌"); if (mAudioTrackIndex != -1 && mVideoTrackIndex != -1){ new RuntimeException("already addTrack"); } int track = mMuxer.addTrack(format); if (isVideo){ mVideoFormat = format; mVideoTrackIndex = track; }else { mAudioFormat = format; mAudioTrackIndex = track; } if (mVideoTrackIndex != -1 && mAudioTrackIndex != -1){//當音訊軌和視訊軌都新增,才start mMuxer.start(); } } //第三步 public synchronized void putStrem(ByteBuffer outputBuffer,MediaCodec.BufferInfo bufferInfo,boolean isVideo){ if (mAudioTrackIndex == -1 || mVideoTrackIndex == -1){ Log.e( TAG,"音訊軌和視訊軌沒有新增"); return; } if ((bufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0){ // The codec config data was pulled out and fed to the muxer when we got // the INFO_OUTPUT_FORMAT_CHANGED status.Ignore it. }else if (bufferInfo.size != 0){ outputBuffer.position(bufferInfo.offset); outputBuffer.limit(bufferInfo.size + bufferInfo.offset); mMuxer.writeSampleData(isVideo?mVideoTrackIndex:mAudioTrackIndex,outputBuffer,bufferInfo); } } //最後一步 public void release(){ if (mMuxer != null){ if (mAudioTrackIndex != -1 && mVideoTrackIndex != -1){ mMuxer.stop(); mMuxer.release(); mMuxer = null; } } } 複製程式碼
其中第二步需要注意的是,必須在音訊軌和視訊軌都新增完成之後,才能呼叫start方法。
2. 使用MediaMuxer
上面的程式碼可能讓各位有點懵,道理大家都懂,但是在實際使用中什麼時候新增音視訊軌,什麼時候喂資料??

在獲取編碼器輸出緩衝區時,呼叫了mediaCodec.dequeueOutputBuffer(),這個方法的返回值是一個int型別的的索引 ,當這個索引等於 MediaCodec.INFO_OUTPUT_FORMAT_CHANGED
(這個常量為-2)常量時,表示編碼器輸出快取區格式改變,通常在儲存資料之前且只會改變一次,所以這個時候新增音視訊軌最合適。
當這個索引大於0,說明已成功解碼的輸出緩衝區,這個時候的資料是有效的,可以餵給MediaMuxer了,視訊資料的寫入具體程式碼如下:
//編碼器輸出緩衝區 ByteBuffer[] outputBuffers = mediaCodec.getOutputBuffers(); MediaCodec.BufferInfo mBufferInfo = new MediaCodec.BufferInfo(); boolean isAddKeyFrame = false; int outputBufferIndex; do { outputBufferIndex = mediaCodec.dequeueOutputBuffer(mBufferInfo, TIMEOUT_USEC); if (outputBufferIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { //Log.i(TAG, "獲得編碼器輸出快取區超時"); } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_BUFFERS_CHANGED) { // 如果API小於21,APP需要重新繫結編碼器的輸入快取區; // 如果API大於21,則無需處理INFO_OUTPUT_BUFFERS_CHANGED if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { outputBuffers = mediaCodec.getOutputBuffers(); } } else if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { // 編碼器輸出快取區格式改變,通常在儲存資料之前且只會改變一次 // 這裡設定混合器視訊軌道,如果音訊已經新增則啟動混合器(保證音視訊同步) synchronized (H264EncoderConsumer.this) { MediaFormat newFormat = mediaCodec.getOutputFormat(); addTrack(newFormat, true); } //Log.i(TAG, "編碼器輸出快取區格式改變,新增視訊軌道到混合器"); } else { //因為上面的addTrackIndex方法不一定會被呼叫,所以要在此處再判斷並新增一次,這也是混合的難點之一 if (!mediaUtil.isAddVideoTrack()) { synchronized (H264EncoderConsumer.this) { MediaFormat newFormat = mediaCodec.getOutputFormat(); addTrack(newFormat, true); } } ByteBuffer outputBuffer = null; if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { outputBuffer = outputBuffers[outputBufferIndex]; } else { outputBuffer = mediaCodec.getOutputBuffer(outputBufferIndex); } // 如果API<=19,需要根據BufferInfo的offset偏移量調整ByteBuffer的位置 // 並且限定將要讀取快取區資料的長度,否則輸出資料會混亂 if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { outputBuffer.position(mBufferInfo.offset); outputBuffer.limit(mBufferInfo.offset + mBufferInfo.size); } // 判斷輸出資料是否為關鍵幀 必須在關鍵幀新增之後,再新增普通幀,不然會出現馬賽克 boolean keyFrame = (mBufferInfo.flags & BUFFER_FLAG_KEY_FRAME) != 0; if (keyFrame) { // 錄影時,第1秒畫面會靜止,這是由於音視軌沒有完全被新增 Log.i(TAG, "編碼混合,視訊關鍵幀資料(I幀)"); putStrem(outputBuffer, mBufferInfo, true); isAddKeyFrame = true; } else { // 新增視訊流到混合器 if (isAddKeyFrame) { Log.i(TAG, "編碼混合,視訊普通幀資料(B幀,P幀)" + mBufferInfo.size); putStrem(outputBuffer, mBufferInfo, true); } } // 處理結束,釋放輸出快取區資源 mediaCodec.releaseOutputBuffer(outputBufferIndex, false); } } while (outputBufferIndex >= 0); 複製程式碼
以上是視訊資料的寫入,程式碼和之前的編碼h264差不多,就不貼全部程式碼了。音訊資料寫入類似,
這裡不做過多闡述,我相信各位都是和我一樣的聰明人,不用我再貼程式碼都能依葫蘆畫瓢寫出來。

音訊編碼的程式碼如下:
public class AudioEncoder { private MediaCodec.BufferInfo mBufferInfo; private final String mime = "audio/mp4a-latm"; private int bitRate = 96000; private FileOutputStream fileOutputStream; private MediaCodec mMediaCodec; private static volatile boolean isEncoding; private static final String TAG = AudioEncoder.class.getSimpleName(); private AudioRecord mAudioRecord; private int mAudioRecordBufferSize; private static AudioEncoder mAudioEncoder; private AudioEncoder() { } public static AudioEncoder getInstance() { if (mAudioEncoder == null) { synchronized (AudioEncoder.class) { if (mAudioEncoder == null) { mAudioEncoder = new AudioEncoder(); } } } return mAudioEncoder; } public AudioEncoder setEncoderParams(EncoderParams params) { try { mMediaCodec = MediaCodec.createEncoderByType(mime); MediaFormat mediaFormat = new MediaFormat(); mediaFormat.setString(MediaFormat.KEY_MIME, mime); mediaFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC); mediaFormat.setInteger(MediaFormat.KEY_BIT_RATE, bitRate); mediaFormat.setInteger(MediaFormat.KEY_CHANNEL_COUNT, 1); //聲道 mediaFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 1024 * 100);//作用於inputBuffer的大小 mediaFormat.setInteger(MediaFormat.KEY_SAMPLE_RATE, 44100);//取樣率 mMediaCodec.configure(mediaFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE); //start()後進入執行狀態,才能做後續的操作 mMediaCodec.start(); startAudioRecord(params); mBufferInfo = new MediaCodec.BufferInfo(); if (null != params.getAudioPath()) { File fileAAc = new File(params.getAudioPath()); if (!fileAAc.exists()) { fileAAc.createNewFile(); } fileOutputStream = new FileOutputStream(fileAAc.getAbsoluteFile()); } } catch (IOException e) { e.printStackTrace(); } return mAudioEncoder; } public void startEncodeAacData() { isEncoding = true; Thread aacEncoderThread = new Thread(new Runnable() { @Override public void run() { while (isEncoding) { if (mAudioRecord != null && mMediaCodec != null) { byte[] audioBuf = new byte[mAudioRecordBufferSize]; int readBytes = mAudioRecord.read(audioBuf, 0, mAudioRecordBufferSize); if (readBytes > 0) { try { encodeAudioBytes(audioBuf, readBytes); } catch (Exception e) { e.printStackTrace(); } } } } stopEncodeAacSync(); } }); aacEncoderThread.start(); } public static boolean isEncoding() { return isEncoding; } private void startAudioRecord(EncoderParams params) { // 計算AudioRecord所需輸入快取空間大小 mAudioRecordBufferSize = AudioRecord.getMinBufferSize(params.getAudioSampleRate(), params.getAudioChannelConfig(), params.getAudioFormat()); if (mAudioRecordBufferSize < 1600) { mAudioRecordBufferSize = 1600; } Process.setThreadPriority(Process.THREAD_PRIORITY_AUDIO); mAudioRecord = new AudioRecord(params.getAudioSouce(), params.getAudioSampleRate(), params.getAudioChannelConfig(), params.getAudioFormat(), mAudioRecordBufferSize); // 開始錄音 mAudioRecord.startRecording(); } private void encodeAudioBytes(byte[] audioBuf, int readBytes) { //dequeueInputBuffer(time)需要傳入一個時間值,-1表示一直等待,0表示不等待有可能會丟幀,其他表示等待多少毫秒 int inputIndex = mMediaCodec.dequeueInputBuffer(-1);//獲取輸入快取的index if (inputIndex >= 0) { ByteBuffer inputByteBuf; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { inputByteBuf = mMediaCodec.getInputBuffer(inputIndex); } else { ByteBuffer[] inputBufferArray = mMediaCodec.getInputBuffers(); inputByteBuf = inputBufferArray[inputIndex]; } if (audioBuf == null || readBytes <= 0) { mMediaCodec.queueInputBuffer(inputIndex, 0, 0, getPTSUs(), MediaCodec.BUFFER_FLAG_END_OF_STREAM); } else { inputByteBuf.clear(); inputByteBuf.put(audioBuf);//新增資料 //inputByteBuf.limit(audioBuf.length);//限制ByteBuffer的訪問長度 mMediaCodec.queueInputBuffer(inputIndex, 0, readBytes, getPTSUs(), 0);//把輸入快取塞回去給MediaCodec } } int outputIndex; byte[] frameBytes = null; do { outputIndex = mMediaCodec.dequeueOutputBuffer(mBufferInfo, 12000); if (outputIndex == MediaCodec.INFO_TRY_AGAIN_LATER) { //Log.i(TAG,"獲得編碼器輸出快取區超時"); } else if (outputIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { //設定混合器視訊軌道,如果音訊已經新增則啟動混合器(保證音視訊同步) synchronized (AudioEncoder.class) { MediaFormat format = mMediaCodec.getOutputFormat(); MediaUtil.getDefault().addTrack(format, false); } } else { //獲取快取資訊的長度 int byteBufSize = mBufferInfo.size; // 當flag屬性置為BUFFER_FLAG_CODEC_CONFIG後,說明輸出快取區的資料已經被消費了 if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_CODEC_CONFIG) != 0) { Log.i(TAG, "編碼資料被消費,BufferInfo的size屬性置0"); byteBufSize = 0; } // 資料流結束標誌,結束本次迴圈 if ((mBufferInfo.flags & MediaCodec.BUFFER_FLAG_END_OF_STREAM) != 0) { Log.i(TAG, "資料流結束,退出迴圈"); break; } ByteBuffer outPutBuf; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { outPutBuf = mMediaCodec.getOutputBuffer(outputIndex); } else { ByteBuffer[] outputBufferArray = mMediaCodec.getOutputBuffers(); outPutBuf = outputBufferArray[outputIndex]; } if (byteBufSize != 0) { //因為上面的addTrackIndex方法不一定會被呼叫,所以要在此處再判斷並新增一次,這也是混合的難點之一 if (!MediaUtil.getDefault().isAddAudioTrack()) { synchronized (AudioEncoder.this) { MediaFormat newFormat = mMediaCodec.getOutputFormat(); MediaUtil.getDefault().addTrack(newFormat, false); } } if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.KITKAT) { outPutBuf.position(mBufferInfo.offset); outPutBuf.limit(mBufferInfo.offset + mBufferInfo.size); } MediaUtil.getDefault().putStrem(outPutBuf, mBufferInfo, false); Log.i(TAG, "------編碼混合音訊資料-----" + mBufferInfo.size); //給adts頭欄位空出7的位元組 int length = mBufferInfo.size + 7; if (frameBytes == null || frameBytes.length < length) { frameBytes = new byte[length]; } addADTStoPacket(frameBytes, length); outPutBuf.get(frameBytes, 7, mBufferInfo.size); if (audioListener != null) { audioListener.onGetAac(frameBytes, length); } } //釋放 mMediaCodec.releaseOutputBuffer(outputIndex, false); } } while (outputIndex >= 0); } private long prevPresentationTimes = 0; private long getPTSUs() { long result = System.nanoTime() / 1000; if (result < prevPresentationTimes) { result = (prevPresentationTimes - result) + result; } return result; } /** * 給編碼出的aac裸流新增adts頭欄位 * * @param packet要空出前7個位元組,否則會搞亂資料 * @param packetLen */ private void addADTStoPacket(byte[] packet, int packetLen) { int profile = 2;//AAC LC int freqIdx = 4;//44.1KHz int chanCfg = 2;//CPE packet[0] = (byte) 0xFF; packet[1] = (byte) 0xF9; packet[2] = (byte) (((profile - 1) << 6) + (freqIdx << 2) + (chanCfg >> 2)); packet[3] = (byte) (((chanCfg & 3) << 6) + (packetLen >> 11)); packet[4] = (byte) ((packetLen & 0x7FF) >> 3); packet[5] = (byte) (((packetLen & 7) << 5) + 0x1F); packet[6] = (byte) 0xFC; } public void stopEncodeAac() { isEncoding = false; } private void stopEncodeAacSync() { if (mAudioRecord != null) { mAudioRecord.stop(); mAudioRecord.release(); mAudioRecord = null; } if (mMediaCodec != null) { mMediaCodec.stop(); mMediaCodec.release(); mMediaCodec = null; try { if (fileOutputStream != null) { fileOutputStream.flush(); fileOutputStream.close(); } } catch (IOException e) { e.printStackTrace(); } } MediaUtil.getDefault().release(); if (audioListener != null) { audioListener.onStopEncodeAacSuccess(); } } private AudioEncodeListener audioListener; public void setEncodeAacListner(AudioEncodeListener listener) { this.audioListener = listener; } public interface AudioEncodeListener { void onGetAac(byte[] data, int length); void onStopEncodeAacSuccess(); } } 複製程式碼
音視訊編碼的mediacodec初始化引數都是差不多的,這裡我用一個單獨的類來設定錄製時的引數:
public class EncoderParams { public static final int DEFAULT_AUDIO_SAMPLE_RATE = 44100; //所有android系統都支援的取樣率 public static final int DEFAULT_CHANNEL_COUNT = 1; //單聲道 public static final int CHANNEL_COUNT_STEREO = 2;//立體聲 public static final int DEFAULT_AUDIO_BIT_RATE = 96000;//預設位元率 public static final int LOW_VIDEO_BIT_RATE = 1;//預設位元率 public static final int MIDDLE_VIDEO_BIT_RATE = 3;//預設位元率 public static final int HIGH_VIDEO_BIT_RATE = 5;//預設位元率 private String videoPath;//視訊檔案的全路徑 private String audioPath;//音訊檔案全路徑 private int frameWidth; private int frameHeight; private int frameRate; // 幀率 private int videoQuality = MIDDLE_VIDEO_BIT_RATE; //位元速率等級 private int audioBitrate = DEFAULT_AUDIO_BIT_RATE;// 音訊編碼位元率 private int audioChannelCount = DEFAULT_CHANNEL_COUNT; // 通道數 private int audioSampleRate = DEFAULT_AUDIO_SAMPLE_RATE;// 取樣率 private int audioChannelConfig ; // 單聲道或立體聲 private int audioFormat;// 取樣精度 private int audioSouce;// 音訊來源 public EncoderParams(){ } //...省略set get方法 } 複製程式碼
最後在錄製mp4的時候,同時啟動編碼音訊資料和視訊資料的執行緒就ok了:
H264EncoderConsumer.getInstance() .setEncoderParams(params) .StartEncodeH264Data(); AudioEncoder.getInstance() .setEncoderParams(params) .startEncodeAacData(); 複製程式碼
3. 解決奇葩問題
使用以上的方法錄製mp4視訊,會出現很多奇怪的問題。
恭喜你,看到這兒才發現本篇文章是大坑,現在是不是想特別錘我呀,可惜你打不著我,略略略

-
不同的手機會出現不同的情況,配置低的手機會出現錄製的視訊變慢的現象,配置高的手機會出現視訊變快的現象。
其實出現這個問題很簡單,之前從網上copy程式碼,都是用的ArrayBlockingQueue佇列接收每一幀yuv格式的資料,然後mediacodec從佇列中不停的讀取資料,配置低的手機處理資料能力慢,配置高的手機處理資料能力快,就會造成這種情況。解決方法也很簡單,不用佇列接收資料了唄,直接從camera回撥中獲取資料編碼。
你以為大功告成了嗎?不存在的,解決上面的問題之後,你還會發現錄製的視訊出現卡頓的現象,因為對yuv資料的處理太耗時了,在java中做旋轉yuv資料耗時200ms左右,旋轉之後還要轉換為mediacodec支援的nv12的資料格式,耗時110ms左右。加起來有300多毫秒,當然卡了。既然java中做資料處理不太方便,那就在native層做吧,直接上cmake寫c++,一氣呵成。

-
一頓操作猛如虎,一看效果卡如狗。套用java的兩個轉換方法(上篇文章有提供程式碼),放進native層,總共耗時在150ms以內,快了將近一倍,雖然沒有那麼卡頓了,但是錄製出來的視訊和
MediaRecorder
錄製出來的用肉眼看,還是有很大的差別。沒辦法了,自己寫的渣程式碼沒法用,只能靠第三方庫libyuv
了。什麼是
libyuv
?看看官方解釋:libyuv是Google開源的實現各種YUV與RGB之間相互轉換、旋轉、縮放的庫。它是跨平臺的,可在Windows、Linux、Mac、Android等作業系統,x86、x64、arm架構上進行編譯執行,支援SSE、AVX、NEON等SIMD指令加速。
看起開很屌的樣子,下載
libyuv
原始碼,匯入android studio,讓我來試試你的深淺!使用libyuv
,首先要將nv21格式的資料轉換為I420格式,然後才能對資料進行其他操作。具體流程是這樣的:camera獲取到nv21資料 -> 轉換為I420 -> 旋轉映象I420資料 -> 轉換為nv12 -> mediacodec編碼為h264
這套流程感覺比上面的方法還要耗時,因為多了I420的轉換,但是實際測試整體耗時在20ms左右。側面反映了google有多厲害,我寫的程式碼有多渣。
libyuv
的使用這裡不做過多介紹,因為android studio 支援cmake,我並沒有將其編譯為so庫使用。具體步驟就不細說了,可以上github看原始碼。後期可能會對錄製的音訊變聲,以及對視訊新增水印等處理。
專案地址: camera開發從入門到入土 歡迎start和fork