技术背景
上篇blog,我们提到了Android平台GB28181历史视音频文件检索规范探讨及技术实现,文件检索后,GB28181平台侧,可以针对文件列表进行回放或下载操作,本文主要探讨视音频文件下载相关。
规范解读
视音频文件下载基本要求
SIP 服务器接收到媒体接收者发送的视音频文件下载请求后向媒体流发送者发送媒体文件下载命令,媒体流发送者采用RTP将视频流传输给媒体流接收者,媒体流接收者直接将视频流保存为媒体文件。
媒体流接收者可以是用户客户端或联网系统,媒体流发送者可以是媒体设备或联网系统。媒体流接收者或 SIP 服务器可通过配置查询等方式获取媒体流发送者支持的下载发送倍速,并在请求的 SDP 消息体中携带指定下载倍速。
媒体流发送者可在 Invite 请求对应的 200 0K 响应 SDP 消息体中扩展携带下载文件的大小参数,以便于媒体流接收者计算下载进度,当媒体流发送者不能提供文件大小参数时,媒体流接收者应支持根据码流中取得的时间计算下载进度。视音频文件下载宜支持媒体流保活机制。
命令流程
其中,信令 1,8,9、10,11,12 为 SIP 服务器接收到客户端的呼叫请求后通过 B2BUA 代理方式建立媒体流接受者与媒体服务器之间的媒体链接信令过程。
信令 2~7 为 SIP 服务器通过三方呼叫控制建立媒体服务器与媒体流之间的媒体链接信令过程。
信令 13~16 为媒体流发送者回放下载到文件结束向媒体接收者发送下载完成的通知消息过程。
信令 17~20 为媒体流接收者断开与媒体服务器之间的媒体链接信令过程。
信令 21~24 为 SIP 服务器断开媒体服务器与媒体流发送者之间的媒体链接信令过程。
命令流程描述如下:
- 媒体流接收者向 SIP 服务器发送Invite 消息,消息头域中携带 Subject 字段,表明点播的视频源 ID、发送方媒体流序列号、媒体流接收者 ID、接收端媒体流序列号标识等参数,SDP 消息体中s字段为“Download”代表文件下载,u字段代表下载通道 ID 和下载类型,字段代表下载时间段,可扩展 a 字段携带下载倍速参数,规定此次下载设备发流倍速,若不携带默认为1 倍速。
- SIP 服务器收到 Invite 请求后,通过三方呼叫控制建立媒体服务器和媒体流发送者之间的媒体连接。向媒体服务器发送 Invite 消息,此消息不携带 SDP 消息体。
- 媒体服务器收到 SIP 服务器的 Invite 请求后,回复 200 0K 响应,携带 SDP 消息体,消息体中描述了媒体服务器接收媒体流的 IP端口、媒体格式等内容。
- SIP 服务器收到媒体服务器返回的 200 OK响应后,向媒体流发送者发送 Invite请求,请求中携带消息 3 中媒体服务器回复的 200 OK响应消息体。s字段为“Download”代表文件下载,u字段代表下载通道 ID 和下载类型,t字段代表下载时间段,增加y字段描述 SSRC 值,f字段描述媒体参数,可扩展 a 字段携带下载倍速,将倍速参数传递给设备。
- 媒体流发送者收到 SIP 服务器的 Invite 请求后,回复 200 OK响应,携带 SDP消息体,消息体中描述了媒体流发送者发送媒体流的IP、端口、媒体格式、SSRC 字段等内容,可扩展 a 字段携带文件大小参数。
- SIP 服务器收到媒体流发送者返回的 200 OK响应后,向媒体服务器发送 ACK 请求,请求中携带消息 5 中媒体流发送者回复的 200 OK响应消息体,完成与媒体服务器的 Invite 会话建立过程。
- SIP 服务器收到媒体流发送者返回的 200 OK响应后,向媒体流发送者发送 ACK 请求,请求中不携带消息体,完成与媒体流发送者的 Invite 会话建立过程。
- 完成三方呼叫控制后,SIP 服务器通过 B2BUA 代理方式建立媒体流接收者和媒体服务器之间的媒体连接。在消息 1 中增加 SSRC 值,转发给媒体服务器。
- 媒体服务器收到 Invite 请求,回复 200 OK响应,携带 SDP 消息体,消息体中描述了媒体服务器发送媒体流的IP、端口、媒体格式、SSRC 值等内容。
- SIP 服务器将消息 9 转发给媒体流接收者,可扩展 a 字段携带文件大小参数。
- 媒体流接收者收到 200 OK响应后,回复 ACK 消息,完成与 SIP 服务器的 Invite 会话建立过程。
- SIP 服务器将消息 11 转发给媒体服务器,完成与媒体服务器的 Invite 会话建立过程。
- 媒体流发送者在文件下载结束后发送会话内 Message 消息。
- SIP 服务器收到消息 17 后转发给媒体流接收者。
- 媒体流接收者收到消息 18 后回复 200 OK响应,进行链路断开过程。
- SIP 服务器将消息 19 转发给媒体流发送者。
- 媒体流接收者向 SIP 服务器发送 BYE 消息,断开消息1、10、11建立的同媒体流接收者的Invite 会话。
- SIP服务器收到 BYE消息后回复200OK 响应,会话断开。
- SIP 服务器收到 BYE 消息后向媒体服务器发送 BYE 消息,断开消息 8,9,12 建立的同媒体服务器的 Invite 会话。
- 媒体服务器收到 BYE 消息后回复 200 OK 响应,会话断开。
- SIP 服务器向媒体服务器发送 BYE 消息,断开消息 2,3,6 建立的同媒体服务器的 Invite 会话。
- 媒体服务器收到 BYE 消息后回复 200 OK响应,会话断开。
- SIP 服务器向媒体流发送者发送 BYE 消息,断开消息 4,5,7 建立的同媒体流发送者的Invite 会话。
- 媒体流发送者收到 BYE 消息后回复 200 OK响应,会话断开。
技术实现
本文以大牛直播SDK开发的Android平台GB28181设备接入侧视音频历史文件检索和下载为例(本文侧重于下载),介绍下相关设计思路:
Android设备接入端收到国标平台侧发过来的INVITE SDP:
v=0o=3402000000138000000100 IN IP4 192.168.2.154 s=Download u=34020000001380000001:0 c=IN IP4 192.168.2.154 t=16937964261693796703m=video 30002 RTP/AVP 969798a=recvonly a=rtpmap:96 PS/90000 a=rtpmap:97 MPEG4/90000 a=rtpmap:98 H264/90000 a=downloadspeed:4 y=1200000001
上述SDP里面,s=Download表示系下载,a=downloadspeed:4 表示4倍速下载,SSRC是:1200000001(SSRC第1位为历史或实时媒体流的标识位,其中0为实时视音频,1为历史视音频)。
Android设备接入端回复:
v=0o=3402000001131000003900 IN IP4 192.168.2.212 s=Download c=IN IP4 192.168.2.212 t=00m=video 36576 RTP/AVP 96a=rtpmap:96 PS/90000 a=filesize:15611511 a=sendonly y=1200000001
a=filesize:15611511表示录像文件大小是15611511Byte,携带文件大小参数, 便于媒体流接收者计算下载进度(a=filesize是整个媒体容器的大小和实际发送的音视频帧总字节数有一定差异)。
国标平台侧发Ack后,开始下载视音频数据,下载过程中,可以通过SIP-INFO消息和MANSRTSP协议调节下载倍速:
PLAY RTSP/1.0 CSeq: 31129Scale: 0.25
Android GB28181设备接入侧发送完音视频帧后,发送通知事件类型"121", 表示历史媒体文件发送结束,发送会话内Message消息如下:
<?xml version="1.0"encoding="GB2312"?> <Notify> <CmdType>MediaStatus</CmdType> <SN>213466963</SN> <DeviceID>34020000001380000001</DeviceID> <NotifyType>121</NotifyType> </Notify>
接口设计
信令接口设计:
/*** Author: daniusdk.com*/packagecom.gb.ntsignalling; publicinterfaceGBSIPAgent { voidaddDownloadListener(GBSIPAgentDownloadListenerdownloadListener); voidremoveDownloadListener(GBSIPAgentDownloadListenerremoveListener); /**响应Invite Download 200 OK*/booleanrespondDownloadInviteOK(longid, StringdeviceId, StringstartTime, StringstopTime, MediaSessionDescriptionlocalMediaDescription); /**响应Invite Download 其他状态码*/booleanrespondDownloadInvite(intstatusCode, longid, StringdeviceId, StringstartTime, StringstopTime); /** 媒体流发送者在文件下载结束后发Message消息通知SIP服务器回文件已发送完成* notifyType 必须是"121“*/booleannotifyDownloadMediaStatus(longid, StringdeviceId, StringstartTime, StringstopTime, StringnotifyType); /**终止Download会话*/voidterminateDownload(longid, StringdeviceId, StringstartTime, StringstopTime, booleanisSendBYE); /**终止所有Download会话*/voidterminateAllDownloads(booleanisSendBYE); }
历史视音频下载listener设计:
/*** Author: daniusdk.com*/packagecom.gb.ntsignalling; publicinterfaceGBSIPAgentDownloadListener { /**收到s=Download的文件下载Invite*/voidntsOnInviteDownload(longid, StringdeviceId, SessionDescriptionsessionDescription); /**发送Download invite response 异常*/voidntsOnDownloadInviteResponseException(longid, StringdeviceId, StringstartTime, StringstopTime, intstatusCode, StringerrorInfo); /** 收到CANCEL Download INVITE请求*/voidntsOnCancelDownload(longid, StringdeviceId, StringstartTime, StringstopTime); /** 收到Ack*/voidntsOnAckDownload(longid, StringdeviceId, StringstartTime, StringstopTime); /** 更改下载速度*/voidntsOnDownloadMANSRTSPScaleCommand(longid, StringdeviceId, StringstartTime, StringstopTime, doublescale); /** 收到Bye*/voidntsOnByeDownload(longid, StringdeviceId, StringstartTime, StringstopTime); /** 不是在收到BYE Message情况下, 终止Download*/voidntsOnTerminateDownload(longid, StringdeviceId, StringstartTime, StringstopTime); /** Download会话对应的对话终止, 一般不会触发这个回调,目前只有在响应了200K, 但在64*T1时间后还没收到ACK,才可能会出发收到这个, 请做相关清理处理*/voidntsOnDownloadDialogTerminated(longid, StringdeviceId, StringstartTime, StringstopTime); }
底层jni接口设计:
/*** SmartPublisherJniV2.java* Author: daniusdk.com*/packagecom.daniulive.smartpublisher; publicclassSmartPublisherJniV2 { /*** Open publisher(启动推送实例)** @param ctx: get by this.getApplicationContext()* * @param audio_opt:* if 0: 不推送音频* if 1: 推送编码前音频(PCM)* if 2: 推送编码后音频(aac/pcma/pcmu/speex).* * @param video_opt:* if 0: 不推送视频* if 1: 推送编码前视频(NV12/I420/RGBA8888等格式)* if 2: 推送编码后视频(AVC/HEVC)* if 3: 层叠加模式** <pre>This function must be called firstly.</pre>** @return the handle of publisher instance*/publicnativelongSmartPublisherOpen(Objectctx, intaudio_opt, intvideo_opt, intwidth, intheight); /*** 设置流类型* @param type: 0:表示 live 流, 1:表示 on-demand 流, SDK默认为0(live流)* 注意: 流类型设置当前仅对GB28181媒体流有效* @return {0} if successful*/publicnativeintSetStreamType(longhandle, inttype); /*** 投递视频 on demand包, 当前只用于GB28181推送, 注意ByteBuffer对象必须是DirectBuffer** @param codec_id: 编码id, 当前支持H264和H265, 1:H264, 2:H265** @param packet: 视频数据, 包格式请参考H264/H265 Annex B Byte stream format, 例如:* 0x00000001 nal_unit 0x00000001 ...* H264 IDR: 0x00000001 sps 0x00000001 pps 0x00000001 IDR_nal_unit .... 或 0x00000001 IDR_nal_unit ....* H265 IDR: 0x00000001 vps 0x00000001 sps 0x00000001 pps 0x00000001 IDR_nal_unit .... 或 0x00000001 IDR_nal_unit ....** @param offset: 偏移量* @param size: packet size* @param pts_us: 时间戳, 单位微秒* @param is_pts_discontinuity: 是否时间戳间断,0:未间断,1:间断* @param is_key: 是否是关键帧, 0:非关键帧, 1:关键帧* @param codec_specific_data: 可选参数,可传null, 对于H264关键帧包, 如果packet不含sps和pps, 可传0x00000001 sps 0x00000001 pps* ,对于H265关键帧包, 如果packet不含vps,sps和pps, 可传0x00000001 vps 0x00000001 sps 0x00000001 pps* @param codec_specific_data_size: codec_specific_data size* @param width: 图像宽, 可传0* @param height: 图像高, 可传0** @return {0} if successful*/publicnativeintPostVideoOnDemandPacketByteBuffer(longhandle, intcodec_id, ByteBufferpacket, intoffset, intsize, longpts_us, intis_pts_discontinuity, intis_key, byte[] codec_specific_data, intcodec_specific_data_size, intwidth, intheight); /*** 投递音频on demand包, 当前只用于GB28181推送, 注意ByteBuffer对象必须是DirectBuffer** @param codec_id: 编码id, 当前支持PCMA和AAC, 65536:PCMA, 65538:AAC* @param packet: 音频数据* @param offset:packet偏移量* @param size: packet size* @param pts_us: 时间戳, 单位微秒* @param is_pts_discontinuity: 是否时间戳间断,0:未间断,1:间断* @param codec_specific_data: 如果是AAC的话,需要传 Audio Specific Configuration* @param codec_specific_data_size: codec_specific_data size* @param sample_rate: 采样率* @param channels: 通道数** @return {0} if successful*/publicnativeintPostAudioOnDemandPacketByteBuffer(longhandle, intcodec_id, ByteBufferpacket, intoffset, intsize, longpts_us, intis_pts_discontinuity, byte[] codec_specific_data, intcodec_specific_data_size, intsample_rate, intchannels); /*** 启动 GB28181 媒体流** @return {0} if successful*/publicnativeintStartGB28181MediaStream(longhandle); /*** 停止 GB28181 媒体流** @return {0} if successful*/publicnativeintStopGB28181MediaStream(longhandle); /*** 关闭推送实例,结束时必须调用close接口释放资源** @return {0} if successful*/publicnativeintSmartPublisherClose(longhandle); }
上次处理逻辑
RecordDownloadListenerImpl实现如下:
/*** RecordDownloadListenerImpl.java* Author: daniusdk.com*/packagecom.daniulive.smartpublisher; publicclassRecordDownloadListenerImplimplementscom.gb.ntsignalling.GBSIPAgentDownloadListener { /**收到s=Download的文件下载Invite*/publicvoidntsOnInviteDownload(longid, StringdeviceId, SessionDescriptionsdp) { if (!post_task(newOnInviteTask(this.context_, this.is_exit_, this.senders_map_, deviceId, sdp, id))) { Log.e(TAG, "ntsOnInviteDownload post_task failed, "+RecordSender.make_print_tuple(id, deviceId, sdp.getTime().getStartTime(), sdp.getTime().getStopTime())); // 这里不发488, 等待事务超时也可以的GBSIPAgentagent=this.context_.get_agent(); if (agent!=null) agent.respondDownloadInvite(488, id, deviceId, sdp.getTime().getStartTime(), sdp.getTime().getStopTime()); } } /** 收到CANCEL Download INVITE请求*/publicvoidntsOnCancelDownload(longid, StringdeviceId, StringstartTime, StringstopTime) { Log.i(TAG, "ntsOnCancelDownload, "+RecordSender.make_print_tuple(id, deviceId, startTime, stopTime)); RecordSendersender=senders_map_.remove(id); if (null==sender) return; StopDisposeTasktask=newStopDisposeTask(sender); if (!post_task(task)) task.run(); } /** 收到Ack*/publicvoidntsOnAckDownload(longid, StringdeviceId, StringstartTime, StringstopTime) { Log.i(TAG, "ntsOnAckDownload, "+RecordSender.make_print_tuple(id, deviceId, startTime, stopTime)); RecordSendersender=senders_map_.get(id); if (null==sender) { Log.e(TAG, "ntsOnAckDownload get sender is null, "+RecordSender.make_print_tuple(id, deviceId, startTime, stopTime)); GBSIPAgentagent=this.context_.get_agent(); if (agent!=null) agent.terminateDownload(id, deviceId, startTime, stopTime, false); return; } StartTasktask=newStartTask(sender, this.senders_map_); if (!post_task(task)) task.run(); } /** 收到Bye*/publicvoidntsOnByeDownload(longid, StringdeviceId, StringstartTime, StringstopTime) { Log.i(TAG, "ntsOnByeDownload, "+RecordSender.make_print_tuple(id, deviceId, startTime, stopTime)); RecordSendersender=this.senders_map_.remove(id); if (null==sender) return; StopDisposeTasktask=newStopDisposeTask(sender); if (!post_task(task)) task.run(); } /** 更改下载速度*/publicvoidntsOnDownloadMANSRTSPScaleCommand(longid, StringdeviceId, StringstartTime, StringstopTime, doublescale) { if (scale<0.01) { Log.e(TAG, "ntsOnDownloadMANSRTSPScaleCommand invalid scale:"+scale+" "+RecordSender.make_print_tuple(id, deviceId, startTime, stopTime)); return; } RecordSendersender=this.senders_map_.get(id); if (null==sender) { Log.e(TAG, "ntsOnDownloadMANSRTSPScaleCommand can not get sender, scale:"+scale+" "+RecordSender.make_print_tuple(id, deviceId, startTime, stopTime)); return; } sender.set_speed(scale); Log.i(TAG, "ntsOnDownloadMANSRTSPScaleCommand, scale:"+scale+" "+RecordSender.make_print_tuple(id, deviceId, startTime, stopTime)); } }
文件发送相关处理代码如下:
/*** RecordSender.java* Author: daniusdk.com*/packagecom.daniulive.smartpublisher; publicclassRecordSender { publicvoidset_speed(doublespeed) { intpercent_speed= (int)(speed*100); this.percent_speed_.set(percent_speed); } publicvoidset_file_description(RecordFileDescriptiondesc) { this.file_description_=desc; } publicstaticStringmake_print_tuple(longid, Stringdevice_id, Stringstart_time, Stringstop_time) { StringBuildersb=newStringBuilder(96); sb.append("[id:").append(id); sb.append(", device:"+device_id); sb.append(", t=").append(start_time).append(" ").append(start_time); sb.append("]"); returnsb.toString(); } publicbooleanstart() { SendThreadcurrent_thread=thread_.get(); if (current_thread!=null) { if (current_thread.is_exit()) { Log.e(TAG, "start, the thread already exists and has exited, return false, "+get_print_tuple()); returnfalse; } Log.i(TAG, "start, the thread already exists and has exited, return true, "+get_print_tuple()); returntrue; } SendThreadthread=newSendThread(); if (!thread_.compareAndSet(null, thread)) { Log.i(TAG, "start, call compareAndSet return false, the thread already exists, return true, "+get_print_tuple()); returntrue; } try { Log.i(TAG, "start thread, "+get_print_tuple()); thread.start(); }catch (Exceptione) { thread_.compareAndSet(thread, null); Log.e(TAG, "start e:", e); returnfalse; } returntrue; } publicvoidstop() { SendThreadcurrent_thread=thread_.get(); if (current_thread!=null&&!current_thread.is_exit()) { current_thread.exit(); Log.i(TAG, "stop, exit thread "+get_print_tuple()); } } privatebooleaninit_native_sender(StackDisposabledisposables) { if(native_handle_!=0) { Log.e(TAG, "init_native_sender, native_handle_ is not 0, "+get_print_tuple()); returnfalse; } if (null==this.media_info_||!this.media_info_.is_has_track() ) { Log.e(TAG, "init_native_sender, there is no track, "+get_print_tuple()); returnfalse; } if (0==rtp_handle_) { Log.e(TAG, "init_native_sender, rtp_handle_ is 0, "+get_print_tuple()); returnfalse; } if (null==lib_publisher_){ Log.e(TAG, "init_native_sender, lib_publisher_ is null, "+get_print_tuple()); returnfalse; } Contextcontext=this.context_.get_context(); if (null==context) { Log.e(TAG, "init_native_sender, context is null, "+get_print_tuple()); returnfalse; } longhandle=lib_publisher_.SmartPublisherOpen(context, media_info_.is_has_audio_track()?2:0, media_info_.is_has_video_track()?2:0, 0, 0); if (0==handle) { Log.e(TAG, "init_native_sender, call SmartPublisherOpen failed, "+get_print_tuple()); returnfalse; } NativeSenderDisposablenative_disposable=newNativeSenderDisposable(lib_publisher_, handle); lib_publisher_.SetStreamType(handle, 1); List<MediaTrack>tracks=media_info_.get_tracks(); for (MediaTracki : tracks) { if (i.is_video()) lib_publisher_.SetEncodedVideoCodecId(handle, i.codec_id(), i.csd_set(), i.csd_set() !=null?i.csd_set().length : 0); elseif(i.is_audio()) lib_publisher_.SetEncodedAudioCodecId(handle, i.codec_id(), i.csd_set(), i.csd_set() !=null?i.csd_set().length : 0); } lib_publisher_.SetGB28181RTPSender(handle, rtp_handle_, rtp_payload_type_, rtp_encoding_name_); intret=lib_publisher_.StartGB28181MediaStream(handle); if (ret!=0) { Log.e(TAG, "init_native_sender, call StartGB28181MediaStream failed, "+get_print_tuple()); native_disposable.dispose(); returnfalse; } native_disposable.is_need_call_stop(true); disposables.push(native_disposable); native_handle_=handle; returntrue; } privatebooleanpost_media_packet(MediaPacketpacket) { /*Log.i(TAG, "post "+ MediaTrack.get_media_type_string(packet.media_type()) + " " +MediaTrack.get_codec_id_string(packet.codec_id()) + " packet, pts:" + out_point_3(packet.pts_us()/1000.0) +"ms, key:"+ (packet.is_key()?1:0) + ", size:" + packet.size()); */if (null==lib_publisher_||0==native_handle_||!packet.is_has_data()) returnfalse; if (packet.is_audio()) { if (packet.is_aac()) { if (packet.is_has_codec_specific_data_set()) return0==lib_publisher_.PostAudioOnDemandPacketByteBuffer(native_handle_, packet.codec_id(), packet.data(), 0, packet.size(), packet.pts_us(), 0, packet.codec_specific_data_set(), packet.codec_specific_data_set_size(), 0, 0); } }elseif (packet.is_video()) { if (packet.is_avc() ||packet.is_hevc()) { return0==lib_publisher_.PostVideoOnDemandPacketByteBuffer(native_handle_, packet.codec_id(), packet.data(), 0, packet.size(), packet.pts_us(), 0, packet.is_key()?1:0, packet.codec_specific_data_set(), packet.codec_specific_data_set_size(), 0, 0); } } returnfalse; } privatevoidrelease_packets(Deque<MediaPacket>packets) { while (!packets.isEmpty()) packets.removeFirst().release_buffer(); } privatestaticStringout_point_3(doublev) { returnString.format("%.3f", v); } publicstaticStringto_mega_bytes_string(longbytes) { doublemb=bytes/(1024*1024.0); returnout_point_3(mb); } privateclassSendThreadextendsThread { publicvoidrun() { /****相关代码**/ } } }
总结
GB28181历史视音频文件下载,看似逻辑复杂,实际上也不简单,文件下载是在完成录像和历史视音频文件检索的基础上,分别从信令、RTP数据打包发送等角度实现,考虑到录像文件的完整性,历史视音频文件下载的设计目标是减少丢帧丢包,推荐使用RTP over TCP模式,尽管作为GB28181设备接入侧,我们尽可能按照标准规范来实现,实际对接的国标平台厂商,多少会有些差异,具体还要根据现场实际情况酌情处理。