Featured image of post 用 C++ MFC 实现 RTSP 客户端

用 C++ MFC 实现 RTSP 客户端

使用 MFC 开发 Live555 Server 的 GUI 客户端程序,实现流媒体音乐播放功能

VS2017工程源码:shawnsky/Live555MediaClient,也许可以帮到正好需要它的人

思路

思路很清晰,客户端发送 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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
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 函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
string RTSPReqHelper::options()
{
  // Build Request
  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 写入到本地文件。

调用 VLC 实现音乐播放

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
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 关键字。

1
2
3
4
/* Thread state */
volatile int rtspThreadState = 0;
volatile int rtpThreadState = 0;
volatile int playerThreadState = 0;

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

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

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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;
}
Licensed under CC BY-NC-SA 4.0
comments powered by Disqus
Site built with Hugo, hosted by Firebase.
Theme Stack designed by Jimmy.