36 KiB
面对面翻译 · 双耳独立 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(现有经典蓝牙协议,本文与它并存,不替代)
速览:五步时序(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 <CMD> [LEN] [payload],LEN 为整帧长度(含 3/4 字节头);
CC 起头时长度 = LEN + 256(长帧,App 拆帧时兼容)。App → 耳机的控制命令是 AA <CMD> [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 <N × 40B G.722> |
见 §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 <value>):
| 含义 | 帧 | 字节 |
|---|---|---|
| 切到 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 <value> |
猜测格式,未采用,代码里已删除 |
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 侧扫描规则(已实现)
- 何时开扫:Android 在
AA 19交给 SPP 写队列后立即开扫(SCAN_MODE_LOW_LATENCY,不带过滤器)。 iOS 在收到BB 99并关掉经典 GATT 之后再等 1 秒才开扫(scanForPeripherals,不按 Service UUID 过滤);扫到两只就stopScan。 15 秒都从真正开始扫描起算,不含 iOS 前面等应答和等 1 秒的时间; - 必须集齐一左一右才开始连接:15s 内只扫到一侧 →
failed(oneEar),一个都没有 →failed(noEar), 提示「耳机 BLE 未连接,请确认双耳都已取出并佩戴后重试」并按 §4 兜底回滚。不做单耳降级,也不退回手机麦; - 左右分配按名字(写死):
Echo-one-L= 左耳 = leg A,Echo-one-R= 右耳 = leg B,其它名字(含老固件同名Echo-one)一律不认。 已有同侧候选时再扫到同侧广播(旁边另一副 Echo-one)忽略,不会凑成两只左耳; 名字以广播里的 Local Name 为准:广播不带名字、名字只来自系统缓存的(真机上主耳经典地址缓存名就叫Echo-one-R)只当备胎: 同侧来了带名字的广播就替换它;有备胎时不立刻开连,等到 15s 仍是一左一右才用备胎连; - 两只集齐后同时连两条 GATT,10s 内两耳都要就绪,否则
failed(connectTimeout); 建链阶段单耳 GATT 断开重试 3 次,用尽failed(connectFailed); - 扫描必须在 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 侧写入规则
- 下行音频一律 Write Without Response,每耳独立写队列,上限 8 包(≈160ms),满了丢最旧的;
- 写回调 500ms 不回就当丢了,不让队列永远卡住;
- 控制命令绝不排队在音频后面。
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> <X> <N × 40B G.722>
| 字段 | 说明 |
|---|---|
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 正常结束与恢复期
- 两条 BLE 上依次写
AA 67、(300ms 后)AA 19 06 02 01 00;一条 BLE 都没有时两条命令改经经典链路发(§4 兜底;iOS 经典 GATT 已关则发不出去); AA 19 00写出 400ms 后断两条 GATT,释放解码器;- 进入恢复期,两耳标签显示「恢复中」:
- 会话期间经典链路断过(iOS 在
BB 99后主动关,算断过)→ 1.5s 后 App 按进入前那条路径回连(Android 回连 SPP;iOS 回连 GATT over BR/EDR)。Android 重试 6 次,遇到别的连接在进行就每 2s 再看一次,最多 5 轮; - 经典链路全程没断(主要是 Android)→ 冷却 2s 结束;
- 最长 30s 强制结束;
- 会话期间经典链路断过(iOS 在
- 恢复期内不许再进面对面:提示「耳机恢复中,请稍后再试」;原生收到
AA 66/BB 66拒绝并上报failed(recovering); - 会话期间经典链路断开不上报给页面(设备页仍显示已连接),结束到回连完成前的窗口同样不上报。非面对面时的断连上报与改之前相同。
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 相比,只多这三件事,都发生在第 ② 步:
AA 19写在 GATT over BR/EDR 上,不写在 SPP 上;- 必须先收到
BB 99,然后关掉这条经典 GATT,面对面期间不再接管它; - 关掉之后固定再等 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 时退回手机麦 |