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。

同步的核心:时间戳调度

上面的代码虽然能同时读取,但还没做到真正的同步。真正的同步,需要根据时间戳来决定先处理谁。

我画了一张图,帮你理解这个调度逻辑:

多轨道同步调度流程 视频轨道 音频轨道 时间戳比较器 比较视频和音频 的时间戳 调度决策 谁的时间戳小 就先处理谁 同步输出 核心逻辑:每次循环比较两个轨道当前帧的时间戳,谁小就先处理谁 这样能保证输出顺序和时间轴完全一致

看到这张图了吗?核心逻辑其实很简单:

  1. 每次循环,读取视频和音频各一帧
  2. 比较它们的时间戳
  3. 时间戳小的先处理(说明它应该更早播放)
  4. 处理完后,再读取下一帧,继续比较

注意:千万不要以为同时读取就是同步。我曾经犯过这个错——两个线程分别读视频和音频,然后直接扔给播放器。结果因为解码耗时不同,画面和声音越差越远。同步必须靠时间戳来调度,不能靠线程调度。

避坑指南:常见同步问题

我在实际项目中踩过不少坑,这里列几个最常见的:

问题 原因 解决方案
刚开始同步,后面越来越不同步 没有基于时间戳调度,只是轮流读取 使用时间戳比较器,谁小先处理谁
音频比视频快很多 音频解码比视频快,缓冲区积累 限制音频缓冲区大小,或者等视频追上
视频卡顿,音频正常 视频帧太大,读取耗时 使用异步读取,或者预读几帧
时间戳出现跳跃 文件本身有问题,或者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