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:我要加几个轨道?每个轨道是什么格式?
流程很简单:
- 创建MediaMuxer实例,指定输出文件路径和输出格式(目前只支持MP4)。
- 为每个轨道调用
addTrack(),传入MediaFormat对象。 - 调用
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 封装与停止
数据写完了,最后一步就是停止封装。这一步看似简单,但顺序很重要。
正确的停止流程:
- 先停止编码器(MediaCodec.stop()),确保不再产生新数据。
- 把编码器缓冲区里剩余的数据全部写入MediaMuxer。
- 调用MediaMuxer.stop()。
- 调用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 整体流程图
下面这张图展示了从编码到封装的完整流程,你可以对照着理解:
8.6 常见问题与避坑
最后,我总结几个实际项目中容易踩的坑:
- 文件打不开:最常见的原因是MediaMuxer没有正确stop。如果进程被强杀,文件是不完整的。解决办法是加一个标志位,在异常退出时尝试调用stop。
- 音画不同步:除了时间戳对齐问题,还有可能是音频采样率不匹配。比如你设置的是44100Hz,但实际AudioRecord返回的是48000Hz的数据。一定要检查MediaFormat和实际数据是否一致。
- 内存泄漏:MediaMuxer内部持有文件句柄,如果不release,文件会一直被占用。建议在finally块中确保release被调用。
- 性能问题:writeSampleData是同步操作,如果写入频繁,会阻塞编码器线程。我一般会用一个单独的线程来写数据,或者用缓冲区队列做异步处理。
我的习惯:每次写完文件,我都会用系统播放器打开看一眼。如果画面和声音对不上,先检查时间戳,再检查编码参数。90%的问题出在这两个地方。
好了,MediaMuxer的内容就这些。说白了,它就是音视频开发的最后一道工序——把原材料打包成成品。只要记住:先加轨道、再写数据、最后停止,中间注意时间戳对齐,基本就不会出大问题。
公众号:蓝海资料掘金营,微信deep3321