You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

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 侧扫描规则(已实现)

  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> <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 正常结束与恢复期

  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。


原生经音频事件流上报 {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 时退回手机麦