You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

21 KiB

通话翻译 BLE 协议规范 v2(APP ↔ 固件 对接版)

用途:APP 与耳机固件重新联调通话翻译的接口契约。本文只描述双方约定的线上格式与时序。 代码基准:分支 newdev_chengguofeng,local_plugins/ble_service/(Android / iOS 行为一致)。 兼容性:下行音频格式为 v2 新格式,与 v1 不兼容,APP 与固件必须同时升级。


目录

  1. v2 变更摘要
  2. 链路总览与声道语义
  3. GATT 服务与特征
  4. 码率与帧长(贯穿全文的基础)
  5. 指令协议
  6. 会话时序
  7. 上行音频(设备 → APP)
  8. 下行音频(APP → 设备)★ 核心
  9. 可配置参数
  10. 异常与边界约定
  11. 联调验收清单
  12. 待双方确认
  13. 附: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. 待双方确认

  1. 上下行码率各定多少?(§4/§9)APP 默认上下行均 16 kbps(F=40 B),支持 8/16/24/32。上行码率必须与固件实际编码一致,否则 APP 会把所有上行包判成坏包。
  2. 上行结构?(§7)是否为 [左 F_up][右 F_up] 交替?APP 当前按 packetSize = 2 × F_up / 双声道解。
  3. 持续发送要不要默认打开?(§8.5)固件是否要求下行不断流?
  4. 节拍与帧数怎么定?(§9)默认 2 帧 / 40 ms(160 B)是否符合固件解码缓存的期望?需要大包低频(如 4 帧/80 ms)或小包高频(1 帧/20 ms)都可以,告知目标值即可。
  5. 静音帧能否正常解码?(§8.4)是标准 opus 帧,不是全 0 字节。
  6. 未进翻译态的下行写入能否安全丢弃?(§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/