跳到主要内容

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
BCNID1
逻辑 RATERATE7
PTT 激活物理 RF RATERATE7(与默认逻辑 RATE 一致)
群组Everyone,keyId=0
原 RF 查询超时1500ms
写入/回读验证超时2000ms

SDK 提供 16 个固定频道:

ChannelFrequency HzBCNID
14702500001
24752500002
34802500003
44852500004
54902500005
64952500006
75002500007
85052500008
94402000009
1044060000010
1144100000011
1244140000012
1344180000013
1444220000014
1544260000015
1644300000016

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,
)

必须传 applicationContextLowLatencyPttSdk 是实例对象,不是 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 必须提供:

能力AndroidiOS
稳定身份identity: Stringidentity: String
当前 readinessisReady: BooleanisReady: Bool
readiness 流StateFlow<Boolean>nonisolated AsyncStream<Bool>
设备 DIDdeviceDid: Long?deviceDid: UInt32?
AT 查询/写入executeAt(command, timeoutMs)executeAt(command, timeout)
RF no-wait 发送sendRfNoWait(payload)sendRfNoWait(payload)
幂等释放release()release()

Adapter 规则:

  1. acquireExclusiveSession 在资源被占用时报告 busy,不能共享可变的“当前 transport”。
  2. readiness 必须包含当前值;真实断连或底层 transport 被迫失效时及时发出 false
  3. SDK 持有 session 时,所有 AT 和 RF no-wait 都走同一 session。
  4. 正常切换 BLE/UART 前先完成 disable()close(),确认原 RF 已恢复并释放。
  5. 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/53 bytes:立即返回 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失败,需要读取 lastErrorrestorePending
closed / CLOSED终态,实例不可再次启用

错误:notConnectedpermissionDeniedbusyinvalidConfigurationrfConfigurationFailedaudioFailedsendFailedrestoreFailedclosed

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 但仍有待恢复/清理会话时返回 busysetAvailableGroups 若移除了当前选中项,两端都回退到 Everyone。

9. V2 空口格式

音频参数:8000Hz、mono、signed PCM16 little-endian;每个 MELP1200 frame 为 540 samples -> 11 bytes。

RATEMELP frames/packet音频 payload完整帧紧凑帧
RATE7222 bytes31 bytes25 bytes
RATE6444 bytes53 bytes47 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. 生命周期建议

  1. 建立并认证底层设备连接。
  2. 完成麦克风权限检查。
  3. 由宿主仲裁 RF/codec 资源,创建 transport adapter 和 SDK 实例。