前言:这是一个大作业,磨磨蹭蹭弄了一个多星期。嗯,果然C++是高手的语言。

实现了音乐播放, VS2017工程源码在 这里,也许可以帮到正好需要它的人

整体思路

很清晰,客户端发送 RTSP 请求到服务器,服务器就会用 RTP 发送媒体数据给客户端,客户端收到数据后保存到文件,再调用 VLC 库就可以播放音乐了。

所以,编写客户端就是要干这些事:

  • 学习一下 RTSP 协议(搜索 RFC 2326)
  • 通过 TCP Socket 依次发送 OPTIONS、DESCRIBE、SETUP、PLAY 请求(照着协议规定来拼接字符串)
  • 起线程用 UDP Socket 接收 server 发来的 RTP 数据,直接写到本地文件
  • 阅读 libvlc 文档,创建播放器实例播放指定文件

RTSP

RTSP 协议规范的请求格式参见 RFC 2326 Method definitions

封装 Winsock API

class MySocket
{
private:
	CInitSock sock;
	SOCKET socket;
public:
	MySocket()
	{
		socket = INVALID_SOCKET;
	}
	bool Create(std::string protocol)
	{
		if (protocol == "TCP")
		{
			socket = ::socket(AF_INET, SOCK_STREAM, 0);
		}
		else
		{
			socket = ::socket(AF_INET, SOCK_DGRAM, 0);
		}
		if (socket == INVALID_SOCKET) {
			return false;
		}
		return true;
	}
	bool Bind(int port)
	{
		sockaddr_in sin;
		sin.sin_family = AF_INET;
		sin.sin_port = htons(port);
		sin.sin_addr.s_addr = htonl(ADDR_ANY);
		if (::bind(socket, (sockaddr*)&sin, sizeof(sin)) == SOCKET_ERROR)
		{
			return false;
		}
		return true;
	}
	bool Connect(char *ip, int port)
	{
		sockaddr_in server_sin;
		int adddr_len = sizeof(server_sin);
		/* set media server address */
		memset((void*)&server_sin, 0, adddr_len);
		server_sin.sin_family = AF_INET;
		server_sin.sin_port = htons(port);
		server_sin.sin_addr.S_un.S_addr = inet_addr(ip);
		/* do connect */
		if (::connect(socket, (sockaddr *)&server_sin, adddr_len) != 0)
		{
			return false;
		}
		return true;
	}
	int Send(std::string str)
	{
		int iResult;
		iResult = ::send(socket, str.c_str(), str.size(), 0);
		return iResult;
	}
	int TCPRecv(char *buff, int len)
	{
		int iResult;
		iResult = ::recv(socket, buff, len, 0);
		return iResult;
	}
	int UDPRecv(char *buff, int len)
	{
		int iResult;
		/* no need to save sockaddr */
		iResult = ::recvfrom(socket, buff, len, 0, NULL, NULL);
		return iResult;
	}
	void Close()
	{
		::closesocket(socket);
		socket = INVALID_SOCKET;
	}
};

请求顺序

OPTIONS -> DESCRIBE -> SETUP -> PLAY -> TEARDOWN

发送 PLAY 之后间隔性的发送 GET_PARAMTER 请求保活(心跳机制)。

请求

客户端构造出对应字符串发送,然后用字符串操作来解析服务器返回的内容,如下是 RTSP SETUP 请求的格式,和 HTTP 差不多。SETUP 响应里会提供会话ID,之后的请求需要带着它。

# C->S
SETUP rtsp://example.com/foo/bar/baz.rm RTSP/1.0
CSeq: 302
Transport: RTP/AVP;unicast;client_port=4588-4589

# S->C
RTSP/1.0 200 OK
CSeq: 302
Date: 23 Jan 1997 15:35:06 GMT
Session: 47112344
Transport: RTP/AVP;unicast;client_port=4588-4589;server_port=6256-6257

我写了一个 RTSPReqHelper 辅助类,专门来处理所有 RTSP 请求和响应,如下是其中的 options 函数:

string RTSPReqHelper::options()
{
	// Build Request Line
	string line = "OPTIONS ";
	line.append(url);
	line.append(" RTSP/1.0\r\n");
	line.append("CSeq: ");
	line.append(to_string(seq++));
	line.append("\r\n");
	line.append("User-Agent: Cxt Media Client v0.1\r\n\r\n");
	// Send over TCP
	socket.Send(line);
	// Handle Response
	memset(recvBuff, 0, sizeof(recvBuff));
	socket.TCPRecv(recvBuff, sizeof(recvBuff));
	string s(recvBuff);
	string cseq = parseValueOf(s, "CSeq: ");
	string status = parseValueOf(s, " ");
	string options = parseValueOf(s, "Public: ");
	if (to_string(seq-1).compare(cseq) != 0 || status.compare("200 OK") != 0)
	{
		return "ERROR";
	}
	return options;
}

所以,需要编写一个发送 RTSP 请求的线程工作函数,顺序调用RTSPReqHelper 类中的请求方法,在 RTSP PLAY 请求得到 200 OK 的响应之后,再启动接收 RTP 数据的线程。这个 RTSP 请求线程会在用户点击「打开URL」按钮时触发。

RTP

了解 RTP 数据的格式是关键,可以参见 RFC 3550,找到如下请求头

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |V=2|P|X|  CC   |M|     PT      |       sequence number         |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |                           timestamp                           |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |           synchronization source (SSRC) identifier            |
   +=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+=+
   |            contributing source (CSRC) identifiers             |
   |                             ....                              |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

根据实际抓包测试,可以发现:

  • Version, Padding, Extension, CSRC, Marker 占 1 byte

  • Payload type 占 1 byte

  • Sequence number 占 2 bytes

  • Timestamp 占 4 bytes

  • SSRC 占 4 bytes

查询 RFC 3551,可以知道 Payload 类型定义表(本次作业是播放音频,对应的 PT = 4)

剩下的部分是 Payload,我需要把它们写入文件。

对于payload部分,还需要去查阅 RFC 2250,可以找到 MPEG Audio-specific header

    0                   1                   2                   3
    0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+
   |             MBZ               |          Frag_offset          |
   +-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+-+

可以看出,payload 的开始处(RTP header 的末尾)还有 4 个字节的 payload header。

因此,在保存媒体文件时,需要设置长度 16 字节的偏移量。

RTP 线程在启动后要创建一个 UDP Socket,并绑定一个本地端口号,这个端口号必须固定,因为 RTSP 的 SETUP 请求中提供了这个端口号(不然的话,server 不知道朝哪发 RTP)。

然后就是调用 recvfrom 函数接收 UDP 数据,并把 payload 写入到本地文件。

播放

照着 libvlc 的文档,就大概可以封装好一些简单的播放器 API:

class MyVlcPlayer
{
private:
	libvlc_instance_t* vlc_ins = NULL;
	libvlc_media_player_t* vlc_player = NULL;
	libvlc_media_t* vlc_media = NULL;
	libvlc_time_t total_time;

	const char *  vlc_args[3] = {
			   "-I", "dummy", /* Don't use any interface */
			   "--ignore-config" /* Don't use VLC's config */
			   };
public:
	MyVlcPlayer(){}
	~MyVlcPlayer()
	{
		libvlc_media_release(vlc_media);
		libvlc_media_player_release(vlc_player);
		libvlc_release(vlc_ins);
	}
	bool Initialize(string path)
	{
		// Create VLC instance
		vlc_ins = libvlc_new(0, NULL);
		if (vlc_ins == NULL)
		{
			return false;
		}
		vlc_player = libvlc_media_player_new(vlc_ins);
		if (vlc_player == NULL)
		{
			return false;
		}
		// Set media file path
		const char *p = path.c_str();
		vlc_media = libvlc_media_new_path(vlc_ins, p);
		if (vlc_media == NULL)
		{
			return false;
		}
		// Parse media
		libvlc_media_parse(vlc_media);
		// Get total time in ms
		total_time = libvlc_media_get_duration(vlc_media);
		// Set media to player
		libvlc_media_player_set_media(vlc_player, vlc_media);
		return true;
	}
	void Play()
	{
		libvlc_media_player_play(vlc_player);
	}
	void Stop()
	{
		libvlc_media_player_stop(vlc_player);
	}
	libvlc_time_t GetTotalTime()
	{
		return total_time;
	}
};

注意需要正确配置项目,下载 vlc 的 sdk,设置 include 包含文件夹和 lib 库文件夹,然后把 libvlc.dll 和 libvlccore.dll 和 plugins 文件夹拷贝到项目 Debug 目录下,才可以正常播放。

线程同步

使用全局变量的方式,注意需要加上 volatile 关键字。

/* Thread state */
volatile int rtspThreadState = 0;
volatile int rtpThreadState = 0;
volatile int playerThreadState = 0;

线程同步的目的是保证 RTSP PLAY 收到 200 OK 响应之后才开始接收 RTP,然后收到第一个 RTP 数据并且已经完成文件创建以及第一次写入之后,才开始播放媒体文件。

相较于 WaitForSingleObject 的方式,我这次使用循环来等待线程状态,比如播放器线程:

UINT MyPlayFunction(LPVOID p)
{
	for (;;) if (playerThreadState) break;

	CString* ps = new CString("[INFO] Start playing...\r\n");
	PostMessage(AfxGetMainWnd()->GetSafeHwnd(), WM_UPDATE_MSG, NULL, (LPARAM)ps);

	CMediaClientDlg* dlg = (CMediaClientDlg*)p;

	MyVlcPlayer player;
	player.Initialize(dlg->m_filename);
	player.Play();

	for (;;)
	{
		// Terminate thread condition
		if (!playerThreadState)
		{
			AfxEndThread(0);
		}
	}
	return 0;
}