实时音视频同步总结

1. 视频向音频同步

  • 优点:逻辑简单,不需要记录开始播放的系统时间,只需要根据音频的每一帧的播放时间计算视频每一帧的播放时间即可。 当音频和视频都出现丢帧时,用户感知不明显。
  • 缺点:没有音频时无法使用。

2. 视频向系统时钟同步

  • 优点:没有音频时也可正常使用。
  • 缺点:逻辑比较复杂,需要记录视频开始播放的时间,并计算每一帧解码后相对于开始播放的时间,将其与pts对比,大于pts需要延时,小于pts需要丢帧。 当App退后台后由于系统时钟不会停止,而连接可能会断开导致视频停止播放,因此需要在回前台时重置开始播放的时间与pts,重新对比。

音频部分代码

- (void)decodePacket:(AVPacket*)pkt {
    if (_abort) {
        return;
    }
    
    [[MGVideoPerformanceTool sharedManager] markDecodeStartIsVideo:NO];
 
    if (pkt) {
        int ret = avcodec_send_packet(codec_ctxt, pkt);
        free(pkt->data);
        av_packet_free(&pkt);
        if (ret < 0) {
            av_log(NULL, AV_LOG_ERROR, "audio avcodec_send_frame failed,ret=%d\n",ret);
            if (ret != AVERROR(EAGAIN))
            {
                [[MGVideoPerformanceTool sharedManager] markDecodeEndIsVideo:NO displayResult:VideoDecodeFail];
                return;
            }
        }
        AVFrame *frame = av_frame_alloc();
        while (!_abort) {
            ret = avcodec_receive_frame(codec_ctxt, frame);
            if (ret == 0) {
                //获取音频的相对时间
                AVRational timebase;
                if (self.stream) {
                    timebase = self.stream->time_base;
                    self.videoContext.audio_pts_second = frame->pts * av_q2d(timebase);
                }else{
                    timebase = codec_ctxt->time_base;
                    double durationPerPacket = (double)frame->nb_samples / (double)frame->sample_rate ;
                    self.videoContext.audio_pts_second += durationPerPacket;
                }

                printf("audio_pts_second:%f\n", self.videoContext.audio_pts_second);
                // 声道数
                int inChs = av_get_channel_layout_nb_channels(codec_ctxt->channel_layout);
                
                if (inChs>1 || (codec_ctxt->sample_fmt != AV_SAMPLE_FMT_S16)) {
                    ret = [self resampleAudioFrame:frame ];
                    if (ret> 0) {
                        if (self.isWriteDataToFile) {
                            fwrite((const char *)frame->data[0], 1, frame->linesize[0], fp_pcm);
                        }
                        if (self.delegate && [self.delegate respondsToSelector:@selector(decoder:didDecodeAudioFrame:)]){
                            [self.delegate decoder:self didDecodeAudioFrame:frame];
                        }
                    }
                    
//                    av_log(NULL, AV_LOG_INFO, "release outData memory\n");
                    if (frame->data[0]) {
                        av_freep(&(frame->data[0]));
                    }
                }
                else {
                    if (self.isWriteDataToFile) {
                        fwrite(frame->data[0],1,frame->linesize[0], fp_pcm);
                    }
                    if (self.delegate && [self.delegate respondsToSelector:@selector(decoder:didDecodeAudioFrame:)]){
                        [self.delegate decoder:self didDecodeAudioFrame:frame];
                    }
                }
                [[MGVideoPerformanceTool sharedManager] markDecodeEndIsVideo:NO displayResult:VideoNoFail];
            }else if (AVERROR(EAGAIN)) {
                break;
            }else{
//                _abort = 1;
//                av_log(NULL, AV_LOG_ERROR, "avcodec_receive_frame failed,ret=%d\n",ret);
                break;
            }
        }
        av_frame_free(&frame);
    }else{
        av_log(NULL, AV_LOG_INFO, "decodeThread not got packet\n");
    }
}

视频部分代码

- (void)decodePacket:(AVPacket*)pkt{
    if (_abort){
        av_log(NULL, AV_LOG_TRACE, "vidoe decoder is stoped, but still receive package\n");
        return;
    }
    [[MGVideoPerformanceTool sharedManager] markDecodeStartIsVideo:YES];
    if (pkt) {
        av_log(NULL, AV_LOG_TRACE,"video packet pts =%llu\n", pkt->pts);
        if (!_videoStartTime) {
            _videoStartTime = [NSDate date];
        }
        int ret = avcodec_send_packet(codec_ctxt, pkt);
        if (ret < 0) {
            av_log(NULL, AV_LOG_ERROR, "video avcodec_send_frame failed,%s\n",av_err2str(ret));
            if (ret != AVERROR(EAGAIN))
            {
                [[MGVideoPerformanceTool sharedManager] markDecodeEndIsVideo:YES displayResult:VideoDecodeFail];
                if (self.delegate && [self.delegate respondsToSelector:@selector(decoder:didFailedToDecodePacket:)]) {
                    [self.delegate decoder:self didFailedToDecodePacket:pkt];
                }
                free(pkt->data);
                av_packet_free(&pkt);
                return;
            }
        }
        free(pkt->data);
        av_packet_free(&pkt);
        int fps = self.stream ? av_q2d(self.stream->avg_frame_rate) : self.fps;
        double frame_delay = 1.0 / fps;
        AVFrame *frame = av_frame_alloc();
        while (!_abort) {
            ret = avcodec_receive_frame(codec_ctxt, frame);
            self.frameNum ++;
            if (ret == 0) {
            
                if (self.isWriteDataToFile) {
                    [self recordFrameToFile:frame];
                }
                
                
                //判断当前帧是否有效,如果isValidFrame为false,则需要跳帧
                BOOL isValidFrame = [self scheduleVideoFrame:frame fps:fps frame_delay:frame_delay];
                if (!isValidFrame) {
                    NSLog(@"视频太慢,跳过该帧");
                    [[MGVideoPerformanceTool sharedManager] markDecodeEndIsVideo:YES displayResult:VideoSyncSkip];
                    continue;
                }
                //原始frame的数据(包括data、linesize和buffer)在切换线程后会被释放,所以这里需要增加引用计数来确保其不被释放
                //如果是传递转换后的frame则不需要,因为outBuffer没有释放
                
                CVPixelBufferRef buffer = [self convertFrameToPixelBuffer:frame];
                dispatch_async(dispatch_get_main_queue(), ^{
                    if (buffer && self.delegate && [self.delegate respondsToSelector:@selector(decoder:didDecodeVideoBuffer:)]){
                        [self.delegate decoder:self didDecodeVideoBuffer:buffer];
                    }
                    if (buffer) {
                        CVPixelBufferRelease(buffer);
                    }
                });
                [[MGVideoPerformanceTool sharedManager] markDecodeEndIsVideo:YES displayResult:VideoNoFail];
                
                continue;
            }else if (AVERROR(EAGAIN)) {
                break;
            }else{
                break;
            }
        }
        av_frame_free(&frame);
    }else{
        av_log(NULL, AV_LOG_INFO, "decodeThread not got packet\n");
    }
    
}

- (BOOL)scheduleVideoFrame:(AVFrame*)avFrame fps:(double)fps frame_delay:(double)frame_delay{
    //获取当前画面的相对播放时间 , 相对 : 即从播放开始到现在的时间
    //  该值大多数情况下 , 与 pts 值是相同的
    //  该值比 pts 更加精准 , 参考了更多的信息
    //  转换成秒 : 这里要注意 pts 需要转成 秒 , 需要乘以 time_base 时间单位
    //  其中 av_q2d 是将 AVRational 转为 double 类型
    
    double video_best_effort_timestamp_second;
    if (self.stream) {
        AVRational timebase = self.stream->time_base;
        video_best_effort_timestamp_second = avFrame->best_effort_timestamp * av_q2d(timebase);
    }else{
        video_best_effort_timestamp_second = (double)avFrame->best_effort_timestamp /AV_TIME_BASE;
//            video_best_effort_timestamp_second = frame_delay * pkt_num;
    }
//    printf("video packet Num:%d video_best_effort_timestamp_second:%f\n ",self.videoContext.video_packetNum,video_best_effort_timestamp_second);
    
    //解码时 , 该值表示画面需要延迟多长时间在显示
    //  需要使用该值 , 计算一个额外的延迟时间
    //  这里按照文档中的注释 , 计算一个额外延迟时间
    double extra_delay = avFrame->repeat_pict / ( fps * 2 );
    
    //计算总的帧间隔时间 , 这是真实的间隔时间
    double total_frame_delay = frame_delay + extra_delay;
    
    //将 total_frame_delay ( 单位 : 秒 ) , 转换成 微秒值 , 乘以 100 万
    unsigned microseconds_total_frame_delay = total_frame_delay * AV_TIME_BASE;
    
    if(video_best_effort_timestamp_second == 0 ){
        //如果播放的是第一帧 , 或者当前音频没有播放 , 就要正常播放
        //休眠 , 单位微秒 , 控制 FPS 帧率
        av_usleep(microseconds_total_frame_delay);
    }else{
        //如果不是第一帧 , 要开始考虑音视频同步问题了
        double second_delta;
        //优先视频向音频对齐,如果没有音频,则向系统时钟对齐
        if (self.videoContext.syncMode == VideoSyncModeAudioClock) {
            //音频的相对播放时间 , 这个是相对于播放开始的相对播放时间
            double audio_pts_second =  self.videoContext.audio_pts_second;

            //使用视频相对时间 - 音频相对时间
            second_delta = video_best_effort_timestamp_second - audio_pts_second;
//            printf("差距:%f秒 ",second_delta);
        }else{
            //当前时间,videoStartTime是视频相对于播放开始的相对时间
//            double currentTime = av_gettime_relative() / 1000000.0 - videoStartTime;
            double currentTime = [[NSDate date] timeIntervalSinceDate:_videoStartTime];
//            printf("currentTime:%f\n",currentTime);
            //视频帧的时间
            double  pts = video_best_effort_timestamp_second;
            
            //计算时间差,大于0则late,小于0则early。
            second_delta = pts - currentTime;
            printf("差距:%f秒 ",second_delta);
            //没有音频的情况下,调整起始时间,确保下一帧播放时pts和currentTime是差不多的,减少后续丢帧
//            if(![VideoContext sharedInstance].audio_pts_second && second_delta <0) {
//                videoStartTime = [NSDate dateWithTimeInterval:-second_delta sinceDate:videoStartTime];
//            }
           
        }
        //将相对时间转为 微秒单位
        unsigned microseconds_delta = second_delta * AV_TIME_BASE;
        
        //如果 second_delta 大于 0 , 说明视频播放时间比较长 , 视频比音频快或者比系统时钟快
        //如果 second_delta 小于 0 , 说明视频播放时间比较短 , 视频比音频慢或者比系统时钟慢
        if(second_delta > 0){
                //视频快处理方案 : 增加休眠时间
                //休眠 , 单位微秒 , 控制 FPS 帧率
            if (second_delta > 0.1)  second_delta = 0.1;
            printf("视频太快,休眠%f秒,其中microseconds_delta为%f秒\n", (double)(microseconds_total_frame_delay + microseconds_delta)/1000000, (double)microseconds_delta/1000000 );
                av_usleep(microseconds_total_frame_delay + microseconds_delta);
        }else if(second_delta < 0){
            //视频慢处理方案 :
            //  ① 方案 1 : 减小休眠时间 , 甚至不休眠
            //  ② 方案 2 : 视频帧积压太多了 , 这里需要将视频帧丢弃 ( 比方案 1 极端 )
            if(fabs(second_delta) >= 2 * frame_delay){
                
                //丢弃解码后的视频帧
                //终止本次循环 , 继续下一次视频帧绘制
                return false;

            }else{
                //如果音视频之间差距低于 0.05 秒 , 不操作 ( 50ms )
            }
        }
    }
    return true;
}

由于人对声音的停顿比视频感知更强,所以以上两种方法都是对视频做延迟或丢帧处理,而音频不做处理,接收到就播放。

为了减少数据发送过快导致播放太快的问题,发送端需要通过延迟或丢帧的方式来控制发送速率,确保每一帧都是40ms左右发送(fps=25)。
这里有个坑,就是音频和视频如果放在同一个线程里去发送,会造成互相影响(sleep线程)从而降低发送速率的问题,所以音视频需要分两个线程分开发送。

如果发送端没有做延迟处理(比如直接从文件中读取这种情况),则接收端需要通过数据包队列来缓存接收到的数据,并定时从队列中取数据进行解码播放。

关于定时读取,iOS上可以使用CADisplayLink来实现(设置preferredFramesPerSecond),但准确率并不是很高,更好的做法是单独开一个线程,通过sleep来控制。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,242评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,769评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,484评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,133评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,007评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,080评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,496评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,190评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,464评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,549评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,330评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,205评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,567评论 3 298
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,889评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,160评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,475评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,650评论 2 335

推荐阅读更多精彩内容