环境
Qt
Qt版本:Qt5.7.0以上,QT release下载地址http://download.qt.io/official_releases/qt/
Qt中文输入法软键盘需要重新编译qtvirtualkeyboard模块
qmake CONFIG+="lang-en_GB lang-zh_CN"
当前linux下部署版本是QT5.7.1,放在/opt/路径下,在ld.so.conf.d中使用libqt5.conf文件指定库文件目录,执行程序所在目录下使用qt.conf设置qt相关路径
linux
- 操作系统:Ubuntu14.04或者centos6以上
- 编译器:gcc4.8以上
- IDE:推荐Qt自带的QtCreator,因为Qt工程文件.pro文件需要用qmake工具生成MakeFile文件
- 依赖库:GLEW,freetype2,ftgl,portaudio,ffmpeg(这些库都可使用apt-get或yum直接安装)
win32
- 操作系统:win7 sp1以上
- 编译器:msvc(cl)
- IDE:VS2013以上或者QtCreator
- 依赖库:同上,只是windows下需要下载编译好的库,或者自行下载源码编译
总体架构
ds库(director_service导播台服务的缩写)为trancoder进程下调用的一个动态库,使用QT构建界面+OpenGL渲染显示,界面布局由配置文件director_service.xml文件灵活配置生成,主要分单输出版(4G便携式导播台),多输入版(用于监控大屏,可自由切换row*col),单输出带多输入版(导播台),三个版本由ds_version.h中的条件编译宏控制,定义如下:
#ifndef DS_VERSION_H
#define DS_VERSION_H#define VERSION 7
#define RELEASEINFO "5.7.1 @ 2018/01/01"#define LAYOUT_TYPE_ONLY_OUTPUT 1
#define LAYOUT_TYPE_MULTI_INPUT 0
#define LAYOUT_TYPE_OUTPUT_AND_INPUT 0#endif // DS_VERSION_H
编译哪个版本,便将对于的宏置1,其它置0即可。你在添加新的需求时,如果只是添加到对应的一个版本下,记得使用这些宏去包含,不要影响到其它版本。
几个版本的主要区别在于:
LAYOUT_TYPE_MULTI_INPUT 多输出版带一个切换row*col的主工具栏;
LAYOUT_TYPE_OUTPUT_AND_INPUT 单输出带多输入版带一个切换输出模板和调音台的主工具栏;
此外各版本的标题栏、工具栏按钮和功能可能有些差异的地方,具体依照需求而定。
先附上整体架构图
配置文件
ds.conf是一个ini格式文件,记录了QT和后台进行交互的URL,表情图片路径,数据库路径等
[URL]
toolbar= http://localhost/transcoder/audio/index.html
soundmixer= http://localhost/transcoder/audio/audio.html
combinfo= http://localhost/transcoder/index.php?controller=channels&action=Dragsave
voiceinfo= http://localhost/transcoder/index.php?controller=channels&action=voiceinfo
micphone= http://localhost/transcoder/index.php?controller=channels&action=voiceover
query_overlay= http://localhost/transcoder/index.php?controller=logo&action=allinfo
add_overlay= http://localhost/transcoder/index.php?controller=logo&action=logoadd
remove_overlay= http://localhost/transcoder/index.php?controller=logo&action=ddelete
modify_overlay= http://localhost/transcoder/index.php?controller=logo&action=editpt[PATH]
pic_usb=/usb/images/
pic_local=/var/www/transcoder/
pic_upload=/var/www/transcoder/Upload/[SQL]
type= QMYSQL
username= root
hostname= localhost
port= 3306
dbname= videowell_dbquery_models= SELECT * FROM complex_temp;
director_service.xml则是和界面配置相关的xml格式文件,程序中对此文件添加了监视器,一旦文件改变,则会重新解析此文件,做到动态更改某些配置参数,如修改debug等级,可以很方便的查看调试日志,日志记录在/var/log/ds.log文件中,通过
tail -f /var/log/ds.log
可以很方便的动态监视日志。
<?xml version="1.0" encoding="UTF-8" ?>
<director_service><important debug="0" save_span="0" audio="1" display_mode="2" scale_mode="1" fps="15" audio_bufnum="10"audio_bufnum_max="50"video_bufnum="10" video_bufnum_max="30"av_maxspan="100" /><UI><!--<layout auto="1" maxnum="9" row="3" col="3" merge="2-6" output="6" />--><layout auto="0"><mainwindow width="1280" height="800"><item type="input" pos="4,4,424,264" /><item type="input" pos="4,268,424,264" /><item type="input" pos="4,532,424,264" /><item type="input" pos="428,532,424,264" /><item type="input" pos="852,532,424,264" /><item type="output" pos="428,4,848,528" /></mainwindow></layout><settings mouse="0" fontsize="24"drawtitle="1"drawfps="0"drawnum="1"drawaudio="1"drawinfo="0"drawoutline="1"drawdebuginfo="0"spacing="20"titlebar_height="48"toolbar_height="64"output_titlebar_height="64"output_toolbar_height="64"audiostyle="1"audiocolor_bg="0x00FFFF80"audiocolor_fg_low="0xFFFF0080"audiocolor_fg_high="0xFFFF00FF"audiocolor_fg_top="0xFF0000FF"title_format="%title"taskinfo_format="%rate %avcodec"/></UI><!-- ptz left,right,top,bottom,near,far --><ptz numerator="1,1,1,1,1,1" denominator="20,20,20,20,10,10" /><overlaymaxnum="5"expre_policy="0" />
</director_service>
下面列出一些重要的配置参数:
- debug设为0关闭打印,4除了debug外,warn,info,error,fatal都会打印,5则debug也打印;
- save_span为自动保存界面的时间间隔(为0代表不保存),程序会将当前界面布局保存在文件dssave.conf中,下次进入程序时会优先根据此文件生成界面布局,这个属性主要是用在LAYOUT_TYPE_MULTI_INPUT 版本;
- audio控制是否处理音频数据;
- display_mode为1代表即时渲染模式,即视频帧过来就渲染出来,2代表定时渲染模式,通过缓存和定时器来控制渲染的均匀性;
- scale_mode为1代表对于分辨率大于显示窗口大小的视频流先进行缩放然后在进行纹理渲染,打开此标志主要是可以节省一定的CPU资源;
- fps每秒渲染的帧数,不按照码流原始的帧率播放,也是为了节省CPU消耗;
- audio_bufnum和video_bufnum分别代表音频和视频的缓存帧数,这俩个属性很关键,缓存过大会造成延时过大,过小可能无法适应网络抖动,造成播放卡顿,此外视频帧缓存也相对耗费内存,所以更加硬件和网络环境配置这两个参数很重要。
- audio_bufnum_max和video_bufnum_max分别代表当网络抖动时允许自动扩展缓存,程序中以10为步长增加;
- av_maxspan:音视频同步允许的时间戳差值,单位为ms;
- layout标签中auto属性代表是否自动布局,即程序根据主屏幕分辨率和row*col进行计算切分窗口网格,另外支持merge初始化时合并a-b的窗口,output指定哪个窗口是输出窗口(输出窗口和输入窗口是两种不同的控件类型,典型如标题栏和工具栏就不一样);
- 当然layout支持手动布局,即通过mainwindow和item标签实现布局;
- settings 标签中包含一系列的对界面的控制,如是否鼠标操作的mouse,标题栏字体大小fontsize,是否绘制标题、fps、音柱、轮廓线、任务信息、调试信息等,还要音柱的风格audiostyle,音柱的前景色和各阶段的背景色,标题栏和工具栏的高度(主要是不同物理尺寸,不同分辨率的设备上为了操作方便需要调整此高度),标题栏文本的显示格式,任务信息的显示格式等;
- ptz 中设置了云台控制上下左右拉近拉远的幅度,以numerator/denominator的方式给出
- overlay标签中控制输出画面叠加物的一些属性,例如允许的最多叠加物数量maxnum,表情图片的尺寸策略expre_policy,如果将expre_policy置为1,则按照原始图片大小叠加(因为表情图片分辨率都很大,所以实际中我们是按照比例缩放到一定氛围叠加的)
这些属性,通过在程序中搜索关键字,可以分析得出大致的作用,你后续如果要添加其他属性,也请归类后写入此文件中。
程序中解析这个文件的函数是HDsContext类中的int parse_layout_xml(const char* xml_file);
保存这些属性的数据结构体是DsInitInfo和DsLayoutInfo
导出接口
ds库导出的接口很简单,但扩展性也很强,主要参照程总那边的service.h头文件定义,我们的导出头文件为ds.h,接口如下:
extern "C" {DSSHARED_EXPORT int libversion(void);DSSHARED_EXPORT int libchar(void);DSSHARED_EXPORT int libtrace(int);DSSHARED_EXPORT int libinit(const char* xml, void* task, void** ctx);DSSHARED_EXPORT int libstop(void* ctx);DSSHARED_EXPORT int liboper(int media_type, int data_type, int opt, void* param, void * ctx);
}
注:extern “C”代表导出为C编译器风格的函数名
libversion是版本数字,libchar是版本的fourcc宏,libtrace是trace等级,这些接口都是历史接口,有的可能只是为了适配,如我们的trace等级有配置文件中的debug决定,并不采取libtrace设置的值
libinit用于库初始化操作,主要是new出重要的上下文内容类HDsContext,这是一个全局单例类,全局指针是g_dsCtx,然后调用parse_layout_xml函数解析上文提到的director_service.xml文件,当然也记录了图片路径,字体路径等;
需要注意是是每一路码流开始都会调用libinit,所以libinit和libstop不只是调用一次,但我们程序中实际只需调用到一次,只是为了兼容库的调用者,所以设置了调用次数引用ref,实际只会new一个HDsContext类,由srvid去区别每一路码流,由HDsContext类中的数组DsSrvItem m_srvs[DIRECTOR_MAX_SERVS];去记录每一路码流的各自信息;
liboper则是最灵活也调用最频繁的接口,media_type指示媒体类型
enum MediaType{MediaTypeUnknown = 0,MediaTypeAudio = 1,MediaTypeVideo = 2,
};
data_type指示数据类型,opt指示操作类型,param是附带的参数指针,ctx指向HDsContext类;
这个接口功能是将调用者传递的控制信息和数据保存在HDsContext类中,具体实现比较复杂,建议直接去看源码,基本是
switch(media_type)switch(data_type)switch(opt)
三层switch-case嵌套结构
重要的类
我自己写的类一般以H开头(我姓贺,首字母H,这个习惯主要是受以前公司师傅的影响,这样谁写的类出现bug谁负责哈哈),以和QT以Q开头的类区分开来
HDsContext
这个类在上文我们提到过了,是用来保存调用者传递过来的控制信息和数据。是一个承上启下的类。
这个类在libinit中被创建,所以是属于调用者线程的,而程序中我们需要另外创建一个线程来跑QT的消息循环,这个线程是在HDsContext类的start_gui_thread()中创建的,gui线程的入口函数是void* HDsContext::thread_gui(void* param);
核心QT线程调用流程简化如下:
QApplication app(argc, argv);//创建应用程序类
//启动画面与初始化工作
...
app.exec();//消息循环
QT的信号与槽机制为跨线程的事件通知和方法调用提供了便利性,在调用者线程和QT的gui线程间,我们不需要加锁就可以访问HDsContext类中的属性和方法。这个类中定义了一些了的信号通知,如actionChanged、videoPushed、audioPushed等,gui线程中的类可以很方便的连接这些信号,调用对应槽函数进行处理。
HMainWidget
这个类就是我们主窗口类,在thread_gui中被创建,继承自QT的控件类,我们一律调用initUI构建UI界面,调用initConnect建立信号与槽连接。
在HMainWidget的initUI函数中,我们主要根据dssave.conf文件和director_service.xml文件来动态生成界面,即使就是创建HGeneralGLWidget(用于输入画面)和 HCombGLWidget(用于输出画面),HGeneralGLWidget和HCombGLWidget公共父类时HGLWidget,所以在HMainWidget类中,我们通过std::vector<HGLWidget*> m_vecGLWdg;
向量来保存创建的HGLWidget类指针;
在定时渲染模式下,我们开始了一个定时器timer_repaint,定时触发onTimerRepaint函数调用
QObject::connect( &timer_repaint, SIGNAL(timeout()), this, SLOT(onTimerRepaint()) );
if (g_dsCtx->m_tInit.display_mode == DISPLAY_MODE_TIMER){timer_repaint.start(1000 / g_dsCtx->m_tInit.fps);
}
在onTimerRepaint函数中自然就是遍历m_vecGLWdg向量,对处于播放状态的窗口进行更新操作;简化的onTimerRepaint函数如下:
for (int i = 0; i < m_vecGLWdg.size(); ++i){HGLWidget* wdg = m_vecGLWdg[i];if (!wdg->isResetStatus() && wdg->isVisible()){if (g_dsCtx->pop_video(wdg->srvid) == 0)wdg->update();}
}
此外,HMainWidget类对鼠标和键盘事件进行了重载
virtual void keyPressEvent(QKeyEvent *e);
virtual void mousePressEvent(QMouseEvent *e);
virtual void mouseMoveEvent(QMouseEvent *e);
virtual void mouseReleaseEvent(QMouseEvent *e);
实现了输入的HGLWidget可以互换位置,输入的HGLWidget可以叠加到输出的HGLWidget上去等操作;
提供了全屏某个HGLWidget的槽函数void onFullScreen(bool);
HGLWidget
HGLWidget的父类时QGLWidgetImpl,QGLWidgetImpl是对QT提供的QOpenGLWidget的实现,主要是实现了initializeGL和resizeGL,此外提供了顶点缓存、YUV着色器程序,drawYUV、drawTex、drawStr、drawRect等封装接口;具体实现感兴趣的可以去看源码;
HGLWidget目前子类有HGeneralGLWidget,HLmicGLWidget和HCombGLWidget,分别对应枚举类型中的GENERAL,LMIC 和 COMB
enum TYPE{UNKOWN = 0, GENERAL = 1, // 用于显示输入画面COMB = 2, // 用于显示输出画面EXTEND = 3, // 用于HDMI接扩展屏LMIC = 4, // 用于显示连麦画面
};
HGLWidget主要是对paintGL的实现,流程函数有drawVideo、drawAudio、drawIcon、drawTitle、drawTaskInfo、drawOutline、drawDebugInfo、drawFps;子类中如有自己的特殊需求,也可以重载这些函数。
播放状态枚举
enum GLWND_STATUS{MAJOR_STATUS_MASK = 0x00FF,STOP = 0x0001,PAUSE = 0x0002,PLAYING = 0x0004,NOSIGNAL = 0x0008,MINOR_STATUS_MASK = 0xFF00,PLAY_VIDEO = 0x0100,PLAY_AUDIO = 0x0200,
};
paintGL源码
void HGLWidget::paintGL(){calFps();glClearColor(0.0f, 0.0f, 0.0f, 1.0f);glClear(GL_COLOR_BUFFER_BIT);// drawVideo->drawAudio->drawTitle->drawOutlineDrawInfo di;switch (m_status & MAJOR_STATUS_MASK) {case STOP:di.left = width()/2 - 50;di.top = height()/2 - 20;di.color = 0xFFFFFFFF;drawStr("无视频", &di);break;case NOSIGNAL:di.left = width()/2 - 50;di.top = height()/2 - 20;di.color = 0xFFFFFFFF;drawStr("无信号", &di);break;case PAUSE:case PLAYING:if (m_status & PLAY_VIDEO){drawVideo();if (g_dsCtx->m_tInit.drawfps)drawFps();}if (m_bDrawInfo && g_dsCtx->m_tInit.drawtitle){drawTitle();}if (m_bDrawInfo && g_dsCtx->m_tInit.drawinfo){drawTaskInfo();}if (m_status & PLAY_AUDIO){if (m_bDrawInfo && g_dsCtx->m_tInit.drawaudio){drawAudio();}}break;}if (g_dsCtx->m_tInit.drawoutline){drawOutline();}if (g_dsCtx->m_tInit.drawDebugInfo){drawDebugInfo();}
}
HGeneralGLWidget和HCombGLWidget主要区别就是标题栏和工具栏不同,此外,HCombGLWidget比较复杂的功能就是子画面和叠加物(包括文字和图片)的CRUD操作,这些操作对象大致都是一个矩形画面在输出画面上的叠加,我们统一用一个类来抽象,就是接下来的主角类HAbstractItem;
HAbstractItem
先来看看HAbstractItem的抽象接口
class HAbstractItem
{
public:HAbstractItem();virtual ~HAbstractItem();virtual void add() {}virtual void remove() {}virtual void modify() {}virtual bool undo() {return false;}enum TYPE{NONE = 0,SCREEN = 1,OVERLAY = 10,PICTURE,TEXT,OVERLAY_END = 99,ALL = 0xFF,};inline bool isOverlay(){return type > OVERLAY && type < OVERLAY_END;}public:TYPE type;int id;QRect rc;
};
主要子类别在枚举TYPE中给出,有画面SCREEN,图片PICTURE,文字TEXT,此外就是抽象的CRUD操作接口,还有一个撤销接口undo;
对应子类分别是HCombItem、HPictureItem和HTextItem,文字类中又分标签文本、时间、秒表、字幕
enum TEXT_TYPE{LABEL = 1,TIME = 2,WATCHER = 3,SUBTITLE = 4,
};
具体CRUD接口实现就是通过HTTP协议和后台提供的URL通信,可以通过tcpdumo去抓取http包去分析,具体通信的json格式在项目的wiki中可以找到
单例类
ds库中有几个很重要的单例类:
- HNetwork:负责HTTP请求;
- HDsConf:读取ds.conf配置信息;
- HDsDB:数据库操作类;
- HRcLoader:图片资源加载类;
- HExpreWidget:表情包控件类,因为缩略图很耗费时间,所以在启动画面时初始化这个控件
后台通信json协议
见项目wiki文档
疑难点
- 音视频的缓存,渲染,同步;
- 延时和卡顿之间的协调;
- 视频缩放和显示区大小
我们使用环形缓冲HRingBuffer去保存音视频数据,OpenGL去渲染yuv纹理,ffmpeg去缩放yuv帧,PortAudio去播放音频;
音频自动判断卡顿发生的情况(音频缓存为空,播放的是静音帧)下暂停播放,等到帧缓存到一定数量(这个数量不好设置,目前设置为6帧,过大延时高,过小卡顿可能再次发生)再继续播放,如果发生帧溢出的情况,首先是扩大缓冲,如果情况还是发生则只能采取丢弃一部分旧的音频帧;
因为硬件性能的限制,一般是低于25fps播放视频,所以需要保证均匀的丢帧,确保不会出现较大的画面跳跃性;
对于没有播放音频的路数采取的是缓存满尾部不缓存方式;
对于有音频播放的路数因为同步机制,视频帧通常慢于音频帧,所以采取的是头部去帧方式;
音视频同步的功能,基本思路是在帧保存时附带上时间戳,根据音视频时间差去调整视频;
如果视频快,那就不从缓存区取帧(停留在上一画面,这里有个隐患,如果音频帧太慢,会造成画面卡顿);
如果慢就连续取两帧丢弃前一帧(即上面说到的头部去帧),这样就能循序渐进的达到音视频同步的目的;
但同步、延时、卡顿在恶劣环境(帧来的特别不规律,时快时慢)下,还是很难协调的,特别是视频帧缓存受限(内存不足)的情况下。
一些基本情况的处理:
首先当声音卡顿时,考虑是网络抖动太厉害造成的,应该扩大音频缓存;
当视频卡顿时,可能是音视频同步在自动调节中,如果始终不能调整好,应考虑扩大视频缓存;
延时比较大时,又需要考虑缩减缓存;
所以说卡顿和延时是很难平衡的,应尽量在保证不卡顿的情况下减少延迟,即设置合适的缓存值;
部署
gitlab中已经新建一个install目录用于存放需要部署的文件,通过运行脚本update.sh即可安装。
部署文件有:
- 库文件:将对应版本的libdirector_service.so文件放到指定目录;
- Qt:将Qt库及资源打包,通过qt.conf(qt.conf的书写可查看Qt帮助文档)配置文件指定路径;
- ds配置文件:ds.conf放到和so文件同级目录,director_service.xml放到ds目录下;
- 资源文件:图标放到指定的img/director_service目录下,字体文件放在fonts目录下
- 表情包:放到/var/www/transcoder/Upload目录下
调试
- 日志文件 /var/log/ds.log : 设置配置文件的debug等级(0-5)
- 屏幕打印:打开配置文件的drawdebuginfo选项
- tcpdump或者wireshark抓包分析
以调试音视频同步情况为例:
程序中关于音视频同步打印语句有:
qDebug("srvid=%d video faster audio, v_span=%ld, a_span=%ld", wdg->srvid, v_span, a_span);
qDebug("srvid=%d video slower audio, v_span=%ld, a_span=%ld", wdg->srvid, v_span, a_span);
qDebug("srvid=%d video approximate audio, v_span=%ld, a_span=%ld", wdg->srvid, v_span, a_span);
以日志打印中的某个关键词进行grep就可以看到筛选出此类信息了
tail -f /var/log/ds.log | grep v_span
这样我们就可以看到视频帧和音频帧时间戳的比较情况,一般视频帧过快faster,就会造成画面显示停顿,直到音频帧播放赶上;而视频帧过慢slower,则会造成画面跳帧播放,画面不是很连贯,approximate则表示音视频基本处于同步;
因为fps一般设置的低于25帧渲染播放,所以视频帧会时不时处于slower状态,跳帧播放,这是正常的现象;
通过打开drawdebuginfo,在对应窗口上我们可以看到窗口id和srvid的对应关系,源信息,窗口的大小,视频分辨率,显示的实际大小,音视频缓存情况;
注意事项
- 因为项目中使用到了chrome引擎,对应Qt的webengine模块,所以不能使用mingw编译器;
为了跨平台,源码文件使用UTF-8编码格式保存,但VS默认以GBK编码读取源码文件,所以源码文件中的中文字符串会乱码,对于使用了QString的接口,我们声明了一个宏来应付VS的这种怪行为;
#define STR(str) QString::fromLocal8Bit(str)
- 对应libinit,liboper接口参数中使用了std::string的,需要确保STL版本一致性以及debug和release一致性;
- 因为Qt的信号与槽机制,很好的提供了跨线程对象间的访问,所以ds库中使用的互斥锁主要只有视频和音频缓存锁,如果发生Qt线程锁死,而transcoder线程依然运行,着重检查是否有死锁;
总结
维护这个库,你需要基本的QT+OpenGL+ffmpeg+PortAudio+音视频知识,如果你是这些方面的老手,看了估计会吐槽我写的烂代码(反正我也听不见了,当然你也要体谅这是三个版本不断糅合,需求不断变更的结果,数据结构,整体架构还是挺清晰的),如果你是新手,我相信理解后也会受益匪浅,从此QT得心应手,领略到音视频编解码,渲染播放,也就那么回事。