# GB28181Pusher **Repository Path**: AndroidCoderPeng/GB28181Pusher ## Basic Information - **Project Name**: GB28181Pusher - **Description**: 基于 Android(Java / Kotlin / C++)的 GB/T 28181-2016 国标推流与语音对讲 SDK, 支持设备注册、目录/状态查询响应、实时视频推流(H.264 / H.265 + PS + RTP)与双向语音对讲(G.711 A/μ-law)。 - **Primary Language**: Kotlin - **License**: GPL-2.0 - **Default Branch**: master - **Homepage**: None - **GVP Project**: No ## Statistics - **Stars**: 1 - **Forks**: 0 - **Created**: 2026-03-24 - **Last Updated**: 2026-09-11 ## Categories & Tags **Categories**: Uncategorized **Tags**: None ## README # GB28181Pusher 基于 Android(Java / Kotlin / C++)的 GB/T 28181-2016 国标推流与语音对讲 SDK, 支持设备注册、目录/状态查询响应、实时视频推流(H.264 / H.265 + PS + RTP)与双向语音对讲(G.711 A/μ-law)。 ## 目录 - [项目简介](#项目简介) - [功能特性](#功能特性) - [工程结构](#工程结构) - [环境要求](#环境要求) - [快速开始](#快速开始) - [接口说明](#接口说明) - [本地录制](#本地录制) - [事件码说明](#事件码说明) - [推流流程](#推流流程) - [语音对讲流程](#语音对讲流程) - [第三方依赖编译](#第三方依赖编译) ## 项目简介 本项目实现了一套自主可控的 GB/T 28181 推流与语音对讲方案,核心链路(协议栈 + 媒体封装)自研, 仅依赖开源基础库(osip2 / eXosip2 / pugixml),摆脱对第三方商业 SDK 的依赖。 工程由三个 Gradle 模块组成,职责清晰、可独立复用: | 模块 | 语言 | 职责 | |-----------|--------------|----------------------------------------------------------------------------------------------| | `app` | Kotlin | 示例应用:权限申请、参数配置、采集/编码/推流/对讲/本地录制的业务调度 | | `encoder` | Java | 采集与编码库:Camera2 + OpenGL ES 渲染 + MediaCodec 硬编(H.264 / H.265);AudioRecord + G.711 / AAC 双分支编码 | | `pusher` | Kotlin + C++ | 国标协议库:SIP 信令(eXosip2)、PS 封装(H.264 / H.265)、RTP 收发、G.711 编解码、对讲播放 | 核心链路: - **SIP 信令**:基于 eXosip2 实现设备注册(Digest 认证)、心跳保活、实时视频协商、语音对讲协商、目录/状态查询响应 - **视频链路**:Camera2 采集 → OpenGL ES 渲染(旋转 / 缩放 / 水印)→ MediaCodec 硬件编码( H.264 / H.265)→ MPEG-2 PS 封装 → RTP 分片发送 - **音频上行链路**:AudioRecord 采集 48kHz PCM → 扇出两路: - G.711 分支(48k → 8k 重采样 → μ/A 律编码)→ PS 封装 → RTP 发送 - AAC 分支(AAC-LC 48kHz / 96kbps)→ 本地 MP4 录制 - **对讲链路**:接收平台 G.711 数据 → 环形缓冲 → 解码为 PCM → `IntercomPlayer`(AudioTrack)播放 - **本地录制链路**:编码帧 + AAC 帧 → `MP4Wrapper`(MediaMuxer)→ MP4 文件 > 推流与录制是两条**互相独立**的会话:推流由 SIP 信令(2100 / 2101 / 注销 / 失败)控制, > 录制由 UI 开关控制,编码与录音的生命周期 = 推流 ∪ 录制。详见 `状态机设计说明.md`。 ## 功能特性 - 设备注册 / 注销(支持 Digest 认证,注册有效期 7200s,心跳间隔 30s) - 心跳保活(`MANSCDP+xml` 心跳报文) - 实时视频推流(H.264 / H.265 + MPEG-2 PS + RTP,支持 TCP / UDP,按 SDP 协商结果动态适配) - 运行时切换 H.264 / H.265(`setVideoCodec()` + `VideoEncoder.setCodecType()`,未推流状态下生效) - 双向语音对讲(G.711 μ-law / A-law) - 视频格式自动适配:自动探测 AnnexB / AVCC,AVCC 自动转换为 AnnexB;按编码类型分别解析 SPS / PPS / VPS - 参数集预注入(`setVideoParameterSets()`):编码器先于推流启动时预热 SPS / PPS / VPS 缓存, 避免平台侧永久丢 IDR - 零拷贝视频帧投递(`ByteBuffer` 直传 MediaCodec 输出缓冲) - 目录查询(Catalog)、设备信息(DeviceInfo)、设备状态(DeviceStatus)查询响应 - OpenGL ES 水印叠加(公司名称 / 位置 / 时间,支持运行时开关) - 本地 MP4 录制(与推流解耦):H.264 / H.265 + AAC-LC 封装为 MP4,可纯本地录制、也可边推边录 - 音频上行双分支:G.711 μ/A 律(给推流)+ AAC-LC(给本地录制),一次采集两条分支互不阻塞 - 下行音频双通道输出(解码后 PCM / 原始 G.711 裸流),可通过 `setAudioOutput()` 按需开关 ## 工程结构 ``` GB28181Pusher/ ├── app/ # 示例应用(Kotlin) │ └── src/main/ │ ├── java/com/pengxh/pusher/ │ │ ├── PusherApplication.kt # 应用入口,初始化 KV 存储 │ │ ├── ui/ │ │ │ ├── PermissionActivity.kt # 启动页 + 动态权限申请 │ │ │ ├── MainActivity.kt # 相机/编码/推流/对讲/录制调度(状态机中枢) │ │ │ └── ParameterConfigDialog.kt # SIP 参数配置弹窗(自动填充设备序列号) │ │ └── utils/ │ │ ├── NetworkHelper.kt # 本机 IPv4 地址获取 │ │ └── MP4Wrapper.kt # MediaMuxer 封装(H.264/H.265 + AAC → MP4) │ └── AndroidManifest.xml # 权限声明、入口 Activity │ ├── encoder/ # 采集与编码库(Java) │ └── src/main/java/com/pengxh/encoder/ │ ├── video/ │ │ ├── VideoEncoder.java # Camera2 + GL + MediaCodec 硬编(H.264 / H.265) │ │ ├── VideoEncoderConfig.java # 编码参数(Builder,默认 720x1280@30/2Mbps/1s) │ │ ├── VideoCodecType.java # 编码格式枚举:H264 / H265 │ │ ├── SizeSelector.java # 预览与编码尺寸选择 │ │ ├── FrameDataCallback.java # 编码数据回调(ByteBuffer 零拷贝 + 输出格式 + 错误) │ │ ├── EncoderState.java # 编码器状态枚举 │ │ └── gl/ # EGL 渲染管线 │ │ ├── EglCore.java / EglRenderLayer.java / FrameEglRenderer.java │ │ ├── EglRenderFilter.java # 帧处理滤镜接口(可插拔) │ │ ├── ShaderProgram.java / PassThroughFilter.java / FloatArray.java │ │ └── WatermarkFilter.java / WatermarkController.java │ └── audio/ │ ├── AudioPipeline.java # 音频管线门面:采集 → 扇出 → 双编码分支 │ ├── AudioCapture.java # AudioRecord 采集(生产者-消费者 + 有界队列) │ ├── AacEncoder.java # AAC-LC 编码(独立线程,1024 样本/帧) │ ├── G711Encoder.java # G.711 A/μ 律编码(查表,无状态) │ ├── PcmResampler.java # 48k → 8k 重采样(G.711 分支用) │ ├── AudioEncoder.java # 编码器接口 │ ├── EncodedAudioCallback.java # 编码回调(csd-0 定义 + 帧数据 + 错误) │ ├── AudioEncoderConfig.java # 采集 48kHz / 单声道 / 16bit / 20ms 一帧 │ └── AudioCodecType.java # G711A / G711U │ ├── pusher/ # 国标信令与媒体封装库(Kotlin + C++) │ └── src/main/ │ ├── java/com/pengxh/media/ │ │ ├── GB28181Pusher.kt # 对外 JNI API(单例 object) │ │ ├── SipParameter.kt # 注册参数 │ │ ├── SipEventCallback.kt # 事件回调接口 │ │ └── IntercomPlayer.kt # 对讲下行 PCM 播放器(AudioTrack + 缓冲队列) │ ├── cpp/ │ │ ├── CMakeLists.txt # 唯一构建脚本,产物 libGB28181Pusher.so │ │ ├── GB28181Pusher.cpp # JNI 入口 │ │ ├── java_callback.cpp / .hpp # Java 回调桥 │ │ ├── eXosip2/ osip2/ osipparser2/ # 第三方头文件 │ │ └── src/ │ │ ├── global_definition.hpp # 全局常量与结构体 │ │ ├── logger.cpp / .hpp # 日志工具 │ │ ├── pugixml.cpp / .hpp # XML 解析(MANSCDP 消息) │ │ ├── rtp_sender.cpp / .hpp # RTP 封包与发送 │ │ ├── video/frame_splitter.* # 格式探测 / AVCC→AnnexB / H.264·H.265 NALU 拆分 │ │ ├── audio/ # G.711 编解码 / 音频接收 / 环形缓冲(256KB) │ │ ├── muxer/ # PS 封装 / PES·系统头·PSM 构建 │ │ └── sip/ │ │ ├── sip_manager.cpp # 总控 │ │ ├── register_manager.cpp # 注册状态机 │ │ ├── heartbeat_manager.cpp # 心跳线程 │ │ ├── stream_manager.cpp # 推流与对讲会话管理 │ │ ├── event_dispatcher.cpp # eXosip 事件分发 │ │ ├── sip_context.cpp # eXosip 上下文(TCP 5060 监听) │ │ ├── utils/ # 响应发送 / SDP 解析 / 状态码 / 参数解析 / XML 构建 / 工作线程 │ │ └── handlers/ # 注册 / 呼叫 / 消息 / 默认 事件处理器 │ └── jniLibs// # 预编译 so(osipparser2 / osip2 / eXosip2) │ ├── share/ # 技术详解文档 ├── 状态机设计说明.md # 本地录制 / 推流 双会话状态机设计 ├── SIP指令.md # SIP 信令交互示例 ├── build_osip2.sh / build_exosip2.sh # 第三方依赖交叉编译脚本 └── build_libyuv.sh / build_x264.sh ``` ## 环境要求 | 项 | 要求 | |---------------------------|----------------------------------------| | Gradle | 8.13 | | Android Gradle Plugin | 8.11.1 | | Kotlin | 2.3.20 | | JDK | 11 | | NDK | 21.4.7075529 | | CMake | 3.22.1 | | C++ 标准 | C++14 | | minSdk | 26(Android 8.0) | | targetSdk / compileSdk | 36 | | 支持架构 | armeabi-v7a / arm64-v8a / x86 / x86_64 | | versionCode / versionName | 1001 / 1.0.0.1 | 应用模块主要依赖: | 依赖 | 版本 | 用途 | |-----------------------------------------------|--------|--------------------------| | `com.github.AndroidCoderPeng:Kotlin-lite-lib` | 2.0.0 | 基础 Activity / KV 存储 / 扩展 | | `androidx.core:core-ktx` | 1.17.0 | — | | `androidx.appcompat:appcompat` | 1.7.1 | — | | `com.google.android.material:material` | 1.13.0 | Material 组件 | | `pub.devrel:easypermissions` | 3.0.0 | 动态权限申请 | | `com.google.code.gson:gson` | 2.14.0 | SIP 参数本地持久化 | ## 快速开始 ### 1. 克隆并构建 ```bash git clone <仓库地址> cd GB28181Pusher ./gradlew assembleDebug ``` Release 包输出名为 `GB28181Pusher__.apk`,位于 `app/build/outputs/apk/release/`。 ### 2. 安装运行 安装到 Android 8.0 及以上设备后: 1. 首次启动 `PermissionActivity` 会申请相机、麦克风权限(Android 10 以下额外申请写存储权限) 2. 进入主界面后即可看到相机预览;在「H.264 / H.265」单选组选择推流编码格式(默认 H.264) (示例应用参数:720×1280、25fps、2.5Mbps、I 帧间隔 1s) 3. 点击「国标参数配置」,填写平台参数: - 服务器 IP / 端口 - 服务器账号(SIP 服务器 ID) - 域 ID(服务器域) - 设备 ID(国标设备编码) - 设备名称、密码、经度、纬度 4. 点击「国标注册」完成设备注册(未配置参数时会先弹出配置框,配置完成后自动注册) 5. 平台侧下发拉流 / 对讲信令后,自动开始推流 / 对讲 6. 「本地录像」开关可随时启停本地 MP4 录制:**不依赖注册与推流**,勾选即开录,取消即封口 7. 「添加水印」开关可实时启停 OpenGL 水印 > 注意:已注册 / 录制中不允许切换编码格式,需先取消录制并点击「注销流媒体」。 ### 3. SIP 参数说明 注册参数由 `SipParameter` 描述(`com.pengxh.media.SipParameter`): | 字段 | 说明 | |--------------------------|------------------------------------| | `localHost` | 本地 IP(由 `NetworkHelper` 自动获取,无需填写) | | `serverHost` | SIP 服务器 IP | | `serverPort` | SIP 服务器端口 | | `serverCode` | SIP 服务器账号 | | `serverDomain` | SIP 服务器域 | | `deviceCode` | 设备国标编码(20 位) | | `serialNumber` | 设备序列号(由硬件信息 MD5 自动生成) | | `deviceName` | 设备名称 | | `password` | 认证密码 | | `longitude` / `latitude` | 经度 / 纬度 | 参数经 Gson 序列化后保存在本地 KV(key = `sip_parameter`),下次启动自动回显。 ## 接口说明 ### 国标库(`com.pengxh.media.GB28181Pusher`) | 方法 | 说明 | |---------------------------------------------|--------------------------------------------------------------| | `register(parameter, callback)` | 注册到国标平台(若已注册,内部先注销再注册) | | `unregister()` | 注销 | | `setVideoCodec(codecType)` | 设置视频编码格式:`"H264"` / `"H265"`,需在注册/起流前调用 | | `setVideoParameterSets(csd0, csd1)` | 预注入视频参数集:H.264 传 `[SPS][PPS]`;H.265 传 `[VPS][SPS]` + `[PPS]` | | `pushVideoFrameBuffer(buffer, size, ptsUs)` | 推送视频帧,**推荐**:直传 MediaCodec 输出 `ByteBuffer`,少一次拷贝 | | `pushVideoFrameBytes(bytes, ptsUs)` | 推送视频帧(`ByteArray`,**已废弃**,JNI 需整体拷贝) | | `pushAudioOriginalFrame(pcm, ptsUs)` | 推送原始 PCM(`ShortArray`),Native 侧编码为 G.711μ | | `pushAudioEncodedFrame(data, ptsUs)` | 推送已编码的 G.711 数据(`ByteArray`),直接封装 | | `setAudioOutput(needG711)` | 是否回调下行**原始 G.711 裸流**(`onRawAudioReceived`);解码后 PCM 始终回调 | | `releaseMuxer()` | 释放 PS 封装器(注销 / 注册失败时调用,清空参数集缓存与 PS 输出回调) | | `shutdown()` | 销毁并回收所有资源(App 销毁时调用) | > `.so` 加载顺序不可调整:`osipparser2` → `osip2` → `eXosip2` → `GB28181Pusher`。 > `size` 必须传**本帧有效长度**(`MediaCodec.BufferInfo.size` 或 `buffer.remaining()`), > 不可传 `buffer.capacity()`:capacity 是编码器申请的整块缓冲大小, > 会把 padding / 旧帧数据当作有效帧写入,导致平台侧解析失败。 ### 事件回调(`com.pengxh.media.SipEventCallback`) ```kotlin interface SipEventCallback { fun onEventReceived(code: Int, content: String?) // SIP / 媒体事件 fun onAudioReceived(pcm: ShortArray) // 下行音频(已解码为 PCM,可直接 AudioTrack 播放) fun onRawAudioReceived(g711: ByteArray, type: Int) // 下行音频原始裸流,type: 0=PCMU, 8=PCMA } ``` > 两个音频回调均在 Native 接收线程触发,**必须先拷贝数据再投递到业务线程**,否则后续帧会覆盖缓冲区。 ### 对讲播放器(`com.pengxh.media.IntercomPlayer`) 内置的下行音频播放器(8kHz / 单声道 / 16bit,`MODE_STREAM`):内部维护最多 50 帧(约 1s)的 缓冲队列,预缓冲 10 帧后开始播放,缓冲耗尽时补静音帧;队列满则丢弃最旧帧以保证实时性。 | 方法 | 说明 | |----------------------|-----------------------------------------| | `onPcmReceived(pcm)` | 投入一帧 PCM(内部已 `copyOf()`,外部无需再拷贝) | | `start()` | 启动播放协程(收到 `2200` 时调用) | | `stop()` | 停止播放并释放 AudioTrack(收到 `2201` / 停止推流时调用) | ### 视频编码(`com.pengxh.encoder.video`) ```kotlin val config = VideoEncoderConfig.Builder() .setCameraFacing(CameraCharacteristics.LENS_FACING_BACK) .setWidth(720).setHeight(1280) .setFrameRate(25).setBitrate(2_500_000).setIFrameInterval(1) .build() val watermark = WatermarkFilter().apply { setCompany("公司名称") setLocation("位置信息") setShowTime(true) } val encoder = VideoEncoder( activity, config, textureView, watermark, VideoCodecType.H264, object : FrameDataCallback { override fun onFrameEncoded( buffer: ByteBuffer, size: Int, ptsUs: Long, isKeyFrame: Boolean ) { // size 必须是本帧有效长度,不要用 buffer.capacity() if (isPushing) { GB28181Pusher.pushVideoFrameBuffer(buffer, size, ptsUs) } if (isRecording) { // 录制:拷贝出 ByteArray 交给 MP4Wrapper(见「本地录制」) } } override fun onVideoOutputFormatChanged(format: MediaFormat) { // 缓存 format:建 MP4 视频轨、以及 2100 时向 native 预注入参数集都需要它 } override fun onEncoderError(e: Exception) = e.printStackTrace() }) encoder.start() ``` 构造函数:`VideoEncoder(activity, config, textureView, filter, codecType, callback)` - `config`:传 `null` 使用 `VideoEncoderConfig.createDefault()` - `filter`:实现 `EglRenderFilter` 的帧处理滤镜,`WatermarkFilter` 为其实现;传 `null` 则直通 - `codecType`:`VideoCodecType.H264` / `VideoCodecType.H265` 主要方法: | 方法 | 说明 | |-----------------------------|------------------------------------------------------------| | `start()` | 打开相机、建立预览会话(默认只预览不编码) | | `setEncodingEnable(enable)` | 开关编码推流(收到 2100/2101 时调用) | | `setCodecType(codecType)` | 切换 H.264 / H.265,编码中调用会抛异常;下次 `setEncodingEnable(true)` 生效 | | `getWatermarkController()` | 获取水印控制器,用于运行时开关/更新水印 | | `getEncoderState()` | 查询当前编码器状态(`IDLE`/`STARTING`/`ENCODING`/`STOPPING`) | | `release()` | 释放相机、编码器与 GL 资源(幂等) | ### 音频采集与编码(`com.pengxh.encoder.audio`) 音频侧采用**一次采集、两路扇出**的 `AudioPipeline`: ``` AudioCapture(48kHz/mono/16bit, 20ms=960 samples) ├── [G.711 分支] PcmResampler(48k→8k) → 160 样本对齐 → G711Encoder → 推流 └── [AAC 分支] AacEncoder(独立线程, 1024 样本/帧) → 本地录制 ``` 注册了哪个回调就启用哪条分支;两条分支互不阻塞(G.711 同步算完立即返回,AAC 只入队)。 ```kotlin val config = AudioEncoderConfig.Builder() .setCodecType(AudioCodecType.G711U) // G.711 A 律 / μ 律 .build() val pipeline = AudioPipeline(activity, config).apply { // 分支一:G.711 → 推流 setG711Callback(object : EncodedAudioCallback { override fun onFormatDefined(csd0: ByteArray) {} // G.711 不触发 override fun onEncodedFrame(data: ByteArray, ptsUs: Long) { if (isPushing) GB28181Pusher.pushAudioEncodedFrame(data, ptsUs) } override fun onError(e: Exception) = e.printStackTrace() }) // 分支二:AAC → 本地录制 setAacCallback(object : EncodedAudioCallback { override fun onFormatDefined(csd0: ByteArray) { mp4Wrapper?.setAudioFormat(MP4Wrapper.buildAacFormat(csd0, 48000, 1)) } override fun onEncodedFrame(data: ByteArray, ptsUs: Long) { mp4Wrapper?.writeAudioFrame(data, ptsUs) } override fun onError(e: Exception) = e.printStackTrace() }) } pipeline.start() // isPushing || isRecording 时启动;两者都停时 stop() ``` 关键参数: | 项 | 取值 | |----------|--------------------------------------------------------------------------| | 采集 | 48000 Hz / 单声道 / 16bit,每帧 960 样本(20ms) | | G.711 分支 | 重采样到 8000 Hz,每帧 160 样本 → 160 字节/20ms,与 Native `G711_FRAME_SIZE = 160` 对齐 | | AAC 分支 | AAC-LC,48000 Hz / 单声道 / 96 kbps,每帧 1024 样本 | | 时间戳 | `System.nanoTime() / 1000`,与视频 pts 同属 CLOCK_MONOTONIC 时间基 | > ⚠️ 音频 pts **必须**与视频同基(`System.nanoTime()/1000`)。 > 若从 0 归零计数(如 `frameIndex × 20ms`),同一条 PS 流会混入两个纪元的时间戳, > 接收端缓冲音频会导致延迟线性增长。 > ⚠️ 48k → 8k 重采样在滤波器预热期会少产出几个样本,`AudioPipeline` 内部用 `mG711Pending` > 暂存残量,保证喂给 `G711Encoder` 的永远是整 160 样本。 下行(对讲)音频的输出通道按需声明: ```kotlin GB28181Pusher.setAudioOutput(needG711 = true) // 额外回调原始 G.711 裸流 ``` ## 本地录制 录制是一个**独立于推流**的会话:由 UI 开关控制,不依赖注册与推流状态。 编码器和录音的生命周期 = `isPushing || isRecording`,编码产物按各自开关分发。 ### 状态机 | 事件 | isPushing | isRecording | 视频编码 | 录音 | MP4 | 推 native | |----------------|-----------|-------------|--------|--------|----------|----------| | 勾选录制(未推流) | false | → true | **开启** | **开启** | 建会话,等关键帧 | 不推 | | 勾选录制(推流中) | true | → true | 已开(幂等) | 已开(幂等) | 建会话,等关键帧 | 继续 | | 2100 开始推流(录制中) | → true | true | 已开 | 已开 | **不动** | **开始推** | | 2101 停止推流(录制中) | → false | true | **继续** | **继续** | **继续** | 停推 | | 取消勾选(推流中) | true | → false | 继续 | 继续 | **封口** | 继续 | | 取消勾选(未推流) | false | → false | **停止** | **停止** | **封口** | — | 完整矩阵、典型场景走线与数据流见 `状态机设计说明.md`。 ### 用法 ```kotlin // 开录 val file = File(getExternalFilesDir(Environment.DIRECTORY_MOVIES), "VID_$timeStamp.mp4") mp4Wrapper = MP4Wrapper.create(file.absolutePath).apply { cachedVideoFormat?.let { setVideoFormat(it) } // 编码器输出格式 cachedAacCsd0?.let { setAudioFormat(MP4Wrapper.buildAacFormat(it, 48000, 1)) } } isRecording = true syncMediaPipeline() // 拉起编码 + 录音(幂等) // 停录(封口,独立线程执行,保证 moov 写入) mp4Wrapper?.stopAndRelease() mp4Wrapper = null isRecording = false syncMediaPipeline() ``` 要点: - **两轨齐备才 `start()`**:视频 `setVideoFormat()` + 音频 `setAudioFormat()` 都调用后 muxer 才启动 - **录制起点对齐关键帧**:首个 IDR 之前丢弃所有帧(含音频),避免文件开头"有声音没画面" - **非单调 pts 直接丢弃**:`MediaMuxer` 硬性要求每轨 pts 严格递增 - **封口必须调用 `stopAndRelease()`**,否则不写 moov,文件无法播放 - 单帧上限 2MB;从未成功 `start()`(无有效数据)时自动删除 0 字节残留文件 ### 输出规格 | 项 | 取值 | |-----|-------------------------------------------------------| | 路径 | `getExternalFilesDir(Environment.DIRECTORY_MOVIES)` | | 命名 | `VID_yyyyMMdd_HHmmss.mp4` | | 容器 | MPEG-4(`MediaMuxer.OutputFormat.MUXER_OUTPUT_MPEG_4`) | | 视频轨 | `video/avc` 或 `video/hevc`,透传编码器输出格式(含 csd-0 / csd-1) | | 音频轨 | `audio/mp4a-latm`(AAC-LC),48 kHz / 单声道 / 96 kbps | ## 事件码说明 `onEventReceived` 回传的事件码(完整码表见 `pusher/src/main/cpp/src/sip/utils/state_code.cpp`): | 码值 | 含义 | 示例应用处理 | |--------|-------------|-------------------------------------------------------------------------------| | `1000` | 注册成功 | 按钮切换为「注销流媒体」 | | `201` | 注销成功 | `isPushing = false` → `syncMediaPipeline()`;停对讲;`releaseMuxer()` | | `2100` | 开始推流 | `isPushing = true` → `injectFrameConfig()` 预注入参数集 → `syncMediaPipeline()` | | `2101` | 停止推流 | `isPushing = false` → `syncMediaPipeline()`;停对讲 | | `2107` | 收到 BYE,停止推流 | 同 `2101` | | `2200` | 开始接收音频 | 先 `stopIntercomAudio()`,再创建 `IntercomPlayer` 并 `start()` | | `2201` | 停止接收音频 | `IntercomPlayer.stop()` | | `408` | 请求超时 | 视为注册失败:`isPushing = false` → `syncMediaPipeline()` + `releaseMuxer()` + 复位 UI | | `0` | 未知 / 异常事件 | 同 `408` | | `2001` | 发送注册请求失败 | 同 `408` | | `2003` | 发送认证注册失败 | 同 `408` | | `2007` | 注册已过期 | 同 `408` | > 所有"停止类"事件(2101 / 201 / 408 等)都只复位 `isPushing`,**不停止录制**: > `syncMediaPipeline()` 按 `isPushing || isRecording` 决定是否真的停编码/录音。 其余区间: - `1xx`–`6xx`:SIP 标准响应码 - `1000`–`1099`:eXosip2 事件类型 - `1100`–`1199`:osip2 返回码 - `2000`–`2099`:注册相关错误 - `2100`–`2199`:媒体流相关 - `2200`–`2299`:语音对讲相关 - `2300`–`2399`:消息处理相关 - `2400`–`2499`:设备控制相关 - `3000`–`3099`:系统 / 网络错误 --- # Android 平台 GB/T 28181-2016 推流与语音对讲技术详解 ## 推流流程 ### 1. 初始化 SIP 参数并注册到国标平台 - **Kotlin 层**:组装 `SipParameter`(设备 ID、服务器地址、端口、经纬度等),通过 JNI 传递至 Native 层 - **Native 层**:`SipContext` 初始化 eXosip 上下文并以 **TCP 5060** 监听;`RegisterManager` 基于状态机( `IDLE → SENT_INITIAL → SENT_AUTH`)发起注册,支持 Digest 认证 - **注册成功**:启动 `HeartbeatManager`(30s 间隔)并回调 `1000` - **时间戳基准**:音视频帧的时间戳(微秒)由采集侧产生,Native 侧统一转换为 90kHz ### 2. 视频采集与预处理 - 采用 **Camera2** API 采集,输出送入 OpenGL ES 渲染管线(`EglCore` / `EglRenderLayer` / `FrameEglRenderer`) - 渲染管线完成**画面旋转、缩放与水印叠加**,再通过 `MediaCodec` 输入 Surface 实现硬件编码 - 编码输出经 `FrameDataCallback` 以 `ByteBuffer` + `size` 零拷贝回调给业务层 > ⚠️ 回调返回的 `ByteBuffer` 与 MediaCodec 输出缓冲区共享内存,**必须在回调返回前完成数据投递**。 ### 3. 音频采集与编码 - 使用 `AudioRecord` 采集原始 PCM(**48kHz** / 单声道 / 16bit,每帧 960 样本 = 20ms) - 选 48k 的原因:多数设备麦克风原生率即 48k(免设备侧二次重采样),且与 G.711 的 8kHz 成 6:1 整数比 - `AudioPipeline` 一次采集、两路扇出: - **G.711 分支**(给推流):`PcmResampler` 48k → 8k,凑满 160 样本后编码为 μ-law / A-law, 输出 160 字节/20ms,与 Native `G711_FRAME_SIZE = 160` 对齐 - **AAC 分支**(给本地录制):`AacEncoder` 独立线程攒满 1024 样本编码为 AAC-LC 48kHz / 96kbps - 每帧时间戳取 `System.nanoTime() / 1000`(微秒),与视频帧共用同一 CLOCK_MONOTONIC 时间基 ### 4. 拉流信令触发视频编码 **默认状态**:客户端仅进行本地预览,不执行编码和推流操作 **触发机制**:当国标平台下发拉流信令(INVITE + SDP)后,`StreamManager` 完成 SDP 协商并回调 `2100` ,此时启动编码推流: 1. **SDP 协商**:解析平台下发的 SDP,获取收流 IP / 端口、传输协议(TCP / UDP)、媒体类型 2. **视频编码**:`MediaCodec`(H.264 / H.265)输出完整帧数据,携带 `ptsUs` 与关键帧标记 3. **时间戳记录**:记录每帧编码完成时的时间戳(微秒) ### 5. 视频帧封装为 MPEG-2 PS 流(高复杂度环节) #### 5.1 格式探测与归一化 Native 侧先探测视频帧数据格式: - **AnnexB**(起始码 `00 00 00 01` / `00 00 01`):直接使用 - **AVCC**(长度前缀):调用 `frame_splitter::avcc_to_annex_b()` 转为 AnnexB - 其他:丢弃该帧 > ⚠️ 因此业务层**无需关心**编码器输出的是 AnnexB 还是 AVCC,Native 侧已统一处理。 #### 5.2 时间戳转换 ```cpp uint64_t pts_90kHz = static_cast(pts_us / 1000) * 90; ``` > ⚠️ **强制要求**:GB/T 28181-2016 规定必须使用 90kHz 时间基准,否则收流端无法解码导致无画面。 > 先除法再乘法,避免大时间戳溢出。 #### 5.3 NALU 拆分与关键帧处理 - 按起始码将整帧拆分为 NALU(H.264 取 NAL 头低 5 位,H.265 取第 1 字节的 6 位类型) - 提取并缓存参数集:H.264 缓存 SPS(7) / PPS(8);H.265 额外缓存 VPS(32) - 识别 IDR 帧:H.264 为 type 5;H.265 为 type 19(IDR_W_RADL)/ 20(IDR_N_LP) #### 5.4 IDR 帧封装流程 - H.264:`[起始码] + [SPS] + [PPS] + [IDR 帧]` → 封装为 PES 包 - H.265:`[起始码] + [VPS] + [SPS] + [PPS] + [IDR 帧]` → 封装为 PES 包 > ⚠️ **传输顺序强制要求**:IDR 帧必须先于任何其他帧发送,参数集缺失会导致平台无法解析画面。 > H.265 下若 VPS / SPS / PPS 三者缺一,该 IDR 帧会被直接丢弃。 #### 5.5 非 IDR 帧封装流程 组帧顺序:`[起始码] + [P 帧]` → 封装为 PES 包 #### 5.6 MPEG-2 PS 流最终封装 以 PES 包为载荷封装为 MPEG-2 PS 流: - **IDR 帧**:额外添加系统头(System Header)和 PSM(Program Stream Map) - **非 IDR 帧**:直接封装 视频 `Stream Type`:`0x1B`(H.264)/ `0x24`(H.265);音频 `Stream Type` 为 `0x91`(PCMU), 音频 `Stream ID` 使用私有流 `0xBD`。 > ⚠️ **字节级精度要求**:系统头与 PSM 的封装涉及复杂的字节与 Bit 位操作,每个字节的含义必须严格符合 > MPEG-2 PS 规范。 ### 6. 音频帧编码与 MPEG-2 PS 流封装 #### 6.1 两种投递方式 | 方式 | 调用 | 编码位置 | |--------|--------------------------------------|-------------------------------------------| | 已编码 | `pushAudioEncodedFrame(g711, ptsUs)` | Java 层 `G711Encoder`(推荐,A/μ 律可选) | | 原始 PCM | `pushAudioOriginalFrame(pcm, ptsUs)` | Native 层 `audio_processor::pcm_to_ulaw()` | #### 6.2 时间戳转换 ```cpp uint64_t pts_90kHz = static_cast(pts_us / 1000) * 90; ``` > ⚠️ **同步关键**:90kHz 时间基准是保证音画同步的核心要求。 #### 6.3 PES 与 PS 封装 整帧 G.711 数据直接封装为 PES 包,再封装为 PS 流。 > ⚠️ **重要区分**:音频帧均为非 IDR 帧,**无需添加**系统头、PSM 和起始码。 ### 7. PES 包 RTP 分片处理 #### 7.1 MTU 限制适配 受 RTP MTU 限制(约 1400 字节),需对较大的 PES 包进行分片。 #### 7.2 分片策略 阈值定义见 `global_definition.hpp`: | 常量 | 值 | 说明 | |------------------------------|------|--------------------| | `MAX_PES_PAYLOAD_PER_PACKET` | 1300 | 单个 RTP 包内 PES 负载上限 | | `MAX_RTP_PAYLOAD` | 1400 | 单个 PS 包最大尺寸 | | `MAX_RTP_PACKET` | 1412 | 完整 RTP 包最大长度 | - **PES 负载 ≤ 1300 字节**:直接作为单个 RTP 包发送 - **PES 负载 > 1300 字节**:按 RFC 3984 / GB/T 28181-2016 规范分片封装;系统头 / PSM 贯穿每一片, 末片置 RTP marker 位 #### 7.3 封装传输 > ⚠️ **协议一致性要求**:必须严格按照 SDP 协商的传输方式与端口发送,否则平台会丢弃数据包。 ### 8. 网络传输 #### 8.1 传输模式 - **TCP 模式**:连接可靠、抗丢包,适合网络环境不稳定场景 - **UDP 模式**:延迟更低,适合实时性要求高的场景 #### 8.2 协议协商 传输协议由平台 SDP 协商确定,客户端通过 `StreamManager` 动态适配,无需业务层干预。 --- ## 语音对讲流程 ### 1. 接收平台 Notify 语音对讲信令 - **信令监听**:`MessageHandler` 监听平台下发的 Notify 消息 - **参数解析**:识别 `CmdType` 为 `Broadcast` 的信令,解析出 `SourceID`、`TargetID` - **确认响应**:回复平台 200 OK ### 2. 初始化本地 TCP 客户端并发送 INVITE - **TCP 初始化**:`AudioReceiver` 初始化本地 TCP 监听,获取本地端口 - **SDP 构造**:按编码类型构造下行 SDP(`PCMA/8000` 或 `PCMU/8000`,`setup:active`) - **INVITE 发送**:携带 SDP 向平台发起 INVITE > ⚠️ **TCP 选择原因**:设备通常位于内网、平台位于公网,TCP 可建立双向连接,平台可通过该链路回传对讲数据; - **UDP 模式需自行实现 NAT 穿透或打洞,复杂度较高——暂未实现**。 ### 3. 等待平台回复 SDP 并建立连接 - 解析平台回复的 SDP,获取平台 TCP IP 与端口,回复 ACK - 连接平台指定地址,开始接收音频数据 > ⚠️ **高性能接收要求**:采用【独立接收线程 + 256KB 环形缓冲区】架构,避免高频数据接收成为系统瓶颈。 ### 4. 数据解码与播放 接收到的 G.711 数据 Android 端无法直接播放,Native 侧解码为 PCM 后回调: | 回调 | 数据类型 | 说明 | |----------------------------------|--------------|-------------------------------------| | `onAudioReceived(pcm)` | `ShortArray` | 已解码 PCM,可直接 `AudioTrack.write()` 播放 | | `onRawAudioReceived(g711, type)` | `ByteArray` | 原始裸流,`type`:0 = PCMU,8 = PCMA,需自行解码 | 解码后 PCM 通道(`onAudioReceived`)始终回调;原始 G.711 裸流通道(`onRawAudioReceived`)由 `GB28181Pusher.setAudioOutput(needG711)` 控制,不需要时可关闭以省去回调开销。 示例应用的播放链路: ``` onAudioReceived → IntercomPlayer.onPcmReceived() → 缓冲队列(≤50帧) → IO 协程 → AudioTrack(8kHz/MONO/16bit, MODE_STREAM) ``` `IntercomPlayer` 内部已 `copyOf()`,外部无需再拷贝;缓冲耗尽时补静音帧,队列满则丢最旧帧保实时。 > ⚠️ **回调线程注意**:音频回调在 Native 接收线程触发,业务层必须**先 `copyOf()` 再投递**,否则数据会被后续帧覆盖。 --- ## 第三方依赖编译 `pusher` 模块通过 `jniLibs` 引入预编译的 `libosipparser2.so`、`libosip2.so`、`libeXosip2.so`(三者均基于 Android 8 / API 26 编译)。 如需重新编译,可使用仓库根目录的交叉编译脚本: ```bash ./build_osip2.sh # osip2 + osipparser2 ./build_exosip2.sh # eXosip2(依赖 osip2) ./build_libyuv.sh # libyuv(如果使用 camera1 则需要自己编译此库,然后用它处理画面旋转、水印等一系列逻辑,较复杂且性能不好,不建议) ./build_x264.sh # x264(如果采用 ffmpeg 软编码,则需要自己编译此库,较复杂且性能不好,不建议) ``` 产物需按 ABI 放入 `pusher/src/main/jniLibs//`。 > 说明:当前视频链路已改为 Camera2 + OpenGL ES + MediaCodec 硬编, > `libyuv` 与 `x264` 不再是主链路的必需依赖,脚本保留仅供扩展使用。 ## 参考文档 - `状态机设计说明.md`:本地录制 / 推流双会话状态机(设计语义、状态转换矩阵、典型场景、数据流、关键实现细节) - `SIP指令.md`:SIP 信令交互示例(Catalog、DeviceInfo、DeviceStatus、DeviceControl、Broadcast 等) - `share/Android 端GB28181推流与语音对讲技术详解.md`:完整技术详解