移動直播的興起使得在移動端觀看直播的需求日漸增多,相交于點播而言,直播提出了一個新的要求——實時性,也即要求主播端至觀眾端的總延時不能過高。而已有的移動端視頻播放器如: 系統(tǒng)播放器、VLC和ijkplayer等開源播放器均是為了點播視頻播放而設計,雖能播放直播視頻,但是不能降低直播端至終端的延時。
針對以上問題,有必要以播放器為核心實現(xiàn)降低直播延時的功能。
一. 什么是直播延遲
移動直播的基本架構圖如下所示:

移動直播整體架構大致可分為五個部分:
- 主播端。主要負責音視頻數(shù)據(jù)的采集、預覽、處理(美聲、美顏、濾鏡等)、編碼及將編碼后的數(shù)據(jù)推送至源站(可能經(jīng)過上行加速節(jié)點)。
- 源站。該部分屬于云服務的一項功能,接收來自主播端的音視頻數(shù)據(jù),當來自CDN網(wǎng)絡(下行節(jié)點)的數(shù)據(jù)拉取請求時,按照對應的格式返回給CDN。同時也擔負將直播音視頻數(shù)據(jù)落盤,生成點播回看視頻。
- 轉(zhuǎn)碼??梢詮脑凑纠∫宦妨?,轉(zhuǎn)碼成多種分辨率、碼率,再回推給源站。這樣實現(xiàn)了一路主播視頻流的推送,制造出碼率不同的多路流。拉取器是轉(zhuǎn)碼的一種變種,它從其他源處拉取數(shù)據(jù)流(使用某種約定好的拉流協(xié)議),并remux成rtmp推送給源站。
- CDN。上下行節(jié)點都歸為數(shù)據(jù)分發(fā)網(wǎng)絡。該部分屬于云服務的一項功能,多層下行節(jié)點從源站獲取直播音視頻數(shù)據(jù),然后將數(shù)據(jù)分發(fā)給各地觀眾。
- 觀眾端。從下行節(jié)點獲取直播數(shù)據(jù),解析、解碼并渲染音視頻,以供觀眾觀看。
如果一幀畫面在主播側(cè)被采集時刻為t0,某觀眾屏幕上展示出這幀畫面的時候為t1,那么該觀眾能感知的延時為t1-t0。這個延時,我們叫直播延時。
主播端從攝像頭、麥克風采集音視頻數(shù)據(jù),在移動端處理編碼后,經(jīng)由源站、CDN直至觀眾端解碼并渲染播放整個鏈路引入的延時,直播延時涵蓋了整個鏈路的完整延時。
二. 直播鏈路各模塊對延時的"貢獻"
直播延時大致可分為兩個部分:
- 音視頻數(shù)據(jù)在直播網(wǎng)絡鏈路傳輸所引入的延時,此部分無法避免;
- 直播鏈路各模塊對音視頻數(shù)據(jù)的
cache、process操作引入的延時,則可以采用一定方法降低甚至消除;
下面將分析各模塊對于直播延時的"貢獻":
2.1 主播端
主播端在采集音視頻數(shù)據(jù)后基本流程如下所示:

- 采集。首先使用麥克風采集音頻,使用攝像頭采集畫面。在此時,打上對應的時間戳
t0。 - 處理。音頻可以加上混響。畫面可以做各種濾鏡處理。
- 混合。將背景音樂、麥克風聲音混合。將攝像頭畫面與背景圖、連麥畫面等做圖層疊加。
- 預覽。預覽包括兩部分:
- 耳返。音頻處理后的數(shù)據(jù),即可送入耳返通道,此時主播能聽到
t0時刻的聲音。主播耳朵提到對應聲音時刻為t1。t1-t0表征了主播唱出一個詞,到耳朵里面聽到這個詞的耗時。主播對耳返延時的要求比較高,該部分延時較小,大約40ms至80ms。 - 畫面預覽。 圖像混合后的數(shù)據(jù),接入主播屏幕畫面預覽,此時主播屏幕上的畫面渲染時刻為
t1。一般來說t1與t0延時極其小。如果t1-t0大于40ms,人眼即能有所感知delay。
- 耳返。音頻處理后的數(shù)據(jù),即可送入耳返通道,此時主播能聽到
如果Android設備不支持
Low-Latency時,耳返功能本身耗時較大,大約300ms以上,但是并不影響直播整體延時。
關于Android耳返測試效果,請見鏈接。
- 編碼。如果直播準備采用30fps推流,那么視頻編碼需要達到至少30fps的性能。每幀編碼耗時需要控制在33ms以下。整個編碼的耗時除了單幀耗時,還有B幀參數(shù)數(shù)量。編碼器配置和編碼性能會引入耗時。
- 網(wǎng)絡自適應內(nèi)部有個發(fā)送
buffer,用于監(jiān)控網(wǎng)絡發(fā)送情況,并在網(wǎng)絡惡劣情況下丟掉待發(fā)送的碼流數(shù)據(jù)。基本邏輯如下(可以看到,只在網(wǎng)絡從良好到惡劣的轉(zhuǎn)變過程中,臨時引入延時):- 網(wǎng)絡良好時,發(fā)送
buffer內(nèi)為空。該環(huán)節(jié)不引入延時。 - 網(wǎng)絡惡劣時,發(fā)送
buffer堆積,超過閾值觸發(fā)丟幀。該環(huán)節(jié)引入固定延時。 - 網(wǎng)絡惡劣時,監(jiān)控
buffer堆積,反饋編碼器降低輸出碼率,buffer堆積情況轉(zhuǎn)好,直至清空buffer。清空后延時歸零。
- 網(wǎng)絡良好時,發(fā)送
- 封包。flv封包過程簡單,不引入延時。
- 發(fā)送。對于不同的協(xié)議:
- RTMP推流層不引入延時,客戶端tcp協(xié)議棧buffer延時很小,可以忽略。TCP引入的延時主要在高丟包、高重傳率網(wǎng)絡下,鏈路引入的延時。
- 基于UDP的私有推流協(xié)議,協(xié)議層可能引入buffer,依照實際情況而定。高丟包或者高重傳率的網(wǎng)絡情況下,鏈路延時UDP優(yōu)于TCP。
總結就是,推流端經(jīng)過不懈努力,除了突變的網(wǎng)絡情況臨時引入的buffer延時,推流SDK的延時主要是濾鏡處理(gpu性能相關)、編碼性能引入的延時(cpu性能相關)。該延時一般在100ms左右。
2.2 上行節(jié)點
上行節(jié)點會透明轉(zhuǎn)發(fā)數(shù)據(jù),合理的上行加速,會降低主播直連源站的鏈路延時。
同時上行節(jié)點也支持就近分發(fā),也能降低鏈路延時。
2.3 源站
源站在接收直播數(shù)據(jù)時會緩存該路直播的最新音視頻數(shù)據(jù),一般為若干個GOP,某CDN節(jié)點初次向源站請求某直播流數(shù)據(jù)時,源站會將緩存的數(shù)據(jù)全部傳給該CDN節(jié)點。
在CDN已與源站建立鏈接并拉取該路直播的數(shù)據(jù)時,源站會將最新的數(shù)據(jù)轉(zhuǎn)發(fā)給CDN。
2.4 轉(zhuǎn)碼
轉(zhuǎn)碼服務從源站拉取直播流,并轉(zhuǎn)碼轉(zhuǎn)推回源站。此時會引入轉(zhuǎn)碼延時。實時轉(zhuǎn)碼延時一般會引入100ms-200ms延時。
2.5 拉取器
從其他數(shù)據(jù)源拉取直播流后,轉(zhuǎn)推到源站。
如果拉取器與數(shù)據(jù)源帶寬滿足實時傳輸?shù)那疤嵯?,延時主要依賴數(shù)據(jù)源的延時。
2.6 下行節(jié)點
在第一次接收到播放某直播流的請求后,CDN邊緣節(jié)點會通過CDN網(wǎng)絡拉取該直播流的數(shù)據(jù)并緩存最新若干*gop的數(shù)據(jù),以便應答后續(xù)可能的播放請求。
當某一個觀眾端發(fā)起播放請求,播放器在與CDN節(jié)點初次建立鏈接后,播放器會快速從CDN邊緣節(jié)點讀取其緩存數(shù)據(jù)直至讀取到最新數(shù)據(jù)。在播放器耗盡對應的gop緩存前,下行節(jié)點引入了短暫的延時。
耗盡gop緩存的場景大致幾種:
- 播放端拉流速度足夠快,會很快耗盡該
buffer; - 播放端拉流速度和直播流碼率相差不大,該
buffer長期位于CDN邊緣節(jié)點,該部分緩存無法清除; - 播放器拉流速度低于直播流碼率,播放端頻繁卡頓,該
buffer持續(xù)增長,觸發(fā)CDN邊緣節(jié)點對buffer的丟幀邏輯。該場景的延時等于CDN邊緣節(jié)點的buffer最大閾值。該情況下,觀眾端觀看體驗很差,應該通過客戶端監(jiān)控斷開連接并選擇更低碼率的直播流。
一般情況下,用戶場景主要在1場景下,即觀眾拉流速度最大值大于直播流碼率。下文重點考慮該場景。
2.7 播放端
觀眾端開始播放某直播流,大量gop cache數(shù)據(jù)到了播放器內(nèi)存,這部分緩存是影響直播延時的關鍵部分。
舉個例子,該直播流gop為3秒,CDN邊緣節(jié)點gop配置為6秒。觀眾端拉流速度足夠快,開播后,播放器內(nèi)會出現(xiàn)6至9秒的音視頻數(shù)據(jù)。
本文的核心考量是如何快速消耗這部分數(shù)據(jù),以達到降低直播延時的目的。
三. 延時控制思路
3.1 延時的說明
章節(jié)2.6、2.7已經(jīng)說明了原理,這里畫個圖說明一下。

圖中黃色箭頭是時間軸,t0時刻首先到來。
為了方便舉例,先說前提條件:當前直播流是固定關鍵幀間隔,固定幀率30fps。在CDN邊緣節(jié)點,t0時刻到了第一個關鍵幀。t1時刻到了第一個gop最后一幀。t2時刻到了第二個關鍵幀。t2-t0值為3秒。t3-t2為1秒。t4是第二個gop最后一幀。t5時刻到了第三個關鍵幀。當前CDN邊緣節(jié)點緩存配置為3秒。那么有如下結論:
-
t2-t0為關鍵幀間隔,值為3秒; - CDN
buffer的最小數(shù)據(jù)長度為t0至t1,即3秒緩存數(shù)據(jù); - CDN
buffer的最大數(shù)據(jù)長度為t0至t4,即6秒緩存數(shù)據(jù); -
t5時刻關鍵幀的到來,會觸發(fā)t0至t1的整個gop從當前buffer中清空;
如果觀眾在t3時刻發(fā)起播放請求,如果觀眾的拉流速度足夠快,從t0對應的關鍵幀到t3對應的視頻數(shù)據(jù),會快速轉(zhuǎn)移到播放器待解碼隊列中。由于t3位于t2后一秒,即此時播放器待解碼隊列中cache了4秒音視頻數(shù)據(jù),觀眾看到的畫面與主播畫面最小延時4秒(忽略了鏈路延時)。后續(xù)拉流的再次卡頓,會持續(xù)引入更多的延時。
3.2 思路
緩存即延時,播放器緩存的數(shù)據(jù)即引入延時的關鍵點,將播放器的緩存快速消耗就能降低直播延時。有兩種方案可供選擇,各有優(yōu)劣:
- 倍速播放
若想快速消耗播放器緩存的數(shù)據(jù),則需要設置較高的播放倍速,可能導致音頻播放時有尖銳的聲音。
播放倍速較低時不會有尖銳的聲音,但是持續(xù)時間較長。 - 丟棄數(shù)據(jù)
此方案必須考慮音視頻數(shù)據(jù)各自的特性,即音頻數(shù)據(jù)可視情況隨意丟棄,而視頻幀就必須考慮幀與幀之間的參考關系,不能隨意丟棄。與此同時還需考慮音視頻同步的情況,以免造成新的問題。
NetStream bufferTimeMax提供了播放RTMP/HTTP-FLV直播流時flash播放內(nèi)核控制延時的思路,金山云多媒體團隊借鑒了該思路。
flash控制時延的思路是,當大于閾值bufferTimeMax時,NetStream會根據(jù)當前延時的具體情況,audio播放速度提速1.5%到6.25%。這個較小的提速,可以保證音頻下采樣引入的變聲無法察覺。
四. 直播延時控制實踐
金山云多媒體SDK直播實踐中,降低直播延時采用的第二種方案,該方案涉及播放器使用的音視頻同步策略。下面將簡述播放器使用的音視頻同步策略視頻同步至音頻,即
- 音頻解碼后分次將數(shù)據(jù)寫入播放音頻的對象,根據(jù)該音頻幀
PTS及已寫入數(shù)據(jù)量更新音頻時間軸; - 視頻解碼后將數(shù)據(jù)放入隊列,由視頻渲染線程從隊列中取一幀視頻,根據(jù)該視頻幀的PTS及音頻時間軸等信息判斷是否可渲染。
4.1 降低直播延遲的條件
文件解析后,播放器內(nèi)部會有待解碼的音頻數(shù)據(jù)緩存隊列與視頻數(shù)據(jù)緩存隊列,根據(jù)現(xiàn)有的音視頻同步策略,音頻時間軸是基準時間軸,音頻緩存隊列的可播放時長反映了播放器的緩存時長。因此可以
- 使用音頻緩存隊列的可播放時長是否超過設定閾值做為判斷是否發(fā)起降低直播延遲的動作的條件;
- 音頻緩存隊列的可播放時長是否低于閾值作為判斷是否停止降低直播延遲的條件;
4.2 丟棄音頻數(shù)據(jù)
基于現(xiàn)有的音視頻同步策略,當音頻時間軸出現(xiàn)跳躍時視頻幀會使用最新的音頻時間軸做同步,導致視頻快速渲染,也即多余的緩存被快速消耗。
下圖為降低直播延時時對音視頻數(shù)據(jù)操作的示意圖,豎直紅色虛線表示降低直播延時行為的開始與結束。

- 音頻緩存隊列中首幀與尾幀的
PTS為aF、aL,視頻緩存隊列中首幀與尾幀的PTS為vF、vL,后續(xù)讀取的視頻幀的PTS為v1、v2等。正常情況下Video1、Video2的視頻數(shù)據(jù)大致分別同步至Audio1、Audio2的音頻數(shù)據(jù)。 - 降低直播延遲期間新讀取到的音頻數(shù)據(jù)會被丟棄,也即藍色方塊所代表的音頻數(shù)據(jù)會被丟棄。讀取到音頻幀
Audio4時,音頻緩存隊列可播放時長已低于預設的閾值,降低直播延時的行為結束,Audio4會被放入音頻緩存隊列 - 從上圖可以看到,播放過程中音頻時間軸的發(fā)生了一次跳躍,在音頻幀ALast播放完畢時會繼續(xù)播放音頻幀
Audio4,音頻時間軸會跳躍至音頻幀Audio4的PTS: a4 - 視頻幀
Video2會被解碼并等待渲染時已經(jīng)開始播放音頻幀Audio4,Video2會同步至Audio4,但此時音頻時間軸已經(jīng)領先于視頻時間軸(a4 > v2),導致Video2會被立刻渲染,同理于Video3。此過程持續(xù)至音頻時間軸與視頻時間軸的差值在閾值內(nèi)
此方法會導致視頻快速渲染,出現(xiàn)類似于快進效果以及解碼后丟幀。
4.3 丟棄視頻數(shù)據(jù)
上一步驟講述了通過丟棄音頻數(shù)據(jù)快速消耗播放緩存數(shù)據(jù)以降低直播延時的方法,該方法會要求視頻解碼器的快速解碼。
在降低直播延時的過程中,滿足一定條件的情況下是可以丟棄視頻數(shù)據(jù)的。
下圖為降低直播延時過程中丟棄音頻及視頻數(shù)據(jù)的示意圖:

- 在開始降低直播延時之后讀取的視頻數(shù)據(jù)均會先放入視頻緩存隊列,上圖中
Video1與Video2均為降低直播延時過程中讀取到的非關鍵視頻幀
+Video3視頻幀為IDR幀(關鍵幀,此幀之后的視頻幀不能以此幀之前的視頻幀為參考幀),此刻可查找視頻隊列,DTS大于aL(音頻緩存隊列尾幀的PTS)的視頻幀可被丟棄,例如Video1與Video2。然后將Video3放入視頻緩存隊列中 - 這樣操作會使視頻內(nèi)容與時間軸發(fā)生跳躍。
降低直播延時的過程中,音頻緩存隊列尾幀ALast之后的音頻幀均會被丟棄,在讀到視頻的IDR幀時,將視頻緩存隊列中DTS大于音頻幀ALast的PTS的視頻幀丟棄,可視為丟棄與已丟棄音頻對應的視頻幀??杀苊獬霈F(xiàn)只丟棄音頻幀時視頻畫面快進的效果。
五. ijkplayer代碼實踐
本節(jié)會基于ijkplayer最新版本k0.8.4,簡要介紹降低直播延時功能的關鍵代碼實現(xiàn)。本節(jié)后續(xù)代碼默認諸位讀者對ijkplayer的基本結構、核心結構體與關鍵函數(shù)有基本的認識,對ijkplayer不熟悉的同學可以參考文章ijkplayer架構深入剖析。
5.1 基本定義
關于下述結構體的定義于文件 ijkmedia/ijkplayer/ff_ffplay_def.h
struct VideoState {
int audio_stream; // 音頻流索引
PacketQueue audioq; // 音頻緩存隊列
int video_stream; // 視頻流索引
PacketQueue videoq; // 視頻緩存隊列
int realtime; // 標志是否為直播視頻
int chasing_status; // 標志是否開啟 降低直播延時功能
int64_t latest_pts_in_audio_queue; // 音頻隊列尾幀的PTS
int buffer_time_max; // 開始降低直播延時的閾值
};
struct FFPlayer {
VideoState *is;
FFStatistic stat;
}
5.2 狀態(tài)管理與丟棄音頻數(shù)據(jù)
上文提到降低直播延時功能的開啟與關閉是以音頻緩存隊列可播放時長為基準,因此在播放過程中每次讀取到音頻數(shù)據(jù)之后需判斷音頻緩存隊列可播放時長,開啟、關閉降低直播延時的操作或無操作。
文件ijkmedia/ijkplayer/ff_ffplay.c中函數(shù)read_thread
static int read_thread(void *arg) {
FFPlayer *ffp = arg;
VideoState *is = ffp->is;
AVFormatContext *ic = NULL;
AVPacket pkt1, *pkt = &pkt1;
int ret, pkt_in_play_range = 0;
// ...
ret = av_read_frame(ic, pkt);
// ...
if (is->realtime && pkt->stream_index == is->audio_stream) {
// 開啟降低直播延時功能
if( ffp->stat.audio_cache.duration > ffp->buffer_time_max) {
is->chasing_status = 1;
if(is->audioq.last_pkt)
is->latest_pts_in_audio_queue = is->audioq.last_pkt->pkt.pts;
else
is->latest_pts_in_audio_queue = pkt->pts;
}
// 關閉降低直播延時的功能
if (is->chasing_status && ffp->stat.audio_cache.duration < ffp->i_buffer_time_max) {
is->chasing_status = 0;
is->latest_pts_in_audio_queue = INT64_MAX;
}
// 丟棄音頻數(shù)據(jù)
if (is->chasing_status)
pkt_in_play_range = 0;
}
}
5.3 丟棄視頻數(shù)據(jù)
文件ijkmedia/ijkplayer/ff_ffplay.c中函數(shù)read_thread
static void packet_queue_flush_by_dts(PacketQueue *q, int64_t dts) {
// 實現(xiàn)根據(jù)輸入dts丟棄PacketQueue里的相應數(shù)據(jù)
}
static int read_thread(void *arg) {
FFPlayer *ffp = arg;
VideoState *is = ffp->is;
AVFormatContext *ic = NULL;
AVPacket pkt1, *pkt = &pkt1;
int ret, pkt_in_play_range = 0;
// ...
ret = av_read_frame(ic, pkt);
// ...
// 丟棄視頻數(shù)據(jù)
if (is->realtime && pkt->stream_index == is->video_stream) {
if (pkt->flags & ((pkt->flags & AV_PKT_FLAG_KEY) == AV_PKT_FLAG_KEY)) {
if (is->chasing_status)
packet_queue_flush_by_dts(&is->videoq, is->latest_pts_in_audio_queue);
}
}
}
六. 結語
通過以上介紹的方法就實現(xiàn)了降低直播延時的功能,在探索實現(xiàn)降低直播延時的過程中遇到不少坑,這樣的實現(xiàn)方案只對有音頻的直播視頻有效,對純視頻的直播沒有效果,后續(xù)會改進此不足之處。
轉(zhuǎn)載請注明:
作者金山視頻云,首發(fā)簡書 Jianshu.com
也歡迎大家使用我們的直播、短視頻SDK。金山云SDK倉庫地址:
https://github.com/ksvc
金山云SDK相關的QQ交流群:
- 視頻云技術交流群:574179720
- 視頻云Android技術交流:6200036233