技术背景
在实现GB28181历史视音频文件回放之前,我们已完成了历史视音频文件检索和下载,历史视音频回放,在GB28181平台非常重要,比如执法记录仪等前端设备,默认录像数据存储在前端设备侧,如果需要上传到平台统一保存,除了到工作站拷贝外,还可以通过GB28181的历史视音频文件下载到指挥中心。如果指挥中心需要直接看历史视音频文件,也可以通过GB28181历史视音频回放实现。
GB28181历史视音频文件回放基本要求:
- 需采用 SIP 协议中的 Invite 方法实现会话连接;
- 采用SIP扩展协议Info方法的消息体携带视音频回放控制命令;
- 采用 RTP/RTCP 协议实现媒体传输;
- 媒体回放控制命令引用MANSRTSP协议中的 PlayPause、Teardown 的请求消息和应答消息;
- 历史视音频的回放宜支持媒体流保活机制。
基本流程如下:
本文结合Android平台GB28181设备接入侧和GB28181国标平台侧,理下基本流程:
1、GB28181平台侧向Android平台GB28181设备接入侧发送Invite消息,消息头域携带Subject字段,表明点播的视频源ID、发送方媒体流序列号、媒体流接受者ID、接收端媒体流序列号标志等参数。消息中携带SDP信息,s字段为"Playback"代表历史回放,u字段代表回放通道ID和回放类型,t字段代表回放时间段,增加y字段描述SSRC值;
2、Android GB28181设备接入侧收到国标平台侧的Invite请求后,回复200OK,并携带SDP消息体, SDP中描述了安卓设备发送媒体流的IP、端口、媒体格式、SSRC字段等内容;
3、国标平台侧收到Android国标设备侧返回的200OK响应后,向Android国标设备侧发送ACK请求,请求中不携带消息体,完成与Android国标设备侧的Invite会话建立过程;
4、Android GB28181设备侧按Invite SDP中给出的IP地址和端口等信息,发送音视频RTP包(推荐PS RTP包)到媒体服务器;
5、回放过程中,播放端通过向SIP服务器发送会话内Info+MANSRTSP消息(SIP服务器再转发给安卓设备端)进行回放控制,包括视频暂停、播放、快放、慢放、随机拖放等操作;
6、Android GB28181设备侧在文件回放结束后发送会话内Message消息,通知SIP服务器回放已结束;
7、国标平台侧收到媒体通知消息后做相应的处理,之后国标服务侧向Android国标设备侧发送BYE消息;
8、Android平台GB28181设备侧收到BYE消息后,回复200 OK,会话断开,并释放相关资源。
这里聊下媒体回放控制命令:
媒体回放控制命令由客户端到服务器的请求消息和由服务器到客户端的应答消息完成,请求和应
答引用 RTSP(IETFRFC2326)协议中的部分请求和应答消息格式。
媒体播放命令:
客户端发送 PLAY 请求消息,请求服务器发送媒体。应支持 Range 头,在 Range 头中给出播放时间范围,播放指定时间段的媒体,时间范围应支持npt、smpte相对时间戳范围。
Range 头取值为"ntp=now-",不携带Scale头,表示从暂停位置以原倍速恢复播放。
PLAY RTSP/1.0
CSeq: 2
Range: npt= now-
暂停播放命令示例:
PAUSERTSP/1.0
CSeq:1
PauseTime:now
快进慢进命令示例:
PLAYRTSP/1.0
CSeq:3
Scale:2.0
随机拖放命令示例:
PLAYRTSP/1.0
CSeq:4
Range:npt=100-
停止命令:
客户端发送 TEARDOWN 请求消息,停止发送指定流,结束会话,并释放资源。
应答命令:
客户端、服务器端应支持应答命令的状态码200、4xx以及5xx。见IETFRFC2326。
Scale和 Range头域取值范围
Scale头应支持的基本取值为0.25、0.5、1、2、4。
Range 头的值为播放录像起点的相对值,取值范围为 0 到播放录像的终点时间,参数以s为单位,不能为负值。比如Range 头的值为0,则表示从起点开始播放,Range头的值为100,则表示从录像起点后的100s处开始播放,Range 头的取值为now表示从当前位置开始播放。
技术实现
本文以大牛直播SDK的Android平台GB28181设备接入侧为例,目前我们实现的功能如下:
- [视频格式]H.264/H.265(Android H.265硬编码);
- [音频格式]G.711 A律、AAC;
- [音量调节]Android平台采集端支持实时音量调节;
- [H.264硬编码]支持H.264特定机型硬编码;
- [H.265硬编码]支持H.265特定机型硬编码;
- [软硬编码参数配置]支持gop间隔、帧率、bit-rate设置;
- [软编码参数配置]支持软编码profile、软编码速度、可变码率设置;
- 支持横屏、竖屏推流;
- Android平台支持后台service推送屏幕(推送屏幕需要5.0+版本);
- 支持纯视频、音视频PS打包传输;
- 支持RTP OVER UDP和RTP OVER TCP被动模式(TCP媒体流传输客户端);
- 支持信令通道网络传输协议TCP/UDP设置;
- 支持注册、注销,支持注册刷新及注册有效期设置;
- 支持设备目录查询应答;
- 支持心跳机制,支持心跳间隔、心跳检测次数设置;
- 支持移动设备位置(MobilePosition)订阅和通知;
- 适用国家标准:GB/T 28181---2016;
- 支持语音广播;
- 支持语音对讲;
- 支持历史视音频文件检索;
- 支持历史视音频文件下载;
- 支持历史视音频文件回放;
- 支持云台控制和预置位查询;
- [实时水印]支持动态文字水印、png水印;
- [镜像]Android平台支持前置摄像头实时镜像功能;
- [实时静音]支持实时静音/取消静音;
- [实时快照]支持实时快照;
- [降噪]支持环境音、手机干扰等引起的噪音降噪处理、自动增益、VAD检测;
- [外部编码前视频数据对接]支持YUV数据对接;
- [外部编码前音频数据对接]支持PCM对接;
- [外部编码后视频数据对接]支持外部H.264数据对接;
- [外部编码后音频数据对接]外部AAC数据对接;
- [扩展录像功能]支持和录像模块组合使用,录像相关功能。
信令交互示例
Android平台GB28181设备侧,收到录像检索:
xml
<?xml version="1.0" encoding="GB2312"?>
<Query>
<CmdType>RecordInfo</CmdType>
<SN>564849544</SN>
<DeviceID>34020000001380000001</DeviceID>
<StartTime>2023-11-05T06:00:00</StartTime>
<EndTime>2023-11-05T12:00:00</EndTime>
<Type>all</Type>
</Query>
国标平台侧列表显示检索到的录像文件信息:
Android平台GB28181设备侧收到国标平台侧的Invite:
ini
v=0
o=34020000001380000001 0 0 IN IP4 192.168.0.108
s=Playback
u=34020000001380000001:0
c=IN IP4 192.168.0.108
t=1699159500 1699161303
m=video 30014 RTP/AVP 96 97 98 99
a=recvonly
a=rtpmap:96 PS/90000
a=rtpmap:97 MPEG4/90000
a=rtpmap:98 H264/90000
a=rtpmap:99 H265/90000
y=1200000006
Android平台GB28181设备侧回复国标平台侧:
ini
v=0
o=34020000011310000039 0 0 IN IP4 192.168.0.104
s=Playback
c=IN IP4 192.168.0.104
t=0 0
m=video 55584 RTP/AVP 96
a=rtpmap:96 PS/90000
a=filesize:199500000
a=sendonly
y=1200000006
如需2倍速快进播放:
yaml
PLAY RTSP/1.0
CSeq: 785427390
Scale: 2.000000
Android平台GB28181设备侧发送会话内Message消息,通知时间类型为"121",表示历史媒体文件发送结束:
xml
<?xml version="1.0" encoding="GB2312"?>
<Notify>
<CmdType>MediaStatus</CmdType>
<SN>433507779</SN>
<DeviceID>34020000001380000001</DeviceID>
<NotifyType>121</NotifyType>
</Notify>
接口实现
java
/*
* Author: daniusdk.com
*/
package com.gb.ntsignalling;
public interface GBSIPAgent {
void addPlaybackListener(GBSIPAgentPlaybackListener playbackListener);
void removePlaybackListener(GBSIPAgentPlaybackListener playbackListener);
/*
*响应Invite Playback 200 OK
*/
boolean respondPlaybackInviteOK(long id, String deviceId, String startTime, String stopTime, MediaSessionDescription localMediaDescription);
/*
*响应Invite Playback 其他状态码
*/
boolean respondPlaybackInvite(int statusCode, long id, String deviceId);
/*
* 媒体流发送者在回放结束后发Message消息通知SIP服务器回放文件已发送完成
* notifyType 必须是"121"
*/
boolean notifyPlaybackMediaStatus(long id, String deviceId, String notifyType);
/*
*终止Playback会话
*/
void terminatePlayback(long id, String deviceId, boolean isSendBYE);
/*
*终止所有Playback会话
*/
void terminateAllPlaybacks(boolean isSendBYE);
}
/**
* 信令Playback Listener
*/
package com.gb.ntsignalling;
public interface GBSIPAgentPlaybackListener {
/*
*收到s=Playback的历史回放Invite
*/
void ntsOnInvitePlayback(long id, String deviceId, SessionDescription sessionDescription);
/*
*发送Playback invite response 异常
*/
void ntsOnPlaybackInviteResponseException(long id, String deviceId, int statusCode, String errorInfo);
/*
* 收到CANCEL Playback INVITE请求
*/
void ntsOnCancelPlayback(long id, String deviceId);
/*
* 收到Ack
*/
void ntsOnAckPlayback(long id, String deviceId);
/*
* 播放命令
*/
void ntsOnPlaybackMANSRTSPPlayCommand(long id, String deviceId);
/*
* 暂停命令
*/
void ntsOnPlaybackMANSRTSPPauseCommand(long id, String deviceId);
/*
* 快进/慢进命令
*/
void ntsOnPlaybackMANSRTSPScaleCommand(long id, String deviceId, double scale);
/*
* 随机拖动命令
*/
void ntsOnPlaybackMANSRTSPSeekCommand(long id, String deviceId, double position_sec);
/*
* 停止命令
*/
void ntsOnPlaybackMANSRTSPTeardownCommand(long id, String deviceId);
/*
* 收到Bye
*/
void ntsOnByePlayback(long id, String deviceId);
/*
* 不是在收到BYE Message情况下, 终止Playback
*/
void ntsOnTerminatePlayback(long id, String deviceId);
/*
* Playback会话对应的对话终止, 一般不会触发这个回调,目前只有在响应了200K, 但在64*T1时间后还没收到ACK,才可能会出发
收到这个, 请做相关清理处理
*/
void ntsOnPlaybackDialogTerminated(long id, String deviceId);
}
/**
* 部分JNI接口, rtp ps 打包发送等代码C++实现
*/
public class SmartPublisherJniV2 {
/**
* 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
*/
public native long SmartPublisherOpen(Object ctx, int audio_opt, int video_opt, int width, int height);
/**
* 设置流类型
* @param type: 0:表示 live 流, 1:表示 on-demand 流, SDK默认为0(live流)
* 注意: 流类型设置当前仅对GB28181媒体流有效
* @return {0} if successful
*/
public native int SetStreamType(long handle, int type);
/**
* 投递视频 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
*/
public native int PostVideoOnDemandPacketByteBuffer(long handle, int codec_id,
ByteBuffer packet, int offset, int size, long pts_us, int is_pts_discontinuity, int is_key,
byte[] codec_specific_data, int codec_specific_data_size,
int width, int height);
/**
* 投递音频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
*/
public native int PostAudioOnDemandPacketByteBuffer(long handle, int codec_id,
ByteBuffer packet, int offset, int size, long pts_us, int is_pts_discontinuity,
byte[] codec_specific_data, int codec_specific_data_size,
int sample_rate, int channels);
/**
* on demand source完成seek后, 请调用
* @return {0} if successful
*/
public native int OnSeekProcessed(long handle);
/**
* 启动 GB28181 媒体流
*
* @return {0} if successful
*/
public native int StartGB28181MediaStream(long handle);
/**
* 停止 GB28181 媒体流
*
* @return {0} if successful
*/
public native int StopGB28181MediaStream(long handle);
/**
* 关闭推送实例,结束时必须调用close接口释放资源
*
* @return {0} if successful
*/
public native int SmartPublisherClose(long handle);
}
/**
* Listener部分实现代码
*/
public class PlaybackListenerImpl implements com.gb.ntsignalling.GBSIPAgentPlaybackListener {
/*
*收到s=Playback的文件下载Invite
*/
@Override
public void ntsOnInvitePlayback(long id, String deviceId, SessionDescription sdp) {
if (!post_task(new PlaybackListenerImpl.OnInviteTask(this.context_, this.is_exit_, this.senders_map_, deviceId, sdp, id))) {
Log.e(TAG, "ntsOnInvitePlayback post_task failed, " + RecordSender.make_print_tuple(id, deviceId, sdp.getTime().getStartTime(), sdp.getTime().getStopTime()));
// 这里不发488, 等待事务超时也可以的
GBSIPAgent agent = this.context_.get_agent();
if (agent != null)
agent.respondPlaybackInvite(488, id, deviceId);
}
}
/*
*发送Playback invite response 异常
*/
@Override
public void ntsOnPlaybackInviteResponseException(long id, String deviceId, int statusCode, String errorInfo) {
Log.i(TAG, "ntsOnPlaybackInviteResponseException, status_code:" + statusCode + ", "
+ RecordSender.make_print_tuple(id, deviceId) + ", error_info:" + errorInfo);
RecordSender sender = senders_map_.remove(id);
if (null == sender)
return;
PlaybackListenerImpl.StopDisposeTask task = new PlaybackListenerImpl.StopDisposeTask(sender);
if (!post_task(task))
task.run();
}
/*
* 收到CANCEL Playback INVITE请求
*/
@Override
public void ntsOnCancelPlayback(long id, String deviceId) {
Log.i(TAG, "ntsOnCancelPlayback, " + RecordSender.make_print_tuple(id, deviceId));
RecordSender sender = senders_map_.remove(id);
if (null == sender)
return;
PlaybackListenerImpl.StopDisposeTask task = new PlaybackListenerImpl.StopDisposeTask(sender);
if (!post_task(task))
task.run();
}
/*
* 收到Ack
*/
@Override
public void ntsOnAckPlayback(long id, String deviceId) {
Log.i(TAG, "ntsOnAckPlayback, "+ RecordSender.make_print_tuple(id, deviceId));
RecordSender sender = senders_map_.get(id);
if (null == sender) {
Log.e(TAG, "ntsOnAckPlayback get sender is null, " + RecordSender.make_print_tuple(id, deviceId));
GBSIPAgent agent = this.context_.get_agent();
if (agent != null)
agent.terminatePlayback(id, deviceId, false);
return;
}
PlaybackListenerImpl.StartTask task = new PlaybackListenerImpl.StartTask(sender, this.senders_map_);
if (!post_task(task))
task.run();
}
/*
* 收到Bye
*/
@Override
public void ntsOnByePlayback(long id, String deviceId) {
Log.i(TAG, "ntsOnByePlayback, "+ RecordSender.make_print_tuple(id, deviceId));
RecordSender sender = this.senders_map_.remove(id);
if (null == sender)
return;
PlaybackListenerImpl.StopDisposeTask task = new PlaybackListenerImpl.StopDisposeTask(sender);
if (!post_task(task))
task.run();
}
/*
* 播放命令
*/
@Override
public void ntsOnPlaybackMANSRTSPPlayCommand(long id, String deviceId) {
RecordSender sender = this.senders_map_.get(id);
if (null == sender) {
Log.e(TAG, "ntsOnPlaybackMANSRTSPPlayCommand can not get sender " + RecordSender.make_print_tuple(id, deviceId));
return;
}
sender.post_play_command();
Log.i(TAG, "ntsOnPlaybackMANSRTSPPlayCommand " + RecordSender.make_print_tuple(id, deviceId));
}
/*
* 暂停命令
*/
@Override
public void ntsOnPlaybackMANSRTSPPauseCommand(long id, String deviceId) {
RecordSender sender = this.senders_map_.get(id);
if (null == sender) {
Log.e(TAG, "ntsOnPlaybackMANSRTSPPauseCommand can not get sender " + RecordSender.make_print_tuple(id, deviceId));
return;
}
sender.post_pause_command();
Log.i(TAG, "ntsOnPlaybackMANSRTSPPauseCommand " + RecordSender.make_print_tuple(id, deviceId));
}
/*
* 快进/慢进命令
*/
@Override
public void ntsOnPlaybackMANSRTSPScaleCommand(long id, String deviceId, double scale) {
if (scale < 0.01) {
Log.e(TAG, "ntsOnPlaybackMANSRTSPScaleCommand invalid scale:" + scale + " " + RecordSender.make_print_tuple(id, deviceId));
return;
}
RecordSender sender = this.senders_map_.get(id);
if (null == sender) {
Log.e(TAG, "ntsOnPlaybackMANSRTSPScaleCommand can not get sender, scale:" + scale + " " + RecordSender.make_print_tuple(id, deviceId));
return;
}
sender.post_scale_command(scale);
Log.i(TAG, "ntsOnPlaybackMANSRTSPScaleCommand, scale:" + scale + " " + RecordSender.make_print_tuple(id, deviceId));
}
/*
* 随机拖动命令
*/
@Override
public void ntsOnPlaybackMANSRTSPSeekCommand(long id, String device_id, double position_sec) {
if (position_sec < 0.0) {
Log.e(TAG, "ntsOnPlaybackMANSRTSPSeekCommand invalid seek pos:" + position_sec + ", " + RecordSender.make_print_tuple(id, device_id));
return;
}
RecordSender sender = this.senders_map_.get(id);
if (null == sender) {
Log.e(TAG, "ntsOnPlaybackMANSRTSPSeekCommand can not get sender " + RecordSender.make_print_tuple(id, device_id));
return;
}
long offset_ms = sender.get_file_start_time_offset_ms();
position_sec += (offset_ms/1000.0);
sender.post_seek_command(position_sec);
Log.i(TAG, "ntsOnPlaybackMANSRTSPSeekCommand seek pos:" + RecordSender.out_point_3(position_sec) + "s, " + RecordSender.make_print_tuple(id, device_id));
}
/*
* 停止命令
*/
@Override
public void ntsOnPlaybackMANSRTSPTeardownCommand(long id, String device_id) {
CallTerminatePlaybackTask call_terminate_task = new CallTerminatePlaybackTask(this.context_, id, device_id, true);
post_task(call_terminate_task);
RecordSender sender = this.senders_map_.remove(id);
if (null == sender) {
Log.w(TAG, "ntsOnPlaybackMANSRTSPTeardownCommand can not remove sender " + RecordSender.make_print_tuple(id, device_id));
return;
}
Log.i(TAG, "ntsOnPlaybackMANSRTSPTeardownCommand " + RecordSender.make_print_tuple(id, device_id));
PlaybackListenerImpl.StopDisposeTask task = new PlaybackListenerImpl.StopDisposeTask(sender);
if (!post_task(task))
task.run();
}
/*
* 不是在收到BYE Message情况下, 终止Playback
*/
@Override
public void ntsOnTerminatePlayback(long id, String deviceId) {
Log.i(TAG, "ntsOnTerminatePlayback, "+ RecordSender.make_print_tuple(id, deviceId));
RecordSender sender = this.senders_map_.remove(id);
if (null == sender)
return;
PlaybackListenerImpl.StopDisposeTask task = new PlaybackListenerImpl.StopDisposeTask(sender);
if (!post_task(task))
task.run();
}
/*
* Playback会话对应的对话终止, 一般不会触发这个回调,目前只有在响应了200K, 但在64*T1时间后还没收到ACK,才可能会出发
收到这个, 请做相关清理处理
*/
@Override
public void ntsOnPlaybackDialogTerminated(long id, String deviceId) {
Log.i(TAG, "ntsOnPlaybackDialogTerminated, "+ RecordSender.make_print_tuple(id, deviceId));
RecordSender sender = this.senders_map_.remove(id);
if (null == sender)
return;
PlaybackListenerImpl.StopDisposeTask task = new PlaybackListenerImpl.StopDisposeTask(sender);
if (!post_task(task))
task.run();
}
}
总结
Android平台GB28181历史视音频文件回放,除了上述信令交互外,还需要处理RTP打包发送等,相对其他功能实现更复杂,感兴趣的开发者,可以尝试看看。