# 面对面翻译 · 双耳独立 BLE 链路协议(v0.5) > 状态:**Android 与 iOS App 均已按本文落地**。 > Android:`F2fBleLink.kt`(经典链路 = SPP)。 > iOS:`F2fBleLink.swift`(经典链路 = GATT over BR/EDR)。 > 业务编排:`translation_controller.dart`。固件待确认项见 §14,改动记进 §16。 > > 配套阅读:[bes_earphone_protocol.md](bes_earphone_protocol.md)(现有经典蓝牙协议,本文与它并存,不替代) ## 速览:五步时序(v0.5) | 步 | 动作 | 帧 | 链路 | |---|---|---|---| | ① | 切到 BLE 链路。只发这一帧,不再试其它格式 | `AA 19 06 02 01 01` | 经典蓝牙(Android = SPP,iOS = GATT over BR/EDR) | | ② | 等左右耳广播,各建一条 GATT(发现服务 + MTU + 开通知)。**何时开扫见下表** | — | BLE,广播名 `Echo-one-L` / `Echo-one-R` | | ③ | **两耳都握手好**之后才启动面对面 | `AA 66 06 02 01 01` | BLE(两耳各写一次) | | ④ | 左耳收音 → 译文只播右耳;右耳收音 → 译文只播左耳 | 上行 `BB 68` / 下行 `AA 69 2C 01` + 40B G.722 | BLE | | ⑤ | 结束面对面,再切回经典蓝牙 | `AA 67`,300ms 后 `AA 19 06 02 01 00`,再 400ms 断两条 GATT | BLE(两耳各写一次) | 帧头约定:**App → 耳机一律 `AA`,耳机 → App 一律 `BB`**(长帧 `CC`)。 Dart 只发 2 字节的 `AA 66` / `AA 67`。原生改写成上面的 6 字节设置帧,并负责 `AA 19`、扫描、握手、链路切换。 **第 ② 步两端不一样**(其余四步相同): | | Android | iOS | |---|---|---| | 经典链路 | SPP / RFCOMM | GATT over BR/EDR(不是 BLE,也不是 SPP) | | `AA 19` 写出之后 | **立刻扫描**。`BB 99` 只记日志,不挡扫描 | **先等 `BB 99`**(最多 3 秒)。没有应答就失败,不扫描 | | 收到 `BB 99` 之后 | 不关 SPP | **立刻关掉**这条经典 GATT,再等 **1 秒**,然后才扫描、连接左右耳 | | 面对面期间的经典链路 | 不断开;断了也不上报,结束后按需回连 | 必须保持关闭。系统把经典设备再交回来也不重新接管。结束后才回连 | | 没连上 Echo-one 就点开始 | 提示并不启动,不走手机麦 | 退回手机麦 | | 连着耳机但双耳 BLE 没起来 | 不启动,不开麦、不计时 | 同左,不退回手机麦 | 真机(Echo-one,固件 0.1.8)上,`AA 19 06 02 01 01` 的应答是 **`BB 99 04 00`**。 iOS 上 1 秒是实测值:收到 `BB 99` 后只等 500ms 再扫,右耳 GATT 连不上,接着几场右耳不再广播;改回 1 秒后,从 `BB 99` 到双耳就绪大约 2.7~3.6 秒,可以连续进出。 --- ## 0. 产品形态与为什么要单开 BLE **形态:A、B 两个人各戴一只耳机,一副耳机拆成两只独立设备,各自一条 BLE 连到同一部手机。** A 说话 → A 那只耳机的麦上行 → 云端翻译 → 译文只下行到 B 那只耳机;反向同理。 手机只做中枢,不参与收音和放音(手机侧 UI 只显示字幕)。 这要求手机同时做四件事:**收左耳麦、收右耳麦、往左耳放译文、往右耳放译文**,四条流内容各不相同,且: - 两路上行**同时并发**、可独立起停(A 说话时 B 插话,不能排队); - 两路下行**可独立寻址**(A 说的话只能进 B 的耳朵); - 两只耳机之间**不需要同步**:各听各的译文,没有立体声对齐要求。这一点让拆 TWS 变得可行。 现有恒玄链路是**一条经典蓝牙**(Android 是 SPP/RFCOMM,iOS 是 GATT over BR/EDR,见 §1.1), 所有指令与音频挤在这一条上,**副耳根本没有到手机的链路**,面对面的两路在这条链路上做不出来。 所以另起 BLE:`AA 19 06 02 01 01` 让耳机拆成左右两个 BLE 外设、各自建链,结束时 `AA 19 06 02 01 00` 切回经典蓝牙。 --- ## 1. 设计边界(明确不动什么) | 不动 | 说明 | |---|---| | 现有经典蓝牙全部指令与音频格式 | `AA 51/52/56/57/58/59/5A/5E/5F/65/6A`、`BB D1/D2/D9/DA/E5/64/55/60/5B` 一律不改 | | 通话翻译 / AI 对话 / 录音 / OTA | 全部保持现状;面对面 BLE 与它们**互斥**,不并行(§11.5) | | 经典蓝牙配对关系 | 面对面结束后必须回到进入前的状态(§11.3) | | 非面对面路径 | App 侧所有改动都以「面对面会话进行中」为前提(`F2fBleLink.sessionActive` / `_f2fEarMode`),其它功能的发送、断连上报、下行一行不变 | > ⚠️ `19 / 66 / 67 / 68 / 69` 这几个命令号在**本条固件线(Echo-one)**上是面对面专用;命令号是 (产品, 固件版本) 的函数,换产品要重新核对。 > `AA 19` 是链路切换命令。不带 value 的短帧回过 `BB 99 04 01`,那不是成功。现行格式见 §4。 ### 1.1 经典链路:Android 是 SPP,iOS 是 GATT over BR/EDR 两边面对面用的 BLE UUID 相同(§6),但**进面对面之前那条旧链路不是同一种东西**: | | Android | iOS | |---|---|---| | 平时连耳机 | SPP(RFCOMM) | CoreBluetooth API,底层是 **经典蓝牙 BR/EDR 上的 GATT** | | `AA 19` 从哪写出 | `writeSppCtrl` | `writeClassicCtrl`(绕开对 `AA 66` / `AA 67` 的拦截) | | 面对面期间这条旧链路 | 保持。断开不上报给页面 | `BB 99` 一到就 `cancelPeripheralConnection` 关掉。进行中若系统再次把这台设备交过来(`.peerConnected`),直接返回,不重新连接 | | 非面对面 | 与改之前相同,不会去关这条链路 | 同左。只有 `F2fBleLink` 会话进行中才拒绝重新接管 | iOS 上 `00001100-d102-…` 这套 UUID **平时跑在 BR/EDR,面对面时跑在 LE**。两个 central:经典那条带状态恢复,左右耳 BLE 用另一个**不带**状态恢复标识的 central。不要合成一个。 ### 1.2 App 侧载体 | 层 | 落点 | 说明 | |---|---|---| | 时序编排 + LE 链路 | `F2fBleLink.kt` / `F2fBleLink.swift` | 发 `AA 19` →(iOS 先等 `BB 99`、关经典 GATT、等 1 秒)→ 扫描集齐双耳 → 两条 GATT → BLE 发 `AA 66`;上行 `BB 68` 拆帧解码;单耳重连;结束 `AA 67` → `AA 19 00` → 断 GATT → 恢复期 | | 命令拦截 / 经典链路 | `BluetoothManager.kt` / `.swift` | 拦下 Dart 的 `AA 66` / `AA 67` 交给 `F2fBleLink`;经经典链路发 `AA 19`;接收 `BB 66/67`、`BB E6/E7`、`BB 99`;会话中经典链路断开不上报页面;结束后回连;拦截互斥命令 | | 下行 | `CallTranslationDownlink`(Kotlin / Swift 各一份) | 复用通话翻译的追赶器与固定节拍定时器,面对面时两耳**各自**起停,打 `AA 69` 单路包 | | 设备契约 | `lib/devices/bes/` 的 `BesDeviceSession` | `DeviceCapability.faceToFace`,`AudioRoute.f2fLegA/f2fLegB`,`DeviceFeatureKeys.f2fLink`;协议常量见 `BesF2f` | | 业务 | `translation_controller.dart` | 两端都等双耳 BLE 就绪才开 AST、下行和计时。连着耳机却没起来:不开手机麦、不计时 | **理由**:这条链路有实时截止期(每 20ms 一包、四路并发),必须下沉原生;Dart 只做没有截止期的状态机与展示。 `AA 66` 要等两耳 BLE 都就绪才发,不适合跨一次 Dart 往返。 --- ## 2. 拓扑:两只耳机各自独立 ``` BLE (2 条,互相独立) 手机 ═══════════════► 左耳(广播名 Echo-one-L) ═══════════════► 右耳(广播名 Echo-one-R) ─ ─ ─ ─ ─ ─ ─ ─► 主耳经典链路:只用来发 AA 19 06 02 01 01 Android 之后仍可能留着;iOS 在 BB 99 后主动关掉 ``` - 收到 `AA 19 06 02 01 01` 后,耳机**解除 TWS**,左右耳各自起 BLE 广播、各自被 App 连接,各跑一条 GATT; - 两条链路彻底解耦;代价是**A2DP / HFP 期间必然中断**(来电见 §11.5); - **每条链路上只传本耳那一路**:左耳链路上的 `BB 68` 就是左耳麦,`AA 69` 写到左耳链路就是只给左耳。 帧里没有耳位字段,耳位由「走的是哪条链路」决定。 --- ## 3. 完整时序 Android(`BB 99` 不挡扫描,经典 SPP 不主动关): ``` App 主耳(SPP) 左耳(LE) 右耳(LE) │ ① SPP: AA 19 06 02 01 01 │ │ │ │───────────────────────────►│ 拆 TWS,两耳各起 BLE 广播 │ ② 写出后立刻 LE 扫描(15s,从开扫起算) │ 集齐后两条 GATT:发现服务 → MTU → 开通知(10s 内两耳都要就绪) │ ③ 两耳就绪 → LE: AA 66 06 02 01 01(两耳各一次)→ Dart 才开始翻译 │ ④ 左耳 BB 68 → AST leg A → 右耳 AA 69;右耳对称 │ ⑤ LE: AA 67,300ms 后 LE: AA 19 06 02 01 00,400ms 后断 GATT │ SPP 断过则 1.5s 后回连,没断则冷却 2s。恢复期内不能再进 ``` iOS(先等应答,再关掉经典 GATT,再等 1 秒): ``` App 主耳(GATT/BR-EDR) 左耳(LE) 右耳(LE) │ ① 经典 GATT: AA 19 06 02 01 01 │──────────────────────────►│ │ 等 BB 99(最多 3s)。没有就失败,不扫描 │◄──────────────────────────│ BB 99 04 00 │ 立刻 cancelPeripheralConnection(经典 GATT 让开) │ 再等 1 秒 │ ② 然后才 LE 扫描(15s 从这一刻起算),集齐后连两条 GATT(10s) │ ③④⑤ 与 Android 相同。结束时经典 GATT 已经关过,1.5s 后按原路径回连 │ 面对面进行中,系统再次上报这台经典设备已连接 → 不接管 ``` **原生里的对应** | 步 | 触发 | 原生做法 | |---|---|---| | ① | Dart `f2fStart`(`AA 66`)/ 耳机经经典链路上报 `BB 66` | 拦下 → `F2fBleLink.start()`:写 `AA 19 06 02 01 01`。经典链路没连着 → `failed(noSpp)` | | ② Android | `AA 19` 写出成功 | 立刻 `startScan()`。15s 从这一刻起 | | ② iOS | 收到任意 `BB 99` | 关经典 GATT,`linkScanDelay = 1s` 后 `beginScan()`。15s 从开扫起,不含前面的等待。3s 内没有 `BB 99` → `failed(linkAck)`,不扫描 | | ② 共同 | 扫到广播 | 按名字分左右,集齐才连(§5);发现服务 + MTU + 开通知 = 就绪 | | ③ | 第二只耳机就绪 | `writeCtrl(AA 66 06 02 01 01)` 发给两耳;两耳 `ready`,Dart 的等待完成,这时才开下行、AST、计时 | | ④ | — | 左耳 PCM 作 `micData`(leg A),右耳作 `spkData`(leg B);下行 swapLegs 交叉写 | | ⑤ | Dart `f2fStop`(`AA 67`)/ 失败兜底 / 耳机 `BB 67` 兜底 | 清两耳音频队列 → BLE `AA 67` → 300ms 后 BLE `AA 19 06 02 01 00` → 400ms 后断两条 GATT、进恢复期 | **耳机发起**:用户在耳机上开启面对面时,耳机经经典链路上报 `BB 66`,原生直接走 ①,并通知 Dart。 Dart 在面对面页、前台时再发的 `AA 66` 按幂等处理。 耳机上报 `BB 67` = 用户在耳机上结束,Dart 停翻译并回发 `AA 67`;Dart 3s 内没响应,原生自己走 ⑤。 --- ## 4. 指令 帧格式沿用恒玄现有格式:耳机 → App 为 `BB [LEN] [payload]`,`LEN` 为**整帧长度**(含 3/4 字节头); `CC` 起头时长度 = `LEN + 256`(长帧,App 拆帧时兼容)。App → 耳机的控制命令是 `AA [value]`。 | 指令 | 方向 | 链路 | 格式 | 说明 | |---|---|---|---|---| | 切 BLE | App → 耳机 | **经典**(Android SPP / iOS GATT over BR/EDR) | `AA 19 06 02 01 01` | 第一步。耳机拆 TWS、两耳各起 BLE 广播。只发这一帧 | | 切 BLE 应答 | 耳机 → App | 同上 | `BB 99 04 00` | 成功。`04` 是整帧长度,末字节 `00` = 成功。非 `00` 见 §4.1 | | 启动 | App → 耳机 | **BLE**(两耳各写一次) | `AA 66 06 02 01 01`(应答 `BB E6`) | 两耳都握手好后发。Dart 下发的是 2 字节 `AA 66`,原生改写 | | 启动 | 耳机 → App | 经典 / BLE | `BB 66 …` | 用户在耳机上发起,App 按 §3「耳机发起」处理 | | 上行 | 耳机 → App | 本耳 BLE(notify 1102) | `BB 68 LEN X ` | 见 §8 | | 下行 | App → 耳机 | 本耳 BLE(write 1101) | `AA 69 2C 01 <40B G.722>` | 见 §9 | | 结束 | App → 耳机 | **BLE**(两耳各写一次) | `AA 67`(应答 `BB E7`) | 退出面对面。Dart 下发 2 字节,原生照发 | | 切回经典 | App → 耳机 | **BLE**(两耳各写一次),`AA 67` 后 300ms | `AA 19 06 02 01 00` | 最后一步。耳机切回经典蓝牙、恢复 TWS / A2DP | | 结束 | 耳机 → App | 经典 / BLE | `BB 67 …` | 用户在耳机上结束 | - **兜底**:一只 BLE 耳机都没连上就要结束时,`AA 67` + `AA 19 06 02 01 00` 改经经典链路发(它还在的话)。 iOS 若已经在 `BB 99` 后把经典 GATT 关掉,这条兜底发不出去,只记日志; - **单耳重连**:会话中掉线的耳机重连就绪后,单独给它补发一次 `AA 66`(§11.2); - **`AA 66` / `AA 67` 的应答只记日志**,不作为流程推进条件。`BB 99` 的用法两端不同,见 §4.1; - 上行也兼容 `BB 55` / `BB 60`(与经典链路同构的音频帧),按同一种拆帧解码处理。 ### 4.1 `AA 19` 只使用这一种格式 与设置类命令同构(对照 EQ 的 `AA 0F 06 02 01 `): | 含义 | 帧 | 字节 | |---|---|---| | 切到 BLE | `AA 19 06 02 01 01` | SOF `AA`,CMD `19`,LEN `06`(整帧长度),类型 `02`,长度 `01`,值 `01` | | 切回经典 | `AA 19 06 02 01 00` | 同上,值 `00` | | 成功应答 | `BB 99 04 00` | 与 `BB E6 04 00` / `BB E7 04 00` 同一惯例:LEN=`04`,status=`00` | 已经排除、**不要再发**的格式: | 帧 | 真机结果 | |---|---| | `AA 19 01` / `AA 19 00`(3 字节,不带长度) | 回 `BB 99 04 01`,耳机不切 BLE | | `AA 19 04 ` | 猜测格式,未采用,代码里已删除 | App 不再做格式试探,也不再把「上次成功的格式」存盘。 `BB 99` 怎么用: | 平台 | 行为 | |---|---| | iOS | 写出 `AA 19` 后最多等 **3 秒**。收到 `BB 99`(status 记进日志,不要求必须是 `00` 才继续)→ 关经典 GATT → 等 1 秒 → 扫描。超时没有 `BB 99` → `failed(linkAck)`,**不扫描** | | Android | 写出后立刻扫描。`BB 99` 只打日志,不挡扫描,也不据此关 SPP | 真机成功场次看到的都是 `BB 99 04 00`。iOS 不把非 `00` 当成失败,是为了避免应答形态轻微变化时整条链路停住;固件侧成功仍应回 `00`。 --- ## 5. BLE 广播与发现 ### 5.1 广播内容 | 字段 | 当前固件 | |---|---| | Local Name | 左耳 **`Echo-one-L`**,右耳 **`Echo-one-R`** | | Service UUID | 未要求;App 只按名字认 | | TOKEN / MSD | **没有** → 防连错靠时机 + 左右名字,见 §14 Q6 | ### 5.2 App 侧扫描规则(已实现) 1. **何时开扫**:Android 在 `AA 19` 交给 SPP 写队列后立即开扫(`SCAN_MODE_LOW_LATENCY`,不带过滤器)。 iOS 在收到 `BB 99` 并关掉经典 GATT 之后再等 1 秒才开扫(`scanForPeripherals`,不按 Service UUID 过滤);扫到两只就 `stopScan`。 15 秒都从**真正开始扫描**起算,不含 iOS 前面等应答和等 1 秒的时间; 2. **必须集齐一左一右才开始连接**:15s 内只扫到一侧 → `failed(oneEar)`,一个都没有 → `failed(noEar)`, 提示「耳机 BLE 未连接,请确认双耳都已取出并佩戴后重试」并按 §4 兜底回滚。**不做单耳降级,也不退回手机麦**; 3. 左右分配按名字(**写死**):`Echo-one-L` = 左耳 = leg A,`Echo-one-R` = 右耳 = leg B,其它名字(含老固件同名 `Echo-one`)一律不认。 已有同侧候选时再扫到同侧广播(旁边另一副 Echo-one)**忽略**,不会凑成两只左耳; **名字以广播里的 Local Name 为准**:广播不带名字、名字只来自系统缓存的(真机上主耳经典地址缓存名就叫 `Echo-one-R`)只当**备胎**: 同侧来了带名字的广播就替换它;有备胎时不立刻开连,等到 15s 仍是一左一右才用备胎连; 4. 两只集齐后同时连两条 GATT,**10s 内两耳都要就绪**,否则 `failed(connectTimeout)`; 建链阶段单耳 GATT 断开重试 3 次,用尽 `failed(connectFailed)`; 5. **扫描必须在 App 前台完成**:Android 熄屏 / 后台时不带过滤器的扫描会被系统拦掉。 等待双耳期间 App 切到后台(paused)→ 放弃本次,提示「连接耳机时请保持 App 在前台」; 耳机发起的 `BB 66` 在 App 不在前台时不响应。 ### 5.3 耳机侧超时(建议,待固件确认 §14 Q4) - 收到 `AA 19 06 02 01 01`、起广播后 **30s** 内无人连接,该耳自动切回经典蓝牙;两耳都退出后重新组对、恢复常态; - 连接建立后停止广播。 --- ## 6. GATT 服务与特征(两耳完全相同) ### 6.1 UUID | 项 | UUID | 属性 | 方向 | |---|---|---|---| | Service | `00001100-d102-11e1-9b23-00025b00afae` | — | — | | 写 | `00001101-d102-11e1-9b23-00025b00afae` | Write Without Response(无则 Write) | App → 耳机(`AA 66/67/19`、`AA 69`) | | 通知 | `00001102-d102-11e1-9b23-00025b00afae` | Notify | 耳机 → App(`BB 66/67/68`、应答) | 真机上两个特征按 properties 挑(有写属性的当写、有 notify 的当通知),不硬依赖编号;建链时把所有可 notify 特征都打开并打印整张 GATT 表(诊断用)。 控制与音频共用一个写特征:控制帧**插到写队列队首**直发,不排在音频后面; 结束时 `AA 19 00` 则排在 `AA 67` **之后**(先清空音频队列),保证「先退面对面、再切链路」。 ### 6.2 链路参数 | 项 | App 侧做法 | 理由 | |---|---|---| | ATT MTU | 请求 247(真机协商到 512) | 单路下行包 44B,上行聚合包更长 | | 连接优先级 | `CONNECTION_PRIORITY_HIGH` | 连接间隔 > 40ms 会让下行断续 | | 就绪判定 | GATT 连上 + 发现服务 + MTU + CCCD 开通知成功 | 即「握手」,见 §7 | ### 6.3 App 侧写入规则 1. 下行音频一律 Write Without Response,每耳独立写队列,上限 8 包(≈160ms),满了**丢最旧的**; 2. 写回调 500ms 不回就当丢了,不让队列永远卡住; 3. 控制命令绝不排队在音频后面。 --- ## 7. BLE 握手与启动 **握手 = GATT 连上 + 发现服务 + MTU + 开通知**,没有额外的应用层握手帧(v0.2 的 HELLO / SESSION_CFG / START_STREAM / KEEPALIVE / EXIT 作废)。 - 单耳握手完成:该耳状态 `ready`,**还不发 `AA 66`**; - **两耳都握手完成**:同一时刻经 BLE 给两耳各发一次 `AA 66`,耳机开始推 `BB 68`;Dart 同时正式开始翻译; - 只有一只握手完成、另一只超时:不发 `AA 66`,`failed(connectTimeout)` 后按 ⑤ 收尾(对已连上的那只经 BLE 发 `AA 67` + `AA 19 00`)。 --- ## 8. 音频上行(耳机 → App) ``` BB 68 ``` | 字段 | 说明 | |---|---| | `LEN` | 整帧长度(含 4 字节头);`CC` 起头时 +256 | | `X` | App 忽略(含义待确认,§14 Q5) | | 音频 | 从第 5 字节起每 40 字节一帧 G.722(16kHz / 16bit / 单声道,一帧 = 20ms = 640B PCM) | - **每只耳机只传自己那一路**:左耳 → `micData`(leg A),右耳 → `spkData`(leg B), 之后与通话翻译的 SPP 路径完全一样(原生直连桥 / 录音 / Dart 音频流); - 兼容:通知不是 `BB/CC` 开头、长度又正好是 40 的倍数时,按裸 G.722 解; - 收到本耳第一帧 PCM,链路状态 `ready → streaming`;上行停 1.5s 退回 `ready`; - 丢包不重传。 --- ## 9. 音频下行(App → 耳机) ``` AA 69 2C 01 <40B G.722> 共 44 字节 = 20ms 音频 ``` - 路由:左耳上行 → AST leg A(源语 → 目标语)→ 译文**只写右耳链路**;右耳上行 → leg B → 译文只写左耳链路 (Dart 仍按 leg A/B 写,「播到对侧」由原生下行器的 swapLegs 完成); - **两耳各自起停**:哪只耳机有译文才给哪只发包,另一只不陪发静音; - 起播前先垫 **4 包编码静音**再发真音频(从空缓冲起播必出杂音);一段结束后续 **≈500ms** 静音才停发; - 积压追赶直接复用 `CallTranslationDownlink`(裁静音 + WSOLA 变速);发包定时器固定 20ms 节拍、永不重排; - 某只耳机掉线重连中(§11.2)时**两耳下行都停**,恢复后自然续上; - 原生按 cmd=`0x69` 识别面对面下行(包头与通话翻译 `AA 56` 同为 `AA`)。 --- ## 10. 事件上报 固件目前**没有**按键 / 佩戴 / 电量 / 异常事件帧(v0.2 的 `BB 87` 作废)。 只有 `BB 66` / `BB 67`(§4)表示用户在耳机上开始 / 结束。 日志(tag `BesF2f` / `BesF2fBle`): - SPP 上 `BB E6/E7/99` 单独打印;会话中与恢复期内其它未识别命令**不限量**打印(对照应答码); - BLE 上 `BB E6/E7/99` 单独打印,其它未识别帧前 20 条打印。 App 内部把原生链路状态以 `f2fLink` 事件交给 Dart(§12),供 UI 与状态机用。 --- ## 11. 退出与恢复(重点) 功能结束后**必须完整回到进入前的状态**。这一节的每一条都是验收项。 ### 11.1 建链失败回滚 扫描超时 / 只扫到一只 / 连接超时 / 重试用尽 / 蓝牙关闭 / 经典链路没连着(`AA 19` 发不出去,`noSpp`)/ iOS 上 3 秒没有 `BB 99`(`linkAck`)→ 原生上报 `failed(reason)`(每场只报一次)→ Dart 提示原因、走正常收尾(发 `AA 67` → 原生按 ⑤ 发 `AA 67` + `AA 19 00`、断 GATT、进恢复期)。 连着耳机时这一步**不会**打开手机麦,也**不会**开始录音计时。 Dart 没响应时,原生 **3s** 后自己走 ⑤。 Dart 侧另有 **30s** 总等待上限。iOS 的 3s 等应答 + 1s + 15s 扫描 + 10s 建链落在这个上限里面。 ### 11.2 单耳失联 会话中某耳 GATT 断开:该耳状态 `reconnecting`,提示「左/右耳已断开,正在重连…」; **两耳下行都停**;**10s** 内重扫同地址并重连(两次连接间隔 ≥ 1s),重连就绪后**单独给它补发 `AA 66`**,续上; 超时 `failed(earLost)` → 提示「耳机连接已断开,面对面翻译已结束」并完整退出。 结束流程(⑤)中耳机收到 `AA 19 00` 自己断 LE,**不算掉线**、不重连。 ### 11.3 正常结束与恢复期 1. 两条 BLE 上依次写 `AA 67`、(300ms 后)`AA 19 06 02 01 00`;一条 BLE 都没有时两条命令改经经典链路发(§4 兜底;iOS 经典 GATT 已关则发不出去); 2. `AA 19 00` 写出 400ms 后断两条 GATT,释放解码器; 3. 进入**恢复期**,两耳标签显示「恢复中」: - 会话期间经典链路断过(iOS 在 `BB 99` 后主动关,算断过)→ 1.5s 后 App 按**进入前那条路径**回连(Android 回连 SPP;iOS 回连 GATT over BR/EDR)。Android 重试 6 次,遇到别的连接在进行就每 2s 再看一次,最多 5 轮; - 经典链路全程没断(主要是 Android)→ 冷却 2s 结束; - 最长 30s 强制结束; 4. 恢复期内**不许再进面对面**:提示「耳机恢复中,请稍后再试」;原生收到 `AA 66` / `BB 66` 拒绝并上报 `failed(recovering)`; 5. 会话期间经典链路断开**不上报**给页面(设备页仍显示已连接),结束到回连完成前的窗口同样不上报。非面对面时的断连上报与改之前相同。 ### 11.4 异常退出 进入时落盘 `exitPending=true`;`AA 19 06 02 01 00` 确认发出(任一 BLE 写出,或无 BLE 时经经典链路发出)才清掉。 App 被杀 / 崩溃 / 两条链路都不通时没清掉 → 下次经典链路连上只清标记、打日志,**不补发** (经典链路能连上说明耳机已经回到原来的通道)。 > 固件侧还应有看门狗:LE 连接断开(supervision timeout)后该耳自动切回经典蓝牙并等待对耳重新组对。 > App 被杀时没有任何机会发 `AA 67` / `AA 19 00`,只有链路断开这个信号必然发生(§14 Q4)。 ### 11.5 互斥 面对面与下列功能**同一时刻只能有一个**: | 场景 | App 侧做法 | |---|---| | 进入前:通话中 | 不进,提示「通话中,无法使用面对面翻译」 | | 进入前:AI 对话 / 主麦录音 / 通话录音进行中 | 不进,提示「请先结束耳机上正在进行的功能」 | | 进入前:恢复期 | 不进,提示「耳机恢复中」 | | 会话中:App 要发 `AA 51`(通话翻译)/ `AA 59`(录音)/ `AA 6A`(AI 应答) | 原生拦下不发 | | 会话中:耳机上报 `BB 64`(AI 唤醒)/ `BB D9`(耳机录音) | 原生忽略 | | 会话中:来电(`inCall`) | 结束面对面,提示「有来电,面对面翻译已结束」 | ### 11.6 手机麦 - **Android**:没有就绪的 Echo-one 会话 → 不启动,提示「未连接 Echo-one 耳机,无法使用面对面翻译」。没有手机麦这条退路; - **iOS**:没有就绪的 Echo-one 会话 → 退回手机麦,并提示将使用手机麦克风收音; - **两端只要连着耳机**:发出 `AA 66` 后等两耳 BLE 都就绪才开下行、起 AST、接桥、开始计时。连不齐按 §11.1 收尾,**不开手机麦、不计时**; - 等待中再点一次开始 = 取消。 ### 11.7 iOS 与 Android 的差异(已实现) iOS 双耳 BLE 已落地,时序见 §3。和 Android 相比,只多这三件事,都发生在第 ② 步: 1. `AA 19` 写在 GATT over BR/EDR 上,不写在 SPP 上; 2. 必须先收到 `BB 99`,然后**关掉**这条经典 GATT,面对面期间不再接管它; 3. 关掉之后固定再等 **1 秒**才扫描。500ms 在同一台耳机上右耳连不上,不要改短。 结束回连走 iOS 原来的经典蓝牙接管(`registerForConnectionEvents` + `.peerConnected`),不新开一套。非面对面的连接、电量、版本查询都不经过 `F2fBleLink`。 --- ## 12. App 内链路状态(`f2fLink` 事件) 原生经音频事件流上报 `{type: f2fLink, side, state, reason?, ok?}`,Dart 收为 `DeviceFeatureKeys.f2fLink`。 **单耳**(`side` = `L` / `R`) | state | 含义 | 页面标签 | |---|---|---| | `scanning` / `connecting` | 扫描 / 连接中 | 橙点 | | `ready` | 已握手,还没收到上行 | 蓝点 | | `streaming` | 正在收音 | 绿点 | | `reconnecting` | 会话中掉线重连 | 红点 | | `restoring` | 会话结束,恢复期 | 琥珀点 +「恢复中」 | | `idle` | 不在会话 | 无 | **整体**(`side` 为空) | state | reason / 字段 | App 提示 | |---|---|---| | `failed` | `oneEar` / `noEar` | 耳机 BLE 未连接,请确认双耳都已取出并佩戴后重试 | | `failed` | `noSpp` / `linkAck` / `connectTimeout` / `connectFailed` / `scanFailed` / `noService` / `bluetoothOff` / `notInit` | 耳机 BLE 未连接,无法使用面对面翻译 | | `failed` | `earLost` | 耳机连接已断开,面对面翻译已结束 | | `failed` | `recovering` | 耳机恢复中,请稍后再试 | | `recovered` | `ok`(SPP 回连是否成功) | 无,恢复期结束、允许再次进入 | Dart 自身的等待失败原因:`background`(切后台)、`timeout`(30s 总上限)、`cancelled`(用户取消 / 离开页面 / 耳机结束 / 来电,不额外提示)。 --- ## 13. 带宽与延迟预算 **带宽**(G.722 @16kHz = 16 kbps/路,每条链路一上一下):单链路 ≈ 33 kbps,44B 下行包每 20ms 一个。 BLE 1M PHY 在 15~30ms 连接间隔下余量充足;但手机侧是两条 LE 并发,**共存下**的实测数仍要固件给(§14 Q7)。 裸 PCM 单路即 256 kbps,BLE 上不可行,上下行都必须是编码码流。 **延迟预算**(单向,从 A 开口到 B 听到) | 段 | 预估 | |---|---| | 耳机采集 + 编码 | 20~40ms | | BLE 上行 | 30~60ms | | 云端翻译(端到端 AST) | ≈ 800ms | | BLE 下行 + 垫底 | 30~80ms | | 解码 + 抖动缓冲 + 播放 | 60~120ms | | **合计** | **≈ 1.0s** | 蓝牙链路只占 100~250ms,优化重心在云端。面对面两只耳机恰好与 AST 的 leg A / leg B 同构, 通话翻译那套原生直连桥直接复用。 --- ## 14. 待固件确认清单 请逐条回复「支持 / 不支持 / 需改为」: | # | 问题 | 影响 | |---|---|---| | Q1 | ✅ **格式已定**:`AA 19 06 02 01 01` / `AA 19 06 02 01 00`。成功应答 `BB 99 04 00`(真机已见到)。3 字节 `AA 19 01` 回 `BB 99 04 01`,已废弃。请确认 status 除 `00` 之外还有哪些值、各自含义 | iOS 目前任意 `BB 99` 都继续;固件若会回失败 status,需要 App 再拦 | | Q2 | `AA 19 06 02 01 01` 之后多久起 L/R 广播?经典链路是否由耳机自己断开?`AA 19 00` 之后由耳机回连,还是只能 App 回连? | iOS 在应答后主动关经典 GATT 并再等 1 秒才扫,这段是为了让耳机把 BLE 广播稳下来。Android 仍是写出后立刻扫、不关 SPP | | Q3 | ✅ `AA 66` / `AA 67` / `AA 19 00` **两耳各发一份**(产品确认:两耳 BLE 分别绑定,启停指令左右耳各自同步下发;App 已这样实现)。`AA 67` 与 `AA 19 00` 之间 300ms 是否足够?只发 `AA 19 00` 不发 `AA 67` 是否也能退出? | 结束时序 | | Q4 | 起广播后 30s 无人连接是否自动切回经典?LE 断开后该耳是否切回并重新组对? | 决定「App 被杀耳机会不会卡在 BLE」 | | Q5 | `BB 68` 第 4 字节(`X`)的含义?一包里固定几帧 40B?`AA 69 2C 01 <40B>` 第 4 字节 `01` 是否表示单帧? | 上行切帧、下行能否播 | | Q6 | ✅ 广播名已是 `Echo-one-L` / `Echo-one-R`。能否再在广播里带一个本次会话的标识(如主耳地址后 2 字节)? | 旁边另一副同时进面对面的 Echo-one 仍可能被先扫到而连错 | | Q7 | 两条 LE 并发下的吞吐与丢包? | 决定上行聚合帧长 | | Q8 | 面对面期间的来电:从振铃到 HFP 恢复可接听要多久?耳机能否主动上报? | 现在 App 只能靠系统 `inCall` 结束面对面 | | Q9 | 与通话 / AI / 录音 / OTA 的互斥固件是否也拦? | 建议双保险 | | Q10 | 是否能提供按键 / 佩戴 / 电量 / 本人说话(OWN_VOICE)事件? | 按住说话、摘耳暂停、串音抑制 | | Q11 | 耳机按键发起面对面时,是先上报 `BB 66` 等 App 发 `AA 19 06 02 01 01`,还是自己直接切 BLE? | App 现在收到 `BB 66` 一律发这一帧再按 §3 扫描 | --- ## 15. 联调验收用例 | # | 场景 | 期望 | |---|---|---| | T01 | 两耳佩戴,App 点开始 | 日志有 `AA 19 06 02 01 01` 与 `BB 99 04 00`,再扫到 Echo-one-L 与 Echo-one-R,两耳 GATT 就绪后才出现「AA 66 经 BLE 发给 2 只耳机」。标签 橙 → 蓝 → 绿。计时和字幕都在两耳就绪之后。Android 写出后立刻扫;iOS 先关经典 GATT,日志为「1000 毫秒后只扫左右耳 BLE」,从 `BB 99` 到双耳就绪约 3~4 秒 | | T02 | 左耳说话 | 只有右耳听到译文;左耳自己听不到 | | T03 | 双方同时说话 | 两路各自独立上行,两路译文分别到达对侧 | | T04 | 一只耳机在盒里点开始 | 扫描窗口结束后提示双耳未就绪,**不走手机麦、不计时**。已连上的那只经 BLE 收到 `AA 67` + `AA 19 06 02 01 00`;一只都没连上则改经经典链路发(iOS 经典 GATT 已关时发不出去) | | T05 | 没连耳机点开始 | Android:立即提示无法使用,不进准备中。iOS:退回手机麦并提示 | | T06 | 等待双耳时再点一次开始 / 返回 / 切后台 | 取消本次并按 ⑤ 收尾;切后台提示保持前台 | | T07 | 正常结束后立即再开 | 提示「耳机恢复中」;恢复期结束后可正常进入 | | T08 | 正常结束 | 日志「AA 67 经 BLE 发给 2 只耳机」→「AA 19 00 经 BLE 发给 2 只耳机」;两耳重新组对,音乐可播、来电可接。iOS 结束约 1.5s 后经典 GATT 回来,设备页在面对面期间不报掉线 | | T09 | **App 进程被杀** | 耳机按看门狗切回经典(Q4);下次 SPP 连上 App 只清 exitPending,不补发 | | T10 | 面对面期间一只耳走远 | 该耳红点 + 提示正在重连,两耳下行暂停;10s 内回来补发 `AA 66` 续上;超时提示已断开并完整退出 | | T11 | 面对面期间来电 | 提示「有来电,面对面翻译已结束」,耳机退出面对面并切回经典 | | T12 | 面对面期间 / 进入前 有通话、AI、录音 | 进入前拦下并提示;会话中耳机唤醒 AI、按录音无反应 | | T13 | 在耳机上发起 / 结束面对面 | App 在面对面页前台时跟着开始 / 结束;不在前台时不启动 | | T14 | 连续进出 20 次 | 无资源泄漏,每次都成功组对 | | T15 | 一句 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 降为诊断;`BB 86` 调速改为 `BB 88` 按包补/扣;上行 FLAGS bit7 定义为 `OWN_VOICE`;补 iOS BR/EDR GATT、原生直连载体、前台扫描、写入规则、复用追赶器、单耳失联与来电、端到端为前置 | | v0.3 | 2026-10-20 | **协议统一为固件实现**:`BB 66` 启动 / `BB 67` 结束 / `BB 68` 上行 / `BB 69` 下行,GATT 用私有服务 `00001100-d102-…`(1101 写 / 1102 通知);作废 `AA 6B/6C`、`BB EB/EC`、TOKEN/MSD、LE 握手、`BB 90`/`AA 91`、`BB 87`/`BB 88`、错误码表。**App 侧落地**:集齐双耳再连(15s 扫描 + 10s 建链)、失败回滚并提示、单耳失联 10s 重连、下行两耳独立起停、恢复期、异常退出标记、互斥、前台限制、左右耳状态标签;Android **不再退回手机麦** | | v0.3.1 | 2026-10-20 | 固件广播名改为 **`Echo-one-L` / `Echo-one-R`**:App 按名字分左右,必须一左一右集齐才连,同侧第二个广播忽略 | | v0.3.2 | 2026-10-20 | 左右名写死;耳朵标签加文字状态;收音中上行停 1.5s 标签退回「已连接」;建链时打开所有可 notify 特征并打印 GATT 表;启动只走 SPP、结束只走 BLE | | v0.3.3 | 2026-10-20 | **App → 耳机启停改为 `AA` 帧头**:`AA 66` / `AA 67`(耳机回 `BB E6` / `BB E7`);耳机主动上报仍是 `BB 66` / `BB 67` | | v0.3.4 | 2026-10-20 | **译文下行改为 `AA 69 2C 01 <40B G.722>`**;上行仍是 `BB 68`。真机:经 SPP 发 `AA 66` 后耳机回 `BB E6 04 00`、在 SPP 上推 `BB 68`,不起 L/R 广播 | | v0.4 | 2026-10-20 | **五步时序:`AA 19 01`(SPP 切 BLE)→ BLE 握手 L/R → `AA 66`(BLE)→ 交叉收放 → `AA 67` + `AA 19 00`(BLE 切回经典)**。原生:`start()` 先经 SPP 发 `AA 19 01`(SPP 没连着报 `failed(noSpp)`),两耳都就绪时才经 BLE 发 `AA 66`,单耳重连就绪补发 `AA 66`;新增 `requestStop()` 统一结束流程(Dart `AA 67` / 失败兜底 / 耳机 `BB 67` 兜底都走它),结束中耳机断 LE 不算掉线;一只 BLE 都没有时 `AA 67` + `AA 19 00` 改经 SPP;`exitPending` 以 `AA 19 00` 送达为准;`BB 99` 记为 `AA 19` 应答(待确认)。文档:新增「速览」、§3 原生对应表、§7 改为握手与启动、§14 按新时序重排待确认项 | | v0.4.1 | 2026-10-20 | 曾做 `AA 19` 三种格式自动试探。真机 3 字节 `AA 19 01` / `AA 19 00` 回 `BB 99 04 01`,耳机不切 BLE。该试探已在 v0.5 删除 | | v0.5 | 2026-10-20 | **按两端已落地的行为改写。** 帧格式固定为 `AA 19 06 02 01 01` / `AA 19 06 02 01 00`,启动帧为 `AA 66 06 02 01 01`,成功应答 `BB 99 04 00`。Android:写出后立刻扫,不关 SPP,`BB 99` 只记日志。iOS:等 `BB 99`(3s)→ 立刻关闭 GATT over BR/EDR → 等 1 秒 → 再扫左右耳;面对面期间不重新接管经典 GATT,结束后回连。500ms 等待在真机上右耳连不上,1 秒可连续进出(`BB 99` 到双耳就绪约 2.7~3.6 秒)。两端连着耳机时,双耳 BLE 没起来不开手机麦、不计时;仅 iOS 在完全没连 Echo-one 时退回手机麦 |