10、多轨道同步提取:同时提取视频和音频,保持音画同步
音画同步,这词儿听起来挺专业,对吧?
说白了,就是视频里人张嘴说话的时候,声音刚好从喇叭里出来。如果嘴巴动了半天,声音才跟上,或者声音先到了,嘴还没张开,那就是不同步。我刚开始做音视频开发时,觉得这事儿挺简单的——不就是读数据嘛。结果第一次做多轨道提取,播放出来的视频,声音比画面快了将近一秒。嗯,那画面,简直像看了一场配音失败的译制片。
为什么多轨道提取会不同步?
要搞清楚这个问题,得先明白MediaExtractor是怎么工作的。
你想想看,一个MP4文件里,视频轨道和音频轨道是分开存放的。MediaExtractor读取的时候,就像有两个独立的管道。视频管道里流的是帧数据,音频管道里流的是采样数据。这两个管道各自为政,没有天然的同步机制。
我打个比方:
- 视频轨道:每秒钟25帧,每帧都有自己的时间戳
- 音频轨道:每秒钟44100个采样点,每个采样点也有时间戳
问题就出在这里——两个轨道的时间戳,虽然都是基于同一个时间轴,但它们的单位可能不一样。视频用微秒,音频用采样数。你得把它们换算成同一个单位,才能对齐。
核心要点:同步的关键,就是基于时间戳来调度。不是谁先读到就播放谁,而是谁的时间戳到了,就播放谁。
实战:双轨道同步提取
好,咱们直接上代码。我会用一个完整的例子,演示如何同时提取视频和音频,并保持同步。
public class SyncExtractor {
private MediaExtractor videoExtractor;
private MediaExtractor audioExtractor;
private MediaFormat videoFormat;
private MediaFormat audioFormat;
// 时间基准,统一用微秒
private static final long TIMEOUT_US = 10000;
public void extractSync(String filePath) {
// 初始化两个提取器
videoExtractor = new MediaExtractor();
audioExtractor = new MediaExtractor();
try {
videoExtractor.setDataSource(filePath);
audioExtractor.setDataSource(filePath);
// 分别选择轨道
int videoTrackIndex = selectTrack(videoExtractor, "video/");
int audioTrackIndex = selectTrack(audioExtractor, "audio/");
if (videoTrackIndex < 0 || audioTrackIndex < 0) {
throw new RuntimeException("找不到视频或音频轨道");
}
videoExtractor.selectTrack(videoTrackIndex);
audioExtractor.selectTrack(audioTrackIndex);
videoFormat = videoExtractor.getTrackFormat(videoTrackIndex);
audioFormat = audioExtractor.getTrackFormat(audioTrackIndex);
// 开始同步提取
syncExtract();
} finally {
videoExtractor.release();
audioExtractor.release();
}
}
private void syncExtract() {
ByteBuffer videoBuffer = ByteBuffer.allocate(1024 * 1024);
ByteBuffer audioBuffer = ByteBuffer.allocate(1024 * 1024);
MediaCodec.BufferInfo videoInfo = new MediaCodec.BufferInfo();
MediaCodec.BufferInfo audioInfo = new MediaCodec.BufferInfo();
boolean videoDone = false;
boolean audioDone = false;
while (!videoDone || !audioDone) {
// 读取视频帧
if (!videoDone) {
int videoSampleSize = videoExtractor.readSampleData(videoBuffer, 0);
if (videoSampleSize < 0) {
videoDone = true;
} else {
videoInfo.size = videoSampleSize;
videoInfo.presentationTimeUs = videoExtractor.getSampleTime();
videoInfo.flags = videoExtractor.getSampleFlags();
// 处理视频帧(这里只做演示,实际需要解码)
processVideoFrame(videoBuffer, videoInfo);
videoExtractor.advance();
}
}
// 读取音频帧
if (!audioDone) {
int audioSampleSize = audioExtractor.readSampleData(audioBuffer, 0);
if (audioSampleSize < 0) {
audioDone = true;
} else {
audioInfo.size = audioSampleSize;
audioInfo.presentationTimeUs = audioExtractor.getSampleTime();
audioInfo.flags = audioExtractor.getSampleFlags();
// 处理音频帧
processAudioFrame(audioBuffer, audioInfo);
audioExtractor.advance();
}
}
}
}
private int selectTrack(MediaExtractor extractor, String mimeType) {
int trackCount = extractor.getTrackCount();
for (int i = 0; i < trackCount; i++) {
MediaFormat format = extractor.getTrackFormat(i);
String mime = format.getString(MediaFormat.KEY_MIME);
if (mime != null && mime.startsWith(mimeType)) {
return i;
}
}
return -1;
}
}
个人经验:我习惯用两个独立的MediaExtractor实例,而不是一个实例来回切换轨道。为什么?因为切换轨道有性能开销,而且容易搞乱状态。两个实例各管各的,逻辑清晰,不容易出bug。
同步的核心:时间戳调度
上面的代码虽然能同时读取,但还没做到真正的同步。真正的同步,需要根据时间戳来决定先处理谁。
我画了一张图,帮你理解这个调度逻辑:
看到这张图了吗?核心逻辑其实很简单:
- 每次循环,读取视频和音频各一帧
- 比较它们的时间戳
- 时间戳小的先处理(说明它应该更早播放)
- 处理完后,再读取下一帧,继续比较
注意:千万不要以为同时读取就是同步。我曾经犯过这个错——两个线程分别读视频和音频,然后直接扔给播放器。结果因为解码耗时不同,画面和声音越差越远。同步必须靠时间戳来调度,不能靠线程调度。
避坑指南:常见同步问题
我在实际项目中踩过不少坑,这里列几个最常见的:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 刚开始同步,后面越来越不同步 | 没有基于时间戳调度,只是轮流读取 | 使用时间戳比较器,谁小先处理谁 |
| 音频比视频快很多 | 音频解码比视频快,缓冲区积累 | 限制音频缓冲区大小,或者等视频追上 |
| 视频卡顿,音频正常 | 视频帧太大,读取耗时 | 使用异步读取,或者预读几帧 |
| 时间戳出现跳跃 | 文件本身有问题,或者B帧导致 | 检查文件完整性,或者做时间戳平滑 |
我的经验:如果你发现同步总是有问题,先别急着改代码。把两个轨道的时间戳打印出来,画成折线图看看。我遇到过好几次,问题出在源文件上——有些录制工具生成的文件,时间戳本身就是乱的。这种情况下,再怎么优化代码也没用。
进阶:动态同步策略
上面讲的是基础同步。但在实际项目中,情况会更复杂。比如网络流、直播场景,时间戳可能不稳定。
这时候,我一般会加一个动态缓冲区:
public class DynamicSyncBuffer {
private static final int MAX_VIDEO_BUFFER = 30; // 最多缓存30帧视频
private static final int MAX_AUDIO_BUFFER = 100; // 最多缓存100帧音频
private Queue<FrameData> videoQueue = new LinkedList<>();
private Queue<FrameData> audioQueue = new LinkedList<>();
public void addFrame(FrameData frame, boolean isVideo) {
if (isVideo) {
if (videoQueue.size() >= MAX_VIDEO_BUFFER) {
videoQueue.poll(); // 丢弃最老的帧
}
videoQueue.offer(frame);
} else {
if (audioQueue.size() >= MAX_AUDIO_BUFFER) {
audioQueue.poll();
}
audioQueue.offer(frame);
}
}
public FrameData getNextFrame() {
if (videoQueue.isEmpty() || audioQueue.isEmpty()) {
return null;
}
FrameData videoHead = videoQueue.peek();
FrameData audioHead = audioQueue.peek();
// 谁的时间戳小,就返回谁
if (videoHead.timestampUs <= audioHead.timestampUs) {
return videoQueue.poll();
} else {
return audioQueue.poll();
}
}
}
这个缓冲区的思路是:
- 给每个轨道一个队列,先读进来的数据先缓存
- 每次从队列头部取数据,比较时间戳
- 如果某个轨道的数据太多,就丢弃最老的
这样做的好处是,能应对短时间的抖动。比如视频突然卡了一下,音频还能继续播放,不会导致整个流程卡死。
总结一下:多轨道同步提取,说白了就是两件事——选对轨道,管好时间戳。选轨道用MIME类型过滤,管时间戳用比较器调度。把这两件事做好了,音画同步就不是问题。
嗯,我记得第一次把同步做成功的时候,看着画面和声音完美对齐,那种感觉真的很爽。希望这篇文章能帮你少走一些弯路。
公众号:蓝海资料掘金营,微信deep3321