# Echo-one 面对面翻译 · 双耳 BLE 协议(App 当前实现) 日期:2026-10-10 对象:固件 对应 App:已按本文组包、解析。请对照固件实现逐条核对。 更早的讨论稿见 [face_to_face_ble_protocol.md](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 帧格式(和经典蓝牙同形,操作码空间独立): ```text AA/BB | OP | LEN | payload ``` `LEN` 是 payload 的字节数,不含这 3 字节头。 --- ## 3. 启动时序 耳机平时不要广播这个服务。只有收到经典链路上的 `AA 6B` 才拆 TWS、开始广播。 ```text 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。 ```text AA 6B 06 02 01 ``` | 字节 | 值 | 含义 | |---|---|---| | PROTO_VER | `0x02` | 本协议版本 | | CAP | `0x01` | 只要 G.722 | App 等 `BB EB` 的上限是 **3 秒**。超时就放弃,并补发 `AA 6C`。 ### 4.2 应答 `BB EB` 整帧 10 字节: ```text BB EB 07 ``` | 字段 | 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` ```text AA 6C → BB EC 01 ``` `AA 6C` **没有长度字节**。 必须幂等:当前不在面对面模式,也要回 `BB EC 01 00`,不要报错。App 在异常恢复时会无条件补发。 `RESULT=0x00` 表示已重新组对,A2DP / HFP 恢复。 --- ## 5. 广播与扫描 收到 `AA 6B` 且 `RESULT=0`、双耳都可用之后,左右耳各自广播。 厂商数据(AD Type `0xFF`)。Company ID 2 字节之后是下面 9 字节: ```text 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 扫描规则: 1. 只扫上面的 Service UUID。 2. TOKEN 必须和这次 `AA 6B` 逐字节相同。旁边另一副同型号耳机不能连上。 3. **必须同时看到 `EAR=1` 和 `EAR=2` 才连接。** 15 秒没集齐就发 `AA 6C` 回滚。 4. iOS 拿不到 BLE MAC,身份只认 TOKEN + EAR。 5. 扫描在 App 前台完成。建议建链期广播间隔 30~50ms。 6. **30 秒内没被连上,该耳自己退出面对面**;两耳都退出后重新组对,并在经典链路上报 `BB EC 01 00`。 连上之后该耳停止广播。 --- ## 6. 每只耳朵的握手 GATT 连上后 App 会:请求 MTU 247、连接优先级拉高、优先 2M PHY。 通知顺序:先 `CTRL_RX`,再 `AUDIO_UP`。两个通知都开了才发 HELLO。 左右耳各走一遍,内容相同,TOKEN 相同。 | 步骤 | App 发出 | 耳机应答 | 说明 | |---|---|---|---| | 1 | `AA 01 06 02 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 ` | 可不回 | 两只都 `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. 退出 顺序: 1. 每只还连着的耳朵,`CTRL_TX` 发 `AA 06 00`。耳机停流、断开这条 BLE、准备重新组对。 2. 经典链路还在,再发 `AA 6C`,等 `BB EC 01 00`。 3. 经典链路已经断了(`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) ```text BB 90 ``` ### 8.2 下行 `AUDIO_DOWN`(App → 耳机) ```text AA 91 ``` `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`): ```text BB 88 ``` | ADJ | 含义 | |---|---| | `+1` (`0x01`) | 缓冲少一包,马上再补一包 | | `-1` (`0xFF`) | 缓冲多一包,下一拍停发一包 | | `0` | 缓冲已经空,App 重新垫静音再起播 | 这是补包 / 扣包,不是把速率改成 1.25 倍。 --- ## 9. 事件 写在 `CTRL_RX`: ```text BB 87 ``` | EVENT | 含义 | PARAM | App 行为 | |---|---|---|---| | `0x01` | 按键 | ` ` | ACTION `0x01` 按下、`0x02` 抬起。当前是连续拾音,按键只更新界面上的左右标记 | | `0x02` | 佩戴 | ` ` | `0x00` 摘下,`0x01` 戴上,`0x02` 入盒。App 提示确认双耳都戴好 | | `0x03` | 电量 | ` ` | 低 7 位电量,最高位充电。App 暂不展示 | | `0x06` | 请求退出 | `` | `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`(经典链路还在时),把耳机退回普通状态。