一、基本分析
目前实现的第一个版本 之后进行改进和优化,第一个版本主要实现内容有;
1、读取视频文件使用ffmpeg进行解码、音频重采样得到的数据。
2、基于qt提供的QAudioFormat进行音频播放,QTOpenGL进行视频播放,及音视频同步问题。
3、qt界面管理设计了打开文件(考虑多次打开) ,暂停(考虑缓冲,音频存在三个缓冲 解码的缓冲队列,播放QAudioOutput的自带缓冲,QIODevice的硬件设备缓冲) ,以及滑动条的拖动(涉及qt自带滑动条点击每次只能移动一下进行重载实现点击在哪进度就移动到哪里)。
最后实际效果图
二、框架分析
1、隔离ffmpeg相关
将ffmpeg进行的音视频编解码及重采样要进行隔离要与qt播放显示部分隔开,则需要对他们进行封装成类。主要是三个部分解封装 解码 以及音频重采样
XDcode 负责音视频解码 到时候创建两个这样的对象分别解码音频包 和视频包
XDemux 解封装 进行ffmpeg前面的一系列操作注册 保存参数 获取数据包
XResample 对音频数据进行重采样
XDemux相关函数
//打开音视频文件 赋值成员变量 AVFormatContext *ic 和一系列信息参数
virtual bool Open(const char *url);
//读取一包数据
virtual AVPacket* Read();
//只读取视频数据 这个与后面的seek操作有关
virtual AVPacket* ReadVedio();
//返回视频流的解码器参数AVCodecParameters
virtual AVCodecParameters* CopyVPara()
//返回音频流的解码器参数AVCodecParameters
virtual AVCodecParameters* CopyAPara()
//移动关键帧根据视频移动 seek 位置 pos0.0-1.0 类似百分比的值
virtual bool Seek(double pos);
//清空读取缓存
virtual void Clear();
//关闭
virtual void Close();
//新增一个接口判断是音频还是视频
virtual bool IsAudio(AVPacket *pkt);
//注册封装器
XDemux();
XDcode相关函数 就是获取解码上下文 发送pkt 接收frame
extern void XFreePacket(AVPacket **pkt);
extern void XFreeFrame(AVFrame **frame);
//传入解码器参数,设置成员AVCodecContext codec
virtual bool Open(AVCodecParameters *para);
//将pkt发送到解码线程,不管成功与否都要释放pkt(包含两个空间对象和内部数据)
virtual bool Send(AVPacket *pkt);
//从解码线程读取解码的数据帧 并返回帧 注意与ffmpeg一样 对应多次Send
virtual AVFrame* Recv();
//清理关闭AVCodecContext
virtual void Close();
//清理缓冲
virtual void Clear();
XResample
//初始化 创建赋值SwrContext *actx
virtual bool Open(AVCodecParameters *para, bool isClearPara = false);
//进行重采样 数据采用后数据
virtual int Resample(AVFrame *indata, unsigned char *data);
//关闭
virtual void Close();
2、解码是多线程
XDemuxThread;解封装读包到对应解码器管理类的pkts的list中
XVideoThread;视频解码器进行取包解码显示
XAudioThread;音频解码器进行取包解码重采样播放
因为音视频的解码处理线程很多操作都是相同的则可以重新构建一下封装一个XDecodeThread基类,将共同操作提取到基类
XDemuxThread
成员有;
XDemux *demux = 0;
XVideoThread *vt = 0;
XAudioThread *at = 0;
成员函数;
//将XDemux 、XVideoThread 、XAudioThread 都调用open操作
virtual bool Open(const char *url, IVideoCall *call);
//创建XDemux 、XVideoThread 、XAudioThread对象 并启动这三个线程run函数
virtual void Start();
//关闭线程等待清理 关闭vt at 并delete
virtual void Close();
//清理缓冲资源 demux vt at 三个都调用clear函数进行清理
virtual void Clear();
//设置状态暂停 以及 at vt都调用pause函数
void SetPause(bool isPause);
bool isPause = false;
//清理资源 调用都调用pause 解封装移动到seek位置 只读视频流 进行显示
virtual void Seek(double pos);
//音视频同步、读取包分别存放到at、vt管理的队列中去
void run();
XDecodeThread
成员
XDcode *decode = 0;
std::list <AVPacket *> packts;
成员函数
//产生
virtual void Push(AVPacket *pkt);
//取出一帧数据并入栈,没有贼返回NULL
virtual AVPacket *Pop();
//清理队列
virtual void Clear();
//关闭清理 要将XDcode关闭
virtual void Close();
XAudioThread
成员
XAudioPlay *ap = 0; 音频播放的类
XResample *res = 0; 重采样的类
成员函数
//decode ap res都调用Open操作
virtual bool Open(AVCodecParameters *para,int sampleRate, int channels);
//停止线程 清理资源 ap res都要close
virtual void Close();
//音频播放有两部分缓冲 要重载Clear ap部分也要clear
virtual void Clear();
//读取包 解码 重采样 IO播放
void run();
//暂停操作 并调用ap的pause
void SetPause(bool isPause);
bool isPause = false;
//创建对象ap 、res
XAudioThread::XAudioThread()
XVideoThread
成员
IVideoCall *call = NULL; //视频显示的类
成员函数
//创建窗口 decode打开
virtual bool Open(AVCodecParameters *para,IVideoCall *call,int width,int height);
//解码pts,如果接收到的解码数据pts大于seekPts return true 并显示
virtual bool RepaintPts(AVPacket *Pkt, long long seekPts);
//音视频同步 取包 解码 显示
void run();
//设置视频处理暂停步骤
void SetPause(bool isPause);
bool isPause = false;
3、视频显示类设计 添加抽象接口进行封装
抽象出来一个接口给XVideoThread使用 在XVideoThread播放的时候使用接口类,而真正显示的类继承接口类传入进来,进行隔离封装,在视频编解码部分是不需要知道显示部分的,不管他用什么技术显示都不用管,我里面只管调用接口类的几个函数进行调用显示,至于内部具体什么操作这边是不需要管的进行了隔离。
//创建接口,则以后用其他显示的时候 videoThread这个类是不用变的
class IVideoCall
{
public:
virtual void Init(int width, int height) = 0;
virtual void Repaint(AVFrame *frame) = 0;
};
XVideoWidget
使用qt的OPenGL进行显示
class XVideoWidget : public QOpenGLWidget, protected QOpenGLFunctions, public IVideoCall
则后面需要继承IVideoCall这个接口类,并实现接口中的方法
并且调用这两个接口可以完成视频的显示
4、音频播放类设计 工程模式进行封装
提供一个抽象类并实现一个Get的静态方法用来返回子类的实例化对象的,这样操作主要是将真正的播放类进行隔离与编解码这边没有任何关系,因为这边都看不到这个类。
class XAudioPlay
{
public:
//工程模式
static XAudioPlay *Get();
....
}
XAudioPlay *XAudioPlay::Get()
{
static CXAudiaoPlay play;//返回具体静态类
return &play;
}
使用qt的QAudioFormat进行音频播放的
class CXAudiaoPlay : public XAudioPlay
{
//重载里面的方法
};
5、界面相关处理
实现了双击放大还原、滑动条的拖动与点击位置播放、尺寸大小的变化
滑动条随视频播放移动是采用重载定时器事件的方式进行处理的
main函数中启动startTimer(40);
//重载定时器函数的方法 滑动条进行显示
void Widget::timerEvent(QTimerEvent *e)
{
//如果滑动条按下的时候就不在计时
if(isSliderPress) return ;
long long total = dt.totalMs;
//播放的pts
if(total > 0)
{
double pos = (double)dt.pts/(double)total;
int v = ui->playPos->maximum() * pos;
ui->playPos->setValue(v);
}
}
三、具体逻辑
只有static XDemuxThread dt;这个类与界面类进行交互的
在main函数中也是一开始就启动了dt.Start();线程
1、线程逻辑
void XDemuxThread::Start()
demux = new XDemux();
vt = new XVideoThread();
at = new XAudioThread();
QThread::start();
XDemuxThread::run()
//如果暂停则取消下面的解封装读pkt操作
//音视频同步
AVPacket *pkt = demux->Read();
at->Push(pkt); or vt->Push(pkt);
vt->start();
//暂停状态
//音频同步
AVPacket *pkt = Pop();
bool ret = decode->Send(pkt);
AVFrame *frame = decode->Recv();
call->Repaint(frame);
at->start();
//处理暂停情况
AVPacket *pkt = Pop();
bool ret = decode->Send(pkt);
AVFrame *frame = decode->Recv();
int size = res->Resample(frame, pcm);
ap->Write(pcm,size);
2、播放流程
//保证XDemuxThread::run() -》 AVPacket *pkt = demux->Read();能取到包 那么线程就能运行进行播放了
void Widget::on_pushButton_clicked()
dt.Open(name.toLocal8Bit(), ui->vedio)
bool ret = demux->Open(url);
int re = avformat_open_input(&ic,url,0,&opts);
avformat_find_stream_info(ic, 0);
保存各个参数
ret = vt->Open(demux->CopyVPara(), call, demux->width, demux->height);
call->Init(width, height);
decode->Open(para)
AVCodec *vcodec = avcodec_find_decoder(para->codec_id);
codec = avcodec_alloc_context3(vcodec);
avcodec_parameters_to_context(codec, para);
int re = avcodec_open2(codec, 0, 0);
ret = at->Open(demux->CopyAPara(), demux->sampleRate, demux->channels);
res->Open(para, false)
actx = swr_alloc_set_opts()
swr_init(actx);
ap->Open()
output = new QAudioOutput(fmt);
io = output->start();
decode->Open(para)
AVCodec *vcodec = avcodec_find_decoder(para->codec_id);
codec = avcodec_alloc_context3(vcodec);
avcodec_parameters_to_context(codec, para);
int re = avcodec_open2(codec, 0, 0);
3、音视频同步
首先要知道音视频同步是有三种策略的
参考时钟同步;音视频都根据第三方时钟来进行同步,就是以外部时钟为参考对象,将音频和视频同步到此时间 ,这个实现较为复杂。但是因为人对声音的变化的敏感度比对视觉变化的敏感度大得多,所以如果使用这种方式频繁去调整音频的的话可能会产生一些沙沙或刺耳的杂音,这是很影响用户体验的。但也不是说这种方式很不好,只是不太适用于单个音视频的同步而已,但是对于多路音视频的同步则比较适用了。
**音频同步视频;**以视频时间为基准,判断音频快了还是慢了,从而调整音频的播放速度,其实是一个动态的追赶与等待的过程。但是前面说了人体对于声音的变化太敏感,而对于画面的变化不太敏感,而这个方案也是需要频繁调整音频的,所以这种方案并没有太好的效果。
**视频同步音频;**以音频时间为基准,判断视频是快了还是慢了,从而调整视频的播放速度。因为人体对于画面的变化不太敏感,所以在同步的过程如果视频轻微等待或者丢掉一些画面帧也不太影响观看体验。因而这个方案最合适。
虽然视频同步音频这个方案最合适并且实现也简单,但是同样也存在问题的,如没有音频的时候 音频失帧的时候。
在代码中如何实现同步,需要获取音视频两边的时间,并且音频需要把时间传出来,视频根据传出的音频时间来进行等待或者调节
1、首先确定在xdemuxThread类中进行同步
2、再就是要把音频的时间传出来,音频读取是有缓冲的,则要以解码的时间为准传出来pts(统一转为ms,便于比较),并且这里解码的时间也不一定就是播放的时间,因为XAudioPlay是以qt的QAudioOutput他同样也有部分的缓冲的,因此都要考虑到,
则音频传出的时间应该是当前解码时间pts减去QAudioOutput缓冲中未播放的时间
//在XAudioPlay中实现 返回QAudioOutput缓冲中还没有播放的时间 毫秒单位
virtual long long GetNoPlayMs()
{
mux.lock();
if(!output)
{
mux.unlock();
return 0;
}
long long pts;
//还未播放的字节数
double size = output->bufferSize() - output->bytesFree();
//1秒音频字节大小
double secSize = sampleRate*(samleSize/8)*channels;
if(secSize <= 0)
{
pts = 0;
}
else
{
pts = (size/secSize)*1000;
}
mux.unlock();
return pts;
}
//在XAudioThread中定义音频传出的时间pts
并在解码run中计算
//decode->pts 是在解码AVFrame* XDcode::Recv()中进行赋值 音频解码时间pts
ap->GetNoPlayMs()减去QAudioOutput的缓冲时间,就是当前音频帧播放的时间
pts = decode->pts - ap->GetNoPlayMs();
3、再就可以在视频编解码这边根据传入的音频时间对视频帧时间进行同步
在视频解码线程中进行比较等待;void XVideoThread::run()
//音频同步 如果音频播放时间小于视频当前播放时间则进行等待一下
if(synpts > 0 && synpts < decode->pts)//视频快与音频,则要进行一下等待
{
vmux.unlock();
msleep(1);
continue;
}
4、又回到1、在xdemuxThread类中进行同步 就是把音频那边传出的时间赋值给视频这边传入,从而视频在解码的时候就进行判断是否等待从而同步播放
//音视频同步 void XDemuxThread::run()
if(vt && at)
{
vt->synpts = at->pts;
}
4、暂停操作
void Widget::on_isplay_clicked()
SetPause(isPause);
dt.SetPause(isPause);
this->isPause = isPause; //其线程run就不再进行读包了
if(at) at->SetPause(isPause);
this->isPause = isPause;//其线程run就不再进行读包了
ap->SetPause(isPause);
output->suspend();//播放设备进行挂起
if(vt) vt->SetPause(isPause);
this->isPause = isPause;//其线程run就不再进行读包了
5、滑动条的拖动
void Widget::on_playPos_sliderReleased()
dt.Seek(pos);
Clear();
bool status = this->isPause;
SetPause(true);
demux->Seek(pos);
avformat_flush(ic);
seek_pos = (double)ic->streams[videoStream]->duration*pos;
int ret = av_seek_frame(ic, videoStream, seek_pos, AVSEEK_FLAG_BACKWARD | AVSEEK_FLAG_FRAME);
long long seekpts = pos*demux->totalMs;
while(1)//采用不断读取的方式 直到读取到对应位置的关键帧
AVPacket *pkt = demux->ReadVedio();//不断读取 直到是视频帧返回
vt->RepaintPts(pkt, seekpts) //发送解码 接收判断直到decode->pts=seekpts
源码:Git地址
项目参考自夏曹俊老师的ffmpeg与qt开发项目