8、MediaMuxer封装:MP4文件结构、音视频轨道添加、时间戳同步、封装与停止

好,咱们继续往下走。前面几章我们把Camera、AudioRecord、MediaCodec都摸了一遍,音视频数据已经编码成H.264和AAC了。但说实话,光有这些裸流文件,播放器是认不出来的。你得把它们装进一个容器里——最常见的,就是MP4。

这一章,我们就来聊聊MediaMuxer。说白了,它就是Android官方提供的一个“打包工具”,负责把编码后的音视频数据按MP4格式封装起来。我当年第一次用的时候,觉得这玩意儿挺简单的,不就是addTrack、writeSampleData、stop三步走吗?结果一跑起来,视频画面和声音对不上,或者文件打不开,坑不少。

核心要点:MediaMuxer的核心职责是“复用”——把独立的音视频流按时间顺序交错写入MP4容器。它不负责编码,只负责封装。

8.1 MP4文件结构速览

先简单了解一下MP4的结构。你不用背,但得知道它长什么样。MP4基于ISO Base Media File Format,内部由一个个“Box”(也叫Atom)组成。

常见的Box有:

  • ftyp:文件类型,告诉播放器这是MP4。
  • moov:元数据,包含音视频轨道信息、时长、编码参数等。这个Box很重要,播放器得先读它才能开始播放。
  • mdat:真正的音视频数据,也就是我们编码后的H.264和AAC样本。

这里有个关键点:moov Box的位置。如果moov在mdat前面,叫“快速启动”,适合网络播放;如果moov在mdat后面,叫“普通封装”,文件得下载完才能播放。MediaMuxer默认把moov放在文件末尾,所以录完的文件不能边下载边播。我在做直播回放功能时就踩过这个坑,后来用工具把moov挪到前面才解决。

小提示:如果你需要moov前置,可以用mp4parser或ffmpeg后处理。MediaMuxer本身不支持设置moov位置。

8.2 添加音视频轨道

开始封装前,必须先告诉MediaMuxer:我要加几个轨道?每个轨道是什么格式?

流程很简单:

  1. 创建MediaMuxer实例,指定输出文件路径和输出格式(目前只支持MP4)。
  2. 为每个轨道调用addTrack(),传入MediaFormat对象。
  3. 调用start(),开始接收数据。

代码示例:

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, 2_000_000);
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, channelCount);
audioFormat.setInteger(MediaFormat.KEY_BIT_RATE, 128_000);
audioFormat.setInteger(MediaFormat.KEY_AAC_PROFILE, MediaCodecInfo.CodecProfileLevel.AACObjectLC);
int audioTrackIndex = muxer.addTrack(audioFormat);

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

嗯,这里要注意:addTrack()必须在start()之前调用,而且一旦start了,就不能再添加新轨道了。我刚开始做的时候,想着先start再动态加轨道,结果直接抛异常。

警告:addTrack()返回的trackIndex要记好,后面写数据时要用。视频和音频的索引是分开的,别搞混了。

8.3 时间戳同步——最头疼的部分

好,轨道加完了,数据也来了。但你会发现,如果直接把视频数据和音频数据往MediaMuxer里塞,出来的文件画面和声音大概率是不同步的。为什么?

因为视频和音频的时间戳是独立的。视频编码器输出的时间戳单位是微秒,音频也是微秒,但它们的起始时间可能不一样。比如视频第一帧的时间戳是0,音频第一帧的时间戳可能也是0,但实际录制时,音频可能比视频晚启动了几毫秒。如果不做对齐,播放器就会乱套。

我的做法是:统一以系统时钟为基准。在开始录制时,记录一个起始时间startTime,然后每个音视频帧的时间戳都减去这个起始时间。这样两路流的时间戳就对齐了。

代码示例:

private long startTime = -1;

public void onVideoFrame(ByteBuffer buffer, MediaCodec.BufferInfo info) {
    if (startTime == -1) {
        startTime = info.presentationTimeUs;
    }
    info.presentationTimeUs -= startTime;
    muxer.writeSampleData(videoTrackIndex, buffer, info);
}

public void onAudioFrame(ByteBuffer buffer, MediaCodec.BufferInfo info) {
    if (startTime == -1) {
        startTime = info.presentationTimeUs;
    }
    info.presentationTimeUs -= startTime;
    muxer.writeSampleData(audioTrackIndex, buffer, info);
}

我曾经遇到过一个情况:视频时间戳是单调递增的,但音频时间戳偶尔会往回跳。后来发现是AudioRecord的缓冲区在某些低端设备上不稳定。解决办法是:如果发现音频时间戳小于上一个时间戳,就强制设为上一个值+1。

经验之谈:时间戳同步是音视频开发里最容易出bug的地方之一。我的建议是:录完文件后,用ffprobe检查一下两个轨道的时间戳是否对齐。命令:ffprobe -show_packets output.mp4

8.4 封装与停止

数据写完了,最后一步就是停止封装。这一步看似简单,但顺序很重要。

正确的停止流程:

  1. 先停止编码器(MediaCodec.stop()),确保不再产生新数据。
  2. 把编码器缓冲区里剩余的数据全部写入MediaMuxer。
  3. 调用MediaMuxer.stop()。
  4. 调用MediaMuxer.release()释放资源。

代码示例:

// 停止编码器
videoCodec.stop();
audioCodec.stop();

// 把剩余数据写入muxer
drainEncoder(videoCodec, videoTrackIndex, true);
drainEncoder(audioCodec, audioTrackIndex, true);

// 停止muxer
muxer.stop();
muxer.release();

这里有个细节:drainEncoder()的最后一个参数是endOfStream标志。设为true时,编码器会输出剩余的帧,包括可能存在的B帧延迟。如果不处理这些剩余帧,文件末尾可能会丢数据。

注意:MediaMuxer.stop()之后就不能再写数据了。而且stop()可能会耗时较长(尤其是文件较大时),建议在子线程中调用。

8.5 整体流程图

下面这张图展示了从编码到封装的完整流程,你可以对照着理解:

MediaMuxer封装流程 视频编码器 H.264 数据 音频编码器 AAC 数据 时间戳对齐 统一减去起始时间 保证两路同步 MediaMuxer addTrack() writeSampleData() stop() / release() output.mp4 ftyp + moov + mdat 编码后的裸流 同步后写入 最终输出

8.6 常见问题与避坑

最后,我总结几个实际项目中容易踩的坑:

  • 文件打不开:最常见的原因是MediaMuxer没有正确stop。如果进程被强杀,文件是不完整的。解决办法是加一个标志位,在异常退出时尝试调用stop。
  • 音画不同步:除了时间戳对齐问题,还有可能是音频采样率不匹配。比如你设置的是44100Hz,但实际AudioRecord返回的是48000Hz的数据。一定要检查MediaFormat和实际数据是否一致。
  • 内存泄漏:MediaMuxer内部持有文件句柄,如果不release,文件会一直被占用。建议在finally块中确保release被调用。
  • 性能问题:writeSampleData是同步操作,如果写入频繁,会阻塞编码器线程。我一般会用一个单独的线程来写数据,或者用缓冲区队列做异步处理。

我的习惯:每次写完文件,我都会用系统播放器打开看一眼。如果画面和声音对不上,先检查时间戳,再检查编码参数。90%的问题出在这两个地方。

好了,MediaMuxer的内容就这些。说白了,它就是音视频开发的最后一道工序——把原材料打包成成品。只要记住:先加轨道、再写数据、最后停止,中间注意时间戳对齐,基本就不会出大问题。


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