《Android音视频系列-5》音视频采集,生成mp4

最近晚上和周末基本都在排队练车,累成狗,好久没写文章了~

《Android音视频系列-5》音视频采集,生成mp4_第1张图片

抽空整理了一下音视频采集的方式,最终生成mp4。

一、音频采集,得到PCM数据

音频采集比较简单,通过 AudioRecord 录音,然后在子线程不断去读PCM数据

记得声明录音权限

开始录音

    //默认参数
    private static final int AUDIO_SOURCE = MediaRecorder.AudioSource.MIC;
    private static final int SAMPLE_RATE = 44100;
    private static final int CHANNEL_CONFIGS = AudioFormat.CHANNEL_IN_STEREO;
    private static final int AUDIO_FORMAT = AudioFormat.ENCODING_PCM_16BIT;
    private int bufferSize = AudioRecord.getMinBufferSize(SAMPLE_RATE, CHANNEL_CONFIGS, AUDIO_FORMAT);

    private AudioRecord audioRecord;

    public void start() {
        start(AUDIO_SOURCE, SAMPLE_RATE, CHANNEL_CONFIGS, AUDIO_FORMAT);
    }

    public void start(int audioSource, int sampleRate, int channels, int audioFormat) {
        if (isStartRecord) {
            Log.d(TAG, "音频录制已经开启");
            return;
        }

        bufferSize = AudioRecord.getMinBufferSize(sampleRate, channels, audioFormat);

        if (bufferSize == AudioRecord.ERROR_BAD_VALUE) {
            Log.d(TAG, "无效参数");
            return;
        }

        audioRecord = new AudioRecord(audioSource, sampleRate, channels, audioFormat, bufferSize);
        audioRecord.startRecording();
        isStopRecord = false;

        threadCapture = new Thread(new CaptureRunnable());
        threadCapture.start();
    }

主要是创建AudioRecord的一些参数,然后调用audioRecord.startRecording();开始录制,然后启动一个子线程,去读取录制的PCM格式的数据

读取PCM

 /**
 * 子线程读取采集到的PCM数据
 */
private class CaptureRunnable implements Runnable {

    @Override
    public void run() {
        while (!isStopRecord) {
            byte[] buffer = new byte[bufferSize];
            int readRecord = audioRecord.read(buffer, 0, bufferSize);
            if (readRecord > 0) {
                if (captureListener != null)
                    captureListener.onCaptureListener(buffer,readRecord);
                Log.d(TAG, "音频采集数据源 -- ".concat(String.valueOf(readRecord)).concat(" -- bytes"));
            } else {
                Log.d(TAG, "录音采集异常");
            }
            //延迟写入 SystemClock  --  Android专用
            SystemClock.sleep(10);
        }
    }
}

读取PCM 比较简单,就是通过audioRecord.read(buffer, 0, bufferSize),最终PCM格式的数据会读到这个buffer里

拿到录制的每一帧PCM数据之后,可以用AudioTrack播放,这里就不播放了,回调出去,后面合成mp4要用到。

上面两个步骤,可以封装一个录音的管理类。
【传送门】(待补充)

现在获取的音频是PCM格式,我们要将它编码成aac,然后跟视频数据合成mp4,这里要用到 MediaCodecMediaMuxer

MediaCodec 使用

MediaCodec 是一个音视频编解码器,本篇主要用于:

  1. 将PCM格式的音频数据编码成aac格式,
  2. 将NV21格式的相机预览数据编码成avc格式。

API 简介

getInputBuffers:获取需要编码数据的输入流队列,返回的是一个ByteBuffer数组
queueInputBuffer:输入流入队列
dequeueInputBuffer:从输入流队列中取数据进行编码操作
getOutputBuffers:获取编解码之后的数据输出流队列,返回的是一个ByteBuffer数组
dequeueOutputBuffer:从输出队列中取出编码操作之后的数据
releaseOutputBuffer:处理完成,释放ByteBuffer数据

初始化音频编解码器

    private MediaCodec mAudioCodec;

    String audioType = MediaFormat.MIMETYPE_AUDIO_AAC;  //编码成aac格式
    int sampleRate = 44100;
    int channels = 2;//单声道 channelCount=1 , 双声道  channelCount=2

    private void initAudioCodec(String audioType, int sampleRate, int channels) {
    try {
        mAudioCodec = MediaCodec.createEncoderByType(audioType);
        MediaFormat audioFormat = MediaFormat.createAudioFormat(audioType, sampleRate, channels);
        int BIT_RATE = 96000;
        audioFormat.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE);
        audioFormat.setInteger(MediaFormat.KEY_AAC_PROFILE,
                MediaCodecInfo.CodecProfileLevel.AACObjectLC);
        int MAX_INOUT_SIZE = 8192;
        audioFormat.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, MAX_INOUT_SIZE);

        mAudioCodec.configure(audioFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);

    } catch (IOException e) {
        Log.e(TAG, "initAudioCodec: 音频类型无效");
    }
}

视频编解码器的初始化同理

    String videoType = MediaFormat.MIMETYPE_VIDEO_AVC;

    private void initVideoCodec(String videoType, int width, int height) {
    try {
        mVideoCodec = MediaCodec.createEncoderByType(videoType);
        MediaFormat videoFormat = MediaFormat.createVideoFormat(videoType, width, height);

        videoFormat.setInteger(MediaFormat.KEY_COLOR_FORMAT,
                MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface);
        //MediaFormat.KEY_FRAME_RATE -- 可通过Camera#Parameters#getSupportedPreviewFpsRange获取
        videoFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
        //mSurfaceWidth*mSurfaceHeight*N  N标识码率低、中、高,类似可设置成1,3,5,码率越高视频越大,也越清晰
        videoFormat.setInteger(MediaFormat.KEY_BIT_RATE, width * height * 4);
        //每秒关键帧数
        videoFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);

        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
            videoFormat.setInteger(MediaFormat.KEY_PROFILE, MediaCodecInfo.CodecProfileLevel.AVCProfileHigh);
            videoFormat.setInteger(MediaFormat.KEY_LEVEL, MediaCodecInfo.CodecProfileLevel.AVCLevel31);
        }

        mVideoCodec.configure(videoFormat, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE);
        //注意这里,获取视频解码器的surface,之后要将opengl输出到这个surface中
        mSurface = mVideoCodec.createInputSurface();
    } catch (IOException e) {
        Log.e(TAG, "initVideoCodec: 视频类型无效");
    }
}

这里要注意的是,音频数据从AudioRecord 直接读出来,但是视频数据处理有些不同,视频数据不直接读相机预览,而是通过相机纹理id,利用OpenGL直接渲染到视频编解码器的surface上,可以直接被编码,效率比较高,所以这里获取了MediaCodec的surface,mSurface = mVideoCodec.createInputSurface();,后面通过EGL创建NatieWindow的时候要用到,这里大概先了解一下就行,关于GLSurfaceView原理,EGL的使用,后面再整理一篇文章吧。

两个编解码器创建好了,接下来要用到混合器, MediaMuxer

MediaMuxer 使用详解

MediaMuxer 是一个音视频混合器,我们录制音频和视频数据,经过MediaCodec编码,然后再将编码后的音视频数据混合在一起,最终生成mp4。

MediaMuxer主要方法:

1.int addTrack(@NonNull MediaFormat format)

一个视频文件是包含一个或多个音视频轨道的,而这个方法就是用于添加一个音频频或视频轨道,并返回对应的ID。之后我们可以通过这个ID向相应的轨道写入数据。用于新建音视频轨道的MediaFormat是需要从MediaCodec.getOutputFormat()获取的,而不是自己简单构造的MediaFormat。

2.start()

当我们添加完所有音视频轨道之后,需要调用这个方法告诉Muxer,我要开始写入数据了。需要注意的是,调用了这个方法之后,我们是无法再次addTrack了的。

3.void writeSampleData(int trackIndex, @NonNull ByteBuffer byteBuf,
@NonNull BufferInfo bufferInfo)

用于向Muxer写入编码后的音视频数据。trackIndex是我们addTrack的时候返回的ID,byteBuf便是要写入的数据,而bufferInfo是跟这一帧byteBuf相关的信息,包括时间戳、数据长度和数据在ByteBuffer中的位移

4.void stop()

与start()相对应,用于停止写入数据,并生成文件。

5.void release()

释放Muxer资源。

MediaMuxer 实战

我们先来构造一个 MediaMuxer ,需要两个参数,第一个是音视频文件的保存路径,第二个是音视频封装文件的格式,可以选择mp4或3gp,我们使用mp4就好

int mediaFormat = MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4;

private void initMediaMuxer(String filePath, int mediaFormat) {
    try {
        mMediaMuxer = new MediaMuxer(filePath, mediaFormat);
    } catch (IOException e) {
        Log.e(TAG, "initMediaMuxer: 文件打开失败,path=" + filePath);
    }
}

添加音频轨道

添加音频轨道,在音频编码线程 AudioCodecThread 处理

public void run() {
    super.run();
    mIsStop = false;
    audioCodec.start();
    while (true) {
        if (mMediaEncodeManager == null) {
            Log.e(TAG, "run: mediaEncodeManagerWeakReference == null");
            return;
        }
        if (mIsStop) {
            mMediaEncodeManager.audioStop();
            return;
        }

        //获取一帧解码完成的数据到bufferInfo,没有数据就阻塞
        int outputBufferIndex = audioCodec.dequeueOutputBuffer(bufferInfo, 0);
        //第一次会返回-2,在这时候添加音轨
        if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
            mAudioTrackIndex = mediaMuxer.addTrack(audioCodec.getOutputFormat());
            mMediaEncodeManager.mAudioTrackReady  = true;
            Log.d(TAG, "run: 添加音轨 mAudioTrackIndex= " + mAudioTrackIndex);
            mMediaEncodeManager.startMediaMuxer();
        } else {
            while (outputBufferIndex != 0) {
                if (!mMediaEncodeManager.mEncodeStart) {
                    Log.d(TAG, "run: 混合器还没开始,线程延迟");
                    SystemClock.sleep(10);
                    continue;
                }

                ByteBuffer outputBuffer = audioCodec.getOutputBuffers()[outputBufferIndex];
                outputBuffer.position(bufferInfo.offset);
                outputBuffer.limit(bufferInfo.offset + bufferInfo.size);

                if (mPresentationTimeUs == 0) {
                    mPresentationTimeUs = bufferInfo.presentationTimeUs;
                }
                bufferInfo.presentationTimeUs = bufferInfo.presentationTimeUs - mPresentationTimeUs;
                mediaMuxer.writeSampleData(mAudioTrackIndex, outputBuffer, bufferInfo);

                audioCodec.releaseOutputBuffer(outputBufferIndex, false);
                outputBufferIndex = audioCodec.dequeueOutputBuffer(bufferInfo, 0);
            }
        }
    }
}

添加视频轨道

添加视频轨道,在视频编码线程 VideoCodecThread 处理

public void run() {
    mIsStop = false;
    videoCodec.start();
    while (true) {
        if (mMediaEncodeManager == null) {
            Log.e(TAG, "run: mMediaEncodeManager == null");
            return;
        }
        if (mIsStop) {
            mMediaEncodeManager.videoStop();
            return;
        }

        int outputBufferIndex = videoCodec.dequeueOutputBuffer(bufferInfo, 0);
        //第一次返回 -2,在这个时候添加音轨
        if (outputBufferIndex == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
            mVideoTrackIndex = mediaMuxer.addTrack(videoCodec.getOutputFormat());
            Log.d(TAG, "添加视频轨道,mVideoTrackIndex = " + mVideoTrackIndex);
            mMediaEncodeManager.mVideoTrackReady = true;
            mMediaEncodeManager.startMediaMuxer();
        } else {
            while (outputBufferIndex >= 0) {
                if (!mMediaEncodeManager.mEncodeStart) {
                    Log.d(TAG, "run: 混合器还没开始,线程延迟");
                    SystemClock.sleep(10);
                    continue;
                }

                ByteBuffer outputBuffer = videoCodec.getOutputBuffers()[outputBufferIndex];
                outputBuffer.position(bufferInfo.offset);
                outputBuffer.limit(bufferInfo.offset + bufferInfo.size);

                if (mPresentationTimeUs == 0) {
                    mPresentationTimeUs = bufferInfo.presentationTimeUs;
                }
                bufferInfo.presentationTimeUs = bufferInfo.presentationTimeUs - mPresentationTimeUs;
                mediaMuxer.writeSampleData(mVideoTrackIndex, outputBuffer, bufferInfo);
                if (bufferInfo != null) {
                    mMediaEncodeManager.onRecordTimeCallBack((int) (bufferInfo.presentationTimeUs / 1000000));
                }
                videoCodec.releaseOutputBuffer(outputBufferIndex, false);
                outputBufferIndex = videoCodec.dequeueOutputBuffer(bufferInfo, 0);
            }
        }
    }
}

添加音频轨道和视频轨道后,就可以启动混合器,然后不断从编解码器MediaCodec中读取已经编码成功的数据,然后调用mediaMuxer.writeSampleData(mAudioTrackIndex, outputBuffer, bufferInfo);将编码后的音/视频数据写到混合器里,停止的时候要调用

mMediaMuxer.stop();
mMediaMuxer.release();

如果不出意外的话,会在指定目录下生成mp4文件。

然后在PCM回调那里,将PCM数据扔到 MediaCodec 里面去,这样AudioCodecThread 里面就能读到已经编码的aac格式数据。

    public void setPcmSource(byte[] pcmBuffer, int buffSize) {

    try {

        int buffIndex = mAudioCodec.dequeueInputBuffer(0);
        if (buffIndex < 0) {
            return;
        }
        ByteBuffer byteBuffer;
        if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
            byteBuffer = mAudioCodec.getInputBuffer(buffIndex);
        } else {
            byteBuffer = mAudioCodec.getInputBuffers()[buffIndex];
        }

        byteBuffer.clear();
        byteBuffer.put(pcmBuffer);
        //mPresentationTimeUs = 1000000L * (buffSize / 2) / mSampleRate
        //一帧音频帧大小 int size = 采样率 x 位宽 x 采样时间 x 通道数
        // 1s时间戳计算公式  mPresentationTimeUs = 1000000L * (totalBytes / mSampleRate/ mAudioFormat / mChannelCount / 8 )
        //totalBytes : 传入编码器的总大小
        //1000 000L : 单位为 微秒,换算后 = 1s,
        //除以8     : pcm原始单位是bit, 1 byte = 8 bit, 1 short = 16 bit, 用 Byte[]、Short[] 承载则需要进行换算
        mPresentationTimeUs += (long) (1.0 * buffSize / (mSampleRate * mChannelCount * (mAudioFormat / 8)) * 1000000.0);
        Log.d(TAG, "pcm一帧时间戳 = " + mPresentationTimeUs / 1000000.0f);
        mAudioCodec.queueInputBuffer(buffIndex, 0, buffSize, mPresentationTimeUs, 0);
    } catch (IllegalStateException e) {
        //mAudioCodec 线程对象已释放MediaCodec对象
        Log.d(TAG, "setPcmSource: " + "MediaCodec对象已释放");
    }
}

视频数据通过OpenGL渲染到视频编解码器的surface中,只要打开相机,视频编码器就能获取到编码后的视频数据,然后写到混合器里,跟音频处理基本差不多。当然,这里涉及到自定义GLSurfaceView,参照GLSurfaceView中对EGL的处理,自己写EglHelper,这里不是本文重点,后面有时间再说下GLSurfaceView源码。

接下来再简单看一下如何通过Camera1采集视频数据

视频数据采集

相机功能封装在 CameraManager中,使用的是Camera1,需要注意的是设置预览数据格式,还有一个是SurfaceTexture,在外部创建(OpenGL创建纹理的时候),然后再启动相机,把纹理传过去,简单贴下启动相机代码

    private void startCamera(int cameraId) {
    try {
        camera = Camera.open(cameraId);
        camera.setPreviewTexture(surfaceTexture);

        Camera.Parameters parameters = camera.getParameters();
        parameters.setFlashMode(Camera.Parameters.FLASH_MODE_OFF);
        parameters.setPreviewFormat(ImageFormat.NV21);

        //设置对焦模式,后置摄像头开启时打开,切换到前置时关闭(三星、华为不能设置前置对焦,魅族、小米部分机型可行)
        if (cameraId == 0) {
            //小米、魅族手机存在对焦无效情况,需要针对设备适配,想要无感知对焦完全适配最好是监听加速度传感器
            camera.cancelAutoFocus();
            //这种设置方式存在屏幕闪烁一下问题,包括Camera.Parameters.FOCUS_MODE_CONTINUOUS_VIDEO
            parameters.setFocusMode(Camera.Parameters.FOCUS_MODE_CONTINUOUS_PICTURE);
        }
        Camera.Size size = getCameraSize(parameters.getSupportedPreviewSizes(), screenWidth,
                screenHeight, 0.1f);
        parameters.setPreviewSize(size.width, size.height);
        //水平方向未旋转,所以宽就是竖直方向的高,对应旋转操作
        Log.d(TAG, "startCamera: 预览宽:" + size.width + " -- " + "预览高:" + size.height);
        previewWidth = size.width;
        previewHeight = size.height;

        size = getCameraSize(parameters.getSupportedPictureSizes(), screenWidth, screenHeight, 0.1f);
        parameters.setPictureSize(size.width, size.height);
        //水平方向未旋转,所以宽就是竖直方向的高
        Log.d(TAG, "startCamera: 图片宽:" + size.width + " -- " + "图片高:" + size.height);

        camera.setParameters(parameters);
        camera.startPreview();
    } catch (IOException e) {
        e.printStackTrace();
    }
}

相机启动之后预览数据会输出到 surfaceTexture,这个surfaceTexture 关联一个纹理id,就是通过OpenGL创建并绑定的纹理id

  /**
 * 创建摄像头预览扩展纹理
 */
private void createCameraTexture() {
    int[] textureIds = new int[1];
    GLES20.glGenTextures(1, textureIds, 0);
    cameraTextureId = textureIds[0];
    GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, cameraTextureId);
    //环绕(超出纹理坐标范围)  (s==x t==y GL_REPEAT 重复)
    GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_S, GLES20.GL_REPEAT);
    GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_WRAP_T, GLES20.GL_REPEAT);
    //过滤(纹理像素映射到坐标点)  (缩小、放大:GL_LINEAR线性)
    GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MIN_FILTER, GLES20.GL_LINEAR);
    GLES20.glTexParameteri(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, GLES20.GL_TEXTURE_MAG_FILTER, GLES20.GL_LINEAR);

    surfaceTexture = new SurfaceTexture(cameraTextureId);
    surfaceTexture.setOnFrameAvailableListener(this);

    if (onSurfaceListener != null) {
        //回调给CameraManager获取surfaceTexture:通过camera.setPreviewTexture(surfaceTexture);
        onSurfaceListener.onSurfaceCreate(surfaceTexture, fboTextureId);
    }

    // 解绑扩展纹理
    GLES20.glBindTexture(GLES11Ext.GL_TEXTURE_EXTERNAL_OES, 0);
}

总结一下相机数据采集过程

  1. OpenGL创建纹理,绑定一个纹理id
  2. 启动相机,传入前面创建的纹理,这样相机预览数据就会输出到OpenGL绑定的纹理。
  3. 纹理并不能直接拿来编码,需要参考GLSurfaceView的显示原理,创建EGL,通过OpenGL不断将纹理渲染到MediaCodec的surface上,然后在一个子线程不断获取MediaCodec 中编码成功的数据,后面就跟音频处理一样,添加到混合器里,最终合成mp4文件。

对OpenGL不熟悉的话没关系,有时间的话可以去学一下,不需要太深,也可以在我的主页查看OpenGL的入门系列文章
https://www.jianshu.com/u/282785a6b12f

这篇文章内容属于音视频开发的基础部分了,后面要整理相机推流,会涉及到音视频采集,也就是本章内容。

在后面章节完成之后会把源码提交到github,

想让自己变优秀,就要少看头条,少刷抖音,坚持学习,写文章,不然的话可能如下图:


《Android音视频系列-5》音视频采集,生成mp4_第2张图片

你可能感兴趣的:(《Android音视频系列-5》音视频采集,生成mp4)