系列文章目錄
- 基于 FFmpeg 的跨平臺視頻播放器簡明教程(一):FFMPEG + Conan 環(huán)境集成
- 基于 FFmpeg 的跨平臺視頻播放器簡明教程(二):基礎(chǔ)知識和解封裝(demux)
- 基于 FFmpeg 的跨平臺視頻播放器簡明教程(三):視頻解碼
- 基于 FFmpeg 的跨平臺視頻播放器簡明教程(四):像素格式與格式轉(zhuǎn)換
- 基于 FFmpeg 的跨平臺視頻播放器簡明教程(五):使用 SDL 播放視頻
- 基于 FFmpeg 的跨平臺視頻播放器簡明教程(六):使用 SDL 播放音頻和視頻
- 基于 FFmpeg 的跨平臺視頻播放器簡明教程(七):使用多線程解碼視頻和音頻
前言
在上篇文章 基于 FFmpeg 的跨平臺視頻播放器簡明教程(七):使用多線程解碼視頻和音頻 中,我們使用多個線程來做不同的事情,讓整個播放器更加的模塊化。
我們的播放器現(xiàn)在能夠同時視頻和音頻了,但還不夠,你會發(fā)現(xiàn)視頻畫面和音頻會對不上,這是因為我們沒有做音畫同步。在現(xiàn)有的代碼中,我們每隔 30ms 去播放一幀畫面,而音頻則是由系統(tǒng)的音頻線程來負責(zé)驅(qū)動調(diào)用。這等于說,視頻和音頻各播各的,畫面與聲音失去了同步。因此,本章將討論如何進行音畫同步。
本文參考文章來自 An ffmpeg and SDL Tutorial - Tutorial 05: Synching Video 和 An ffmpeg and SDL Tutorial - Tutorial 06: Synching Audio。這個系列對新手較為友好,但 2015 后就不再更新了,以至于文章中的 ffmpeg api 已經(jīng)被棄用了。幸運的是,有人對該教程的代碼進行重寫,使用了較新的 api,你可以在 rambodrahmani/ffmpeg-video-player 找到這些代碼。
本文的代碼在 ffmpeg_video_player_tutorial-my_tutorial05_01_clock.cpp。
I 幀,P 幀,B幀
I 幀(關(guān)鍵幀):也被稱為內(nèi)插幀。每一幀都是獨立的圖像,也就是說它不依賴于其他任何幀的圖像信息。I幀類似于完整的圖像,編解碼器只需要本幀數(shù)據(jù)就可以完成解碼。在視頻播放時,I幀也是快進、快退、拖動的定位點,通常情況下,視頻中的第一幀就是I幀。
P 幀(預(yù)測幀):P幀的數(shù)據(jù)包含與前一個I幀或P幀的差別,在編碼時僅考慮了前向預(yù)測,也就是只與前一幀比較,來找出兩幀之間的區(qū)別,然后只記錄下這個差別。P幀依賴于前面的I幀或P幀。
B 幀(雙向預(yù)測幀):B幀記錄的是該幀與前后幀的差別,即考慮了雙向的預(yù)測,可以理解為,B幀被插在I、P幀之間,通過前后幀進行預(yù)測、記錄、解壓。B幀依賴于前后的I幀或P幀。
需要所有這些幀的主要原因是為了壓縮視頻。I幀需要的數(shù)據(jù)量最多,但是不可能所有幀都為I幀,這樣壓縮率就很低,所以P幀和B幀誕生了,P幀和B幀只記錄和參考幀的差異信息,所以數(shù)據(jù)量小,壓縮率高,缺點是如果參考幀丟失,那么將導(dǎo)致無法解壓出真實圖像??偟膩碚f,所有這些幀的組合能夠使視頻數(shù)據(jù)得以有效的壓縮,同時保持視頻質(zhì)量。
PTS 與 DTS
假設(shè)現(xiàn)在有 4 幀畫面,且沒有 B 幀的情況,一種最常見的編碼情況是:
I P0 P1 P0
在 “I P P P” 這種編碼模式下,視頻幀的解碼過程較為簡單,播放順序和解碼順序是一樣的,因為這種情況下沒有B幀存在,P幀只依賴前面的I幀或P幀。
具體來說,解碼過程如下:
- 首先解碼第一幀I幀,I幀是關(guān)鍵幀,可以獨立解碼,不依賴任何其他幀。
- 然后解碼第二幀P幀,此P幀只依賴于前一幀,前一幀是I幀,已經(jīng)解碼。
- 接著解碼第三幀P幀,此P幀也只依賴于前一幀,前一幀是P幀,已經(jīng)解碼。
- 最后解碼第四幀P幀,此P幀只依賴于前一幀,前一幀是P幀,已經(jīng)解碼。
所以,解碼順序和播放順序一致,均為:I P P P。
如果有 B 幀呢?一種最常見的編碼情況是:
I B0 B1 P0
在 “I B B P” 這種編碼模式下,視頻幀的解碼順序和播放順序也是不一樣的。B幀依賴前面和后面的幀,因此需要等待后一個參考幀(I或P幀)解析完成后,才能開始 B 幀的解碼。
- 首先解碼第一幀I幀,I幀是關(guān)鍵幀,可以獨立解碼,不依賴任何其他幀。
- 然后解碼第四幀P幀,P幀只依賴于之前的I幀或P幀,這一步可以順利進行。
- 解碼完第四幀P幀后,才能開始解碼第二幀和第三幀B幀。B幀需要依賴前一幀和后一幀,也就是需要依賴第一幀I幀和第四幀P幀。
所以,解碼順序為:I P B B。然而,播放順序仍然為:I B B P。
因此,類似的,在包含B幀的情況下,解碼順序和播放順序是有所不同的,這也是為什么在視頻解碼過程中需要對幀進行重新排序的原因。
我們使用 DTS(Decoding Time Stamp;解碼時間戳)來表示解碼的順序,PTS(Presentation Time Stamp;呈現(xiàn)時間戳)來表示播放的順序。
在 FFmpeg 中,解封裝得到 AVPacket 后,每個 AVPacket 都有個成員變量叫做 dts;解碼得到 AVFrame 后,每個 AVFrame 都有一個成員變量叫 pts。這兩個變量對應(yīng)著 DTS 和 PTS 的概念。
仍然以上面的 4 幀畫面為例,在沒有 B 幀的情況,經(jīng)過編碼后,它的碼流應(yīng)該是 Stream: I P0 P1 P2
,解碼順序與播放順序一致,因此:
PTS: 1 2 3 4
DTS: 1 2 3 4
Stream: I P0 P1 P2
在有 B 幀的情況,經(jīng)過編碼后碼流為 Stream: I P B0 B1
,解碼順序是 DTS: 1 2 3 4
,播放順序是 PTS: 1 4 2 3
,因此:
PTS: 1 4 2 3
DTS: 1 2 3 4
Stream: I P B0 B1
Timebase,時間基
DTS 和 PTS 都是時間戳,那么時間的單位是什么?是秒(s)還是毫秒(ms)呢?其實都不是,DTS 和 PTS 是以時間基(timebase)為單位的,時間基是由編碼格式?jīng)Q定的,通常它是一個如1/90000這樣的分數(shù)。
為什么要引入 timebase,而不是固定使用 秒(s)或者毫秒(ms) 呢?引入timebase主要是為了提供更為靈活和精細的時間表示方法。雖然可以選擇只用秒或毫秒作為單位,但這可能會在一些特殊的情況下引入不必要的誤差或者損失精度。
在多媒體處理中,每種編碼格式或者媒體流可能有其特定的幀率或者時鐘頻率,用一個固定的時間單位如秒或毫秒可能難以精確地表示這些特定的時間點或時長。比如有些視頻可能是24幀/秒,有的可能是30幀/秒,有的可能是29.97幀/秒等。
而timebase是一個分數(shù)形式的時間單位,它的分子和分母都可以根據(jù)具體的編碼格式或者媒體流來自由設(shè)定,可以精確適配不同的幀率和時鐘頻率,使得時間表示既可以非常精細,也可以非常靈活。例如,對于一個幀率為29.97fps的視頻,我們可以設(shè)置timebase為1/30000,這樣就能夠非常精確地表示每一幀的時間。所以,引入timebase主要是為了在多媒體處理中提供一個既靈活又精確的時間表示方法。
Timebase 不如常見的秒或者毫秒那樣直觀。對于絕大多數(shù)人來說,"1/30000秒"涉及到的分數(shù)運算可能會比較難以理解,相比之下,"33.33毫秒"可能會更直觀一些。
但是在多媒體編程中,由于需要處理各種不同的編碼格式和媒體流,每種可能有其特定的幀率或者時鐘頻率,這就需要一個更靈活和精細的時間表示方法來適應(yīng)這些差異,這就是引入timebase的原因。
對于程序來說,處理timebase只不過是一些簡單的乘法和除法運算,而對于熟悉多媒體編程的開發(fā)者來說,他們也已經(jīng)習(xí)慣了timebase這樣的時間表示方法。
在FFmpeg中,timebase是一個分數(shù),表示的是時間單位,用于進行時間戳與實際時間的轉(zhuǎn)換。它由一個分子和一個分母構(gòu)成。分子通常為1,分母通常等于媒體的幀率或者時鐘頻率。
- 分子(numerator):通常為1,在方程中根據(jù)需要設(shè)定
- 分母(denominator):通常將幀率或者時鐘頻率設(shè)定為分母
以數(shù)字視頻為例,如果一個視頻的幀率是每秒30幀,那么它的timebase就是1/30,這個值表示的是每幀的時間長度。換算成秒,就是約0.0333秒。如果你有一個時間戳值,例如100,那么這個時間戳對應(yīng)的實際時間就是100 * (1/30)= 約3.33秒。
再舉一個數(shù)字音頻的例子,如果音頻的采樣率是44100Hz,表示每秒鐘有44100個音頻樣本,那么它的timebase就是1/44100。如果你有一個時間戳值,例如22050,那么這個時間戳對應(yīng)的實際時間就是22050 * (1/44100)= 0.5秒。
需要注意的是,不同的流可能有不同的time_base。例如一個包含視頻和音頻的媒體文件,它的視頻流的time_base可能是1/30(對應(yīng)30fps的幀率),而音頻流的time_base可能是1/44100(對應(yīng)44100Hz的采樣率)。所以在計算時間戳對應(yīng)的實際時間時,需要使用正確的time_base。
或者換一種理解,在FFmpeg中,timebase以一個分數(shù)形式存在,分母表示將1秒拆分成的份額,而分子表示每一幀(或采樣,或其他計數(shù)單位)占用的份額。例如,如果一個時間基準(zhǔn)(timebase)是7/30,那么這意味著1秒被拆分成了30份,每一幀占用了7份。所以,每一幀對應(yīng)的時間就是7 * 1/30 = 0.2333秒。
在大多數(shù)的實際應(yīng)用中,timebase的分子通常為1,這是因為在大多數(shù)媒體格式中,每一幀(或其他的計數(shù)單位)通常都對應(yīng)于一個單位的時間。這樣的話,如1/24、1/30、1/60這樣的timebase也就表示了每幀的時間,直接是1/24秒、1/30秒、1/60秒。
Timebase 轉(zhuǎn)換
由于視頻流和音頻流使用的 timebase 是不同的,為了能夠進行音畫同步,我們首先要做的是將視頻幀的 PTS 與音頻幀的 PTS 都轉(zhuǎn)換到同一個 Timebase 上,以便我們進行時間上的比較。
在FFmpeg中,AV_TIME_BASE是一個宏定義,其值為1000000。這是作為FFmpeg內(nèi)部處理時間戳?xí)r的一個共同參考,用于統(tǒng)一不同的時間基準(zhǔn)。
這個宏定義的原理是以微秒作為基礎(chǔ)時間單位。這樣,你可以用這個AV_TIME_BASE來將時間戳從微秒轉(zhuǎn)換到FFmpeg內(nèi)部使用的時間單位,反之亦然。例如,如果你有一個以秒為單位的時間量,你可以乘以AV_TIME_BASE將其轉(zhuǎn)換為基于FFmpeg的時間單位。
例如,1秒 = 1 * AV_TIME_BASE,0.5秒 = 0.5 * AV_TIME_BASE。多了解這一點,可以幫助你更好地理解FFmpeg在處理時間戳?xí)r的一些機制和單位轉(zhuǎn)換等問題。
需要注意的是,雖然FFmpeg內(nèi)部用AV_TIME_BASE作為統(tǒng)一的時間處理標(biāo)準(zhǔn),但是不同的流還是可能有各自的時間基準(zhǔn),這需要我們在處理時對時間戳進行相應(yīng)的轉(zhuǎn)換。
假設(shè)我們有一個視頻流,幀率是30fps,所以視頻流的timebase是1/30;我們還有一個音頻流,樣本率是44100Hz,所以音頻流的timebase是1/44100。
假設(shè)我們現(xiàn)在有一個視頻幀和一個音頻樣本,它們的PTS(顯示時間戳)分別是v_pts和a_pts。首先,我們需要把v_pts和a_pts轉(zhuǎn)換到以AV_TIME_BASE為單位的時間戳,也就是以微秒為單位的時間戳:
v_pts_us = v_pts * (1 / 30) * AV_TIME_BASE
a_pts_us = a_pts * (1 / 44100) * AV_TIME_BASE
然后,我們就可以直接比較v_pts_us和a_pts_us,這樣就能知道音頻和視頻哪個應(yīng)該先播放。
如果v_pts_us < a_pts_us,那么這個視頻幀應(yīng)該先播放; 如果a_pts_us < v_pts_us,那么這個音頻樣本應(yīng)該先播放。這樣,就實現(xiàn)了音畫同步。當(dāng)然這是一個簡化的示例,在真實的應(yīng)用中,可能還需要更復(fù)雜的邏輯來處理AV同步,例如處理拖動播放條引起的seek操作,處理音頻和視頻的解碼延遲等。
在 FFmpeg API 中,轉(zhuǎn)換時間戳的函數(shù)是 av_rescale_q。該函數(shù)通過給定的 AVRational 結(jié)構(gòu)體(表示時間基數(shù)的分數(shù)結(jié)構(gòu)體)來重新縮放時間戳。你可以像下面這樣使用它:
int64_t v_pts_us = av_rescale_q(v_pts, video_stream->time_base, AV_TIME_BASE_Q);
這樣 av_rescale_q 就會將 v_pts 從 video_stream->time_base (視頻流的時間基數(shù))轉(zhuǎn)換為 AV_TIME_BASE_Q(AV_TIME_BASE的時間基數(shù),值為1/1000000)。同樣的,你可以用這個函數(shù)轉(zhuǎn)換音頻時間戳:
int64_t a_pts_us = av_rescale_q(a_pts, audio_stream->time_base, AV_TIME_BASE_Q);
音畫同步
有了前面的知識鋪墊,相信你已經(jīng)對 FFmpeg 中關(guān)于時間概念有所了解,這是進行音畫同步編程的前提。
就目前的程序而言,視頻和音頻正在愉快地運行,根本不需要同步。如果一切正常,我們就不必擔(dān)心這個問題。但你的電腦并不完美,很多視頻文件也不完美。因此,我們有三種選擇:將音頻同步到視頻、將視頻同步到音頻,或者將兩者同步到外部時鐘(如電腦)?,F(xiàn)在,我們介紹的是將視頻同步到音頻。
精確地紀(jì)錄和獲取時間
我們首先要解決的第一個問題是:如何精確的紀(jì)錄當(dāng)前視頻/音頻流的時間。只有拿到了精確的時間,我們才能知道視頻與音頻之間的快慢關(guān)系。
在 ffmpeg_video_player_tutorial-my_tutorial05_01_clock.cpp 中我們封裝了一個叫 Clock 的類,它負責(zé)紀(jì)錄時間。
class Clock {
public:
std::atomic<double> pts{0}; // clock base, seconds
std::atomic<double> last_updated{0}; // last pts updated time
std::atomic<double> pre_pts{0};
std::atomic<double> pre_frame_delay{0};
};
- pts,當(dāng)前流的播放時間,單位 s
- last_updated,最近 pts 更新的時間,單位 s
- pre_pts,上次的 pts
- pre_frame_delay,上次幀的延遲。在某些情況我們將使用這個變量作為當(dāng)前幀的延遲。
使用下面這些函數(shù)來更新/獲取時鐘:
void setClockAt(Clock &clock, double pts, double time) {
clock.pts = pts;
clock.last_updated = time;
}
void setClock(Clock &clock, double pts) {
setClockAt(clock, pts, (double)av_gettime() / 1000000.0);
}
double getClock(const Clock &c) const {
double time = (double)av_gettime() / 1000000.0;
return c.pts + time - c.last_updated;
}
setClock(Clock &clock, double pts)
更新當(dāng)前 clock 的 pts,同時更新 last_updated 值,last_updated 也就是調(diào)用該函數(shù)時的系統(tǒng)時間。為什么需要 last_updated ?getClock
給出了答案。
getClock
獲取當(dāng)前流的播放時間。下圖解釋了 getClock
的計算邏輯
pts=30 pts=60 pts=90 pts=120
last_updated=1000 last_updated=1030 last_updated=1090 last_updated=1120
Thread0: |--------------------|--------------------|--------------------|
| |
| |
Thread1: |----------|---------------------------------------------|-----|
time=1015 time=1110
t0=30+(1015-1000) t1=90+(1110-1090)
=45 =110
想象有兩個線程,Thread 0(視頻播放線程)和Thread 1。Thread 0定期播放視頻幀,并更新視頻流的時間。Thread 1在任何時間都可能訪問視頻流時鐘以獲取當(dāng)前播放時間。
在時間點 t0,系統(tǒng)時間 time 為 1015,視頻流時鐘的 pts=30,last_updated=1000;經(jīng)過的時間是 (time - last_updated) = 15,這表明更新 pts 距現(xiàn)在過去了15個時間單位,所以當(dāng)前播放時間是 pts + 15 = 45。
在時間點 t1,系統(tǒng)時間 time 為 1110,視頻流時鐘的 pts=90,last_updated=1090;經(jīng)過的時間是 (time - last_updated) = 20,這表明更新 pts 距現(xiàn)在過去了20個時間單位,所以當(dāng)前播放時間是 pts + 20 = 110。
紀(jì)錄時間的時機
在代碼中,我們使用兩個 clock 分別紀(jì)錄視頻和音頻的時間
Clock audio_clock_t; // 紀(jì)錄音頻時間
Clock video_clock_t; // 紀(jì)錄視頻時間
那么應(yīng)該在何時更新時鐘的時間呢?
在視頻流中,在渲染當(dāng)前幀時,我們更新 video_clock_t
時鐘,具體的,在 videoRefreshTimer
函數(shù)使用 ctx->videoSync
進行時鐘更新
void videoRefreshTimer(void *userdata) {
// ...
auto real_delay = ctx->videoSync(video_frame);
// ...
}
在音量流中,在播放當(dāng)前音頻幀時,我們更新 audio_clock_t
時鐘,具體的,在 audioCallback
函數(shù)中使用 ctx->setClock
進行時鐘更新:
void audioCallback(void *userdata, Uint8 *stream, int len){
// ...
play_ctx->setClock(play_ctx->audio_clock_t,
frame->pts * av_q2d(audio_stream->time_base));
// ...
}
當(dāng)然,注意我們設(shè)置時鐘時需要將 pts 轉(zhuǎn)換到以秒(s)為單位,確保視頻時鐘和音頻時鐘時間單位一致。
音畫同步具體算法
本文主要講解如何將視頻同步到音頻,因此,在音頻線程中,我們只需更新音頻時鐘,而無需進行任何同步操作。所有的音頻視頻同步操作都會在視頻播放線程中進行。
簡單來說,音視頻同步的原理并不復(fù)雜。以每秒 25 幀(即每 40 毫秒刷新一幀)的視頻為例。如果視頻播放速度超過了音頻,我們可以適當(dāng)?shù)匮娱L下一幀的刷新時間,使視頻播放速度慢一些,給音頻一些“追趕”的時間,比如延長到 45 毫秒刷新一幀。相反,如果音頻的播放速度快于視頻,我們可以縮短下一幀的刷新時間,使視頻播放快一些,以便能跟得上音頻的速度。
具體計算視頻下一幀刷新時間的代碼在 videoSync 函數(shù)中,看代碼:
int videoSync(AVFrame *video_frame) {
auto video_timebase_d = av_q2d(decode_ctx->video_stream->time_base);
auto pts = video_frame->pts * video_timebase_d;
setClock(video_clock_t, pts);
auto pts_delay = pts - video_clock_t.pre_pts;
printf("PTS Delay:\t\t\t\t%lf\n", pts_delay);
// if the obtained delay is incorrect
if (pts_delay <= 0 || pts_delay >= 1.0) {
// use the previously calculated delay
pts_delay = video_clock_t.pre_frame_delay;
}
printf("Corrected PTS Delay:\t%f\n", pts_delay);
// save delay information for the next time
video_clock_t.pre_pts = pts;
video_clock_t.pre_frame_delay = pts_delay;
auto audio_ref_clock = getAudioClock();
auto video_clock = getVideoClock();
auto diff = video_clock - audio_ref_clock;
printf("Audio Ref Clock:\t\t%lf\n", audio_ref_clock);
printf("Audio Video Delay:\t\t%lf\n", diff);
auto sync_threshold = std::max(pts_delay, AV_SYNC_THRESHOLD);
printf("Sync Threshold:\t\t\t%lf\n", sync_threshold);
if (fabs(diff) < AV_NOSYNC_THRESHOLD) {
if (diff <= -sync_threshold) {
pts_delay = 0;
} else if (diff >= sync_threshold) {
pts_delay = 2 * pts_delay; // [2]
}
}
printf("Corrected PTS delay:\t%lf\n", pts_delay);
return (int)std::round(pts_delay * 1000);
}
};
上述代碼片段是一個函數(shù),用于視頻同步。用于使用音頻時鐘作為參考來調(diào)整視頻幀的展示時間,從而實現(xiàn)音畫同步。
- 第一部分:計算出當(dāng)前視頻幀的表現(xiàn)時間 pts。影片序數(shù)(pts)是一個表示時間基數(shù)的特殊單位,表示在給定的時間標(biāo)度(time base)下的時間。這里av_q2d 函數(shù)將 decode_ctx->video_stream->time_base 轉(zhuǎn)化為雙精度浮點數(shù)。將 pts設(shè)為當(dāng)前音頻時鐘的時間。
- 第二部分:計算 pts_delay,它表示當(dāng)前幀和上一幀之間的延遲。如果這被認為是無效的(即小于等于0或大于等于1.0),則會使用先前的幀延遲。這些信息存儲起來供下一次使用。
- 第三部分:獲取音頻時鐘 audio_ref_clock和視頻時鐘 video_clock,并計算出二者之間的差值diff,這表示音頻和視頻幀之間的延遲。
- 第四部分:計算出調(diào)整閾值 sync_threshold,當(dāng)差值 diff 在閾值范圍內(nèi)時,不調(diào)整 pts_delay,音頻和視頻同步。若差值(音頻和視頻延時)過大,則根據(jù)其正負,使視頻速度加快或放慢,實現(xiàn)音視頻同步。
- 最后,返回 pts_delay 的值(以毫秒為單位),這個返回值將被用作等待時間,來決定何時展示下一幀。
在視頻播放線程中,使用 videoSync
計算出下一幀的刷新等待時間后,使用 scheduleRefresh(ctx, real_delay);
設(shè)置一個定時器,讓定時器在 real_delay
后發(fā)送一個事件讓 SDL 去渲染下一幀畫面。就這樣,我們完成了音畫同步。文章來源:http://www.zghlxwxcb.cn/news/detail-638643.html
總結(jié)
本文介紹了如何實現(xiàn)播放器的音畫同步,首先介紹了 I/P/B 幀的區(qū)別,引出了 PTS 和 DTS 的概念;接著,介紹了在 FFmpeg 中的 timebase 的概念,讓讀者了解 FFmpeg 是如何描述時間的;然后,我們詳細的描述了音畫同步實施的具體要點,包括如何精確的紀(jì)錄不同流的當(dāng)前時間,在什么時間節(jié)點來更新時鐘,以及音畫同步的具體算法。文章來源地址http://www.zghlxwxcb.cn/news/detail-638643.html
參考
- An ffmpeg and SDL Tutorial - Tutorial 05: Synching Video
- An ffmpeg and SDL Tutorial - Tutorial 06: Synching Audio
- FFmpeg 音視頻(DTS / PTS)
- ffmpeg_video_player_tutorial-my_tutorial05_01_clock.cpp。
到了這里,關(guān)于基于 FFmpeg 的跨平臺視頻播放器簡明教程(八):音畫同步的文章就介紹完了。如果您還想了解更多內(nèi)容,請在右上角搜索TOY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!