什么是延时
很多小伙伴认为,当推流端和拉流端显示的时间不一致,即为延时。
其实这种看法是比较片面的,不同的播放器,对同一路流进行测试,可能会得到不同的结果。
一般来说,延时为以下几个部分的累加组成
- 采集延时
在采集摄像头或显卡画面时,由于fps的限制和cpu性能、内存拷贝速度等客观限制,采集画面成YUV/RGB等数据时会有一定的延时,一般延时为毫秒级别。由于一般编码器对输入数据格式存在限制,譬如要求统一输入YUV420P,这样在做RGB->YUV420P转换时,也会有转换计算延时(这个可以通过libyuv库来降低)。总而言之,采集延时大概为毫秒级别,如果fps为30,那么一般采集延时会有30毫秒以上的延时,在内存拷贝和颜色转换时,又可能增加若干毫秒的延时。
- 编码延时
在把原始画面输入到编码器时,并不会立即输出编码后的数据,特别是在开启B帧时,由于需要参考后面的P帧,那么延时会更大,所以延时敏感的情况下一般不开启B帧,这种情况下编码延时应该是毫秒级别,不是很大。
- 网络上行传输延时
编码后的数据,要经过一定的协议打包才能写入socket,然后传输给推流服务器或拉流代理服务器,协议打包会有一定的内存拷贝和计算量,那么会增加延时,不过这个延时很小,基本忽略不计。数据在上传到服务器时,这个延时可大可小,取决于网络质量。
- 服务器转协议延时
服务器在收到数据后,要读socket缓存、协议解析、解复用、重新打包等操作,不过总体而言,这个延时比较小,基本没什么影响。有时,服务器为了提高性能,会采取合并写的机制,也就是收到一定量的数据后才会一并转发,这个延时一般为几百毫秒。不过一般服务器会默认不会打开此机制
- 网络下行延时
流媒体在把视频数据转发给播放器时,会存在网络发送,这个延时大小取决于网络质量。
- 播放器延时
播放器延时主要有网络接收延时、协议解析解复用延时、解码延时、缓存延时、渲染延时组成,这些延时中缓存延时最大,因为一般的播放器为了保证在网络抖动情况下视频播放的流畅性,会以增加延时为代价,增加播放缓存,这样在网络变差时,不至于播放缓冲卡顿。而且为了音视频同步,也必须确保一定的缓存量。这种延时一般都是秒级别,一般5秒左右。
- 播放器GOP缓存延时
流媒体服务器为了能让播放器立即出画面,往往会缓存最近的一个I帧,这个I帧往后的所有音视频数据被称作为GOP缓存。如果不缓存GOP,那么播放器要等下一个I帧才能解码成功或不花屏,显然为了提高播放体验,这个GOP缓存是不能去掉的。而一般GOP短则1~3秒,长则10几秒,这个跟采集端编码器设置有关,服务器改变不了。但是由于一般的播放器收到缓存后,并不会丢弃过多的画面来确保低延时。况且播放器还希望有一定的缓存来确保播放的流畅性,所以这个GOP缓存将会增大播放器的延时。
- 综合延时
以上所有的延时累加,就是你观看到的直观延时。通常大部分延时可能是由播放器造成,如果对播放器缓冲区感兴趣的同学可以参考这篇文章:https://zhuanlan.zhihu.com/p/51582357
如何计算延时
本文所讨论的延时为网络传输延时,也就是经过采集
、编码
后的数据,经由推流端
通过网络发送到到流媒体服务器
,流媒体服务器
将数据通过网络推送到到拉流端
的延时。
本文推荐使用在码流中混入SEI帧的方式来计算传输延时,具体步骤如下
- 推流端在I帧之前插入SEI帧,内容为推流端时间戳
- 拉流端在接收到SEI帧之后,解码出推流端时间戳,与拉流端时间戳对比,计算出延时
SEI 帧
先复习一下H264码流结构
- H.264 原始码流组成结构
H.264 原始码流(裸流)是由一个接一个 NALU 组成。它的功能分为两层,VCL(视频编码层)和 NAL(网络提取层)。
为了方便从字节流中提取出 NALU,协议规定,在每个 NALU 的前面加上起始码(StartCode): 0x000001 或 0x00000001。
- NALU 组成结构
NALU(NAL Unit)= 一组对应于视频编码的 NALU 头部信息(NAL header)+ 一个 RBSP(Raw Byte Sequence Payload,原始字节序列负荷)
NAL Unit Type 常⻅类型如下:
NAL Unit Type | NAL Unit Content |
---|---|
1 | 非 IDR 图像,且不采用数据划分的片段。 |
5 | IDR 图像。 |
6 | 补充增强信息(SEI)。 |
7 | 序列参数集(SPS)。 |
8 | 图像参数集(PPS)。 |
11 | 流结束符。 |
那么NAL Unit Type为6时,即为SEI帧。
SEI payload type 计算方式
当开始解析类型为 SEI 的 NAL 时,在 RBSP 中持续读取 8 bit,直到非 0xff 为止,然后把读取的数值累加,累加值即为 SEI payload type。
SEI RBSP 结构图如下:
SEI payload size 计算方式
读取 SEI payload size 的逻辑与 SEI payload type 类似,即读取到非 0xff 为止,这样可以支持任意⻓度的 SEI payload 添加。假设 SEI payload type 后面的字符序列是 FF FF AA BB …,则 FF FF AA 将会解析成 SEI payload size,为 255 + 255 + 170 = 680。
实例代码
// @brief: 将时间戳写入sei frame,将sei frame写入文件
#include <iostream>
#include <vector>
#include <cassert>
#include <fstream>
#include <string>
#include <chrono>
std::vector<uint8_t> MakeSei(const std::string& data)
{
// 使用1个字节存储payload
assert(data.size() + 16 < 255);
std::vector<uint8_t> seiFrame;
std::vector<uint8_t> uuid(16, 0x41);
uint8_t payloadSize = 16 + data.size();
seiFrame.insert(seiFrame.end(), {0x00, 0x00, 0x00, 0x01}); // start code
seiFrame.insert(seiFrame.end(), {0x06}); // nalu type
seiFrame.insert(seiFrame.end(), {0x05}); // sei unregister data type
seiFrame.push_back(payloadSize); // sei payload size
seiFrame.insert(seiFrame.end(), uuid.begin(), uuid.end()); // uuid,这里可以替换成你自己的
for (auto& ch : data)
seiFrame.push_back(ch); // custom message
seiFrame.push_back(0x80); // rbsp trailing bits
return seiFrame;
}
int main()
{
std::ofstream file("sei.h264", std::ios::binary);
std::string data =
"ts:" + std::to_string(std::chrono::duration_cast<std::chrono::milliseconds>(std::chrono::system_clock::now().time_since_epoch()).count());
auto sei = MakeSei(data);
file.write(reinterpret_cast<char*>(sei.data()), sei.size());
file.close();
return 0;
}
这里在SEI中写入的数据格式为ts:{timestamp}
,你也可以定义为你希望的数据格式,如json
,注意不要超过255 - 16个字节。
生成的帧信息如下:
推流端和拉流端如何进行时钟对齐
在拉流端拿到SEI frame之后,解码出推流端时间戳,计算delay = {拉流端时间戳} - {推流端时间戳}
。
这里存在一个问题是,拉流端系统时钟可能与推流端系统时钟不一致(如人为调整过系统时间),导致延时计算不准确,甚至是拉流端时间戳早于推流端时间戳。那么这时候就需要将推流端和拉流端的时间戳进行对齐
。
一般选择流媒体服务器时间戳
进行对齐。
计算方法如下:
- 参考文章:
https://github.com/ZLMediaKit/ZLMediaKit/wiki/%E6%80%8E%E4%B9%88%E6%B5%8B%E8%AF%95ZLMediaKit%E7%9A%84%E5%BB%B6%E6%97%B6%EF%BC%9F
https://doc-zh.zego.im/faq/sei?product=ExpressAudio&platform=macos