Camera开发系列之四-使用MediaMuxer封装编码后的音视频到mp4容器

章节

Camera开发系列之一-显示摄像头实时画面

Camera开发系列之二-相机预览数据回调

Camera开发系列之三-相机数据硬编码为h264

Camera开发系列之四-使用MediaMuxer封装编码后的音视频到mp4容器

Camera开发系列之五-使用MediaExtractor制作一个简易播放器

Camera开发系列之六-使用mina框架实现视频推流

Camera开发系列之七-使用GLSurfaceviw绘制Camera预览画面

前几篇的文章中,我们已经能够获取到h264格式的视频裸流和pcm格式的音频数据了,而使用MediaMuxer这个工具,则可以将我们处理过的音视频数据封装到mp4容器里。

1. MediaMuxer简单介绍

学习一个从来没接触过的东西,当然先从官方文档给开始看啦,下面是MediaMuxer的主要方法:

  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等操作系统,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

猜你喜欢

转载自juejin.im/post/5c6442496fb9a04a006f8352