【C++】從零開始,只使用FFmpeg,Win32 API,實現一個播放器(三)

最後的紳士 發表於 2021-05-12
C++ FFmpeg

前情提要

前篇:https://www.cnblogs.com/judgeou/p/14728617.html

上一集我們攻略了 Direct3D 11 渲染,充分發揮現代 GPU 的效能。這一集比較輕鬆,主要是完善剩下需要的功能。

利用垂直同步控制播放速度

正確控制播放速度其實有非常多的方式,比較常見的是將視訊和音訊同步,或者與外部時鐘同步。但這裡我要介紹一種比較少見的方式,可以在沒有音訊的時候使用,就是利用螢幕的垂直同步訊號來同步視訊畫面。

當呼叫 IDXGISwapChain::Present 並且第一個引數為 1 時,會阻塞執行緒,直到螢幕完成一幀畫面的顯示,傳送垂直同步訊號,才會返回繼續執行,利用這一特性,來完成播放速度的正確處理。

假設我們的螢幕重新整理率是 60Hz,視訊是 30fps,那麼處理起來很簡單,每 2 個呈現週期,更新一次視訊畫面即可,可以保證每一幀畫面的出現,時機都恰到好處。但如果視訊是 24fps,就需要每 2.5 個呈現週期更新一次畫面,導致你的視訊畫面幾乎在絕大多數時候會與正確的播放時機錯開,你能做的,只能是這幀慢了,下一幀就快點,這一幀快了,下一幀就慢點。

// 獲取視訊幀率
double GetFrameFreq(const DecoderParam& param) {
	auto avg_frame_rate = param.fmtCtx->streams[param.videoStreamIndex]->avg_frame_rate;
	auto framerate = param.vcodecCtx->framerate;

	if (avg_frame_rate.num > 0) {
		return (double)avg_frame_rate.num / avg_frame_rate.den;
	}
	else if (framerate.num > 0) {
		return (double)framerate.num / framerate.den;
	}
}
// ...

DEVMODE devMode = {};
devMode.dmSize = sizeof(devMode);
EnumDisplaySettings(NULL, ENUM_CURRENT_SETTINGS, &devMode);
// 螢幕重新整理率
auto displayFreq = devMode.dmDisplayFrequency;

// 記錄螢幕呈現了多少幀
int displayCount = 1;
// 記錄視訊播放了多少幀
int frameCount = 1;

MSG msg;
while (1) {
	// ...
	if (hasMsg) {
		// ...
	}
	else {
		double frameFreq = GetFrameFreq(decoderParam);
		double freqRatio = displayFreq / frameFreq;
		double countRatio = (double)displayCount / frameCount;

		if (freqRatio < countRatio) {
			auto frame = RequestFrame(decoderParam);
			UpdateVideoTexture(frame, scenceParam, decoderParam);
			frameCount++;
			av_frame_free(&frame);
		}

		Draw(d3ddeivce.Get(), d3ddeviceCtx.Get(), swapChain.Get(), scenceParam);

		swapChain->Present(1, 0);
		displayCount++;
	}
}

用 displayCount 和 frameCount 分別記錄渲染的幀數和播放的幀數,這兩個數字的比值(countRatio)應當與 螢幕重新整理率 和 視訊幀率 的比值(freqRatio )儘可能接近,所以判斷一旦 freqRatio < countRatio 就解碼下一幀視訊,否則就繼續渲染上一次的畫面。經過這個改動後,低於或等於螢幕重新整理率的視訊就可以正常播放了。

但是如果是高重新整理率的視訊,比如120fps的視訊,此時你的螢幕是60幀,那麼就要放棄渲染一些幀。

// ...

double frameFreq = GetFrameFreq(decoderParam);
double freqRatio = displayFreq / frameFreq;
double countRatio = (double)displayCount / frameCount;

while (freqRatio < countRatio) {
	auto frame = RequestFrame(decoderParam);
	frameCount++;
	countRatio = (double)displayCount / frameCount;

	if (freqRatio >= countRatio) {
		UpdateVideoTexture(frame, scenceParam, decoderParam);
	}
	av_frame_free(&frame);
}

把原來的 if (freqRatio < countRatio) 改為 while (freqRatio < countRatio),這樣視訊解碼一幀後會再觸發判斷,如果是120fps視訊則繼續解碼下一幀並跳過 UpdateVideoTexture。

這樣不管是什麼幀率的視訊,在什麼重新整理率的螢幕上都可以以正確的速度播放了。

注意:通過 EnumDisplaySettings 獲取的螢幕重新整理率其實是不太精確的,實際重新整理率通常不是整數,而是帶小數點,這裡就不深究了,有興趣的看 DwmGetCompositionTimingInfo

保持畫面比例

先把windows窗體樣式改回 WS_OVERLAPPEDWINDOW,方便我們對視窗進行任意縮放。

auto window = CreateWindow(className, L"Hello World 標題", WS_OVERLAPPEDWINDOW, 100, 100, clientWidth, clientHeight, NULL, NULL, hInstance, NULL);

想要保持畫面比例,就要根據當前視窗的 width height 對四邊形進行縮放調整,要麼變胖變瘦,要麼變高變矮,這些都屬於縮放變換,那麼四邊形每一個頂點要如何變化呢?答案就是把每一個頂點座標,乘以相對應的縮放矩陣即可。其他的諸如平移、旋轉等也是通過與矩陣相乘實現的:

image

當物體的頂點數量十分龐大時,在CPU做矩陣變換太耗費時間了,GPU就非常適合幹這個活兒。儘管我們只有4個點,但這裡還是使用業界標準做法,把矩陣傳送到圖形管線,在著色器裡面對各個頂點進行矩陣乘法。

這裡要用上微軟提供的庫:DirectXMath,已經包含在 Windows SDK 裡了,先引入必要的標頭檔案:

#include <DirectXMath.h>
namespace dx = DirectX;

相關函式是在名稱空間 DirectX 下的,為了寫起來方便,就用 dx 別名代替。

為了把矩陣放進管線,需要一個新的 ID3D11Buffer。

struct ScenceParam {
// ...
	ComPtr<ID3D11Buffer> pConstantBuffer;
// ...
	int viewWidth;
	int viewHeight;
};

在結構體 ScenceParam 新增 ComPtr<ID3D11Buffer> pConstantBuffer,並且新增兩個屬性 viewWidth viewHeight,儲存當前視窗大小。

修改 InitScence 函式,新增建立常量緩衝區的程式碼:

void InitScence(ID3D11Device* device, ScenceParam& param, const DecoderParam& decoderParam) {
// ...

	// 常量緩衝區
	auto constant = dx::XMMatrixScaling(1, 1, 1);
	constant = dx::XMMatrixTranspose(constant);
	D3D11_BUFFER_DESC cbd = {};
	cbd.Usage = D3D11_USAGE_DYNAMIC;
	cbd.BindFlags = D3D11_BIND_CONSTANT_BUFFER;
	cbd.CPUAccessFlags = D3D11_CPU_ACCESS_WRITE;
	cbd.ByteWidth = sizeof(constant);
	cbd.StructureByteStride = 0;
	D3D11_SUBRESOURCE_DATA csd = {};
	csd.pSysMem = &constant;
	
	device->CreateBuffer(&cbd, &csd, &param.pConstantBuffer);
// ...
}

因為需要每一幀都更新 pConstantBuffer 的內容,所以 Usage 必須要是 D3D11_USAGE_DYNAMIC,CPUAccessFlags 必須是 D3D11_CPU_ACCESS_WRITE。初始的時候,先給一個 縮放(1, 1, 1) 矩陣,其實就相當於啥也沒變,這裡注意 XMMatrixTranspose 函式,他把矩陣的行和列置換了,為什麼要幹這個呢,因為GPU看待矩陣行列的形式反了過來,CPU他是一行一行的讀,GPU是一列一列的讀。所以傳送到GPU前需要處理一下。不過,縮放矩陣就算你不置換,結果都是正常的🤣,這個你們觀察一下上面的圖就懂了。

編寫一個新函式 FitQuadSize,通過計算 視訊的解析度 和 視窗解析度的比例寫入正確的矩陣

// 通過視窗比例與視訊比例的計算,得出合適的縮放矩陣,寫入常量緩衝。
void FitQuadSize(
	ID3D11DeviceContext* ctx, ID3D11Buffer* constant,
	int videoWidth, int videoHeight, int viewWidth, int viewHeight
) {
	double videoRatio = (double)videoWidth / videoHeight;
	double viewRatio = (double)viewWidth / viewHeight;
	dx::XMMATRIX matrix;

	if (videoRatio > viewRatio) {
		matrix = dx::XMMatrixScaling(1, viewRatio / videoRatio, 1);
	}
	else if (videoRatio < viewRatio) {
		matrix = dx::XMMatrixScaling(videoRatio / viewRatio, 1, 1);
	}
	else {
		matrix = dx::XMMatrixScaling(1, 1, 1);
	}
	matrix = dx::XMMatrixTranspose(matrix);

	D3D11_MAPPED_SUBRESOURCE mapped;
	ctx->Map(constant, 0, D3D11_MAP_WRITE_DISCARD, 0, &mapped);
	memcpy(mapped.pData, &matrix, sizeof(matrix));
	ctx->Unmap(constant, 0);
}

XMMatrixScaling 可以分別設定xyz三個軸的縮放,videoRatio > viewRatio 與 videoRatio < viewRatio 決定到底應該Y軸縮放,還是X軸縮放。使用 ID3D11DeviceContext::Map 把矩陣資料從記憶體寫入到 ID3D11Buffer。

修改 Draw 函式,先呼叫 FitQuadSize 再把常量緩衝放進管線:

void Draw(
	ID3D11Device* device, ID3D11DeviceContext* ctx, IDXGISwapChain* swapchain,
	ScenceParam& param, const DecoderParam& decoderParam
) {
// ...

	FitQuadSize(ctx, param.pConstantBuffer.Get(), decoderParam.width, decoderParam.height, param.viewWidth, param.viewHeight);
	ID3D11Buffer* cbs[] = { param.pConstantBuffer.Get() };
	ctx->VSSetConstantBuffers(0, 1, cbs);
// ...

	viewPort.Width = param.viewWidth;
	viewPort.Height = param.viewHeight;
}

因為需要獲取視訊解析度,所以引數也記得加上 DecoderParam。

修改 main 函式:

int WINAPI WinMain (
	_In_ HINSTANCE hInstance,
	_In_opt_ HINSTANCE hPrevInstance,
	_In_ LPSTR lpCmdLine,
	_In_ int nShowCmd
) {
// ...

	int windowWidth = 1280;
	int windowHeight = 720;
	auto window = CreateWindow(className, L"Hello World 標題", WS_OVERLAPPEDWINDOW, 100, 100, windowWidth, windowHeight, NULL, NULL, hInstance, NULL);
	
	RECT clientRect;
	GetClientRect(window, &clientRect);
	int clientWidth = clientRect.right - clientRect.left;
	int clientHeight = clientRect.bottom - clientRect.top;

	ShowWindow(window, SW_SHOW);
// ...

	scenceParam.viewWidth = clientWidth;
	scenceParam.viewHeight = clientHeight;

	InitScence(d3ddeivce.Get(), scenceParam, decoderParam);
// ...
}

CreateWindow 建立視窗填入的 width height 數值是包含了標題欄的,所以需要呼叫 GetClientRect 獲取到不含標題欄和邊框的長寬大小值。

執行效果:

image

注意到兩邊的黑邊了嗎,雖然視窗高度設定了 720,但是標題欄佔了一部分,所以實際顯示區域矮了,兩邊有黑邊這才是正確的視訊比例。

接著需要修改視窗處理函式 WNDCLASSW::lpfnWndProc,監聽 WM_SIZE 訊息,把變化後的size放進 ScenceParam。

wndClass.lpfnWndProc = [](HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) -> LRESULT {
	switch (msg)
	{
	case WM_SIZE:
	{
		auto scenceParam = (ScenceParam*)GetWindowLongPtr(hwnd, GWLP_USERDATA);
		if (scenceParam) {
			auto width = GET_X_LPARAM(lParam);
			auto height = GET_Y_LPARAM(lParam);

			scenceParam->viewWidth = width;
			scenceParam->viewHeight = height;
		}
		return 0;
	}
	case WM_DESTROY:
		PostQuitMessage(0);
		return 0;
	default:
		return DefWindowProc(hwnd, msg, wParam, lParam);
	}
};
// ...

ShowWindow(window, SW_SHOW);
SetWindowLongPtr(window, GWLP_USERDATA, (LONG_PTR)&scenceParam);

為了能在 lpfnWndProc 訪問到 scenceParam,需要呼叫 SetWindowLongPtr 把 scenceParam 指標設定進去,然後在 lpfnWndProc 裡通過 GetWindowLongPtr 獲取。

注意 GET_X_LPARAM GET_Y_LPARAM 這兩個巨集必須要引入 windowsx.h 這個標頭檔案才能使用。

修改 Draw 函式,當視窗size改變時,交換鏈也重新設定對應大小。

void Draw(
	ID3D11Device* device, ID3D11DeviceContext* ctx, IDXGISwapChain* swapchain,
	ScenceParam& param, const DecoderParam& decoderParam
) {
// ...

	// 必要時重新建立交換鏈
	DXGI_SWAP_CHAIN_DESC swapDesc;
	swapchain->GetDesc(&swapDesc);
	auto& bufferDesc = swapDesc.BufferDesc;
	if (bufferDesc.Width != param.viewWidth || bufferDesc.Height != param.viewHeight) {
		swapchain->ResizeBuffers(swapDesc.BufferCount, param.viewWidth, param.viewHeight, bufferDesc.Format, swapDesc.Flags);
	}
// ...
}

執行效果:

image

image

順便提一句,使用低解析度的交換鏈和光柵視點可以在播放4k高解析度視訊時節省一些GPU效能。

全屏播放

我們不用獨佔全屏的方式,體驗不太好,用無邊框全屏正合適。

wndClass.lpfnWndProc = [](HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) -> LRESULT {
	switch (msg)
	{
	case WM_SIZE:
	{
		auto scenceParam = (ScenceParam*)GetWindowLongPtr(hwnd, GWLP_USERDATA);
		if (scenceParam) {
			auto width = GET_X_LPARAM(lParam);
			auto height = GET_Y_LPARAM(lParam);

			// 專門處理從全屏恢復到視窗的特殊情況
			if ((GetWindowLongPtr(hwnd, GWL_STYLE) == (WS_VISIBLE | WS_POPUP | WS_CLIPSIBLINGS))) {
				RECT clientRect = { 0, 0, 0, 0 };
				AdjustWindowRect(&clientRect, WS_OVERLAPPEDWINDOW, FALSE);
				width = width - (clientRect.right - clientRect.left);
				height = height - (clientRect.bottom - clientRect.top);
			}

			scenceParam->viewWidth = width;
			scenceParam->viewHeight = height;
		}
		return 0;
	}
	case WM_KEYUP:
	{
		if (wParam == VK_RETURN) {
			static bool isMax = false;
			if (isMax) {
				isMax = false;
				SendMessage(hwnd, WM_SYSCOMMAND, SC_RESTORE, 0);
				SetWindowLongPtr(hwnd, GWL_STYLE, WS_VISIBLE | WS_OVERLAPPEDWINDOW);
			}
			else {
				isMax = true;
				SetWindowLongPtr(hwnd, GWL_STYLE, WS_VISIBLE | WS_POPUP);
				SendMessage(hwnd, WM_SYSCOMMAND, SC_MAXIMIZE, 0);
			}
		}
		return 0;
	}
	case WM_DESTROY:
		PostQuitMessage(0);
		return 0;
	default:
		return DefWindowProc(hwnd, msg, wParam, lParam);
	}
};

監聽 WM_KEYUP 訊息,判斷按鍵是Enter鍵就切換全屏。全屏的呼叫方式不復雜,改下視窗style,然後傳送最大化指令就行,還原的話,就反過來操作。但從全屏回到視窗時,需要特別處理,否則客戶端區域獲取的是無邊框時的大小,但此時應該獲取有標題欄情況下的大小才對。

如果你使用的是最新的 Win 10,最新的顯示卡驅動,在交換鏈使用Flip的情況下,獨佔全屏與無邊框全屏效能差距幾乎沒有,這也是為什麼從某個時候起3D遊戲的顯示設定多了無邊框全屏的選項給你選擇。

互動介面

是時候來點按鈕介面什麼的了,用 Win32 的控制元件做介面實在是麻煩,這裡推薦一個庫:Dear ImGui,用它可以很方便直接在我們的 dx11 上進行繪製。

首先直接把原始碼整個下載下來:https://github.com/ocornut/imgui/archive/refs/tags/v1.82.zip

然後把資料夾複製進VS的專案裡面:

image

然後在VS把以下的檔案新增進專案:

image

注意 imgui/backends 裡面的其他原始碼千萬別新增進去VS,否則VS會編譯他,但你可以保留在資料夾裡面。

引入相關標頭檔案:

#include "imgui/backends/imgui_impl_win32.h"
#include "imgui/backends/imgui_impl_dx11.h"
extern IMGUI_IMPL_API LRESULT ImGui_ImplWin32_WndProcHandler(HWND hWnd, UINT msg, WPARAM wParam, LPARAM lParam);

在 InitScence 初始化 imgui 的 dx11 實現:

void InitScence(ID3D11Device* device, ID3D11DeviceContext* ctx, ScenceParam& param, const DecoderParam& decoderParam) {
// ...

	// 畫素著色器
	device->CreatePixelShader(g_main_PS, sizeof(g_main_PS), nullptr, &param.pPixelShader);

	// imgui
	ImGui_ImplDX11_Init(device, ctx);
}

編寫一個新函式 DrawImgui 處理 imgui 的介面邏輯:

void DrawImgui(
	ID3D11Device* device, ID3D11DeviceContext* ctx, IDXGISwapChain* swapchain,
	ScenceParam& param, const DecoderParam& decoderParam
) {
	ImGui_ImplDX11_NewFrame();
	ImGui_ImplWin32_NewFrame();
	ImGui::NewFrame();

	// 這裡開始寫介面邏輯
	ImGui::ShowDemoWindow();

	ImGui::Render();
	ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
}

ImGui::ShowDemoWindow() 會顯示自帶的 demo 視窗。

void Draw(
	ID3D11Device* device, ID3D11DeviceContext* ctx, IDXGISwapChain* swapchain,
	ScenceParam& param, const DecoderParam& decoderParam
) {
// ...

	DrawImgui(device, ctx, swapchain, param, decoderParam);
}

Draw 函式最後一行呼叫 DrawImgui。

// ...

wndClass.lpfnWndProc = [](HWND hwnd, UINT msg, WPARAM wParam, LPARAM lParam) -> LRESULT {
	ImGui_ImplWin32_WndProcHandler(hwnd, msg, wParam, lParam);
// ...
}

還要把 windows 視窗的訊息傳遞給 imgui,否則你雖然能看到 imgui 的介面,但是無法和它互動。

// ...

scenceParam.viewWidth = clientWidth;
scenceParam.viewHeight = clientHeight;

auto imguiCtx = ImGui::CreateContext();
ImGui_ImplWin32_Init(window);

InitScence(d3ddeivce.Get(), d3ddeviceCtx.Get(), scenceParam, decoderParam);
// ...

呼叫 InitScence 之前先呼叫 ImGui::CreateContext 和 ImGui_ImplWin32_Init。

ImGui_ImplDX11_Shutdown();
ImGui_ImplWin32_Shutdown();

ReleaseDecoder(decoderParam);
return 0;

即將退出程式時釋放 imgui 的資源。

執行效果:

image

這裡會發現拖動 imgui 視窗到視訊畫面外的位置時,會有永久停留的拖影,這是因為我們在繪製每一幀的時候,沒有刻意去清除以前的內容。

void Draw(
	ID3D11Device* device, ID3D11DeviceContext* ctx, IDXGISwapChain* swapchain,
	ScenceParam& param, const DecoderParam& decoderParam
) {
// ...

	const FLOAT black[] = { 0, 0, 0, 1 };
	ctx->ClearRenderTargetView(rtv.Get(), black);

	// Draw Call
	auto indicesSize = std::size(param.indices);
	ctx->DrawIndexed(indicesSize, 0, 0);

	DrawImgui(device, ctx, swapchain, param, decoderParam);
}

在 DrawIndexed 前呼叫 ClearRenderTargetView 把整個畫面用黑色填充,這樣就沒有拖影的問題了。

播放、暫停、進度條

struct DecoderParam
{
	AVFormatContext* fmtCtx;
	AVCodecContext* vcodecCtx;
	int width;
	int height;
	int videoStreamIndex;

	float durationSecond;
	float currentSecond;
	bool isJumpProgress;
};

先在 DecoderParam 結構新增三個成員,durationSecond 是視訊總長度,currentSecond 是當前播放的進度,都是秒為單位,isJumpProgress 用來判斷是否執行跳轉。

DecoderParam 初始化方式修改一下:

DecoderParam decoderParam = {};

修改 DrawImgui,顯示一個 Slider 控制元件:

void DrawImgui(
	ID3D11Device* device, ID3D11DeviceContext* ctx, IDXGISwapChain* swapchain,
	ScenceParam& param, DecoderParam& decoderParam
) {
	ImGui_ImplDX11_NewFrame();
	ImGui_ImplWin32_NewFrame();
	ImGui::NewFrame();

	// 這裡開始寫介面邏輯
	// ImGui::ShowDemoWindow();
	if (ImGui::Begin("Play")) {
		ImGui::PushItemWidth(700);
		if (ImGui::SliderFloat("time", &decoderParam.currentSecond, 0, decoderParam.durationSecond)) {
			decoderParam.isJumpProgress = true;
		}
		ImGui::PopItemWidth();
		ImGui::SameLine();
		ImGui::Text("%.3f", decoderParam.durationSecond);
	}
	ImGui::End();
	

	ImGui::Render();
	ImGui_ImplDX11_RenderDrawData(ImGui::GetDrawData());
}

把 currentSecond 繫結到控制元件,這樣 currentSecond 的值改變的時候,控制元件也會有相應的變化,相反,如果手動拖動 Slider,也會影響 currentSecond 的值。我很喜歡這個雙向繫結。同時當我們點選 Slider 的時候把 isJumpProgress 設定為 true,代表執行跳轉操作。

// ...

// 記錄螢幕呈現了多少幀
int displayCount = 0;
// 記錄視訊播放了多少幀
int frameCount = 0;

decoderParam.durationSecond = (double)fmtCtx->duration / AV_TIME_BASE;
auto videoTimeBase = fmtCtx->streams[decoderParam.videoStreamIndex]->time_base;
double videoTimeBaseDouble = (double)videoTimeBase.num / videoTimeBase.den;
// ...

AVFormatContext::duration 就是視訊的長度,但注意還得除以 AV_TIME_BASE 得到的才是秒。videoTimeBase 是視訊流的基本時間單位。

在解碼迴圈裡面計算當前的秒數:

// ...

while (freqRatio < countRatio || countRatio == 0) {
	auto frame = RequestFrame(decoderParam);
	frameCount++;
	countRatio = (double)displayCount / frameCount;

	decoderParam.currentSecond = frameCount / frameFreq;

	if (freqRatio >= countRatio) {
		UpdateVideoTexture(frame, scenceParam, decoderParam);
	}
	av_frame_free(&frame);
}
// ..

執行效果:

image

可以看到 Slider 會不停的移動,這裡我就不去格式化時間了,湊合著用就行。

接著處理 isJumpProgress 的情況:

// ...

while (freqRatio < countRatio || countRatio == 0) {
	if (decoderParam.isJumpProgress) {
		decoderParam.isJumpProgress = false;
		auto& current = decoderParam.currentSecond;
		int64_t jumpTimeStamp = current / videoTimeBaseDouble;
		av_seek_frame(fmtCtx, decoderParam.videoStreamIndex, jumpTimeStamp, 0);

		frameCount = current * frameFreq;
		displayCount = current * displayFreq;
	}
}
// ...

跳轉功能核心函式就是 ffmpeg 的 av_seek_frame,注意 timestamp 引數的單位並不是秒,而是前面我們計算出來的 videoTimeBase,所以要把實際秒數除以它得到最終的數字作為引數。同時別忘了重新計算 frameCount 和 displayCount,否則畫面會跳轉,但是進度條就不會停留在新位置了。

執行效果:

image

這裡有一個小bug,播放結束後會卡死,下面修復它:

AVFrame* RequestFrame(DecoderParam& param) {
// ...

	while (1) {
		AVPacket* packet = av_packet_alloc();
		int ret = av_read_frame(fmtCtx, packet);
		if (ret == 0 && packet->stream_index == videoStreamIndex) {
			// ...
		}
		else if (ret < 0) {
			return nullptr;
		}

		av_packet_unref(packet);
	}

	return nullptr;
}

修改 RequestFrame 函式,while 迴圈裡面的 av_read_frame 返回值判斷,如果是小於0,則無法再讀取新的資料了,此時返回空指標。

while (freqRatio < countRatio) {
// ...

	auto frame = RequestFrame(decoderParam);
	if (frame == nullptr) {
		break;
	}
// ..
}

解碼出來判斷如果是空指標,則直接跳出迴圈,繼續渲染畫面。此時依然可以拖動進度條回到之前的位置繼續播放。

接下來新增播放暫停功能,首先 DecoderParam 新增一個 playStatus

struct DecoderParam
{
	// ...
	int playStatus; // 0:播放,1:暫停
};

新增一個按鈕去控制這個狀態。

// 這裡開始寫介面邏輯
// ImGui::ShowDemoWindow();
if (ImGui::Begin("Play")) {
	auto& playStatus = decoderParam.playStatus;
	if (playStatus == 0) {
		if (ImGui::Button("Pause")) {
			playStatus = 1;
		}
	}
	else if (playStatus == 1 || playStatus == 2) {
		if (ImGui::Button("Play")) {
			playStatus = 0;
		}
	}

// ...
}
ImGui::End();

判斷 playStatus 決定是否進入解碼的分支:

double frameFreq = GetFrameFreq(decoderParam);
double freqRatio = displayFreq / frameFreq;
double countRatio = (double)displayCount / frameCount;

while (freqRatio < countRatio && decoderParam.playStatus == 0) {
// ...
}

Draw(d3ddeivce.Get(), d3ddeviceCtx.Get(), swapChain.Get(), scenceParam, decoderParam);

swapChain->Present(1, 0);
if (decoderParam.playStatus == 0) {
	displayCount++;
}

注意如果是暫停狀態就別更新 displayCount,不然重新播放的時候進度會突然往前一大截。

執行效果:

image

UI 如果老是擋住畫面也不太好,加一個滑鼠不動1秒,就自動隱藏UI吧:

struct DecoderParam
{
// ...
	system_clock::time_point mouseStopTime;
};
void DrawImgui(
	ID3D11Device* device, ID3D11DeviceContext* ctx, IDXGISwapChain* swapchain,
	ScenceParam& param, DecoderParam& decoderParam
) {
	// 這裡開始寫介面邏輯
	// ImGui::ShowDemoWindow();
	auto& io = ImGui::GetIO();
	auto& mouseStopTime = decoderParam.mouseStopTime;
	if (io.MouseDelta.y != 0 || io.MouseDelta.x != 0) {
		mouseStopTime = system_clock::now();
	}

	constexpr auto hideMouseDelay = 1s;
	bool isShowWidgets = ((system_clock::now() - mouseStopTime) < hideMouseDelay) || io.WantCaptureMouse;

	if (isShowWidgets) {
		if (ImGui::Begin("Play")) {
			// ...
		}
		ImGui::End();
	}
}

如果滑鼠運動了,就儲存當前時間到 mouseStopTime,一旦當前時間與 mouseStopTime 差距大於1秒,並且通過 io.WantCaptureMouse 判斷滑鼠不在UI上,則隱藏UI。

音訊

在 Windows 播放音訊需要使用 WASAPI,這是新的介面,從 Windows Vista 開始才有,微軟官方有程式碼例子:https://docs.microsoft.com/en-us/windows/win32/coreaudio/rendering-a-stream,我基本就是參照這份程式碼改的,用的時候不需要依賴其他庫,直接引入標頭檔案即可。

視訊一幀的畫面是由一個一個畫素構成的,音訊一秒的聲音,是由一個一個 樣本(Sample) 構成,一個 Sample 就是一個數字。音訊可以用波形來表達,計算機儲存波形,就是儲存波形函式上的點,一秒鐘的波形儲存了48000個點,就說明這段音訊的取樣率是 48000hz,計算機可以反過來根據這些點還原出波形,點數量越多,聲波還原度就越高,點 就是剛剛說的 Sample。

FFmpeg 解碼音訊的產物就是一個個 Sample,我們把這些Sample給到 Windows 的音訊介面,計算機就可以發出聲音了。

先把音訊播放的部分寫到兩個獨立的檔案:AudioPlayer.h 和 AudioPlayer.cpp

// AudioPlayer.h

#pragma once
#include <Windows.h>
#include <atlcomcli.h>
#include <mmdeviceapi.h>
#include <Audioclient.h>
#include <audiopolicy.h>

namespace nv {
	class AudioPlayer {
	public:
		AudioPlayer(WORD nChannels_, DWORD nSamplesPerSec_);

		HRESULT Start();

		HRESULT Stop();

		BYTE* GetBuffer(UINT32 wantFrames);

		HRESULT ReleaseBuffer(UINT32 writtenFrames);

		// FLTP 格式左右聲道分開,我們把他們合併到一起,“左右左右左右”這樣
		HRESULT WriteFLTP(float* left, float* right, UINT32 sampleCount);

		// 播放正弦波,僅僅只是用來測試你的喇叭會不會響
		HRESULT PlaySinWave(int nb_samples);

		// 設定音量
		HRESULT SetVolume(float v);
	private:
		WORD nChannels;
		DWORD nSamplesPerSec;
		int maxSampleCount; // 緩衝區大小(樣本數)

		WAVEFORMATEX* pwfx;
		CComPtr<IMMDeviceEnumerator> pEnumerator;
		CComPtr<IMMDevice> pDevice;
		CComPtr<IAudioClient> pAudioClient;
		CComPtr<IAudioRenderClient> pRenderClient;
		CComPtr<ISimpleAudioVolume> pSimpleAudioVolume;

		DWORD flags = 0;

		HRESULT Init();

	};
}
// AudioPlayer.cpp
#include "AudioPlayer.h"
#include <cmath>

namespace nv {
	AudioPlayer::AudioPlayer(WORD nChannels_, DWORD nSamplesPerSec_)
		: nChannels(nChannels_), nSamplesPerSec(nSamplesPerSec_), pwfx(nullptr), flags(0)
	{
		Init();
	}

	HRESULT AudioPlayer::Start() {
		return pAudioClient->Start();
	}

	HRESULT AudioPlayer::Stop() {
		return pAudioClient->Stop();
	}

	BYTE* AudioPlayer::GetBuffer(UINT32 wantFrames) {
		BYTE* buffer;
		pRenderClient->GetBuffer(wantFrames, &buffer);
		return buffer;
	}

	HRESULT AudioPlayer::ReleaseBuffer(UINT32 writtenFrames) {
		return pRenderClient->ReleaseBuffer(writtenFrames, flags);
	}

	HRESULT AudioPlayer::WriteFLTP(float* left, float* right, UINT32 sampleCount) {
		UINT32 padding;
		pAudioClient->GetCurrentPadding(&padding);
		if ((maxSampleCount - padding) < sampleCount) { // 音訊寫入太快了,超出緩衝區,我們直接清空現有緩衝區,保證時間對的上
			pAudioClient->Stop();
			pAudioClient->Reset();
			pAudioClient->Start();
		}

		if (left && right) {
			auto pData = GetBuffer(sampleCount);
			for (int i = 0; i < sampleCount; i++) {
				int p = i * 2;
				((float*)pData)[p] = left[i];
				((float*)pData)[p + 1] = right[i];
			}
		}
		else if (left) {
			auto pData = GetBuffer(sampleCount);
			for (int i = 0; i < sampleCount; i++) {
				int p = i * 2;
				((float*)pData)[p] = left[i];
				((float*)pData)[p + 1] = left[i];
			}
		}


		return ReleaseBuffer(sampleCount);
	}

	HRESULT AudioPlayer::PlaySinWave(int nb_samples) {
		auto m_time = 0.0;
		auto m_deltaTime = 1.0 / nb_samples;

		auto pData = GetBuffer(nb_samples);

		for (int sample = 0; sample < nb_samples; ++sample) {
			float value = 0.05 * std::sin(5000 * m_time);
			int p = sample * nChannels;
			((float*)pData)[p] = value;
			((float*)pData)[p + 1] = value;
			m_time += m_deltaTime;
		}

		return ReleaseBuffer(nb_samples);
	}

	HRESULT AudioPlayer::SetVolume(float v) {
		return pSimpleAudioVolume->SetMasterVolume(v, NULL);
	}

	HRESULT AudioPlayer::Init() {
		constexpr auto REFTIMES_PER_SEC = 10000000; // 1s的緩衝區

		HRESULT hr;

		hr = pEnumerator.CoCreateInstance(__uuidof(MMDeviceEnumerator));

		hr = pEnumerator->GetDefaultAudioEndpoint(
			eRender, eConsole, &pDevice);

		hr = pDevice->Activate(
			__uuidof(IAudioClient), CLSCTX_ALL,
			NULL, (void**)&pAudioClient);

		CComPtr<IAudioSessionManager> pAudioSessionManager;
		hr = pDevice->Activate(
			__uuidof(IAudioSessionManager), CLSCTX_INPROC_SERVER,
			NULL, (void**)&pAudioSessionManager
		);

		CComPtr<IAudioSessionControl> pAudioSession;
		hr = pAudioSessionManager->GetAudioSessionControl(
			&GUID_NULL,
			FALSE,
			&pAudioSession
		);

		hr = pAudioSessionManager->GetSimpleAudioVolume(
			&GUID_NULL,
			0,
			&pSimpleAudioVolume
		);

		hr = pAudioClient->GetMixFormat(&pwfx);

		// 我們可以設定與音訊裝置不同的取樣率
		pwfx->nSamplesPerSec = nSamplesPerSec;
		// 固定雙聲道
		pwfx->nAvgBytesPerSec = pwfx->nSamplesPerSec * 2 * (pwfx->wBitsPerSample / 8);
		// 必須使用這種格式
		pwfx->wFormatTag = WAVE_FORMAT_EXTENSIBLE;
		
		hr = pAudioClient->Initialize(
			AUDCLNT_SHAREMODE_SHARED,
			AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY, // 這裡的flag告訴系統需要重取樣
			REFTIMES_PER_SEC,
			0,
			pwfx,
			NULL);

		hr = pAudioClient->GetService(
			__uuidof(IAudioRenderClient),
			(void**)&pRenderClient);

		maxSampleCount = pwfx->nSamplesPerSec;

		return hr;
	}
}

每臺電腦的音訊裝置所支援的取樣率和聲道數量可能有所不同,這裡我固定使用雙聲道,並且指定 AUDCLNT_STREAMFLAGS_AUTOCONVERTPCM | AUDCLNT_STREAMFLAGS_SRC_DEFAULT_QUALITY,讓系統對 Sample 進行重新取樣,以自動適配音訊裝置的取樣率。

WASAPI 的基本操作就是初始化裝置後,先 GetBuffer 獲取一個指標,然後往裡面寫入資料,再呼叫 ReleaseBuffer。但是系統給你的緩衝區是有限的,不能一下往裡面寫太多,但也不能寫入的太慢,否則會導致聲音聽起來有毛刺。

WASAPI 還提供一種回撥的方式,由系統呼叫你提供的函式指標,你在函式裡面寫入資料,好處是你只要你的取樣率設定正確了,你就不需要操心音訊的播放速度,而且不會出現毛刺現象,同時也可以作為視訊畫面的同步機制,但這裡我不用這種方式,因為他對程式結構的影響比較大。

視訊和音訊資料在檔案裡面其實是交替儲存的,並不是開頭一大段視訊,最後再儲存音訊,因為讀取檔案肯定是順序讀取的,如果視訊和音訊位置差距太遠,機械硬碟磁頭就要來回跑,也不適合流式傳輸。

DecoderParam 需要新增一些新成員:

struct DecoderParam
{
// ...
	AVCodecContext* acodecCtx;
	int audioStreamIndex;
	std::map<int, AVCodecContext*> codecMap;
	shared_ptr<nv::AudioPlayer> audioPlayer;
// ...
};

codecMap 儲存 streamIndex 和 AVCodecContext 的鍵值對,AudioPlayer 使用智慧指標就不用擔心資源釋放的問題(記得引入 map 和 memory 標頭檔案)。

重新編寫 InitDecoder

void InitDecoder(const char* filePath, DecoderParam& param) {
	AVFormatContext* fmtCtx = nullptr;
	avformat_open_input(&fmtCtx, filePath, NULL, NULL);
	avformat_find_stream_info(fmtCtx, NULL);

	AVCodecContext* vcodecCtx = nullptr;
	AVCodecContext* acodecCtx = nullptr;
	for (int i = 0; i < fmtCtx->nb_streams; i++) {
		const AVCodec* codec = avcodec_find_decoder(fmtCtx->streams[i]->codecpar->codec_id);
		if (codec->type == AVMEDIA_TYPE_VIDEO) {
			param.videoStreamIndex = i;
			param.vcodecCtx = vcodecCtx = avcodec_alloc_context3(codec);
			avcodec_parameters_to_context(vcodecCtx, fmtCtx->streams[i]->codecpar);
			avcodec_open2(vcodecCtx, codec, NULL);
			param.codecMap[i] = vcodecCtx;
		}
		if (codec->type == AVMEDIA_TYPE_AUDIO) {
			param.audioStreamIndex = i;
			param.acodecCtx = acodecCtx = avcodec_alloc_context3(codec);
			avcodec_parameters_to_context(acodecCtx, fmtCtx->streams[i]->codecpar);
			avcodec_open2(acodecCtx, codec, NULL);
			param.codecMap[i] = acodecCtx;

			// 初始化 AudioPlayer,無論如何固定使用雙聲道
			param.audioPlayer = make_shared<nv::AudioPlayer>(2, acodecCtx->sample_rate);
			param.audioPlayer->Start();
		}
	}

	// 啟用硬體解碼器
	AVBufferRef* hw_device_ctx = nullptr;
	av_hwdevice_ctx_create(&hw_device_ctx, AVHWDeviceType::AV_HWDEVICE_TYPE_D3D11VA, NULL, NULL, NULL);
	vcodecCtx->hw_device_ctx = hw_device_ctx;

	param.fmtCtx = fmtCtx;
	param.vcodecCtx = vcodecCtx;
	param.width = vcodecCtx->width;
	param.height = vcodecCtx->height;
}

這次多了音訊的解碼初始化部分,並且把視訊和音訊的 Context 正確寫入 codecMap,並初始化 AudioPlayer。這裡固定使用雙聲道,即使是單聲道音訊,我們待會也會按需處理。

RequestFrame 函式不能僅僅只返回 AVFrame,還需要直到其媒體型別,所以新增一個結構體來返回比較合適:

struct MediaFrame {
	AVMediaType type;
	AVFrame* frame;
};
MediaFrame RequestFrame(DecoderParam& param) {
	auto& fmtCtx = param.fmtCtx;

	while (1) {
		AVPacket* packet = av_packet_alloc();
		int ret = av_read_frame(fmtCtx, packet);
		if (ret == 0) {
			auto codecCtx = param.codecMap[packet->stream_index];
			ret = avcodec_send_packet(codecCtx, packet);
			if (ret == 0) {
				AVFrame* frame = av_frame_alloc();
				ret = avcodec_receive_frame(codecCtx, frame);
				if (ret == 0) {
					av_packet_unref(packet);
					return { codecCtx->codec_type, frame };
				}
				else if (ret == AVERROR(EAGAIN)) {
					av_frame_unref(frame);
				}
			}
		}
		else if (ret < 0) {
			return { AVMEDIA_TYPE_UNKNOWN };
		}

		av_packet_unref(packet);
	}

	return { AVMEDIA_TYPE_UNKNOWN };
}

codecMap 在這裡排上用場了,不同型別的 packet 要傳送給不同的解碼器。

解碼迴圈新增針對音訊的處理:

while (freqRatio < countRatio && decoderParam.playStatus == 0) {
// ...

	auto mediaFrame = RequestFrame(decoderParam);
	auto& frame = mediaFrame.frame;

	if (frame == nullptr) {
		break;
	}

	if (mediaFrame.type == AVMEDIA_TYPE_VIDEO) {
		frameCount++;
		countRatio = (double)displayCount / frameCount;

		decoderParam.currentSecond = frameCount / frameFreq;

		if (freqRatio >= countRatio) {
			UpdateVideoTexture(frame, scenceParam, decoderParam);
		}
	}
	else if (mediaFrame.type == AVMEDIA_TYPE_AUDIO) {
		// 目前只考慮 FLTP 格式
		if (frame->format == AV_SAMPLE_FMT_FLTP) {
			decoderParam.audioPlayer->WriteFLTP((float*)frame->data[0], (float*)frame->data[1], frame->nb_samples);
		}
	}

	av_frame_free(&mediaFrame.frame);
}

這樣執行就可以聽到視訊聲音了,偶爾畫面卡頓會導致聲音會出現一些毛刺,想要完全解決這個問題,就需要搞個佇列把解碼資料緩衝起來,這裡我就不搞了。

注意這裡並沒有刻意去調整音訊的播放速度,但是播放起來完全不會出現音畫不同步的現象。這是因為音訊並不會因為你一下塞很多資料他就會加快速度,再加上音視訊資料交替儲存,不存在已經解碼了很多幀視訊都沒等到一個音訊資料的情況。

WriteFLTP 我專門做了一個空指標判斷,如果 data[1] 是空指標,則直接把 data[0] 當作另外一個聲道去讀取。

最後,加一個控制音量的控制元件,先在 DecoderParam 新增一個 audioVolume 表示當前音量。

struct DecoderParam
{
// ...
	float audioVolume;
};

在 InitDecoder 設定好初始音量

void InitDecoder(const char* filePath, DecoderParam& param) {
// ...

param.audioPlayer = make_shared<nv::AudioPlayer>(2, acodecCtx->sample_rate);
param.audioPlayer->Start();
constexpr float defaultVolume = 0.5;
param.audioPlayer->SetVolume(defaultVolume);
param.audioVolume = defaultVolume;
}

在 DrawImgui 新增一個 VSliderFloat 控制元件,就是豎著的 Slider,然後還監聽滑鼠的滾輪,這樣滑鼠的滾輪也可以調整音量。

void DrawImgui(
	ID3D11Device* device, ID3D11DeviceContext* ctx, IDXGISwapChain* swapchain,
	ScenceParam& param, DecoderParam& decoderParam
) {
// ...

	// 這裡開始寫介面邏輯
	// ImGui::ShowDemoWindow();
	auto& io = ImGui::GetIO();

	// 滾輪可以調整音量
	auto& audioVolume = decoderParam.audioVolume;
	if (io.MouseWheel != 0) {
		audioVolume += io.MouseWheel * 0.05;
		if (audioVolume < 0) audioVolume = 0;
		if (audioVolume > 1) audioVolume = 1;
		decoderParam.audioPlayer->SetVolume(audioVolume);
	}
// ...

	if (isShowWidgets) {
		if (ImGui::Begin("Play")) {
			// ...
		}
		ImGui::End();

		if (ImGui::Begin("Volume")) {
			ImGui::PushItemWidth(50);
			if (ImGui::VSliderFloat("", { 18, 160 }, &decoderParam.audioVolume, 0, 1, "")) {
				decoderParam.audioPlayer->SetVolume(audioVolume);
			}
			ImGui::PopItemWidth();
		}
		ImGui::End();
	}
// ...

}

image

這裡我用的是 WASAPI 提供的音量介面,它和 Windows 的音量合成器是繫結的,缺點是沒法超過系統主音量,如果想內部有更靈活的調整,就要對音訊資料進行重新處理,這裡我就偷懶不寫了。

結尾

現在整個程式已經可以當一個正常播放器使用,不過其實還是有很多不完善的地方,相當多的邊界情況沒有處理,以及很多可以加上去的功能,比如濾鏡等等,但要分享的內容其實已經差不多了,整個程式是完全單執行緒的,除錯理解也比較方便。最後把原始碼分享出來:https://gitee.com/judgeou/native-video/tree/cnblog/

相關文章