【C++】从零开始,只使用FFmpeg,Win32 API,实现一个播放器(三)

前情提要

前篇: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 消息,判断按键是回车键就切换全屏。全屏的调用方式不复杂,改下窗口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/

posted @ 2021-05-12 15:03  最后的绅士  阅读(459)  评论(3编辑  收藏  举报