13 KiB
Echo-one 面对面翻译 · 双耳 BLE 协议(App 当前实现)
日期:2026-10-10
对象:固件
对应 App:已按本文组包、解析。请对照固件实现逐条核对。
更早的讨论稿见 face_to_face_ble_protocol.md(草案 v0.2)。以本文为准,不一致的地方按本文改固件或回我。
1. 形态
两个人各戴一只耳机。TWS 拆开后,左耳、右耳各自一条 BLE 连到同一部手机。手机不收音、不放音,只做字幕和转发。
| 谁在说 | 麦从哪来 | 云端 | 译文播到哪 |
|---|---|---|---|
| 戴左耳的人(源语言) | 左耳 BB 90,EAR=1 |
A 路 | 右耳 AA 91,EAR=2 |
| 戴右耳的人(目标语言) | 右耳 BB 90,EAR=2 |
B 路 | 左耳 AA 91,EAR=1 |
两条 BLE 互相独立,可以同时上行。经典蓝牙只用来进出这个模式(AA 6B / AA 6C),不传面对面的音频。
旧的面对面 AA 66 / AA 67 不动,和新模式互斥。通话翻译、AI、录音、OTA 也不并行。正在做这些事时,AA 6B 应回 ERR_BUSY (0x02)。
2. 两条 BLE 通道
左右耳的 GATT 完全一样。耳位不靠 UUID 区分,靠广播里的 EAR,以及音频帧低 4 位。
| 特征 | UUID | 属性 | 方向 |
|---|---|---|---|
| Service | AEAF5F32-0001-4C4B-A100-000000000001 |
— | 扫描按这个 UUID 过滤 |
CTRL_TX |
AEAF5F32-0002-4C4B-A100-000000000001 |
Write Without Response | App → 耳机,握手 / 配置 / 保活 / 退出 |
CTRL_RX |
AEAF5F32-0003-4C4B-A100-000000000001 |
Notify | 耳机 → App,握手应答 / 事件 |
AUDIO_UP |
AEAF5F32-0004-4C4B-A100-000000000001 |
Notify | 耳机 → App,本耳麦,BB 90 |
AUDIO_DOWN |
AEAF5F32-0005-4C4B-A100-000000000001 |
Write Without Response | App → 耳机,译文,AA 91 |
控制和音频必须分开。停流、打断如果排在音频写后面,点结束会没反应。
链路要求(每条 LE 各自满足):
| 项 | 要求 |
|---|---|
| 写类型 | CTRL_TX、AUDIO_DOWN 都认 Write Without Response |
| ATT MTU | App 请求 247。40ms 包大约 86 字节,MTU 至少要装得下一包 |
| PHY | 优先 2M,不行再 1M |
| 连接间隔 | 15~30ms。大于 40ms 译文会断 |
| Slave latency | 0 |
| Supervision timeout | 2~4s。App 被杀掉时来不及发退出,靠这条超时让固件自己恢复 |
BLE 帧格式(和经典蓝牙同形,操作码空间独立):
AA/BB | OP | LEN | payload
LEN 是 payload 的字节数,不含这 3 字节头。
3. 启动时序
耳机平时不要广播这个服务。只有收到经典链路上的 AA 6B 才拆 TWS、开始广播。
App 经典蓝牙 左耳 BLE 右耳 BLE
│ AA 6B │ │ │
│──────────────────────────►│ 拆 TWS,两耳广播 │ │
│ BB EB │ │ │
│◄──────────────────────────│ │ │
│ 扫描,TOKEN 对上,集齐左右耳后分别连接 │ │
│ 订阅 CTRL_RX、AUDIO_UP │ │ │
│───────────────────────────────────────────────►│─────────────────►│
│ HELLO → CFG → START(每只耳朵各一遍) │ │
│ BB 90 左耳麦 │ │ │
│◄═══════════════════════════════════════════════│ │
│ AA 91 译文给右耳 │ │ │
│══════════════════════════════════════════════════════════════════►│
│ BB 90 右耳麦 / AA 91 译文给左耳,方向相反 │ │
两只耳朵的握手互不等待。两只都回了 BB 83 才开始推音频。 12 秒内没握完,App 整场退出。
4. 经典蓝牙:进入 / 退出
走现有经典链路(Android SPP,iOS GATT over BR/EDR)。不是 BLE。
4.1 进入 AA 6B
App 每次进入重新生成 4 字节随机 TOKEN。
AA 6B 06 02 01 <TOKEN 4 字节>
| 字节 | 值 | 含义 |
|---|---|---|
| PROTO_VER | 0x02 |
本协议版本 |
| CAP | 0x01 |
只要 G.722 |
App 等 BB EB 的上限是 3 秒。超时就放弃,并补发 AA 6C。
4.2 应答 BB EB
整帧 10 字节:
BB EB 07 <RESULT> <EAR_MASK> <PROTO_VER> <CODEC> <FRAME_MS> <FW_MINOR> <CLASSIC_KEEP>
| 字段 | App 怎么用 |
|---|---|
| RESULT | 0x00 才继续扫 BLE。非 0 按第 8 节提示用户,并退出 |
| EAR_MASK | bit0 = 左耳可用,bit1 = 右耳可用。必须是 0x03。缺一只不进入,请直接回 0x03,不要拆了一半 |
| CODEC | 0x01 = G.722 |
| FRAME_MS | 20 或 40。决定一包里几帧。其它值 App 按 40 处理 |
| FW_MINOR | 固件小版本,App 记下来,暂不分支 |
| CLASSIC_KEEP | 0x01 = 拆 TWS 后经典链路还在;0x00 = 经典链路会断 |
CLASSIC_KEEP=0 时,App 不当成用户把耳机断开。退出命令改走每只耳朵的 AA 06。下次经典蓝牙再连上,App 会先补发一次 AA 6C。
4.3 退出 AA 6C
AA 6C → BB EC 01 <RESULT>
AA 6C 没有长度字节。
必须幂等:当前不在面对面模式,也要回 BB EC 01 00,不要报错。App 在异常恢复时会无条件补发。
RESULT=0x00 表示已重新组对,A2DP / HFP 恢复。
5. 广播与扫描
收到 AA 6B 且 RESULT=0、双耳都可用之后,左右耳各自广播。
厂商数据(AD Type 0xFF)。Company ID 2 字节之后是下面 9 字节:
F2 F2 | VER | EAR | TOKEN 4 字节 | BATT
| 字段 | 要求 |
|---|---|
| MAGIC | 固定 F2 F2 |
| VER | 0x02,和 AA 6B 的 PROTO_VER 一致 |
| EAR | 0x01 左耳,0x02 右耳。两只耳机的名字可以相同 |
| TOKEN | 原样回显这次 AA 6B 的 4 字节。对不上 App 直接忽略 |
| BATT | 低 7 位电量 0~100,最高位充电标志 |
App 扫描规则:
- 只扫上面的 Service UUID。
- TOKEN 必须和这次
AA 6B逐字节相同。旁边另一副同型号耳机不能连上。 - 必须同时看到
EAR=1和EAR=2才连接。 15 秒没集齐就发AA 6C回滚。 - iOS 拿不到 BLE MAC,身份只认 TOKEN + EAR。
- 扫描在 App 前台完成。建议建链期广播间隔 30~50ms。
- 30 秒内没被连上,该耳自己退出面对面;两耳都退出后重新组对,并在经典链路上报
BB EC 01 00。
连上之后该耳停止广播。
6. 每只耳朵的握手
GATT 连上后 App 会:请求 MTU 247、连接优先级拉高、优先 2M PHY。
通知顺序:先 CTRL_RX,再 AUDIO_UP。两个通知都开了才发 HELLO。
左右耳各走一遍,内容相同,TOKEN 相同。
| 步骤 | App 发出 | 耳机应答 | 说明 |
|---|---|---|---|
| 1 | AA 01 06 02 <TOKEN 4B> 01 |
BB 81 … |
HELLO。最后一字节 01 表示角色是 App。TOKEN 再对一次 |
| 2 | AA 02 03 00 50 00 |
BB 82 … |
配置。见下表 |
| 3 | AA 03 00 |
BB 83 … |
开流。应答成功后开始推 BB 90 |
| 4 | AA 05 01 <SEQ> |
可不回 | 两只都 BB 83 之后,每 5 秒一次。只做诊断,不要因为没收到保活就断链 |
AA 02 的 3 字节 payload:
| 字节 | 当前固定值 | 含义 |
|---|---|---|
| VAD_MODE | 0x00 |
常发。静音也发,不要在耳机上切句 |
| DOWN_GAIN | 0x50(80) |
下行播放增益 0~100 |
| TRIGGER | 0x00 |
免按键,连续拾音 |
HELLO 应答 BB 81:App 只看 RESULT 是不是 0。RESULT 非 0,这只耳朵判失败,整场退出。
FRAME_MS 以 BB EB 里那个字节为准,不从 BB 81 再改一回。
BB 82 / BB 83 的 RESULT 非 0,同样整场退出。
开流之后如果要暂停而不断开,用 AA 04 00(STOP_STREAM)。当前正常结束不发这条,直接走第 7 节退出。
7. 退出
顺序:
- 每只还连着的耳朵,
CTRL_TX发AA 06 00。耳机停流、断开这条 BLE、准备重新组对。 - 经典链路还在,再发
AA 6C,等BB EC 01 00。 - 经典链路已经断了(
CLASSIC_KEEP=0):这次只发AA 06。App 记下标记,下次经典蓝牙一连上就补发AA 6C。
固件收到 AA 06、AA 6C,或者下面任一条件,都要自己恢复,不能停在面对面模式:
| 条件 | 谁负责 |
|---|---|
| 广播 30 秒没人连 | 固件 |
| LE 断连(supervision timeout 2~4s,含 App 被杀) | 固件。这是 App 被杀掉时的唯一恢复手段 |
| App 主动结束 | App 发 AA 06,再视情况发 AA 6C |
恢复要做完这些:停广播、断这条 GATT、两耳重新组对、A2DP / HFP 能用、按键和降噪回到进入前的状态。CLASSIC_KEEP=0 的固件,组对完成后要主动把经典链路连回手机。
单耳 BLE 断开:App 结束整场,不再单耳继续翻。
来电:耳机在 CTRL_RX 上报第 9 节的退出事件,原因 0x03。App 结束会话并提示用户。固件恢复 HFP,把电话交给系统。
8. 音频
编码固定 G.722。16 kHz、16 bit、单声道。20ms = 640 字节 PCM = 40 字节码流。
BB EB 的 FRAME_MS=40:一包 2 帧,payload 80 字节。
FRAME_MS=20:一包 1 帧,payload 40 字节。
8.1 上行 AUDIO_UP(耳机 → App)
BB 90 <LEN> <EAR|FLAGS> <SEQ 小端 2 字节> <G.722…>
8.2 下行 AUDIO_DOWN(App → 耳机)
AA 91 <LEN> <EAR|FLAGS> <SEQ 小端 2 字节> <G.722…>
LEN = 1(耳位和标志)+ 2(序号)+ G.722 字节数。
40ms 包:LEN = 0x53(83),整帧 86 字节。
20ms 包:LEN = 0x2B(43),整帧 46 字节。
低 4 位是耳位,必须等于这条链路自己的耳朵。对不上就丢,并可回 ERR_EAR_MISMATCH (0x08)。
下行的耳位是「播给谁」:左耳麦的译文写到右耳那条链路,帧里 EAR=2。
高 4 位:
| 位 | 掩码 | 上行(耳机 → App) | 下行(App → 耳机) |
|---|---|---|---|
| bit4 | 0x10 |
本包含人声 | 一句译文开始 |
| bit5 | 0x20 |
一段开始 | 播放结束 |
| bit6 | 0x40 |
一段结束 | 打断:丢掉本耳还没播完的缓冲,从本包播 |
| bit7 | 0x80 |
佩戴者本人在说话 | 不用 |
SEQ 每耳独立,从 0 递增,到 0xFFFF 回绕。丢包不重传。
OWN_VOICE(bit7):没有骨传导 / VPU 时这一位请一直发 0。
App 只有在这只耳朵曾经出现过 bit7=1 之后,才丢掉 bit7=0 的包。一直是 0 的话,整路都会送去识别,不会被静音。
下行节奏:没音频时 App 会先垫几包编码静音再送真声音,一句结束后再补一段静音。耳机用 BB 88 告诉 App 缓冲松紧(写在 CTRL_RX):
BB 88 <LEN> <ADJ>
| ADJ | 含义 |
|---|---|
+1 (0x01) |
缓冲少一包,马上再补一包 |
-1 (0xFF) |
缓冲多一包,下一拍停发一包 |
0 |
缓冲已经空,App 重新垫静音再起播 |
这是补包 / 扣包,不是把速率改成 1.25 倍。
9. 事件
写在 CTRL_RX:
BB 87 <LEN> <EVENT> <PARAM…>
| EVENT | 含义 | PARAM | App 行为 |
|---|---|---|---|
0x01 |
按键 | <EAR> <KEY> <ACTION> |
ACTION 0x01 按下、0x02 抬起。当前是连续拾音,按键只更新界面上的左右标记 |
0x02 |
佩戴 | <EAR> <STATE> |
0x00 摘下,0x01 戴上,0x02 入盒。App 提示确认双耳都戴好 |
0x03 |
电量 | <EAR> <LEVEL> |
低 7 位电量,最高位充电。App 暂不展示 |
0x06 |
请求退出 | <REASON> |
0x03 来电:App 结束面对面。0x01 用户操作,0x02 电量过低,0x04 对耳失联 |
10. 错误码
BB EB 的 RESULT,以及握手应答里的 RESULT,用同一张表。
| 值 | 名称 | App 提示 |
|---|---|---|
0x00 |
OK | 继续 |
0x01 |
固件不支持 / 版本过低 | 耳机固件需升级 |
0x02 |
正在通话、AI、录音或 OTA | 请先结束当前功能 |
0x03 |
有一只耳不可用 | 请确认双耳都已取出并佩戴 |
0x04 |
TOKEN 不一致 | 耳机未就绪 |
0x05 |
BLE 资源不足 | 耳机未就绪 |
0x06 |
编码失败 | 耳机未就绪 |
0x07 |
超时 | 耳机未就绪 |
0x08 |
下行耳位和这条链路不符 | 记日志 |
0x0A |
固件内部错误 | 耳机未就绪 |
0x0B |
拆 TWS 失败 | 请把双耳放回盒中再取出重试 |
任一阶段失败,App 都会发 AA 06(连上了的耳朵)和 AA 6C(经典链路还在时),把耳机退回普通状态。