21 KiB
通话翻译 BLE 协议规范 v2(APP ↔ 固件 对接版)
用途:APP 与耳机固件重新联调通话翻译的接口契约。本文只描述双方约定的线上格式与时序。 代码基准:分支
newdev_chengguofeng,local_plugins/ble_service/(Android / iOS 行为一致)。 兼容性:下行音频格式为 v2 新格式,与 v1 不兼容,APP 与固件必须同时升级。
目录
- v2 变更摘要
- 链路总览与声道语义
- GATT 服务与特征
- 码率与帧长(贯穿全文的基础)
- 指令协议
- 会话时序
- 上行音频(设备 → APP)
- 下行音频(APP → 设备)★ 核心
- 可配置参数
- 异常与边界约定
- 联调验收清单
- 待双方确认
- 附:APP 侧实现位置
1. v2 变更摘要(固件重点看这里)
| 项 | v1(旧) | v2(新) |
|---|---|---|
| 下行包头 | [4B 序号(小端)][1B 声道] 共 5 字节 |
无包头,整包纯 opus 帧数据 |
| 下行声道组织 | 一包只装一个声道(左包 / 右包分别发) | 一包同时装左右,帧级交替 L R L R … |
| 下行包长 | 5 + N×40,变长 |
恒定 2 × N × F 字节 |
| 下行帧数 | 5 帧/包(单声道) | 2 帧/声道/拍(默认,可调 1–20) |
| 下行节拍 | 80 ms,左右两路各发一包、错峰 40 ms | 40 ms(默认),单路统一发送 |
| 某声道无数据 | 该声道不发包 | 静音帧补齐,保证左右严格对位、长度恒定 |
| 完全无音频时 | 不发 | 可配置持续发送全静音包(默认关) |
| 单帧字节数 | 固定 40 B | 由码率决定的变量 F,上下行各自可配 |
| F4/F5 反馈调速 | 曾设计过 | 已移除,固件无需上报解码空余 |
固件唯一必须重写的是下行解包逻辑:不要再剥 5 字节包头,改为按单帧字节数 F 切分、左右交替取帧。指令协议、上行音频、UUID 均不变。
2. 链路总览与声道语义
BLE
┌──────────┐ 上行原声(立体声 opus) ┌──────────┐
│ │ ───────── notify ───────▶ │ │
│ 耳机 │ │ APP │ → 端到端语音翻译(ASR+翻译+TTS)
│ 固件 │ ◀──────── write ───────── │ │
│ │ 下行译音(左右交替 opus) └──────────┘
└──────────┘
- 上行:耳机把「本端麦克风原声 + 对方通话原声」合成立体声 opus 上报给 APP。
- 下行:APP 把两路译音编码后写回耳机播放。
声道语义(上下行含义不同,务必区分):
| 左声道 | 右声道 | |
|---|---|---|
| 上行(设备→APP) | 本端麦克风原声 | 对方通话原声 |
| 下行(APP→设备) | 本端译音(说给对方听) | 对端译音(说给本端听) |
APP 只负责按上表填左右;最终左/右播到哪只耳、如何与通话原声混音,由固件决定。
3. GATT 服务与特征
| 服务 | UUID | 特征 | UUID | 方向 | 属性 |
|---|---|---|---|---|---|
| 主服务 | 0000ABC0-0000-1111-2222-123456789ABC |
WRITE | 0000ABC1-0000-… |
APP → 设备 | Write No Response |
| NOTIFY | 0000ABC2-0000-… |
设备 → APP | Notify | ||
| 通话音频服务 | 0000ABC0-0001-1111-2222-123456789ABC |
CALL_WRITE | 0000ABC1-0001-… |
APP → 设备(下行译音) | Write No Response |
| CALL_RECEIVE | 0000ABC2-0001-… |
设备 → APP(上行原声) | Notify |
- CCCD:
00002902-0000-1000-8000-00805f9b34fb - MTU:APP 连接后请求 517(→ 单次写净荷上限 514 B)
- APP 还会尝试
CONNECTION_PRIORITY_HIGH与 PHY 2M(失败自动回退 1M)
4. 码率与帧长(贯穿全文的基础)
opus 帧时长恒为 20 ms(与码率无关)。CBR 模式下:
单帧字节数 F = 码率(kbps) × 20 ms ÷ 8
| 码率 | 单帧字节 F | 一次 BLE 写最多几帧(514 ÷ 2F) |
|---|---|---|
| 8 kbps | 20 B | 12 |
| 16 kbps(默认) | 40 B | 6 |
| 24 kbps | 60 B | 4 |
| 32 kbps | 80 B | 3 |
上行、下行各有独立码率,均可在 APP 侧配置:
F_down:下行单帧字节,APP 编码器用,固件解码器用。F_up:上行单帧字节,固件编码器用,APP 解码器用 —— 必须与固件实际编码码率一致(见 §7)。
⚠️ 固件不要把 40 硬编码。 请按码率推算
F,或按F = 包长 ÷ (2 × N)推算。这样 APP 侧调码率时固件无需改代码。
5. 指令协议
5.1 帧格式
APP 请求 : 0xAA [CMD] [LEN] [DATA…] [CRC8]
设备响应 : 0xBB [CMD] [LEN] [DATA…] [CRC8]
设备主动上报: 0xCC [CMD] [LEN] [DATA…] [CRC8]
LEN= DATA 字节数(不含帧头/CMD/LEN/CRC);整帧长 =LEN + 4。CRC8覆盖从帧头(含)到 DATA 末尾,不含 CRC 自身。
5.2 CRC8 算法(勿套用标准库的 "CRC-8/MAXIM")
APP 代码里函数名叫 calculateCrc8Maxim,但实现是非反射版:多项式 0x31、初值 0x00、MSB-first、不反射输入输出、无最终异或。
标准 CRC-8/MAXIM 是反射版(0x8C),结果不同。请直接照抄下面的实现:
uint8_t crc8(const uint8_t *data, int len) {
uint8_t crc = 0x00;
for (int i = 0; i < len; i++) {
crc ^= data[i];
for (int j = 0; j < 8; j++) {
crc = (crc & 0x80) ? (uint8_t)((crc << 1) ^ 0x31) : (uint8_t)(crc << 1);
}
}
return crc;
}
5.3 通话翻译相关指令
| CMD | 名称 | 方向 | 说明 |
|---|---|---|---|
| 0x05 | CONTROL_CODEC | APP → 设备 | 编解码控制(见下) |
| 0x16 | CALL_TRANSLATION_ON | 设备 → APP(0xCC) |
设备侧(按键等)请求开启通话翻译 |
| 0x17 | CALL_TRANSLATION_OFF | 设备 → APP(0xCC) |
设备侧请求关闭通话翻译 |
0x05 的 DATA = [子命令, 声道模式]
| 子命令 | 值 | 含义 |
|---|---|---|
| CLOSE | 0x00 |
关闭编解码(结束通话翻译) |
| A2DP_PLAY | 0xA2 |
开启通话翻译:mic + dac 声音上行,并接收翻译后音频 |
| DECODE_ON | 0xA1 |
仅解码(音乐/通话远端),非通话翻译 |
| CALL_RECORD_PLAY | 0xA3 |
通话录音,非通话翻译 |
声道模式:0x01=左 / 0x02=右 / 0x03=立体声(通话翻译固定用这个)
实际收发字节:
开启:APP → AA 05 02 A2 03 [CRC]
设备 → BB 05 02 A2 03 [CRC] ← 期望回显子命令与声道
关闭:APP → AA 05 02 00 03 [CRC]
设备 → BB 05 02 00 03 [CRC]
5.4 ACK 约定
APP 侧指令串行:发出一条后等 0xBB 响应才发下一条,超时 1 秒自动放行队列。
请确保每条 0xAA 请求都回一条 0xBB,否则会拖慢后续指令。
6. 会话时序
sequenceDiagram
participant D as 耳机固件
participant A as APP
Note over A,D: ① 开启(APP 主动 或 设备按键触发)
D-->>A: 0xCC 16 …(可选:设备侧请求开启)
A->>A: 启动上行解码器 + 下行编码器 + 发送线程
A->>D: AA 05 02 A2 03(CONTROL_CODEC / A2DP_PLAY / 立体声)
D-->>A: BB 05 02 A2 03
Note over A,D: ② 会话中(双向音频流并行)
loop 持续
D-->>A: notify CALL_RECEIVE:上行立体声 opus(本端 mic + 对方声)
A->>A: 拆左右 → 两路端到端翻译 → 两路译音 PCM → opus 编码
A->>D: write CALL_WRITE:下行 opus(左右帧级交替,无包头)
end
Note over A,D: ③ 关闭
D-->>A: 0xCC 17 …(可选:设备侧请求关闭)
A->>D: AA 05 02 00 03(CONTROL_CODEC / CLOSE)
D-->>A: BB 05 02 00 03
A->>A: 停发送线程、停编解码、清空队列
⚠️ APP 在发出
0xA2之前就已启动下行发送线程。若「持续发送」开关是开的,固件可能在收到0xA2前就收到下行写入 —— 固件应能安全丢弃未进入翻译态时收到的音频包。
7. 上行音频(设备 → APP)
| 项 | 约定 |
|---|---|
| 特征 | CALL_RECEIVE(0000ABC2-0001-…)notify |
| 编码 | Opus,CBR,帧时长 20 ms |
| 采样率 | 16 kHz |
| 声道 | 立体声(左 = 本端麦克风原声,右 = 对方通话原声) |
| 包头 | 无 |
| 单帧字节 | F_up(由上行码率决定,默认 16 kbps → 40 B) |
| APP 解码配置 | hasHeader=false, channel=2, sampleRate=16000, packetSize = 2 × F_up(默认 80) |
| 包长校验 | APP 丢弃长度不是 F_up 整数倍的包(防止错位污染流式解码器) |
上行码率必须双方一致。 APP 用它推 F_up 来做收包校验和解码器切分。固件若改了上行码率而 APP 没同步,所有上行包都会被判成坏包丢掉(表现:完全收不到对方声音)。
⚠️ 待固件确认:APP 按
packetSize = 2 × F_up的双声道流解码,即每2 × F_up字节为一个立体声单元。请确认固件上行的实际组织方式是[左 F_up][右 F_up]交替(与下行对称)。若不是,APP 侧解码参数需相应调整。
8. 下行音频(APP → 设备)★ 核心
| 项 | 约定 |
|---|---|
| 特征 | CALL_WRITE(0000ABC1-0001-…)Write Without Response |
| 编码 | Opus,CBR,单声道编码器 ×2(左右各一路,互相独立) |
| 采样率 | 16 kHz |
| 帧时长 | 恒定 20 ms/帧 |
| 单帧字节 | F(由下行码率决定,默认 16 kbps → 40 B) |
| 包头 | 无 |
| 包结构 | 左右帧级交替 |
| 一拍字节数 | 2 × N × F(N = 每声道帧数,默认 N=2、F=40 → 160 B) |
8.1 包结构
通式(N 帧/声道,每帧 F 字节):
[L0][R0][L1][R1] … [L(N-1)][R(N-1)] 每个 [] 恒为 F 字节
默认配置(N=2,F=40B,16 kbps)→ 160 字节:
偏移: 0 40 80 120 160
┌────────┬────────┬────────┬────────┐
│ L0 │ R0 │ L1 │ R1 │
│ 40B │ 40B │ 40B │ 40B │
└────────┴────────┴────────┴────────┘
左 右 左 右
同样 N=2,但下行码率降到 8 kbps(F=20B)→ 只有 80 字节:
偏移: 0 20 40 60 80
┌────┬────┬────┬────┐
│ L0 │ R0 │ L1 │ R1 │ 每帧 20B
└────┴────┴────┴────┘
- 每帧都是完整、独立可解的 opus 帧。
- 左右两路要送进两个独立的 opus 解码器(不要用一个立体声解码器解)。
8.2 固件解包伪代码
#define OPUS_FRAME_MS 20
// 由码率推单帧字节:16→40, 8→20, 24→60, 32→80
static inline int frame_bytes_of(int bitrate_kbps) {
return bitrate_kbps * OPUS_FRAME_MS / 8;
}
// g_downlink_frame_bytes = frame_bytes_of(约定的下行码率)
void on_downlink_packet(const uint8_t *packet, int len) {
const int F = g_downlink_frame_bytes;
if (F <= 0 || len % (2 * F) != 0) { /* 异常包,丢弃 */ return; }
int n = len / (2 * F); // 本包帧数/声道,动态推算
for (int i = 0; i < n; i++) {
const uint8_t *left = packet + (i * 2) * F;
const uint8_t *right = packet + (i * 2 + 1) * F;
opus_decode(dec_left, left, F, pcm_left, FRAME_SAMPLES, 0);
opus_decode(dec_right, right, F, pcm_right, FRAME_SAMPLES, 0);
// 播放 / 混音
}
}
不要假设 N 固定为 2,也不要假设 F 固定为 40。 N 按
len / (2×F)推,F 按码率推。这样 APP 调参时固件无需改代码。
8.3 一拍可能被拆成多个 BLE 写
BLE 单次写净荷上限是 MTU-3(514 B)。当一拍的数据(2 × N × F)超过它时,APP 按「左右一对」(2 × F) 对齐拆成多个 BLE 写连续发出。
例:N=20、F=40B → 一拍 1600 B → 拆 4 个写(6 + 6 + 6 + 2 帧)。
这对固件完全透明,不需要任何特殊处理:
- 每个分片本身仍是
2 × F的整数倍,左右交替顺序不变; - 所以固件无论「每个 BLE 包独立解析」还是「当成连续字节流解析」,结果都正确;
- §8.2 的解包函数原样适用。
8.4 静音填充(关键约定)
APP 的左右两路译音是异步产生的,任何一拍都可能只有一边有数据。此时 APP 不会缩短包长,而是用一帧编码好的静音 opus 帧填满缺的位置。
这意味着:
- 包长永远恒定、左右永远严格对位,固件可无条件按固定偏移切分;
- 静音帧是合法的 opus 帧(由编码器编码零 PCM 得到,不是全 0 字节),正常解码即得静音 PCM;
- 只有一边说话时,另一边持续收到静音帧 —— 这是正常现象,不是丢包。
8.5 持续发送开关
| 状态 | 行为 |
|---|---|
| 关(默认) | 左右都没有译音时,该拍不发包,链路空闲 |
| 开 | 左右都没有译音时,仍按节拍发全静音包,保持设备侧音频流不断 |
若固件的解码/播放管线要求音频流不能断(断流会导致 underrun、PLL 失锁、时基丢失等),告诉我们,把默认值改成「开」。
8.6 带宽
| 配置 | 一拍字节 / 节拍 | 带宽 |
|---|---|---|
| 16 kbps,N=2,40 ms | 160 B / 40 ms | 4000 B/s = 32 kbps(左右各 16k) |
| 8 kbps,N=2,40 ms | 80 B / 40 ms | 2000 B/s = 16 kbps(左右各 8k) |
9. 可配置参数(APP 侧运行时可调)
APP 内置调试页(通话翻译调试)可实时下发,无需重连,联调时可现场改:
| 参数 | 默认 | 范围 | 说明 |
|---|---|---|---|
downlinkBitrateKbps |
16 | 8 / 16 / 24 / 32 | 下行码率 → F = kbps × 2.5 |
uplinkBitrateKbps |
16 | 8 / 16 / 24 / 32 | 上行码率,必须与固件实际编码一致 |
bundleFrameCount |
2 | 1 – 20 | 每声道每拍帧数 N;一拍 = 2 × N × F 字节 |
sendIntervalMs |
40 | 5 – 200(步长 5) | 发送节拍 |
continuousSend |
关 | 开 / 关 | 无译音时是否持续发静音包 |
9.1 节拍必须与帧数匹配
每帧恒为 20 ms 音频(与码率无关),所以:
节拍(ms) == N × 20 → 下发速率与真实语速 1:1(推荐)
节拍 > N × 20 → 偏慢,译音在 APP 侧积压,延迟越来越大
节拍 < N × 20 → 偏快,设备侧解码缓存持续上涨,可能溢出
速查(16 kbps / F=40B):
| N(帧/声道) | 一拍字节 | 匹配节拍 | BLE 写次数 |
|---|---|---|---|
| 1 | 80 B | 20 ms | 1 |
| 2(默认) | 160 B | 40 ms | 1 |
| 4 | 320 B | 80 ms | 1 |
| 6 | 480 B | 120 ms | 1 |
| 10 | 800 B | 200 ms | 2 |
| 20 | 1600 B | 400 ms | 4 |
⚠️ N > 10 配不出 1:1 节拍:节拍上限是 200 ms,而 N=20 需要 400 ms。此时只能超发(设备侧缓存会涨)。若确实要用大 N,需把节拍上限放开 —— 告诉我们即可。
9.2 改码率会重启编码器
opus 编码器的 packetSize 在启动时固化。APP 改下行码率时会:停编码流 → 清空左右发送队列 → 作废旧静音模板 → 用新帧长重启编码流。
避免新旧帧长混进同一包导致整包错位。表现为下行有一次极短(数十毫秒)停顿,属正常。停顿之后包长按新 F 变化,固件需按新码率同步更新 F。
10. 异常与边界约定
| 场景 | APP 侧行为 | 对固件的影响 / 要求 |
|---|---|---|
| BLE 写拥塞(底层缓冲满) | 该分片不发,尚未发出的帧退回队头,下一拍续发。不丢译音 | 已发出的分片自身对齐、顺序正确,字节流不错位;剩余帧下一拍补上 |
| 单帧长度异常(≠ 当前 F) | 入队时直接丢弃该帧 | 保证固件收到的包长永远是 2 × F 的整数倍 |
| 静音帧模板未就绪(编码器刚启动的极短窗口) | 整拍不发,帧退回队列 | 会话最开始可能有几十 ms 无下行,属正常 |
| 运行中改码率 | 重启编码器、清队列、重建静音模板 | 短暂停顿;之后包长按新 F 变化 |
| 意外断连 | 1 s 后主动回连(最多 5 次);期间译音继续缓存不丢;回连成功后 1600 ms 重下 0xA2 并续发 |
固件回连后需能重新接受 0xA2 并恢复播放 |
| 会话结束 | 停发送线程、清空左右队列 | 下行立即停止 |
| 未进翻译态就收到下行包 | 「持续发送」开启时可能发生(见 §6) | 固件应容忍并丢弃 |
11. 联调验收清单
按顺序逐项验证,每项都能独立定位问题:
- 0. 码率对齐:双方确认上、下行码率各是多少,据此各自算出
F(16 kbps → 40 B)。这是一切的前提。 - 1. 指令通路:APP 发
AA 05 02 A2 03,固件回BB 05 02 A2 03;关闭同理。CRC 双方一致。 - 2. 上行通路:固件上报立体声 opus,APP 能解出左右两路(左=本端 mic,右=对方声),无「长度非
F_up倍数」告警。 - 3. 下行包长:固件收到的每包长度恒为
2 × N × F(默认 160 B),且始终是2 × F的整数倍。 - 4. 下行对位:固件按
F切分,偶数块喂左解码器、奇数块喂右解码器,两路都解码成功(opus_decode无错误返回)。 - 5. 单边说话:只有本端说话时,右声道收到静音帧(可解码、内容为静音),不应报错或丢包。
- 6. 双向同传:本端说话 → 对方听到译音;对方说话 → 本端听到译音;左右不串台。
- 7. 节拍稳定:固件侧收包间隔稳定在 40 ms 附近,解码缓存水位不持续上涨、也不饿死。
- 8. 长时间跑:连续 10 分钟无缓存溢出、卡顿、左右错位。
- 9. 断连恢复:手动断连后自动回连,
0xA2重下发,音频恢复。 - 10. 持续发送开关:打开后,无人说话时固件仍持续收到全静音包,播放无杂音。
- 11. 大帧数拆写(可选):把 N 调到 20,固件收到的是多个 480 B/160 B 的包,解码播放仍正常。
- 12. 切码率(可选):APP 切到 8 kbps 后包长变 80 B,固件按新
F=20B切分仍正常。
联调时 APP 侧日志会打印每拍的:包序、左右真实帧数、静音补齐数、总字节、拆写次数、队列积压。出问题可直接对时间戳定位是哪一侧。
12. 待双方确认
- 上下行码率各定多少?(§4/§9)APP 默认上下行均 16 kbps(F=40 B),支持 8/16/24/32。上行码率必须与固件实际编码一致,否则 APP 会把所有上行包判成坏包。
- 上行结构?(§7)是否为
[左 F_up][右 F_up]交替?APP 当前按packetSize = 2 × F_up/ 双声道解。 - 持续发送要不要默认打开?(§8.5)固件是否要求下行不断流?
- 节拍与帧数怎么定?(§9)默认 2 帧 / 40 ms(160 B)是否符合固件解码缓存的期望?需要大包低频(如 4 帧/80 ms)或小包高频(1 帧/20 ms)都可以,告知目标值即可。
- 静音帧能否正常解码?(§8.4)是标准 opus 帧,不是全 0 字节。
- 未进翻译态的下行写入能否安全丢弃?(§6)
13. 附:APP 侧实现位置(备查)
| 内容 | 文件 · 符号 |
|---|---|
| 下行组包 / 分片写 / 静音补齐 | ble_service/android/…/BleService.kt → trySendMixedChunk / buildInterleavedPacket / maxFramesPerWrite |
| iOS 同构实现 | ble_service/ios/…/BleService.swift → 同名方法 |
| 码率 ↔ 帧长换算、改码率重启编码器 | BleService.kt → bitrateToFrameBytes / applyDownlinkBitrate / applyUplinkBitrate |
| 上行收包校验 | BleService.kt → onCharacteristicChanged / isValidUplinkAudioLen |
| 指令协议 / CRC8 | ble_service/android/…/BleCommandSender.kt → createCommandPacket / calculateCrc8Maxim |
| UUID / 指令常量 | ble_service/android/…/BleConst.kt |
| 调试页(现场调参) | lib/modules/call_translation_debug/ |