35 KiB
面对面翻译 · 双耳独立 BLE 链路协议(草案 v0.2)
状态:待固件确认。本文只定义协议,不含实现。 固件与 App 双方按本文对齐后再各自写代码;有异议的条目在 §14 逐条回复,改动记进 §16 变更记录。
配套阅读:bes_earphone_protocol.md(现有经典蓝牙协议,本文与它并存,不替代)
v0.2 相对 v0.1 的核心变化:产品形态定为「两个人各戴一只耳机」,TWS 拆成两只独立的 BLE 外设, 各自一条链路连 App。v0.1 的「档位 A 主耳中转」与
TOPO自适应字段整体删除。 另外吸收了 2026-09-20 ~ 09-23 通话翻译真机定性的一批结论(写类型、调速语义、看门狗、音频路径),见 §16。
0. 产品形态与为什么要单开 BLE
形态:A、B 两个人各戴一只耳机,一副耳机拆成两只独立设备,各自一条 BLE 连到同一部手机。 A 说话 → A 那只耳机的麦上行 → 云端翻译 → 译文只下行到 B 那只耳机;反向同理。 手机只做中枢,不参与收音和放音(手机侧 UI 只显示字幕)。
这要求手机同时做四件事:收左耳麦、收右耳麦、往左耳放译文、往右耳放译文,四条流内容各不相同,且:
- 两路上行同时并发、可独立起停(A 说话时 B 插话,不能排队);
- 两路下行可独立寻址(A 说的话只能进 B 的耳朵);
- 每路带独立序号,好判丢包、好做打断;
- 两只耳机之间不需要同步:各听各的译文,没有立体声对齐要求。这一点让拆 TWS 变得可行。
现有恒玄链路是一条经典蓝牙(Android 是 SPP/RFCOMM,iOS 是 GATT over BR/EDR,见 §1.1),
所有指令与音频挤在这一条上,上行靠 mode=0x03 交织、下行靠 AA 56 的 84 字节双槽区分方向。
它服务「通话翻译」够用(两路语义固定:本端 mic / 对端 spk),但副耳根本没有到手机的链路,
面对面的两路是两个人、两只耳机,在这条链路上做不出来。所以另起 BLE:进面对面时拆 TWS、两耳各自建链,
退出时拆链并重新组对,恢复原状。
1. 设计边界(明确不动什么)
| 不动 | 说明 |
|---|---|
| 现有经典蓝牙全部指令与音频格式 | AA 51/52/56/57/58/59/5A/5E/5F/65/69/6A、BB D1/D2/D9/DA/E5/E6/E7/64/55/68/60/5B 一律不改 |
现有 AA 66 / AA 67 面对面 |
那是旧版面对面(手机麦上行 + AA 69 单声道下行),命令号保留,App 侧当前也没有调用方;新模式用新命令号,两者互斥但互不修改 |
| 通话翻译 / AI 对话 / 录音 / OTA | 全部保持现状;面对面 BLE 与它们互斥,不并行 |
| 经典蓝牙配对关系 | 面对面结束后必须回到进入前的状态(见 §11) |
1.1 ⚠️ iOS 上现有链路不是 SPP
Android 上现有链路是 SPP(RFCOMM);iOS 上是 GATT over BR/EDR(CoreBluetooth API,
但底层是经典蓝牙,靠 registerForConnectionEvents 接管系统已配对设备,详见 CLAUDE.md
「iOS 上恒玄走的是 GATT over BR/EDR,不是 BLE」)。这意味着:
- 面对面期间主耳要同时维护一条 BR/EDR 链路(到手机,控制面)和一条 LE 链路(本协议), 副耳只有 LE 链路。能否共存是 §14 Q1 的一部分;
- §6.2 的连接间隔 / PHY / DLE 这些是 LE 概念,只对本协议的两条新链路成立, 别拿去套现有链路。
1.2 App 侧载体
v0.1 提到的 face_to_face_ble_service.dart 已在 2026-09-09 设备内核重构中删除,不要再找它。
新链路的落点按现行分层:
| 层 | 落点 | 说明 |
|---|---|---|
| LE 链路 + 帧编解码 | bluetooth_manager 原生(Swift / Kotlin) |
扫描、连接、GATT、G.722、BB 90/AA 91 组拆包 |
| 音频 | 原生直连 azure_speech(同 CallPcmBridge 的接缝写法) |
上行 PCM 不经 Dart;下行译文 PCM 由 azure_speech 直接喂回 bluetooth_manager |
| 设备契约 | lib/devices/bes/ 的 BesDeviceSession |
声明能力(沿用 DeviceCapability.faceToFace),AudioRoute 加 f2fEarL/f2fEarR |
| 业务 | translation_controller.dart 的 FaceToFaceTrigger.device 分支 |
只收文本、状态、按键事件;setActiveSpeaker 语义不变 |
理由:这条链路有实时截止期(每 40ms 一包、四路并发),按 9-21 的口径必须下沉原生, Dart 只做没有截止期的状态机与展示(见 CLAUDE.md「音频链路继续下沉」)。
2. 拓扑:两只耳机各自独立
BLE (2 条,互相独立)
手机 ═══════════════► 左耳(EAR=1)
═══════════════► 右耳(EAR=2)
─ ─ ─ ─ ─ ─ ─ ─► 主耳(BR/EDR,现有链路,只走 AA 6B / 6C 控制)
- 进入面对面时固件解除 TWS 主从,左右耳各自广播、各自被 App 连接,各跑一条 GATT;
- 时空壶 WT2 Edge 走的就是这条路(其耳机不出现在系统蓝牙列表,全靠 App 内分别连 L/R);
- 两条链路彻底解耦,延迟最低;代价是A2DP / HFP 期间必然中断(见 §11.5 来电处理);
- 每条链路上只应出现自己耳位的
EAR值。帧里保留EAR字段是为了防御: 收到不属于本耳的包直接丢弃并上报ERR_EAR_MISMATCH(属 App 侧 bug)。
3. 完整时序
App 主耳(BR/EDR) 左耳(LE) 右耳(LE)
│ ① 经典: AA 6B <ver><cap><token> │ │ │
│─────────────────────────────────────►│ │ │
│ │ 停A2DP/HFP、拆TWS主从 │
│ ② 经典: BB EB <ok><earmask>... │ 两耳各起 LE 广播(带 token) │
│◄─────────────────────────────────────│ │ │
│ ③ LE 扫描 → 按 token 过滤 → 集齐 EAR=1 与 EAR=2 → 分别连接 │
│ ④ 各链路:MTU 交换 / 2M PHY / 订阅通知 │
│─────────────────────────────────────────────────────►│────────────────►│
│ ⑤ LE: AA 01 HELLO <token><role>(每条链路各一次) │ │
│ ⑥ LE: BB 81 HELLO_ACK <ear><fw>... │ │
│ ⑦ LE: AA 02 SESSION_CFG │ │
│ ⑧ LE: AA 03 START_STREAM │ 开拾音 │ 开拾音
│ │ │
│ ⑨ LE(左): BB 90 上行音频(EAR=1, seq++) │ │
│◄═════════════════════════════════════════════════════│ │
│ ⑩ 云端 AST(端到端) │ │
│ ⑪ LE(右): AA 91 下行音频(EAR=2, seq++) │ │
│═══════════════════════════════════════════════════════════════════════►│ 播放
│ │ │
│ ⑫ LE: AA 04 STOP_STREAM(每条链路) │ │
│ ⑬ LE: AA 06 EXIT(每条链路,见 §7)→ 耳机主动断开 │ │
│ ⑭ 经典: AA 6C 退出面对面(兜底,幂等) │ 恢复TWS/恢复A2DP │
│─────────────────────────────────────►│ │ │
│ ⑮ 经典: BB EC <ok> │ │ │
│◄─────────────────────────────────────│ │ │
为什么入口放在经典链路而不是直接扫 BLE:耳机平时不广播这个服务(省电、也避免被无关 App 扫到),
只有收到 AA 6B 才拆主从、开始广播,且广播里带上 App 给的 token。
这样「扫描时连到隔壁桌同型号耳机」这个问题在协议层就不成立(见 §5)。
为什么退出要有两条路:拆主从之后经典链路是否还活着,取决于固件(§14 Q1)。
所以 LE 上必须有自己的 EXIT(⑬),经典链路的 AA 6C(⑭)只是兜底与清状态。
任何一条到了,固件都要走 §11.2 的恢复清单。
4. 阶段一 · 经典链路侧模式切换
沿用现有帧格式:AA CMD [LEN] [payload] / BB CMD LEN [payload]。
命令号选在模式控制段(0x5x/0x6x)内的空位,响应码遵守本机固件 响应 = 请求 | 0x80 的规律。
⚠️ 命令号是 (产品, 固件版本) 的函数(CLAUDE.md 同名一节)。下表与
6B/6C只针对 Echo-one 这条固件线, 换产品要重新核对。已占用下行:
06 09 0B 0C 0E 0F 10 20 51 52 56 57 58 59 5A 5E 5F 65 66 67 69 6A已占用上行:02 03 04 07 0A 18 55 5B 60 61 64 68 86 89 8C 8E 90 D1 D2 D6 D9 DA DE DF E5 E6 E7 E9本协议新增6B/6C↔EB/EC,与上表均无冲突。⚠️ App 侧
BesCmd.switchesMode()与BesBluetoothService.probeCommands()目前跳过的是0x51~0x6A, 落地本协议时必须把区间扩到0x6C—— 否则扫命令时会把耳机扫进面对面模式并拆掉 TWS, 且扫描进程不会发退出,只能靠看门狗兜底。
4.1 AA 6B 进入面对面 BLE 模式
AA 6B 06 <PROTO_VER> <CAP> <TOKEN 4B>
| 字段 | 长度 | 取值 |
|---|---|---|
PROTO_VER |
1 | 本文档版本,0x02 |
CAP |
1 | App 支持的编码位图:bit0 G.722、bit1 mSBC、bit2 Opus(预留)。首版固定 0x01 |
TOKEN |
4 | App 生成的随机会话标识(crypto 级随机),每次进入重新生成,用于广播过滤 |
(v0.1 的 TOPO_REQ 已删除:形态只有一种。)
4.2 BB EB 响应
BB EB 07 <RESULT> <EAR_MASK> <PROTO_VER> <CODEC> <FRAME_MS> <FW_MINOR> <CLASSIC_KEEP>
| 字段 | 取值 |
|---|---|
RESULT |
见 §12 错误码,0x00=已拆主从并开始广播 |
EAR_MASK |
bit0=左耳可用、bit1=右耳可用。两位必须都置,否则固件应直接回 ERR_EAR_UNAVAILABLE(面对面需要双耳,单耳在盒/未佩戴不进入) |
CODEC |
实际采用的编码,0x01=G.722 |
FRAME_MS |
每包承载的音频时长,20 或 40(建议 40) |
FW_MINOR |
固件的本协议实现小版本,便于后续差异化兼容 |
CLASSIC_KEEP |
0x01=拆主从后经典链路仍保持;0x00=经典链路将断开,App 退出只能走 LE EXIT(§7) |
4.3 AA 6C 退出
AA 6C → BB EC 01 <RESULT>
⚠️ 必须幂等:不在面对面模式时收到 AA 6C 也要回 BB EC 00,不能报错。
App 在异常恢复路径上会无条件补发一次(见 §11)。
5. 阶段二 · BLE 广播与发现
5.1 广播内容(左右耳各自广播)
| 字段 | 内容 |
|---|---|
| Flags | LE General Discoverable + BR/EDR Not Supported |
| Local Name | <产品名>-F2F,左右耳同名(靠 MSD 区分,不靠名字) |
| Service UUID (16B) | 见 §6.1 |
| Manufacturer Specific Data | 见下 |
Manufacturer Specific Data(AD Type 0xFF,共 11 字节)
| CompanyID 2B (LE) | MAGIC 0xF2 0xF2 | VER 1B | EAR 1B | TOKEN 4B | BATT 1B |
| 字段 | 说明 |
|---|---|
MAGIC |
固定 F2 F2,快速筛掉无关广播 |
VER |
协议版本,与 AA 6B 的 PROTO_VER 一致 |
EAR |
0x01=左耳、0x02=右耳 |
TOKEN |
原样回显 AA 6B 里那 4 字节 |
BATT |
该耳电量 0~100,低 7 位;最高位为充电位(与 BB 0A 同口径) |
5.2 App 侧扫描规则(强制)
- 只接受
MAGIC命中的广播; TOKEN必须与本次AA 6B下发的完全一致,否则一律忽略 —— 这是防止连到旁边同型号耳机的唯一手段,会场/展会场景必然会同时扫到多副;- 必须集齐
EAR=0x01与0x02两个广播才开始连接;15s 内只集到一个 → 按 §5.2 第 4 条回滚, 提示「请确认双耳都已取出并佩戴」。不做单耳降级:单耳面对面在产品上不成立; - 扫描超时 15s:超时后 App 发
AA 6C回滚,并向用户报「耳机未就绪」; - 扫描必须在 App 前台完成:iOS 后台扫描会截断广播数据,MSD 里的 TOKEN 拿不到,第 2 条就无从判定。 扫描期间切后台 → 视为超时回滚。
5.3 广播参数与耳机侧超时
- 建链期快速广播:间隔 30~50ms,持续 30s;
- 30s 内无人连接,该耳自动退出面对面模式;两耳都退出后重新组对、恢复常态(第一道看门狗,见 §11);
- 连接建立后立即停止广播(各耳独立停)。
5.4 iOS 注意
iOS 拿不到 BLE 设备的 MAC(CoreBluetooth 只给每台手机各不相同的 peripheral UUID), 所以设备身份判定只能靠 TOKEN + EAR,不能靠地址。这也是 §5.2 第 2 条必须强制的原因。 (同一问题在设备确权上已经踩过,见 CLAUDE.md「设备确权」一节。)
6. 阶段三 · GATT 服务与特征(两耳完全相同)
6.1 UUID(待固件分配,下表为占位)
沿用现有 UUID 的 128-bit 风格,基址建议:AEAF5F32-XXXX-4C4B-A100-000000000000
| 项 | 占位 UUID | 属性 | 方向 |
|---|---|---|---|
| Service | AEAF5F32-0001-4C4B-A100-000000000001 |
— | — |
CTRL_TX |
AEAF5F32-0002-4C4B-A100-000000000001 |
Write / Write Without Response | App → 耳机(控制) |
CTRL_RX |
AEAF5F32-0003-4C4B-A100-000000000001 |
Notify | 耳机 → App(控制/事件) |
AUDIO_UP |
AEAF5F32-0004-4C4B-A100-000000000001 |
Notify | 耳机 → App(音频) |
AUDIO_DOWN |
AEAF5F32-0005-4C4B-A100-000000000001 |
Write Without Response | App → 耳机(音频) |
控制与音频分开成 4 个特征,不合并:音频是每 40ms 一包的高频流,
与控制帧共用一个特征时,控制消息(尤其是「打断」和 STOP_STREAM)会排在音频队列后面,
表现为「点了停止还在播」。9-21 在通话翻译上实测过同一个坑(控制命令排到积压音频后面 → 电量/版本全查不到),
分开后两条队列各自独立。
6.2 链路参数要求(每条 LE 链路各自满足)
| 项 | 要求 | 理由 |
|---|---|---|
| ATT MTU | ≥ 185,建议 247 | 40ms 聚合包 = 6B 头 + 80B 音频 = 86B;留余量给未来的 Opus/更长聚合 |
| PHY | 优先 2M,回退 1M | 两条链路并发时降低空口占用 |
| 连接间隔 | 15~30ms | 大于 40ms 会让下行音频出现可闻的断续 |
| Slave latency | 0 | 音频流不允许休眠跳包 |
| 数据长度扩展 (DLE) | 开 | 否则 86B 包会被拆成多个 27B 空口包,吞吐骤降 |
| Supervision timeout | 2~4s | 它就是 §11.1 的看门狗 2:App 被杀后链路在此时间内必断 |
6.3 App 侧写入规则(强制,9-21 真机定性)
AUDIO_DOWN与CTRL_TX一律 Write Without Response。带应答写每包要等 ATT 层 ACK 才能发下一包, 50 包/秒的实时流必然追不上;- iOS 的
canSendWriteWithoutResponse只能作观测,不能作门槛(拿它当闸门会死锁,见 CLAUDE.md「GATT 写入三条铁律」); - 控制命令绝不排队在音频后面,直发。
7. 阶段四 · BLE 链路握手(每条链路各自一遍)
BLE 链路上的操作码空间与经典链路独立(不同链路,不会混淆)。
帧格式同样是 AA <OP> <LEN> <payload> / BB <OP|0x80> <LEN> <payload>,LEN 为 payload 字节数。
| 步骤 | 帧 | payload | 说明 |
|---|---|---|---|
| ① HELLO | AA 01 |
<VER 1B><TOKEN 4B><ROLE 1B> |
ROLE:0x01=App。token 再校验一次,防止连错 |
| ② HELLO_ACK | BB 81 |
<RESULT><EAR><FW 3B><CODEC><FRAME_MS><MTU_OK> |
EAR 必须与广播里一致;MTU_OK=0 时 App 降级到 20ms 单帧包 |
| ③ SESSION_CFG | AA 02 |
<VAD_MODE 1B><DOWN_GAIN 1B><TRIGGER 1B> |
见下 |
| ④ CFG_ACK | BB 82 |
<RESULT> |
|
| ⑤ START_STREAM | AA 03 |
— | 开本耳上行。回 0x00 后耳机开始推 BB 90 |
| ⑥ START_ACK | BB 83 |
<RESULT> |
|
| ⑦ STOP_STREAM | AA 04 |
— | 停上行,链路保留(切换语言/暂停时用,不必拆链) |
| ⑧ KEEPALIVE | AA 05 |
<SEQ 1B> |
App 每 5s 发一次,只用于诊断(§11.1),耳机不据此断链 |
| ⑨ KEEPALIVE_ACK | BB 85 |
<SEQ 1B><UP_LOSS 1B><DOWN_LOSS 1B> |
丢包率(%),写日志 |
| ⑩ EXIT | AA 06 |
— | 本耳退出面对面:停流、断开本 LE 连接、走 §11.2 恢复。两耳都收到后重新组对 |
| ⑪ EXIT_ACK | BB 86 |
<RESULT> |
回完再断 |
SESSION_CFG 字段
| 字段 | 取值 |
|---|---|
VAD_MODE |
0x00=常发(不做静音抑制,默认)、0x01=静音抑制(耳机侧 VAD 判静音则不发包) |
DOWN_GAIN |
下行播放增益 0~100,默认 80 |
TRIGGER |
0x00=免按键(连续拾音)、0x01=按住说话(按键事件驱动,对应 App 的 FaceToFaceTrigger.device) |
(v0.1 的 UP_ENABLE 位图已删除:每条链路只有本耳一路,开关就是 START/STOP_STREAM。)
⚠️
VAD_MODE默认0x00是刻意的:断句策略在 App/云端侧,固件 VAD 一旦判早就会把一句话切碎, 每段各翻一次,效果比多传点静音差得多。同传模式下已经踩过这个坑 (见 CLAUDE.md「面对面 / 同声翻译:延迟构成」第 1 条)。省电需求确认后再开0x01。
8. 音频上行(耳机 → App)
BB 90 <LEN> <EAR|FLAGS 1B> <SEQ 2B LE> <PAYLOAD…>
| 字段 | 说明 |
|---|---|
EAR |
低 4 位:0x1=左耳、0x2=右耳(必须等于本链路耳位) |
FLAGS |
高 4 位:bit4 VAD_ACTIVE(本包含人声)、bit5 SEG_BEGIN(一段发声起始)、bit6 SEG_END(结束)、bit7 OWN_VOICE(骨传导/VPU 判定为佩戴者本人在说话;固件没有该传感器时恒为 0) |
SEQ |
每耳独立自增,回绕 0xFFFF。App 据此算丢包,不做重传 |
PAYLOAD |
N × 40B G.722 码流。FRAME_MS=40 时 N=2,=20 时 N=1 |
- 编码:G.722,16kHz / 16bit / 单声道,一帧 640B PCM(320 样本 = 20ms)→ 40B 码流。
选它是因为现有原生插件的
G722Codec编解码器两端都已落地、已验证,不引入新依赖。 SEG_BEGIN/SEG_END是耳机给的提示,不是命令:App 可以用它抢跑一次断句, 但最终断句仍以云端为准(VAD_MODE=0x00时耳机照发不误)。OWN_VOICE是面对面串音抑制的关键:两个人面对面坐着,A 的耳机麦一样能收到 B 的声音。 有这一位,App 可以在OWN_VOICE=0时不把这一路送去识别(或送去但标低权重)。 没有它只能靠云端两路对比,效果差一档(§14 Q6)。- 丢包不重传:语音流重传只会加大延迟。App 检测到 seq 跳变时补静音帧维持时基。
9. 音频下行(App → 耳机)
AA 91 <LEN> <EAR|FLAGS 1B> <SEQ 2B LE> <PAYLOAD…>
| 字段 | 说明 |
|---|---|
EAR |
低 4 位:这包该播给哪只耳。与本链路耳位不符则丢弃并回 ERR_EAR_MISMATCH |
FLAGS |
bit4 PLAY_BEGIN(一段译文开始,耳机可据此压低本地音/给提示音)、bit5 PLAY_END(结束,可恢复)、bit6 FLUSH(打断:丢弃本耳未播完的缓冲,立即播本包) |
SEQ |
每耳独立自增 |
PAYLOAD |
同上行 |
- 打断(
FLUSH)是面对面必需的:对方还在听上一句译文时新一句已经翻好, 没有 flush 就会越积越多、译文与对话彻底脱节。App 在每段译文首包置PLAY_BEGIN, 需要抢播时同时置FLUSH。 - 积压追赶不靠 FLUSH 一种手段。译文是云端突发产出、播放只能 1 倍速,积压必然出现。
App 侧直接复用
CallTranslationDownlink的追赶器(裁静音 + WSOLA 变速,9-22 已在通话翻译落地并实测), 只换包头与 EAR;FLUSH只留给「新一句到了、上一句还没播完」这种语义上该丢的场景。 - 不需要静音填充。这点与经典链路
AA 5684 字节双槽不同:那是定长双槽包,空槽必须填静音帧; BLE 是包交换,没数据就不发包,耳机侧靠SEQ与到达时间维持时基即可。 但起播前先垫 4 包编码静音再发真音频(9-23 定性:从空缓冲起播必出杂音), 一段结束后再续 500ms 静音才停发。 - 节奏调整:按包,不按速率。 耳机用
BB 88 <ADJ int8>请求:+1=缓冲低一包、请立即补一包;-1=缓冲高一包、请扣一包;0=缓冲已空(App 视为起播,重新垫静音)。 ⚠️ v0.1 写的「interval_ms16/20/25/40 对齐0xD6/0xE9」是对D6的误读—— 9-23 真机定性D6的01只持续 20ms 就回02,语义是「补一包」不是「改速率」。新协议直接按补/扣包定义,别再重现这个坑。 - App 发包定时器必须固定节拍、永不重排(iOS
DispatchSourceTimer/ AndroidscheduleAtFixedRate), 编码与写入耗时不能叠进周期(9-23 第 1 条)。 - 缓冲:耳机侧建议 60~120ms 抖动缓冲;溢出时丢最旧的并上报
EVT_BUF_OVERFLOW,不要阻塞。
10. 事件上报
BB 87 <LEN> <EVENT 1B> <PARAM…>
EVENT |
含义 | PARAM | App 侧对应 |
|---|---|---|---|
0x01 |
按键 | <EAR><KEY_CODE><ACTION>,ACTION:0x01按下 / 0x02抬起 / 0x03单击 / 0x04双击 |
按住说话:左耳按下→activeSpeaker=1、右耳按下→=2、抬起→=0。语义已在 translation_controller.setActiveSpeaker 落地,固件只需如实上报 |
0x02 |
佩戴状态 | <EAR><STATE>,STATE:0x00摘下 / 0x01戴上 / 0x02入盒 |
摘下/入盒 → App 暂停该路,UI 提示 |
0x03 |
电量 | <EAR><LEVEL>,最高位为充电位 |
与 BB 0A 同口径 |
0x04 |
链路统计 | <EAR><UP_LOSS%><DOWN_LOSS%> |
每 10s 一次,仅用于诊断日志 |
0x05 |
异常 | <CODE>,见 §12 |
缓冲溢出、编码失败等 |
0x06 |
耳机请求退出 | <REASON>:0x01用户操作 / 0x02电量过低 / 0x03来电 / 0x04对耳失联 |
App 收到后走正常退出流程;来电见 §11.5 |
11. 退出与恢复(重点)
功能结束后必须完整回到进入前的状态。这一节的每一条都是验收项。
11.1 看门狗
| 层 | 条件 | 动作 | 责任方 |
|---|---|---|---|
| 1 | 开始广播后 30s 无 LE 连接 | 该耳退出面对面;两耳都退出后重新组对、恢复常态、经典链路上报 BB EC 00 |
固件 |
| 2 | LE 连接断开(supervision timeout 2~4s,§6.2) | 该耳立即退出面对面;等待对耳 ≤5s 后重新组对 | 固件 |
| 3 | App 侧异常 / 主动退出 | 每条链路发 AA 06 EXIT;再补发经典链路 AA 6C;经典链路也不可用时,下次连上后先发一次 AA 6C 清状态 |
App |
第 2 层是本协议里最重要的一条,且刻意不靠 keepalive。 App 被系统杀掉、崩溃、用户强退时, 没有任何机会发退出指令;但 LE 连接本身会在 supervision timeout 内必然断开,固件收到断连事件即可恢复。 v0.1 的「5s 没收到 keepalive 就断链」删掉了:keepalive 若由 App 定时器发,iOS 锁屏后定时器一停就会被耳机踢出, 把一个正常场景做成了故障;而链路断开这个信号不依赖任何定时器。 缺了第 2 层耳机会永远卡在面对面模式,用户表现为「耳机连着但放不了音乐、也接不了电话」,且看不出原因。 (同类教训见 CLAUDE.md 里 BES OTA 的 90s 看门狗一节。)
11.2 固件的恢复清单
收到 AA 06 EXIT / AA 6C、或任一看门狗触发时,逐项恢复:
- 停止 LE 广播,断开本协议的 GATT 连接;
- 两耳重新组对,恢复 TWS 主从与耳间同步;
- 恢复 A2DP / HFP —— 音乐能播、来电能接;
- 恢复按键功能映射到用户原设置(面对面期间若临时改过键位);
- 恢复 ANC / 通透模式到进入前的档位;
- 恢复麦克风通路与增益;
- 经典链路回
BB EC 00(看门狗触发时为主动上报);CLASSIC_KEEP=0的固件在重新组对后主动回连手机的经典链路。
11.3 App 的恢复动作
CLASSIC_KEEP=1时经典链路全程未断,BesBluetoothService状态不受影响;=0时等固件回连,期间设备页显示「恢复中」,不当成掉线报错;- 恢复完成前不允许再次进入面对面(防止半状态叠加);
- UI 上「结束」按钮必须先返回、后台跑收尾,不要 await 整条退出链 (录音页踩过这个坑,见 CLAUDE.md「录音页退不出去」)。
11.4 互斥
面对面 BLE 模式与下列功能同一时刻只能有一个,冲突时 AA 6B 回 ERR_BUSY:
通话翻译(AA 51)、AI 对话(0x64)、主麦录音(AA 59)、媒体录音(AA 5E)、OTA、旧版面对面(AA 66)。
App 侧同样在 DeviceConnection.isBlockedByCall 这一层拦,双保险。
11.5 单耳失联与来电
- 单耳 LE 断开(走远、没电):固件该耳退出并等待对耳;App 侧另一耳仍连着, 停掉该耳的上行与对耳的下行(一方听不到了,继续翻译没有意义),提示「左/右耳已断开」, 10s 内重扫同 token 尝试回连,超时走完整退出。
- 来电:TWS 已拆、HFP 已停,来电无法在耳机上接。固件收到系统来电 → 上报
EVT 0x06 REASON=0x03→ App 立即走退出流程并提示「有来电,面对面翻译已结束」;固件恢复 HFP 后由系统接管来电。 ⚠️ 这一步的时序(振铃到 HFP 恢复要多久)是 §14 Q2 的实测项,决定用户会不会漏接。
12. 错误码(RESULT / CODE 共用一张表)
| 值 | 名称 | 含义 | App 侧表现 |
|---|---|---|---|
0x00 |
OK |
成功 | — |
0x01 |
ERR_UNSUPPORTED |
固件不支持本协议或版本过低 | 提示「耳机固件需升级」,引导 OTA |
0x02 |
ERR_BUSY |
正在通话/AI/录音/OTA | 提示「请先结束当前功能」 |
0x03 |
ERR_EAR_UNAVAILABLE |
任一耳不可用(在盒/未佩戴/没电) | 提示「请取出并佩戴双耳」 |
0x04 |
ERR_TOKEN_MISMATCH |
token 校验失败 | 静默重试一次,仍失败则回滚 |
0x05 |
ERR_BLE_RESOURCE |
BLE 资源不足(连接数/内存) | 提示重试 |
0x06 |
ERR_CODEC |
编码不支持或初始化失败 | 记日志,回滚 |
0x07 |
ERR_TIMEOUT |
阶段超时 | 回滚并提示 |
0x08 |
ERR_EAR_MISMATCH |
下行包耳位与链路不符 | 记日志(属 App 侧 bug) |
0x09 |
EVT_BUF_OVERFLOW |
下行缓冲溢出 | 记日志,App 降速 |
0x0A |
ERR_INTERNAL |
固件内部错误 | 回滚并提示 |
0x0B |
ERR_TWS_SPLIT |
拆主从失败 / 对耳不应答 | 回滚并提示「请把双耳放回盒中再取出重试」 |
13. 带宽与延迟预算
带宽(G.722 @16kHz = 16 kbps/路,每条链路各承担一上一下)
| 每条 LE 链路 | 小计 |
|---|---|
| 上行 1 路 | 16 kbps |
| 下行 1 路 | 16 kbps |
| 控制 + keepalive | < 1 kbps |
| 单链路合计 | ≈ 33 kbps |
40ms 聚合时每包 86 字节,单链路每 40ms 2 包 ≈ 4.3 KB/s。 BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps,余量充足;开 2M PHY + DLE 后更宽松。
⚠️ 手机侧是两条 LE 连接并发,再加主耳可能还挂着一条 BR/EDR,三条链路共用手机一根天线的时隙。 单链路余量充足不等于三链路共存余量充足,§14 Q4 要固件给共存下的实测数,不是单链路理论值。
对照:若直接传 16k/16bit 裸 PCM,单路即 256 kbps —— BLE 上不可行。 所以下行/上行都必须是编码后码流,不能传裸 PCM。
延迟预算(单向,从 A 开口到 B 听到)
| 段 | 预估 | 备注 |
|---|---|---|
| 耳机采集 + 编码 | 20~40ms | 一个聚合包 |
| BLE 上行 | 30~60ms | 含连接间隔与重传 |
| 云端翻译 | 800ms(端到端)/ 2000ms+(三段串行) | 大头,见下 |
| BLE 下行 | 30~60ms | |
| 解码 + 抖动缓冲 + 播放 | 60~120ms | |
| 合计 | ≈ 1.0 / 2.3s |
蓝牙链路只占其中 100~250ms。优化重心在云端那一段,不要在协议上过度抠 10ms。
⚠️ 云端走端到端是本功能的前置工作,不是可选优化。 面对面当前是 ASR → HTTP 机器翻译 → TTS 三段串行 (CLAUDE.md「面对面 / 同声翻译:延迟构成」),2s+ 的等待配上按住说话,体验不成立。 阿里端到端 AST 的
pushExternalAudio(leg:)已现成,面对面的两只耳机恰好与 AST 的 leg A / leg B 同构: 左耳上行进 leg A、译文回右耳;右耳上行进 leg B、译文回左耳。通话翻译那套原生桥(BesCallPcmBridge)可直接复用。
14. 待固件确认清单
请逐条回复「支持 / 不支持 / 需改为」:
| # | 问题 | 影响 |
|---|---|---|
| Q1 | 拆 TWS 主从后,两只耳机能否各自独立跑 GATT peripheral?拆开后主耳到手机的经典链路能否保持(CLASSIC_KEEP)?iOS 上那条是 BR/EDR GATT,与两条 LE 共存有没有限制? |
决定退出通道(§3 ⑬⑭)与 §11.3 |
| Q2 | 面对面模式下 A2DP / HFP 必然中断。来电时从振铃到 EVT 0x06 上报、再到 HFP 恢复可接听,各需多久? |
决定 §11.5 文案与是否可接受漏接 |
| Q3 | §6.1 的 GATT UUID 由谁分配?固件方是否已有预留的 128-bit 基址? | 阻塞两端编码 |
| Q4 | 支持 MTU 247 / 2M PHY / DLE 吗?两条 LE + 一条 BR/EDR 共存下的实测吞吐与丢包? | 决定聚合帧长(40ms vs 20ms) |
| Q5 | G.722 能否直接用在 BLE 通道上(现有实现在经典链路上)?编解码器是否可复用? | 不能则要换 Opus,两端都要加依赖 |
| Q6 | 是否有入耳检测 / 骨传导 / VPU 可判定「是佩戴者本人在说话」?能否随上行包置 OWN_VOICE(§8)? |
面对面串音抑制的关键;没有的话只能靠云端,效果差一档 |
| Q7 | 面对面模式下哪些按键可用、键位码如何编排?按下/抬起事件能否上报? | 决定「按住说话」能否做 |
| Q8 | 广播里的 TOKEN 回显能实现吗?广播内容能否在运行时动态改? | 不能则无法防止连错耳机,需另想办法 |
| Q9 | 拆主从 + 两耳各起广播从收到 AA 6B 到广播出现要多久?重新组对要多久? |
决定 §5.2 扫描超时与 T12 连续进出的可行性 |
| Q10 | §11.1 看门狗 1(30s)与 supervision timeout(2~4s)能否实现?断连后重新组对的等待(5s)是否合适? | 直接决定「App 崩了耳机会不会卡死」 |
| Q11 | 与 OTA / AI 对话 / 通话的互斥由固件拦(回 ERR_BUSY)还是 App 自律? |
建议固件拦,App 也拦,双保险 |
| Q12 | 单包最大长度、单位时间最大包数有无限制? | 影响聚合策略 |
| Q13 | 耳机侧下行抖动缓冲多大?溢出策略是丢旧还是丢新?BB 88 补/扣包的触发阈值是多少? |
影响打断(FLUSH)与追赶的实际效果 |
| Q14 | 单耳失联后另一耳如何得知(§11.5 REASON=0x04)?拆主从后两耳之间还有没有任何私有链路? |
决定单耳失联能否由固件自己发现 |
(v0.1 的 Q1「能否做双链路」已由产品决定为必做;Q12「上行时间戳是否对齐」删除——两人各听各的,不需要对齐。)
15. 联调验收用例
| # | 场景 | 期望 |
|---|---|---|
| T01 | 正常进入 → 拆主从 → 双耳广播 → 分别连接 → 握手 → 上下行通 | 全流程 < 5s,双耳都能收发 |
| T02 | 左耳说话 | 只有右耳听到译文;左耳自己听不到 |
| T03 | 双方同时说话 | 两路各自独立上行,互不排队,两路译文分别到达对侧 |
| T04 | 打断:A 的第二句在第一句译文播完前到达 | 置 FLUSH 后立即切到新句,不排队堆积 |
| T05 | 扫描期旁边有另一副同型号耳机(也在面对面模式) | 绝不连错:token 不匹配的广播被忽略 |
| T06 | 正常退出 | 两耳重新组对,音乐可播、来电可接、按键功能恢复、ANC 恢复 |
| T07 | App 进程被杀 | supervision timeout 内两条 LE 断开,≤ 10s 耳机重新组对并恢复常态(看门狗 2) |
| T08 | 进入模式后一直不连(App 卡住) | ≤ 30s 后耳机自行退出并组对(看门狗 1) |
| T09 | 面对面期间来电 | 按 Q2 的结论执行,用户能接到电话,结束后状态一致 |
| T10 | 面对面期间摘下一只耳 / 放回盒 | 上报 EVT 0x02,App 暂停该路并提示,戴回后恢复 |
| T11 | 一只耳走远导致该链路断开 | 另一耳停流并提示,10s 内回来能续上;超时后完整退出、两耳组对 |
| T12 | 连续进出 20 次 | 无资源泄漏,第 20 次与第 1 次耗时相当,每次都成功组对 |
| T13 | 重复发 AA 6C 三次 |
三次都回 BB EC 00(幂等) |
| T14 | 面对面期间尝试进 AI / 通话翻译 | 回 ERR_BUSY,两边状态都不被破坏 |
| T15 | 单耳在盒时发 AA 6B |
回 ERR_EAR_UNAVAILABLE,不拆主从 |
| T16 | 两人面对面坐着、只有 A 说话 | B 那只耳机的上行 OWN_VOICE=0(Q6 成立时),B 侧不产生识别结果 |
| T17 | 一句 20 秒不换气的长句 | 下行积压由追赶器(裁静音 + 变速)消化,无杂音、无整段丢弃 |
16. 变更记录
| 版本 | 日期 | 变更 |
|---|---|---|
| v0.1 | 2026-09-05 | 首版草案,待固件确认 §14 |
| v0.2 | 2026-09-23 | 形态定为两耳独立 BLE:删除档位 A 与 TOPO/TOPO_REQ/UP_ENABLE;AA 6B 长度 07→06,BB EB 新增 CLASSIC_KEEP;LE 侧新增 AA 06 EXIT;看门狗 2 改为「链路断开即退出」,keepalive 降为诊断(1s→5s);BB 86 调速改为 BB 88 按包补/扣(修正对 D6 的误读);上行 FLAGS bit7 定义为 OWN_VOICE;补 §1.1 iOS 是 BR/EDR GATT、§1.2 App 载体改为原生直连(face_to_face_ble_service.dart 已删)、§5.2 前台扫描、§6.3 写入规则、§9 复用追赶器、§11.5 单耳失联与来电、§13 端到端为前置;EVT 0x06 增加 REASON 表;新增 ERR_TWS_SPLIT、T15~T17 |