FFmpeg學習5:多線程播放視音頻


在前面的學習中,視頻和音頻的播放是分開進行的。這主要是為了學習的方便,經過一段時間的學習,對FFmpeg的也有了一定的了解,本文就介紹了
如何使用多線程同時播放音頻和視頻(未實現同步),並對前面的學習的代碼進行了重構,便於后面的擴展。
本文主要有以下幾個方面的內容:

  • 多線程播放視音頻的整體流程
  • 多線程隊列
  • 音頻播放
  • 視頻播放
  • 總結以及后續的計划

1. 整體流程

FFmpeg和SDL的初始化過程這里不再贅述。整個流程如下:

  • 對於一個打開的視頻文件(也就是取得其AVFormatContext),創建一個分離線程,不斷的從stream中讀取Packet,並按照其stream index,將Packet分別存放到Audio Packet QueueVideo Packet這兩個隊列緩存中。
  • 音頻播放線程。創建一個回調函數,從Audio Packet Queue中取出Packet並解碼,將解碼的數據發送到SDL Audio Device中進行播放
  • 視頻播放線程。
    • 創建Video解碼線程,從Video Packet Queue中取出Packet進行解碼,並將解碼后的數據放入到 Video Frame Queue隊列緩存中。
    • 進入到SDL Window 事件循環中,按照一定的速度從 Video Frame Queue中取出Frame,並轉換為相應的格式,然后在SDL Screen上顯示

其整個流程中如下圖:

1.1 重構后的main函數

在前面的學習過程中,主要是跟着dranger tutorial。由於該教程是基於C語言的,在其使用多線程播放音視頻的教程中,代碼使用不是很方便。在本文中,使用C++對其代碼進行了重構封裝。
封裝后的main函數如下:

	av_register_all();

	SDL_Init(SDL_INIT_VIDEO | SDL_INIT_AUDIO | SDL_INIT_TIMER);

	char* filename = "F:\\test.rmvb";
	MediaState media(filename);

	if (media.openInput())
		SDL_CreateThread(decode_thread, "", &media); // 創建解碼線程,讀取packet到隊列中緩存

	media.audio->audio_play(); // create audio thread
	media.video->video_play(); // create video thread

	SDL_Event event;
	while (true) // SDL event loop
	{
		SDL_WaitEvent(&event);
		switch (event.type)
		{
		case FF_QUIT_EVENT:
		case SDL_QUIT:
			quit = 1;
			SDL_Quit();

			return 0;
			break;

		case FF_REFRESH_EVENT:
			video_refresh_timer(media.video);
			break;

		default:
			break;
		}
	}

主函數的主要分為三個部分:

  • 初始化FFmpeg和SDL
  • 創建Audio播放線程和Video播放線程
  • SDL事件循環,顯示圖像。

1.2 使用到的數據結構

將播放過程中需要使用到的主要數據封裝為三個結構:

  • MediaState 主要包含了AudioStateVideoState指針,以及AVFormatContext
  • AudioState 播放音頻所需要的數據
  • VideoState 播放視頻所需要的數據

這里主要介紹下MediaState,在后面播放音頻和視頻時再介紹與其相關的數據結構。
MediaState的聲明如下:

struct MediaState
{
	AudioState *audio;
	VideoState *video;
	AVFormatContext *pFormatCtx;

	char* filename;
	//bool quit;
	MediaState(char *filename);
	~MediaState();

	bool openInput();
};

結構比較簡單,其主要的功能是在oepnInput中,該函數用來打開相應的video文件,並讀取相應的信息填充到VideoStateAudioState結構中。
主要有以下幾個功能:

  • 調用avformat_open_input獲取AVFormatContext的指針
  • 找到audio stream的index,並打開相應的AVCodecContext
  • 找到video stream的index,並打開相應的AVCodecContext

1.3 Packet分離線程

調用oepnInput后,以獲取到足夠的信息,然后創建packet分離線程,按照得到的stream index,將av_read_frame讀取到的packet分別放到相應的packet 緩存隊列中。
部分代碼如下:

if (packet->stream_index == media->audio->audio_stream) // audio stream
{
    media->audio->audioq.enQueue(packet);
    av_packet_unref(packet);
}		

else if (packet->stream_index == media->video->video_stream) // video stream
{
    media->video->videoq->enQueue(packet);
    av_packet_unref(packet);
}		
else
    av_packet_unref(packet);

2.多線程隊列

分離線程將讀取到的Packet分別存放到視頻和音頻的packet隊列中,這個Packet隊列會被多個線程訪問,分離線程向里面填充Packet;視頻和音頻播放線程取出隊列中的packet
進行解碼然后播放。PacketQueue的聲明如下:

struct PacketQueue
{
	std::queue<AVPacket> queue;

	Uint32    nb_packets;
	Uint32    size;
	SDL_mutex *mutex;
	SDL_cond  *cond;

	PacketQueue();
	bool enQueue(const AVPacket *packet);
	bool deQueue(AVPacket *packet, bool block);
};

使用標准庫中的std::queue作為存放數據的容器,SDL_mutexSDL_cond是SDL庫中提供的互斥量和條件變量用來控制隊列的線程的同步。
當要訪問隊列中的元素時,使用SDL_mutex來鎖定隊列;當隊列中沒有Packet時,而此時又有視頻或者音頻線程取隊列中的Packet,就需要設置一個
設置SDL_cond信號量等待新的Packet入隊列。

  • 入隊列的方法實現如下:
bool PacketQueue::enQueue(const AVPacket *packet)
{
	AVPacket *pkt = av_packet_alloc();
	if (av_packet_ref(pkt, packet) < 0)
		return false;

	SDL_LockMutex(mutex);
	queue.push(*pkt);

	size += pkt->size;
	nb_packets++;

	SDL_CondSignal(cond);
	SDL_UnlockMutex(mutex);
	return true;
}

注意對入隊列的Packet調用av_packet_ref增加引用計數的方法來復制Packet中的數據。在將新的packet入隊以后,設置信號量通知有新的packet入隊列,並
解除對packet隊列的鎖定。

  • 出隊的方法實現如下:
bool PacketQueue::deQueue(AVPacket *packet, bool block)
{
	bool ret = false;

	SDL_LockMutex(mutex);
	while (true)
	{
		if (quit)
		{
			ret = false;
			break;
		}

		if (!queue.empty())
		{
			if (av_packet_ref(packet, &queue.front()) < 0)
			{
				ret = false;
				break;
			}
			//av_packet_free(&queue.front());
			AVPacket pkt = queue.front();

			queue.pop();
			av_packet_unref(&pkt);
			nb_packets--;
			size -= packet->size;

			ret = true;
			break;
		}
		else if (!block)
		{
			ret = false;
			break;
		}
		else
		{
			SDL_CondWait(cond, mutex);
		}
	}
	SDL_UnlockMutex(mutex);
	return ret;
}

參數block標識在隊列為空的時候是否阻塞等待,當設置為true的時候,取packet的線程會阻塞等待,直到得到cond信號量的通知。另外,在
取出packet后要調用av_packet_unref減少packet數據的引用計數。

3. 音頻播放

音頻的播放在前面已經做個總結FFmpeg學習3:播放音頻,其播放過程主要是設置好向音頻設備發送數據的回調函數,這里就不再詳述。和以前不同的是對播放數據進行了封裝,如下:

struct AudioState
{
	const uint32_t BUFFER_SIZE;// 緩沖區的大小

	PacketQueue audioq;

	uint8_t *audio_buff;       // 解碼后數據的緩沖空間
	uint32_t audio_buff_size;  // buffer中的字節數
	uint32_t audio_buff_index; // buffer中未發送數據的index
	
	int audio_stream;          // audio流index
	AVCodecContext *audio_ctx; // 已經調用avcodec_open2打開

	AudioState();              //默認構造函數
	AudioState(AVCodecContext *audio_ctx, int audio_stream);
	
	~AudioState();

	/**
	* audio play
	*/
	bool audio_play();
};
  • audioq是存放audio packet的隊列;
  • audio_stream是audio stream的index

另外幾個字段是用來緩存解碼后的數據的,回調函數從該緩沖區中取出數據發送到音頻設備。

  • audio_buff 緩沖區的指針
  • audio_buff_size 緩沖區中數據的多少
  • audio_buff_index 緩沖區中已經發送數據的指針
  • BUFFER_SIZE 緩沖區的最大容量

函數audio_play用來設置播放所需的參數,並啟動音頻播放線程

bool AudioState::audio_play()
{
	SDL_AudioSpec desired;
	desired.freq = audio_ctx->sample_rate;
	desired.channels = audio_ctx->channels;
	desired.format = AUDIO_S16SYS;
	desired.samples = 1024;
	desired.silence = 0;
	desired.userdata = this;
	desired.callback = audio_callback;

	if (SDL_OpenAudio(&desired, nullptr) < 0)
	{
		return false;
	}

	SDL_PauseAudio(0); // playing

	return true;
}

4. 視頻播放

4.1 VideoState

和音頻播放類似,也封裝了一個VideoState保存視頻播放時所需的數據

struct VideoState
{
	PacketQueue* videoq;        // 保存的video packet的隊列緩存

	int video_stream;          // index of video stream
	AVCodecContext *video_ctx; // have already be opened by avcodec_open2

	FrameQueue frameq;         // 保存解碼后的原始幀數據
	AVFrame *frame;
	AVFrame *displayFrame;

	SDL_Window *window;
	SDL_Renderer *renderer;
	SDL_Texture *bmp;
	SDL_Rect rect;

	void video_play();
	
	VideoState();

	~VideoState();
};

VideoState中的字段大體上可以分為三類:

  • 視頻解碼需要的數據 packet隊列、stream的index以及AVCodecContext
  • 將解碼后的中間數據
    • FrameQueue Frame隊列,存放從packet中解碼得到的Frame。要刷新新的幀時,就從該隊列中取出Frame,進行格式轉換后render到界面上。
    • frame 格式轉換時中間變量
    • displayFrame 格式轉換后的fram,給fram中的數據是最終呈現到界面上的幀
  • SDL播放視頻需要的數據

FrameQueue的實現和PacketQueue的實現類似,不再贅述。

4.2 Video的decode和play

VideoState中函數video_play用來進行video播放的初始化工作,並開啟video的解碼線程

void VideoState::video_play()
{
	int width = 800;
	int height = 600;
	// 創建sdl窗口
	window = SDL_CreateWindow("FFmpeg Decode", SDL_WINDOWPOS_UNDEFINED, SDL_WINDOWPOS_UNDEFINED,
		width, height, SDL_WINDOW_OPENGL);
	renderer = SDL_CreateRenderer(window, -1, 0);
	bmp = SDL_CreateTexture(renderer, SDL_PIXELFORMAT_YV12, SDL_TEXTUREACCESS_STREAMING,
		width, height);

	rect.x = 0;
	rect.y = 0;
	rect.w = width;
	rect.h = height;

	frame = av_frame_alloc();
	displayFrame = av_frame_alloc();

	displayFrame->format = AV_PIX_FMT_YUV420P;
	displayFrame->width = width;
	displayFrame->height = height;

	int numBytes = avpicture_get_size((AVPixelFormat)displayFrame->format,displayFrame->width, displayFrame->height);
	uint8_t *buffer = (uint8_t*)av_malloc(numBytes * sizeof(uint8_t));

	avpicture_fill((AVPicture*)displayFrame, buffer, (AVPixelFormat)displayFrame->format, displayFrame->width, displayFrame->height);

	SDL_CreateThread(decode, "", this);

	schedule_refresh(this, 40); // start display
}

首先創建SDL窗口的一些變量,並根據相應的格式為displayFrame分配數據空間;接着創建video的解碼線程;最后一句schedule_refresh(this, 40)是開始SDL的事件循環,並在窗口上不斷的刷新幀。
video的解碼線程函數如下:

int  decode(void *arg)
{
	VideoState *video = (VideoState*)arg;

	AVFrame *frame = av_frame_alloc();

	AVPacket packet;


	while (true)
	{
		video->videoq->deQueue(&packet, true);

		int ret = avcodec_send_packet(video->video_ctx, &packet);
		if (ret < 0 && ret != AVERROR(EAGAIN) && ret != AVERROR_EOF)
			continue;

		ret = avcodec_receive_frame(video->video_ctx, frame);
		if (ret < 0 && ret != AVERROR_EOF)
			continue;

		if (video->frameq.nb_frames >= FrameQueue::capacity)
			SDL_Delay(500);

		video->frameq.enQueue(frame);

		av_frame_unref(frame);
	}


	av_frame_free(&frame);

	return 0;
}

該函數較簡單,就是不斷從packet隊列中取出packet,然后進行解碼,將解碼得到的frame隊列中,供display線程使用,最終呈現到界面上。注意的是,這里給frame隊列設置一個最大容量,當frame隊列已滿的時候,就阻塞解碼線程,等待display線程播放一段時間。

4.3 display線程

幀的呈現借助了SDL庫,所以display線程實際就是SDL的窗口時間循環。視頻幀的顯示過程如下圖:

video_play函數中,啟動視頻的解碼線程后,就調用了schedule_refresh函數來開始幀的顯示線程。

// 延遲delay ms后刷新video幀
void schedule_refresh(VideoState *video, int delay)
{
	SDL_AddTimer(delay, sdl_refresh_timer_cb, video);
}

uint32_t sdl_refresh_timer_cb(uint32_t interval, void *opaque)
{
	SDL_Event event;
	event.type = FF_REFRESH_EVENT;
	event.user.data1 = opaque;
	SDL_PushEvent(&event);
	return 0; /* 0 means stop timer */
}

schedule_refresh設置一個延遲時間,然后調用sdl_refresh_timer_cb函數。sdl_refresh_timer_cb是向SDL的事件循環
發送一個FF_REFRESH_EVENT事件。從前面的事件處理中可知,在接收到FF_REFRESH_EVENT事件后,會調用video_refresh_timer
該函數會從frame隊列中取出每一個frame,做了格式轉換后呈現到界面上。

void video_refresh_timer(void *userdata)
{
	VideoState *video = (VideoState*)userdata;

	if (video->video_stream >= 0)
	{
		if (video->videoq->queue.empty())
			schedule_refresh(video, 1);
		else
		{
			/* Now, normally here goes a ton of code
			about timing, etc. we're just going to
			guess at a delay for now. You can
			increase and decrease this value and hard code
			the timing - but I don't suggest that ;)
			We'll learn how to do it for real later.
			*/
			schedule_refresh(video, 40);

			video->frameq.deQueue(&video->frame);

			SwsContext *sws_ctx = sws_getContext(video->video_ctx->width, video->video_ctx->height, video->video_ctx->pix_fmt,
			video->displayFrame->width,video->displayFrame->height,(AVPixelFormat)video->displayFrame->format, SWS_BILINEAR, nullptr, nullptr, nullptr);

			sws_scale(sws_ctx, (uint8_t const * const *)video->frame->data, video->frame->linesize, 0, 
				video->video_ctx->height, video->displayFrame->data, video->displayFrame->linesize);

			// Display the image to screen
			SDL_UpdateTexture(video->bmp, &(video->rect), video->displayFrame->data[0], video->displayFrame->linesize[0]);
			SDL_RenderClear(video->renderer);
			SDL_RenderCopy(video->renderer, video->bmp, &video->rect, &video->rect);
			SDL_RenderPresent(video->renderer);

			sws_freeContext(sws_ctx);
			av_frame_unref(video->frame);
		}
	}
	else
	{
		schedule_refresh(video, 100);
	}
}

該函數的實現也挺清晰的,不斷的從frame隊列中取出frame,創建SwsContext按照VideoState中設置的參數對frame進行格式轉換。這里要提一個血淚教訓,在使用完SwsContext后一定要記得調用sws_freeContext釋放。在寫好本文的demo后,播放視頻的發現
其占用的內存一直在增長,不用說肯定是內存泄漏了呀。我是着重對幾個緩存隊列進行檢測,沒有發現問題。最后實在沒有辦法,一段一段代碼的進行檢查,最終發現是使用完了SwsContext沒有釋放掉。起初時候,我就認為SwsContext只是設置一個轉換參數,也沒在意,誰知道會占用那么大的空間,播放一個視頻內存的占用一度達到一個G,這只是播放了十幾分鍾。

Summary

從上一篇總結到現在,磨蹭了將近半個月終於算是把這個多線程播放弄完了,從中真是學到了不少東西。
從畢業到現在進公司快3個月了,基本是打醬油的三個月,公司的代碼都沒有看到過,整天對着電腦屏幕沒有事情可做。
后面的一些計划吧,督促下自己不能這么懶散

  • 實現視音頻的同步
  • 改用C++11的多線程庫
  • 再對代碼進行下重構,可以使用不同的UI庫進行渲染(打算換Qt試試)

本文的代碼 FSplayer


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM