Android Low Latency PTT SDK 接入指南
SDK Version: 1.0.0
1. SDK 负责什么
- 独占并持续持有一次稳定的 RF transport session。
- 事务切换和恢复
FREQ / RATE / BCNID;PTT 激活期间物理 RATE 与所选 RATE6/RATE7 一致。 - 8kHz、mono、PCM16 音频采集与播放。
- MELP1200 编解码、V2 完整/紧凑组包、有状态解析、去重和低延迟有界队列。
- Everyone 与四位数字 key 的私有群 profile。
- 状态、瞬时事件、收发/拒绝统计和最终资源清理。
宿主 App 负责:
- 设备连接、认证、初始化,以及具体 BLE/UART transport adapter。
- Android
RECORD_AUDIO/ iOS 麦克风运行时授权和用户提示。 - UI、设置持久化、前后台策略和音频路由产品策略。
- Low Latency PTT 与文件传输、Realtime Voice、普通 PTT、Mesh RF 操作的资源仲裁。
- 把 RF 入站 payload 先交给
tryHandleInbound,未消费的包再继续原路由。
2. 默认配置
默认构造配置与双端代码一致:
| 项目 | 默认值 |
|---|---|
| 频道 | Channel 1 |
| 频率 | 470,250,000 Hz |
| BCNID | 1 |
| 逻辑 RATE | RATE7 |
| PTT 激活物理 RF RATE | RATE7(与默认逻辑 RATE 一致) |
| 群组 | Everyone,keyId=0 |
| 原 RF 查询超时 | 1500ms |
| 写入/回读验证超时 | 2000ms |
SDK 提供 16 个固定频道:
| Channel | Frequency Hz | BCNID |
|---|---|---|
| 1 | 470250000 | 1 |
| 2 | 475250000 | 2 |
| 3 | 480250000 | 3 |
| 4 | 485250000 | 4 |
| 5 | 490250000 | 5 |
| 6 | 495250000 | 6 |
| 7 | 500250000 | 7 |
| 8 | 505250000 | 8 |
| 9 | 440200000 | 9 |
| 10 | 440600000 | 10 |
| 11 | 441000000 | 11 |
| 12 | 441400000 | 12 |
| 13 | 441800000 | 13 |
| 14 | 442200000 | 14 |
| 15 | 442600000 | 15 |
| 16 | 443000000 | 16 |
3. Android 接入
3.1 依赖
implementation("com.xtalk:low-latency-ptt-sdk:1.0.0")
在 Gradle 的 repositories 中加入交付清单提供的客户专用 Maven 仓库;本站不公开仓库 URL 或访问凭据。
业务工程只依赖上面的产品坐标,不应回退到 project(":low-latency-ptt-sdk") 或复制 App 源码。正式交付时 repository 地址与 codec 依赖的授权方式必须由发布包一并给出。
3.2 权限
独立 library 的 manifest 已声明录音权限;宿主仍必须在运行时请求并处理拒绝:
<uses-permission android:name="android.permission.RECORD_AUDIO" />
3.3 创建实例
val privateGroup = LowLatencyPttGroupProfile.group(
groupId = "ops",
title = "Operations",
channel = 7,
key4 = "7007",
)
val configuration = LowLatencyPttConfiguration(
channel = LowLatencyPttChannel.default,
rateMode = LowLatencyPttRateMode.RATE_7,
availableGroups = listOf(LowLatencyPttGroupProfile.everyone(), privateGroup),
selectedGroupId = LowLatencyPttGroupProfile.everyone().id,
)
val sdk = LowLatencyPttSdk(
applicationContext,
lowLatencyPttTransport,
configuration,
)
必须传 applicationContext。LowLatencyPttSdk 是实例对象,不是 App 全局 singleton。
3.4 观察状态并控制
val snapshotJob = scope.launch {
sdk.snapshot.collect { snapshot ->
render(snapshot)
}
}
val eventJob = scope.launch {
sdk.events.collect { event ->
handleTransientEvent(event)
}
}
try {
sdk.enable()
sdk.startTalking()
sdk.stopTalking()
sdk.disable()
} finally {
sdk.close()
snapshotJob.cancel()
eventJob.cancel()
}
所有控制方法都是 suspend:
sdk.enable()
sdk.disable()
sdk.startTalking()
sdk.stopTalking()
sdk.setChannel(channel)
sdk.setRateMode(rateMode)
sdk.setAvailableGroups(groups)
sdk.setGroup(groupId)
sdk.close()
SDK 控制方法的领域失败抛 LowLatencyPttException,通过 exception.error 读取 LowLatencyPttError。Coroutine cancellation 保持原生 CancellationException,不要把它当成领域错误重试。
模型与配置是在调用控制方法前完成的输入校验:LowLatencyPttGroupProfile.group(...)、LowLatencyPttConfiguration(...) 等构造/factory 对非法群组 ID、频道、key4 或未知选中项抛 IllegalArgumentException。接入方应在保存用户配置时单独处理这类校验失败;它不属于运行期 transport/RF/audio 的 LowLatencyPttException。
5. Transport 合同
Android 实现 LowLatencyPttTransport / LowLatencyPttTransportSession,iOS 实现同名 protocol。一次 enable() 必须取得一个独占、身份稳定的 session;从查询原 RF 配置到最终恢复/释放都不能切换底层 BLE/UART 对象。
Session 必须提供:
| 能力 | Android | iOS |
|---|---|---|
| 稳定身份 | identity: String | identity: String |
| 当前 readiness | isReady: Boolean | isReady: Bool |
| readiness 流 | StateFlow<Boolean> | nonisolated AsyncStream<Bool> |
| 设备 DID | deviceDid: Long? | deviceDid: UInt32? |
| AT 查询/写入 | executeAt(command, timeoutMs) | executeAt(command, timeout) |
| RF no-wait 发送 | sendRfNoWait(payload) | sendRfNoWait(payload) |
| 幂等释放 | release() | release() |
Adapter 规则:
acquireExclusiveSession在资源被占用时报告busy,不能共享可变的“当前 transport”。- readiness 必须包含当前值;真实断连或底层 transport 被迫失效时及时发出
false。 - SDK 持有 session 时,所有 AT 和 RF no-wait 都走同一 session。
- 正常切换 BLE/UART 前先完成
disable()或close(),确认原 RF 已恢复并释放。 release()必须幂等;iOS readiness stream 随后结束,Android readiness 停留在false。
AT 查询响应必须按设备原样交给 SDK,不要在 adapter 中改写字段。AT+RATE? 需要兼容两种已支持格式:标量 +RATE:n,以及四路配置完全一致时的 +RATE:n,n,n,n。四路值不一致或字段数量不是 1/4 时,SDK 会把它视为无效 RF 响应;AT+BCNID? 仍只接受单个整数,AT+FREQ? 仍要求四个完全相同的频点值。
公开 LowLatencyPttRateMode.RATE_6/RATE_7 同时决定 codec/协议档位和 PTT 激活期间
的硬件 RF rate:RATE7 写入并回读验证 AT+RATE=7,RATE6 写入并回读验证
AT+RATE=6。RATE7 与 RATE8 不能互通,SDK 不做跨档位映射。
snapshot.stats.verifiedRf.rateMode 发布实际选中并验证通过的 RATE6/RATE7。
6. RF 入站路由
把原始 RF payload 先交给 SDK:
val consumed = sdk.tryHandleInbound(payload, rssi, snr)
if (!consumed) routeToExistingConsumers(payload, rssi, snr)
let consumed = sdk.tryHandleInbound(payload, rssi: rssi, snr: snr)
if !consumed { routeToExistingConsumers(payload, rssi, snr) }
调用可发生在 RF callback 线程:Android 方法同步返回,iOS 方法为 nonisolated。SDK 只在 callback 路径检查 enabled/candidate、复制 payload 并入有界串行队列,不在该线程解码或更新 UI 状态。
路由语义:
- disabled / closed 或明显不是 V2 PTT candidate:返回
false。 - enabled 且首字节为
0x02、长度为25/31/47/53bytes:立即返回true。 - candidate 后续因缺少紧凑帧同步、rate、group、ck4、auth、echo、duplicate 或 queue-full 被拒绝,仍保持 consumed,避免误交给 Mesh/其他协议。
7. 状态、事件与错误
状态:
| 状态 | 含义 |
|---|---|
disabled / DISABLED | 未持有 RF/codec 资源 |
preparing / PREPARING | 正在应用、验证或回滚 RF 事务 |
idle / IDLE | 已启用且可收发 |
receiving / RECEIVING | 正在播放接收音频 |
error / ERROR | 失败,需要读取 lastError 与 restorePending |
closed / CLOSED | 终态,实例不可再次启用 |
错误:notConnected、permissionDenied、busy、invalidConfiguration、rfConfigurationFailed、audioFailed、sendFailed、restoreFailed、closed。
snapshot 是可靠状态源,events 只用于瞬时通知:state changed、failure、group changed 和 RX rejected。restorePending=true 表示仍有原始 RF 未被验证恢复:
ERROR + restorePending=true:实例仍持有可重试的 snapshot/session;宿主不得擅自切换 transport,可重试disable(),或执行close()的最终恢复流程。CLOSED + restorePending=true:这是不可由该实例重试的终态审计结果;重复close()和其他业务调用均无效。宿主必须隔离该 transport,并通过 adapter 的断开重连或产品恢复流程确认 RF 安全后才能重新分配,不能切换后假装已恢复。
一次异步 no-wait 发送失败会增加发送失败/丢包统计并报告 sendFailed,但不会自动把整个已启用 session 变成终止错误。
startTalking() 在已经处于 talking/TALKING 时是成功的无副作用操作:不会重新启动采集、清空既有错误/统计或重复发布事件。真正从 idle/receiving 进入讲话前,transport session 必须提供设备 DID;缺少 DID 时 SDK 先返回 invalidConfiguration,不会启动麦克风采集。
8. 群组与安全边界
Everyone 是内建 profile,keyId=0。私有群必须在两端使用完全相同的 groupId、channel 和四位数字 key4:
groupId: 可打印 ASCII,trim 后转大写
channel: 1...161
key4: 恰好四位 ASCII 数字
这里的 group profile channel 是现有群组配对凭据的一部分,只参与 keyId、auth 和 payload stream 的派生,不选择物理 RF。它必须保留 App 既有的 1...161 配对/历史频点槽位范围,并与通信对端完全一致。不要把它替换为 MeshWire publicChannel,否则会改变现有群组密钥。
它也不是 LowLatencyPttConfiguration.channel:后者才是 Low Latency PTT 启用时选择的物理频道,继续严格使用本 SDK 的 16 行频道表 1...16。两个字段名字相同,但取值域和职责不同。
公开 snapshot、description、reflection 和日志不暴露原始 key4 或派生 key bytes。私有群 payload 使用 key4 派生 stream;完整同步帧携带 auth16,紧凑帧继承已验证的群组同步状态并携带 ck4。Everyone payload 不加扰。
四位数字 key 的空间很小,该机制用于当前产品群组隔离和误包拒绝,不应被描述为高强度端到端加密、成员身份认证或抗主动攻击安全协议。
Android 与 iOS 的群组修改窗口一致:setAvailableGroups / setGroup 可在 disabled 且没有待清理会话时调用,也可在 SDK 已启用、stable session 仍 ready 且状态为 idle 时调用。正常的 enabled + idle 会话会以 restorePending=true 表示原 RF 尚待退出时恢复;这不会阻止本地群组修改。该修改只更新 snapshot、ingress 和音频 pipeline,不发送 AT、不切换 RF;talking、receiving、preparing、error,或 disabled 但仍有待恢复/清理会话时返回 busy。setAvailableGroups 若移除了当前选中项,两端都回退到 Everyone。
9. V2 空口格式
音频参数:8000Hz、mono、signed PCM16 little-endian;每个 MELP1200 frame 为 540 samples -> 11 bytes。
| RATE | MELP frames/packet | 音频 payload | 完整帧 | 紧凑帧 |
|---|---|---|---|---|
| RATE7 | 2 | 22 bytes | 31 bytes | 25 bytes |
| RATE6 | 4 | 44 bytes | 53 bytes | 47 bytes |
表中的 RATE6/RATE7 既是 MELP 聚合档位,也是激活期间写入设备的物理 rate。 两端必须选择同一档位;RATE7 与 RATE8 不能互通。
发送序号从 1 开始。序号 1、所有能被 16 整除的序号,以及 UInt16 wrap 后的 0 使用完整同步帧;其他序号使用紧凑帧。因此 RATE7 的正常节奏是 31, 25 x14, 31, ...,RATE6 是 53, 47 x14, 53, ...。
完整帧保留 V1 的 9-byte header:
offset size field encoding
0 1 frameCtrl 0x02
1 1 srcShort device DID low 8 bits
2 1 rtCtrl high 4 bits slotId; low 4 bits ck4
3 2 groupKeyId unsigned big-endian
5 2 sequence unsigned big-endian; first complete packet is 1
7 2 auth16 unsigned big-endian
9 N wirePayload RATE7=22B; RATE6=44B
紧凑帧使用 3-byte header:
offset size field encoding
0 1 frameCtrl 0x02
1 1 srcShort device DID low 8 bits
2 1 rtCtrl high 4 bits sequence low4; low 4 bits ck4
3 N wirePayload RATE7=22B; RATE6=44B
紧凑帧省略 groupKeyId(2) + sequence16(2) + auth16(2),因此比完整帧少 6 bytes。接收端按 srcShort 保存最近一次验证通过的完整帧同步状态,并用以下单向公式推导紧凑帧序号:
delta = (receivedLow4 - lastSequenceLow4) & 0x0F
sequence = (lastSequence + delta) & 0xFFFF
没有完整帧同步或 delta == 0 时直接拒绝,不枚举 sequence。fresh enable、disable、close、RF/RATE 重配置或群组内部定义变化都会清除同步;不同 srcShort 独立同步。
ck4 按明文 MELP payload 求和后取低 4 bit。私有群的 wirePayload 是派生 stream XOR 后的字节;Everyone 保持明文。完整帧的 auth16、keyId、stream,以及 V2 完整/紧凑帧均由 Android/iOS 共同读取的固定 fixture 逐字节约束。
RF 发送走 session 的 no-wait AT+SENDB 底层路径,不走 Mesh、ACK 或重传。AT 层的 binary escape 可能改变实际写入 transport 的字节数,但传给 sendRfNoWait 的 RF payload 必须按序号严格为 RATE7 的 31/25 bytes 或 RATE6 的 53/47 bytes。
V2 会改变 steady-state 空口字节,iOS 与 Android 必须同步升级;不能让只识别 V1 完整帧的一端与发送 V2 紧凑帧的一端混用。
SDK 在 fresh enable 事务中保存并验证启用前真实的 FREQ / RATE / BCNID 三元组;
激活目标使用所选固定频道的频率、对应 BCNID 和所选 RATE6/RATE7。disable() /
close() 恢复进入前保存的原三元组,包括原始真实硬件 rate,不用 PTT 会话档位
覆盖它。BCNID=0 可以作为启用前的默认/非消息配置被恢复,但不是任一正式 PTT
频道的启用值。
10. 生命周期建议
- 建立并认证底层设备连接。
- 完成麦克风权限检查。
- 由宿主仲裁 RF/codec 资源,创建 transport adapter 和 SDK 实例。