一个简单的播放器。
- 解封装、解码:FFmpeg
- 视频渲染:OpenGL
- 音频渲染:OpenAL
- 一个解封装线程(Demux Thread),负责从一个文件中解出音频流和视频流,分别将流中的包序列送入音频和视频解码队列(Decode Queue)。
- 两个解码线程(Decode Thread),分别负责从音频和视频解码队列取出包,进行解码并将数据帧分别输出到音频和视频播放队列中(Play Queue)。
- 两个播放线程(Play Thread),分别从两个播放队列中取出帧,进行播放控制。
- 一个渲染线程(Render Thread),渲染线程负责在屏幕上显示内容(视频内容、文字等),以及捕获事件(鼠标、键盘、窗口等)。视频播放线程会将要渲染的帧提交给渲染线程以显示内容。
解码队列和播放队列都有长度限制,当消费方速率不及生产方导致队列满了之后,生产方需要等待队列空闲之后才可以继续生产并入队(使用queue_dequeue_wait)。
解封装线程需要同时向两个解码队列入队,同时两个队列都需要等待,因此可能出现某个队列满了,阻塞另一个队列的情况。
实际上,播放线程和渲染线程之间的数据传递也是用的队列,不过与上面几个队列相比,渲染线程作为消费者的消费逻辑不太一样,它不会保证处理队列里面的每一个元素,而是每次只取队列的最后一个元素,同时跳过并释放其他元素。这是由于渲染队列需要尽量使得每帧都能显示出最新的画面,而不能因为排队而延迟。具体来说,渲染队列由VSync驱动,视频播放线程由视频帧率驱动,因此通常比视频播放线程更新频率高,这时候该队列基本上长度都小于等于1;如果遇到高帧率视频,或是屏幕刷新率低,导致视频播放线程更新速率大于等于VSync速率,该队列就会堆积多个帧,而渲染线程只需要取最新一帧即可,确保及时渲染。
测试看下来,在没有同步的时候,视频逐渐慢于音频,且差距不断扩大;直接原因可以理解为:
- 音频始终在连续播放,时长由框架保证;
- 视频需要每帧之后通过usleep等待一帧的时间,忽略了渲染、排队、线程调度等其他过程所消耗的时间,而这部分时间也是很难去估算的,这会导致实际两帧之间的时间比预期的要长,因此速度上就慢于音频了。
因此需要:一是完善视频的帧间隔;二是需要一个不断调整的机制,同步二者的时间,即音视频同步。
同步思路如下:
- 音频始终正常播放,同时维护播放时间;
- 视频解码出来之后,计算与音频的时间差d,与阈值T作比较:
- -T < d < T: 正常渲染;
- d <= -T: 视频比音频慢,丢掉当前帧;
- d >= T: 视频比音频快,等待d时长。
- 音频播放
- 音频卡顿问题
alSourcePlay
调用时机不合适-
alSourcePlay
: When called on a source which is already playing, the source will restart at the beginning.
- 拆文件
- 拆线程、拆队列
- 线程同步
pthread_mutex
/pthread_cond
- 线程同步
- 完善音频播放
- 重写队列逻辑
- 音频状态跳动
- 包速率莫名变低,导致队列消耗完,AL将状态转到了STOPPED,出现音频卡顿
- 视频帧的消耗速率比实际的pts要慢,而音频的播放由音频库维护,因此速度不会慢。这导致了音频包队列和音频帧队列依次逐渐被消耗完,阻塞音频播放,从而卡顿。
- 本质上还是因为视频播放机制不完善,以及缺乏音视频同步机制。
- 音频时间更新
- 音画同步
- ? 是否需要两个FormatContext分别解析音视频流?
- 合并音视频PlayContext
- 播放控制
- 键盘操作
- VSync驱动视频渲染(glfwPollEvents),每次判断视频画面是否需要更新(观察者模式),避免事件处理与视频渲染的耦合。
- 事件处理
- 事件机制
- 音视频的StreamContext中各包含一个事件队列,在每一帧渲染处理完之后,从这个队列中取事件来消费
- 事件对象通过引用计数管理
- 暂停/继续
- 播放线程消费到暂停事件之后,挂起整个线程(音频播放线程还需要暂停al的播放),直到下一个继续事件。
- 这里要考虑是否需要将整个播放线程都改造成事件驱动的?改造之后,帧数据和事件在同一个循环里处理。
- 播放状态定义
- Prepare (TODO)
- Play
- Pause
- Stop (TODO)
- 前进/后退
- Seek事件
-
- 暂停解封装、解码的同时,需要清空包队列、帧队列、音频缓冲队列等
- Queue支持原子地清空
- 由生产者清空队列
- 通过SEEK_START事件触发,处理完之后等待SEEK_END事件
-
- 解封装线程调用av_seek_frame
- av_seek_frame成功之后,由解封装线程触发SEEK_END事件
-
- 继续解封装、解码
-
- 处理队列满、解封装结束等导致线程等待,无法处理事件的情况
- 队列支持timed_wait,每次定时唤醒之后,处理事件
- 将每个线程都改造为事件驱动的,支持通用事件。
- 如何在同一个线程中同时处理数据和事件(如同时处理包队列和事件队列)
- 队列支持selector
- condition variable list
- pipe
- 将所有数据、事件都通过同一个队列传递
- 因为会有单独清除数据不清理事件的需要,队列要支持按需清理之类的操作
- (目前)入队、出队时使用timed_wait,在超时之后处理事件
- 如果timed_wait等待的是入队,同时处理事件的时候发生了清空队列的
操作,就会导致之前
timed_wait
的一帧立马入队,相当于队列清空不彻底
- 如果timed_wait等待的是入队,同时处理事件的时候发生了清空队列的
操作,就会导致之前
- 队列支持selector
- Seek粒度和准确度
- 帧缓存,避免seek之后全部销毁(VirtualSeekBar)
- Seek事件
- 键盘操作
- 音量
- 支持缩放
- FPS显示
- 支持按视频时间同步、按外部时钟同步
- 字幕
- 优化日志系统
- 支持Tag
- 支持日志等级
- 单元测试
- 错误处理
- Kotlin/Native
- 视频右侧花条
- 似乎是GL需要纹理以8像素对齐
- VBR/VFR
- MacOS适配
- glfw需要放到主线程
- 单线程播放的可行性
- xmake - sysroot