Browse Source

client:Echo-one 面对面改走左右耳独立 BLE,iOS 收到 BB 99 后关闭经典 GATT 并等 1 秒再扫描连接。构建号 1.0.3+44

Co-authored-by: Cursor <cursoragent@cursor.com>
along
Rodger-Wang 9 hours ago
parent
commit
c76c432bfd
  1. 722
      apps/client/docs/face_to_face_ble_protocol.md
  2. 2
      apps/client/ios/Podfile.lock
  3. 16
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  4. 8
      apps/client/ios/Runner/AppDelegate.swift
  5. 21
      apps/client/ios/Runner/Info.plist
  6. 15
      apps/client/ios/Runner/RunnerProfile.entitlements
  7. 6
      apps/client/ios/Runner/SceneDelegate.swift
  8. 16
      apps/client/lib/core/translations/language/ar_sa.dart
  9. 16
      apps/client/lib/core/translations/language/bg_bg.dart
  10. 16
      apps/client/lib/core/translations/language/cs_cz.dart
  11. 16
      apps/client/lib/core/translations/language/da_dk.dart
  12. 16
      apps/client/lib/core/translations/language/de_de.dart
  13. 16
      apps/client/lib/core/translations/language/el_gr.dart
  14. 16
      apps/client/lib/core/translations/language/en_us.dart
  15. 16
      apps/client/lib/core/translations/language/es_es.dart
  16. 16
      apps/client/lib/core/translations/language/et_ee.dart
  17. 16
      apps/client/lib/core/translations/language/fa_ir.dart
  18. 16
      apps/client/lib/core/translations/language/fi_fi.dart
  19. 16
      apps/client/lib/core/translations/language/fr_fr.dart
  20. 16
      apps/client/lib/core/translations/language/hi_in.dart
  21. 16
      apps/client/lib/core/translations/language/hr_hr.dart
  22. 16
      apps/client/lib/core/translations/language/hu_hu.dart
  23. 16
      apps/client/lib/core/translations/language/id_id.dart
  24. 16
      apps/client/lib/core/translations/language/is_is.dart
  25. 16
      apps/client/lib/core/translations/language/it_it.dart
  26. 16
      apps/client/lib/core/translations/language/ja_jp.dart
  27. 16
      apps/client/lib/core/translations/language/ko_kr.dart
  28. 16
      apps/client/lib/core/translations/language/lt_lt.dart
  29. 16
      apps/client/lib/core/translations/language/lv_lv.dart
  30. 16
      apps/client/lib/core/translations/language/ms_my.dart
  31. 16
      apps/client/lib/core/translations/language/nb_no.dart
  32. 16
      apps/client/lib/core/translations/language/nl_nl.dart
  33. 16
      apps/client/lib/core/translations/language/pl_pl.dart
  34. 16
      apps/client/lib/core/translations/language/pt_pt.dart
  35. 16
      apps/client/lib/core/translations/language/ro_ro.dart
  36. 16
      apps/client/lib/core/translations/language/ru_ru.dart
  37. 16
      apps/client/lib/core/translations/language/sk_sk.dart
  38. 16
      apps/client/lib/core/translations/language/sl_si.dart
  39. 16
      apps/client/lib/core/translations/language/sr_rs.dart
  40. 16
      apps/client/lib/core/translations/language/sv_se.dart
  41. 16
      apps/client/lib/core/translations/language/th_th.dart
  42. 16
      apps/client/lib/core/translations/language/tl_ph.dart
  43. 16
      apps/client/lib/core/translations/language/tr_tr.dart
  44. 16
      apps/client/lib/core/translations/language/uk_ua.dart
  45. 16
      apps/client/lib/core/translations/language/vi_vn.dart
  46. 16
      apps/client/lib/core/translations/language/zh_cn.dart
  47. 16
      apps/client/lib/core/translations/language/zh_hk.dart
  48. 16
      apps/client/lib/core/translations/language/zh_tw.dart
  49. 23
      apps/client/lib/devices/bes/bes_bluetooth_service.dart
  50. 72
      apps/client/lib/devices/bes/bes_device_plugin.dart
  51. 34
      apps/client/lib/devices/bes/bes_protocol.dart
  52. 470
      apps/client/lib/modules/translation/controllers/translation_controller.dart
  53. 68
      apps/client/lib/modules/translation/views/translation_view.dart
  54. 194
      apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt
  55. 7
      apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt
  56. 120
      apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/CallTranslationDownlink.kt
  57. 1070
      apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/F2fBleLink.kt
  58. 126
      apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManager.swift
  59. 7
      apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift
  60. 79
      apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/CallTranslationDownlink.swift
  61. 965
      apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/F2fBleLink.swift
  62. 8
      apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager.dart
  63. 11
      apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_method_channel.dart
  64. 2
      apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_platform_interface.dart
  65. 18
      apps/client/local_plugins/device_plugin_interface/lib/src/audio_format.dart
  66. 12
      apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart
  67. 2
      apps/client/pubspec.yaml
  68. 10
      apps/client/test/devices/bes_protocol_test.dart
  69. 18
      apps/client/test/devices/bes_session_commands_test.dart

722
apps/client/docs/face_to_face_ble_protocol.md

@ -1,13 +1,38 @@
# 面对面翻译 · 双耳独立 BLE 链路协议(草案 v0.2)
# 面对面翻译 · 双耳独立 BLE 链路协议(v0.5)
> 状态:**待固件确认**。本文只定义协议,不含实现。
> 固件与 App 双方按本文对齐后再各自写代码;有异议的条目在 §14 逐条回复,改动记进 §16 变更记录。
> 状态:**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.2 相对 v0.1 的核心变化**:产品形态定为「两个人各戴一只耳机」,TWS 拆成两只独立的 BLE 外设,
> 各自一条链路连 App。v0.1 的「档位 A 主耳中转」与 `TOPO` 自适应字段整体删除。
> 另外吸收了 2026-09-20 ~ 09-23 通话翻译真机定性的一批结论(写类型、调速语义、看门狗、音频路径),见 §16。
## 速览:五步时序(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 秒,可以连续进出。
---
@ -21,14 +46,11 @@ A 说话 → A 那只耳机的麦上行 → 云端翻译 → 译文只下行到
- 两路上行**同时并发**、可独立起停(A 说话时 B 插话,不能排队);
- 两路下行**可独立寻址**(A 说的话只能进 B 的耳朵);
- 每路带**独立序号**,好判丢包、好做打断;
- 两只耳机之间**不需要同步**:各听各的译文,没有立体声对齐要求。这一点让拆 TWS 变得可行。
现有恒玄链路是**一条经典蓝牙**(Android 是 SPP/RFCOMM,iOS 是 GATT over BR/EDR,见 §1.1),
所有指令与音频挤在这一条上,上行靠 `mode=0x03` 交织、下行靠 `AA 56` 的 84 字节双槽区分方向。
它服务「通话翻译」够用(两路语义固定:本端 mic / 对端 spk),但**副耳根本没有到手机的链路**,
面对面的两路是两个人、两只耳机,在这条链路上做不出来。所以另起 BLE:进面对面时拆 TWS、两耳各自建链,
退出时拆链并重新组对,恢复原状。
所有指令与音频挤在这一条上,**副耳根本没有到手机的链路**,面对面的两路在这条链路上做不出来。
所以另起 BLE:`AA 19 06 02 01 01` 让耳机拆成左右两个 BLE 外设、各自建链,结束时 `AA 19 06 02 01 00` 切回经典蓝牙。
---
@ -36,36 +58,39 @@ A 说话 → A 那只耳机的麦上行 → 云端翻译 → 译文只下行到
| 不动 | 说明 |
|---|---|
| 现有经典蓝牙全部指令与音频格式 | `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) |
| 现有经典蓝牙全部指令与音频格式 | `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`),其它功能的发送、断连上报、下行一行不变 |
### 1.1 ⚠️ iOS 上现有链路不是 SPP
> ⚠️ `19 / 66 / 67 / 68 / 69` 这几个命令号在**本条固件线(Echo-one)**上是面对面专用;命令号是 (产品, 固件版本) 的函数,换产品要重新核对。
> `AA 19` 是链路切换命令。不带 value 的短帧回过 `BB 99 04 01`,那不是成功。现行格式见 §4。
Android 上现有链路是 SPP(RFCOMM);**iOS 上是 GATT over BR/EDR**(CoreBluetooth API,
但底层是经典蓝牙,靠 `registerForConnectionEvents` 接管系统已配对设备,详见 CLAUDE.md
「iOS 上恒玄走的是 GATT over BR/EDR,不是 BLE」)。这意味着:
### 1.1 经典链路:Android 是 SPP,iOS 是 GATT over BR/EDR
- 面对面期间主耳要**同时维护一条 BR/EDR 链路(到手机,控制面)和一条 LE 链路(本协议)**,
副耳只有 LE 链路。能否共存是 §14 Q1 的一部分;
- §6.2 的连接间隔 / PHY / DLE 这些是 LE 概念,**只对本协议的两条新链路成立**,
别拿去套现有链路。
两边面对面用的 BLE UUID 相同(§6),但**进面对面之前那条旧链路不是同一种东西**:
### 1.2 App 侧载体
| | 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。不要合成一个。
v0.1 提到的 `face_to_face_ble_service.dart` **已在 2026-09-09 设备内核重构中删除**,不要再找它。
新链路的落点按现行分层:
### 1.2 App 侧载体
| 层 | 落点 | 说明 |
|---|---|---|
| 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` 语义不变 |
| 时序编排 + 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、下行和计时。连着耳机却没起来:不开手机麦、不计时 |
**理由**:这条链路有实时截止期(每 40ms 一包、四路并发),按 9-21 的口径必须下沉原生,
Dart 只做没有截止期的状态机与展示(见 CLAUDE.md「音频链路继续下沉」)。
**理由**:这条链路有实时截止期(每 20ms 一包、四路并发),必须下沉原生;Dart 只做没有截止期的状态机与展示。
`AA 66` 要等两耳 BLE 都就绪才发,不适合跨一次 Dart 往返。
---
@ -73,311 +98,241 @@ Dart 只做没有截止期的状态机与展示(见 CLAUDE.md「音频链路
```
BLE (2 条,互相独立)
手机 ═══════════════► 左耳(EAR=1)
═══════════════► 右耳(EAR=2)
─ ─ ─ ─ ─ ─ ─ ─► 主耳(BR/EDR,现有链路,只走 AA 6B / 6C 控制)
手机 ═══════════════► 左耳(广播名 Echo-one-L)
═══════════════► 右耳(广播名 Echo-one-R)
─ ─ ─ ─ ─ ─ ─ ─► 主耳经典链路:只用来发 AA 19 06 02 01 01
Android 之后仍可能留着;iOS 在 BB 99 后主动关掉
```
- 进入面对面时固件**解除 TWS 主从**,左右耳各自广播、各自被 App 连接,各跑一条 GATT;
- 时空壶 WT2 Edge 走的就是这条路(其耳机不出现在系统蓝牙列表,全靠 App 内分别连 L/R);
- 两条链路彻底解耦,延迟最低;代价是**A2DP / HFP 期间必然中断**(见 §11.5 来电处理);
- **每条链路上只应出现自己耳位的 `EAR` 值**。帧里保留 `EAR` 字段是为了防御:
收到不属于本耳的包直接丢弃并上报 `ERR_EAR_MISMATCH`(属 App 侧 bug)。
- 收到 `AA 19 06 02 01 01` 后,耳机**解除 TWS**,左右耳各自起 BLE 广播、各自被 App 连接,各跑一条 GATT;
- 两条链路彻底解耦;代价是**A2DP / HFP 期间必然中断**(来电见 §11.5);
- **每条链路上只传本耳那一路**:左耳链路上的 `BB 68` 就是左耳麦,`AA 69` 写到左耳链路就是只给左耳。
帧里没有耳位字段,耳位由「走的是哪条链路」决定。
---
## 3. 完整时序
Android(`BB 99` 不挡扫描,经典 SPP 不主动关):
```
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> │ │ │
│◄─────────────────────────────────────│ │ │
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。恢复期内不能再进
```
**为什么入口放在经典链路而不是直接扫 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 模式
iOS(先等应答,再关掉经典 GATT,再等 1 秒):
```
AA 6B 06 <PROTO_VER> <CAP> <TOKEN 4B>
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 后按原路径回连
│ 面对面进行中,系统再次上报这台经典设备已连接 → 不接管
```
| 字段 | 长度 | 取值 |
|---|---|---|
| `PROTO_VER` | 1 | 本文档版本,`0x02` |
| `CAP` | 1 | App 支持的编码位图:bit0 G.722、bit1 mSBC、bit2 Opus(预留)。首版固定 `0x01` |
| `TOKEN` | 4 | App 生成的随机会话标识(`crypto` 级随机),每次进入重新生成,用于广播过滤 |
**原生里的对应**
(v0.1 的 `TOPO_REQ` 已删除:形态只有一种。)
| 步 | 触发 | 原生做法 |
|---|---|---|
| ① | 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.2 `BB EB` 响应
---
```
BB EB 07 <RESULT> <EAR_MASK> <PROTO_VER> <CODEC> <FRAME_MS> <FW_MINOR> <CLASSIC_KEEP>
```
## 4. 指令
| 字段 | 取值 |
|---|---|
| `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) |
帧格式沿用恒玄现有格式:耳机 → App 为 `BB <CMD> [LEN] [payload]`,`LEN` 为**整帧长度**(含 3/4 字节头);
`CC` 起头时长度 = `LEN + 256`(长帧,App 拆帧时兼容)。App → 耳机的控制命令是 `AA <CMD> [value]`。
### 4.3 `AA 6C` 退出
| 指令 | 方向 | 链路 | 格式 | 说明 |
|---|---|---|---|---|
| 切 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 …` | 用户在耳机上结束 |
```
AA 6C → BB EC 01 <RESULT>
```
- **兜底**:一只 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`(与经典链路同构的音频帧),按同一种拆帧解码处理。
⚠️ **必须幂等**:不在面对面模式时收到 `AA 6C` 也要回 `BB EC 00`,不能报错。
App 在异常恢复路径上会无条件补发一次(见 §11)。
### 4.1 `AA 19` 只使用这一种格式
---
与设置类命令同构(对照 EQ 的 `AA 0F 06 02 01 <value>`):
## 5. 阶段二 · BLE 广播与发现
| 含义 | 帧 | 字节 |
|---|---|---|
| 切到 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` |
### 5.1 广播内容(左右耳各自广播)
已经排除、**不要再发**的格式:
| 字段 | 内容 |
| 帧 | 真机结果 |
|---|---|
| Flags | LE General Discoverable + BR/EDR Not Supported |
| Local Name | `<产品名>-F2F`,左右耳**同名**(靠 MSD 区分,不靠名字) |
| Service UUID (16B) | 见 §6.1 |
| Manufacturer Specific Data | 见下 |
| `AA 19 01` / `AA 19 00`(3 字节,不带长度) | 回 `BB 99 04 01`,耳机不切 BLE |
| `AA 19 04 <value>` | 猜测格式,未采用,代码里已删除 |
**Manufacturer Specific Data(AD Type `0xFF`,共 11 字节)**
App 不再做格式试探,也不再把「上次成功的格式」存盘。
```
| CompanyID 2B (LE) | MAGIC 0xF2 0xF2 | VER 1B | EAR 1B | TOKEN 4B | BATT 1B |
```
`BB 99` 怎么用:
| 字段 | 说明 |
| 平台 | 行为 |
|---|---|
| `MAGIC` | 固定 `F2 F2`,快速筛掉无关广播 |
| `VER` | 协议版本,与 `AA 6B` 的 `PROTO_VER` 一致 |
| `EAR` | `0x01`=左耳、`0x02`=右耳 |
| `TOKEN` | **原样回显 `AA 6B` 里那 4 字节** |
| `BATT` | 该耳电量 0~100,低 7 位;最高位为充电位(与 `BB 0A` 同口径) |
### 5.2 App 侧扫描规则(强制)
| iOS | 写出 `AA 19` 后最多等 **3 秒**。收到 `BB 99`(status 记进日志,不要求必须是 `00` 才继续)→ 关经典 GATT → 等 1 秒 → 扫描。超时没有 `BB 99` → `failed(linkAck)`,**不扫描** |
| Android | 写出后立刻扫描。`BB 99` 只打日志,不挡扫描,也不据此关 SPP |
1. 只接受 `MAGIC` 命中的广播;
2. **`TOKEN` 必须与本次 `AA 6B` 下发的完全一致,否则一律忽略** ——
这是防止连到旁边同型号耳机的唯一手段,会场/展会场景必然会同时扫到多副;
3. **必须集齐 `EAR=0x01` 与 `0x02` 两个广播才开始连接**;15s 内只集到一个 → 按 §5.2 第 4 条回滚,
提示「请确认双耳都已取出并佩戴」。不做单耳降级:单耳面对面在产品上不成立;
4. 扫描超时 **15s**:超时后 App 发 `AA 6C` 回滚,并向用户报「耳机未就绪」;
5. **扫描必须在 App 前台完成**:iOS 后台扫描会截断广播数据,MSD 里的 TOKEN 拿不到,第 2 条就无从判定。
扫描期间切后台 → 视为超时回滚。
真机成功场次看到的都是 `BB 99 04 00`。iOS 不把非 `00` 当成失败,是为了避免应答形态轻微变化时整条链路停住;固件侧成功仍应回 `00`。
### 5.3 广播参数与耳机侧超时
---
- 建链期快速广播:间隔 30~50ms,持续 30s;
- **30s 内无人连接,该耳自动退出面对面模式**;两耳都退出后重新组对、恢复常态(第一道看门狗,见 §11);
- 连接建立后立即停止广播(各耳独立停)。
## 5. BLE 广播与发现
### 5.4 iOS 注意
### 5.1 广播内容
iOS 拿不到 BLE 设备的 MAC(CoreBluetooth 只给每台手机各不相同的 peripheral UUID),
所以**设备身份判定只能靠 TOKEN + EAR,不能靠地址**。这也是 §5.2 第 2 条必须强制的原因。
(同一问题在设备确权上已经踩过,见 CLAUDE.md「设备确权」一节。)
| 字段 | 当前固件 |
|---|---|
| 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(**待固件分配,下表为占位**)
## 6. GATT 服务与特征(两耳完全相同)
沿用现有 UUID 的 128-bit 风格,基址建议:`AEAF5F32-XXXX-4C4B-A100-000000000000`
### 6.1 UUID
| 项 | 占位 UUID | 属性 | 方向 |
| 项 | 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 → 耳机(音频) |
| 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`、应答) |
**控制与音频分开成 4 个特征,不合并**:音频是每 40ms 一包的高频流,
与控制帧共用一个特征时,控制消息(尤其是「打断」和 `STOP_STREAM`)会排在音频队列后面,
表现为「点了停止还在播」。9-21 在通话翻译上实测过同一个坑(控制命令排到积压音频后面 → 电量/版本全查不到),
分开后两条队列各自独立。
真机上两个特征按 properties 挑(有写属性的当写、有 notify 的当通知),不硬依赖编号;建链时把所有可 notify 特征都打开并打印整张 GATT 表(诊断用)。
控制与音频共用一个写特征:控制帧**插到写队列队首**直发,不排在音频后面;
结束时 `AA 19 00` 则排在 `AA 67` **之后**(先清空音频队列),保证「先退面对面、再切链路」。
### 6.2 链路参数要求(每条 LE 链路各自满足)
### 6.2 链路参数
| 项 | 要求 | 理由 |
| 项 | App 侧做法 | 理由 |
|---|---|---|
| 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 被杀后链路在此时间内必断 |
| ATT MTU | 请求 247(真机协商到 512) | 单路下行包 44B,上行聚合包更长 |
| 连接优先级 | `CONNECTION_PRIORITY_HIGH` | 连接间隔 > 40ms 会让下行断续 |
| 就绪判定 | GATT 连上 + 发现服务 + MTU + CCCD 开通知成功 | 即「握手」,见 §7 |
### 6.3 App 侧写入规则(强制,9-21 真机定性)
### 6.3 App 侧写入规则
1. `AUDIO_DOWN` 与 `CTRL_TX` **一律 Write Without Response**。带应答写每包要等 ATT 层 ACK 才能发下一包,
50 包/秒的实时流必然追不上;
2. iOS 的 `canSendWriteWithoutResponse` **只能作观测,不能作门槛**(拿它当闸门会死锁,见 CLAUDE.md「GATT 写入三条铁律」);
3. 控制命令**绝不排队**在音频后面,直发。
1. 下行音频一律 Write Without Response,每耳独立写队列,上限 8 包(≈160ms),满了**丢最旧的**;
2. 写回调 500ms 不回就当丢了,不让队列永远卡住;
3. 控制命令绝不排队在音频后面。
---
## 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`) |
## 7. BLE 握手与启动
(v0.1 的 `UP_ENABLE` 位图已删除:每条链路只有本耳一路,开关就是 `START/STOP_STREAM`。)
**握手 = GATT 连上 + 发现服务 + MTU + 开通知**,没有额外的应用层握手帧(v0.2 的 HELLO / SESSION_CFG / START_STREAM / KEEPALIVE / EXIT 作废)。
> ⚠️ `VAD_MODE` 默认 `0x00` 是刻意的:断句策略在 App/云端侧,固件 VAD 一旦判早就会把一句话切碎,
> 每段各翻一次,效果比多传点静音差得多。同传模式下已经踩过这个坑
> (见 CLAUDE.md「面对面 / 同声翻译:延迟构成」第 1 条)。省电需求确认后再开 `0x01`。
- 单耳握手完成:该耳状态 `ready`,**还不发 `AA 66`**;
- **两耳都握手完成**:同一时刻经 BLE 给两耳各发一次 `AA 66`,耳机开始推 `BB 68`;Dart 同时正式开始翻译;
- 只有一只握手完成、另一只超时:不发 `AA 66`,`failed(connectTimeout)` 后按 ⑤ 收尾(对已连上的那只经 BLE 发 `AA 67` + `AA 19 00`)。
---
## 8. 音频上行(耳机 → App)
```
BB 90 <LEN> <EAR|FLAGS 1B> <SEQ 2B LE> <PAYLOAD…>
BB 68 <LEN> <X> <N × 40B G.722>
```
| 字段 | 说明 |
|---|---|
| `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 跳变时补静音帧维持时基。
| `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 91 <LEN> <EAR|FLAGS 1B> <SEQ 2B LE> <PAYLOAD…>
AA 69 2C 01 <40B G.722> 共 44 字节 = 20ms 音频
```
| 字段 | 说明 |
|---|---|
| `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 56` 84 字节双槽不同:那是定长双槽包,空槽必须填静音帧;
BLE 是包交换,没数据就不发包,耳机侧靠 `SEQ` 与到达时间维持时基即可。
但**起播前先垫 4 包编码静音**再发真音频(9-23 定性:从空缓冲起播必出杂音),
一段结束后再续 500ms 静音才停发。
- **节奏调整:按包,不按速率。** 耳机用 `BB 88 <ADJ int8>` 请求:`+1`=缓冲低一包、请立即补一包;
`-1`=缓冲高一包、请扣一包;`0`=缓冲已空(App 视为起播,重新垫静音)。
⚠️ v0.1 写的「`interval_ms` 16/20/25/40 对齐 `0xD6/0xE9`」是对 `D6` 的误读——
9-23 真机定性 `D6` 的 `01` 只持续 20ms 就回 `02`,语义是「补一包」不是「改速率」。新协议直接按补/扣包定义,别再重现这个坑。
- App 发包**定时器必须固定节拍、永不重排**(iOS `DispatchSourceTimer` / Android `scheduleAtFixedRate`),
编码与写入耗时不能叠进周期(9-23 第 1 条)。
- 缓冲:耳机侧建议 60~120ms 抖动缓冲;溢出时**丢最旧的**并上报 `EVT_BUF_OVERFLOW`,不要阻塞。
- 路由:左耳上行 → AST leg A(源语 → 目标语)→ 译文**只写右耳链路**;右耳上行 → leg B → 译文只写左耳链路
(Dart 仍按 leg A/B 写,「播到对侧」由原生下行器的 swapLegs 完成);
- **两耳各自起停**:哪只耳机有译文才给哪只发包,另一只不陪发静音;
- 起播前先垫 **4 包编码静音**再发真音频(从空缓冲起播必出杂音);一段结束后续 **≈500ms** 静音才停发;
- 积压追赶直接复用 `CallTranslationDownlink`(裁静音 + WSOLA 变速);发包定时器固定 20ms 节拍、永不重排;
- 某只耳机掉线重连中(§11.2)时**两耳下行都停**,恢复后自然续上;
- 原生按 cmd=`0x69` 识别面对面下行(包头与通话翻译 `AA 56` 同为 `AA`)。
---
## 10. 事件上报
```
BB 87 <LEN> <EVENT 1B> <PARAM…>
```
固件目前**没有**按键 / 佩戴 / 电量 / 异常事件帧(v0.2 的 `BB 87` 作废)。
只有 `BB 66` / `BB 67`(§4)表示用户在耳机上开始 / 结束。
| `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 |
日志(tag `BesF2f` / `BesF2fBle`):
- SPP 上 `BB E6/E7/99` 单独打印;会话中与恢复期内其它未识别命令**不限量**打印(对照应答码);
- BLE 上 `BB E6/E7/99` 单独打印,其它未识别帧前 20 条打印。
App 内部把原生链路状态以 `f2fLink` 事件交给 Dart(§12),供 UI 与状态机用。
---
@ -385,114 +340,122 @@ BB 87 <LEN> <EVENT 1B> <PARAM…>
功能结束后**必须完整回到进入前的状态**。这一节的每一条都是验收项。
### 11.1 看门狗
### 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 |
扫描超时 / 只扫到一只 / 连接超时 / 重试用尽 / 蓝牙关闭 / 经典链路没连着(`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 建链落在这个上限里面。
> **第 2 层是本协议里最重要的一条,且刻意不靠 keepalive。** App 被系统杀掉、崩溃、用户强退时,
> 没有任何机会发退出指令;但 LE 连接本身会在 supervision timeout 内必然断开,固件收到断连事件即可恢复。
> v0.1 的「5s 没收到 keepalive 就断链」删掉了:keepalive 若由 App 定时器发,iOS 锁屏后定时器一停就会被耳机踢出,
> 把一个正常场景做成了故障;而链路断开这个信号不依赖任何定时器。
> 缺了第 2 层耳机会永远卡在面对面模式,用户表现为「耳机连着但放不了音乐、也接不了电话」,且看不出原因。
> (同类教训见 CLAUDE.md 里 BES OTA 的 90s 看门狗一节。)
### 11.2 单耳失联
### 11.2 固件的恢复清单
会话中某耳 GATT 断开:该耳状态 `reconnecting`,提示「左/右耳已断开,正在重连…」;
**两耳下行都停**;**10s** 内重扫同地址并重连(两次连接间隔 ≥ 1s),重连就绪后**单独给它补发 `AA 66`**,续上;
超时 `failed(earLost)` → 提示「耳机连接已断开,面对面翻译已结束」并完整退出。
结束流程(⑤)中耳机收到 `AA 19 00` 自己断 LE,**不算掉线**、不重连。
收到 `AA 06 EXIT` / `AA 6C`、或任一看门狗触发时,**逐项恢复**:
### 11.3 正常结束与恢复期
1. 停止 LE 广播,断开本协议的 GATT 连接;
2. **两耳重新组对,恢复 TWS 主从与耳间同步**;
3. 恢复 A2DP / HFP —— 音乐能播、来电能接;
4. 恢复按键功能映射到用户原设置(面对面期间若临时改过键位);
5. 恢复 ANC / 通透模式到进入前的档位;
6. 恢复麦克风通路与增益;
7. 经典链路回 `BB EC 00`(看门狗触发时为主动上报);`CLASSIC_KEEP=0` 的固件在重新组对后**主动回连**手机的经典链路。
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.3 App 的恢复动作
### 11.4 异常退出
- `CLASSIC_KEEP=1` 时经典链路全程未断,`BesBluetoothService` 状态不受影响;
`=0` 时等固件回连,期间设备页显示「恢复中」,不当成掉线报错;
- 恢复完成前不允许再次进入面对面(防止半状态叠加);
- UI 上「结束」按钮必须**先返回、后台跑收尾**,不要 await 整条退出链
(录音页踩过这个坑,见 CLAUDE.md「录音页退不出去」)。
进入时落盘 `exitPending=true`;`AA 19 06 02 01 00` 确认发出(任一 BLE 写出,或无 BLE 时经经典链路发出)才清掉。
App 被杀 / 崩溃 / 两条链路都不通时没清掉 → 下次经典链路连上只清标记、打日志,**不补发**
(经典链路能连上说明耳机已经回到原来的通道)。
### 11.4 互斥
> 固件侧还应有看门狗:LE 连接断开(supervision timeout)后该耳自动切回经典蓝牙并等待对耳重新组对。
> App 被杀时没有任何机会发 `AA 67` / `AA 19 00`,只有链路断开这个信号必然发生(§14 Q4)。
面对面 BLE 模式与下列功能**同一时刻只能有一个**,冲突时 `AA 6B` 回 `ERR_BUSY`:
通话翻译(`AA 51`)、AI 对话(`0x64`)、主麦录音(`AA 59`)、媒体录音(`AA 5E`)、OTA、旧版面对面(`AA 66`)。
App 侧同样在 `DeviceConnection.isBlockedByCall` 这一层拦,双保险。
### 11.5 互斥
### 11.5 单耳失联与来电
面对面与下列功能**同一时刻只能有一个**:
- **单耳 LE 断开**(走远、没电):固件该耳退出并等待对耳;App 侧另一耳仍连着,
**停掉该耳的上行与对耳的下行**(一方听不到了,继续翻译没有意义),提示「左/右耳已断开」,
10s 内重扫同 token 尝试回连,超时走完整退出。
- **来电**:TWS 已拆、HFP 已停,来电无法在耳机上接。固件收到系统来电 → 上报 `EVT 0x06 REASON=0x03` →
App 立即走退出流程并提示「有来电,面对面翻译已结束」;固件恢复 HFP 后由系统接管来电。
⚠️ 这一步的时序(振铃到 HFP 恢复要多久)是 §14 Q2 的实测项,决定用户会不会漏接。
| 场景 | App 侧做法 |
|---|---|
| 进入前:通话中 | 不进,提示「通话中,无法使用面对面翻译」 |
| 进入前:AI 对话 / 主麦录音 / 通话录音进行中 | 不进,提示「请先结束耳机上正在进行的功能」 |
| 进入前:恢复期 | 不进,提示「耳机恢复中」 |
| 会话中:App 要发 `AA 51`(通话翻译)/ `AA 59`(录音)/ `AA 6A`(AI 应答) | 原生拦下不发 |
| 会话中:耳机上报 `BB 64`(AI 唤醒)/ `BB D9`(耳机录音) | 原生忽略 |
| 会话中:来电(`inCall`) | 结束面对面,提示「有来电,面对面翻译已结束」 |
---
### 11.6 手机麦
## 12. 错误码(`RESULT` / `CODE` 共用一张表)
- **Android**:没有就绪的 Echo-one 会话 → 不启动,提示「未连接 Echo-one 耳机,无法使用面对面翻译」。没有手机麦这条退路;
- **iOS**:没有就绪的 Echo-one 会话 → 退回手机麦,并提示将使用手机麦克风收音;
- **两端只要连着耳机**:发出 `AA 66` 后等两耳 BLE 都就绪才开下行、起 AST、接桥、开始计时。连不齐按 §11.1 收尾,**不开手机麦、不计时**;
- 等待中再点一次开始 = 取消。
| 值 | 名称 | 含义 | 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` | 拆主从失败 / 对耳不应答 | 回滚并提示「请把双耳放回盒中再取出重试」 |
### 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`。
---
## 13. 带宽与延迟预算
## 12. App 内链路状态(`f2fLink` 事件)
**带宽**(G.722 @16kHz = 16 kbps/路,**每条链路各承担一上一下**)
原生经音频事件流上报 `{type: f2fLink, side, state, reason?, ok?}`,Dart 收为 `DeviceFeatureKeys.f2fLink`。
| 每条 LE 链路 | 小计 |
|---|---|
| 上行 1 路 | 16 kbps |
| 下行 1 路 | 16 kbps |
| 控制 + keepalive | < 1 kbps |
| **单链路合计** | **≈ 33 kbps** |
**单耳**(`side` = `L` / `R`)
40ms 聚合时每包 86 字节,单链路每 40ms 2 包 ≈ 4.3 KB/s。
BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps,**余量充足**;开 2M PHY + DLE 后更宽松。
| state | 含义 | 页面标签 |
|---|---|---|
| `scanning` / `connecting` | 扫描 / 连接中 | 橙点 |
| `ready` | 已握手,还没收到上行 | 蓝点 |
| `streaming` | 正在收音 | 绿点 |
| `reconnecting` | 会话中掉线重连 | 红点 |
| `restoring` | 会话结束,恢复期 | 琥珀点 +「恢复中」 |
| `idle` | 不在会话 | 无 |
> ⚠️ 手机侧是**两条 LE 连接并发**,再加主耳可能还挂着一条 BR/EDR,三条链路共用手机一根天线的时隙。
> 单链路余量充足不等于三链路共存余量充足,§14 Q4 要固件给**共存下**的实测数,不是单链路理论值。
>
> 对照:若直接传 16k/16bit 裸 PCM,单路即 256 kbps —— BLE 上不可行。
> **所以下行/上行都必须是编码后码流,不能传裸 PCM。**
**整体**(`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 | 含连接间隔与重传 |
| 云端翻译 | **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`)可直接复用。
| 段 | 预估 |
|---|---|
| 耳机采集 + 编码 | 20~40ms |
| BLE 上行 | 30~60ms |
| 云端翻译(端到端 AST) | ≈ 800ms |
| BLE 下行 + 垫底 | 30~80ms |
| 解码 + 抖动缓冲 + 播放 | 60~120ms |
| **合计** | **≈ 1.0s** |
蓝牙链路只占 100~250ms,优化重心在云端。面对面两只耳机恰好与 AST 的 leg A / leg B 同构,
通话翻译那套原生直连桥直接复用。
---
@ -502,22 +465,17 @@ BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps
| # | 问题 | 影响 |
|---|---|---|
| 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「上行时间戳是否对齐」删除——两人各听各的,不需要对齐。)
| 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 扫描 |
---
@ -525,23 +483,21 @@ BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps
| # | 场景 | 期望 |
|---|---|---|
| T01 | 正常进入 → 拆主从 → 双耳广播 → 分别连接 → 握手 → 上下行通 | 全流程 < 5s,双耳都能收发 |
| 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 | 打断: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 秒不换气的长句 | 下行积压由追赶器(裁静音 + 变速)消化,无杂音、无整段丢弃 |
| 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 秒不换气的长句 | 下行积压由追赶器消化,无杂音、无整段丢弃 |
---
@ -550,4 +506,12 @@ BLE 1M PHY 在 30ms 连接间隔下的实测可用吞吐通常在 100~300 kbps
| 版本 | 日期 | 变更 |
|---|---|---|
| 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 |
| 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 时退回手机麦 |

2
apps/client/ios/Podfile.lock

@ -110,6 +110,6 @@ SPEC CHECKSUMS:
WechatOpenSDK-XCFramework: 5df9b250e9839dcc306ad8b00f46822eead1ed47
wifi_iot: b5aafd6f9b52f8a357383a1deabab45f31cd602d
PODFILE CHECKSUM: 401345823f88247f8534d70ffc8240014a8b14fb
PODFILE CHECKSUM: 615a00a0854e6c6a2c6db75ecb9a29206ab3e341
COCOAPODS: 1.16.2

16
apps/client/ios/Runner.xcodeproj/project.pbxproj

@ -20,6 +20,7 @@
97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; };
99D9F83B2EDAE83B002D852E /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 99D9F83A2EDAE83B002D852E /* StoreKit.framework */; };
9B1423ECCD573E1777AAE79D /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4D34AABE0D2A447A1914C74B /* Pods_Runner.framework */; };
A1F2B3C51ED2DC5600515811 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1F2B3C41ED2DC5600515811 /* SceneDelegate.swift */; };
BE00ED3053C04BB0DAD6667F /* BroadcastExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = 529AACAD0ACFFB427B535F31 /* BroadcastExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
E977CA22F136C6274B5E8045 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 4272682CC8ADD6E3BBDEF14E /* Pods_RunnerTests.framework */; };
EA1A0002EA1A0002EA1A0002 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = EA1A0001EA1A0001EA1A0001 /* GoogleService-Info.plist */; };
@ -94,6 +95,7 @@
97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
99D9F83A2EDAE83B002D852E /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; };
A1F2B3C41ED2DC5600515811 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = "<group>"; };
B132B19743B64127CBB7007D /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
B2BDA6B1A54D93FE6A784DF3 /* BroadcastAudioSocket.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BroadcastAudioSocket.swift; sourceTree = "<group>"; };
BEEFDEAD0F00BA5E00112233 /* Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Profile.xcconfig; path = Flutter/Profile.xcconfig; sourceTree = "<group>"; };
@ -209,6 +211,7 @@
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */,
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */,
74858FAE1ED2DC5600515810 /* AppDelegate.swift */,
A1F2B3C41ED2DC5600515811 /* SceneDelegate.swift */,
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */,
F2CF3D526E530840CC7E91E8 /* Runner.entitlements */,
EA1A0003EA1A0003EA1A0003 /* RunnerProfile.entitlements */,
@ -523,6 +526,7 @@
buildActionMask = 2147483647;
files = (
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
A1F2B3C51ED2DC5600515811 /* SceneDelegate.swift in Sources */,
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
@ -649,7 +653,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -658,7 +662,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.2;
MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -843,7 +847,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -852,7 +856,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.2;
MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";
@ -877,7 +881,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 26;
CURRENT_PROJECT_VERSION = 44;
DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist;
@ -886,7 +890,7 @@
"$(inherited)",
"@executable_path/Frameworks",
);
MARKETING_VERSION = 1.0.2;
MARKETING_VERSION = 1.0.3;
PRODUCT_BUNDLE_IDENTIFIER = com.yimai.eaimar;
PRODUCT_NAME = "$(TARGET_NAME)";
PROVISIONING_PROFILE_SPECIFIER = "";

8
apps/client/ios/Runner/AppDelegate.swift

@ -3,20 +3,24 @@ import MachO
import UIKit
@main
@objc class AppDelegate: FlutterAppDelegate {
@objc class AppDelegate: FlutterAppDelegate, FlutterImplicitEngineDelegate {
override func application(
_ application: UIApplication,
didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?
) -> Bool {
application.applicationIconBadgeNumber = 0
NativeCrashRecorder.install()
GeneratedPluginRegistrant.register(with: self)
// 第三方 SDK(高德/杰理等)初始化时可能装自己的信号处理器把我们顶掉,
// 过一会儿再装一次;装的时候会把当时在位的处理器记下来链式调用,两边都不丢。
DispatchQueue.main.asyncAfter(deadline: .now() + 15) { NativeCrashRecorder.install() }
return super.application(application, didFinishLaunchingWithOptions: launchOptions)
}
/// 插件必须挂到场景创建出来的引擎上。留在 didFinishLaunching 里注册会挂到还没建好的引擎。
func didInitializeImplicitFlutterEngine(_ engineBridge: FlutterImplicitEngineBridge) {
GeneratedPluginRegistrant.register(with: engineBridge.pluginRegistry)
}
override func applicationDidBecomeActive(_ application: UIApplication) {
application.applicationIconBadgeNumber = 0
super.applicationDidBecomeActive(application)

21
apps/client/ios/Runner/Info.plist

@ -148,6 +148,27 @@
<string>Photo library permission is required to save recording files and import audio files</string>
<key>NSRemindersUsageDescription</key>
<string>This app needs access to reminders to set calendar reminders</string>
<key>UIApplicationSceneManifest</key>
<dict>
<key>UIApplicationSupportsMultipleScenes</key>
<false/>
<key>UISceneConfigurations</key>
<dict>
<key>UIWindowSceneSessionRoleApplication</key>
<array>
<dict>
<key>UISceneClassName</key>
<string>UIWindowScene</string>
<key>UISceneConfigurationName</key>
<string>flutter</string>
<key>UISceneDelegateClassName</key>
<string>$(PRODUCT_MODULE_NAME).SceneDelegate</string>
<key>UISceneStoryboardFile</key>
<string>Main</string>
</dict>
</array>
</dict>
</dict>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>UIBackgroundModes</key>

15
apps/client/ios/Runner/RunnerProfile.entitlements

@ -1,5 +1,18 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict/>
<dict>
<key>com.apple.developer.applesignin</key>
<array>
<string>Default</string>
</array>
<key>com.apple.developer.associated-domains</key>
<array>
<string>applinks:yimai.games</string>
</array>
<key>com.apple.developer.networking.wifi-info</key>
<true/>
<key>com.apple.external-accessory.wireless-configuration</key>
<true/>
</dict>
</plist>

6
apps/client/ios/Runner/SceneDelegate.swift

@ -0,0 +1,6 @@
import Flutter
import UIKit
/// iOS 26 起没有 UIScene 的 App 会在创建窗口时被系统直接掐掉
/// (`UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption`)。
class SceneDelegate: FlutterSceneDelegate {}

16
apps/client/lib/core/translations/language/ar_sa.dart

@ -1761,6 +1761,22 @@ const Map<String, String> arSA = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'جاهز',

16
apps/client/lib/core/translations/language/bg_bg.dart

@ -1823,6 +1823,22 @@ const Map<String, String> bgBG = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Готово',

16
apps/client/lib/core/translations/language/cs_cz.dart

@ -1792,6 +1792,22 @@ const Map<String, String> csCZ = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Připraveno',

16
apps/client/lib/core/translations/language/da_dk.dart

@ -1793,6 +1793,22 @@ const Map<String, String> daDK = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Klar',

16
apps/client/lib/core/translations/language/de_de.dart

@ -2037,6 +2037,22 @@ const Map<String, String> deDE = {
'recordingSavedDesc': 'Im Sprachprotokoll gespeichert',
'faceToFaceLeftEar': 'Links',
'faceToFaceRightEar': 'Rechts',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Bereit',

16
apps/client/lib/core/translations/language/el_gr.dart

@ -1827,6 +1827,22 @@ const Map<String, String> elGR = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Έτοιμο',

16
apps/client/lib/core/translations/language/en_us.dart

@ -2005,6 +2005,22 @@ const Map<String, String> enUS = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Ready',

16
apps/client/lib/core/translations/language/es_es.dart

@ -2031,6 +2031,22 @@ const Map<String, String> esES = {
'recordingSavedDesc': 'Guardado en actas de voz',
'faceToFaceLeftEar': 'Izquierdo',
'faceToFaceRightEar': 'Derecho',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Listo',

16
apps/client/lib/core/translations/language/et_ee.dart

@ -1788,6 +1788,22 @@ const Map<String, String> etEE = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Valmis',

16
apps/client/lib/core/translations/language/fa_ir.dart

@ -1778,6 +1778,22 @@ const Map<String, String> faIR = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'آماده',

16
apps/client/lib/core/translations/language/fi_fi.dart

@ -1798,6 +1798,22 @@ const Map<String, String> fiFI = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Valmis',

16
apps/client/lib/core/translations/language/fr_fr.dart

@ -2049,6 +2049,22 @@ const Map<String, String> frFR = {
'recordingSavedDesc': 'Enregistré dans les comptes rendus vocaux',
'faceToFaceLeftEar': 'Gauche',
'faceToFaceRightEar': 'Droite',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Prêt',

16
apps/client/lib/core/translations/language/hi_in.dart

@ -1797,6 +1797,22 @@ const Map<String, String> hiIN = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'तैयार',

16
apps/client/lib/core/translations/language/hr_hr.dart

@ -1813,6 +1813,22 @@ const Map<String, String> hrHR = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Spremno',

16
apps/client/lib/core/translations/language/hu_hu.dart

@ -1815,6 +1815,22 @@ const Map<String, String> huHU = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Kész',

16
apps/client/lib/core/translations/language/id_id.dart

@ -1797,6 +1797,22 @@ const Map<String, String> idID = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Siap',

16
apps/client/lib/core/translations/language/is_is.dart

@ -1794,6 +1794,22 @@ const Map<String, String> isIS = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Tilbúið',

16
apps/client/lib/core/translations/language/it_it.dart

@ -1835,6 +1835,22 @@ const Map<String, String> itIT = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Pronto',

16
apps/client/lib/core/translations/language/ja_jp.dart

@ -1834,6 +1834,22 @@ const Map<String, String> jaJP = {
'recordingSavedDesc': '音声議事録に保存しました',
'faceToFaceLeftEar': '左耳',
'faceToFaceRightEar': '右耳',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': '準備完了',

16
apps/client/lib/core/translations/language/ko_kr.dart

@ -1827,6 +1827,22 @@ const Map<String, String> koKR = {
'recordingSavedDesc': '음성 회의록에 저장되었습니다',
'faceToFaceLeftEar': '왼쪽',
'faceToFaceRightEar': '오른쪽',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': '준비됨',

16
apps/client/lib/core/translations/language/lt_lt.dart

@ -1811,6 +1811,22 @@ const Map<String, String> ltLT = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Paruošta',

16
apps/client/lib/core/translations/language/lv_lv.dart

@ -1816,6 +1816,22 @@ const Map<String, String> lvLV = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Gatavs',

16
apps/client/lib/core/translations/language/ms_my.dart

@ -1801,6 +1801,22 @@ const Map<String, String> msMY = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Sedia',

16
apps/client/lib/core/translations/language/nb_no.dart

@ -1795,6 +1795,22 @@ const Map<String, String> nbNO = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Klar',

16
apps/client/lib/core/translations/language/nl_nl.dart

@ -1692,6 +1692,22 @@ const Map<String, String> nlNL = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Gereed',

16
apps/client/lib/core/translations/language/pl_pl.dart

@ -1822,6 +1822,22 @@ const Map<String, String> plPL = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Gotowe',

16
apps/client/lib/core/translations/language/pt_pt.dart

@ -2036,6 +2036,22 @@ const Map<String, String> ptPT = {
'recordingSavedDesc': 'Guardado nas atas de voz',
'faceToFaceLeftEar': 'Esquerdo',
'faceToFaceRightEar': 'Direito',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Pronto',

16
apps/client/lib/core/translations/language/ro_ro.dart

@ -1824,6 +1824,22 @@ const Map<String, String> roRO = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Gata',

16
apps/client/lib/core/translations/language/ru_ru.dart

@ -2012,6 +2012,22 @@ const Map<String, String> ruRU = {
'recordingSavedDesc': 'Сохранено в голосовом протоколе',
'faceToFaceLeftEar': 'Левый',
'faceToFaceRightEar': 'Правый',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Готово',

16
apps/client/lib/core/translations/language/sk_sk.dart

@ -1805,6 +1805,22 @@ const Map<String, String> skSK = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Pripravené',

16
apps/client/lib/core/translations/language/sl_si.dart

@ -1808,6 +1808,22 @@ const Map<String, String> slSI = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Pripravljeno',

16
apps/client/lib/core/translations/language/sr_rs.dart

@ -1804,6 +1804,22 @@ const Map<String, String> srRS = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Спремно',

16
apps/client/lib/core/translations/language/sv_se.dart

@ -1800,6 +1800,22 @@ const Map<String, String> svSE = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Redo',

16
apps/client/lib/core/translations/language/th_th.dart

@ -1755,6 +1755,22 @@ const Map<String, String> thTH = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'พร้อมแล้ว',

16
apps/client/lib/core/translations/language/tl_ph.dart

@ -1838,6 +1838,22 @@ const Map<String, String> tlPH = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Handa na',

16
apps/client/lib/core/translations/language/tr_tr.dart

@ -1793,6 +1793,22 @@ const Map<String, String> trTR = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Hazır',

16
apps/client/lib/core/translations/language/uk_ua.dart

@ -1811,6 +1811,22 @@ const Map<String, String> ukUA = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Готово',

16
apps/client/lib/core/translations/language/vi_vn.dart

@ -1774,6 +1774,22 @@ const Map<String, String> viVN = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'faceToFaceEarNotReady': 'Earbuds BLE not connected. Make sure both earbuds are out of the case and worn, then try again.',
'faceToFaceEarDisconnected': 'Earbud disconnected. Face-to-face translation ended.',
'faceToFaceNoEarbuds': 'No Echo-one earbuds connected. Face-to-face translation is unavailable.',
'faceToFaceBleNotConnected': 'Earbuds BLE not connected. Face-to-face translation is unavailable.',
'faceToFaceEarReconnecting': '@ear earbud disconnected. Reconnecting…',
'faceToFaceRestoring': 'Earbuds are restoring. Please try again shortly.',
'faceToFaceRestoringShort': 'Restoring',
'faceToFaceEarConnectingShort': 'Connecting',
'faceToFaceEarReadyShort': 'Connected',
'faceToFaceEarStreamingShort': 'Listening',
'faceToFaceEarLostShort': 'Disconnected',
'faceToFaceBusy': 'Please end the current earbud activity (AI chat / recording) first.',
'faceToFaceBusyCall': 'In a call. Face-to-face translation is unavailable.',
'faceToFaceEndedByCall': 'Incoming call. Face-to-face translation ended.',
'faceToFaceBackground': 'Keep the app in the foreground while connecting earbuds.',
'faceToFacePhoneMic': 'No earbuds connected. Using the phone microphone.',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Sẵn sàng',

16
apps/client/lib/core/translations/language/zh_cn.dart

@ -1820,6 +1820,22 @@ const Map<String, String> zhCN = {
'recordingSavedDesc': '已存入语音纪要',
'faceToFaceLeftEar': '左耳',
'faceToFaceRightEar': '右耳',
'faceToFaceEarNotReady': '耳机 BLE 未连接,请确认双耳都已取出并佩戴后重试',
'faceToFaceEarDisconnected': '耳机连接已断开,面对面翻译已结束',
'faceToFaceNoEarbuds': '未连接 Echo-one 耳机,无法使用面对面翻译',
'faceToFaceBleNotConnected': '耳机 BLE 未连接,无法使用面对面翻译',
'faceToFaceEarReconnecting': '@ear已断开,正在重连…',
'faceToFaceRestoring': '耳机恢复中,请稍后再试',
'faceToFaceRestoringShort': '恢复中',
'faceToFaceEarConnectingShort': '连接中',
'faceToFaceEarReadyShort': '已连接',
'faceToFaceEarStreamingShort': '收音中',
'faceToFaceEarLostShort': '已断开',
'faceToFaceBusy': '请先结束耳机上正在进行的功能(AI 对话 / 录音)',
'faceToFaceBusyCall': '通话中,无法使用面对面翻译',
'faceToFaceEndedByCall': '有来电,面对面翻译已结束',
'faceToFaceBackground': '连接耳机时请保持 App 在前台',
'faceToFacePhoneMic': '未连接 Echo-one 耳机,将使用手机麦克风收音',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': '已就绪',

16
apps/client/lib/core/translations/language/zh_hk.dart

@ -1804,6 +1804,22 @@ const Map<String, String> zhHK = {
'recordingSavedDesc': '已存入語音紀要',
'faceToFaceLeftEar': '左耳',
'faceToFaceRightEar': '右耳',
'faceToFaceEarNotReady': '耳機 BLE 未連接,請確認雙耳都已取出並佩戴後重試',
'faceToFaceEarDisconnected': '耳機連線已中斷,面對面翻譯已結束',
'faceToFaceNoEarbuds': '未連接 Echo-one 耳機,無法使用面對面翻譯',
'faceToFaceBleNotConnected': '耳機 BLE 未連接,無法使用面對面翻譯',
'faceToFaceEarReconnecting': '@ear已中斷,正在重新連線…',
'faceToFaceRestoring': '耳機恢復中,請稍後再試',
'faceToFaceRestoringShort': '恢復中',
'faceToFaceEarConnectingShort': '連線中',
'faceToFaceEarReadyShort': '已連線',
'faceToFaceEarStreamingShort': '收音中',
'faceToFaceEarLostShort': '已中斷',
'faceToFaceBusy': '請先結束耳機上正在進行的功能(AI 對話 / 錄音)',
'faceToFaceBusyCall': '通話中,無法使用面對面翻譯',
'faceToFaceEndedByCall': '有來電,面對面翻譯已結束',
'faceToFaceBackground': '連接耳機時請保持 App 在前景',
'faceToFacePhoneMic': '未連接 Echo-one 耳機,將使用手機麥克風收音',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': '已就緒',

16
apps/client/lib/core/translations/language/zh_tw.dart

@ -1804,6 +1804,22 @@ const Map<String, String> zhTW = {
'recordingSavedDesc': '已存入語音紀要',
'faceToFaceLeftEar': '左耳',
'faceToFaceRightEar': '右耳',
'faceToFaceEarNotReady': '耳機 BLE 未連接,請確認雙耳都已取出並佩戴後重試',
'faceToFaceEarDisconnected': '耳機連線已中斷,面對面翻譯已結束',
'faceToFaceNoEarbuds': '未連接 Echo-one 耳機,無法使用面對面翻譯',
'faceToFaceBleNotConnected': '耳機 BLE 未連接,無法使用面對面翻譯',
'faceToFaceEarReconnecting': '@ear已中斷,正在重新連線…',
'faceToFaceRestoring': '耳機恢復中,請稍後再試',
'faceToFaceRestoringShort': '恢復中',
'faceToFaceEarConnectingShort': '連線中',
'faceToFaceEarReadyShort': '已連線',
'faceToFaceEarStreamingShort': '收音中',
'faceToFaceEarLostShort': '已中斷',
'faceToFaceBusy': '請先結束耳機上正在進行的功能(AI 對話 / 錄音)',
'faceToFaceBusyCall': '通話中,無法使用面對面翻譯',
'faceToFaceEndedByCall': '有來電,面對面翻譯已結束',
'faceToFaceBackground': '連接耳機時請保持 App 在前景',
'faceToFacePhoneMic': '未連接 Echo-one 耳機,將使用手機麥克風收音',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': '已就緒',

23
apps/client/lib/devices/bes/bes_bluetooth_service.dart

@ -376,7 +376,7 @@ class BesBluetoothService extends GetxService {
/// | `AA 0B` | `BB 8B 04 00` | 未知,只回一个 0 |
/// | `AA 0D` | `BB 8D 07 00 02 01 02` | 未知 |
/// | `AA 0F` | `BB 8F 04 00` | 未知 |
/// | `AA 19` | `BB 99 04 01` | 未知,回 1 |
/// | `AA 19` | `BB 99 04 01` | 链路切换(带 value:01 = BLE、00 = 经典蓝牙,面对面用);不带 value 时回 1 |
///
/// `BB 18` 是耳机周期主动上报的另一种状态帧(`02 01 01 02 02 00`,值 0/1),
/// **不是电量**,`AA 18` 也查不出东西。
@ -718,8 +718,15 @@ class BesBluetoothService extends GetxService {
.where((d) => d.isNotEmpty);
/// 启动/停止通话翻译的下行 TTS 通道(`AA 56` 84 字节包,
/// G.722 编码与 20ms 节流都在原生做,Dart 只丢 PCM)
Future<void> startTranslationDownlink() => _manager.startCallDownlink();
/// G.722 编码与 20ms 节流都在原生做,Dart 只丢 PCM)。
///
/// [f2f] = Echo-one 面对面:包头换成 `AA 69`,并且 leg A(A 耳说的话的译文)
/// 要播到 **B 耳**。上行 A 耳 = micData 槽、B 耳 = spkData 槽,下行沿用同一套槽位
/// 就得交叉放(swapLegs)。槽位对不对看原生 `BesCallDownlink` 日志。
Future<void> startTranslationDownlink({bool f2f = false}) => f2f
? _manager.startCallDownlink(
header: BesCmd.sof, cmd: BesF2f.downlink, swapLegs: true)
: _manager.startCallDownlink();
Future<void> stopTranslationDownlink() => _manager.stopCallDownlink();
@ -820,12 +827,12 @@ class BesBluetoothService extends GetxService {
/// 通知耳机「ASR 已关闭」(`AA 58`)
Future<void> notifyAsrStopped() => sendData(BesCmd.frame(BesCmd.asrStopped));
/// 开始面对面翻译(`AA 66`)。耳机就绪后回 `0xE6`;
/// 耳机按键触发时也会主动上报 `0xE6`。
Future<void> startFaceToFace() => sendData(BesCmd.frame(BesCmd.faceToFaceStart));
/// 开始 Echo-one 面对面翻译(`AA 66`)。Android 原生拦下后先经 SPP 发 `AA 19 01` 切 BLE、
/// 连好左右耳再经 BLE 发 `AA 66`。耳机按键发起时会主动上报 `BB 66`。
Future<void> startFaceToFace() => sendData(BesF2f.command(BesF2f.start));
/// 结束面对面翻译(`AA 67`),设备回 `0xE7`
Future<void> stopFaceToFace() => sendData(BesCmd.frame(BesCmd.faceToFaceStop));
/// 结束 Echo-one 面对面翻译(`AA 67`)。原生经 BLE 发 `AA 67`,再发 `AA 19 00` 切回经典蓝牙
Future<void> stopFaceToFace() => sendData(BesF2f.command(BesF2f.stop));
/// 开始设备端主麦录音(`AA 59`)。设备就绪后会回 `0xD9`,
/// 音频以 mSBC 帧上行,由原生解码后走 [audioStream]。

72
apps/client/lib/devices/bes/bes_device_plugin.dart

@ -252,6 +252,7 @@ class BesDeviceSession extends DeviceSession {
StreamController<DeviceWakeEvent>.broadcast();
final List<Worker> _workers = [];
StreamSubscription? _aiSub;
StreamSubscription? _f2fSub;
DeviceConnectionState _state = DeviceConnectionState.ready;
/// 通话翻译下行的引用计数:A/B 两路都关了才 stopCallDownlink
@ -308,6 +309,34 @@ class BesDeviceSession extends DeviceSession {
break;
}
}, onError: (e) => Logger.e(_tag, 'AI 事件流错误: $e'));
// Echo-one 面对面:耳机发 BB 66 / BB 67(主动发起,或对 App 下发的回应),
// 以及原生 BLE 双耳链路的左右耳状态(f2fLink)。
// 原生把它们发在音频事件流上,那条流里大部分是 PCM 帧,先按 type 滤掉。
_f2fSub = _link.audioEventStream
.where((e) =>
e['type'] == 'startFaceToFace' ||
e['type'] == 'stopFaceToFace' ||
e['type'] == 'f2fLog' ||
e['type'] == 'f2fLink')
.listen((e) {
if (e['type'] == 'f2fLog') {
Logger.w(_tag, '[F2F-BLE] ${e['msg']}');
return;
}
if (e['type'] == 'f2fLink') {
_feature(DeviceFeatureKeys.f2fLink, {
'side': e['side'] as String? ?? '',
'state': e['state'] as String? ?? '',
'reason': e['reason'] as String?,
'ok': e['ok'] as bool?,
});
return;
}
final started = e['type'] == 'startFaceToFace';
Logger.w(_tag, '[F2F] 耳机上报 ${started ? "启动 BB 66" : "结束 BB 67"}');
_feature(started ? DeviceFeatureKeys.f2fStarted : DeviceFeatureKeys.f2fEnded);
}, onError: (e) => Logger.e(_tag, '面对面事件流错误: $e'));
}
@override
@ -347,6 +376,8 @@ class BesDeviceSession extends DeviceSession {
_workers.clear();
_aiSub?.cancel();
_aiSub = null;
_f2fSub?.cancel();
_f2fSub = null;
_ota?.onSessionGone();
if (!_events.isClosed) {
_events.add(DeviceSessionEvent(
@ -459,6 +490,11 @@ class BesDeviceSession extends DeviceSession {
return _BesSource(route, _link.micPcmStream);
case AudioRoute.callPeer:
return _BesSource(route, _link.spkPcmStream);
// 面对面 BB 68 上行与 BB 55 同格式:A 耳落 micData 槽、B 耳落 spkData 槽
case AudioRoute.f2fEarA:
return _BesSource(route, _link.micPcmStream);
case AudioRoute.f2fEarB:
return _BesSource(route, _link.spkPcmStream);
case AudioRoute.ai:
return _BesSource(
route,
@ -547,6 +583,22 @@ class BesDeviceSession extends DeviceSession {
await _link.stopTranslationDownlink();
}
});
case AudioRoute.f2fLegA:
case AudioRoute.f2fLegB:
// 与通话翻译共用同一个原生下行器(两者互斥),只是包头换成 AA 69、槽位交叉。
if (_callSinks == 0) {
await _link.startTranslationDownlink(f2f: true);
Logger.w(_tag, '[F2F] 译文下行已启动(AA 69,swapLegs)');
}
_callSinks++;
return _BesCallSink(_link, route, () async {
_callSinks--;
if (_callSinks <= 0) {
_callSinks = 0;
await _link.stopTranslationDownlink();
Logger.w(_tag, '[F2F] 译文下行已停止');
}
});
default:
throw DeviceException(
DeviceErrorCode.notSupported, 'openSpeaker($route)');
@ -566,12 +618,18 @@ class BesDeviceSession extends DeviceSession {
DeviceFeatures.asrStopped: BesCmd.asrStopped,
DeviceFeatures.aiAccept: BesCmd.aiAccept,
DeviceFeatures.aiExit: BesCmd.aiExit,
DeviceFeatures.f2fStart: BesCmd.faceToFaceStart,
DeviceFeatures.f2fStop: BesCmd.faceToFaceStop,
DeviceFeatures.batteryQuery: BesCmd.status,
'bes.firmware.query': BesCmd.firmwareVersion,
};
/// 面对面启停单独列出来是为了发出时打日志(整帧字节)。同样**冻结**,改动看金测试。
/// Echo-one 面对面的启停协议是 `AA 66` / `AA 67`(见 [BesF2f]);Android 原生会把它们编排成
/// `AA 19 01` → BLE 握手 → `AA 66` … `AA 67` → `AA 19 00`。
static const Map<String, List<int>> featureFrames = {
DeviceFeatures.f2fStart: [BesCmd.sof, BesF2f.start],
DeviceFeatures.f2fStop: [BesCmd.sof, BesF2f.stop],
};
@override
Future<Map<String, Object?>> invokeFeature(String featureKey,
[Map<String, Object?> args = const {}]) async {
@ -581,6 +639,13 @@ class BesDeviceSession extends DeviceSession {
await _link.sendData(BesCmd.frame(cmd));
return {'sent': BesCmd.frame(cmd)};
}
final frame = featureFrames[featureKey];
if (frame != null) {
await _link.sendData(frame);
Logger.w(_tag,
'[F2F] $featureKey 已发出 ${frame.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ')}');
return {'sent': frame};
}
switch (featureKey) {
case DeviceFeatures.stateRefresh:
await _link.refreshDeviceState();
@ -693,7 +758,8 @@ class _BesCallSink implements DeviceAudioSink {
final Future<void> Function() _onClose;
bool _closed = false;
String get _leg => route == AudioRoute.callLegA ? 'A' : 'B';
String get _leg =>
route == AudioRoute.callLegA || route == AudioRoute.f2fLegA ? 'A' : 'B';
@override
AudioFormat get format => AudioFormat.pcm16kMono;

34
apps/client/lib/devices/bes/bes_protocol.dart

@ -57,12 +57,6 @@ class BesCmd {
/// 请求退出 AI 模式,耳机回 `BB E5`
static const int aiExit = 0x65;
/// 开始面对面翻译,耳机回 `BB E6`
static const int faceToFaceStart = 0x66;
/// 结束面对面翻译,耳机回 `BB E7`
static const int faceToFaceStop = 0x67;
/// 应答 AI 唤醒(收到 `BB 64` 后 **3 秒内**必须发)
static const int aiAccept = 0x6A;
@ -72,6 +66,34 @@ class BesCmd {
static List<int> frame(int cmd) => <int>[sof, cmd & 0xFF];
}
/// Echo-one 面对面翻译(两人各戴一只耳机)。App → 耳机命令一律 `AA` 帧头,耳机 → App 是 `BB`:
///
/// | 方向 | 帧 | 链路 | 说明 |
/// |---|---|---|---|
/// | App → 耳机 | `AA 19 01` | SPP | 切到 BLE 链路(耳机随后广播 `Echo-one-L` / `Echo-one-R`)|
/// | App → 耳机 | `AA 66` | BLE | 两耳 BLE 都握手好后启动面对面 |
/// | 耳机 → App | `BB 68 …` | BLE | 每只耳机上行自己的收音(G.722,格式同 `BB 55`)|
/// | App → 耳机 | `AA 69 …` | BLE | 译文下行(格式同 `AA 56`,原生组包,左耳的话播右耳、右耳的话播左耳)|
/// | App → 耳机 | `AA 67` | BLE | 结束面对面 |
/// | App → 耳机 | `AA 19 00` | BLE | 切回经典蓝牙链路,之后原生回连 SPP |
///
/// Dart 只发 [command] 的 `AA 66` / `AA 67`;`AA 19`、BLE 扫描握手、两条命令走哪条链路
/// 全由 Android 原生(F2fBleLink)编排,见 docs/face_to_face_ble_protocol.md。
/// 耳机主动发 `BB 66` / `BB 67`(按键发起/结束)时 App 跟着开/停。
/// [sof] 是耳机 → App 方向(`BB 66/67/68`);App → 耳机方向(`AA 19/66/67/69`)用 [BesCmd.sof]。
class BesF2f {
BesF2f._();
static const int sof = 0xBB;
static const int start = 0x66;
static const int stop = 0x67;
static const int uplink = 0x68;
static const int downlink = 0x69;
/// App → 耳机的启停命令帧(`AA 66` / `AA 67`,原生拦下后按上表编排)
static List<int> command(int cmd) => <int>[BesCmd.sof, cmd & 0xFF];
}
/// 耳机 → App 的响应/上报(`BB xx`)。
class BesResp {
BesResp._();

470
apps/client/lib/modules/translation/controllers/translation_controller.dart

@ -27,8 +27,12 @@ import '../../../core/services/floating_window_manager.dart';
import '../../../data/services/asr_service.dart';
import '../../../data/services/usage_stats_service.dart';
import '../../../data/services/device_ai_session_service.dart';
import '../../../data/services/device_recording_service.dart';
import '../../../data/services/device_call_recording_service.dart';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import '../../../devices/device_hub.dart';
import '../../../routes/app_routes.dart';
import '../../../data/services/recording_archive.dart';
import '../models/translation_export.dart';
import '../../../core/utils/background_session_notifier.dart';
@ -232,6 +236,61 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// 真正执行一次——stopAll() 和 AST 流异步报错触发的 _terminateCallOnError()
// 都可能收尾同一个会话,不加锁耳机会收到两遍关闭指令。见 _teardownCallModeAudio。
bool _callAudioTornDown = true;
// Echo-one 面对面(两人各戴一只耳机,A 耳说的译文只播 B 耳、反之亦然):
// 这一轮走的是 AST 双路而不是手机麦 + ASR。与通话翻译共用 _callSession /
// _callSinkA/B / 原生直连桥 / 重连 / 录音,凡是「AST 双路才成立」的判断统一走
// [_isAstDualLeg],别再只写 `currentMode == 'call'`。
bool _f2fEarMode = false;
bool _f2fEarStopping = false;
StreamSubscription<DeviceSessionEvent>? _f2fDeviceSub;
/// Echo-one 面对面左/右耳 BLE 链路状态(见 [DeviceFeatureKeys.f2fLink]),
/// 空串 = 不在耳机面对面会话里。页面上语言旁的「左耳/右耳」标签按它亮灯。
final RxString f2fLeftEarState = ''.obs;
final RxString f2fRightEarState = ''.obs;
/// 上一场耳机面对面刚结束、耳机还在恢复(原生回连 SPP / 冷却),这期间不让再进。
/// 由原生 `restoring` / `recovered` 驱动,[_f2fRecoverTimer] 兜底,免得事件丢了永远卡着。
final RxBool f2fRecovering = false.obs;
Timer? _f2fRecoverTimer;
/// 发出 AA 66(原生先 AA 19 01 切 BLE 再扫连左右耳)后等两只耳机 BLE 都就绪:完成值 null = 就绪,否则是失败原因
/// (原生 failed 的 reason,或 `cancelled` / `background` / `timeout`)。
Completer<String?>? _f2fLinkWait;
// 原生扫描 15s + 建链 10s,再留点余量
static const Duration _f2fLinkMaxWait = Duration(seconds: 30);
// 面对面中来电:耳机要去接电话,这一场只能结束
Worker? _f2fInCallWorker;
bool get _f2fWaitingLink =>
_f2fLinkWait != null && !_f2fLinkWait!.isCompleted;
void _completeF2fLinkWait(String? reason) {
final w = _f2fLinkWait;
if (w == null || w.isCompleted) return;
Logger.w('Translation', '[F2F] 双耳 BLE 等待结束:${reason ?? "两耳就绪"}');
w.complete(reason);
}
/// [cap] = 这次置位最长维持多久(到点自动清)
void _setF2fRecovering(bool v, {Duration? cap}) {
_f2fRecoverTimer?.cancel();
_f2fRecoverTimer = null;
f2fRecovering.value = v;
if (v && cap != null) {
_f2fRecoverTimer = Timer(cap, () {
if (f2fRecovering.value) {
Logger.w('Translation', '[F2F] 恢复期兜底到时(${cap.inSeconds}s),放行');
}
f2fRecovering.value = false;
});
}
}
/// 当前这一轮是不是 AST 双路(通话翻译 / Echo-one 耳机面对面)
bool get _isAstDualLeg =>
currentMode.value == 'call' ||
(currentMode.value == 'faceToFace' && _f2fEarMode);
// 监听恒玄耳机的通话状态:挂断(0xD1/0xD2/0x02/0x03 → isCallOngoing=false)
// 时如果正在通话翻译,自动停止识别,不用等用户手动点停止
Worker? _besCallOngoingWorker;
@ -739,6 +798,9 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// 监听恒玄耳机通话状态,挂断时自动停止通话翻译
_setupBesCallHangupListener();
// 监听 Echo-one 耳机端发起/结束面对面(BB 66 / BB 67)
_setupF2fDeviceListener();
// 监听应用生命周期:当应用切到后台/失焦时兜底重置 activeSpeaker
WidgetsBinding.instance.addObserver(this);
}
@ -787,6 +849,182 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
);
}
/// Echo-one 耳机端按键发起/结束面对面:在面对面页上就跟着开/停。
/// 不在翻译页(比如「后台继续」留下的实例)一律不理,免得在用户看不见的地方起会话。
void _setupF2fDeviceListener() {
_f2fDeviceSub?.cancel();
if (!Get.isRegistered<DeviceHub>()) return;
_f2fDeviceSub = DeviceHub.to.sessionEvents.listen((e) {
if (e.type != DeviceSessionEventType.feature) return;
final key = e.feature?.key;
if (key == DeviceFeatureKeys.f2fStarted) {
unawaited(_onF2fDeviceEvent(true));
} else if (key == DeviceFeatureKeys.f2fEnded) {
unawaited(_onF2fDeviceEvent(false));
} else if (key == DeviceFeatureKeys.f2fLink) {
_onF2fLinkEvent(e.feature!.data);
}
});
_f2fInCallWorker?.dispose();
_f2fInCallWorker = ever<bool>(DeviceHub.to.inCall, (calling) {
if (!calling || !_f2fEarMode || _f2fEarStopping) return;
if (!isRecognizing.value && !isPreparing.value) return;
Logger.w('Translation', '[F2F] 面对面中有来电,结束这一场');
Get.snackbar('tip'.tr, 'faceToFaceEndedByCall'.tr);
_completeF2fLinkWait('cancelled');
unawaited(stopAll().catchError((Object e) {
Logger.w('Translation', '[F2F] 来电后停止失败: $e');
}));
});
}
/// 原生 BLE 双耳链路状态:更新左右耳标签;两耳就绪放行启动;`failed` 时停掉这一轮并提示;
/// `restoring` / `recovered` 驱动恢复期。
void _onF2fLinkEvent(Map<String, Object?> data) {
final side = data['side'] as String? ?? '';
final state = data['state'] as String? ?? '';
if (side.isEmpty && state == 'recovered') {
Logger.w('Translation', '[F2F] 耳机恢复期结束 ok=${data['ok']}');
_setF2fRecovering(false);
return;
}
if (state == 'failed') {
final reason = data['reason'] as String? ?? '';
Logger.w('Translation',
'[F2F] 耳机 BLE 链路失败 reason=$reason 耳机模式=$_f2fEarMode '
'识别中=${isRecognizing.value} 准备中=${isPreparing.value} 等待中=$_f2fWaitingLink');
if (reason == 'recovering') _setF2fRecovering(true, cap: const Duration(seconds: 35));
// 还在等两耳就绪:交给 _startF2fEarTranslation 提示并收尾
if (_f2fWaitingLink) {
_completeF2fLinkWait(reason.isEmpty ? 'connectFailed' : reason);
return;
}
if (!_f2fEarMode || _f2fEarStopping) return;
if (!isRecognizing.value && !isPreparing.value) return;
Get.snackbar('tip'.tr, _f2fFailText(reason));
unawaited(stopAll().catchError((Object e) {
Logger.w('Translation', '[F2F] 链路失败后停止失败: $e');
}));
return;
}
if (state == 'restoring') {
_setF2fRecovering(true, cap: const Duration(seconds: 35));
// 原生这一场已经收了(比如耳机发起、Dart 还没开始等就失败了),别空等到超时
_completeF2fLinkWait('recovering');
}
if (state == 'reconnecting' && _f2fEarMode && isRecognizing.value) {
Get.snackbar(
'tip'.tr,
'faceToFaceEarReconnecting'.trParams({
'ear': (side == 'L' ? 'faceToFaceLeftEar' : 'faceToFaceRightEar').tr,
}),
);
}
// idle 一律置空,标签不再亮灯
final v = state == 'idle' ? '' : state;
if (side.isNotEmpty) Logger.w('Translation', '[F2F] ${side == 'L' ? '左' : '右'}耳标签 → $state');
if (side == 'L') {
f2fLeftEarState.value = v;
} else if (side == 'R') {
f2fRightEarState.value = v;
}
if (_f2fWaitingLink &&
_f2fEarLinked(f2fLeftEarState.value) &&
_f2fEarLinked(f2fRightEarState.value)) {
_completeF2fLinkWait(null);
}
}
static bool _f2fEarLinked(String s) => s == 'ready' || s == 'streaming';
/// 双耳 BLE 没连上 / 中途掉了的提示:面对面不再退回手机麦,只告诉用户为什么用不了
String _f2fFailText(String reason) {
switch (reason) {
case 'oneEar':
case 'noEar':
return 'faceToFaceEarNotReady'.tr;
case 'earLost':
return 'faceToFaceEarDisconnected'.tr;
case 'recovering':
return 'faceToFaceRestoring'.tr;
case 'background':
return 'faceToFaceBackground'.tr;
default:
return 'faceToFaceBleNotConnected'.tr;
}
}
/// 耳机正忙(通话 / AI 对话 / 录音 / 刚结束还在恢复)时不进面对面:返回提示 key,空闲为 null。
/// 面对面要把耳机切成两个 BLE 广播、SPP 会断,其它功能在跑的话会被一起打断。
String? _f2fBusyReason() {
if (Get.isRegistered<DeviceHub>() && DeviceHub.to.inCall.value) {
return 'faceToFaceBusyCall';
}
if (Get.isRegistered<DeviceAiSessionService>() &&
DeviceAiSessionService.to.isActive.value) {
return 'faceToFaceBusy';
}
if (Get.isRegistered<DeviceRecordingService>() &&
DeviceRecordingService.to.isRecording.value) {
return 'faceToFaceBusy';
}
if (Get.isRegistered<DeviceCallRecordingService>() &&
DeviceCallRecordingService.to.isRecording.value) {
return 'faceToFaceBusy';
}
if (f2fRecovering.value) return 'faceToFaceRestoring';
return null;
}
/// 没找到可用耳机会话时把 Hub 里的情况全打出来:v97 就是这里返回了 null、
/// AA 66 根本没发,然后退回了手机麦——日志里只有一句「未连接」,分不清是没会话还是没 ready。
void _logF2fNoSession() {
if (!Get.isRegistered<DeviceHub>()) {
Logger.w('Translation', '[F2F] DeviceHub 未注册');
return;
}
final hub = DeviceHub.to;
final ss = hub.sessions
.map((s) => '${s.vendor}/${s.info.name}/${s.state.name}'
'/f2f=${s.supports(DeviceCapability.faceToFace)}')
.join('; ');
final ps = hub.plugins
.map((p) => '${p.vendorKey}:${p.activeSession?.state.name ?? "无会话"}')
.join('; ');
Logger.w('Translation', '[F2F] 无可用的面对面耳机会话:sessions=[$ss] plugins=[$ps]');
}
Future<void> _onF2fDeviceEvent(bool started) async {
Logger.w('Translation',
'[F2F] 耳机上报${started ? "启动" : "结束"}: mode=${currentMode.value} route=${Get.currentRoute} '
'识别中=${isRecognizing.value} 准备中=${isPreparing.value} 耳机模式=$_f2fEarMode');
if (currentMode.value != 'faceToFace') return;
try {
if (started) {
// 正在起/已在跑:多半是耳机对启动命令的回应
if (isRecognizing.value || isPreparing.value) return;
if (Get.currentRoute != Routes.translation) return;
// 不在前台不起:后台扫 BLE 受限,用户也看不到提示
final life = WidgetsBinding.instance.lifecycleState;
if (life != null && life != AppLifecycleState.resumed) {
Logger.w('Translation', '[F2F] App 不在前台($life),不响应耳机发起的面对面');
return;
}
await startRecognition();
} else {
// 还在等两耳 BLE:用户在耳机上取消了
if (_f2fWaitingLink) {
_completeF2fLinkWait('cancelled');
return;
}
if (!_f2fEarMode || !isRecognizing.value || _f2fEarStopping) return;
await stopAll();
}
} catch (e, s) {
Logger.e('Translation', '[F2F] 响应耳机上报失败: $e', e, s);
}
}
// @override
// void onReady() {
// super.onReady();
@ -1133,6 +1371,13 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_deviceCallTranslationSubscription = null;
_besCallOngoingWorker?.dispose();
_besCallOngoingWorker = null;
_f2fDeviceSub?.cancel();
_f2fDeviceSub = null;
_f2fInCallWorker?.dispose();
_f2fInCallWorker = null;
_f2fRecoverTimer?.cancel();
_f2fRecoverTimer = null;
_completeF2fLinkWait('cancelled');
_floatingWindowClosedSubscription?.cancel();
_floatingWindowClosedSubscription = null;
_detachBesCallTranslationBridge();
@ -1164,13 +1409,18 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// 观测点:切后台会不会让通话翻译卡顿,光看识别/翻译日志分不出来——
// 那两条在后台照样按时出结果,真正被 iOS 节流的是下行那条 20ms 定时器。
// 所以每次切换都把当时的桥统计打一份,前后一对比就知道帧是在哪一段丢的。
if (currentMode.value == 'call' && isRecognizing.value) {
Logger.w('Translation', '[生命周期] → $state(通话翻译进行中)');
if (_isAstDualLeg && isRecognizing.value) {
Logger.w('Translation', '[生命周期] → $state(${currentMode.value} 双路翻译进行中)');
unawaited(_dumpCallBridgeStats('lifecycle:$state'));
}
if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
// 连双耳 BLE 时切后台:后台扫描会被系统节流、用户也看不到结果,直接放弃这一场。
// inactive 不算(下拉通知栏、系统弹窗都会触发)
if (state != AppLifecycleState.inactive && _f2fWaitingLink) {
_completeF2fLinkWait('background');
}
if (activeSpeaker.value != 0) {
Future.microtask(() {
setActiveSpeaker(0);
@ -1185,7 +1435,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// 照样 `Freeze process`)。进程被冻住时连重连定时器都不会跑,所以必须在解冻回来
// 的这一刻主动补一次——否则用户看到的就是「切出去一趟,回来翻译已经死了,
// 还得手动结束再重开」。
if (currentMode.value != 'call' || !isRecognizing.value) return;
if (!_isAstDualLeg || !isRecognizing.value) return;
if (_astHealthy) return;
Logger.w('Translation', '[STS] 回到前台且 AST 已掉线,立即重连');
_astReconnectTimer?.cancel();
@ -1263,7 +1513,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
: "${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav";
_currentRecordingPath = filePath;
if (currentMode.value == 'call') {
if (_isAstDualLeg) {
await _astService.enableRecord(filePath);
} else {
AudioSourceType audioSourceType;
@ -1301,7 +1551,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_recordingStartedAt = null;
_timerManager.stopTimer();
if (currentMode.value == 'call') {
if (_isAstDualLeg) {
await _astService.stopRecord(true);
} else {
await _asrService.stopRecord(true);
@ -1366,7 +1616,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
Future<void> pauseRecording() async {
_pauseStartedAt ??= DateTime.now();
_timerManager.pauseTimer();
if (currentMode.value == 'call') {
if (_isAstDualLeg) {
await _astService.pauseRecord();
} else {
await _asrService.pauseRecord();
@ -1382,7 +1632,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_pauseStartedAt = null;
}
_timerManager.resumeTimer();
if (currentMode.value == 'call') {
if (_isAstDualLeg) {
await _astService.resumeRecord();
} else {
await _asrService.resumeRecord();
@ -1429,10 +1679,34 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
if (isRecognizing.value) return;
// 上一次点击还在准备中(等初始化 / 开桥),再点一次会起第二条初始化与第一条并发。
if (isPreparing.value) {
// 面对面正在等双耳 BLE:再点一次 = 取消
if (_f2fWaitingLink) {
Logger.w('Translation', '[F2F] 等待双耳 BLE 时用户再次点击,取消');
_completeF2fLinkWait('cancelled');
return;
}
Logger.w('Translation', '识别正在准备中,忽略重复的开始请求');
return;
}
// Echo-one 面对面(Android):只走耳机双路,不再退回手机麦。没连耳机 / 耳机正忙
// 就在这里直接告诉用户、不启动——放在点亮 isPreparing / _initializeSession 之前,
// 按钮和录音状态都不动。iOS 没连耳机时仍走下面的手机麦;连着耳机则和 Android
// 一样等双耳 BLE 就绪,没起来不开麦、不计时。
if (Platform.isAndroid && currentMode.value == 'faceToFace') {
if (_f2fEarSession() == null) {
_logF2fNoSession();
Get.snackbar('tip'.tr, 'faceToFaceNoEarbuds'.tr);
return;
}
final busy = _f2fBusyReason();
if (busy != null) {
Logger.w('Translation', '[F2F] 耳机忙,不进面对面:$busy');
Get.snackbar('tip'.tr, busy.tr);
return;
}
}
// ⚠️ 必须在这里点亮「准备中」:view 早就绑了 isPreparing 做按钮的缩放/阴影
// 反馈,但一直没人把它设成 true——点下去到真正开始识别之间毫无视觉变化,
// 用户看到的就是「点了没反应」。ASR 首次初始化要连 Azure,本来就有秒级耗时,
@ -1499,6 +1773,27 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
await _startPip();
}
// Echo-one 面对面:连着支持面对面协议的耳机就走耳机双路(A 耳↔B 耳)。
// Android 没连已在上面拦掉;只有 iOS 还会退回下面的手机麦 + ASR 语种检测。
if (currentMode.value == 'faceToFace') {
final ear = _f2fEarSession();
_f2fEarMode = ear != null;
if (ear != null) {
await _startF2fEarTranslation(ear);
return;
}
if (Platform.isAndroid) {
// 等模式初始化的这几秒里耳机断了
_logF2fNoSession();
Get.snackbar('tip'.tr, 'faceToFaceNoEarbuds'.tr);
return;
}
Logger.w('Translation', '[F2F] 未连接支持面对面的耳机,退回手机麦克风收音');
// 说清楚走的是手机麦:这条路译文经系统媒体通道播,两只耳机会一起响,
// 不说的话很容易被当成「耳机面对面没分耳播」
Get.snackbar('tip'.tr, 'faceToFacePhoneMic'.tr);
}
await _configureAudioMode();
// 设备端触发的面对面翻译不从这里启动——那是「按住说话」,由耳机左右耳
// 按键经 [setActiveSpeaker] 逐句拉起识别。客户端触发(App 上的开始按钮)
@ -1618,13 +1913,129 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
}
// 音视频/通话模式:识别挂了,设备侧/录屏广播那条链路也得收掉,
// 否则 socket 接收端还开着、没人消费(正常 stopAll 走的是同一个收尾,幂等)。
if (currentMode.value == "call" || currentMode.value == "audioVideo") {
if (currentMode.value == "call" ||
currentMode.value == "audioVideo" ||
_f2fEarMode) {
try {
await _teardownCallModeAudio();
} catch (e) {
Logger.error('异常终止时收尾音频链路失败(忽略): $e');
}
}
if (_f2fEarMode) {
try {
await _astService.stopContinuousTranslation();
} catch (_) {}
_f2fEarMode = false;
}
}
/// 在线(ready)、声明了 [DeviceCapability.faceToFace] 的耳机;没有为 null。
/// 不用 [DeviceHub.sessionWith]:它只看第一台,第一台恰好没 ready 就整个判成「没连」。
DeviceSession? _f2fEarSession() {
if (!Get.isRegistered<DeviceHub>()) return null;
for (final s in DeviceHub.to.sessions) {
if (s.supports(DeviceCapability.faceToFace) &&
s.state == DeviceConnectionState.ready) {
return s;
}
}
return null;
}
/// Echo-one 耳机面对面启动(Dart 只发 AA 66,切通道和双耳 BLE 由原生编排):
/// 经典链路发一帧 AA 19 切 BLE → 扫连 Echo-one-L / -R → **两只都就绪**后经 BLE 发 AA 66
/// (连不齐就提示并收尾,不退回手机麦、不开录音计时)→ 开两路译文下行(AA 69)→ AST → 接桥。
///
/// 路由与通话翻译同构:A 耳(micData) → AST.A(源语→目标语)→ 只播 B 耳;
/// B 耳(spkData) → AST.B(目标语→源语)→ 只播 A 耳。「播到对侧」由原生下行器的
/// swapLegs 完成,这里仍按 leg A/B 写。
Future<void> _startF2fEarTranslation(DeviceSession session) async {
Logger.w('Translation',
'[F2F] 耳机面对面启动 device=${session.info.name} '
'A耳=${sourceLanguageCode.value} B耳=${targetLanguageCode.value}');
// 同 _configureCallMode:两路音频全走耳机,切后台得靠前台服务保住进程
await AudioForegroundService.start();
_callSession = session;
_callModeUsingBes = true;
_callAudioTornDown = false;
// 两端都要等双耳 BLE。不等的话 iOS 会在扫描期间就把 AST、录音计时和音频会话拉起来,
// 面对面没起来,手机麦和右上角计时已经在走。
// 耳机发起时原生可能已经先跑起来了,已就绪的耳别被打回 scanning。
if (!_f2fEarLinked(f2fLeftEarState.value)) f2fLeftEarState.value = 'scanning';
if (!_f2fEarLinked(f2fRightEarState.value)) f2fRightEarState.value = 'scanning';
// 先建等待再发 AA 66:原生拒绝(恢复期 / 经典链路不通)时 failed 是紧跟着报上来的
final wait = _f2fLinkWait = Completer<String?>();
await session.invokeFeature(DeviceFeatures.f2fStart);
if (_f2fEarLinked(f2fLeftEarState.value) &&
_f2fEarLinked(f2fRightEarState.value)) {
_completeF2fLinkWait(null);
}
final reason = await wait.future.timeout(_f2fLinkMaxWait, onTimeout: () {
_completeF2fLinkWait('timeout');
return 'timeout';
});
if (identical(_f2fLinkWait, wait)) _f2fLinkWait = null;
if (reason != null) {
Logger.w('Translation', '[F2F] 双耳 BLE 未就绪($reason),不启动面对面,也不开手机麦和计时');
if (reason != 'cancelled') Get.snackbar('tip'.tr, _f2fFailText(reason));
await _teardownCallModeAudio(); // 收尾里发 AA 67(原生接着发 AA 19 00 切回经典蓝牙)
_f2fEarMode = false;
isRecording.value = false;
isRecognizing.value = false;
return;
}
if (_callAudioTornDown || !_f2fEarMode) {
Logger.w('Translation', '[F2F] 双耳就绪时这一场已被收尾,不再启动');
isRecording.value = false;
isRecognizing.value = false;
return;
}
Logger.w('Translation', '[F2F] 双耳 BLE 已就绪,开始面对面');
if (_recordsByDefault && !isRecording.value) {
isRecording.value = true;
}
_audioSourceType = true;
// 译文只进耳机,手机不播
isTtsEnabled.value = false;
_callSinkA = await session.openSpeaker(route: AudioRoute.f2fLegA);
_callSinkB = await session.openSpeaker(route: AudioRoute.f2fLegB);
await _initializeCallModeTranslationService();
await _astService.setBroadcastPeerTranslate(false);
await _attachBesCallTranslationBridge(f2f: true);
await _finalizeRecognitionStart();
Logger.w('Translation', '[F2F] 耳机面对面已就绪(native=$_callBridgeNative)');
}
/// 耳机面对面收尾:断桥 + 关译文下行 + AA 67(原生经 BLE 发 AA 67 → AA 19 00 → 断 GATT → 回连 SPP)。
/// 先断桥再清 _callSession:断桥要靠它把设备端直连桥关回去。
Future<void> _teardownF2fEarMode() async {
// 还在等双耳:让 _startF2fEarTranslation 尽快退出(它再调收尾时会被 _callAudioTornDown 短路)
_completeF2fLinkWait('cancelled');
final session = _callSession;
unawaited(BackgroundKeepalive.release('callTranslation'));
await _detachBesCallTranslationBridge(); // 关掉两路 sink = 停 AA 69 下行
_callSession = null;
_callModeUsingBes = false;
f2fLeftEarState.value = '';
f2fRightEarState.value = '';
if (session == null || session.state != DeviceConnectionState.ready) {
Logger.w('Translation', '[F2F] 收尾时耳机会话不可用,AA 67 不发');
return;
}
try {
await session.invokeFeature(DeviceFeatures.f2fStop);
// 原生发完 AA 67 / AA 19 00 后进恢复期并报 restoring(会把这里的兜底延到 35s),
// 先拦住这几秒空档,免得用户立刻重开撞上耳机还没退出面对面
if (Platform.isAndroid && !f2fRecovering.value) {
_setF2fRecovering(true, cap: const Duration(seconds: 3));
}
} catch (e) {
Logger.w('Translation', '[F2F] 发送 AA 67 失败: $e');
}
}
/// 检查权限
@ -1645,7 +2056,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// 不经过后者,但第一句一定经过这里。
// ⚠️ 通话翻译录的是通话双方的声音。默认录是 2026-09-18 的产品决定
// (翻译完要能在纪要里回听),上线前请确认录音告知/合规口径。
if (_recordsByDefault && !isRecording.value) {
// 耳机面对面要等双耳 BLE 就绪才算这一场开始。提前点亮 isRecording,
// 右上角计时在扫描的十几秒里就会走,BLE 没起来也像已经在录。
final deferEarRecord =
currentMode.value == 'faceToFace' && _f2fEarSession() != null;
if (_recordsByDefault && !isRecording.value && !deferEarRecord) {
isRecording.value = true;
}
@ -1823,7 +2238,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// callPeer(对端) → AST.B(对端→己方语种)
/// AST.A TTS → callLegA(对端听)
/// AST.B TTS → callLegB(己方听)
Future<void> _attachBesCallTranslationBridge() async {
Future<void> _attachBesCallTranslationBridge({bool f2f = false}) async {
// 开桥之前必须等通话模式初始化真正落地:startRecognition 等 _modeInitFuture 最多 3 秒就
// 往下走,而阿里两路初始化本身要 1.2 秒以上、前面还可能排着一次 dispose——桥先开、
// startContinuousTranslation 先发,原生那边会话还没建好(2026-09-23 K90 真机日志:
@ -1872,8 +2287,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// ---- 兜底:Dart 流路径(原样保留)----
Logger.w('Translation', '[CALL-BRIDGE] 原生直连桥不可用,退回 Dart 流路径');
_callLocalSrc = await session.openMic(route: AudioRoute.callLocal);
_callPeerSrc = await session.openMic(route: AudioRoute.callPeer);
_callLocalSrc = await session.openMic(
route: f2f ? AudioRoute.f2fEarA : AudioRoute.callLocal);
_callPeerSrc = await session.openMic(
route: f2f ? AudioRoute.f2fEarB : AudioRoute.callPeer);
_besCallMicSub = _callLocalSrc!.pcm.listen(
(pcm) => _astService.pushExternalAudio(leg: 'A', pcm: pcm),
onError: (e) => Logger.error('[CALL-BRIDGE] local stream error: $e'),
@ -1945,7 +2362,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
void _markAstLinkHealthy() {
_astReconnectAttempts = 0;
_astDropWindowStart = null;
if (currentMode.value == 'call' && isRecognizing.value) {
if (_isAstDualLeg && isRecognizing.value) {
astLinkState.value = AstLinkState.connected;
}
}
@ -2166,7 +2583,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// 有事件进来就说明链路是活的(错误/取消除外,它们在下面各自置回 false)
if (event.type != ASTEventType.error && event.type != ASTEventType.canceled) {
_astHealthy = true;
if (currentMode.value == 'call' && isRecognizing.value &&
if (_isAstDualLeg && isRecognizing.value &&
astLinkState.value != AstLinkState.connected) {
astLinkState.value = AstLinkState.connected;
}
@ -2373,7 +2790,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
Future<void> stopRecognition() async {
if (!isRecognizing.value) return;
// 正常结束:链路灯归位(错误终止那条路在 _terminateCallOnError 里已置成 disconnected)
if (currentMode.value == 'call' &&
if (_isAstDualLeg &&
astLinkState.value != AstLinkState.disconnected) {
astLinkState.value = AstLinkState.idle;
}
@ -2389,7 +2806,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
try {
await stopRecording();
await _asrService.stopContinuousRecognition();
if (currentMode.value == "call") {
if (_isAstDualLeg) {
_astReconnectTimer?.cancel();
_astReconnectTimer = null;
_astReconnecting = false;
@ -2842,9 +3259,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 停止所有操作
Future<void> stopAll() async {
final f2fEar = _f2fEarMode;
if (f2fEar) _f2fEarStopping = true;
try {
isPreparing.value = false;
if (currentMode.value == "audioVideo" || currentMode.value == "call") {
if (currentMode.value == "audioVideo" || _isAstDualLeg) {
await _teardownCallModeAudio();
}
if (isRecognizing.value) {
@ -2861,6 +3280,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
await _ttsService.stop();
} catch (e) {
Logger.error('停止操作失败: ${e.toString()}');
} finally {
if (f2fEar) {
_f2fEarMode = false;
_f2fEarStopping = false;
Logger.w('Translation', '[F2F] 耳机面对面已结束');
}
}
}
@ -2894,7 +3319,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 才按原来的方式终止——那时确实不是抖动了。
Future<void> _handleAstDrop(String reason) async {
_astHealthy = false;
if (currentMode.value != 'call' || !isRecognizing.value) {
if (!_isAstDualLeg || !isRecognizing.value) {
await _terminateCallOnError(reason);
return;
}
@ -2925,7 +3350,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_astReconnectTimer = Timer(delay, () async {
try {
// 这段时间里用户可能已经自己结束了,别把它又拉起来
if (currentMode.value != 'call' || !isRecognizing.value) {
if (!_isAstDualLeg || !isRecognizing.value) {
Logger.i('Translation', '[STS] 重连前发现已不在通话翻译中,放弃重连');
return;
}
@ -2980,9 +3405,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_stopAsrActiveTracking();
_translationDebounceTimer?.cancel();
await _ttsService.stop();
if (currentMode.value == "call" || currentMode.value == "audioVideo") {
if (currentMode.value == "call" ||
currentMode.value == "audioVideo" ||
_isAstDualLeg) {
await _teardownCallModeAudio();
}
_f2fEarMode = false;
} catch (e) {
Logger.error('[STS] 终止通话服务失败: $e');
}
@ -2996,6 +3424,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_callAudioTornDown = true;
if (currentMode.value == "call" && _callModeUsingBes) {
await _teardownBesCallMode();
} else if (_f2fEarMode && _callModeUsingBes) {
await _teardownF2fEarMode();
} else if (currentMode.value == "audioVideo" &&
(Platform.isAndroid || Platform.isIOS)) {
// Android:关掉 MediaProjection 的前台服务。

68
apps/client/lib/modules/translation/views/translation_view.dart

@ -524,7 +524,8 @@ class TranslationView extends GetView<TranslationController> {
// 面对面翻译:左边胶囊 = 左耳,右边胶囊 = 右耳。
// 两只耳朵各自朝向一位说话人,所以要在语言旁边标清楚是哪一只。
sourceSuffix: controller.currentMode.value == 'faceToFace'
? _earTag('faceToFaceLeftEar'.tr, isDarkMode)
? _earTag('faceToFaceLeftEar'.tr, isDarkMode,
controller.f2fLeftEarState)
: null,
targetSuffix: controller.currentMode.value == 'call'
? Icon(
@ -533,7 +534,8 @@ class TranslationView extends GetView<TranslationController> {
color: isDarkMode ? Colors.white70 : Colors.grey[600],
)
: controller.currentMode.value == 'faceToFace'
? _earTag('faceToFaceRightEar'.tr, isDarkMode)
? _earTag('faceToFaceRightEar'.tr, isDarkMode,
controller.f2fRightEarState)
: null,
),
@ -576,24 +578,52 @@ class TranslationView extends GetView<TranslationController> {
);
}
/// 面对面翻译里标注这一侧对应哪只耳机
Widget _earTag(String text, bool isDarkMode) {
return Container(
margin: EdgeInsets.only(left: 4.w),
padding: EdgeInsets.symmetric(horizontal: 5.w, vertical: 1.h),
decoration: BoxDecoration(
color: (isDarkMode ? Colors.white : Colors.black).withOpacity(0.08),
borderRadius: BorderRadius.circular(4.r),
),
child: Text(
text,
style: TextStyle(
fontSize: 10.sp,
height: 1.2,
color: isDarkMode ? Colors.white70 : Colors.grey[700],
/// 面对面翻译里标注这一侧对应哪只耳机。
/// 耳机面对面会话里前面带一个状态点([linkState],见 TranslationController.f2fLeftEarState):
/// 橙 = 扫描/连接中,蓝 = 已连上但没收到耳机音频,绿 = 正在收音,红 = 已断开/重连中,
/// 琥珀 = 刚结束一场、耳机还在回到普通模式(这期间不能再开)。点后面跟文字状态,别只靠颜色。
Widget _earTag(String text, bool isDarkMode, RxString linkState) {
return Obx(() {
final (dot, status) = switch (linkState.value) {
'scanning' || 'connecting' => (Colors.orange, 'faceToFaceEarConnectingShort'),
'ready' => (Colors.blue, 'faceToFaceEarReadyShort'),
'streaming' => (Colors.green, 'faceToFaceEarStreamingShort'),
'lost' || 'reconnecting' => (Colors.red, 'faceToFaceEarLostShort'),
'restoring' => (Colors.amber, 'faceToFaceRestoringShort'),
_ => (null, null),
};
return Container(
margin: EdgeInsets.only(left: 4.w),
padding: EdgeInsets.symmetric(horizontal: 5.w, vertical: 1.h),
decoration: BoxDecoration(
color: (isDarkMode ? Colors.white : Colors.black).withOpacity(0.08),
borderRadius: BorderRadius.circular(4.r),
),
),
);
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (dot != null) ...[
Container(
width: 7.r,
height: 7.r,
decoration: BoxDecoration(color: dot, shape: BoxShape.circle),
),
SizedBox(width: 3.w),
],
Text(
status != null ? '$text · ${status.tr}' : text,
style: TextStyle(
fontSize: 10.sp,
height: 1.2,
color: dot == Colors.red
? Colors.red
: (isDarkMode ? Colors.white70 : Colors.grey[700]),
),
),
],
),
);
});
}
Widget _buildBody(bool isDarkMode) {

194
apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt

@ -44,6 +44,9 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
// 上行帧计数(诊断用,见 g722DecodeData / msbcDecodeData)
private var g722FrameCount: Long = 0L
private var msbcFrameCount: Long = 0L
// 面对面(Echo-one)诊断:BB 68 上行帧数、未识别命令数
private var f2fUplinkCount: Long = 0L
private var unknownCmdCount: Long = 0L
private var g722SpkHandle: Long = 0L
private var g722MicHandle: Long = 0L
private var msbcMainHandle: Long = 0L
@ -59,6 +62,12 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
private var aclReceiver: BroadcastReceiver? = null
private const val RFCOMM_RETRY_DELAY_MS = 2000L
private const val RFCOMM_MAX_RETRIES = 3
// 面对面 BLE 结束后回连 SPP:耳机切回经典蓝牙要一会儿,多给几次
private const val F2F_SPP_RETRIES = 6
// 回连时已有别的连接在进行:每隔这么久再看一次,最多看这么多轮
private const val F2F_SPP_WAIT_MS = 2000L
private const val F2F_SPP_WAIT_ROUNDS = 5
private const val DEFAULT_SPP_UUID = "aeaf5241-5453-7070-7353-445542594c46"
// 新的 connect 等上一次连接退出的上限。cancelReconnect() 已经把旧 socket 关了,
// 阻塞中的 sock.connect() 会立刻抛异常,正常情况远用不到这个上限。
@ -274,9 +283,23 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
publishAIData(mapOf("type" to "speedAdjustment", "data" to data))
}
0x55.toByte(), 0x68.toByte(), 0x60.toByte() -> g722DecodeData(data)
0x55.toByte(), 0x60.toByte() -> g722DecodeData(data)
// Echo-one 面对面上行 BB 68:包体与通话翻译同布局(mode 01/02/03 + 40B G.722 块),
// micData = A 耳、spkData = B 耳。前几帧打原始头,真机核对槽位用。
0x68.toByte() -> {
f2fUplinkCount++
if (f2fUplinkCount <= 5L || f2fUplinkCount % 500 == 0L) {
Log.w("BesF2f", "上行#$f2fUplinkCount len=${data.size} 头=${hex(data, 8)}")
}
g722DecodeData(data)
}
0xD9.toByte() -> {
if (F2fBleLink.sessionActive) {
Log.w("BesF2fBle", "面对面进行中,忽略耳机录音 BB D9")
return
}
publishTranslationData(mapOf("type" to "startMicRecording", "data" to data))
BluetoothManager.publishBluetoothCmd(mapOf("type" to "startMicRecording", "data" to data))
}
@ -292,11 +315,40 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
0x5B.toByte() -> msbcDecodeData(data)
0xE6.toByte() -> publishTranslationData(mapOf("type" to "startFaceToFace", "data" to data))
// AA 66 / AA 67 的应答(老协议耳机回 BB E6 / BB E7)
0xE6.toByte() -> {
Log.w("BesF2f", "收到启动应答 ${hex(data, 16)} 会话中=${F2fBleLink.sessionActive}")
publishTranslationData(mapOf("type" to "startFaceToFace", "data" to data))
}
0xE7.toByte() -> {
Log.w("BesF2f", "收到结束应答 ${hex(data, 16)}")
publishTranslationData(mapOf("type" to "stopFaceToFace", "data" to data))
}
// Echo-one 面对面:耳机侧发起/结束(BB 66 / BB 67)
0x66.toByte() -> {
Log.w("BesF2f", "收到启动 ${hex(data, 16)}")
f2fUplinkCount = 0L
// 耳机按键发起:原生直接走 AA 19 06 02 01 01 → BLE 握手 → AA 66,Dart 随后回发的 AA 66 会被 start 去重
if (F2fBleLink.start("耳机经 SPP 上报 BB 66")) {
publishTranslationData(mapOf("type" to "startFaceToFace", "data" to data))
} else {
// 上一场还在恢复:经 SPP 回 AA 67 让耳机退出面对面
BluetoothManager.writeBytes(byteArrayOf(0xAA.toByte(), 0x67))
}
}
0xE7.toByte() -> publishTranslationData(mapOf("type" to "stopFaceToFace", "data" to data))
0x67.toByte() -> {
Log.w("BesF2f", "收到结束 ${hex(data, 16)} 本场上行=$f2fUplinkCount")
publishTranslationData(mapOf("type" to "stopFaceToFace", "data" to data))
}
0x64.toByte() -> publishAIData(mapOf("type" to "startAI", "data" to data))
0x64.toByte() -> {
// 面对面与 AI 互斥:会话中耳机唤醒 AI 不理
if (F2fBleLink.sessionActive) Log.w("BesF2fBle", "面对面进行中,忽略 AI 唤醒 BB 64")
else publishAIData(mapOf("type" to "startAI", "data" to data))
}
0xE5.toByte() -> publishAIData(mapOf("type" to "stopAI", "data" to data))
@ -308,10 +360,27 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
}
}
}
else -> BluetoothManager.publishBluetoothCmd(mapOf("type" to "unknown", "data" to data))
// AA 19(链路切换)的应答 BB 99 04 <status>,00 = 成功。格式已固定,不再据此换帧重发。
0x99.toByte() -> {
Log.w("BesF2f", "收到 AA 19 应答 ${hex(data, 16)} 会话中=${F2fBleLink.sessionActive}")
F2fBleLink.onLinkAck(data)
BluetoothManager.publishBluetoothCmd(mapOf("type" to "unknown", "data" to data))
}
else -> {
unknownCmdCount++
// 面对面切链路前后不限量:应答码没有全部定下来,全打出来对照
if (F2fBleLink.sessionActive || F2fBleLink.recovering || unknownCmdCount <= 20L || unknownCmdCount % 100 == 0L) {
Log.w("BesF2f", "未识别命令#$unknownCmdCount ${hex(data, 16)}")
}
BluetoothManager.publishBluetoothCmd(mapOf("type" to "unknown", "data" to data))
}
}
}
private fun hex(data: ByteArray, max: Int): String =
data.take(max).joinToString(" ") { "%02X".format(it.toInt() and 0xFF) } + if (data.size > max) " …(${data.size})" else ""
private fun g722DecodeData(data: ByteArray) {
if (data.size < 4) return
val mode = (data[3].toInt() and 0x0F).toByte()
@ -511,6 +580,7 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
looper = applicationContext.mainLooper
mainHandler = Handler(looper)
application = applicationContext
F2fBleLink.init(applicationContext)
registerAclReceiver()
}
@ -541,13 +611,22 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
}
}
private fun reconnectWithRetry(deviceId: String, serviceUuid: String) {
if (!isConnecting.compareAndSet(false, true)) return
/**
* 后台重连 RFCOMM。返回是否真的发起了(已有连接在进行时返回 false,调用方自己决定怎么办)。
* [onDone] 在重连线程退出时回调,参数为是否已连上。
*/
private fun reconnectWithRetry(
deviceId: String,
serviceUuid: String,
maxRetries: Int = RFCOMM_MAX_RETRIES,
onDone: ((Boolean) -> Unit)? = null,
): Boolean {
if (!isConnecting.compareAndSet(false, true)) return false
val t = Thread {
try {
for (attempt in 1..RFCOMM_MAX_RETRIES) {
for (attempt in 1..maxRetries) {
if (Thread.interrupted() || isUserInitiatedDisconnect || deviceStatus == 2) break
android.util.Log.i("Bluetooth Reconnect", "RFCOMM attempt $attempt/$RFCOMM_MAX_RETRIES")
android.util.Log.i("Bluetooth Reconnect", "RFCOMM attempt $attempt/$maxRetries")
publishBluetoothStatus(1)
resetConnectionState(publishStatus = false)
val success = tryConnectOnce(deviceId, serviceUuid)
@ -555,7 +634,7 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
publishBluetoothStatus(2)
break
}
if (attempt < RFCOMM_MAX_RETRIES && !isUserInitiatedDisconnect) {
if (attempt < maxRetries && !isUserInitiatedDisconnect) {
Thread.sleep(RFCOMM_RETRY_DELAY_MS)
}
}
@ -567,10 +646,12 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
} finally {
isConnecting.set(false)
reconnectThread = null
onDone?.invoke(deviceStatus == 2)
}
}
reconnectThread = t
t.start()
return true
}
private fun tryConnectOnce(deviceId: String, serviceUuid: String): Boolean {
@ -639,7 +720,14 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
}
private fun publishBluetoothStatus(status: Int) {
// 面对面 BLE 期间耳机会主动断 SPP:不上报,Dart 会话保持 ready;结束后 reconnectSppAfterF2f 回连
if (status == 0 && F2fBleLink.suppressSppDown) {
if (!F2fBleLink.sppLost) Log.w("BesF2fBle", "面对面 BLE 期间 SPP 断开,按预期忽略(不上报断连)")
F2fBleLink.sppLost = true
return
}
deviceStatus = status
if (status == 2) F2fBleLink.onSppConnected()
if (status == 0) {
// 链路断开立即通知 AI 会话停止。此路径可能从任意线程、重复触发,接收方须幂等
aiCallback?.invoke(mapOf("type" to "deviceDisconnected"))
@ -680,6 +768,54 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
translationCallback?.invoke(data)
}
/** F2fBleLink 的上行(左耳 micData / 右耳 spkData)与事件,走与 SPP 完全相同的分发 */
internal fun publishF2fData(data: Map<String, Any>) = publishTranslationData(data)
/**
* 面对面 BLE 结束:SPP 在会话中断过,用上次的地址回连(Dart 会话一直保持着,连上后状态 1→2)。
* [onDone] 一定会回调(参数 = 是否连上),F2fBleLink 靠它结束恢复期。
*/
internal fun reconnectSppAfterF2f(onDone: (Boolean) -> Unit) {
F2fBleLink.pendingSppReconnect = false
val id = lastDeviceId
if (id == null || isUserInitiatedDisconnect) {
Log.w("BesF2fBle", "面对面结束,无回连目标(id=$id 用户断开=$isUserInitiatedDisconnect),上报断连")
resetConnectionState(publishStatus = true)
onDone(false)
return
}
Log.w("BesF2fBle", "面对面结束,回连 SPP $id")
// 会话期间断连没上报,deviceStatus 还停在 2;不复位 reconnectWithRetry 会直接跳过
deviceStatus = 0
f2fSppReconnect(id, onDone, 1)
}
/** 已有连接在进行(isConnecting 被占)时不能静默跳过:隔一会儿再看,它连上了也算成功 */
private fun f2fSppReconnect(id: String, onDone: (Boolean) -> Unit, round: Int) {
if (deviceStatus == 2) {
Log.w("BesF2fBle", "SPP 已由其它连接连上")
onDone(true)
return
}
if (isUserInitiatedDisconnect || lastDeviceId != id) {
onDone(false)
return
}
if (reconnectWithRetry(id, lastServiceUuid ?: DEFAULT_SPP_UUID, F2F_SPP_RETRIES, onDone)) return
if (round >= F2F_SPP_WAIT_ROUNDS) {
Log.w("BesF2fBle", "已有 SPP 连接一直在进行,放弃回连,交给它上报结果")
onDone(false)
return
}
Log.w("BesF2fBle", "已有 SPP 连接在进行,${F2F_SPP_WAIT_MS}ms 后再看($round/$F2F_SPP_WAIT_ROUNDS)")
val h = mainHandler
if (h == null) {
onDone(false)
return
}
h.postDelayed({ f2fSppReconnect(id, onDone, round + 1) }, F2F_SPP_WAIT_MS)
}
/** 开/关通话翻译 PCM 原生直连桥;关掉即恢复原来的 EventChannel 路径。 */
fun setCallPcmBridge(enabled: Boolean) {
if (callPcmBridgeEnabled == enabled) return
@ -742,10 +878,44 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
/** 控制命令(AA xx…):串行 + 等管道里的音频发完。异步,调用方不等结果(原来也不等)。 */
fun writeBytes(message: ByteArray) {
// Echo-one 面对面启停:Dart 发 AA 66 / AA 67,由 F2fBleLink 编排成
// AA 19 06 02 01 01(SPP)→ BLE 握手 → AA 66(BLE)… AA 67 → AA 19 06 02 01 00(BLE),这里不直接发
val f2fCmd: Byte? = if (message.size == 2 && message[0] == 0xAA.toByte() &&
(message[1] == 0x66.toByte() || message[1] == 0x67.toByte())) message[1] else null
if (f2fCmd == 0x66.toByte()) {
// 恢复期内拒绝进入时 F2fBleLink 已上报 failed=recovering
F2fBleLink.start("App 下发 AA 66")
return
}
if (F2fBleLink.sessionActive) {
if (f2fCmd == 0x67.toByte()) {
F2fBleLink.requestStop("App 下发 AA 67")
return
}
// 与通话翻译 / 录音 / AI 互斥:会话中拦下「进入」类模式命令
if (message.size >= 2 && message[0] == 0xAA.toByte() &&
(message[1] == 0x51.toByte() || message[1] == 0x59.toByte() || message[1] == 0x6A.toByte())) {
Log.w("BesF2fBle", "面对面进行中,拦下模式命令 AA ${"%02X".format(message[1].toInt() and 0xFF)}")
return
}
if (F2fBleLink.sppLost || thread == null) {
if (f2fCmd == null) Log.w("BesF2fBle", "SPP 已断,控制命令丢弃 ${message.take(4).joinToString(" ") { "%02X".format(it.toInt() and 0xFF) }}")
return
}
}
if (thread == null) {
publishBluetoothStatus(0)
return
}
writeSppCtrl(message, null)
}
/**
* 控制命令直接排进 SPP 写队列(不经面对面拦截,F2fBleLink 发 AA 19 / 兜底 AA 67 用)。
* 返回 false = SPP 没连着。[label] 非空时写出后打一行 BesF2f 日志。
*/
internal fun writeSppCtrl(message: ByteArray, label: String?): Boolean {
if (thread == null) return false
ctrlWriteExecutor.execute {
val startedNs = System.nanoTime()
while (true) {
@ -765,10 +935,12 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
}
t.write(message)
lastCtrlWriteAtNs = System.nanoTime()
if (label != null) Log.w("BesF2f", "$label 已经 SPP 写出(等了 ${waitedMs}ms)")
if (waitedMs >= 100L) {
Log.d("BesCtrlWrite", "控制命令 0x${"%02X".format(message.getOrElse(1) { 0 })} 等了 ${waitedMs}ms 才发(让管道里的音频先发完)")
}
}
return true
}
/** 实时音频包(通话/AI 下行发送器专用):直发,不排队。见 writeBytes 的说明。 */
@ -784,6 +956,8 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
fun disconnect(result: Result) {
isUserInitiatedDisconnect = true
F2fBleLink.stop("用户断开", reconnectSpp = false)
F2fBleLink.pendingSppReconnect = false
cancelReconnect()
lastDeviceId = null
lastServiceUuid = null

7
apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt

@ -136,7 +136,12 @@ class BluetoothManagerPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
result.success(true)
}
"startCallDownlink" -> {
CallTranslationDownlink.start()
// 面对面(Echo-one)传 header=0xAA cmd=0x69 swap=true;不传即通话翻译 AA 56
CallTranslationDownlink.start(
header = call.argument<Int>("header") ?: 0xAA,
cmd = call.argument<Int>("cmd") ?: 0x56,
swap = call.argument<Boolean>("swapLegs") ?: false,
)
result.success(true)
}
// 通话翻译 PCM 原生直连桥(见 BluetoothManager.callPcmTap)。

120
apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/CallTranslationDownlink.kt

@ -161,11 +161,37 @@ object CallTranslationDownlink {
private var sentPackets = 0L
/**
* 包头两字节。通话翻译 `AA 56`;Echo-one 面对面 `AA 69`(2026-10-20 协议:启动 AA 66 / 结束 AA 67 /
* 上行 BB 68 / 下行 AA 69),包体布局不变:`[hdr] [cmd] 54 03 [slot0 40B][slot1 40B]`。
*/
@Volatile private var headerByte: Byte = 0xAA.toByte()
@Volatile private var cmdByte: Byte = 0x56.toByte()
/**
* 面对面:A 耳说的话译文要播到 **B 耳**,所以 leg A 进槽 0、leg B 进槽 1(与通话翻译相反)。
* 槽位与耳朵的对应以真机为准,反了只改调用方传的 swapLegs。
*/
@Volatile private var swapLegs = false
/** 发包配额(包数,可带小数),每拍加 TICK/interval */
private var budget = 0.0
private var wasIdle = true
private var tailLeft = 0
/**
* 面对面走 BLE 时每只耳机各自的起停状态:**不填静音**,哪只有译文才发哪只;
* 每只独立地「从空闲恢复先垫 [PREROLL_PACKETS] 包、说完续 [TAIL_TICKS] 拍」。
*/
private class EarTx {
var wasIdle = true
var tailLeft = 0
var sent = 0L
fun reset() { wasIdle = true; tailLeft = 0; sent = 0L }
}
private val earTxL = EarTx()
private val earTxR = EarTx()
/** 静音帧(deepvoice 原样照搬,耳机侧认这个码流) */
private val silenceFrame = byteArrayOf(
0x0A.toByte(), 0x8E.toByte(), 0x30.toByte(), 0xBD.toByte(), 0x3F.toByte(),
@ -179,8 +205,17 @@ object CallTranslationDownlink {
)
@Synchronized
fun start() {
if (running) return
fun start(header: Int = 0xAA, cmd: Int = 0x56, swap: Boolean = false) {
if (running) {
if (headerByte != header.toByte() || cmdByte != cmd.toByte() || swapLegs != swap) {
Log.w(TAG, "下行已在运行(%02X %02X swap=$swapLegs),忽略新参数 %02X %02X swap=$swap".format(
headerByte.toInt() and 0xFF, cmdByte.toInt() and 0xFF, header and 0xFF, cmd and 0xFF))
}
return
}
headerByte = header.toByte()
cmdByte = cmd.toByte()
swapLegs = swap
running = true
for (leg in arrayOf(legA, legB)) synchronized(leg) {
leg.reset()
@ -190,7 +225,9 @@ object CallTranslationDownlink {
// 每场从 20 ms 起步:上一场结束时耳机可能停在 16 ms,带着它起步会先超发一段
intervalMs = 20L
budget = 0.0; wasIdle = true; tailLeft = 0
earTxL.reset(); earTxR.reset()
scheduleSendTask()
Log.w(TAG, "下行发送启动 hdr=%02X %02X swap=$swapLegs".format(header and 0xFF, cmd and 0xFF))
if (!DEEPVOICE_PARITY) Log.d(TAG, "下行发送启动, interval=${intervalMs}ms 追赶策略: >${CATCHUP_START_SEC}s 起变速, ${CATCHUP_FULL_SEC}s 达 ${MAX_RATE}x, 裁静音 >${SILENCE_TRIM_START_SEC}s")
}
@ -294,7 +331,7 @@ object CallTranslationDownlink {
fun pushPcm(leg: String, pcm: ByteArray) {
if (!running || pcm.isEmpty()) return
val isA = leg.equals("A", ignoreCase = true) || leg.equals("uplink", ignoreCase = true)
val l = if (isA) legA else legB
val l = if (isA != swapLegs) legA else legB
synchronized(l) {
val n = pcm.size / 2
var i = 0
@ -454,8 +491,8 @@ object CallTranslationDownlink {
/** 两路都是编码静音的一包(垫底用) */
private fun writeIdlePacket() {
packetBuf[0] = 0xAA.toByte()
packetBuf[1] = 0x56.toByte()
packetBuf[0] = headerByte
packetBuf[1] = cmdByte
packetBuf[2] = PACKET_SIZE.toByte()
packetBuf[3] = 0x03.toByte()
for (i in 0 until FRAMES_PER_PACKET) {
@ -463,7 +500,7 @@ object CallTranslationDownlink {
System.arraycopy(idleFrame(legB), 0, packetBuf, base, FRAME_ENCODED_BYTES)
System.arraycopy(idleFrame(legA), 0, packetBuf, base + FRAME_ENCODED_BYTES, FRAME_ENCODED_BYTES)
}
BluetoothManager.writeRealtime(packetBuf)
deliver()
sentPackets++
}
@ -472,6 +509,7 @@ object CallTranslationDownlink {
private val frameA = arrayOfNulls<ByteArray>(FRAMES_PER_PACKET)
private fun emitPacket(): Boolean {
if (cmdByte == F2F_CMD && F2fBleLink.sessionActive) return emitF2f()
var any = false
for (i in 0 until FRAMES_PER_PACKET) {
// 编码结果是共享缓冲,必须立刻拷走
@ -494,8 +532,8 @@ object CallTranslationDownlink {
}
}
packetBuf[0] = 0xAA.toByte()
packetBuf[1] = 0x56.toByte()
packetBuf[0] = headerByte
packetBuf[1] = cmdByte
packetBuf[2] = PACKET_SIZE.toByte()
packetBuf[3] = 0x03.toByte()
// 空闲那一路用它自己的编码器编一帧零:G.722 是自适应差分编码,塞一段固定码流会让
@ -505,14 +543,78 @@ object CallTranslationDownlink {
System.arraycopy(frameB[i] ?: idleFrame(legB), 0, packetBuf, base, FRAME_ENCODED_BYTES)
System.arraycopy(frameA[i] ?: idleFrame(legA), 0, packetBuf, base + FRAME_ENCODED_BYTES, FRAME_ENCODED_BYTES)
}
BluetoothManager.writeRealtime(packetBuf)
deliver()
sentPackets++
if (cmdByte == F2F_CMD && (sentPackets <= 6L || sentPackets % 250 == 0L)) {
Log.w(TAG, "下行#$sentPackets 头=" + packetBuf.copyOfRange(0, 8).joinToString(" ") { "%02X".format(it.toInt() and 0xFF) } +
" 槽0=${if (frameB[0] != null) "音" else "静"} 槽1=${if (frameA[0] != null) "音" else "静"}")
}
if (!DEEPVOICE_PARITY && (sentPackets == 1L || sentPackets % 100 == 0L)) {
logProgress()
}
return true
}
/** 把组好的 [packetBuf] 经 SPP 发出去(面对面走 BLE 时不经过这里,见 [emitF2f]) */
private fun deliver() {
BluetoothManager.writeRealtime(packetBuf)
}
/** 面对面下行 cmd(`AA 69`;包头和通话翻译一样是 AA,只能按 cmd 区分) */
private const val F2F_CMD: Byte = 0x69.toByte()
private const val SINGLE_PACKET_SIZE = 4 + FRAME_ENCODED_BYTES
/**
* Echo-one 面对面走 BLE([F2fBleLink.sessionActive]):左右耳各一条 GATT,各发各的单路包
* `AA 69 2C 01 [40B]`(与上行同惯例:第 3 字节整包长、mode 01 单路):
* - 左耳 ← leg A 的帧(原双槽包的槽 1):右耳那人说的话的译文
* - 右耳 ← leg B 的帧(原双槽包的槽 0):左耳那人说的话的译文
* 两耳各自起停,不给没译文的那只填静音。返回这一拍是否至少发了一只。
* ⇢ 按 FRAMES_PER_PACKET = 1 写的(一拍一帧)。
*/
private fun emitF2f(): Boolean {
// 编码结果是共享缓冲,必须立刻拷走
val a = nextEncodedFrame(legA)?.copyOf()
val b = nextEncodedFrame(legB)?.copyOf()
val l = emitEar(earTxL, isLeft = true, leg = legA, frame = a)
val r = emitEar(earTxR, isLeft = false, leg = legB, frame = b)
if (!l && !r) return false
sentPackets++
if (sentPackets <= 6L || sentPackets % 250 == 0L) {
Log.w(TAG, "面对面下行#$sentPackets 左耳=${if (a != null) "音" else if (l) "尾" else "-"}(${earTxL.sent}) " +
"右耳=${if (b != null) "音" else if (r) "尾" else "-"}(${earTxR.sent})")
}
if (!DEEPVOICE_PARITY && (sentPackets == 1L || sentPackets % 100 == 0L)) logProgress()
return true
}
private fun emitEar(e: EarTx, isLeft: Boolean, leg: Leg, frame: ByteArray?): Boolean {
if (frame == null) {
if (e.tailLeft <= 0) { e.wasIdle = true; return false }
e.tailLeft--
} else {
e.tailLeft = TAIL_TICKS
if (e.wasIdle) {
// 从空闲恢复:先垫编码静音让耳机在静音上起播(同 SPP 路径的说明)
e.wasIdle = false
repeat(PREROLL_PACKETS) { F2fBleLink.writeEarAudio(isLeft, singlePacket(idleFrame(leg))) }
}
}
F2fBleLink.writeEarAudio(isLeft, singlePacket(frame ?: idleFrame(leg)))
e.sent++
return true
}
private fun singlePacket(frame: ByteArray): ByteArray {
val p = ByteArray(SINGLE_PACKET_SIZE)
p[0] = headerByte
p[1] = cmdByte
p[2] = SINGLE_PACKET_SIZE.toByte()
p[3] = 0x01
System.arraycopy(frame, 0, p, 4, FRAME_ENCODED_BYTES)
return p
}
private fun logProgress() {
run {
Log.d(TAG, "已下发 $sentPackets 包 (积压 A=%.1fs B=%.1fs 速率 A=%.2f B=%.2f 断粮 A=${legA.starved} B=${legB.starved} 节奏=${intervalMs}ms)".format(

1070
apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/F2fBleLink.kt

File diff suppressed because it is too large

126
apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManager.swift

@ -20,6 +20,11 @@ public final class BluetoothManager: NSObject {
sharedInstance?.write(data)
}
/// 面对面链路上报(f2fLink / 上行 PCM)。与通话翻译走同一个 translationCallback。
public static func publishF2f(_ event: [String: Any]) {
sharedInstance?.publishCallTranslation(event)
}
/// 实时音频专用写入口(通话翻译下行)。与 [writeBytes] 的差别只在统计口径,
/// 两者现在都是无条件直发 —— 见 enqueueOrSendNoResp 里那段死锁说明。
public static func writeRealtimeAudio(_ data: Data) {
@ -76,6 +81,9 @@ public final class BluetoothManager: NSObject {
// App 内已配对授权的设备 UUID 白名单;只有命中的设备才会通过系统 BR/EDR 连接事件自动接管
private var authorizedDeviceIds: Set<UUID> = []
/// 面对面结束回连经典蓝牙用。断连清理不清这个。
private var lastClassicId: UUID?
private var classicReconnectHandler: ((Bool) -> Void)?
// MARK: - 帧解析私有属性
@ -128,9 +136,27 @@ public final class BluetoothManager: NSObject {
// MARK: - 公共接口
/// 面对面收到 `BB 99` 之后调用:关掉当前这条 GATT over BR/EDR。
/// 不断开页面上的「已连接」——会话中的断连由 `finishClassicDown` 吞掉。
/// 面对面结束仍走原来的回连。非面对面不要调这个。
public func suspendClassicForFaceToFace() {
runOnBleQueue { [weak self] in
guard let self else { return }
guard let p = self.peripheral else {
os_log("面对面:经典 GATT 已经不在,无需再关", log: Self.bleLog, type: .default)
return
}
os_log("面对面:关闭经典 GATT over BR/EDR %{public}@", log: Self.bleLog, type: .default, p.identifier.uuidString)
self.central.cancelPeripheralConnection(p)
}
}
public func disconnect() {
F2fBleLink.shared.stop("用户断开", reconnect: false)
F2fBleLink.shared.pendingClassicReconnect = false
runOnBleQueue { [weak self] in
guard let self else { return }
self.classicReconnectHandler = nil
self.processingQueue.async { [weak self] in self?.releaseCodecs() }
guard let p = self.peripheral else { return }
setConnectedFlag(false) // 主动断开即置 false,不等 didDisconnect 回调
@ -181,6 +207,7 @@ public final class BluetoothManager: NSObject {
/// didRequestServiceDiscovery=true / 旧 characteristic 引用会让 didConnect
/// 跳过服务发现,导致"连上但收发不通"。若外设已经连接,iOS 会立即回调 didConnect。
private func attachAndConnect(_ peripheral: CBPeripheral) {
lastClassicId = peripheral.identifier
resetConnectionState()
self.peripheral = peripheral
// 若外设在系统层已是 .connected(retrievePeripherals 可能返回已连接实例),立即把标志置真,
@ -214,7 +241,8 @@ public final class BluetoothManager: NSObject {
}
}
public func write(_ data: Data, realtime: Bool = false) {
public func write(_ data: Data, realtime: Bool = false, bypassF2f: Bool = false) {
if !bypassF2f && !realtime && F2fBleLink.shared.interceptClassicWrite(data) { return }
runOnBleQueue { [weak self] in
guard let self, let p = self.peripheral else { return }
guard let tx = self.txCharacteristic, self.isGattReady() else {
@ -239,6 +267,38 @@ public final class BluetoothManager: NSObject {
connectedFlagLock.lock(); connectedFlag = value; connectedFlagLock.unlock()
}
/// 面对面发 `AA 19` 用:直接写经典 GATT,不再进 AA 66/67 拦截。链路不在返回 false。
@discardableResult
public func writeClassicCtrl(_ data: Data, label: String?) -> Bool {
guard isConnected else { return false }
write(data, bypassF2f: true)
if let label {
os_log("%{public}@ 已经经典蓝牙写出", log: Self.bleLog, type: .default, label)
}
return true
}
/// 面对面结束、经典蓝牙断过:用上次的外设回连。完成回调一定来一次。
public func reconnectClassicAfterF2f(_ onDone: @escaping (Bool) -> Void) {
runOnBleQueue { [weak self] in
guard let self, let id = self.lastClassicId else { onDone(false); return }
if self.isGattReady() { onDone(true); return }
self.classicReconnectHandler = onDone
guard let peripheral = self.central.retrievePeripherals(withIdentifiers: [id]).first else {
self.classicReconnectHandler = nil
onDone(false)
return
}
os_log("面对面结束,回连经典蓝牙 %{public}@", log: Self.bleLog, type: .default, id.uuidString)
self.attachAndConnect(peripheral)
self.bleQueue.asyncAfter(deadline: .now() + 8) { [weak self] in
guard let self, let h = self.classicReconnectHandler else { return }
self.classicReconnectHandler = nil
h(false)
}
}
}
public var isConnected: Bool {
if DispatchQueue.getSpecific(key: bleQueueKey) == true {
return peripheral?.state == .connected
@ -416,6 +476,11 @@ public final class BluetoothManager: NSObject {
let rxReady = (rxCharacteristic == nil) || (rxCharacteristic?.isNotifying == true)
if txReady && rxReady {
isReadyNotified = true
if let h = classicReconnectHandler {
classicReconnectHandler = nil
h(true)
}
F2fBleLink.shared.onClassicConnected()
onConnected?(p)
}
}
@ -487,8 +552,16 @@ public final class BluetoothManager: NSObject {
// 调速命令:翻译与 AI 两条链路共用(两回调互斥,激活的处理、另一条为 nil 忽略)
publishCallTranslation(["type": "speedAdjustment", "data": frame])
publishAIData(["type": "speedAdjustment", "data": frame])
case 0x55, 0x68, 0x60:
case 0x55, 0x60:
g722DecodeData(frame)
case 0x68:
// 面对面进行中上行走 BLE 双耳,经典链路上的 BB 68 不再解,避免和 BLE 叠成两路。
if !F2fBleLink.shared.sessionActive { g722DecodeData(frame) }
case 0x99:
os_log("收到 AA 19 应答,会话中=%{public}@", log: Self.bleLog, type: .default,
F2fBleLink.shared.sessionActive ? "是" : "否")
F2fBleLink.shared.onLinkAck(frame)
publishCmd(["type": "unknown", "data": frame])
case 0xD9:
publishCallTranslation(["type": "startMicRecording", "data": frame])
publishCmd(["type": "startMicRecording", "data": frame])
@ -503,9 +576,16 @@ public final class BluetoothManager: NSObject {
publishCmd(["type": "stopMusic", "data": frame])
case 0x5B:
msbcDecodeData(frame)
case 0x66:
// 耳机按键发起:原生走 AA 19 → BLE 握手 → AA 66。Dart 随后回发的 AA 66 会被 start 去重。
if F2fBleLink.shared.start("耳机经经典蓝牙上报 BB 66") {
publishCallTranslation(["type": "startFaceToFace", "data": frame])
} else {
write(Data([0xAA, 0x67]), bypassF2f: true)
}
case 0xE6:
publishCallTranslation(["type": "startFaceToFace", "data": frame])
case 0xE7:
case 0xE7, 0x67:
releaseCodecs()
publishCallTranslation(["type": "stopFaceToFace", "data": frame])
case 0x64:
@ -722,6 +802,12 @@ extension BluetoothManager: CBCentralManagerDelegate {
for peripheral: CBPeripheral) {
switch event {
case .peerConnected:
// 面对面进行中旧通道必须让开,只留左右耳 BLE。结束后 sessionActive 变回 false,
// 这里恢复成原来的接管,不改变非面对面的连接。
if F2fBleLink.existing?.sessionActive == true {
os_log("面对面进行中,不重新接管经典 GATT %{public}@", log: Self.bleLog, type: .default, peripheral.identifier.uuidString)
return
}
// 只接管用户在 App 配对页主动添加过的设备;
// 否则仅仅系统级配对但 App 未授权的耳机不会被自动视为"已连接"。
guard authorizedDeviceIds.contains(peripheral.identifier) else { return }
@ -730,12 +816,7 @@ extension BluetoothManager: CBCentralManagerDelegate {
attachAndConnect(peripheral)
case .peerDisconnected:
// 系统级 BR/EDR 断连;若正是当前连接设备,主动通知 Dart 层状态为 0。
guard self.peripheral?.identifier == peripheral.identifier else { return }
self.peripheral = nil
resetConnectionState()
onDisconnected?(peripheral, nil)
emitAiDisconnected()
finishClassicDown(peripheral, error: nil, failed: false)
@unknown default:
break
@ -759,19 +840,30 @@ extension BluetoothManager: CBCentralManagerDelegate {
public func centralManager(_ central: CBCentralManager,
didFailToConnect peripheral: CBPeripheral, error: Error?) {
guard self.peripheral?.identifier == peripheral.identifier else { return }
self.peripheral = nil
resetConnectionState()
onDisconnected?(peripheral, error)
emitAiDisconnected()
finishClassicDown(peripheral, error: error, failed: true)
}
public func centralManager(_ central: CBCentralManager,
didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
// 只处理当前追踪的 peripheral;若 .peerDisconnected 已先行清理并派发过
// onDisconnected,self.peripheral 已为 nil,这里静默返回避免重复通知。
guard self.peripheral?.identifier == peripheral.identifier else { return }
finishClassicDown(peripheral, error: error, failed: false)
}
/// 经典蓝牙掉线。面对面切 BLE 期间这是预期的,不上报 Dart,留给结束后回连。
private func finishClassicDown(_ peripheral: CBPeripheral, error: Error?, failed: Bool) {
guard self.peripheral?.identifier == peripheral.identifier else { return }
if failed, let h = classicReconnectHandler {
classicReconnectHandler = nil
self.peripheral = nil
resetConnectionState()
h(false)
return
}
if F2fBleLink.shared.suppressClassicDown {
F2fBleLink.shared.noteClassicLost()
self.peripheral = nil
resetConnectionState()
return
}
self.peripheral = nil
resetConnectionState()
onDisconnected?(peripheral, error)

7
apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift

@ -252,7 +252,12 @@ public class BluetoothManagerPlugin: NSObject, FlutterPlugin {
result(true)
case "startCallDownlink":
CallTranslationDownlink.shared.start()
// 面对面(Echo-one)传 header=0xAA cmd=0x69 swapLegs=true;不传即通话翻译 AA 56
let args = call.arguments as? [String: Any]
CallTranslationDownlink.shared.start(
header: UInt8(truncatingIfNeeded: (args?["header"] as? Int) ?? 0xAA),
cmd: UInt8(truncatingIfNeeded: (args?["cmd"] as? Int) ?? 0x56),
swapLegs: (args?["swapLegs"] as? Bool) ?? false)
result(true)
// 通话翻译 PCM 原生直连桥(见 CallPcmBridge.swift)。

79
apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/CallTranslationDownlink.swift

@ -125,11 +125,29 @@ public final class CallTranslationDownlink {
private var intervalMs: Int = 20
private var sentPackets = 0
/// 包头两字节:通话翻译 `AA 56`,Echo-one 面对面 `AA 69`(与 Android 端对齐)
private var headerByte: UInt8 = 0xAA
private var cmdByte: UInt8 = 0x56
/// 面对面:leg A 进槽 0、leg B 进槽 1(与通话翻译相反)
private var swapLegs = false
/// 发包配额(包数,可带小数),每拍加 tickMs/intervalMs
private var budget = 0.0
private var wasIdle = true
private var tailLeft = 0
/// 面对面 BLE:每只耳机各自起停,不给没译文的那只填静音。
private final class EarTx {
var wasIdle = true
var tailLeft = 0
var sent = 0
func reset() { wasIdle = true; tailLeft = 0; sent = 0 }
}
private let earTxL = EarTx()
private let earTxR = EarTx()
private static let f2fCmd: UInt8 = 0x69
private static let singlePacketSize = 4 + frameEncodedBytes
// MARK: - 流畅度观测(release 包里只有 os_log 抓得到)
private static let dlLog = OSLog(subsystem: "com.eaimar.bluetooth", category: "downlink")
private var lastTickAt: CFAbsoluteTime = 0
@ -150,9 +168,12 @@ public final class CallTranslationDownlink {
private init() {}
public func start() {
public func start(header: UInt8 = 0xAA, cmd: UInt8 = 0x56, swapLegs swap: Bool = false) {
queue.async {
guard !self.running else { return }
self.headerByte = header
self.cmdByte = cmd
self.swapLegs = swap
self.running = true
for leg in [self.legA, self.legB] {
leg.reset()
@ -162,6 +183,7 @@ public final class CallTranslationDownlink {
// 每场从 20ms 起步:上一场结束时耳机可能停在 16ms
self.intervalMs = 20
self.budget = 0; self.wasIdle = true; self.tailLeft = 0
self.earTxL.reset(); self.earTxR.reset()
self.lastTickAt = 0
self.tickGapSumMs = 0; self.tickGapCount = 0; self.tickGapMaxMs = 0
self.peakQueueA = 0; self.peakQueueB = 0
@ -290,7 +312,7 @@ public final class CallTranslationDownlink {
let isA = leg.uppercased() == "A" || leg.lowercased() == "uplink"
queue.async {
guard self.running else { return }
let l = isA ? self.legA : self.legB
let l = (isA != self.swapLegs) ? self.legA : self.legB
let n = pcm.count / 2
var i = 0
pcm.withUnsafeBytes { (raw: UnsafeRawBufferPointer) in
@ -453,7 +475,7 @@ public final class CallTranslationDownlink {
/// 两路都是编码静音的一包(垫底用)
private func writeIdlePacket() {
var packet = Data(capacity: Self.packetSize)
packet.append(contentsOf: [0xAA, 0x56, UInt8(Self.packetSize), 0x03])
packet.append(contentsOf: [headerByte, cmdByte, UInt8(Self.packetSize), 0x03])
packet.append(idleFrame(legB))
packet.append(idleFrame(legA))
BluetoothManager.writeRealtimeAudio(packet)
@ -462,6 +484,7 @@ public final class CallTranslationDownlink {
/// 发一包;两路都空且尾巴续完时返回 false(真正空闲,不发)
private func emitPacket() -> Bool {
if cmdByte == Self.f2fCmd && F2fBleLink.shared.sessionActive { return emitF2f() }
let b = nextEncodedFrame(legB)
let a = nextEncodedFrame(legA)
if b == nil && a == nil {
@ -476,7 +499,7 @@ public final class CallTranslationDownlink {
}
var packet = Data(capacity: Self.packetSize)
packet.append(contentsOf: [0xAA, 0x56, UInt8(Self.packetSize), 0x03])
packet.append(contentsOf: [headerByte, cmdByte, UInt8(Self.packetSize), 0x03])
packet.append(b ?? idleFrame(legB))
packet.append(a ?? idleFrame(legA))
BluetoothManager.writeRealtimeAudio(packet)
@ -496,6 +519,54 @@ public final class CallTranslationDownlink {
return true
}
/// Echo-one 面对面:左耳播 leg A(右耳那人的译文),右耳播 leg B(左耳那人的译文)。
/// 槽位对调由 start(swapLegs:) 在 pushPcm 时完成。一拍一帧,`AA 69 2C 01` + 40B。
private func emitF2f() -> Bool {
let a = nextEncodedFrame(legA)
let b = nextEncodedFrame(legB)
let l = emitEar(earTxL, isLeft: true, leg: legA, frame: a)
let r = emitEar(earTxR, isLeft: false, leg: legB, frame: b)
if !l && !r { return false }
sentPackets += 1
if sentPackets <= 6 || sentPackets % 250 == 0 {
os_log("面对面下行#%d 左耳=%{public}@(%d) 右耳=%{public}@(%d)",
log: Self.dlLog, type: .default, sentPackets,
a != nil ? "音" : (l ? "尾" : "-"), earTxL.sent,
b != nil ? "音" : (r ? "尾" : "-"), earTxR.sent)
}
return true
}
private func emitEar(_ e: EarTx, isLeft: Bool, leg: Leg, frame: Data?) -> Bool {
if frame == nil {
if e.tailLeft <= 0 { e.wasIdle = true; return false }
e.tailLeft -= 1
} else {
e.tailLeft = Self.tailTicks
if e.wasIdle {
e.wasIdle = false
let idle = singlePacket(idleFrame(leg))
for _ in 0..<Self.prerollPackets {
_ = F2fBleLink.shared.writeEarAudio(isLeft: isLeft, pkt: idle)
}
}
}
let pkt = singlePacket(frame ?? idleFrame(leg))
_ = F2fBleLink.shared.writeEarAudio(isLeft: isLeft, pkt: pkt)
e.sent += 1
return true
}
private func singlePacket(_ frame: Data) -> Data {
var p = Data(capacity: Self.singlePacketSize)
p.append(contentsOf: [headerByte, cmdByte, UInt8(Self.singlePacketSize), 0x01])
p.append(frame.prefix(Self.frameEncodedBytes))
if p.count < Self.singlePacketSize {
p.append(Data(count: Self.singlePacketSize - p.count))
}
return p
}
/// WSOLA 变速不变调(Verhelst & Roelands),与 Android `Wsola` 同一套参数与步骤。
/// 合成侧固定步长 S(10ms)、帧长 N(20ms)、Hann 50% 重叠相加;分析侧步长 = S × rate,
/// 每帧在名义位置 ±T 内搜索与「上一帧自然延续」最相似的起点。rate=1.0 时恒等于原信号。

965
apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/F2fBleLink.swift

@ -0,0 +1,965 @@
import Foundation
import CoreBluetooth
import os.log
/// Echo-one 面对面的 BLE 双耳链路,对齐 Android `F2fBleLink.kt`(协议 v0.4.1)。
///
/// iOS 上现有耳机链路是 GATT over BR/EDR,不是 SPP。`AA 19` 走这条经典 GATT 写特征;
/// 左右耳 `Echo-one-L` / `Echo-one-R` 另起一个不带状态恢复的 LE central,避免和经典蓝牙抢同一个 central。
/// Dart 仍只发 `AA 66` / `AA 67`。
public final class F2fBleLink: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
/// 只有进过面对面才有实例。普通连接、电量查询、断开都读 [existing],
/// 避免一碰 `.shared` 就把第二个 CBCentralManager 建出来,把经典 GATT 挤掉。
private static var _live: F2fBleLink?
public static var existing: F2fBleLink? { _live }
public static var shared: F2fBleLink {
if let live = _live { return live }
let created = F2fBleLink()
_live = created
return created
}
private static let log = OSLog(subsystem: "com.eaimar.bluetooth", category: "BesF2fBle")
private let serviceUUID = CBUUID(string: "00001100-d102-11e1-9b23-00025b00afae")
private let writeUUID = CBUUID(string: "00001101-d102-11e1-9b23-00025b00afae")
private let notifyUUID = CBUUID(string: "00001102-d102-11e1-9b23-00025b00afae")
private let scanTimeout: TimeInterval = 15
/// `AA 19` 写出后等 `BB 99` 的上限。到点还没回应,不开扫、不连。
private let linkAckTimeout: TimeInterval = 3
/// 收到 `BB 99` 之后再等这么久才开扫、再连左右耳。
private let linkScanDelay: TimeInterval = 1
/// 等于当前 sessionToken 时,startScan 先不开。`BB 99` 之后的 1 秒里也按住。
private var scanBlockedToken = 0
private let connectTimeout: TimeInterval = 10
private let earRecover: TimeInterval = 10
private let failStop: TimeInterval = 3
private let statsInterval: TimeInterval = 5
private let uplinkSilent: TimeInterval = 1.5
private let startAckWait: TimeInterval = 1.5
private let linkSwitchGap: TimeInterval = 0.3
private let gattCloseDelay: TimeInterval = 0.4
private let classicReconnectDelay: TimeInterval = 1.5
private let recoverCooldown: TimeInterval = 2
private let recoverCap: TimeInterval = 30
private let writeQueueCap = 8
private let gattRetries = 3
private let leftName = "Echo-one-L"
private let rightName = "Echo-one-R"
private let prefsExit = "f2f.exitPending"
private let leQueue = DispatchQueue(label: "f2f.ble", qos: .userInitiated)
private let leQueueKey = DispatchSpecificKey<Bool>()
private let flagLock = NSLock()
private var leCentral: CBCentralManager!
private var _sessionActive = false
private var _recovering = false
private var _classicLost = false
private var _pendingClassicReconnect = false
public var sessionActive: Bool { flagLock.lock(); defer { flagLock.unlock() }; return _sessionActive }
public var recovering: Bool { flagLock.lock(); defer { flagLock.unlock() }; return _recovering }
public var classicLost: Bool {
get { flagLock.lock(); defer { flagLock.unlock() }; return _classicLost }
set { flagLock.lock(); _classicLost = newValue; flagLock.unlock() }
}
public var pendingClassicReconnect: Bool {
get { flagLock.lock(); defer { flagLock.unlock() }; return _pendingClassicReconnect }
set { flagLock.lock(); _pendingClassicReconnect = newValue; flagLock.unlock() }
}
public var suppressClassicDown: Bool { sessionActive || pendingClassicReconnect }
private enum Phase { case scan, connect, live }
private var phase = Phase.scan
private var sessionToken = 0
private var failed = false
private var stopping = false
private var recoverToken = 0
private var sessionStart = CFAbsoluteTimeGetCurrent()
private var scanning = false
private var seen = Set<UUID>()
private var candidates: [UUID: (CBPeripheral, String, Bool)] = [:]
private var left: Ear?
private var right: Ear?
private var scanTimer: DispatchWorkItem?
private var linkAckTimer: DispatchWorkItem?
private var scanDelayTimer: DispatchWorkItem?
private var connectTimer: DispatchWorkItem?
private var statsTimer: DispatchWorkItem?
private var silenceTimer: DispatchWorkItem?
private var recoverTimer: DispatchWorkItem?
private final class Ear {
let side: String
let peripheral: CBPeripheral
let name: String
let type: String
let label: String
let exec = DispatchQueue(label: "f2f.ble.ear")
var tx: CBCharacteristic?
var rx: CBCharacteristic?
var useWithResponse = false
var ready = false
var closed = false
var retries = 0
var recovering = false
var startAcked = false
var lastConnect = CFAbsoluteTimeGetCurrent()
var state = ""
var writeQ: [Data] = []
var writing = false
var stash = Data()
var codec: G722Codec?
var rawMode = false
var notifies: Int64 = 0
var lastNotify = CFAbsoluteTimeGetCurrent()
var frames: Int64 = 0
var pcmFrames: Int64 = 0
var unknown: Int64 = 0
var sent: Int64 = 0
var dropped: Int64 = 0
var writeFail: Int64 = 0
var lost = 0
init(side: String, peripheral: CBPeripheral, name: String) {
self.side = side
self.peripheral = peripheral
self.name = name
self.type = side == "L" ? "micData" : "spkData"
self.label = side == "L" ? "左耳" : "右耳"
}
func summary() -> String {
"\(label) \(peripheral.identifier.uuidString) 通知=\(notifies) 上行帧=\(frames) PCM=\(pcmFrames) 下发=\(sent) 丢=\(dropped) 写失败=\(writeFail) 掉线=\(lost)"
}
}
private override init() {
super.init()
leQueue.setSpecific(key: leQueueKey, value: true)
leCentral = CBCentralManager(delegate: self, queue: leQueue)
}
// MARK: - 经典蓝牙写入拦截(BluetoothManager.write 调用)
/// 返回 true = 这条经典蓝牙写已经被面对面吃掉。
public func interceptClassicWrite(_ data: Data) -> Bool {
let b = [UInt8](data)
let cmd: UInt8? = (b.count == 2 && b[0] == 0xAA && (b[1] == 0x66 || b[1] == 0x67)) ? b[1] : nil
if cmd == 0x66 {
_ = start("App 下发 AA 66")
return true
}
if sessionActive {
if cmd == 0x67 {
requestStop("App 下发 AA 67")
return true
}
if b.count >= 2 && b[0] == 0xAA && (b[1] == 0x51 || b[1] == 0x59 || b[1] == 0x6A) {
os_log("面对面进行中,拦下模式命令 AA %02X", log: Self.log, type: .default, b[1])
return true
}
if classicLost {
if cmd == nil {
let head = b.prefix(4).map { String(format: "%02X", $0) }.joined(separator: " ")
os_log("经典蓝牙已断,控制命令丢弃 %{public}@", log: Self.log, type: .default, head)
}
return true
}
}
return false
}
@discardableResult
public func start(_ reason: String) -> Bool {
flagLock.lock()
if _sessionActive { flagLock.unlock(); return true }
if _recovering {
flagLock.unlock()
os_log("上一场面对面还在恢复中,拒绝进入(%{public}@)", log: Self.log, type: .default, reason)
publishOverall("failed", extra: ["reason": "recovering"])
return false
}
_sessionActive = true
_classicLost = false
_pendingClassicReconnect = false
flagLock.unlock()
onLe {
self.failed = false
self.stopping = false
self.phase = .scan
self.sessionToken += 1
self.sessionStart = CFAbsoluteTimeGetCurrent()
self.left = nil
self.right = nil
self.seen.removeAll()
self.candidates.removeAll()
UserDefaults.standard.set(true, forKey: self.prefsExit)
os_log("面对面 BLE 会话开始(%{public}@),经经典蓝牙发一帧 AA 19 06 02 01 01 切 BLE", log: Self.log, type: .default, reason)
self.publishSide("L", "scanning")
self.publishSide("R", "scanning")
let token = self.sessionToken
let frame = self.linkFrame(value: 1)
if BluetoothManager.sharedInstance?.writeClassicCtrl(frame, label: "AA 19 切 BLE(\(self.hex(frame, 8)))") != true {
self.fail("noSpp", "经典蓝牙未连接,AA 19 发不出去")
return
}
self.scanBlockedToken = token
self.armLinkAck(token)
self.diag("AA 19 已写出,等设备 BB 99;收到后再等 1 秒扫描并连接左右耳")
}
return true
}
public func requestStop(_ reason: String) {
flagLock.lock()
let active = _sessionActive
flagLock.unlock()
guard active else { return }
onLe {
self.linkAckTimer?.cancel()
self.linkAckTimer = nil
self.scanDelayTimer?.cancel()
self.scanDelayTimer = nil
if self.stopping { return }
self.stopping = true
self.left?.writeQ.removeAll()
self.right?.writeQ.removeAll()
let n = self.writeCtrl(Data([0xAA, 0x67]))
if n > 0 {
os_log("AA 67 经 BLE 发给 %d 只耳机,300ms 后发 AA 19 00(%{public}@)", log: Self.log, type: .default, n, reason)
self.onLe(after: self.linkSwitchGap) {
let f = self.linkFrame(value: 0)
let m = self.writeCtrl(f, front: false)
os_log("AA 19 00 经 BLE 发给 %d 只耳机 %{public}@", log: Self.log, type: .default, m, self.hex(f, 8))
self.onLe(after: self.gattCloseDelay) { self.stop(reason, reconnect: true, exitDelivered: m > 0) }
}
return
}
let back = self.linkFrame(value: 0)
let via = !self.classicLost
&& BluetoothManager.sharedInstance?.writeClassicCtrl(Data([0xAA, 0x67]), label: "AA 67") == true
&& BluetoothManager.sharedInstance?.writeClassicCtrl(back, label: "AA 19 00") == true
os_log("没有就绪的 BLE 耳机,AA 67 + AA 19 00 %{public}@(%{public}@)",
log: Self.log, type: .default, via ? "改经经典蓝牙发" : "经典蓝牙也不通,发不出去", reason)
self.onLe(after: self.gattCloseDelay) { self.stop(reason, reconnect: true, exitDelivered: via) }
}
}
public func stop(_ reason: String, reconnect: Bool = true, exitDelivered: Bool = true) {
onLe { self.stopOnLe(reason, reconnect: reconnect, exitDelivered: exitDelivered) }
}
public func onLinkAck(_ data: Data) {
onLe {
let status = self.linkAckStatus(data)
let statusText = status.map { String(format: "%02X", $0) } ?? "无"
self.diag("收到 BB 99 \(self.hex(data, 16)) status=\(statusText)")
let token = self.scanBlockedToken
guard token != 0, token == self.sessionToken, self.sessionActive, !self.stopping, !self.failed else { return }
guard self.scanDelayTimer == nil else { return }
self.linkAckTimer?.cancel()
self.linkAckTimer = nil
// 通道切换应答已经拿到,旧的 GATT over BR/EDR 让开。立刻记成已断开,
// 结束时恢复期才会回连;断连回调晚到也没关系。
self.noteClassicLost()
BluetoothManager.sharedInstance?.suspendClassicForFaceToFace()
self.diag("已关闭经典 GATT over BR/EDR,\(Int(self.linkScanDelay * 1000)) 毫秒后只扫左右耳 BLE")
let work = DispatchWorkItem { [weak self] in
self?.beginScan(token: token)
}
self.scanDelayTimer = work
self.leQueue.asyncAfter(deadline: .now() + self.linkScanDelay, execute: work)
}
}
/// `BB 99 04 <status>`。`00` 才是切到 BLE 成功。
private func linkAckStatus(_ data: Data) -> UInt8? {
let b = [UInt8](data)
guard b.count >= 4, b[0] == 0xBB, b[1] == 0x99 else { return nil }
return b[3]
}
public func onClassicConnected() {
onLe {
if self.sessionActive { return }
guard UserDefaults.standard.bool(forKey: self.prefsExit) else { return }
UserDefaults.standard.set(false, forKey: self.prefsExit)
os_log("上一场面对面异常退出(AA 19 00 未送达),经典蓝牙已连上,不补发", log: Self.log, type: .default)
}
}
public func noteClassicLost() {
if !classicLost {
classicLost = true
os_log("面对面 BLE 期间经典蓝牙断开,按预期忽略(不上报断连)", log: Self.log, type: .default)
}
}
@discardableResult
public func writeEarAudio(isLeft: Bool, pkt: Data) -> Bool {
var ok = false
onLeSync {
let ear = isLeft ? self.left : self.right
let other = isLeft ? self.right : self.left
guard let ear, ear.ready, other?.ready != false else { return }
ok = self.enqueue(ear, pkt, front: false)
}
return ok
}
// MARK: - AA 19
/// `AA 19 06 02 01 <value>`。切 BLE 时 value=1,切回经典时 value=0。只发这一帧。
private func linkFrame(value: UInt8) -> Data {
Data([0xAA, 0x19, 0x06, 0x02, 0x01, value])
}
private func startFrame() -> Data { Data([0xAA, 0x66, 0x06, 0x02, 0x01, 0x01]) }
// MARK: - 扫描 / 连接
public func centralManagerDidUpdateState(_ central: CBCentralManager) {
guard sessionActive, phase == .scan, !scanning, central.state == .poweredOn else {
if sessionActive && central.state != .poweredOn && central.state != .unknown && central.state != .resetting {
fail("bluetoothOff", "蓝牙不可用,无法扫描 BLE")
}
return
}
startScan()
}
/// `BB 99` 之后的 1 秒到了:开扫,扫到左右耳再连 GATT。
private func beginScan(token: Int) {
scanDelayTimer = nil
linkAckTimer?.cancel()
linkAckTimer = nil
guard sessionToken == token, sessionActive, !stopping, !failed else {
diag("等待结束时这场已停,不再扫描")
return
}
if scanBlockedToken == token { scanBlockedToken = 0 }
diag("开始扫描 BLE central.state=\(leCentral.state.rawValue)(5=开)")
startScan()
if !scanning {
diag("扫描没有启动 central.state=\(leCentral.state.rawValue)")
}
arm(after: scanTimeout, token: token) { [weak self] in self?.onScanTimeout() }
armStats(token)
armSilence(token)
}
private func armLinkAck(_ token: Int) {
linkAckTimer?.cancel()
let work = DispatchWorkItem { [weak self] in
guard let self, self.scanBlockedToken == token, self.sessionToken == token,
self.sessionActive, !self.stopping, !self.failed else { return }
self.fail("linkAck", "AA 19 发出后 \(Int(self.linkAckTimeout)) 秒没有收到 BB 99,不扫描 BLE")
}
linkAckTimer = work
leQueue.asyncAfter(deadline: .now() + linkAckTimeout, execute: work)
}
private func startScan() {
guard sessionActive, !scanning else { return }
guard scanBlockedToken != sessionToken else { return }
guard leCentral.state == .poweredOn else { return }
leCentral.scanForPeripherals(withServices: nil, options: [CBCentralManagerScanOptionAllowDuplicatesKey: false])
scanning = true
}
private func stopScan() {
guard scanning else { return }
scanning = false
if leCentral.state == .poweredOn { leCentral.stopScan() }
}
public func centralManager(_ central: CBCentralManager, didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any], rssi RSSI: NSNumber) {
guard sessionActive else { return }
let id = peripheral.identifier
let advName = advertisementData[CBAdvertisementDataLocalNameKey] as? String
let fromCache = advName == nil || advName?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty == true
let name = (fromCache ? peripheral.name : advName) ?? ""
let first = seen.insert(id).inserted
let isEcho = name.lowercased().contains("echo")
if first && (isEcho || (!name.isEmpty && seen.count <= 40)) {
os_log("广播 %{public}@ name='%{public}@'%{public}@ rssi=%d", log: Self.log, type: .default,
id.uuidString, name, fromCache ? "(广播无名字,系统缓存名)" : "", RSSI.intValue)
}
guard isEcho else { return }
switch phase {
case .scan:
if candidates[id] != nil { return }
guard let side = sideOf(name) else {
if first { os_log("广播 %{public}@ '%{public}@' 不是 Echo-one-L / Echo-one-R,忽略", log: Self.log, type: .default, id.uuidString, name) }
return
}
if let same = candidates.first(where: { sideOf($0.value.1) == side }) {
if same.value.2 && !fromCache {
os_log("%{public}@耳广播 %{public}@ 带名字 '%{public}@',替换缓存名备胎", log: Self.log, type: .default,
side == "L" ? "左" : "右", id.uuidString, name)
candidates.removeValue(forKey: same.key)
} else {
if first {
os_log("又扫到一个%{public}@耳广播,已有同侧,忽略", log: Self.log, type: .default, side == "L" ? "左" : "右")
}
return
}
}
candidates[id] = (peripheral, name, fromCache)
let line = "扫到\(side == "L" ? "左" : "右")耳 \(id.uuidString) '\(name)'\(fromCache ? "(缓存名)" : "") rssi=\(RSSI.intValue)"
diag(line)
os_log("扫到%{public}@耳 %{public}@ '%{public}@'", log: Self.log, type: .default,
side == "L" ? "左" : "右", id.uuidString, name)
if candidates.count >= 2 && candidates.values.allSatisfy({ !$0.2 }) { onBothFound() }
default:
guard let ear = [left, right].compactMap({ $0 }).first(where: { $0.peripheral.identifier == id && $0.recovering }) else { return }
let now = CFAbsoluteTimeGetCurrent()
if ear.peripheral.state == .connected || ear.peripheral.state == .connecting || now - ear.lastConnect < 1 { return }
os_log("重新扫到%{public}@,重连 GATT", log: Self.log, type: .default, ear.label)
setState(ear, "connecting")
connectGatt(ear)
}
}
private func onScanTimeout() {
guard sessionActive, phase == .scan else { return }
if candidates.count >= 2 {
os_log("扫描到时:一左一右都有(含缓存名备胎),照样开连", log: Self.log, type: .default)
onBothFound()
return
}
stopScan()
let n = candidates.count
let desc = candidates.map { "\($0.key.uuidString) '\($0.value.1)'" }.joined(separator: ", ")
fail(n == 1 ? "oneEar" : "noEar", "15s 内只扫到 \(n) 个 Echo-one 广播(共见 \(seen.count) 个):\(desc)")
}
private func onBothFound() {
scanTimer?.cancel()
stopScan()
guard let l = candidates.values.first(where: { sideOf($0.1) == "L" }),
let r = candidates.values.first(where: { sideOf($0.1) == "R" }) else { return }
diag("左右耳都已扫到,发起 GATT:左=\(l.0.identifier.uuidString) 右=\(r.0.identifier.uuidString)")
os_log("左右耳都已扫到:左耳=%{public}@ 右耳=%{public}@,同时发起 GATT 连接",
log: Self.log, type: .default, l.0.identifier.uuidString, r.0.identifier.uuidString)
phase = .connect
let le = Ear(side: "L", peripheral: l.0, name: l.1)
let re = Ear(side: "R", peripheral: r.0, name: r.1)
left = le
right = re
setState(le, "connecting")
setState(re, "connecting")
connectGatt(le)
connectGatt(re)
arm(after: connectTimeout, token: sessionToken) { [weak self] in self?.onConnectTimeout() }
}
private func onConnectTimeout() {
guard sessionActive, phase == .connect else { return }
if left?.ready == true && right?.ready == true { return }
fail("connectTimeout", "集齐后 10s 内两耳未都就绪:左耳=\(left?.ready == true ? "就绪" : left?.state ?? "无") 右耳=\(right?.ready == true ? "就绪" : right?.state ?? "无")")
}
private func sideOf(_ name: String) -> String? {
let n = name.trimmingCharacters(in: .whitespacesAndNewlines)
if n.caseInsensitiveCompare(leftName) == .orderedSame { return "L" }
if n.caseInsensitiveCompare(rightName) == .orderedSame { return "R" }
return nil
}
private func connectGatt(_ ear: Ear) {
guard !ear.closed, sessionActive else { return }
ear.lastConnect = CFAbsoluteTimeGetCurrent()
ear.peripheral.delegate = self
diag("\(ear.label) 发起 GATT 连接 \(ear.peripheral.identifier.uuidString)")
leCentral.connect(ear.peripheral, options: nil)
}
public func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
guard let ear = earOf(peripheral) else { return }
diag("\(ear.label) GATT 已连接,开始发现服务")
os_log("%{public}@ GATT 已连接,发现服务", log: Self.log, type: .default, ear.label)
peripheral.discoverServices([serviceUUID])
}
public func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
handleGattDown(peripheral, status: gattErrorText(error, fallback: "fail"))
}
public func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
handleGattDown(peripheral, status: gattErrorText(error, fallback: "disconnect"))
}
private func gattErrorText(_ error: Error?, fallback: String) -> String {
guard let error = error as NSError? else { return fallback }
return "\(fallback) \(error.domain) \(error.code) \(error.localizedDescription)"
}
private func handleGattDown(_ peripheral: CBPeripheral, status: String) {
guard let ear = earOf(peripheral), !ear.closed else { return }
let wasReady = ear.ready
ear.ready = false
ear.writeQ.removeAll()
ear.writing = false
diag("\(ear.label) GATT \(status) 曾就绪=\(wasReady) 重试=\(ear.retries)/\(gattRetries)")
os_log("%{public}@ GATT 断开 %{public}@ 曾就绪=%{public}@ 重试=%d/%d",
log: Self.log, type: .default, ear.label, status, wasReady ? "是" : "否", ear.retries, gattRetries)
guard sessionActive, !stopping else { return }
if phase == .live {
onEarLost(ear)
} else if ear.retries < gattRetries {
ear.retries += 1
setState(ear, "connecting")
onLe(after: 0.8) { self.connectGatt(ear) }
} else {
fail("connectFailed", "\(ear.label) GATT 重试 \(gattRetries) 次仍断开")
}
}
public func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard let ear = earOf(peripheral), error == nil,
let svc = peripheral.services?.first(where: { $0.uuid == serviceUUID }) else {
if let ear = earOf(peripheral) {
fail("noService", "\(ear.label) 没有私有服务")
}
return
}
peripheral.discoverCharacteristics(nil, for: svc)
}
public func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
guard let ear = earOf(peripheral), error == nil, let chars = service.characteristics else { return }
let notifyMask: CBCharacteristicProperties = [.notify, .indicate]
let writeMask: CBCharacteristicProperties = [.write, .writeWithoutResponse]
ear.rx = chars.first { $0.uuid == notifyUUID && !$0.properties.intersection(notifyMask).isEmpty }
?? chars.first { !$0.properties.intersection(notifyMask).isEmpty }
ear.tx = chars.first { $0.uuid == writeUUID && !$0.properties.intersection(writeMask).isEmpty }
?? chars.first { !$0.properties.intersection(writeMask).isEmpty }
if let tx = ear.tx {
ear.useWithResponse = !tx.properties.contains(.writeWithoutResponse)
}
let maxLen = peripheral.maximumWriteValueLength(for: ear.useWithResponse ? .withResponse : .withoutResponse)
os_log("%{public}@ 特征 tx=%{public}@ rx=%{public}@ 写=%{public}@ maxLen=%d",
log: Self.log, type: .default, ear.label,
ear.tx?.uuid.uuidString ?? "无", ear.rx?.uuid.uuidString ?? "无",
ear.useWithResponse ? "带响应" : "无响应", maxLen)
guard let rx = ear.rx else {
os_log("%{public}@ 没有可通知的特征,收不到上行", log: Self.log, type: .default, ear.label)
onReady(ear)
return
}
peripheral.setNotifyValue(true, for: rx)
}
public func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
guard let ear = earOf(peripheral) else { return }
os_log("%{public}@ 通知已开 %{public}@ err=%{public}@", log: Self.log, type: .default,
ear.label, characteristic.uuid.uuidString, error?.localizedDescription ?? "无")
if characteristic.uuid == ear.rx?.uuid { onReady(ear) }
}
public func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard let ear = earOf(peripheral), error == nil, let value = characteristic.value, !value.isEmpty else { return }
guard characteristic.uuid == ear.rx?.uuid else { return }
onNotify(ear, value)
}
public func peripheral(_ peripheral: CBPeripheral, didWriteValueFor characteristic: CBCharacteristic, error: Error?) {
guard let ear = earOf(peripheral) else { return }
ear.writing = false
if error != nil {
ear.writeFail += 1
}
pump(ear)
}
public func peripheralIsReady(toSendWriteWithoutResponse peripheral: CBPeripheral) {
guard let ear = earOf(peripheral) else { return }
pump(ear)
}
private func onReady(_ ear: Ear) {
guard !ear.ready else { return }
ear.ready = true
let ms = Int((CFAbsoluteTimeGetCurrent() - sessionStart) * 1000)
os_log("%{public}@ 就绪(%dms)", log: Self.log, type: .default, ear.label, ms)
setState(ear, ear.pcmFrames > 0 ? "streaming" : "ready")
if ear.recovering {
ear.recovering = false
ear.startAcked = false
let ok = enqueue(ear, startFrame(), front: true)
os_log("%{public}@ 已重连就绪,经 BLE 补发 AA 66 %{public}@", log: Self.log, type: .default, ear.label, ok ? "" : "失败")
let token = sessionToken
onLe(after: startAckWait) { self.checkStartAck(ear, token) }
}
if left?.ready == true && right?.ready == true {
if phase == .connect {
phase = .live
connectTimer?.cancel()
left?.startAcked = false
right?.startAcked = false
let n = writeCtrl(startFrame())
os_log("左右耳都已就绪(%dms),AA 66 06 02 01 01 经 BLE 发给 %d 只耳机,面对面正式开始",
log: Self.log, type: .default, ms, n)
let token = sessionToken
onLe(after: startAckWait) {
if let l = self.left { self.checkStartAck(l, token) }
if let r = self.right { self.checkStartAck(r, token) }
}
}
stopScan()
}
}
private func checkStartAck(_ ear: Ear, _ token: Int) {
guard sessionActive, token == sessionToken, !stopping, !ear.closed, ear.ready else { return }
if ear.startAcked || ear.frames > 0 { return }
if !ear.useWithResponse, ear.tx?.properties.contains(.write) == true {
ear.useWithResponse = true
let ok = enqueue(ear, startFrame(), front: true)
os_log("%{public}@ AA 66 发出 1.5s 没有 BB E6 也没上行,改带响应写重发%{public}@",
log: Self.log, type: .default, ear.label, ok ? "" : "失败")
} else {
os_log("%{public}@ AA 66 发出 1.5s 仍没有 BB E6 也没上行", log: Self.log, type: .default, ear.label)
}
}
private func onEarLost(_ ear: Ear) {
if ear.recovering { return }
ear.recovering = true
ear.lost += 1
os_log("%{public}@ 会话中掉线(第 %d 次),10s 内重扫重连,期间两耳下行停发", log: Self.log, type: .default, ear.label, ear.lost)
setState(ear, "reconnecting")
onLe(after: earRecover) {
guard self.sessionActive, !ear.closed, ear.recovering else { return }
self.fail("earLost", "\(ear.label) 掉线后 10s 内没重连上")
}
startScan()
}
// MARK: - 上行
private func onNotify(_ ear: Ear, _ v: Data) {
guard !ear.closed else { return }
ear.notifies += 1
ear.lastNotify = CFAbsoluteTimeGetCurrent()
if ear.notifies <= 8 || ear.notifies % 1000 == 0 {
os_log("%{public}@ 通知#%d len=%d %{public}@", log: Self.log, type: .default, ear.label, ear.notifies, v.count, hex(v, 24))
}
let bytes = v
ear.exec.async { self.parse(ear, bytes) }
}
private func parse(_ ear: Ear, _ v: Data) {
if ear.closed { return }
let b0 = v.first ?? 0
if ear.stash.isEmpty && b0 != 0xBB && b0 != 0xCC && v.count % 40 == 0 {
if !ear.rawMode {
ear.rawMode = true
os_log("%{public}@ 通知不是 BB/CC 帧且长度为 40 的倍数,按裸 G.722 解", log: Self.log, type: .default, ear.label)
}
decodeChunks(ear, v, start: 0)
return
}
ear.stash.append(v)
if ear.stash.count > 65536 { ear.stash.removeAll(); return }
let buf = [UInt8](ear.stash)
var cursor = 0
while ear.stash.count - cursor >= 3 {
while cursor < buf.count && buf[cursor] != 0xBB && buf[cursor] != 0xCC { cursor += 1 }
if buf.count - cursor < 3 { break }
var len = Int(buf[cursor + 2])
if buf[cursor] == 0xCC { len += 256 }
if len < 2 { cursor += 1; continue }
if buf.count - cursor < len { break }
handleFrame(ear, Data(buf[cursor..<(cursor + len)]))
cursor += len
}
if cursor > 0 { ear.stash = Data(buf.dropFirst(cursor)) }
}
private func handleFrame(_ ear: Ear, _ f: Data) {
let b = [UInt8](f)
guard b.count >= 2 else { return }
switch b[1] {
case 0x68, 0x55, 0x60:
ear.frames += 1
if ear.frames <= 5 || ear.frames % 500 == 0 {
os_log("%{public}@ 上行#%d len=%d 头=%{public}@ 累计PCM=%d",
log: Self.log, type: .default, ear.label, ear.frames, f.count, hex(f, 8), ear.pcmFrames)
}
decodeChunks(ear, f, start: 4)
case 0x66:
os_log("%{public}@ 收到启动 %{public}@", log: Self.log, type: .default, ear.label, hex(f, 16))
publish(["type": "startFaceToFace", "data": f])
case 0x67:
os_log("%{public}@ 收到结束 %{public}@", log: Self.log, type: .default, ear.label, hex(f, 16))
publish(["type": "stopFaceToFace", "data": f])
let token = sessionToken
onLe(after: 3) { if token == self.sessionToken { self.requestStop("耳机经 BLE 上报 BB 67") } }
case 0xE6:
ear.startAcked = true
os_log("%{public}@ 收到启动应答 %{public}@", log: Self.log, type: .default, ear.label, hex(f, 16))
case 0xE7:
os_log("%{public}@ 收到结束应答 %{public}@", log: Self.log, type: .default, ear.label, hex(f, 16))
case 0x99:
os_log("%{public}@ 收到 AA 19 应答 %{public}@", log: Self.log, type: .default, ear.label, hex(f, 16))
default:
ear.unknown += 1
if ear.unknown <= 20 {
os_log("%{public}@ 未识别帧 %{public}@", log: Self.log, type: .default, ear.label, hex(f, 16))
}
}
}
private func decodeChunks(_ ear: Ear, _ data: Data, start: Int) {
if ear.codec == nil { ear.codec = G722Codec(sampleRate: 16000) }
var pos = start
let bytes = [UInt8](data)
while pos + 40 <= bytes.count {
let chunk = Data(bytes[pos..<(pos + 40)])
if let pcm = ear.codec?.decode(chunk) {
ear.pcmFrames += 1
if ear.pcmFrames == 1 || ear.state == "ready" { setState(ear, "streaming") }
publish(["type": ear.type, "data": pcm])
}
pos += 40
}
}
// MARK: - 写
@discardableResult
private func writeCtrl(_ msg: Data, front: Bool = true) -> Int {
var n = 0
for ear in [left, right].compactMap({ $0 }) {
if enqueue(ear, msg, front: front) { n += 1 }
}
return n
}
@discardableResult
private func enqueue(_ ear: Ear, _ bytes: Data, front: Bool) -> Bool {
guard ear.ready, ear.tx != nil else { return false }
if front { ear.writeQ.insert(bytes, at: 0) } else { ear.writeQ.append(bytes) }
if ear.writeQ.count > writeQueueCap {
if !front { ear.writeQ.removeFirst() } else if ear.writeQ.count > 1 { ear.writeQ.remove(at: 1) }
ear.dropped += 1
}
pump(ear)
return true
}
private func pump(_ ear: Ear) {
guard ear.ready, let tx = ear.tx, !ear.writing else { return }
let p = ear.peripheral
let noResp = !ear.useWithResponse && tx.properties.contains(.writeWithoutResponse)
if noResp && !p.canSendWriteWithoutResponse && ear.sent > 0 { return }
guard !ear.writeQ.isEmpty else { return }
let bytes = ear.writeQ.removeFirst()
let type: CBCharacteristicWriteType = noResp ? .withoutResponse : .withResponse
p.writeValue(bytes, for: tx, type: type)
if !noResp { ear.writing = true }
ear.sent += 1
if ear.sent <= 6 || ear.sent % 500 == 0 {
os_log("%{public}@ 下发#%d len=%d %{public}@", log: Self.log, type: .default, ear.label, ear.sent, bytes.count, hex(bytes, 8))
}
if noResp { pump(ear) }
}
// MARK: - 结束 / 恢复
private func stopOnLe(_ reason: String, reconnect: Bool, exitDelivered: Bool) {
flagLock.lock()
guard _sessionActive else { flagLock.unlock(); return }
_sessionActive = false
let lost = _classicLost
_classicLost = false
_pendingClassicReconnect = reconnect && lost
flagLock.unlock()
let ears = [left, right].compactMap { $0 }
left = nil
right = nil
sessionToken += 1
if exitDelivered { UserDefaults.standard.set(false, forKey: prefsExit) }
scanTimer?.cancel(); connectTimer?.cancel(); statsTimer?.cancel(); silenceTimer?.cancel()
stopScan()
os_log("面对面 BLE 会话结束(%{public}@)经典蓝牙%{public}@ 切回指令%{public}@",
log: Self.log, type: .default, reason, lost ? "已断" : "未断", exitDelivered ? "已发" : "未送达")
for e in ears {
e.closed = true
e.ready = false
e.writeQ.removeAll()
if e.peripheral.state == .connected || e.peripheral.state == .connecting {
leCentral.cancelPeripheralConnection(e.peripheral)
}
e.codec = nil
}
guard reconnect else {
publishSide("L", "idle")
publishSide("R", "idle")
return
}
beginRecovery(lost)
}
private func beginRecovery(_ classicWasLost: Bool) {
flagLock.lock()
_recovering = true
recoverToken += 1
let token = recoverToken
flagLock.unlock()
publishSide("L", "restoring")
publishSide("R", "restoring")
os_log("进入恢复期(%{public}@)", log: Self.log, type: .default, classicWasLost ? "回连经典蓝牙" : "经典蓝牙未断,冷却 2s")
recoverTimer?.cancel()
let cap = DispatchWorkItem { [weak self] in self?.endRecovery(token, false, "恢复超时 30s") }
recoverTimer = cap
leQueue.asyncAfter(deadline: .now() + recoverCap, execute: cap)
if classicWasLost {
onLe(after: classicReconnectDelay) {
BluetoothManager.sharedInstance?.reconnectClassicAfterF2f { ok in
self.onLe { self.endRecovery(token, ok, ok ? "经典蓝牙回连成功" : "经典蓝牙回连失败") }
}
}
} else {
onLe(after: recoverCooldown) { self.endRecovery(token, true, "冷却结束") }
}
}
private func endRecovery(_ token: Int, _ ok: Bool, _ why: String) {
flagLock.lock()
guard _recovering, token == recoverToken else { flagLock.unlock(); return }
_recovering = false
_pendingClassicReconnect = false
flagLock.unlock()
recoverTimer?.cancel()
os_log("恢复期结束:%{public}@", log: Self.log, type: .default, why)
publishSide("L", "idle")
publishSide("R", "idle")
publishOverall("recovered", extra: ["ok": ok])
}
private func fail(_ code: String, _ detail: String) {
guard sessionActive, !failed, !stopping else { return }
failed = true
linkAckTimer?.cancel()
linkAckTimer = nil
scanDelayTimer?.cancel()
scanDelayTimer = nil
diag("失败[\(code)] \(detail)")
publishOverall("failed", extra: ["reason": code])
let token = sessionToken
onLe(after: failStop) {
if self.sessionActive && token == self.sessionToken { self.requestStop("失败[\(code)]后 Dart 未收尾") }
}
}
// MARK: - 辅助
private func earOf(_ p: CBPeripheral) -> Ear? {
if left?.peripheral.identifier == p.identifier { return left }
if right?.peripheral.identifier == p.identifier { return right }
return nil
}
private func setState(_ ear: Ear, _ state: String) {
if ear.closed || ear.state == state { return }
ear.state = state
os_log("%{public}@ 状态 → %{public}@", log: Self.log, type: .default, ear.label, state)
publishSide(ear.side, state)
}
private func publishSide(_ side: String, _ state: String) {
publish(["type": "f2fLink", "side": side, "state": state])
}
private func publishOverall(_ state: String, extra: [String: Any]) {
var ev: [String: Any] = ["type": "f2fLink", "side": "", "state": state]
extra.forEach { ev[$0.key] = $0.value }
publish(ev)
}
private func publish(_ event: [String: Any]) {
BluetoothManager.publishF2f(event)
}
/// 扫连过程写进 App 文件日志,真机上不用 syslog 也能看到卡在哪一步。
private func diag(_ message: String) {
os_log("F2F-BLE %{public}@", log: Self.log, type: .default, message)
publish(["type": "f2fLog", "msg": message])
}
private func checkSilence(_ token: Int) {
guard sessionActive, token == sessionToken else { return }
let now = CFAbsoluteTimeGetCurrent()
for e in [left, right].compactMap({ $0 }) {
if e.ready && e.state == "streaming" && now - e.lastNotify > uplinkSilent {
os_log("%{public}@ 上行已 %.0fms 没数据,标签退回 ready", log: Self.log, type: .default, e.label, (now - e.lastNotify) * 1000)
setState(e, "ready")
}
}
armSilence(token)
}
private func logStats(_ token: Int) {
guard sessionActive, token == sessionToken else { return }
let ears = [left, right].compactMap { $0 }
let sec = Int(CFAbsoluteTimeGetCurrent() - sessionStart)
if ears.isEmpty {
os_log("会话统计(%ds 阶段=scan):已扫到 %d 只", log: Self.log, type: .default, sec, candidates.count)
} else {
os_log("会话统计(%ds):%{public}@", log: Self.log, type: .default, sec, ears.map { "\($0.summary()) 就绪=\($0.ready)" }.joined(separator: ";"))
}
armStats(token)
}
private func armStats(_ token: Int) {
statsTimer?.cancel()
let work = DispatchWorkItem { [weak self] in self?.logStats(token) }
statsTimer = work
leQueue.asyncAfter(deadline: .now() + statsInterval, execute: work)
}
private func armSilence(_ token: Int) {
silenceTimer?.cancel()
let work = DispatchWorkItem { [weak self] in self?.checkSilence(token) }
silenceTimer = work
leQueue.asyncAfter(deadline: .now() + 0.5, execute: work)
}
private func arm(after: TimeInterval, token: Int, _ body: @escaping () -> Void) {
let work = DispatchWorkItem { [weak self] in
guard let self, self.sessionToken == token else { return }
body()
}
if after == scanTimeout { scanTimer?.cancel(); scanTimer = work }
if after == connectTimeout { connectTimer?.cancel(); connectTimer = work }
leQueue.asyncAfter(deadline: .now() + after, execute: work)
}
private func onLe(_ body: @escaping () -> Void) {
if DispatchQueue.getSpecific(key: leQueueKey) == true { body() }
else { leQueue.async(execute: body) }
}
private func onLeSync(_ body: () -> Void) {
if DispatchQueue.getSpecific(key: leQueueKey) == true { body() }
else { leQueue.sync(execute: body) }
}
private func onLe(after: TimeInterval, _ body: @escaping () -> Void) {
leQueue.asyncAfter(deadline: .now() + after, execute: body)
}
private func hex(_ data: Data, _ max: Int) -> String {
let n = min(max, data.count)
let h = data.prefix(n).map { String(format: "%02X", $0) }.joined(separator: " ")
return data.count > max ? "\(h) …(\(data.count))" : h
}
}

8
apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager.dart

@ -45,9 +45,11 @@ class BluetoothManager {
return BluetoothManagerPlatform.instance.onDeviceAudioEvent();
}
/// 启动通话翻译的下行 TTS 发送器(native 负责 G.722 编码与 20ms 节流)
Future<bool> startCallDownlink() {
return BluetoothManagerPlatform.instance.startCallDownlink();
/// 启动通话翻译的下行 TTS 发送器(native 负责 G.722 编码与 20ms 节流)。
/// 不传参即通话翻译 `AA 56`;Echo-one 面对面传 header=0xBB cmd=0x69 swapLegs=true。
Future<bool> startCallDownlink({int? header, int? cmd, bool? swapLegs}) {
return BluetoothManagerPlatform.instance
.startCallDownlink(header: header, cmd: cmd, swapLegs: swapLegs);
}
Future<bool> stopCallDownlink() {

11
apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_method_channel.dart

@ -205,8 +205,15 @@ class MethodChannelBluetoothManager extends BluetoothManagerPlatform {
}
@override
Future<bool> startCallDownlink() async {
return await methodChannel.invokeMethod<bool>('startCallDownlink') ?? false;
Future<bool> startCallDownlink({int? header, int? cmd, bool? swapLegs}) async {
final args = <String, Object>{
if (header != null) 'header': header,
if (cmd != null) 'cmd': cmd,
if (swapLegs != null) 'swapLegs': swapLegs,
};
return await methodChannel.invokeMethod<bool>(
'startCallDownlink', args.isEmpty ? null : args) ??
false;
}
@override

2
apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_platform_interface.dart

@ -57,7 +57,7 @@ abstract class BluetoothManagerPlatform extends PlatformInterface {
throw UnimplementedError('onDeviceAudioEvent() has not been implemented.');
}
Future<bool> startCallDownlink() {
Future<bool> startCallDownlink({int? header, int? cmd, bool? swapLegs}) {
throw UnimplementedError('startCallDownlink() has not been implemented.');
}

18
apps/client/local_plugins/device_plugin_interface/lib/src/audio_format.dart

@ -105,7 +105,23 @@ class AudioFrame {
/// - [ai]:AI 会话的回复语音。
/// - [callLegA]:己方译文,给对端听。
/// - [callLegB]:对端译文,给己方听。
enum AudioRoute { mic, callLocal, callPeer, ai, callLegA, callLegB }
///
/// 面对面双耳(两人各戴一只耳机,[DeviceCapability.faceToFace]):
/// - [f2fEarA] / [f2fEarB](上行):A 耳 / B 耳的麦。
/// - [f2fLegA](下行):A 耳说的话的译文,只播到 **B 耳**。
/// - [f2fLegB](下行):B 耳说的话的译文,只播到 **A 耳**。
enum AudioRoute {
mic,
callLocal,
callPeer,
ai,
callLegA,
callLegB,
f2fEarA,
f2fEarB,
f2fLegA,
f2fLegB,
}
/// 设备 → app 的音频源。`pcm` 里的每一块都满足 [format]。
///

12
apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart

@ -133,6 +133,18 @@ class DeviceFeatureKeys {
static const String firmwareUpdated = 'firmware.updated';
static const String macUpdated = 'mac.updated';
/// 设备侧发起/结束面对面翻译(恒玄 Echo-one:`BB 66` / `BB 67`,也可能是对 `f2f.*` 的回应)
static const String f2fStarted = 'f2f.started';
static const String f2fEnded = 'f2f.ended';
/// 面对面左右耳链路状态(Echo-one BLE 双耳)。`data`:
/// - 单耳:`side`=`L`/`R`,`state`=`scanning`/`connecting`/`ready`/`streaming`/
/// `reconnecting`(会话中掉线重连)/`restoring`(会话结束后耳机恢复中)/`idle`;
/// - 整体:`side` 为空,`state`=`failed`(另带 `reason`:`oneEar`/`noEar`/
/// `connectTimeout`/`connectFailed`/`earLost`/`recovering`/`bluetoothOff`…)
/// 或 `recovered`(恢复期结束,另带 `ok`)。
static const String f2fLink = 'f2f.link';
/// 设备设置项变了(设备回了 `0x15` 这类汇总、或 app 写成功后本地回填)。
/// `data` 与 [DeviceFeatures.settingsQuery] 的返回值同形。
static const String settingsChanged = 'settings.changed';

2
apps/client/pubspec.yaml

@ -1,7 +1,7 @@
name: eaimar
description: "Eaimar - AI Voice Assistant."
publish_to: "none"
version: 1.0.3+27
version: 1.0.3+44
environment:
sdk: ">=3.3.0 <4.0.0"

10
apps/client/test/devices/bes_protocol_test.dart

@ -88,11 +88,17 @@ void main() {
expect(BesCmd.frame(BesCmd.micRecordStart), [0xAA, 0x59]);
expect(BesCmd.frame(BesCmd.micRecordStop), [0xAA, 0x5A]);
expect(BesCmd.frame(BesCmd.aiExit), [0xAA, 0x65]);
expect(BesCmd.frame(BesCmd.faceToFaceStart), [0xAA, 0x66]);
expect(BesCmd.frame(BesCmd.faceToFaceStop), [0xAA, 0x67]);
expect(BesCmd.frame(BesCmd.aiAccept), [0xAA, 0x6A]);
});
test('Echo-one 面对面 App → 耳机是 AA(66/67/69),耳机上行 BB 68', () {
expect(BesF2f.command(BesF2f.start), [0xAA, 0x66]);
expect(BesF2f.command(BesF2f.stop), [0xAA, 0x67]);
expect(BesF2f.sof, 0xBB);
expect(BesF2f.uplink, 0x68);
expect(BesF2f.downlink, 0x69);
});
test('电量请求是 09 不是 0A,也不是 |0x80 推出来的任何值', () {
expect(BesCmd.status, 0x09);
expect(BesResp.status, 0x0A);

18
apps/client/test/devices/bes_session_commands_test.dart

@ -26,11 +26,23 @@ void main() {
};
for (final e in expected.entries) {
final cmd = BesDeviceSession.featureCommands[e.key];
expect(cmd, isNotNull, reason: '${e.key} 没有映射');
expect(BesCmd.frame(cmd!), e.value, reason: e.key);
final sent = cmd != null
? BesCmd.frame(cmd)
: BesDeviceSession.featureFrames[e.key];
expect(sent, isNotNull, reason: '${e.key} 没有映射');
expect(sent, e.value, reason: e.key);
}
expect(BesDeviceSession.featureCommands.length, expected.length,
expect(
BesDeviceSession.featureCommands.length +
BesDeviceSession.featureFrames.length,
expected.length,
reason: '多出来的映射也要在这里登记');
expect(
BesDeviceSession.featureCommands.keys
.toSet()
.intersection(BesDeviceSession.featureFrames.keys.toSet()),
isEmpty,
reason: '同一个 feature 不能两张表都登记');
});
test('恒玄会话声明的能力集:只增不删', () {

Loading…
Cancel
Save