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

章節

Camera開發系列之一-顯示攝像頭實時畫面java

Camera開發系列之二-相機預覽數據回調android

Camera開發系列之三-相機數據硬編碼爲h264c++

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

Camera開發系列之五-使用MediaExtractor製做一個簡易播放器github

Camera開發系列之六-使用mina框架實現視頻推流緩存

Camera開發系列之七-使用GLSurfaceviw繪製Camera預覽畫面 架構

前幾篇的文章中,咱們已經可以獲取到h264格式的視頻裸流和pcm格式的音頻數據了,而使用MediaMuxer這個工具,則能夠將咱們處理過的音視頻數據封裝到mp4容器裏。框架

1. MediaMuxer簡單介紹

學習一個歷來沒接觸過的東西,固然先從官方文檔給開始看啦,下面是MediaMuxer的主要方法:ide

  1. int addTrack(@NonNull MediaFormat format):一個視頻文件是包含一個或多個音視頻軌道的,而這個方法就是用於添加一個視頻或視頻軌道,並返回對應的ID。以後咱們能夠經過這個ID向相應的軌道寫入數據。
  2. void start(): 當咱們添加完全部音視頻軌道以後,須要調用這個方法告訴Muxer,我要開始寫入數據了。須要注意的是,調用了這個方法以後,咱們是沒法再次addTrack了的。
  3. void writeSampleData(int trackIndex, ByteBuffer byteBuf, MediaCodec.BufferInfo bufferInfo): 用於向Muxer寫入編碼後的音視頻數據。trackIndex是咱們addTrack的時候返回的ID,byteBuf即是要寫入的數據,而bufferInfo是跟這一幀byteBuf相關的信息,包括時間戳、數據長度和數據在ByteBuffer中的位移。
  4. void stop() :start()相對應,用於中止寫入數據。

MediaMuxer中使用的方法就介紹完了,真是個又短又實用的工具( ̄▽ ̄)/。那這玩意兒怎麼用呢?也很簡單,沒有繁瑣的調用方法,只須要四步就搞定:工具

  1. 初始化MediaMuxer
  2. 添加音頻軌/視頻軌
  3. 喂數據
  4. 處理完數據以後釋放對象

具體的代碼以下:

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視頻,會出現不少奇怪的問題。

恭喜你,看到這兒才發現本篇文章是大坑,如今是否是想特別錘我呀,惋惜你打不着我,略略略

  1. 不一樣的手機會出現不一樣的狀況,配置低的手機會出現錄製的視頻變慢的現象,配置高的手機會出現視頻變快的現象。

    其實出現這個問題很簡單,以前從網上copy代碼,都是用的ArrayBlockingQueue隊列接收每一幀yuv格式的數據,而後mediacodec從隊列中不停的讀取數據,配置低的手機處理數據能力慢,配置高的手機處理數據能力快,就會形成這種狀況。解決方法也很簡單,不用隊列接收數據了唄,直接從camera回調中獲取數據編碼。

​ 你覺得大功告成了嗎?不存在的,解決上面的問題以後,你還會發現錄製的視頻出現卡頓的現象,由於對yuv數據的處理太耗時了,在java中作旋轉yuv數據耗時200ms左右,旋轉以後還要轉換爲mediacodec支持的nv12的數據格式,耗時110ms左右。加起來有300多毫秒,固然卡了。既然java中作數據處理不太方便,那就在native層作吧,直接上cmake寫c++,一鼓作氣。

  1. 一頓操做猛如虎,一看效果卡如狗。套用java的兩個轉換方法(上篇文章有提供代碼),放進native層,總共耗時在150ms之內,快了將近一倍,雖然沒有那麼卡頓了,可是錄製出來的視頻和MediaRecorder錄製出來的用肉眼看,仍是有很大的差異。沒辦法了,本身寫的渣代碼無法用,只能靠第三方庫libyuv了。

    什麼是libyuv?看看官方解釋:

    libyuv是Google開源的實現各類YUV與RGB之間相互轉換、旋轉、縮放的庫。它是跨平臺的,可在Windows、Linux、Mac、Android等操做系統,x8六、x6四、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

相關文章
相關標籤/搜索