什么是延時
很多小伙伴認為,當推流端和拉流端顯示的時間不一致,即為延時。
其實這種看法是比較片面的,不同的播放器,對同一路流進行測試,可能會得到不同的結果。
一般來說,延時為以下幾個部分的累加組成
- 采集延時
在采集攝像頭或顯卡畫面時,由于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個字節存儲payloadassert(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 codeseiFrame.insert(seiFrame.end(), {0x06}); // nalu typeseiFrame.insert(seiFrame.end(), {0x05}); // sei unregister data typeseiFrame.push_back(payloadSize); // sei payload sizeseiFrame.insert(seiFrame.end(), uuid.begin(), uuid.end()); // uuid,這里可以替換成你自己的for (auto& ch : data)seiFrame.push_back(ch); // custom messageseiFrame.push_back(0x80); // rbsp trailing bitsreturn 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