From c76c432bfdb95562dc910cb670aa381b76aa8d77 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Tue, 20 Oct 2026 20:29:34 +0800 Subject: [PATCH] =?UTF-8?q?client=EF=BC=9AEcho-one=20=E9=9D=A2=E5=AF=B9?= =?UTF-8?q?=E9=9D=A2=E6=94=B9=E8=B5=B0=E5=B7=A6=E5=8F=B3=E8=80=B3=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=20BLE=EF=BC=8CiOS=20=E6=94=B6=E5=88=B0=20BB=2099=20?= =?UTF-8?q?=E5=90=8E=E5=85=B3=E9=97=AD=E7=BB=8F=E5=85=B8=20GATT=20?= =?UTF-8?q?=E5=B9=B6=E7=AD=89=201=20=E7=A7=92=E5=86=8D=E6=89=AB=E6=8F=8F?= =?UTF-8?q?=E8=BF=9E=E6=8E=A5=E3=80=82=E6=9E=84=E5=BB=BA=E5=8F=B7=201.0.3+?= =?UTF-8?q?44?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- apps/client/docs/face_to_face_ble_protocol.md | 722 ++++++----- apps/client/ios/Podfile.lock | 2 +- .../ios/Runner.xcodeproj/project.pbxproj | 16 +- apps/client/ios/Runner/AppDelegate.swift | 8 +- apps/client/ios/Runner/Info.plist | 21 + .../ios/Runner/RunnerProfile.entitlements | 15 +- apps/client/ios/Runner/SceneDelegate.swift | 6 + .../lib/core/translations/language/ar_sa.dart | 16 + .../lib/core/translations/language/bg_bg.dart | 16 + .../lib/core/translations/language/cs_cz.dart | 16 + .../lib/core/translations/language/da_dk.dart | 16 + .../lib/core/translations/language/de_de.dart | 16 + .../lib/core/translations/language/el_gr.dart | 16 + .../lib/core/translations/language/en_us.dart | 16 + .../lib/core/translations/language/es_es.dart | 16 + .../lib/core/translations/language/et_ee.dart | 16 + .../lib/core/translations/language/fa_ir.dart | 16 + .../lib/core/translations/language/fi_fi.dart | 16 + .../lib/core/translations/language/fr_fr.dart | 16 + .../lib/core/translations/language/hi_in.dart | 16 + .../lib/core/translations/language/hr_hr.dart | 16 + .../lib/core/translations/language/hu_hu.dart | 16 + .../lib/core/translations/language/id_id.dart | 16 + .../lib/core/translations/language/is_is.dart | 16 + .../lib/core/translations/language/it_it.dart | 16 + .../lib/core/translations/language/ja_jp.dart | 16 + .../lib/core/translations/language/ko_kr.dart | 16 + .../lib/core/translations/language/lt_lt.dart | 16 + .../lib/core/translations/language/lv_lv.dart | 16 + .../lib/core/translations/language/ms_my.dart | 16 + .../lib/core/translations/language/nb_no.dart | 16 + .../lib/core/translations/language/nl_nl.dart | 16 + .../lib/core/translations/language/pl_pl.dart | 16 + .../lib/core/translations/language/pt_pt.dart | 16 + .../lib/core/translations/language/ro_ro.dart | 16 + .../lib/core/translations/language/ru_ru.dart | 16 + .../lib/core/translations/language/sk_sk.dart | 16 + .../lib/core/translations/language/sl_si.dart | 16 + .../lib/core/translations/language/sr_rs.dart | 16 + .../lib/core/translations/language/sv_se.dart | 16 + .../lib/core/translations/language/th_th.dart | 16 + .../lib/core/translations/language/tl_ph.dart | 16 + .../lib/core/translations/language/tr_tr.dart | 16 + .../lib/core/translations/language/uk_ua.dart | 16 + .../lib/core/translations/language/vi_vn.dart | 16 + .../lib/core/translations/language/zh_cn.dart | 16 + .../lib/core/translations/language/zh_hk.dart | 16 + .../lib/core/translations/language/zh_tw.dart | 16 + .../devices/bes/bes_bluetooth_service.dart | 23 +- .../lib/devices/bes/bes_device_plugin.dart | 72 +- apps/client/lib/devices/bes/bes_protocol.dart | 34 +- .../controllers/translation_controller.dart | 470 +++++++- .../translation/views/translation_view.dart | 68 +- .../bluetooth_manager/BluetoothManager.kt | 194 ++- .../BluetoothManagerPlugin.kt | 7 +- .../CallTranslationDownlink.kt | 120 +- .../example/bluetooth_manager/F2fBleLink.kt | 1070 +++++++++++++++++ .../bluetooth_manager/BluetoothManager.swift | 126 +- .../BluetoothManagerPlugin.swift | 7 +- .../CallTranslationDownlink.swift | 79 +- .../bluetooth_manager/F2fBleLink.swift | 965 +++++++++++++++ .../lib/bluetooth_manager.dart | 8 +- .../lib/bluetooth_manager_method_channel.dart | 11 +- .../bluetooth_manager_platform_interface.dart | 2 +- .../lib/src/audio_format.dart | 18 +- .../lib/src/device_session.dart | 12 + apps/client/pubspec.yaml | 2 +- .../test/devices/bes_protocol_test.dart | 10 +- .../devices/bes_session_commands_test.dart | 18 +- 69 files changed, 4262 insertions(+), 500 deletions(-) create mode 100644 apps/client/ios/Runner/SceneDelegate.swift create mode 100644 apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/F2fBleLink.kt create mode 100644 apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/F2fBleLink.swift diff --git a/apps/client/docs/face_to_face_ble_protocol.md b/apps/client/docs/face_to_face_ble_protocol.md index ec7aa633..2a1af160 100644 --- a/apps/client/docs/face_to_face_ble_protocol.md +++ b/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 │ │ │ - │─────────────────────────────────────►│ │ │ - │ │ 停A2DP/HFP、拆TWS主从 │ - │ ② 经典: BB EB ... │ 两耳各起 LE 广播(带 token) │ - │◄─────────────────────────────────────│ │ │ - │ ③ LE 扫描 → 按 token 过滤 → 集齐 EAR=1 与 EAR=2 → 分别连接 │ - │ ④ 各链路:MTU 交换 / 2M PHY / 订阅通知 │ - │─────────────────────────────────────────────────────►│────────────────►│ - │ ⑤ LE: AA 01 HELLO (每条链路各一次) │ │ - │ ⑥ LE: BB 81 HELLO_ACK ... │ │ - │ ⑦ 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 │ │ │ - │◄─────────────────────────────────────│ │ │ +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 +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 -``` +## 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 [LEN] [payload]`,`LEN` 为**整帧长度**(含 3/4 字节头); +`CC` 起头时长度 = `LEN + 256`(长帧,App 拆帧时兼容)。App → 耳机的控制命令是 `AA [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 ` | 见 §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 -``` +- **兜底**:一只 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 `): -## 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 ` | 猜测格式,未采用,代码里已删除 | -**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 ` / `BB `,`LEN` 为 payload 字节数。 - -| 步骤 | 帧 | payload | 说明 | -|---|---|---|---| -| ① HELLO | `AA 01` | `` | `ROLE`:`0x01`=App。token 再校验一次,防止连错 | -| ② HELLO_ACK | `BB 81` | `` | `EAR` 必须与广播里一致;`MTU_OK`=0 时 App 降级到 20ms 单帧包 | -| ③ SESSION_CFG | `AA 02` | `` | 见下 | -| ④ CFG_ACK | `BB 82` | `` | | -| ⑤ START_STREAM | `AA 03` | — | 开本耳上行。回 `0x00` 后耳机开始推 `BB 90` | -| ⑥ START_ACK | `BB 83` | `` | | -| ⑦ STOP_STREAM | `AA 04` | — | 停上行,链路保留(切换语言/暂停时用,不必拆链) | -| ⑧ KEEPALIVE | `AA 05` | `` | App 每 **5s** 发一次,**只用于诊断**(§11.1),耳机不据此断链 | -| ⑨ KEEPALIVE_ACK | `BB 85` | `` | 丢包率(%),写日志 | -| ⑩ EXIT | `AA 06` | — | 本耳退出面对面:停流、断开本 LE 连接、走 §11.2 恢复。两耳都收到后重新组对 | -| ⑪ EXIT_ACK | `BB 86` | `` | 回完再断 | - -**`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 +BB 68 ``` | 字段 | 说明 | |---|---| -| `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 +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 ` 请求:`+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 -``` +固件目前**没有**按键 / 佩戴 / 电量 / 异常事件帧(v0.2 的 `BB 87` 作废)。 +只有 `BB 66` / `BB 67`(§4)表示用户在耳机上开始 / 结束。 -| `EVENT` | 含义 | PARAM | App 侧对应 | -|---|---|---|---| -| `0x01` | 按键 | ``,ACTION:`0x01`按下 / `0x02`抬起 / `0x03`单击 / `0x04`双击 | **按住说话**:左耳按下→`activeSpeaker=1`、右耳按下→`=2`、抬起→`=0`。语义已在 `translation_controller.setActiveSpeaker` 落地,固件只需如实上报 | -| `0x02` | 佩戴状态 | ``,STATE:`0x00`摘下 / `0x01`戴上 / `0x02`入盒 | 摘下/入盒 → App 暂停该路,UI 提示 | -| `0x03` | 电量 | ``,最高位为充电位 | 与 `BB 0A` 同口径 | -| `0x04` | 链路统计 | `` | 每 10s 一次,仅用于诊断日志 | -| `0x05` | 异常 | ``,见 §12 | 缓冲溢出、编码失败等 | -| `0x06` | 耳机请求退出 | ``:`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 功能结束后**必须完整回到进入前的状态**。这一节的每一条都是验收项。 -### 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 时退回手机麦 | diff --git a/apps/client/ios/Podfile.lock b/apps/client/ios/Podfile.lock index a7b6b0fb..c7cddc18 100644 --- a/apps/client/ios/Podfile.lock +++ b/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 diff --git a/apps/client/ios/Runner.xcodeproj/project.pbxproj b/apps/client/ios/Runner.xcodeproj/project.pbxproj index 8d8cf0e6..09b03595 100644 --- a/apps/client/ios/Runner.xcodeproj/project.pbxproj +++ b/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 = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 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 = ""; }; 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 = ""; }; BEEFDEAD0F00BA5E00112233 /* Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Profile.xcconfig; path = Flutter/Profile.xcconfig; sourceTree = ""; }; @@ -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 = ""; diff --git a/apps/client/ios/Runner/AppDelegate.swift b/apps/client/ios/Runner/AppDelegate.swift index 343c8573..580165e6 100644 --- a/apps/client/ios/Runner/AppDelegate.swift +++ b/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) diff --git a/apps/client/ios/Runner/Info.plist b/apps/client/ios/Runner/Info.plist index ea18f365..4bd85722 100644 --- a/apps/client/ios/Runner/Info.plist +++ b/apps/client/ios/Runner/Info.plist @@ -148,6 +148,27 @@ Photo library permission is required to save recording files and import audio files NSRemindersUsageDescription This app needs access to reminders to set calendar reminders + UIApplicationSceneManifest + + UIApplicationSupportsMultipleScenes + + UISceneConfigurations + + UIWindowSceneSessionRoleApplication + + + UISceneClassName + UIWindowScene + UISceneConfigurationName + flutter + UISceneDelegateClassName + $(PRODUCT_MODULE_NAME).SceneDelegate + UISceneStoryboardFile + Main + + + + UIApplicationSupportsIndirectInputEvents UIBackgroundModes diff --git a/apps/client/ios/Runner/RunnerProfile.entitlements b/apps/client/ios/Runner/RunnerProfile.entitlements index 0c67376e..809fc882 100644 --- a/apps/client/ios/Runner/RunnerProfile.entitlements +++ b/apps/client/ios/Runner/RunnerProfile.entitlements @@ -1,5 +1,18 @@ - + + com.apple.developer.applesignin + + Default + + com.apple.developer.associated-domains + + applinks:yimai.games + + com.apple.developer.networking.wifi-info + + com.apple.external-accessory.wireless-configuration + + diff --git a/apps/client/ios/Runner/SceneDelegate.swift b/apps/client/ios/Runner/SceneDelegate.swift new file mode 100644 index 00000000..6644b14d --- /dev/null +++ b/apps/client/ios/Runner/SceneDelegate.swift @@ -0,0 +1,6 @@ +import Flutter +import UIKit + +/// iOS 26 起没有 UIScene 的 App 会在创建窗口时被系统直接掐掉 +/// (`UIApplicationEvaluateRuntimeIssueForNoSceneLifecycleAdoption`)。 +class SceneDelegate: FlutterSceneDelegate {} diff --git a/apps/client/lib/core/translations/language/ar_sa.dart b/apps/client/lib/core/translations/language/ar_sa.dart index 003f3cfc..14301616 100644 --- a/apps/client/lib/core/translations/language/ar_sa.dart +++ b/apps/client/lib/core/translations/language/ar_sa.dart @@ -1761,6 +1761,22 @@ const Map 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': 'جاهز', diff --git a/apps/client/lib/core/translations/language/bg_bg.dart b/apps/client/lib/core/translations/language/bg_bg.dart index fa9c6fec..aaa9c4df 100644 --- a/apps/client/lib/core/translations/language/bg_bg.dart +++ b/apps/client/lib/core/translations/language/bg_bg.dart @@ -1823,6 +1823,22 @@ const Map 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': 'Готово', diff --git a/apps/client/lib/core/translations/language/cs_cz.dart b/apps/client/lib/core/translations/language/cs_cz.dart index fe4507c4..e4cfd063 100644 --- a/apps/client/lib/core/translations/language/cs_cz.dart +++ b/apps/client/lib/core/translations/language/cs_cz.dart @@ -1792,6 +1792,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/da_dk.dart b/apps/client/lib/core/translations/language/da_dk.dart index 1106b9dd..971c3e2c 100644 --- a/apps/client/lib/core/translations/language/da_dk.dart +++ b/apps/client/lib/core/translations/language/da_dk.dart @@ -1793,6 +1793,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/de_de.dart b/apps/client/lib/core/translations/language/de_de.dart index 8bdc2015..e5fad757 100644 --- a/apps/client/lib/core/translations/language/de_de.dart +++ b/apps/client/lib/core/translations/language/de_de.dart @@ -2037,6 +2037,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/el_gr.dart b/apps/client/lib/core/translations/language/el_gr.dart index df534d57..cab12464 100644 --- a/apps/client/lib/core/translations/language/el_gr.dart +++ b/apps/client/lib/core/translations/language/el_gr.dart @@ -1827,6 +1827,22 @@ const Map 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': 'Έτοιμο', diff --git a/apps/client/lib/core/translations/language/en_us.dart b/apps/client/lib/core/translations/language/en_us.dart index f9fffae7..ba22277d 100644 --- a/apps/client/lib/core/translations/language/en_us.dart +++ b/apps/client/lib/core/translations/language/en_us.dart @@ -2005,6 +2005,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/es_es.dart b/apps/client/lib/core/translations/language/es_es.dart index 3e79e8bd..2079fa4b 100644 --- a/apps/client/lib/core/translations/language/es_es.dart +++ b/apps/client/lib/core/translations/language/es_es.dart @@ -2031,6 +2031,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/et_ee.dart b/apps/client/lib/core/translations/language/et_ee.dart index b73a9d7a..531d830f 100644 --- a/apps/client/lib/core/translations/language/et_ee.dart +++ b/apps/client/lib/core/translations/language/et_ee.dart @@ -1788,6 +1788,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/fa_ir.dart b/apps/client/lib/core/translations/language/fa_ir.dart index 78901bf0..00ab62c8 100644 --- a/apps/client/lib/core/translations/language/fa_ir.dart +++ b/apps/client/lib/core/translations/language/fa_ir.dart @@ -1778,6 +1778,22 @@ const Map 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': 'آماده', diff --git a/apps/client/lib/core/translations/language/fi_fi.dart b/apps/client/lib/core/translations/language/fi_fi.dart index b83ef828..773b6072 100644 --- a/apps/client/lib/core/translations/language/fi_fi.dart +++ b/apps/client/lib/core/translations/language/fi_fi.dart @@ -1798,6 +1798,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/fr_fr.dart b/apps/client/lib/core/translations/language/fr_fr.dart index ccc9c571..52ee3608 100644 --- a/apps/client/lib/core/translations/language/fr_fr.dart +++ b/apps/client/lib/core/translations/language/fr_fr.dart @@ -2049,6 +2049,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/hi_in.dart b/apps/client/lib/core/translations/language/hi_in.dart index 8e8e2495..5db79af5 100644 --- a/apps/client/lib/core/translations/language/hi_in.dart +++ b/apps/client/lib/core/translations/language/hi_in.dart @@ -1797,6 +1797,22 @@ const Map 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': 'तैयार', diff --git a/apps/client/lib/core/translations/language/hr_hr.dart b/apps/client/lib/core/translations/language/hr_hr.dart index c300c351..4ea8c79a 100644 --- a/apps/client/lib/core/translations/language/hr_hr.dart +++ b/apps/client/lib/core/translations/language/hr_hr.dart @@ -1813,6 +1813,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/hu_hu.dart b/apps/client/lib/core/translations/language/hu_hu.dart index d6d0f1f9..d88dd3d0 100644 --- a/apps/client/lib/core/translations/language/hu_hu.dart +++ b/apps/client/lib/core/translations/language/hu_hu.dart @@ -1815,6 +1815,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/id_id.dart b/apps/client/lib/core/translations/language/id_id.dart index efe8ebf8..b00bf5cb 100644 --- a/apps/client/lib/core/translations/language/id_id.dart +++ b/apps/client/lib/core/translations/language/id_id.dart @@ -1797,6 +1797,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/is_is.dart b/apps/client/lib/core/translations/language/is_is.dart index e5a5d665..f59b66db 100644 --- a/apps/client/lib/core/translations/language/is_is.dart +++ b/apps/client/lib/core/translations/language/is_is.dart @@ -1794,6 +1794,22 @@ const Map 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ð', diff --git a/apps/client/lib/core/translations/language/it_it.dart b/apps/client/lib/core/translations/language/it_it.dart index 8466154c..1ddb7308 100644 --- a/apps/client/lib/core/translations/language/it_it.dart +++ b/apps/client/lib/core/translations/language/it_it.dart @@ -1835,6 +1835,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/ja_jp.dart b/apps/client/lib/core/translations/language/ja_jp.dart index 798c0ccf..086f1062 100644 --- a/apps/client/lib/core/translations/language/ja_jp.dart +++ b/apps/client/lib/core/translations/language/ja_jp.dart @@ -1834,6 +1834,22 @@ const Map 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': '準備完了', diff --git a/apps/client/lib/core/translations/language/ko_kr.dart b/apps/client/lib/core/translations/language/ko_kr.dart index 4dc2ed08..2ecbc230 100644 --- a/apps/client/lib/core/translations/language/ko_kr.dart +++ b/apps/client/lib/core/translations/language/ko_kr.dart @@ -1827,6 +1827,22 @@ const Map 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': '준비됨', diff --git a/apps/client/lib/core/translations/language/lt_lt.dart b/apps/client/lib/core/translations/language/lt_lt.dart index 8f39f215..d44063b0 100644 --- a/apps/client/lib/core/translations/language/lt_lt.dart +++ b/apps/client/lib/core/translations/language/lt_lt.dart @@ -1811,6 +1811,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/lv_lv.dart b/apps/client/lib/core/translations/language/lv_lv.dart index 1f14f813..d2375179 100644 --- a/apps/client/lib/core/translations/language/lv_lv.dart +++ b/apps/client/lib/core/translations/language/lv_lv.dart @@ -1816,6 +1816,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/ms_my.dart b/apps/client/lib/core/translations/language/ms_my.dart index 48ea0c5e..f5bf5b98 100644 --- a/apps/client/lib/core/translations/language/ms_my.dart +++ b/apps/client/lib/core/translations/language/ms_my.dart @@ -1801,6 +1801,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/nb_no.dart b/apps/client/lib/core/translations/language/nb_no.dart index 3e84045d..c2a24a23 100644 --- a/apps/client/lib/core/translations/language/nb_no.dart +++ b/apps/client/lib/core/translations/language/nb_no.dart @@ -1795,6 +1795,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/nl_nl.dart b/apps/client/lib/core/translations/language/nl_nl.dart index 1eb61288..83cd4f12 100644 --- a/apps/client/lib/core/translations/language/nl_nl.dart +++ b/apps/client/lib/core/translations/language/nl_nl.dart @@ -1692,6 +1692,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/pl_pl.dart b/apps/client/lib/core/translations/language/pl_pl.dart index 55a542f1..6fe9e821 100644 --- a/apps/client/lib/core/translations/language/pl_pl.dart +++ b/apps/client/lib/core/translations/language/pl_pl.dart @@ -1822,6 +1822,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/pt_pt.dart b/apps/client/lib/core/translations/language/pt_pt.dart index d50bdac3..cd4beeb9 100644 --- a/apps/client/lib/core/translations/language/pt_pt.dart +++ b/apps/client/lib/core/translations/language/pt_pt.dart @@ -2036,6 +2036,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/ro_ro.dart b/apps/client/lib/core/translations/language/ro_ro.dart index 7f2321fd..6469008a 100644 --- a/apps/client/lib/core/translations/language/ro_ro.dart +++ b/apps/client/lib/core/translations/language/ro_ro.dart @@ -1824,6 +1824,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/ru_ru.dart b/apps/client/lib/core/translations/language/ru_ru.dart index 1ba0181d..214df7ec 100644 --- a/apps/client/lib/core/translations/language/ru_ru.dart +++ b/apps/client/lib/core/translations/language/ru_ru.dart @@ -2012,6 +2012,22 @@ const Map 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': 'Готово', diff --git a/apps/client/lib/core/translations/language/sk_sk.dart b/apps/client/lib/core/translations/language/sk_sk.dart index 506c29a9..67a0aafc 100644 --- a/apps/client/lib/core/translations/language/sk_sk.dart +++ b/apps/client/lib/core/translations/language/sk_sk.dart @@ -1805,6 +1805,22 @@ const Map 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é', diff --git a/apps/client/lib/core/translations/language/sl_si.dart b/apps/client/lib/core/translations/language/sl_si.dart index 98be2ba9..8793bbaa 100644 --- a/apps/client/lib/core/translations/language/sl_si.dart +++ b/apps/client/lib/core/translations/language/sl_si.dart @@ -1808,6 +1808,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/sr_rs.dart b/apps/client/lib/core/translations/language/sr_rs.dart index 9e352192..abc92a1a 100644 --- a/apps/client/lib/core/translations/language/sr_rs.dart +++ b/apps/client/lib/core/translations/language/sr_rs.dart @@ -1804,6 +1804,22 @@ const Map 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': 'Спремно', diff --git a/apps/client/lib/core/translations/language/sv_se.dart b/apps/client/lib/core/translations/language/sv_se.dart index 79e24064..c16bb1f8 100644 --- a/apps/client/lib/core/translations/language/sv_se.dart +++ b/apps/client/lib/core/translations/language/sv_se.dart @@ -1800,6 +1800,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/th_th.dart b/apps/client/lib/core/translations/language/th_th.dart index 5d97acb2..a080db37 100644 --- a/apps/client/lib/core/translations/language/th_th.dart +++ b/apps/client/lib/core/translations/language/th_th.dart @@ -1755,6 +1755,22 @@ const Map 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': 'พร้อมแล้ว', diff --git a/apps/client/lib/core/translations/language/tl_ph.dart b/apps/client/lib/core/translations/language/tl_ph.dart index 6ee4fbde..92912439 100644 --- a/apps/client/lib/core/translations/language/tl_ph.dart +++ b/apps/client/lib/core/translations/language/tl_ph.dart @@ -1838,6 +1838,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/tr_tr.dart b/apps/client/lib/core/translations/language/tr_tr.dart index ba5e2dca..58a989ec 100644 --- a/apps/client/lib/core/translations/language/tr_tr.dart +++ b/apps/client/lib/core/translations/language/tr_tr.dart @@ -1793,6 +1793,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/uk_ua.dart b/apps/client/lib/core/translations/language/uk_ua.dart index 83828b43..89b3f48a 100644 --- a/apps/client/lib/core/translations/language/uk_ua.dart +++ b/apps/client/lib/core/translations/language/uk_ua.dart @@ -1811,6 +1811,22 @@ const Map 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': 'Готово', diff --git a/apps/client/lib/core/translations/language/vi_vn.dart b/apps/client/lib/core/translations/language/vi_vn.dart index 7d38d9e9..e2f972d4 100644 --- a/apps/client/lib/core/translations/language/vi_vn.dart +++ b/apps/client/lib/core/translations/language/vi_vn.dart @@ -1774,6 +1774,22 @@ const Map 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', diff --git a/apps/client/lib/core/translations/language/zh_cn.dart b/apps/client/lib/core/translations/language/zh_cn.dart index ce16990a..beb73cb8 100644 --- a/apps/client/lib/core/translations/language/zh_cn.dart +++ b/apps/client/lib/core/translations/language/zh_cn.dart @@ -1820,6 +1820,22 @@ const Map 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': '已就绪', diff --git a/apps/client/lib/core/translations/language/zh_hk.dart b/apps/client/lib/core/translations/language/zh_hk.dart index 6178ebac..ba3ba4bc 100644 --- a/apps/client/lib/core/translations/language/zh_hk.dart +++ b/apps/client/lib/core/translations/language/zh_hk.dart @@ -1804,6 +1804,22 @@ const Map 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': '已就緒', diff --git a/apps/client/lib/core/translations/language/zh_tw.dart b/apps/client/lib/core/translations/language/zh_tw.dart index 3b4271ee..6795a893 100644 --- a/apps/client/lib/core/translations/language/zh_tw.dart +++ b/apps/client/lib/core/translations/language/zh_tw.dart @@ -1804,6 +1804,22 @@ const Map 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': '已就緒', diff --git a/apps/client/lib/devices/bes/bes_bluetooth_service.dart b/apps/client/lib/devices/bes/bes_bluetooth_service.dart index a61864d1..bdf6ba27 100644 --- a/apps/client/lib/devices/bes/bes_bluetooth_service.dart +++ b/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 startTranslationDownlink() => _manager.startCallDownlink(); + /// G.722 编码与 20ms 节流都在原生做,Dart 只丢 PCM)。 + /// + /// [f2f] = Echo-one 面对面:包头换成 `AA 69`,并且 leg A(A 耳说的话的译文) + /// 要播到 **B 耳**。上行 A 耳 = micData 槽、B 耳 = spkData 槽,下行沿用同一套槽位 + /// 就得交叉放(swapLegs)。槽位对不对看原生 `BesCallDownlink` 日志。 + Future startTranslationDownlink({bool f2f = false}) => f2f + ? _manager.startCallDownlink( + header: BesCmd.sof, cmd: BesF2f.downlink, swapLegs: true) + : _manager.startCallDownlink(); Future stopTranslationDownlink() => _manager.stopCallDownlink(); @@ -820,12 +827,12 @@ class BesBluetoothService extends GetxService { /// 通知耳机「ASR 已关闭」(`AA 58`) Future notifyAsrStopped() => sendData(BesCmd.frame(BesCmd.asrStopped)); - /// 开始面对面翻译(`AA 66`)。耳机就绪后回 `0xE6`; - /// 耳机按键触发时也会主动上报 `0xE6`。 - Future startFaceToFace() => sendData(BesCmd.frame(BesCmd.faceToFaceStart)); + /// 开始 Echo-one 面对面翻译(`AA 66`)。Android 原生拦下后先经 SPP 发 `AA 19 01` 切 BLE、 + /// 连好左右耳再经 BLE 发 `AA 66`。耳机按键发起时会主动上报 `BB 66`。 + Future startFaceToFace() => sendData(BesF2f.command(BesF2f.start)); - /// 结束面对面翻译(`AA 67`),设备回 `0xE7` - Future stopFaceToFace() => sendData(BesCmd.frame(BesCmd.faceToFaceStop)); + /// 结束 Echo-one 面对面翻译(`AA 67`)。原生经 BLE 发 `AA 67`,再发 `AA 19 00` 切回经典蓝牙 + Future stopFaceToFace() => sendData(BesF2f.command(BesF2f.stop)); /// 开始设备端主麦录音(`AA 59`)。设备就绪后会回 `0xD9`, /// 音频以 mSBC 帧上行,由原生解码后走 [audioStream]。 diff --git a/apps/client/lib/devices/bes/bes_device_plugin.dart b/apps/client/lib/devices/bes/bes_device_plugin.dart index b1ba95b6..37c2ea15 100644 --- a/apps/client/lib/devices/bes/bes_device_plugin.dart +++ b/apps/client/lib/devices/bes/bes_device_plugin.dart @@ -252,6 +252,7 @@ class BesDeviceSession extends DeviceSession { StreamController.broadcast(); final List _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> featureFrames = { + DeviceFeatures.f2fStart: [BesCmd.sof, BesF2f.start], + DeviceFeatures.f2fStop: [BesCmd.sof, BesF2f.stop], + }; + @override Future> invokeFeature(String featureKey, [Map 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 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; diff --git a/apps/client/lib/devices/bes/bes_protocol.dart b/apps/client/lib/devices/bes/bes_protocol.dart index f0cd1080..9b6cfa02 100644 --- a/apps/client/lib/devices/bes/bes_protocol.dart +++ b/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 frame(int cmd) => [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 command(int cmd) => [BesCmd.sof, cmd & 0xFF]; +} + /// 耳机 → App 的响应/上报(`BB xx`)。 class BesResp { BesResp._(); diff --git a/apps/client/lib/modules/translation/controllers/translation_controller.dart b/apps/client/lib/modules/translation/controllers/translation_controller.dart index 467d3c95..1169be7c 100644 --- a/apps/client/lib/modules/translation/controllers/translation_controller.dart +++ b/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? _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? _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()) 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(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 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.to.inCall.value) { + return 'faceToFaceBusyCall'; + } + if (Get.isRegistered() && + DeviceAiSessionService.to.isActive.value) { + return 'faceToFaceBusy'; + } + if (Get.isRegistered() && + DeviceRecordingService.to.isRecording.value) { + return 'faceToFaceBusy'; + } + if (Get.isRegistered() && + DeviceCallRecordingService.to.isRecording.value) { + return 'faceToFaceBusy'; + } + if (f2fRecovering.value) return 'faceToFaceRestoring'; + return null; + } + + /// 没找到可用耳机会话时把 Hub 里的情况全打出来:v97 就是这里返回了 null、 + /// AA 66 根本没发,然后退回了手机麦——日志里只有一句「未连接」,分不清是没会话还是没 ready。 + void _logF2fNoSession() { + if (!Get.isRegistered()) { + 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 _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 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()) 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 _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(); + 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 _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 _attachBesCallTranslationBridge() async { + Future _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 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 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 _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 的前台服务。 diff --git a/apps/client/lib/modules/translation/views/translation_view.dart b/apps/client/lib/modules/translation/views/translation_view.dart index 60a9318a..4e382e57 100644 --- a/apps/client/lib/modules/translation/views/translation_view.dart +++ b/apps/client/lib/modules/translation/views/translation_view.dart @@ -524,7 +524,8 @@ class TranslationView extends GetView { // 面对面翻译:左边胶囊 = 左耳,右边胶囊 = 右耳。 // 两只耳朵各自朝向一位说话人,所以要在语言旁边标清楚是哪一只。 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 { 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 { ); } - /// 面对面翻译里标注这一侧对应哪只耳机 - 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) { diff --git a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt index eecfa5a0..299b4909 100644 --- a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt +++ b/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 ,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) = 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 diff --git a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt index c0a4a9ec..10dbd29d 100644 --- a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt +++ b/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("header") ?: 0xAA, + cmd = call.argument("cmd") ?: 0x56, + swap = call.argument("swapLegs") ?: false, + ) result.success(true) } // 通话翻译 PCM 原生直连桥(见 BluetoothManager.callPcmTap)。 diff --git a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/CallTranslationDownlink.kt b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/CallTranslationDownlink.kt index 522c50c0..4c7c1e60 100644 --- a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/CallTranslationDownlink.kt +++ b/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(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( diff --git a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/F2fBleLink.kt b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/F2fBleLink.kt new file mode 100644 index 00000000..53831ce7 --- /dev/null +++ b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/F2fBleLink.kt @@ -0,0 +1,1070 @@ +package com.example.bluetooth_manager + +import android.annotation.SuppressLint +import android.bluetooth.BluetoothAdapter +import android.bluetooth.BluetoothDevice +import android.bluetooth.BluetoothGatt +import android.bluetooth.BluetoothGattCallback +import android.bluetooth.BluetoothGattCharacteristic +import android.bluetooth.BluetoothGattDescriptor +import android.bluetooth.BluetoothProfile +import android.bluetooth.BluetoothStatusCodes +import android.bluetooth.le.ScanCallback +import android.bluetooth.le.ScanResult +import android.bluetooth.le.ScanSettings +import android.content.Context +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.SystemClock +import android.util.Log +import java.util.UUID +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.RejectedExecutionException + +/** + * Echo-one 面对面的 **BLE 双耳链路**(协议见 docs/face_to_face_ble_protocol.md v0.4)。 + * + * 时序(App → 耳机全是 AA 帧头): + * ① SPP 发一帧 `AA 19 06 02 01 01` 切 BLE 链路 → 左右耳各自发 BLE 广播(**`Echo-one-L` / `Echo-one-R`**); + * ② App 扫到两只、各建一条 GATT(MTU + 开通知); + * ③ 两耳都就绪后经 BLE 给两耳发 `AA 66` 启动面对面; + * ④ 左耳收音 → 译文播右耳,右耳收音 → 译文播左耳; + * ⑤ 结束:BLE 发 `AA 67`,[LINK_SWITCH_GAP_MS] 后再发一帧 `AA 19 06 02 01 00` 切回经典蓝牙,然后断 GATT、回连 SPP。 + * Dart 仍只发 `AA 66` / `AA 67`,由 BluetoothManager.writeBytes 转给 [start] / [requestStop] 编排。 + * + * 细节: + * - **集齐一左一右再连**:扫描 [SCAN_TIMEOUT_MS] 内要扫到 [LEFT_NAME]、[RIGHT_NAME] 各一个广播,只扫到一侧/一个都没有 + * 判失败回滚;同侧的第二个广播(附近别的 Echo-one)忽略;名字写死,`-L` = 左耳 = leg A,其它名字一律不认; + * - 两条 GATT 同时连,[CONNECT_TIMEOUT_MS] 内两耳都要就绪(MTU + 开通知),否则失败回滚; + * - 服务 00001100-d102-…,可写特征 / 通知特征按 properties 挑(真机 1101 可写、1102 notify); + * - 上行:每只耳机只传**自己那一路**(BB 68),左耳 → `micData`(leg A),右耳 → `spkData` + * (leg B),之后与 SPP 路径完全一样(直连桥 / 录音 / Dart 音频流); + * - 下行:[CallTranslationDownlink] 两耳各自起停,有译文才发(见 [writeEarAudio]); + * - 会话中单耳掉线:该耳 `reconnecting`,重扫重连 [EAR_RECOVER_MS],重连就绪后给它补发 `AA 66`,期间对耳下行停发;超时判失败; + * - 结束后进入**恢复期**(回连 SPP / 冷却),恢复期内不许再进面对面([recovering]); + * 一只 BLE 都没连上(扫描失败等)时 `AA 67` + `AA 19 00` 改经 SPP 发(SPP 还在的话); + * - 异常退出(`AA 19 00` 没发到耳机)记 [PREF_EXIT_PENDING],下次 SPP 连上只清标记、记日志。 + * + * 会话期间 SPP 断开**不上报**断连([suppressSppDown]),Dart 侧会话保持 ready。 + * + * 左右耳链路状态经音频事件流上报 `{type: f2fLink, side: L/R, state}`: + * scanning → connecting → ready → streaming(收到第一帧上行);会话中掉线 reconnecting; + * 结束 restoring → idle。整体事件 side 为空:`failed`(带 reason)/ `recovered`(带 ok)。 + * Dart 收到 failed 就停掉面对面并提示(Dart 不理的话 [FAIL_STOP_MS] 后原生自己收尾)。 + * + * ⚠️ 前几条广播/通知/下发都打 Log.w(tag BesF2fBle),真机对照日志用。iOS 暂未实现。 + */ +@SuppressLint("MissingPermission") +object F2fBleLink { + private const val TAG = "BesF2fBle" + + private val SERVICE_UUID: UUID = UUID.fromString("00001100-d102-11e1-9b23-00025b00afae") + private val WRITE_UUID: UUID = UUID.fromString("00001101-d102-11e1-9b23-00025b00afae") + private val NOTIFY_UUID: UUID = UUID.fromString("00001102-d102-11e1-9b23-00025b00afae") + private val CCCD_UUID: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb") + + /** 从切到 BLE 起这么久还没扫到两只耳机,判失败回滚 */ + private const val SCAN_TIMEOUT_MS = 15_000L + /** 集齐两只后这么久两耳还没都就绪(GATT + MTU + 通知),判失败回滚 */ + private const val CONNECT_TIMEOUT_MS = 10_000L + /** 会话中单耳掉线后的重扫重连窗口 */ + private const val EAR_RECOVER_MS = 10_000L + /** 上报 failed 后 Dart 迟迟不发 AA 67,原生自己收尾([requestStop]) */ + private const val FAIL_STOP_MS = 3_000L + /** 会话期间每隔这么久打一次两耳统计(看「就绪了但没上行」) */ + private const val STATS_LOG_MS = 5_000L + /** 收音中的耳机这么久没来上行就把标签退回 ready(放回仓里时 GATT 要等超时才断,先让 UI 变) */ + private const val UPLINK_SILENT_MS = 1_500L + private const val SILENCE_CHECK_MS = 500L + + /** 固件在面对面里的左右耳广播名(写死,只认这两个) */ + private const val LEFT_NAME = "Echo-one-L" + private const val RIGHT_NAME = "Echo-one-R" + private const val REQUEST_MTU = 247 + /** 建链阶段单耳 GATT 断开的重试次数 */ + private const val GATT_RETRIES = 3 + /** 每只耳机的待写包上限(≈160ms),满了丢最旧的 */ + private const val WRITE_QUEUE_CAP = 8 + /** 写回调迟迟不来就当它丢了,别把队列永远卡住 */ + private const val WRITE_STALL_MS = 500L + /** 结束时 AA 67 发出后隔这么久再发 AA 19 00(先让耳机退出面对面,再切链路) */ + private const val LINK_SWITCH_GAP_MS = 300L + /** AA 19 00 发出后隔这么久再断 GATT(BLE 写出去要一点时间) */ + private const val GATT_CLOSE_DELAY_MS = 400L + + /** 链路切换。固件认的是设置帧 `AA 19 06 02 01 `(与 AA 66 同构),value 1 = BLE,0 = 经典蓝牙。只发这一帧,不换格式重试。 */ + private const val CMD_LINK: Byte = 0x19 + private const val LINK_BLE = 1 + private const val LINK_CLASSIC = 0 + private const val CMD_START: Byte = 0x66 + private const val CMD_STOP: Byte = 0x67 + /** BLE 上 AA 66 发出后等 BB E6 / 上行的时间;没动静就把这只耳机改成带响应写再发一次 */ + private const val START_ACK_WAIT_MS = 1_500L + + /** 结束后耳机从 BLE 切回经典蓝牙要一会儿,再回连 SPP */ + private const val SPP_RECONNECT_DELAY_MS = 1_500L + /** SPP 全程没断:给耳机切回经典模式留的冷却 */ + private const val RECOVER_COOLDOWN_MS = 2_000L + /** 恢复期最长这么久,回连卡住也强制结束 */ + private const val RECOVER_CAP_MS = 30_000L + + private const val PREFS = "f2f_ble_link" + private const val PREF_EXIT_PENDING = "exitPending" + + private const val SOF_AA: Byte = 0xAA.toByte() + private const val SOF_BB: Byte = 0xBB.toByte() + private const val SOF_CC: Byte = 0xCC.toByte() + + /** + * BLE 上的启动帧 `AA 66 06 02 01 01`(与 AA 19 同为设置类格式,已与固件确认)。 + * v106 真机:裸 `AA 66` 经 BLE 写给两耳,70s 内两耳零通知、无 BB E6。 + */ + private fun startFrame() = byteArrayOf(SOF_AA, CMD_START, 0x06, 0x02, 0x01, 0x01) + + private val handler = Handler(Looper.getMainLooper()) + private var appContext: Context? = null + + /** 面对面 BLE 会话进行中(切到 BLE 之后、断 GATT 之前) */ + @Volatile + var sessionActive = false + private set + + /** 会话已结束、耳机还在恢复(回连 SPP / 冷却):这段时间不许再进面对面 */ + @Volatile + var recovering = false + private set + + /** 会话期间 SPP 已经断过(结束后要回连) */ + @Volatile + var sppLost = false + + /** 会话已结束、SPP 回连还没开始:这段窗口里 SPP 的断连也不上报 */ + @Volatile + var pendingSppReconnect = false + + /** BluetoothManager 用:SPP 断开此刻是否按预期忽略 */ + val suppressSppDown: Boolean + get() = sessionActive || pendingSppReconnect + + /** 建链阶段:scan(等集齐两只)→ connect(两条 GATT 建立中)→ live(两耳都就绪过) */ + private enum class Phase { SCAN, CONNECT, LIVE } + + @Volatile private var phase = Phase.SCAN + @Volatile private var sessionToken = 0 + @Volatile private var failed = false + /** 已进入结束流程(AA 67 → AA 19 00 → 断 GATT),重复的结束请求忽略 */ + @Volatile private var stopping = false + private var recoverToken = 0 + private var sessionStartMs = 0L + private var scanCb: ScanCallback? = null + private val seen = HashSet() + /** 扫描阶段按发现顺序收集到的 Echo-one 广播(地址 → 设备/名字) */ + private val candidates = LinkedHashMap>() + // 广播里没带名字、名字只是系统缓存的候选(真机:主耳经典地址缓存名就叫 Echo-one-R)。 + // 只当备胎:同侧来了广播带名字的就替换掉,也不凭它立刻开连 + private val cachedNameOnly = HashSet() + + @Volatile private var left: Ear? = null + @Volatile private var right: Ear? = null + + private class Ear(val side: String, val device: BluetoothDevice, val name: String) { + val type = if (side == "L") "micData" else "spkData" + val label = if (side == "L") "左耳" else "右耳" + @Volatile var gatt: BluetoothGatt? = null + @Volatile var tx: BluetoothGattCharacteristic? = null + var txWriteType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE + @Volatile var ready = false + @Volatile var closed = false + var retries = 0 + var mtu = 23 + /** 会话中掉线、正在重扫重连 */ + @Volatile var recovering = false + /** 收到本耳的 BB E6(启动应答) */ + @Volatile var startAcked = false + var lastConnectMs = 0L + val recoverDeadline = Runnable { onEarRecoverTimeout(this) } + + // 写队列(synchronized(this)) + val writeQ = ArrayDeque() + var writing = false + var writeStartNs = 0L + + // 上行拆帧 + 解码,只在 exec 上动 + val exec: ExecutorService = Executors.newSingleThreadExecutor { r -> Thread(r, "f2f.ble.$side") } + var stash = ByteArray(4096) + var stashSize = 0 + var decHandle = 0L + var rawMode = false + + /** 最近一次上报给 Dart 的链路状态(去重用) */ + @Volatile var state = "" + + // 统计 + @Volatile var notifies = 0L + @Volatile var lastNotifyMs = 0L + /** 私有通知特征以外的通知(诊断:固件是不是把上行发到别的特征上了) */ + @Volatile var otherNotifies = 0L + var rxUuid: UUID? = null + /** 待开通知的特征(私有通知特征排最后,它开好才算就绪) */ + val pendingCccd = ArrayDeque() + var frames = 0L + var pcmFrames = 0L + var unknown = 0L + var sent = 0L + var dropped = 0L + var writeFail = 0L + var lost = 0 + + fun summary() = "$label ${device.address} 通知=$notifies 其它特征通知=$otherNotifies 上行帧=$frames PCM=$pcmFrames 下发=$sent 丢=$dropped 写失败=$writeFail 掉线=$lost" + } + + fun init(context: Context) { + appContext = context.applicationContext + } + + /** 有至少一只耳机 notify 就绪,下行可以走 BLE */ + val anyEarReady: Boolean + get() = left?.ready == true || right?.ready == true + + // ---------------- 会话 ---------------- + + /** `AA 19 06 02 01 `。切 BLE 时 value=1,切回经典时 value=0。 */ + private fun linkFrame(value: Int): ByteArray = + byteArrayOf(SOF_AA, CMD_LINK, 0x06, 0x02, 0x01, value.toByte()) + + /** + * Dart 下发 AA 66 / 耳机经 SPP 上报 BB 66 时调用,幂等。新会话经 SPP 发一帧 `AA 19 06 02 01 01` 让耳机切 BLE, + * 然后扫左右耳;`AA 66` 本身等两耳 BLE 都就绪后才经 BLE 发([onEarReadyMain])。 + * 返回 false = 拒绝进入(恢复期 / 未初始化),已经上报了 failed。 + */ + @Synchronized + fun start(reason: String): Boolean { + if (sessionActive) return true + if (appContext == null) { + Log.w(TAG, "未初始化,忽略 start($reason)") + publishOverall("failed", "reason" to "notInit") + return false + } + if (recovering) { + Log.w(TAG, "上一场面对面还在恢复中,拒绝进入($reason)") + publishOverall("failed", "reason" to "recovering") + return false + } + sessionActive = true + failed = false + stopping = false + phase = Phase.SCAN + sppLost = false + pendingSppReconnect = false + sessionToken++ + sessionStartMs = SystemClock.elapsedRealtime() + left = null + right = null + setExitPending(true) + Log.w(TAG, "面对面 BLE 会话开始($reason),经 SPP 发一帧 AA 19 06 02 01 01 切 BLE,${SCAN_TIMEOUT_MS / 1000}s 内需扫到两只") + publishSide("L", "scanning") + publishSide("R", "scanning") + val token = sessionToken + handler.post { + if (!sessionActive || token != sessionToken) return@post + val frame = linkFrame(LINK_BLE) + if (!BluetoothManager.writeSppCtrl(frame, "AA 19 切 BLE(${hex(frame, 8)})")) { + fail("noSpp", "SPP 未连接,AA 19 发不出去") + return@post + } + // 切过去的同时就开扫:广播什么时候出来都接得住 + seen.clear() + candidates.clear() + cachedNameOnly.clear() + startScan() + handler.postDelayed(scanDeadline, SCAN_TIMEOUT_MS) + handler.postDelayed({ logStats(token) }, STATS_LOG_MS) + handler.postDelayed({ checkSilence(token) }, SILENCE_CHECK_MS) + } + return true + } + + /** BluetoothManager 收到 `BB 99` 时调。格式已固定,应答只记日志,不再换帧重发。 */ + fun onLinkAck(data: ByteArray) { + Log.w(TAG, "AA 19 应答 ${hex(data, 8)}") + } + + /** 收音中的耳机上行断了 [UPLINK_SILENT_MS]:标签退回 ready,上行一恢复又回 streaming */ + private fun checkSilence(token: Int) { + if (!sessionActive || token != sessionToken) return + val now = SystemClock.elapsedRealtime() + for (e in listOfNotNull(left, right)) { + if (e.ready && e.state == "streaming" && now - e.lastNotifyMs > UPLINK_SILENT_MS) { + Log.w(TAG, "${e.label} 上行已 ${now - e.lastNotifyMs}ms 没数据(通知=${e.notifies}),标签退回 ready") + setState(e, "ready") + } + } + handler.postDelayed({ checkSilence(token) }, SILENCE_CHECK_MS) + } + + private val scanDeadline = Runnable { + if (!sessionActive || phase != Phase.SCAN) return@Runnable + // 一左一右都有、只是其中有缓存名备胎:等到最后也没有更好的,就用它连 + if (candidates.size >= 2) { + Log.w(TAG, "扫描到时:一左一右都有(含缓存名备胎 ${cachedNameOnly.filter { it in candidates }}),照样开连") + onBothFound() + return@Runnable + } + stopScan() + val n = candidates.size + fail(if (n == 1) "oneEar" else "noEar", + "${SCAN_TIMEOUT_MS / 1000}s 内只扫到 $n 个 Echo-one 广播(共见 ${seen.size} 个广播):" + + candidates.entries.joinToString { "${it.key} '${it.value.second}'" }) + } + + private val connectDeadline = Runnable { + if (!sessionActive || phase != Phase.CONNECT) return@Runnable + val l = left + val r = right + if (l?.ready == true && r?.ready == true) return@Runnable + fail("connectTimeout", "集齐后 ${CONNECT_TIMEOUT_MS / 1000}s 内两耳未都就绪:" + + "左耳=${if (l?.ready == true) "就绪" else "连接中"} 右耳=${if (r?.ready == true) "就绪" else "连接中"}") + } + + private fun logStats(token: Int) { + if (!sessionActive || token != sessionToken) return + val ears = listOfNotNull(left, right) + Log.w(TAG, "会话统计(${(SystemClock.elapsedRealtime() - sessionStartMs) / 1000}s 阶段=$phase)" + + if (ears.isEmpty()) ":已扫到 ${candidates.size} 只" else ":" + ears.joinToString(";") { "${it.summary()} 就绪=${it.ready}" }) + handler.postDelayed({ logStats(token) }, STATS_LOG_MS) + } + + /** + * 建链失败 / 耳机掉线恢复超时:告诉 Dart 停掉面对面(它会走正常收尾、发 AA 67)。 + * Dart 没响应(比如不在面对面页)就 [FAIL_STOP_MS] 后自己收掉。每场只报一次。 + */ + private fun fail(code: String, detail: String) { + if (!sessionActive || failed || stopping) return + failed = true + Log.w(TAG, "面对面 BLE 失败[$code]:$detail") + publishOverall("failed", "reason" to code) + val token = sessionToken + handler.postDelayed({ + // 自己收尾也走完整结束流程:AA 67 + AA 19 00 让耳机退出面对面、切回经典蓝牙 + if (sessionActive && token == sessionToken) requestStop("失败[$code]后 Dart 未收尾") + }, FAIL_STOP_MS) + } + + private fun publishSide(side: String, state: String) { + BluetoothManager.publishF2fData(mapOf("type" to "f2fLink", "side" to side, "state" to state)) + } + + private fun publishOverall(state: String, extra: Pair) { + BluetoothManager.publishF2fData(mapOf("type" to "f2fLink", "side" to "", "state" to state, extra)) + } + + private fun setState(ear: Ear, state: String) { + if (ear.closed || ear.state == state) return + ear.state = state + Log.w(TAG, "${ear.label} 状态 → $state") + publishSide(ear.side, state) + } + + /** + * 结束流程(Dart 下发 AA 67 / 失败兜底 / 耳机上报 BB 67 兜底): + * - 有就绪的 BLE 耳机:BLE 发 `AA 67`,[LINK_SWITCH_GAP_MS] 后再发 `AA 19 00`,[GATT_CLOSE_DELAY_MS] 后 [stop]; + * - 一只都没就绪(扫描/建链失败):经 SPP 发 `AA 67` + `AA 19 00`(SPP 已断就发不了),然后 [stop]。 + */ + fun requestStop(reason: String) { + synchronized(this) { + if (!sessionActive || stopping) return + stopping = true + } + // 下行已由 Dart 先停;队列里剩的音频清掉,别挡在结束命令前面 + for (e in listOfNotNull(left, right)) synchronized(e) { e.writeQ.clear() } + val n = writeCtrl(byteArrayOf(SOF_AA, CMD_STOP)) + if (n > 0) { + Log.w(TAG, "AA 67 经 BLE 发给 $n 只耳机,${LINK_SWITCH_GAP_MS}ms 后发 AA 19 00 切回经典蓝牙($reason)") + handler.postDelayed({ + // 排在 AA 67 后面,保证先退面对面再切链路。一帧写给每只已就绪的耳朵。 + val f = linkFrame(LINK_CLASSIC) + val m = writeCtrl(f, front = false) + Log.w(TAG, "AA 19 00 经 BLE 发给 $m 只耳机 ${hex(f, 8)}") + handler.postDelayed({ stop(reason, exitDelivered = m > 0) }, GATT_CLOSE_DELAY_MS) + }, LINK_SWITCH_GAP_MS) + return + } + val back = linkFrame(LINK_CLASSIC) + val viaSpp = !sppLost && BluetoothManager.writeSppCtrl(byteArrayOf(SOF_AA, CMD_STOP), "AA 67") && + BluetoothManager.writeSppCtrl(back, "AA 19 00 切回经典蓝牙(${hex(back, 8)})") + Log.w(TAG, "没有就绪的 BLE 耳机,AA 67 + AA 19 00 ${if (viaSpp) "改经 SPP 发" else "SPP 也不通,发不出去"}($reason)") + handler.postDelayed({ stop(reason, exitDelivered = viaSpp) }, GATT_CLOSE_DELAY_MS) + } + + /** + * 收掉会话:停扫描、断两条 GATT、释放解码器,然后进入恢复期(SPP 断过就回连)。 + * 正常结束走 [requestStop](它先发 AA 67 / AA 19 00 再调这里);直接调只用于用户主动断开。 + * [reconnectSpp]=false 用于用户主动断开(不回连、不进恢复期)。 + * [exitDelivered]=false 表示 AA 19 00 没能发给耳机(BLE、SPP 都不通),记异常退出。 + */ + fun stop(reason: String, reconnectSpp: Boolean = true, exitDelivered: Boolean = true) { + val ears: List + synchronized(this) { + if (!sessionActive) return + sessionActive = false + ears = listOfNotNull(left, right) + left = null + right = null + sessionToken++ + } + if (exitDelivered) setExitPending(false) + val lost = sppLost + sppLost = false + pendingSppReconnect = reconnectSpp && lost + handler.post { + handler.removeCallbacks(scanDeadline) + handler.removeCallbacks(connectDeadline) + ears.forEach { handler.removeCallbacks(it.recoverDeadline) } + stopScan() + } + Log.w(TAG, "面对面 BLE 会话结束($reason)SPP${if (lost) "已断" else "未断"} 切回指令${if (exitDelivered) "已发" else "未送达"};" + + if (ears.isEmpty()) "一只耳机都没连上" else ears.joinToString(";") { it.summary() }) + for (e in ears) { + e.closed = true + e.ready = false + synchronized(e) { e.writeQ.clear(); e.writing = false } + val g = e.gatt + e.gatt = null + try { g?.disconnect() } catch (_: Exception) {} + // 给 disconnect 一点时间真正发出去再 close,否则有的机型链路会挂着 + handler.postDelayed({ try { g?.close() } catch (_: Exception) {} }, 300L) + try { + e.exec.execute { + if (e.decHandle != 0L) { + try { G722Codec.release(e.decHandle) } catch (_: Exception) {} + e.decHandle = 0L + } + } + } catch (_: RejectedExecutionException) {} + e.exec.shutdown() + } + if (!reconnectSpp) { + publishSide("L", "idle") + publishSide("R", "idle") + return + } + beginRecovery(lost) + } + + /** 恢复期:两耳显示 restoring;回连 SPP(或冷却)完成后 [endRecovery] */ + private fun beginRecovery(sppWasLost: Boolean) { + val token: Int + synchronized(this) { + recovering = true + token = ++recoverToken + } + publishSide("L", "restoring") + publishSide("R", "restoring") + Log.w(TAG, "进入恢复期(${if (sppWasLost) "回连 SPP" else "SPP 未断,冷却 ${RECOVER_COOLDOWN_MS}ms"})") + handler.postDelayed({ endRecovery(token, false, "恢复超时 ${RECOVER_CAP_MS / 1000}s") }, RECOVER_CAP_MS) + if (sppWasLost) { + handler.postDelayed({ + BluetoothManager.reconnectSppAfterF2f { ok -> handler.post { endRecovery(token, ok, "SPP 回连${if (ok) "成功" else "失败"}") } } + }, SPP_RECONNECT_DELAY_MS) + } else { + handler.postDelayed({ endRecovery(token, true, "冷却结束") }, RECOVER_COOLDOWN_MS) + } + } + + private fun endRecovery(token: Int, ok: Boolean, why: String) { + synchronized(this) { + if (!recovering || token != recoverToken) return + recovering = false + } + Log.w(TAG, "恢复期结束:$why") + publishSide("L", "idle") + publishSide("R", "idle") + publishOverall("recovered", "ok" to ok) + } + + // ---------------- 异常退出标记 ---------------- + + private fun setExitPending(v: Boolean) { + try { + appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE)?.edit()?.putBoolean(PREF_EXIT_PENDING, v)?.apply() + } catch (_: Exception) {} + } + + /** + * BluetoothManager 在 SPP 连上(状态 2)时调。SPP 能连上说明耳机已回到经典蓝牙链路, + * 上一场 AA 19 00 没送达也不用补发,只清标记、记一笔。 + */ + fun onSppConnected() { + if (sessionActive) return + val prefs = try { appContext?.getSharedPreferences(PREFS, Context.MODE_PRIVATE) } catch (_: Exception) { null } ?: return + if (!prefs.getBoolean(PREF_EXIT_PENDING, false)) return + prefs.edit().putBoolean(PREF_EXIT_PENDING, false).apply() + Log.w(TAG, "上一场面对面异常退出(AA 19 00 未送达),SPP 已连上 = 耳机已回经典蓝牙,不补发") + } + + // ---------------- 下行 ---------------- + + /** + * 下行单路包写给一只耳机([isLeft])。本耳没就绪丢;对耳掉线重连中也丢 + * (单耳失联时两耳下行都停,恢复后自然续上)。返回是否排上。 + */ + fun writeEarAudio(isLeft: Boolean, pkt: ByteArray): Boolean { + val ear = (if (isLeft) left else right) ?: return false + val other = if (isLeft) right else left + if (!ear.ready) return false + if (other != null && !other.ready) return false + return enqueue(ear, pkt, front = false) + } + + /** 控制命令(AA 66 / 67 / 19):发给就绪的耳机,默认插在队首。返回实际排上的耳机数 */ + private fun writeCtrl(msg: ByteArray, front: Boolean = true): Int { + var n = 0 + for (e in listOfNotNull(left, right)) { + if (enqueue(e, msg.copyOf(), front)) n++ + } + return n + } + + private fun enqueue(ear: Ear, bytes: ByteArray, front: Boolean): Boolean { + val g = ear.gatt + val c = ear.tx + if (!ear.ready || g == null || c == null) return false + synchronized(ear) { + if (ear.writing && System.nanoTime() - ear.writeStartNs > WRITE_STALL_MS * 1_000_000L) { + Log.w(TAG, "${ear.label} 写回调超时,强制放行") + ear.writing = false + } + if (ear.writing) { + if (ear.writeQ.size >= WRITE_QUEUE_CAP) { + ear.writeQ.removeFirst() + ear.dropped++ + if (ear.dropped <= 5L || ear.dropped % 200 == 0L) Log.w(TAG, "${ear.label} 写队列满,丢最旧 累计=${ear.dropped}") + } + if (front) ear.writeQ.addFirst(bytes) else ear.writeQ.addLast(bytes) + return true + } + return startWrite(ear, g, c, bytes) + } + } + + /** 持 ear 锁调用 */ + @Suppress("DEPRECATION") + private fun startWrite(ear: Ear, g: BluetoothGatt, c: BluetoothGattCharacteristic, bytes: ByteArray): Boolean { + val ok = try { + if (Build.VERSION.SDK_INT >= 33) { + g.writeCharacteristic(c, bytes, ear.txWriteType) == BluetoothStatusCodes.SUCCESS + } else { + c.writeType = ear.txWriteType + c.value = bytes + g.writeCharacteristic(c) + } + } catch (e: Exception) { + false + } + if (ok) { + ear.writing = true + ear.writeStartNs = System.nanoTime() + ear.sent++ + if (ear.sent <= 6L || ear.sent % 500 == 0L) { + Log.w(TAG, "${ear.label} 下发#${ear.sent} len=${bytes.size} ${hex(bytes, 8)}") + } + } else { + ear.writeFail++ + if (ear.writeFail <= 5L || ear.writeFail % 200 == 0L) Log.w(TAG, "${ear.label} 写失败#${ear.writeFail} len=${bytes.size}") + } + return ok + } + + private fun onWriteDone(ear: Ear, status: Int) { + synchronized(ear) { + ear.writing = false + if (status != BluetoothGatt.GATT_SUCCESS) { + ear.writeFail++ + if (ear.writeFail <= 5L) Log.w(TAG, "${ear.label} 写回调 status=$status") + } + val g = ear.gatt ?: return + val c = ear.tx ?: return + while (ear.writeQ.isNotEmpty()) { + if (startWrite(ear, g, c, ear.writeQ.removeFirst())) break + } + } + } + + // ---------------- 扫描(主线程) ---------------- + + private fun startScan() { + if (!sessionActive || scanCb != null) return + val adapter = BluetoothAdapter.getDefaultAdapter() + val scanner = adapter?.bluetoothLeScanner + if (adapter == null || !adapter.isEnabled || scanner == null) { + fail("bluetoothOff", "蓝牙不可用,无法扫描 BLE") + return + } + val cb = object : ScanCallback() { + override fun onScanResult(callbackType: Int, result: ScanResult) = onScan(result) + override fun onBatchScanResults(results: MutableList) { + results.forEach { onScan(it) } + } + override fun onScanFailed(errorCode: Int) { + scanCb = null + fail("scanFailed", "BLE 扫描失败 code=$errorCode") + } + } + val settings = ScanSettings.Builder().setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY).build() + try { + scanner.startScan(null, settings, cb) + scanCb = cb + } catch (e: Exception) { + fail("scanFailed", "startScan 异常: $e") + } + } + + private fun stopScan() { + val cb = scanCb ?: return + scanCb = null + try { + BluetoothAdapter.getDefaultAdapter()?.bluetoothLeScanner?.stopScan(cb) + } catch (_: Exception) { + } + } + + private fun onScan(r: ScanResult) { + if (!sessionActive) return + val dev = r.device + val addr = dev.address + val rec = r.scanRecord + val advName = rec?.deviceName?.takeIf { it.isNotBlank() } + val fromCache = advName == null + val name = advName ?: (try { dev.name } catch (_: SecurityException) { null }) ?: "" + val hasSvc = rec?.serviceUuids?.any { it.uuid == SERVICE_UUID } == true + val isEcho = name.contains("echo", ignoreCase = true) + val firstSeen = seen.add(addr) + if (firstSeen) { + if (hasSvc || isEcho) { + Log.w(TAG, "广播★ $addr name='$name'${if (fromCache) "(广播无名字,系统缓存名)" else ""} rssi=${r.rssi} svc=$hasSvc adv=${hex(rec?.bytes, 62)}") + } else if (name.isNotEmpty() && seen.size <= 40) { + Log.w(TAG, "广播 $addr name='$name' rssi=${r.rssi}") + } + } + // 只认名字:带私有服务但不叫 Echo 的可能是旁边别的恒玄耳机 + if (!isEcho) return + when (phase) { + Phase.SCAN -> { + if (candidates.containsKey(addr)) return + val side = sideOf(name) + if (side == null) { + if (firstSeen) Log.w(TAG, "广播 $addr '$name' 不是 $LEFT_NAME / $RIGHT_NAME,忽略") + return + } + // 已经有同侧的了:多半是旁边另一副 Echo-one,别凑成两只左耳 + val same = candidates.entries.firstOrNull { sideOf(it.value.second) == side } + if (same != null) { + if (same.key in cachedNameOnly && !fromCache) { + Log.w(TAG, "${if (side == "L") "左" else "右"}耳广播 $addr 带名字 '$name',替换缓存名备胎 ${same.key}") + candidates.remove(same.key) + cachedNameOnly.remove(same.key) + } else { + if (firstSeen) Log.w(TAG, "又扫到一个${if (side == "L") "左" else "右"}耳广播 $addr '$name',已有 ${same.key},忽略") + return + } + } + candidates[addr] = dev to name + if (fromCache) cachedNameOnly.add(addr) + Log.w(TAG, "扫到${if (side == "L") "左" else "右"}耳 $addr '$name'(${SystemClock.elapsedRealtime() - sessionStartMs}ms)" + + if (fromCache) ",但广播里没名字(系统缓存名),先当备胎" else "") + if (candidates.size >= 2 && candidates.keys.none { it in cachedNameOnly }) onBothFound() + } + // 会话中掉线的那只:扫到同一地址再连 + else -> { + val ear = listOfNotNull(left, right).firstOrNull { it.device.address == addr && it.recovering } ?: return + val now = SystemClock.elapsedRealtime() + if (ear.gatt != null || now - ear.lastConnectMs < 1_000L) return + Log.w(TAG, "重新扫到${ear.label} $addr,重连 GATT") + setState(ear, "connecting") + connectGatt(ear) + } + } + } + + /** 两只都扫到了:分左右、停扫描、同时连两条 GATT */ + private fun onBothFound() { + handler.removeCallbacks(scanDeadline) + stopScan() + // 扫描时只收了一左一右 + val l = candidates.values.first { sideOf(it.second) == "L" } + val r = candidates.values.first { sideOf(it.second) == "R" } + Log.w(TAG, "左右耳都已扫到:左耳=${l.first.address} 右耳=${r.first.address},同时发起 GATT 连接") + phase = Phase.CONNECT + val le = Ear("L", l.first, l.second) + val re = Ear("R", r.first, r.second) + left = le + right = re + setState(le, "connecting") + setState(re, "connecting") + connectGatt(le) + connectGatt(re) + handler.postDelayed(connectDeadline, CONNECT_TIMEOUT_MS) + } + + /** 广播名 → 左右:只认 [LEFT_NAME] / [RIGHT_NAME](忽略大小写和首尾空白),其它返回 null */ + private fun sideOf(name: String): String? { + val n = name.trim { it.isWhitespace() || it == '\u0000' } + return when { + n.equals(LEFT_NAME, ignoreCase = true) -> "L" + n.equals(RIGHT_NAME, ignoreCase = true) -> "R" + else -> null + } + } + + /** 会话中单耳恢复超时:整场失败退出 */ + private fun onEarRecoverTimeout(ear: Ear) { + if (!sessionActive || ear.closed || !ear.recovering) return + fail("earLost", "${ear.label} 掉线后 ${EAR_RECOVER_MS / 1000}s 内没重连上") + } + + /** 会话中(两耳都就绪过)某只掉线:停它的收发,重扫重连 */ + private fun onEarLost(ear: Ear) { + if (ear.recovering) return + ear.recovering = true + ear.lost++ + Log.w(TAG, "${ear.label} 会话中掉线(第 ${ear.lost} 次),${EAR_RECOVER_MS / 1000}s 内重扫重连,期间两耳下行停发") + setState(ear, "reconnecting") + handler.postDelayed(ear.recoverDeadline, EAR_RECOVER_MS) + startScan() + } + + // ---------------- GATT ---------------- + + private fun connectGatt(ear: Ear) { + val ctx = appContext ?: return + if (ear.closed || !sessionActive) return + ear.lastConnectMs = SystemClock.elapsedRealtime() + ear.gatt = try { + ear.device.connectGatt(ctx, false, GattCb(ear), BluetoothDevice.TRANSPORT_LE) + } catch (e: Exception) { + Log.w(TAG, "${ear.label} connectGatt 异常: $e") + null + } + if (ear.gatt == null && !ear.recovering) fail("connectFailed", "${ear.label} connectGatt 失败") + } + + private class GattCb(private val ear: Ear) : BluetoothGattCallback() { + override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) { + if (ear.closed) { + try { g.close() } catch (_: Exception) {} + return + } + if (newState == BluetoothProfile.STATE_CONNECTED && status == BluetoothGatt.GATT_SUCCESS) { + Log.w(TAG, "${ear.label} GATT 已连接,请求 MTU $REQUEST_MTU") + if (!g.requestMtu(REQUEST_MTU)) g.discoverServices() + return + } + val wasReady = ear.ready + ear.ready = false + synchronized(ear) { ear.writeQ.clear(); ear.writing = false } + Log.w(TAG, "${ear.label} GATT 断开 status=$status state=$newState 曾就绪=$wasReady 阶段=$phase 重试=${ear.retries}/$GATT_RETRIES") + try { g.close() } catch (_: Exception) {} + if (ear.gatt === g) ear.gatt = null + if (!sessionActive) return + handler.post { + // 结束流程里耳机收到 AA 19 00 会自己断 LE,不算掉线 + if (!sessionActive || ear.closed || stopping) return@post + when { + // 会话中掉线:重扫重连(同一只在恢复中再断,继续等扫描结果) + phase == Phase.LIVE -> onEarLost(ear) + ear.retries < GATT_RETRIES -> { + ear.retries++ + setState(ear, "connecting") + handler.postDelayed({ connectGatt(ear) }, 800L) + } + else -> fail("connectFailed", "${ear.label} GATT 重试 $GATT_RETRIES 次仍断开") + } + } + } + + override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) { + ear.mtu = mtu + Log.w(TAG, "${ear.label} MTU=$mtu status=$status,发现服务") + g.discoverServices() + } + + override fun onServicesDiscovered(g: BluetoothGatt, status: Int) { + if (status != BluetoothGatt.GATT_SUCCESS) { + Log.w(TAG, "${ear.label} 发现服务失败 status=$status") + g.disconnect() + return + } + val svc = g.getService(SERVICE_UUID) + // 诊断:整张 GATT 表打出来(上行一直为 0 时对照固件到底把音频发在哪) + for (s in g.services) { + Log.w(TAG, "${ear.label} 服务 ${s.uuid}: " + + s.characteristics.joinToString { "${it.uuid}(props=0x%02X)".format(it.properties) }) + } + if (svc == null) { + Log.w(TAG, "${ear.label} 没有私有服务,现有: ${g.services.joinToString { it.uuid.toString() }}") + fail("noService", "${ear.label} ${ear.device.address} 没有私有服务") + return + } + val chars = svc.characteristics + Log.w(TAG, "${ear.label} 私有服务特征: " + chars.joinToString { "${it.uuid}(props=0x%02X)".format(it.properties) }) + val notifyMask = BluetoothGattCharacteristic.PROPERTY_NOTIFY or BluetoothGattCharacteristic.PROPERTY_INDICATE + val writeMask = BluetoothGattCharacteristic.PROPERTY_WRITE or BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE + val rx = chars.firstOrNull { it.uuid == NOTIFY_UUID && it.properties and notifyMask != 0 } + ?: chars.firstOrNull { it.properties and notifyMask != 0 } + val tx = chars.firstOrNull { it.uuid == WRITE_UUID && it.properties and writeMask != 0 } + ?: chars.firstOrNull { it.properties and writeMask != 0 } + if (tx != null) { + ear.txWriteType = if (tx.properties and BluetoothGattCharacteristic.PROPERTY_WRITE_NO_RESPONSE != 0) + BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE else BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + } + ear.tx = tx + if (rx == null) { + Log.w(TAG, "${ear.label} 没有可通知的特征,收不到上行") + onReady(g) + return + } + ear.rxUuid = rx.uuid + // 其它服务里能 notify 的特征也开上(只记日志,看固件上行是不是走别的特征), + // 私有通知特征排最后:它的 CCCD 写完才就绪,GATT 操作一次只能有一个 + ear.pendingCccd.clear() + for (s in g.services) { + for (c in s.characteristics) { + if (c !== rx && c.properties and notifyMask != 0 && c.getDescriptor(CCCD_UUID) != null) ear.pendingCccd.addLast(c) + } + } + ear.pendingCccd.addLast(rx) + nextCccd(g) + } + + /** 依次开 [Ear.pendingCccd] 的通知;开完最后一个(私有通知特征)就绪 */ + private fun nextCccd(g: BluetoothGatt) { + while (true) { + val c = ear.pendingCccd.removeFirstOrNull() ?: run { onReady(g); return } + val isRx = c.uuid == ear.rxUuid && ear.pendingCccd.isEmpty() + g.setCharacteristicNotification(c, true) + val d = c.getDescriptor(CCCD_UUID) + val v = if (c.properties and BluetoothGattCharacteristic.PROPERTY_NOTIFY != 0) + BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE else BluetoothGattDescriptor.ENABLE_INDICATION_VALUE + if (d != null && writeDescriptor(g, d, v)) return + Log.w(TAG, "${ear.label} ${c.uuid} CCCD ${if (d == null) "不存在" else "写入失败"}${if (isRx) ",直接当就绪" else ",跳过"}") + } + } + + override fun onDescriptorWrite(g: BluetoothGatt, d: BluetoothGattDescriptor, status: Int) { + Log.w(TAG, "${ear.label} 通知已开 ${d.characteristic.uuid} status=$status") + if (ear.closed) return + nextCccd(g) + } + + @Deprecated("Deprecated in Java") + @Suppress("DEPRECATION") + override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic) { + // 33+ 走下面那个带 value 的回调 + if (Build.VERSION.SDK_INT >= 33) return + val v = c.value ?: return + onChanged(c, v.copyOf()) + } + + override fun onCharacteristicChanged(g: BluetoothGatt, c: BluetoothGattCharacteristic, value: ByteArray) { + onChanged(c, value.copyOf()) + } + + private fun onChanged(c: BluetoothGattCharacteristic, v: ByteArray) { + if (c.uuid == ear.rxUuid) { + onNotify(ear, v) + return + } + val n = ++ear.otherNotifies + if (n <= 20L || n % 500 == 0L) Log.w(TAG, "${ear.label} 其它特征通知#$n ${c.uuid} len=${v.size} ${hex(v, 24)}") + } + + override fun onCharacteristicWrite(g: BluetoothGatt, c: BluetoothGattCharacteristic, status: Int) { + onWriteDone(ear, status) + } + + private fun onReady(g: BluetoothGatt) { + if (ear.ready) return + try { g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH) } catch (_: Exception) {} + ear.ready = true + Log.w(TAG, "${ear.label} 就绪(${SystemClock.elapsedRealtime() - sessionStartMs}ms)mtu=${ear.mtu} tx=${ear.tx?.uuid} " + + "写=${if (ear.txWriteType == BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) "无响应" else "带响应"}") + setState(ear, if (ear.pcmFrames > 0) "streaming" else "ready") + handler.post { onEarReadyMain(ear) } + } + } + + private fun onEarReadyMain(ear: Ear) { + if (!sessionActive || stopping || ear.closed || !ear.ready) return + if (ear.recovering) { + ear.recovering = false + handler.removeCallbacks(ear.recoverDeadline) + // 掉线的耳机可能已退出面对面,重连后给它单独补发 AA 66 + ear.startAcked = false + val ok = enqueue(ear, startFrame(), front = true) + Log.w(TAG, "${ear.label} 已重连就绪,经 BLE 补发 AA 66 06 02 01 01${if (ok) "" else "失败"},恢复下行") + val token = sessionToken + handler.postDelayed({ checkStartAck(ear, token) }, START_ACK_WAIT_MS) + } + val l = left + val r = right + if (l?.ready == true && r?.ready == true) { + if (phase == Phase.CONNECT) { + phase = Phase.LIVE + handler.removeCallbacks(connectDeadline) + // 握手完成才启动面对面:AA 66 06 02 01 01 经 BLE 左右耳各发一份 + l.startAcked = false + r.startAcked = false + val n = writeCtrl(startFrame()) + Log.w(TAG, "左右耳都已就绪(${SystemClock.elapsedRealtime() - sessionStartMs}ms),AA 66 06 02 01 01 经 BLE 发给 $n 只耳机,面对面正式开始") + val token = sessionToken + handler.postDelayed({ checkStartAck(l, token); checkStartAck(r, token) }, START_ACK_WAIT_MS) + } + stopScan() + } + } + + /** + * AA 66 发出 [START_ACK_WAIT_MS] 后本耳既没 BB E6 也没上行:多半是固件只认带响应的写, + * 支持的话这只耳机以后都改带响应写,并重发一次启动帧 + */ + private fun checkStartAck(ear: Ear, token: Int) { + if (!sessionActive || token != sessionToken || stopping || ear.closed || !ear.ready) return + if (ear.startAcked || ear.frames > 0) return + val c = ear.tx ?: return + if (ear.txWriteType == BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE && + c.properties and BluetoothGattCharacteristic.PROPERTY_WRITE != 0) { + ear.txWriteType = BluetoothGattCharacteristic.WRITE_TYPE_DEFAULT + val ok = enqueue(ear, startFrame(), front = true) + Log.w(TAG, "${ear.label} AA 66 发出 ${START_ACK_WAIT_MS}ms 没有 BB E6 也没上行,改带响应写重发${if (ok) "" else "失败"}") + } else { + Log.w(TAG, "${ear.label} AA 66 发出 ${START_ACK_WAIT_MS}ms 仍没有 BB E6 也没上行(写=${ + if (ear.txWriteType == BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE) "无响应" else "带响应"})") + } + } + + @Suppress("DEPRECATION") + private fun writeDescriptor(g: BluetoothGatt, d: BluetoothGattDescriptor, v: ByteArray): Boolean = + if (Build.VERSION.SDK_INT >= 33) { + g.writeDescriptor(d, v) == BluetoothStatusCodes.SUCCESS + } else { + d.value = v + g.writeDescriptor(d) + } + + // ---------------- 上行(各耳自己的线程) ---------------- + + private fun onNotify(ear: Ear, v: ByteArray) { + if (v.isEmpty() || ear.closed) return + val n = ++ear.notifies + ear.lastNotifyMs = SystemClock.elapsedRealtime() + if (n <= 8L || n % 1000 == 0L) Log.w(TAG, "${ear.label} 通知#$n len=${v.size} ${hex(v, 24)}") + try { + ear.exec.execute { parse(ear, v) } + } catch (_: RejectedExecutionException) { + } + } + + private fun parse(ear: Ear, v: ByteArray) { + if (ear.closed) return + // 不是 BB/CC 帧、长度又正好是 40 的整数倍:当裸 G.722 解 + if (ear.stashSize == 0 && v[0] != SOF_BB && v[0] != SOF_CC && v.size % 40 == 0) { + if (!ear.rawMode) { + ear.rawMode = true + Log.w(TAG, "${ear.label} 通知不是 BB/CC 帧且长度为 40 的倍数,按裸 G.722 解") + } + decodeChunks(ear, v, 0) + return + } + val required = ear.stashSize + v.size + if (required > 65536) { + ear.stashSize = 0 + return + } + if (required > ear.stash.size) { + var cap = ear.stash.size + while (cap < required) cap = cap shl 1 + ear.stash = ear.stash.copyOf(cap) + } + System.arraycopy(v, 0, ear.stash, ear.stashSize, v.size) + ear.stashSize += v.size + + val buf = ear.stash + var cursor = 0 + while (ear.stashSize - cursor >= 3) { + while (cursor < ear.stashSize && buf[cursor] != SOF_BB && buf[cursor] != SOF_CC) cursor++ + if (ear.stashSize - cursor < 3) break + var len = buf[cursor + 2].toInt() and 0xFF + if (buf[cursor] == SOF_CC) len += 256 + if (len < 2) { + cursor++ + continue + } + if (ear.stashSize - cursor < len) break + val frame = buf.copyOfRange(cursor, cursor + len) + cursor += len + handleFrame(ear, frame) + } + if (cursor > 0) { + val remaining = ear.stashSize - cursor + if (remaining > 0) System.arraycopy(buf, cursor, buf, 0, remaining) + ear.stashSize = remaining + } + } + + private fun handleFrame(ear: Ear, f: ByteArray) { + when (f[1]) { + 0x68.toByte(), 0x55.toByte(), 0x60.toByte() -> { + ear.frames++ + if (ear.frames <= 5L || ear.frames % 500 == 0L) { + Log.w(TAG, "${ear.label} 上行#${ear.frames} len=${f.size} 头=${hex(f, 8)} 累计PCM=${ear.pcmFrames}") + } + decodeChunks(ear, f, 4) + } + 0x66.toByte() -> { + Log.w(TAG, "${ear.label} 收到启动 ${hex(f, 16)}") + BluetoothManager.publishF2fData(mapOf("type" to "startFaceToFace", "data" to f)) + } + 0x67.toByte() -> { + Log.w(TAG, "${ear.label} 收到结束 ${hex(f, 16)}") + BluetoothManager.publishF2fData(mapOf("type" to "stopFaceToFace", "data" to f)) + // 正常是 Dart 收到后停翻译、回发 AA 67 时收尾;Dart 不理(比如不在面对面页)就兜底走结束流程 + val token = sessionToken + handler.postDelayed({ if (token == sessionToken) requestStop("耳机经 BLE 上报 BB 67") }, 3000L) + } + // AA 66 / AA 67 / AA 19 的应答;只记日志,启停以 App 侧流程为准 + 0xE6.toByte() -> { + ear.startAcked = true + Log.w(TAG, "${ear.label} 收到启动应答 ${hex(f, 16)}") + } + 0xE7.toByte() -> Log.w(TAG, "${ear.label} 收到结束应答 ${hex(f, 16)}") + 0x99.toByte() -> Log.w(TAG, "${ear.label} 收到 AA 19 应答 ${hex(f, 16)}") + else -> { + ear.unknown++ + if (ear.unknown <= 20L) Log.w(TAG, "${ear.label} 未识别帧 ${hex(f, 16)}") + } + } + } + + private fun decodeChunks(ear: Ear, data: ByteArray, start: Int) { + if (ear.decHandle == 0L) ear.decHandle = G722Codec.init(16000) + val chunk = ByteArray(40) + var pos = start + while (pos + 40 <= data.size) { + System.arraycopy(data, pos, chunk, 0, 40) + val pcm = try { G722Codec.decodeSync(ear.decHandle, chunk) } catch (_: Exception) { null } + if (pcm != null) { + // 第一帧,或者上行断过一阵(checkSilence 退回了 ready)后又来了 + if (++ear.pcmFrames == 1L || ear.state == "ready") setState(ear, "streaming") + BluetoothManager.publishF2fData(mapOf("type" to ear.type, "data" to pcm)) + } + pos += 40 + } + } + + private fun hex(data: ByteArray?, max: Int): String { + if (data == null) return "null" + return data.take(max).joinToString(" ") { "%02X".format(it.toInt() and 0xFF) } + if (data.size > max) " …(${data.size})" else "" + } +} diff --git a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManager.swift b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManager.swift index 2464f58d..3ba1c7dc 100644 --- a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManager.swift +++ b/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 = [] + /// 面对面结束回连经典蓝牙用。断连清理不清这个。 + 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) diff --git a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift index c3aa0e2e..07751301 100644 --- a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift +++ b/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)。 diff --git a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/CallTranslationDownlink.swift b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/CallTranslationDownlink.swift index 393382ed..9764c634 100644 --- a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/CallTranslationDownlink.swift +++ b/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.. 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 时恒等于原信号。 diff --git a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/F2fBleLink.swift b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/F2fBleLink.swift new file mode 100644 index 00000000..0898e198 --- /dev/null +++ b/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() + 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() + 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 `。`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 `。切 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 + } +} diff --git a/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager.dart b/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager.dart index 4e0b6fbf..84254ed6 100644 --- a/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager.dart +++ b/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 startCallDownlink() { - return BluetoothManagerPlatform.instance.startCallDownlink(); + /// 启动通话翻译的下行 TTS 发送器(native 负责 G.722 编码与 20ms 节流)。 + /// 不传参即通话翻译 `AA 56`;Echo-one 面对面传 header=0xBB cmd=0x69 swapLegs=true。 + Future startCallDownlink({int? header, int? cmd, bool? swapLegs}) { + return BluetoothManagerPlatform.instance + .startCallDownlink(header: header, cmd: cmd, swapLegs: swapLegs); } Future stopCallDownlink() { diff --git a/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_method_channel.dart b/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_method_channel.dart index 08f5decc..7675a895 100644 --- a/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_method_channel.dart +++ b/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_method_channel.dart @@ -205,8 +205,15 @@ class MethodChannelBluetoothManager extends BluetoothManagerPlatform { } @override - Future startCallDownlink() async { - return await methodChannel.invokeMethod('startCallDownlink') ?? false; + Future startCallDownlink({int? header, int? cmd, bool? swapLegs}) async { + final args = { + if (header != null) 'header': header, + if (cmd != null) 'cmd': cmd, + if (swapLegs != null) 'swapLegs': swapLegs, + }; + return await methodChannel.invokeMethod( + 'startCallDownlink', args.isEmpty ? null : args) ?? + false; } @override diff --git a/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_platform_interface.dart b/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_platform_interface.dart index 4bb38af7..bb245437 100644 --- a/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_platform_interface.dart +++ b/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 startCallDownlink() { + Future startCallDownlink({int? header, int? cmd, bool? swapLegs}) { throw UnimplementedError('startCallDownlink() has not been implemented.'); } diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/audio_format.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/audio_format.dart index c935435e..da4aad8a 100644 --- a/apps/client/local_plugins/device_plugin_interface/lib/src/audio_format.dart +++ b/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]。 /// diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart index fa4b83d2..1392590b 100644 --- a/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart +++ b/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'; diff --git a/apps/client/pubspec.yaml b/apps/client/pubspec.yaml index 11310110..ab7393ba 100644 --- a/apps/client/pubspec.yaml +++ b/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" diff --git a/apps/client/test/devices/bes_protocol_test.dart b/apps/client/test/devices/bes_protocol_test.dart index 553a7f7d..ea72ce26 100644 --- a/apps/client/test/devices/bes_protocol_test.dart +++ b/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); diff --git a/apps/client/test/devices/bes_session_commands_test.dart b/apps/client/test/devices/bes_session_commands_test.dart index 1b73a2a3..fe47b0c2 100644 --- a/apps/client/test/devices/bes_session_commands_test.dart +++ b/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('恒玄会话声明的能力集:只增不删', () {