第25章 音视频录制系统:Camera2采集、音频采集、同步录制、MP4封装

做音视频开发这么多年,我始终觉得录制系统是最能体现综合功力的模块。你想想看,要同时管好摄像头、麦克风,还得让它们步调一致地往MP4里写数据——任何一个环节出问题,录出来的视频不是没声音就是音画不同步。今天我就把这一整套流程拆开揉碎了讲给你听。

核心要点:一个完整的录制系统 = Camera2视频采集 + AudioRecord音频采集 + 同步时钟 + MediaMuxer封装。四者缺一不可。

25.1 整体架构设计

先看整体流程。我个人习惯把录制系统分成三层:

  • 采集层:Camera2负责视频帧,AudioRecord负责音频数据
  • 同步层:用MediaCodec编码后,通过时间戳对齐
  • 封装层:MediaMuxer把编码后的音视频流写入MP4

下面这张图能帮你快速建立全局认知:

采集层 Camera2 视频帧采集 AudioRecord 音频采集 编码层 MediaCodec H.264/H.265 编码 MediaCodec AAC 编码 同步层 时间戳对齐 + 音画同步控制 MediaMuxer → MP4 文件

25.2 Camera2 视频采集实战

Camera2 的 API 设计得比较灵活,但初次接触容易懵。我建议你抓住三个核心对象:CameraManagerCameraDeviceCaptureSession

先看初始化流程:

// 获取 CameraManager
CameraManager manager = (CameraManager) getSystemService(Context.CAMERA_SERVICE);

// 打开摄像头
manager.openCamera(cameraId, new CameraDevice.StateCallback() {
    @Override
    public void onOpened(@NonNull CameraDevice camera) {
        // 创建 CaptureSession
        camera.createCaptureSession(Arrays.asList(surface), 
            new CameraCaptureSession.StateCallback() {
                @Override
                public void onConfigured(@NonNull CameraCaptureSession session) {
                    // 开始预览/录制
                    session.setRepeatingRequest(request, callback, handler);
                }
            }, handler);
    }
}, handler);

我的经验:创建 CaptureSession 时,surface 列表的顺序很重要。第一个 surface 通常用于预览,第二个用于录制。如果你把顺序搞反了,预览画面可能黑屏——我踩过这个坑。

视频采集的关键参数设置:

参数 推荐值 说明
分辨率 1920x1080 1080p 是兼容性最好的选择
帧率 30fps 大部分设备稳定支持
编码格式 H.264 兼容性优于 H.265
码率 8-12 Mbps 1080p 下画质与文件大小的平衡点

25.3 音频采集与编码

音频采集我用的是 AudioRecord,配合 MediaCodec 做 AAC 编码。这里有个细节:音频的采样率最好和视频的帧率形成整数倍关系,比如 44100Hz 配 30fps,这样时间戳计算起来更干净。

// 音频采集配置
int sampleRate = 44100;
int channelConfig = AudioFormat.CHANNEL_IN_STEREO;
int audioFormat = AudioFormat.ENCODING_PCM_16BIT;

AudioRecord recorder = new AudioRecord(
    MediaRecorder.AudioSource.MIC,
    sampleRate,
    channelConfig,
    audioFormat,
    AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat)
);

// 启动采集线程
new Thread(() -> {
    recorder.startRecording();
    byte[] buffer = new byte[4096];
    while (isRecording) {
        int bytesRead = recorder.read(buffer, 0, buffer.length);
        // 送入 MediaCodec 编码
        feedToEncoder(buffer, bytesRead);
    }
}).start();

注意:音频采集线程不能和 UI 线程混在一起。我曾经在项目里直接在主线程读音频数据,结果录了不到 10 秒就 ANR 了。音频采集必须开独立线程。

25.4 音画同步的核心机制

说白了,音画同步就是让视频帧和音频帧的时间戳对齐。MediaCodec 编码后的输出 buffer 会带一个 presentationTimeUs 字段,这个就是时间戳。

我的做法是这样的:

  1. 视频帧的时间戳用 System.nanoTime() 换算成微秒
  2. 音频帧的时间戳也用同一个时钟源
  3. 写入 MediaMuxer 时,确保两个 track 的时间戳是单调递增的
// 视频编码输出回调
MediaCodec.BufferInfo info = new MediaCodec.BufferInfo();
int outputIndex = videoCodec.dequeueOutputBuffer(info, TIMEOUT_US);
if (outputIndex >= 0) {
    ByteBuffer outputData = videoCodec.getOutputBuffer(outputIndex);
    // 关键:时间戳必须单调递增
    if (info.presentationTimeUs < lastVideoTimestamp) {
        info.presentationTimeUs = lastVideoTimestamp + 1;
    }
    lastVideoTimestamp = info.presentationTimeUs;
    muxer.writeSampleData(videoTrackIndex, outputData, info);
    videoCodec.releaseOutputBuffer(outputIndex, false);
}

避坑指南:有些设备上 MediaCodec 输出的时间戳会跳变。我曾经在小米某款机型上遇到过时间戳回退的情况,导致 MP4 文件损坏。解决方案就是上面代码里那样——手动检查并修正时间戳的单调性。

25.5 MediaMuxer 封装 MP4

MediaMuxer 的使用相对简单,但有几个点要注意:

  • 必须先 addTrack 再 start,顺序不能乱
  • 视频 track 和音频 track 的添加顺序不影响播放
  • stop 之后不能再写入数据
MediaMuxer muxer = new MediaMuxer(outputPath, MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4);

// 添加视频轨
MediaFormat videoFormat = MediaFormat.createVideoFormat("video/avc", width, height);
videoFormat.setInteger(MediaFormat.KEY_BIT_RATE, 8000000);
videoFormat.setInteger(MediaFormat.KEY_FRAME_RATE, 30);
videoFormat.setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 1);
int videoTrackIndex = muxer.addTrack(videoFormat);

// 添加音频轨
MediaFormat audioFormat = MediaFormat.createAudioFormat("audio/mp4a-latm", sampleRate, 2);
audioFormat.setInteger(MediaFormat.KEY_BIT_RATE, 128000);
int audioTrackIndex = muxer.addTrack(audioFormat);

// 开始封装
muxer.start();

// ... 写入数据 ...

// 结束封装
muxer.stop();
muxer.release();

个人建议:录制过程中如果发生异常,一定要在 finally 块里调用 muxer.stop()muxer.release()。否则生成的 MP4 文件可能无法播放。我见过太多因为忘记释放资源导致文件损坏的案例了。

25.6 完整录制流程的状态机

录制系统本质上是一个状态机。我习惯用四个状态来管理:

状态 说明 关键操作
IDLE 空闲状态 等待用户点击录制按钮
RECORDING 录制中 音视频同时采集、编码、封装
PAUSED 暂停录制 停止采集但保留编码器状态
STOPPING 正在停止 等待编码器 flush 完成,释放资源

嗯,这里要注意:暂停功能在 MediaMuxer 里没有直接支持。我的做法是暂停时停止向 muxer 写数据,但保留编码器。恢复时重新开始采集,时间戳继续累加。这样生成的 MP4 文件在播放器里看起来就是一段完整的视频。

做录制系统这么多年,我最大的体会是:稳定性比功能更重要。用户宁可要一个功能少但每次都能录成功的 App,也不要一个功能花哨但经常崩溃的 App。所以,异常处理、资源释放、状态管理这些看似枯燥的代码,恰恰是最值得花时间打磨的地方。


公众号:蓝海资料掘金营,微信deep3321