音视频同步基础知识

Posted Loken2020

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了音视频同步基础知识相关的知识,希望对你有一定的参考价值。

之前的文章,已经把音频,视频的 解码线程,播放线程通通讲了一遍,现在到了播放器实现最复杂的功能之一,就是音视频同步。

FFplay 支持 3 种同步方式,如下:

1,以音频时钟为主时钟,默认方式。

2,以视频时钟为主时钟。

3,以外部时钟为主时钟。

因为音频流的连续性非常强,例如一秒需要播放 48000 个样本。而视频流通常1秒只需播放 24 帧。而且人的耳朵对音频特别敏感。

所以第一种方式以音频时钟为准是最常用的,其他两种方式几乎没有应用场景。


先普及一下音视频同步的一些基本概念。

1,为什么需要进行音视频同步?

答:由于计算机系统大部分是分时系统,所以当负载过高或者设备性能差的时候,音频播放线程 或者 视频播放线程会卡顿,调度不过来,导致视频画面已经更新了,但是声音还没放出来。这种不同步的差异如果越积越大,就会明显体验不好。典型的场景就是,演讲视频里面的口型跟声音。

2,如何把音频播放 跟 视频播放做到完全同步?

答:无法做到,音频与视频的播放,几乎不能完全同步,不信你可以用 FFplay 播放一个视频试试,控制台输出的 A-V 大多数时间都不是 0 。完全同步的话 A-V 就是 0。

通常我们只需要把音视频不同步的程度,控制在一个合理的阈值内 就可以了。人的感官没有那么敏感的,只要在阈值内,就感受不到不同步了。

虽然不同步是常态,但是感受不到就不算是了。

3,如果只有视频流,没有音频流,如何进行同步?

答:不需要进行同步,只有音频流跟视频流同时播放的时候才需要同步。


由于每一个音频样本就打一个 pts 过于繁琐与麻烦,而且音视频不需要完全同步,控制在阈值内即可。所以大部分音频标准会把多个样本塞进去一个帧,只对一个帧打 PTS 即可。对于 aac 来说,通常一个 AVFrame 里面有 1024 个音频样本。

因此,如果采样率是 48000 每秒,那一帧音频(AVFrame)可以播放 0.021s。

无论是音频还是视频,播放的逻辑都是,在预定的时间(pts)播放对应的 AVFrame

还是以 juren-30s.mp4 为例,它的帧率是 1/24,采样率是 1/48000。也就是每隔0.041s 播放一帧视频,每隔 0.021s 播放一帧音频。

因此这个文件的播放过程应该如下图:

上面的流程图是,在下午2点整的时候,播放 juren-30s.mp4 文件,那在 14:00:00 的时候就会播放第一帧视频跟第一帧音频。按照预定的时间 pts,第四帧视频应该在 14:00:00:123 的时刻播放,第 7 帧音频差不多也需要播放,所以算同时播放第 7 帧音频吧。

补充:第7帧音频预定播放时间是 14:00:00:126,有一点点误差,但是为了方便讲解,我假设第七帧的预定时间是 14:00:00:123 。

假如,计算机突然打开另一个软件,做了大量计算,导致视频播放线程没及时调度过来,从而导致 第四帧视频在 14:00:00:143 的时刻才播放,因此视频流就比预定的时候 14:00:00:123 慢了 0.02s。

那是不是就说明音视频开始不同步了?

不是,视频帧不在预定的时间播放,不代表音视频不同步。因为音频帧也可能不在预定的时间播放,假设音频播放线程也没有及时调度过来,也是在第 14:00:00:143 的时刻才播放第 7 帧音频。

那在 14:00:00:143 这一刻,音视频就是完全同步的,虽然他们没有在预定的时间播放。


再假设一个场景,系统卡顿,导致第四帧视频在 14:00:00:153 的时刻才播放,但是音频播放线程卡顿没那么严重,在 14:00:00:136 的时候已经开始播放第 7 帧了。

对应视频流, 14:00:00:153 比预定的时间 14:00:00:123 慢了 0.03s,那是不是就说明视频比音频慢了 0.03s?

也不是,因为 音频流本来应该在 14:00:00:126 的时刻播放第 7 帧,但到了 14:00:00:136 才播放第 7 帧,音频也慢了 0.01s。

正确的描述是,视频流比预定的时间慢了 0.03s,音频流比预定的时间慢了 0.01s,预定的时间可以对消掉,所以,计算过程如下:

视频pts - 预定时间 = 0.03
音频pts - 预定时间 = 0.01
视频pts - 音频pts = 0.02

现在已经计算出 音频 跟 视频 的时间差,但是以音频为主时钟 跟 与 视频为主时钟又有什么区别呢?无论以哪个为主时钟,时间差都是一样的啊?

答:没错,时间差都是一样的。

以音频为主时钟的逻辑是这样,按 juren-30s.mp4 的 pts 来说,在听到 第 7 帧音频的第一个样本的时候,你的眼睛就应该同时看到第 4 帧视频画面。

第 7 帧音频 跟 第 4 帧视频应该在同一时刻播放,假设音频播放线程卡顿,在 14:00:00:153 的时候才播放第7帧音频,但是视频播放线程无卡顿,那 14:00:00:123 的时候,视频播放线程本来应该去取第四帧视频播放,但是由于第7帧音频还未播放,所以第三帧视频需要继续显示0.03s,等待音频。

这就是以音频为主时钟的逻辑,拉长或者缩短视频帧的播放时长,或者丢弃视频帧。

如果是以视频为主时钟的情况,那在 14:00:00:123 的时候,视频播放线程就正常去取第四帧播放就行,因为确实已经到了第四帧播放的时间点,音频慢了,就缩短音频帧的播放时长就行,不需要视频流慢下来。

以视频为主时钟,就是拉长或者缩短音频帧的播放时长,但是不会丢弃音频帧。音频帧连续性太长,丢帧很容易被耳朵发现。

上面的例子是假设用来说明的,一般音视频不会一下子就差那么多,不同步的程度是一点一滴累加的。当累加的值超过阈值,就需要我们的程序做干预。


推荐一个零声学院免费公开课程,个人觉得老师讲得不错,分享给大家:

Linux,Nginx,ZeroMQ,MySQL,Redis,fastdfs,MongoDB,ZK,流媒体,CDN,P2P,K8S,Docker,TCP/IP,协程,DPDK等技术内容,立即学习

Qt与FFmpeg联合开发指南——编码:完善功能和基础封装

上一章我用一个demo函数演示了基于Qt的音视频采集到编码的完整流程,最后经过测试我们也发现了代码中存在的问题。本章我们就先处理几个遗留问题,再对代码进行完善,最后把编码功能做基础封装。

一、遗留问题和解决方法

(1)如何让音视频的录制保持同步?

在我们的演示代码中之所以发现音视频录制不同步的主要原因是音频帧和视频帧不应该简单的按照1:1的比例进行编码。那么到底应该以什么样的比例控制呢?首先建议大家回顾一下之前写过的解码过程。如果我们把解码音视频的过程输出到控制台,我们会注意到大致每解码一帧画面应该解码2~4帧声音。按照这个思路我们先尝试修改一下demo中的编码步骤,人为控制视频和音频的编码比例为1:3。修改以后的代码如下:

// 音频编码
for (int i = 0; i < 3; ++i) {
    // 固定写法:配置一帧音频的数据结构
    const char *pcm = aa->getPCM();

    /* 此处省略的代码请参考上一章的内容或查看源码 */

    delete pcm;
}

然后再尝试录制,我们发现音频似乎可以正常播放,但是画面和音频并没有同步。另外,如果仔细一些的同学可能还会发现。在上一篇博客的最后一张截图中,音频的比特率显示为35kbps。

让我们先了解一下视频帧率和音频帧率的概念:通常fps10代表1秒显示10幅画面,这个比较容易理解。不太容易理解的是音频,以CD音质为例44100Hz的采样率,假设一帧音频包含1024个采样数据,那么1秒钟的音频大约有43帧。在编码阶段无论是视频还是音频我们都需要提供一个基础的pts作为参考。代表视频的vpts每次自增1即可,而代表音频的apts需要每次自增1024。

FFmpeg提供了一个比较函数 av_compare_ts(int64_t ts_a, AVRational tb_a, int64_t ts_b, AVRational tb_b) 来帮助开发人员计算音视频pts同步。

while (true) {
    // 音频编码
    const char *pcm = aa->getPCM();
    if (pcm) {
        ...

        apkt->pts = apts;
        apkt->dts = apkt->pts;
        apts += av_rescale_q(aframe->nb_samples, { 1, pAudioCodecCtx->sample_rate }, pAudioCodecCtx->time_base); // 1024
        errnum = av_interleaved_write_frame(pFormatCtx, apkt);

        ...
        delete pcm;
        av_packet_free(&apkt);
    }

    // 比较音视频pts,大于0表示视频帧在前,音频需要连续编码。小于0表示,音频帧在前,应该至少编码一帧视频
    int ts = av_compare_ts(vpts, pVideoCodecCtx->time_base, apts, pAudioCodecCtx->time_base);
    if (ts > 0) {
        continue;
    }

    // 视频编码
    const uchar *rgb = va->getRGB();
    if (rgb) {
        ...
        
        vframe->pts = vpts++;

        ...
        errnum = av_interleaved_write_frame(pFormatCtx, vpkt);
        
        ...
        delete rgb;
        av_packet_free(&vpkt);
    }    
}

 这样音视频同步的部分就基本完成。

 (2)如何正确析构QImage

通过memcpy函数将QImage中的数据拷贝一份

QPixmap pix = screen->grabWindow(wid);
uchar *rgb = new uchar[width * height * 4]; // 申请图像存储空间
memcpy(rgb, pix.toImage().bits(), width * height * 4); // 拷贝数据到新的内存区域

这样外部的调用者正常对rgb数据析构就不会有任何问题了。

(3)有关Qt截屏的效率讨论*

Qt提供的截屏方案虽然简单,但是时间开销有点大。如果我们希望录制fps25以上的画面时可能不尽如人意。因此如果是在Windows环境下,我推荐通过DirectX做截屏操作。有兴趣的同学可以参考我的源码,这里就不做过多讨论了。

二、功能封装

首先说明一下我们的封装目标。由于主线程需要留给界面和事件循环,因此音视频采集以及编码都各自运行在独立的线程中。音视频的采集可以和编码分离,通过队列暂存数据。

(1)界面设计(略)

这个部分不是本文的重点

(2)视频捕获线程(VideoAcquisitionThread)

const uchar* VideoAcquisitionThread::getRGB()
{
    mtx.lock();
    if (rgbs.size() > 0) {
        uchar *rgb = rgbs.front();
        rgbs.pop_front();
        mtx.unlock();
        return rgb;
    }
    mtx.unlock();
    return NULL;
}

void VideoAcquisitionThread::run()
{
    int interval = 1000 / fps;
    QTime rt;
    while (!isThreadQuit) {
        if (rgbs.size() < listSize) {
            rt.restart();
            mtx.lock();
            QPixmap pix = screen->grabWindow(wid);
            uchar *rgb = new uchar[width * height * 4]; // 申请图像存储空间
            memcpy(rgb, pix.toImage().bits(), width * height * 4); // 拷贝数据到新的内存区域
            rgbs.push_back(rgb);
            cout << ".";
            mtx.unlock();
            int el = rt.restart();
            if (interval > el) {
                msleep(interval - el);
            }
        }
    }
}

(3)音频捕获线程(AudioAcquishtionThread)

const char* AudioAcquishtionThread::getPCM()
{
    mtx.lock();
    if (pcms.size() > 0) {
        char *pcm = pcms.front();
        pcms.pop_front();
        mtx.unlock();
        return pcm;
    }
    mtx.unlock();
    return NULL;
}

void AudioAcquishtionThread::run()
{
    
    while (!isThreadQuit) {
        mtx.lock();
        if (pcms.size() < listSize) {
            int readOnceSize = 1024; // 每次从音频设备中读取的数据大小
            int offset = 0; // 当前已经读到的数据大小,作为pcm的偏移量
            int pcmSize = 1024 * 2 * 2;
            char *pcm = new char[pcmSize];
            while (audioInput) {
                int remains = pcmSize - offset; // 剩余空间
                int ready = audioInput->bytesReady(); // 音频采集设备目前已经准备好的数据大小
                if (ready < readOnceSize) { // 当前音频设备中的数据不足
                    QThread::msleep(1);
                    continue;
                }
                if (remains < readOnceSize) { // 当帧存储(pcmSize)的剩余空间(remainSize)小于单次读取数据预设(readSizeOnce)时
                    device->read(pcm + offset, remains); // 从设备中读取剩余空间大小的数据
                    // 读满一帧数据退出
                    break;
                }
                int len = device->read(pcm + offset, readOnceSize);
                offset += len;
            }
            pcms.push_back(pcm);
        }
        mtx.unlock();
    }
}

(4)初始化封装器,音视频流和音视频转码器

bool EncoderThread::init(QString filename, int fps)
{
    close();
    mtx.lock();
    at = new AudioAcquishtionThread();
    vt = new VideoAcquisitionThread();
    // 启动音视频采集线程
    vt->start(fps);
    at->start();
    this->filename = filename;
    errnum = avformat_alloc_output_context2(&pFormatCtx, NULL, NULL, filename.toLocal8Bit().data());
    if (errnum < 0) {
        av_strerror(errnum, errbuf, sizeof(errbuf));
        mtx.unlock();
        return false;
    }
    // 创建视频编码器
    const AVCodec *vcodec = avcodec_find_encoder(AVCodecID::AV_CODEC_ID_H264);
    if (!vcodec) {
        mtx.unlock();
        return false;
    }

    pVideoCodecCtx = avcodec_alloc_context3(vcodec);
    if (!pVideoCodecCtx) {
        mtx.unlock();
        return false;
    }

    // 比特率、宽度、高度
    pVideoCodecCtx->bit_rate = 4000000;
    pVideoCodecCtx->width = vt->getWidth();
    pVideoCodecCtx->height = vt->getHeight();
    // 时间基数、帧率
    pVideoCodecCtx->time_base = { 1, fps };
    pVideoCodecCtx->framerate = { fps, 1 };
    // 关键帧间隔
    pVideoCodecCtx->gop_size = 10;
    // 不使用b帧
    pVideoCodecCtx->max_b_frames = 0;
    // 帧、编码格式
    pVideoCodecCtx->pix_fmt = AVPixelFormat::AV_PIX_FMT_YUV420P;
    pVideoCodecCtx->codec_id = AVCodecID::AV_CODEC_ID_H264;
    // 预设:快速
    av_opt_set(pVideoCodecCtx->priv_data, "preset", "superfast", 0);
    // 全局头
    pVideoCodecCtx->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;

    // 打开编码器
    errnum = avcodec_open2(pVideoCodecCtx, vcodec, NULL);
    if (errnum < 0) {
        av_strerror(errnum, errbuf, sizeof(errbuf));
        mtx.unlock();
        return false;
    }

    // 创建音频编码器
    const AVCodec *acodec = avcodec_find_encoder(AVCodecID::AV_CODEC_ID_AAC);
    if (!acodec) {
        mtx.unlock();
        return false;
    }

    // 根据编码器创建编码器上下文
    pAudioCodecCtx = avcodec_alloc_context3(acodec);
    if (!pAudioCodecCtx) {
        mtx.unlock();
        return false;
    }

    // 比特率、采样率、采样类型、音频通道、文件格式
    pAudioCodecCtx->bit_rate = 64000;
    pAudioCodecCtx->sample_rate = 44100;
    pAudioCodecCtx->sample_fmt = AVSampleFormat::AV_SAMPLE_FMT_FLTP;
    pAudioCodecCtx->channels = 2;
    pAudioCodecCtx->channel_layout = av_get_default_channel_layout(2); // 根据音频通道数自动选择输出类型(默认为立体声)
    pAudioCodecCtx->flags |= AV_CODEC_FLAG_GLOBAL_HEADER;

    // 打开编码器
    errnum = avcodec_open2(pAudioCodecCtx, acodec, NULL);
    if (errnum < 0) {
        av_strerror(errnum, errbuf, sizeof(errbuf));
        mtx.unlock();
        return false;
    }

    // 初始化视频转码器
    swsCtx = sws_getContext(
        vt->getWidth(), vt->getHeight(), AVPixelFormat::AV_PIX_FMT_BGRA,
        vt->getWidth(), vt->getHeight(), AVPixelFormat::AV_PIX_FMT_YUV420P,
        SWS_BICUBIC,
        0, 0, 0);
    if (!swsCtx) {
        mtx.unlock();
        return false;
    }

    // 初始化音频转码器
    swrCtx = swr_alloc_set_opts(swrCtx,
        av_get_default_channel_layout(2), AVSampleFormat::AV_SAMPLE_FMT_FLTP, 44100, // 输出
        av_get_default_channel_layout(2), AVSampleFormat::AV_SAMPLE_FMT_S16, 44100, // 输入
        0, 0);
    errnum = swr_init(swrCtx);
    if (errnum < 0) {
        mtx.unlock();
        return false;
    }
    
    mtx.unlock();
    return true;
}

(5)添加视频流

bool EncoderThread::addVideoStream()
{
    mtx.lock();
    // 为封装器创建视频流
    pVideoStream = avformat_new_stream(pFormatCtx, NULL);
    if (!pVideoStream) {
        mtx.unlock();
        return false;
    }
    
    // 配置视频流的编码参数
    errnum = avcodec_parameters_from_context(pVideoStream->codecpar, pVideoCodecCtx);
    if (errnum < 0) {
        av_strerror(errnum, errbuf, sizeof(errbuf));
        mtx.unlock();
        return false;
    }

    pVideoStream->codec->codec_tag = 0;
    pVideoStream->codecpar->codec_tag = 0;

    mtx.unlock();
    return true;
}

(6)添加音频流

bool EncoderThread::addAudioStream()
{
    mtx.lock();
    // 添加音频流
    pAudioStream = avformat_new_stream(pFormatCtx, NULL);
    if (!pAudioStream) {
        mtx.unlock();
        return false;
    }
    // 配置音频流的编码器参数
    errnum = avcodec_parameters_from_context(pAudioStream->codecpar, pAudioCodecCtx);
    if (errnum < 0) {
        av_strerror(errnum, errbuf, sizeof(errbuf));
        mtx.unlock();
        return false;
    }
    pAudioStream->codec->codec_tag = 0;
    pAudioStream->codecpar->codec_tag = 0;
    mtx.unlock();
    return true;
}

 

(7)重写线程启动方法(代理模式)

void EncoderThread::start()
{
    
    mtx.lock();
    // 打开输出流
    errnum = avio_open(&pFormatCtx->pb, filename.toLocal8Bit().data(), AVIO_FLAG_WRITE); // 打开AVIO流
    if (errnum < 0) {
        av_strerror(errnum, errbuf, sizeof(errbuf));
        avio_closep(&pFormatCtx->pb);
        mtx.unlock();
        return;
    }
    // 写文件头
    errnum = avformat_write_header(pFormatCtx, NULL);
    if (errnum < 0) {
        av_strerror(errnum, errbuf, sizeof(errbuf));
        mtx.unlock();
        return;
    }

    quitFlag = false;
    mtx.unlock();
    QThread::start();
}

(8)编码线程

void EncoderThread::run()
{
    // 初始化视频帧
    AVFrame *vframe = av_frame_alloc();
    vframe->format = AVPixelFormat::AV_PIX_FMT_YUV420P;
    vframe->width = vt->getWidth();
    vframe->height = vt->getHeight();
    vframe->pts = 0;
    // 为视频帧分配空间
    errnum = av_frame_get_buffer(vframe, 32);
    if (errnum < 0) {
        av_strerror(errnum, errbuf, sizeof(errbuf));
        return;
    }

    // 初始化音频帧
    AVFrame *aframe = av_frame_alloc();
    aframe->format = AVSampleFormat::AV_SAMPLE_FMT_FLTP;
    aframe->channels = 2;
    aframe->channel_layout = av_get_default_channel_layout(2);
    aframe->nb_samples = 1024;
    // 为音频帧分配空间
    errnum = av_frame_get_buffer(aframe, 0);
    if (errnum < 0) {
        av_strerror(errnum, errbuf, sizeof(errbuf));
        return;
    }

    int vpts = 0;
    int apts = 0;

    while (!quitFlag) {
        // 音频编码
        const char *pcm = at->getPCM();
        if (pcm) {
            const uint8_t *in[AV_NUM_DATA_POINTERS] = { 0 };
            in[0] = (uint8_t *)pcm;

            int len = swr_convert(swrCtx,
                aframe->data, aframe->nb_samples, // 输出
                in, aframe->nb_samples); // 输入
            if (len < 0) {
                continue;
            }
            // 音频编码
            errnum = avcodec_send_frame(pAudioCodecCtx, aframe);
            if (errnum < 0) {
                av_strerror(errnum, errbuf, sizeof(errbuf));
                continue;
            }
            AVPacket *apkt = av_packet_alloc();
            errnum = avcodec_receive_packet(pAudioCodecCtx, apkt);
            if (errnum < 0) {
                av_strerror(errnum, errbuf, sizeof(errbuf));
                av_packet_free(&apkt);
                continue;
            }
            apkt->stream_index = pAudioStream->index;

            apkt->pts = apts;
            apkt->dts = apkt->pts;
            apts += av_rescale_q(aframe->nb_samples, { 1, pAudioCodecCtx->sample_rate }, pAudioCodecCtx->time_base);
            errnum = av_interleaved_write_frame(pFormatCtx, apkt);
            if (errnum < 0) {
                av_strerror(errnum, errbuf, sizeof(errbuf));
                continue;
            }
            delete pcm;
            av_packet_free(&apkt);
            cout << ".";
        }

        int ts = av_compare_ts(vpts, pVideoCodecCtx->time_base, apts, pAudioCodecCtx->time_base);
        if (ts > 0) {
            continue;
        }

        // 视频编码
        const uchar *rgb = vt->getRGB();
        if (rgb) {
            // 固定写法:配置1帧原始视频画面的数据结构通常为RGBA的形式
            uint8_t *srcSlice[AV_NUM_DATA_POINTERS] = { 0 };
            srcSlice[0] = (uint8_t *)rgb;
            int srcStride[AV_NUM_DATA_POINTERS] = { 0 };
            srcStride[0] = vt->getWidth() * 4;
            // 转换
            int h = sws_scale(swsCtx, srcSlice, srcStride, 0, vt->getHeight(), vframe->data, vframe->linesize);
            if (h < 0) {
                continue;
            }

            vframe->pts = vpts++;
            errnum = avcodec_send_frame(pVideoCodecCtx, vframe);
            if (errnum < 0) {
                av_strerror(errnum, errbuf, sizeof(errbuf));
                continue;
            }
            AVPacket *vpkt = av_packet_alloc();

            errnum = avcodec_receive_packet(pVideoCodecCtx, vpkt);
            if (errnum < 0 || vpkt->size <= 0) {
                av_packet_free(&vpkt);
                av_strerror(errnum, errbuf, sizeof(errbuf));
                continue;
            }
            // 转换pts
            av_packet_rescale_ts(vpkt, pVideoCodecCtx->time_base, pVideoStream->time_base);
            vpkt->stream_index = pVideoStream->index;
            // 向封装器中写入压缩报文,该函数会自动释放pkt空间,不需要调用者手动释放
            errnum = av_interleaved_write_frame(pFormatCtx, vpkt);
            if (errnum < 0) {
                av_strerror(errnum, errbuf, sizeof(errbuf));
                continue;
            }
            delete rgb;
            av_packet_free(&vpkt);
            cout << "*";
        }
    }

    errnum = av_write_trailer(pFormatCtx);
    if (errnum != 0) {
        return;
    }
    errnum = avio_closep(&pFormatCtx->pb); // 关闭AVIO流
    if (errnum != 0) {
        return;
    }

    // 清理音视频帧
    if (vframe) {
        av_frame_free(&vframe);
    }
    if (aframe) {
        av_frame_free(&aframe);
    }
}

(9)关闭与成员变量析构

void EncoderThread::close()
{
    mtx.lock();
    quitFlag = true;
    wait();
    if (pFormatCtx) {
        avformat_close_input(&pFormatCtx); // 关闭封装上下文
    }
    // 关闭编码器和清理上下文的所有空间
    if (pVideoCodecCtx) {
        avcodec_close(pVideoCodecCtx);
        avcodec_free_context(&pVideoCodecCtx);
    }
    if (pAudioCodecCtx) {
        avcodec_close(pAudioCodecCtx);
        avcodec_free_context(&pAudioCodecCtx);
    }
    // 音视频转换上下文
    if (swsCtx) {
        sws_freeContext(swsCtx);
        swsCtx = NULL;
    }
    if (swrCtx) {
        swr_free(&swrCtx);
    }
    mtx.unlock();
    
}

这个部分都是对代码的封装处理,这里就不做什么解释了。最后附上完整的源码地址,仅供参考。

以上是关于音视频同步基础知识的主要内容,如果未能解决你的问题,请参考以下文章

即时通讯——详解音视频同步技术

eagle怎么同步?eagle要怎么才能同步啊?、求助

个人知识管理软件与网络文件同步的联系是啥?

Java基础知识_毕向东_Java基础视频教程笔记(22-25 GUI 网络编程 正则)

多媒体通信基础

多媒体通信基础