Browse Source

client:Echo-one 面对面改走双耳 BLE,并扩展翻译语种、收掉安卓保活特征。

Echo-translation 与 Echo-one 共用恒玄链路;繁体、乌克兰语、克罗地亚语改走微软翻译。构建号 1.0.2+27。

Co-authored-by: Cursor <cursoragent@cursor.com>
along-test
Rodger-Wang 2 days ago
parent
commit
14adc84e9c
  1. 30
      apps/client/android/app/src/main/AndroidManifest.xml
  2. 20
      apps/client/android/app/src/main/kotlin/com/yimai/eaimar/MyApplication.kt
  3. BIN
      apps/client/assets/images/devices/Echo-translation.png
  4. BIN
      apps/client/assets/images/devices/Echo-translation_icon.png
  5. 313
      apps/client/docs/面对面翻译-双耳BLE协议-给固件.md
  6. 2
      apps/client/ios/Podfile.lock
  7. 14
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  8. 40
      apps/client/lib/core/translations/language/ar_sa.dart
  9. 40
      apps/client/lib/core/translations/language/bg_bg.dart
  10. 40
      apps/client/lib/core/translations/language/cs_cz.dart
  11. 40
      apps/client/lib/core/translations/language/da_dk.dart
  12. 40
      apps/client/lib/core/translations/language/de_de.dart
  13. 40
      apps/client/lib/core/translations/language/el_gr.dart
  14. 55
      apps/client/lib/core/translations/language/en_us.dart
  15. 40
      apps/client/lib/core/translations/language/es_es.dart
  16. 40
      apps/client/lib/core/translations/language/et_ee.dart
  17. 40
      apps/client/lib/core/translations/language/fa_ir.dart
  18. 40
      apps/client/lib/core/translations/language/fi_fi.dart
  19. 40
      apps/client/lib/core/translations/language/fr_fr.dart
  20. 40
      apps/client/lib/core/translations/language/hi_in.dart
  21. 40
      apps/client/lib/core/translations/language/hr_hr.dart
  22. 40
      apps/client/lib/core/translations/language/hu_hu.dart
  23. 40
      apps/client/lib/core/translations/language/id_id.dart
  24. 40
      apps/client/lib/core/translations/language/is_is.dart
  25. 40
      apps/client/lib/core/translations/language/it_it.dart
  26. 40
      apps/client/lib/core/translations/language/ja_jp.dart
  27. 40
      apps/client/lib/core/translations/language/ko_kr.dart
  28. 40
      apps/client/lib/core/translations/language/lt_lt.dart
  29. 40
      apps/client/lib/core/translations/language/lv_lv.dart
  30. 40
      apps/client/lib/core/translations/language/ms_my.dart
  31. 40
      apps/client/lib/core/translations/language/nb_no.dart
  32. 40
      apps/client/lib/core/translations/language/nl_nl.dart
  33. 40
      apps/client/lib/core/translations/language/pl_pl.dart
  34. 40
      apps/client/lib/core/translations/language/pt_pt.dart
  35. 40
      apps/client/lib/core/translations/language/ro_ro.dart
  36. 40
      apps/client/lib/core/translations/language/ru_ru.dart
  37. 40
      apps/client/lib/core/translations/language/sk_sk.dart
  38. 40
      apps/client/lib/core/translations/language/sl_si.dart
  39. 40
      apps/client/lib/core/translations/language/sr_rs.dart
  40. 40
      apps/client/lib/core/translations/language/sv_se.dart
  41. 40
      apps/client/lib/core/translations/language/th_th.dart
  42. 40
      apps/client/lib/core/translations/language/tl_ph.dart
  43. 40
      apps/client/lib/core/translations/language/tr_tr.dart
  44. 40
      apps/client/lib/core/translations/language/uk_ua.dart
  45. 40
      apps/client/lib/core/translations/language/vi_vn.dart
  46. 55
      apps/client/lib/core/translations/language/zh_cn.dart
  47. 48
      apps/client/lib/core/translations/language/zh_hk.dart
  48. 48
      apps/client/lib/core/translations/language/zh_tw.dart
  49. 12
      apps/client/lib/data/models/device_category.dart
  50. 4
      apps/client/lib/data/services/ast_service.dart
  51. 15
      apps/client/lib/data/services/background_launch_consent.dart
  52. 3
      apps/client/lib/data/services/language_configs/alibaba35_language_config.dart
  53. 5
      apps/client/lib/data/services/language_configs/alibaba_language_config.dart
  54. 5
      apps/client/lib/data/services/language_configs/azure_language_config.dart
  55. 166
      apps/client/lib/data/services/language_manager.dart
  56. 147
      apps/client/lib/data/services/microsoft_text_translation.dart
  57. 16
      apps/client/lib/data/services/server_translation_service.dart
  58. 15
      apps/client/lib/data/services/speech_impl/azure_ast_service.dart
  59. 33
      apps/client/lib/devices/bes/bes_bluetooth_service.dart
  60. 17
      apps/client/lib/devices/bes/bes_device_plugin.dart
  61. 267
      apps/client/lib/devices/bes/bes_f2f_protocol.dart
  62. 12
      apps/client/lib/devices/bes/bes_protocol.dart
  63. 23
      apps/client/lib/devices/holder/holder_device_service.dart
  64. 11
      apps/client/lib/modules/devices/views/bes_scan_view.dart
  65. 4
      apps/client/lib/modules/settings/controllers/settings_controller.dart
  66. 245
      apps/client/lib/modules/translation/controllers/translation_controller.dart
  67. 4
      apps/client/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt
  68. 3
      apps/client/local_plugins/amap_service/android/src/main/AndroidManifest.xml
  69. 2
      apps/client/local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationService.kt
  70. 7
      apps/client/local_plugins/azure_speech/android/src/main/AndroidManifest.xml
  71. 31
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  72. 79
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BesF2fPcmBridge.kt
  73. 36
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AudioRecordingForegroundService.kt
  74. 2
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/ScreenCaptureForegroundService.kt
  75. 28
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  76. 85
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BesF2fPcmBridge.swift
  77. 5
      apps/client/local_plugins/ble_service/android/src/main/AndroidManifest.xml
  78. 79
      apps/client/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCompanionManager.kt
  79. 14
      apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt
  80. 26
      apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt
  81. 950
      apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/FaceToFaceBleSession.kt
  82. 4
      apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManager.swift
  83. 33
      apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift
  84. 510
      apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/FaceToFaceBleSession.swift
  85. 13
      apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager.dart
  86. 20
      apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_method_channel.dart
  87. 14
      apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_platform_interface.dart
  88. 7
      apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart
  89. 2
      apps/client/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt
  90. 2
      apps/client/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt
  91. 4
      apps/client/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt
  92. 5
      apps/client/local_plugins/recorder_holder/android/src/main/AndroidManifest.xml
  93. 70
      apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleCompanionManager.kt
  94. 4
      apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleMediaService.kt
  95. 15
      apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleRecord.kt
  96. 26
      apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BootReceiver.kt
  97. 26
      apps/client/local_plugins/recorder_holder/sdk/lib/src/client.dart
  98. 2
      apps/client/pubspec.yaml
  99. 108
      apps/client/test/call_extended_languages_test.dart
  100. 79
      apps/client/test/devices/bes_f2f_protocol_test.dart

30
apps/client/android/app/src/main/AndroidManifest.xml

@ -48,9 +48,8 @@
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW" />
<!-- 添加发送短信权限 -->
<!-- <uses-permission android:name="android.permission.SEND_SMS" /> -->
<!-- 添加查询应用包权限 -->
<uses-permission android:name="android.permission.QUERY_ALL_PACKAGES"
tools:ignore="QueryAllPackagesPermission" />
tools:node="remove" />
<!--允许获取网络状态,用于网络定位(无gps情况下的定位),若需网络定位功能则必选-->
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<!--允许获取wifi网络信息,用于网络定位(无gps情况下的定位),若需网络定位功能则必选-->
@ -61,10 +60,17 @@
<uses-permission android:name="android.permission.ACCESS_BACKGROUND_LOCATION" />
<!--用于申请调用A-GPS模块,卫星定位加速-->
<uses-permission android:name="android.permission.ACCESS_LOCATION_EXTRA_COMMANDS" />
<!--用于申请调用精确闹钟权限-->
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM" />
<!--允许写设备缓存,用于问题排查-->
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
<uses-permission android:name="android.permission.SCHEDULE_EXACT_ALARM"
tools:node="remove" />
<uses-permission android:name="android.permission.WRITE_SETTINGS"
tools:node="remove" />
<!-- 插件合并进来的伴侣设备特权:本应用不靠它们保活 -->
<uses-permission android:name="android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND"
tools:node="remove" />
<uses-permission android:name="android.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND"
tools:node="remove" />
<uses-permission android:name="android.permission.REQUEST_COMPANION_START_FOREGROUND_SERVICES_FROM_BACKGROUND"
tools:node="remove" />
<uses-permission android:name="android.permission.VIBRATE"/>
<uses-permission
android:name="android.permission.RECEIVE_BOOT_COMPLETED"
@ -88,11 +94,9 @@
android:name="androidx.work.impl.background.systemalarm.RescheduleReceiver"
tools:node="remove" />
<!-- ============ 自启动 / 关联启动收敛(应用商店合规) ============ -->
<!-- 下面这几个组件都是「系统扫到已绑定的蓝牙设备 → 唤醒本应用」链路上的,
插件自带的清单把它们声明成 exported=true,等于任何第三方应用都能把本应用
拉起来(关联启动)。它们实际只接收本应用自己创建的 PendingIntent,
同进程投递不需要 exported,这里统一收成 false。 -->
<!-- 插件清单里这几个组件是 exported=true。本应用不再登记能在进程死后
拉起自己的蓝牙扫描;注销旧扫描时仍会按同一个 PendingIntent 调 stopScan,
所以组件留着,但收成 exported=false,避免别的应用拿它们拉起本进程。 -->
<receiver
android:name="com.recorder.holder.BleWakeReceiver"
android:exported="false"
@ -101,6 +105,10 @@
android:name="com.recorder.holder.BleMediaService"
android:exported="false"
tools:replace="android:exported" />
<service
android:name="com.yunqiinnovation.agent_service.BleMediaService"
android:exported="false"
tools:replace="android:exported" />
<receiver
android:name="com.yunqiinnovation.ble_service.BleWakeReceiver"
android:exported="false"

20
apps/client/android/app/src/main/kotlin/com/yimai/eaimar/MyApplication.kt

@ -171,26 +171,10 @@ class MyApp : FlutterApplication() {
}
private fun isAppBeingLaunched(): Boolean {
// 改进的启动检测逻辑
val currentTime = System.currentTimeMillis()
val isRecentStart = (currentTime - appStartTime) < 3000 // 3秒内认为是新启动
val isRecentStart = (System.currentTimeMillis() - appStartTime) < 3000
if (isRecentStart) {
Log.d(TAG, "检测到应用新启动")
return true
}
// 备用检测方法(兼容旧版本)
return try {
val am = getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
val tasks = am.getRunningTasks(1)
val result = tasks.isNotEmpty() && tasks[0].topActivity?.packageName == packageName
Log.d(TAG, "通过 RunningTasks 检测启动状态: $result")
result
} catch (e: Exception) {
Log.w(TAG, "无法获取 RunningTasks,可能缺少权限", e)
// 如果无法获取任务信息,默认认为是启动状态
true
}
return isRecentStart
}
}

BIN
apps/client/assets/images/devices/Echo-translation.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

BIN
apps/client/assets/images/devices/Echo-translation_icon.png

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

313
apps/client/docs/面对面翻译-双耳BLE协议-给固件.md

@ -0,0 +1,313 @@
# Echo-one 面对面翻译 · 双耳 BLE 协议(App 当前实现)
日期:2026-10-10
对象:固件
对应 App:已按本文组包、解析。请对照固件实现逐条核对。
更早的讨论稿见 [face_to_face_ble_protocol.md](face_to_face_ble_protocol.md)(草案 v0.2)。**以本文为准**,不一致的地方按本文改固件或回我。
---
## 1. 形态
两个人各戴一只耳机。TWS 拆开后,左耳、右耳各自一条 BLE 连到同一部手机。手机不收音、不放音,只做字幕和转发。
| 谁在说 | 麦从哪来 | 云端 | 译文播到哪 |
|---|---|---|---|
| 戴左耳的人(源语言) | 左耳 `BB 90`,`EAR=1` | A 路 | 右耳 `AA 91`,`EAR=2` |
| 戴右耳的人(目标语言) | 右耳 `BB 90`,`EAR=2` | B 路 | 左耳 `AA 91`,`EAR=1` |
两条 BLE 互相独立,可以同时上行。经典蓝牙只用来进出这个模式(`AA 6B` / `AA 6C`),不传面对面的音频。
旧的面对面 `AA 66` / `AA 67` 不动,和新模式互斥。通话翻译、AI、录音、OTA 也不并行。正在做这些事时,`AA 6B` 应回 `ERR_BUSY (0x02)`。
---
## 2. 两条 BLE 通道
左右耳的 GATT **完全一样**。耳位不靠 UUID 区分,靠广播里的 `EAR`,以及音频帧低 4 位。
| 特征 | UUID | 属性 | 方向 |
|---|---|---|---|
| Service | `AEAF5F32-0001-4C4B-A100-000000000001` | — | 扫描按这个 UUID 过滤 |
| `CTRL_TX` | `AEAF5F32-0002-4C4B-A100-000000000001` | Write Without Response | App → 耳机,握手 / 配置 / 保活 / 退出 |
| `CTRL_RX` | `AEAF5F32-0003-4C4B-A100-000000000001` | Notify | 耳机 → App,握手应答 / 事件 |
| `AUDIO_UP` | `AEAF5F32-0004-4C4B-A100-000000000001` | Notify | 耳机 → App,本耳麦,`BB 90` |
| `AUDIO_DOWN` | `AEAF5F32-0005-4C4B-A100-000000000001` | Write Without Response | App → 耳机,译文,`AA 91` |
控制和音频必须分开。停流、打断如果排在音频写后面,点结束会没反应。
链路要求(每条 LE 各自满足):
| 项 | 要求 |
|---|---|
| 写类型 | `CTRL_TX`、`AUDIO_DOWN` 都认 **Write Without Response** |
| ATT MTU | App 请求 247。40ms 包大约 86 字节,MTU 至少要装得下一包 |
| PHY | 优先 2M,不行再 1M |
| 连接间隔 | 15~30ms。大于 40ms 译文会断 |
| Slave latency | 0 |
| Supervision timeout | 2~4s。App 被杀掉时来不及发退出,靠这条超时让固件自己恢复 |
BLE 帧格式(和经典蓝牙同形,操作码空间独立):
```text
AA/BB | OP | LEN | payload
```
`LEN` 是 payload 的字节数,不含这 3 字节头。
---
## 3. 启动时序
耳机平时不要广播这个服务。只有收到经典链路上的 `AA 6B` 才拆 TWS、开始广播。
```text
App 经典蓝牙 左耳 BLE 右耳 BLE
│ AA 6B │ │ │
│──────────────────────────►│ 拆 TWS,两耳广播 │ │
│ BB EB │ │ │
│◄──────────────────────────│ │ │
│ 扫描,TOKEN 对上,集齐左右耳后分别连接 │ │
│ 订阅 CTRL_RX、AUDIO_UP │ │ │
│───────────────────────────────────────────────►│─────────────────►│
│ HELLO → CFG → START(每只耳朵各一遍) │ │
│ BB 90 左耳麦 │ │ │
│◄═══════════════════════════════════════════════│ │
│ AA 91 译文给右耳 │ │ │
│══════════════════════════════════════════════════════════════════►│
│ BB 90 右耳麦 / AA 91 译文给左耳,方向相反 │ │
```
两只耳朵的握手互不等待。**两只都回了 `BB 83` 才开始推音频。** 12 秒内没握完,App 整场退出。
---
## 4. 经典蓝牙:进入 / 退出
走现有经典链路(Android SPP,iOS GATT over BR/EDR)。不是 BLE。
### 4.1 进入 `AA 6B`
App 每次进入重新生成 4 字节随机 TOKEN。
```text
AA 6B 06 02 01 <TOKEN 4 字节>
```
| 字节 | 值 | 含义 |
|---|---|---|
| PROTO_VER | `0x02` | 本协议版本 |
| CAP | `0x01` | 只要 G.722 |
App 等 `BB EB` 的上限是 **3 秒**。超时就放弃,并补发 `AA 6C`。
### 4.2 应答 `BB EB`
整帧 10 字节:
```text
BB EB 07 <RESULT> <EAR_MASK> <PROTO_VER> <CODEC> <FRAME_MS> <FW_MINOR> <CLASSIC_KEEP>
```
| 字段 | App 怎么用 |
|---|---|
| RESULT | `0x00` 才继续扫 BLE。非 0 按第 8 节提示用户,并退出 |
| EAR_MASK | bit0 = 左耳可用,bit1 = 右耳可用。**必须是 `0x03`**。缺一只不进入,请直接回 `0x03`,不要拆了一半 |
| CODEC | `0x01` = G.722 |
| FRAME_MS | `20` 或 `40`。决定一包里几帧。其它值 App 按 40 处理 |
| FW_MINOR | 固件小版本,App 记下来,暂不分支 |
| CLASSIC_KEEP | `0x01` = 拆 TWS 后经典链路还在;`0x00` = 经典链路会断 |
`CLASSIC_KEEP=0` 时,App **不当成用户把耳机断开**。退出命令改走每只耳朵的 `AA 06`。下次经典蓝牙再连上,App 会先补发一次 `AA 6C`。
### 4.3 退出 `AA 6C`
```text
AA 6C → BB EC 01 <RESULT>
```
`AA 6C` **没有长度字节**。
必须幂等:当前不在面对面模式,也要回 `BB EC 01 00`,不要报错。App 在异常恢复时会无条件补发。
`RESULT=0x00` 表示已重新组对,A2DP / HFP 恢复。
---
## 5. 广播与扫描
收到 `AA 6B` 且 `RESULT=0`、双耳都可用之后,左右耳各自广播。
厂商数据(AD Type `0xFF`)。Company ID 2 字节之后是下面 9 字节:
```text
F2 F2 | VER | EAR | TOKEN 4 字节 | BATT
```
| 字段 | 要求 |
|---|---|
| MAGIC | 固定 `F2 F2` |
| VER | `0x02`,和 `AA 6B` 的 PROTO_VER 一致 |
| EAR | `0x01` 左耳,`0x02` 右耳。两只耳机的名字可以相同 |
| TOKEN | **原样回显这次 `AA 6B` 的 4 字节**。对不上 App 直接忽略 |
| BATT | 低 7 位电量 0~100,最高位充电标志 |
App 扫描规则:
1. 只扫上面的 Service UUID。
2. TOKEN 必须和这次 `AA 6B` 逐字节相同。旁边另一副同型号耳机不能连上。
3. **必须同时看到 `EAR=1` 和 `EAR=2` 才连接。** 15 秒没集齐就发 `AA 6C` 回滚。
4. iOS 拿不到 BLE MAC,身份只认 TOKEN + EAR。
5. 扫描在 App 前台完成。建议建链期广播间隔 30~50ms。
6. **30 秒内没被连上,该耳自己退出面对面**;两耳都退出后重新组对,并在经典链路上报 `BB EC 01 00`。
连上之后该耳停止广播。
---
## 6. 每只耳朵的握手
GATT 连上后 App 会:请求 MTU 247、连接优先级拉高、优先 2M PHY。
通知顺序:先 `CTRL_RX`,再 `AUDIO_UP`。两个通知都开了才发 HELLO。
左右耳各走一遍,内容相同,TOKEN 相同。
| 步骤 | App 发出 | 耳机应答 | 说明 |
|---|---|---|---|
| 1 | `AA 01 06 02 <TOKEN 4B> 01` | `BB 81 …` | HELLO。最后一字节 `01` 表示角色是 App。TOKEN 再对一次 |
| 2 | `AA 02 03 00 50 00` | `BB 82 …` | 配置。见下表 |
| 3 | `AA 03 00` | `BB 83 …` | 开流。应答成功后开始推 `BB 90` |
| 4 | `AA 05 01 <SEQ>` | 可不回 | 两只都 `BB 83` 之后,每 5 秒一次。只做诊断,**不要因为没收到保活就断链** |
`AA 02` 的 3 字节 payload:
| 字节 | 当前固定值 | 含义 |
|---|---|---|
| VAD_MODE | `0x00` | 常发。静音也发,不要在耳机上切句 |
| DOWN_GAIN | `0x50`(80) | 下行播放增益 0~100 |
| TRIGGER | `0x00` | 免按键,连续拾音 |
HELLO 应答 `BB 81`:App **只看 RESULT 是不是 0**。RESULT 非 0,这只耳朵判失败,整场退出。
`FRAME_MS` 以 `BB EB` 里那个字节为准,不从 `BB 81` 再改一回。
`BB 82` / `BB 83` 的 RESULT 非 0,同样整场退出。
开流之后如果要暂停而不断开,用 `AA 04 00`(STOP_STREAM)。当前正常结束不发这条,直接走第 7 节退出。
---
## 7. 退出
顺序:
1. 每只还连着的耳朵,`CTRL_TX` 发 `AA 06 00`。耳机停流、断开这条 BLE、准备重新组对。
2. 经典链路还在,再发 `AA 6C`,等 `BB EC 01 00`。
3. 经典链路已经断了(`CLASSIC_KEEP=0`):这次只发 `AA 06`。App 记下标记,**下次经典蓝牙一连上就补发 `AA 6C`**。
固件收到 `AA 06`、`AA 6C`,或者下面任一条件,都要自己恢复,不能停在面对面模式:
| 条件 | 谁负责 |
|---|---|
| 广播 30 秒没人连 | 固件 |
| LE 断连(supervision timeout 2~4s,含 App 被杀) | 固件。这是 App 被杀掉时的唯一恢复手段 |
| App 主动结束 | App 发 `AA 06`,再视情况发 `AA 6C` |
恢复要做完这些:停广播、断这条 GATT、两耳重新组对、A2DP / HFP 能用、按键和降噪回到进入前的状态。`CLASSIC_KEEP=0` 的固件,组对完成后要主动把经典链路连回手机。
单耳 BLE 断开:App 结束整场,不再单耳继续翻。
来电:耳机在 `CTRL_RX` 上报第 9 节的退出事件,原因 `0x03`。App 结束会话并提示用户。固件恢复 HFP,把电话交给系统。
---
## 8. 音频
编码固定 **G.722**。16 kHz、16 bit、单声道。20ms = 640 字节 PCM = 40 字节码流。
`BB EB` 的 `FRAME_MS=40`:一包 2 帧,payload 80 字节。
`FRAME_MS=20`:一包 1 帧,payload 40 字节。
### 8.1 上行 `AUDIO_UP`(耳机 → App)
```text
BB 90 <LEN> <EAR|FLAGS> <SEQ 小端 2 字节> <G.722…>
```
### 8.2 下行 `AUDIO_DOWN`(App → 耳机)
```text
AA 91 <LEN> <EAR|FLAGS> <SEQ 小端 2 字节> <G.722…>
```
`LEN` = 1(耳位和标志)+ 2(序号)+ G.722 字节数。
40ms 包:`LEN = 0x53`(83),整帧 86 字节。
20ms 包:`LEN = 0x2B`(43),整帧 46 字节。
低 4 位是耳位,**必须等于这条链路自己的耳朵**。对不上就丢,并可回 `ERR_EAR_MISMATCH (0x08)`。
下行的耳位是「播给谁」:左耳麦的译文写到右耳那条链路,帧里 `EAR=2`。
高 4 位:
| 位 | 掩码 | 上行(耳机 → App) | 下行(App → 耳机) |
|---|---|---|---|
| bit4 | `0x10` | 本包含人声 | 一句译文开始 |
| bit5 | `0x20` | 一段开始 | 播放结束 |
| bit6 | `0x40` | 一段结束 | 打断:丢掉本耳还没播完的缓冲,从本包播 |
| bit7 | `0x80` | 佩戴者本人在说话 | 不用 |
`SEQ` 每耳独立,从 0 递增,到 `0xFFFF` 回绕。丢包不重传。
`OWN_VOICE`(bit7):没有骨传导 / VPU 时这一位请一直发 0。
App 只有在这只耳朵**曾经出现过 bit7=1** 之后,才丢掉 bit7=0 的包。一直是 0 的话,整路都会送去识别,不会被静音。
下行节奏:没音频时 App 会先垫几包编码静音再送真声音,一句结束后再补一段静音。耳机用 `BB 88` 告诉 App 缓冲松紧(写在 `CTRL_RX`):
```text
BB 88 <LEN> <ADJ>
```
| ADJ | 含义 |
|---|---|
| `+1` (`0x01`) | 缓冲少一包,马上再补一包 |
| `-1` (`0xFF`) | 缓冲多一包,下一拍停发一包 |
| `0` | 缓冲已经空,App 重新垫静音再起播 |
这是补包 / 扣包,不是把速率改成 1.25 倍。
---
## 9. 事件
写在 `CTRL_RX`:
```text
BB 87 <LEN> <EVENT> <PARAM…>
```
| EVENT | 含义 | PARAM | App 行为 |
|---|---|---|---|
| `0x01` | 按键 | `<EAR> <KEY> <ACTION>` | ACTION `0x01` 按下、`0x02` 抬起。当前是连续拾音,按键只更新界面上的左右标记 |
| `0x02` | 佩戴 | `<EAR> <STATE>` | `0x00` 摘下,`0x01` 戴上,`0x02` 入盒。App 提示确认双耳都戴好 |
| `0x03` | 电量 | `<EAR> <LEVEL>` | 低 7 位电量,最高位充电。App 暂不展示 |
| `0x06` | 请求退出 | `<REASON>` | `0x03` 来电:App 结束面对面。`0x01` 用户操作,`0x02` 电量过低,`0x04` 对耳失联 |
---
## 10. 错误码
`BB EB` 的 RESULT,以及握手应答里的 RESULT,用同一张表。
| 值 | 名称 | App 提示 |
|---|---|---|
| `0x00` | OK | 继续 |
| `0x01` | 固件不支持 / 版本过低 | 耳机固件需升级 |
| `0x02` | 正在通话、AI、录音或 OTA | 请先结束当前功能 |
| `0x03` | 有一只耳不可用 | 请确认双耳都已取出并佩戴 |
| `0x04` | TOKEN 不一致 | 耳机未就绪 |
| `0x05` | BLE 资源不足 | 耳机未就绪 |
| `0x06` | 编码失败 | 耳机未就绪 |
| `0x07` | 超时 | 耳机未就绪 |
| `0x08` | 下行耳位和这条链路不符 | 记日志 |
| `0x0A` | 固件内部错误 | 耳机未就绪 |
| `0x0B` | 拆 TWS 失败 | 请把双耳放回盒中再取出重试 |
任一阶段失败,App 都会发 `AA 06`(连上了的耳朵)和 `AA 6C`(经典链路还在时),把耳机退回普通状态。

2
apps/client/ios/Podfile.lock

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

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

@ -338,7 +338,7 @@
);
mainGroup = 97C146E51CF9000F007C117D;
packageReferences = (
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */,
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */,
);
productRefGroup = 97C146EF1CF9000F007C117D /* Products */;
projectDirPath = "";
@ -389,14 +389,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Copy Pods Resources";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n";
@ -441,14 +437,10 @@
inputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist",
);
inputPaths = (
);
name = "[CP] Embed Pods Frameworks";
outputFileListPaths = (
"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist",
);
outputPaths = (
);
runOnlyForDeploymentPostprocessing = 0;
shellPath = /bin/sh;
shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n";
@ -996,7 +988,7 @@
/* End XCConfigurationList section */
/* Begin XCLocalSwiftPackageReference section */
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = {
781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = {
isa = XCLocalSwiftPackageReference;
relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage;
};
@ -1005,7 +997,7 @@
/* Begin XCSwiftPackageProductDependency section */
78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = {
isa = XCSwiftPackageProductDependency;
package = 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */;
package = 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */;
productName = FlutterGeneratedPluginSwiftPackage;
};
/* End XCSwiftPackageProductDependency section */

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

@ -1603,6 +1603,46 @@ const Map<String, String> arSA = {
"乌尔都语": "الأردية",
"波斯语": "الفارسية",
"斯瓦希里语": "السواحيلية",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "البريد الإلكتروني",

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

@ -1663,6 +1663,46 @@ const Map<String, String> bgBG = {
"乌尔都语": "Урду",
"波斯语": "Персийски",
"斯瓦希里语": "Суахили",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "Имейл",

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

@ -1632,6 +1632,46 @@ const Map<String, String> csCZ = {
"乌尔都语": "Urdština",
"波斯语": "Perština",
"斯瓦希里语": "Svahilština",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-mail",

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

@ -1633,6 +1633,46 @@ const Map<String, String> daDK = {
"乌尔都语": 'Urdu',
"波斯语": "Persisk",
"斯瓦希里语": 'Swahili',
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-mail",

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

@ -1675,6 +1675,46 @@ const Map<String, String> deDE = {
"乌尔都语": 'Urdu',
"波斯语": "Persisch",
"斯瓦希里语": 'Suaheli',
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-Mail",

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

@ -1667,6 +1667,46 @@ const Map<String, String> elGR = {
"乌尔都语": "Ουρντού",
"波斯语": "Περσικά",
"斯瓦希里语": "Σουαχίλι",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "Email",

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

@ -1666,6 +1666,46 @@ const Map<String, String> enUS = {
"乌尔都语": "Urdu",
"波斯语": "Persian",
"斯瓦希里语": "Swahili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "Email",
@ -1885,14 +1925,14 @@ const Map<String, String> enUS = {
'holderRetryHint': 'Make sure the device is on and nearby, then retry',
'autoConnectTitle': 'Auto-connect devices',
'autoConnectDesc':
'Wake the app to reconnect and receive recordings when your device powers on or comes near',
'While the app is open, reconnect a remembered device and receive recordings. Closing the app will not wake it',
'autoConnectConsentTitle': 'Enable auto-connect',
'autoConnectConsentIntro':
'To let your device (AI Earbuds / AI Phone Stand) connect automatically and save recordings when you press its record button, we need your consent:',
'To let your device (AI Earbuds / AI Phone Stand) connect automatically while the app is open, and save recordings when you press its record button, we need your consent:',
'autoConnectConsentB1':
'Register a background Bluetooth scan so the app is woken (auto-started) when your bound device is detected, to reconnect and receive recordings',
'While the app is open, automatically connect the device you remembered, then reconnect and receive recordings. After you close the app or swipe it away, the device will not wake it',
'autoConnectConsentB2':
'Once woken, it is used only for device connection, recording and upload — nothing else, and no other apps are launched',
'Auto-connect is used only for device connection, recording and upload — nothing else, and no other apps are launched',
'autoConnectConsentB3':
'You can still use the app without it — just connect the device manually each time',
'autoConnectConsentB4':
@ -2005,6 +2045,13 @@ const Map<String, String> enUS = {
'recordingSavedDesc': 'Saved to Voice Minutes',
'faceToFaceLeftEar': 'Left',
'faceToFaceRightEar': 'Right',
'f2fEarNotReady': 'Earbuds are not ready',
'f2fWearBothEars': 'Take out and wear both earbuds',
'f2fEndOtherFeature': 'End the current feature first',
'f2fFirmwareUpgrade': 'Earbud firmware needs an update',
'f2fIncomingCallEnded': 'Incoming call ended face-to-face translation',
'f2fPutBackInCase': 'Put both earbuds back in the case and try again',
'f2fEarDisconnected': 'An earbud disconnected',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': 'Ready',

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

@ -1666,6 +1666,46 @@ const Map<String, String> esES = {
"乌尔都语": "Urdu",
"波斯语": "Persa",
"斯瓦希里语": "Suajili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "Email",

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

@ -1628,6 +1628,46 @@ const Map<String, String> etEE = {
"乌尔都语": "Urdu",
"波斯语": "Pärsia",
"斯瓦希里语": "Suahiili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-mail",

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

@ -1618,6 +1618,46 @@ const Map<String, String> faIR = {
"乌尔都语": "اردو",
"波斯语": "فارسی",
"斯瓦希里语": "سواحیلی",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "ایمیل",

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

@ -1638,6 +1638,46 @@ const Map<String, String> fiFI = {
"乌尔都语": "Urdu",
"波斯语": "Persia",
"斯瓦希里语": "Swahili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "Sähköposti",

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

@ -1686,6 +1686,46 @@ const Map<String, String> frFR = {
"乌尔都语": "Ourdou",
"波斯语": "Persan",
"斯瓦希里语": 'Swahili',
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-mail",

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

@ -1639,6 +1639,46 @@ const Map<String, String> hiIN = {
"乌尔都语": "उर्दू",
"波斯语": "फारसी",
"斯瓦希里语": "स्वाहिली",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "ईमेल",

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

@ -1653,6 +1653,46 @@ const Map<String, String> hrHR = {
"乌尔都语": "Urdu",
"波斯语": "Perzijski",
"斯瓦希里语": "Svahili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-pošta",

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

@ -1655,6 +1655,46 @@ const Map<String, String> huHU = {
"乌尔都语": "Urdu",
"波斯语": "Perzsa",
"斯瓦希里语": "Szuahéli",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-mail",

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

@ -1637,6 +1637,46 @@ const Map<String, String> idID = {
"乌尔都语": "Bahasa Urdu",
"波斯语": "Bahasa Persia",
"斯瓦希里语": "Bahasa Swahili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "Email",

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

@ -1634,6 +1634,46 @@ const Map<String, String> isIS = {
"乌尔都语": "Úrdú",
"波斯语": "Persneska",
"斯瓦希里语": "Svahílí",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "Tölvupóstur",

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

@ -1674,6 +1674,46 @@ const Map<String, String> itIT = {
"乌尔都语": 'Urdu',
"波斯语": "Persiano",
"斯瓦希里语": 'Swahili',
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-mail",

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

@ -1492,6 +1492,46 @@ const Map<String, String> jaJP = {
"乌尔都语": "ウルドゥー語",
"波斯语": "ペルシャ語",
"斯瓦希里语": "スワヒリ語",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "メールアドレス",

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

@ -1483,6 +1483,46 @@ const Map<String, String> koKR = {
"乌尔都语": "우르두어",
"波斯语": "페르시아어",
"斯瓦希里语": "스와힐리어",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "이메일",

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

@ -1651,6 +1651,46 @@ const Map<String, String> ltLT = {
"乌尔都语": "Urdu",
"波斯语": "Persų",
"斯瓦希里语": "Svahilių",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "El. paštas",

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

@ -1656,6 +1656,46 @@ const Map<String, String> lvLV = {
"乌尔都语": "Urdu",
"波斯语": "Persiešu",
"斯瓦希里语": "Svahili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-pasts",

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

@ -1642,6 +1642,46 @@ const Map<String, String> msMY = {
"乌尔都语": "Bahasa Urdu",
"波斯语": "Bahasa Parsi",
"斯瓦希里语": "Bahasa Swahili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-mel",

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

@ -1635,6 +1635,46 @@ const Map<String, String> nbNO = {
"乌尔都语": 'Urdu',
"波斯语": "Persisk",
"斯瓦希里语": 'Swahili',
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-post",

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

@ -1532,6 +1532,46 @@ const Map<String, String> nlNL = {
"\u4e4c\u5c14\u90fd\u8bed": "Urdu",
"\u6ce2\u65af\u8bed": "Perzisch",
"\u65af\u74e6\u5e0c\u91cc\u8bed": "Swahili",
"\u7ca4\u8bed": "Cantonese",
"\u5a01\u5c14\u58eb\u8bed": "Welsh",
"\u4fdd\u52a0\u5229\u4e9a\u8bed": "Bulgarian",
"\u963f\u59c6\u54c8\u62c9\u8bed": "Amharic",
"\u963f\u8428\u59c6\u8bed": "Assamese",
"\u5357\u975e\u8377\u5170\u8bed": "Afrikaans",
"\u963f\u585e\u62dc\u7586\u8bed": "Azerbaijani",
"\u6ce2\u65af\u5c3c\u4e9a\u8bed": "Bosnian",
"\u5361\u7eb3\u8fbe\u8bed": "Kannada",
"\u7231\u6c99\u5c3c\u4e9a\u8bed": "Estonian",
"\u5df4\u65af\u514b\u8bed": "Basque",
"\u83f2\u5f8b\u5bbe\u8bed": "Filipino",
"\u7231\u5c14\u5170\u8bed": "Irish",
"\u52a0\u5229\u897f\u4e9a\u8bed": "Galician",
"\u53e4\u5409\u62c9\u7279\u8bed": "Gujarati",
"\u5e0c\u4f2f\u6765\u8bed": "Hebrew",
"\u4e9a\u7f8e\u5c3c\u4e9a\u8bed": "Armenian",
"\u51b0\u5c9b\u8bed": "Icelandic",
"\u722a\u54c7\u8bed": "Javanese",
"\u683c\u9c81\u5409\u4e9a\u8bed": "Georgian",
"\u54c8\u8428\u514b\u8bed": "Kazakh",
"\u9ad8\u68c9\u8bed": "Khmer",
"\u8001\u631d\u8bed": "Lao",
"\u7acb\u9676\u5b9b\u8bed": "Lithuanian",
"\u62c9\u8131\u7ef4\u4e9a\u8bed": "Latvian",
"\u9a6c\u5176\u987f\u8bed": "Macedonian",
"\u9a6c\u62c9\u96c5\u62c9\u59c6\u8bed": "Malayalam",
"\u8499\u53e4\u8bed": "Mongolian",
"\u9a6c\u8033\u4ed6\u8bed": "Maltese",
"\u7f05\u7538\u8bed": "Burmese",
"\u5c3c\u6cca\u5c14\u8bed": "Nepali",
"\u666e\u4ec0\u56fe\u8bed": "Pashto",
"\u50e7\u4f3d\u7f57\u8bed": "Sinhala",
"\u65af\u6d1b\u4f10\u514b\u8bed": "Slovak",
"\u65af\u6d1b\u6587\u5c3c\u4e9a\u8bed": "Slovenian",
"\u7d22\u9a6c\u91cc\u8bed": "Somali",
"\u963f\u5c14\u5df4\u5c3c\u4e9a\u8bed": "Albanian",
"\u585e\u5c14\u7ef4\u4e9a\u8bed": "Serbian",
"\u4e4c\u5179\u522b\u514b\u8bed": "Uzbek",
"\u7956\u9c81\u8bed": "Zulu",
// 国家列表
"\u90ae\u7bb1": "E-mail",

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

@ -1663,6 +1663,46 @@ const Map<String, String> plPL = {
"乌尔都语": "Urdu",
"波斯语": "Perski",
"斯瓦希里语": "Suahili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-mail",

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

@ -1673,6 +1673,46 @@ const Map<String, String> ptPT = {
"乌尔都语": 'Urdu',
"波斯语": "Persa",
"斯瓦希里语": "Suaíli",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-mail",

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

@ -1664,6 +1664,46 @@ const Map<String, String> roRO = {
"乌尔都语": 'Urdu',
"波斯语": "Persană",
"斯瓦希里语": 'Swahili',
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-mail",

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

@ -1651,6 +1651,46 @@ const Map<String, String> ruRU = {
"乌尔都语": "Урду",
"波斯语": "Персидский",
"斯瓦希里语": "Суахили",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-mail",

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

@ -1645,6 +1645,46 @@ const Map<String, String> skSK = {
"乌尔都语": "Urdčina",
"波斯语": "Perzština",
"斯瓦希里语": "Svahilčina",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-mail",

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

@ -1648,6 +1648,46 @@ const Map<String, String> slSI = {
"乌尔都语": "Urdujščina",
"波斯语": "Perzijščina",
"斯瓦希里语": "Svahilščina",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-pošta",

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

@ -1644,6 +1644,46 @@ const Map<String, String> srRS = {
"乌尔都语": "Urdu",
"波斯语": "Persijski",
"斯瓦希里语": "Svahili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-pošta",

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

@ -1640,6 +1640,46 @@ const Map<String, String> svSE = {
"乌尔都语": "Urdu",
"波斯语": "Persiska",
"斯瓦希里语": "Swahili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-post",

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

@ -1597,6 +1597,46 @@ const Map<String, String> thTH = {
"乌尔都语": "ภาษาอูรดู",
"波斯语": "ภาษาเปอร์เซีย",
"斯瓦希里语": "ภาษาสวาฮีลี",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "อีเมล",

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

@ -1678,6 +1678,46 @@ const Map<String, String> tlPH = {
"乌尔都语": "Urdu",
"波斯语": "Persian",
"斯瓦希里语": "Swahili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "Email",

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

@ -1634,6 +1634,46 @@ const Map<String, String> trTR = {
"乌尔都语": "Urduca",
"波斯语": "Farsça",
"斯瓦希里语": "Svahili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "E-posta",

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

@ -1651,6 +1651,46 @@ const Map<String, String> ukUA = {
"乌尔都语": "Урду",
"波斯语": "Перська",
"斯瓦希里语": "Свахілі",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "Email",

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

@ -1616,6 +1616,46 @@ const Map<String, String> viVN = {
"乌尔都语": "Tiếng Urdu",
"波斯语": "Tiếng Ba Tư",
"斯瓦希里语": "Tiếng Swahili",
"粤语": "Cantonese",
"威尔士语": "Welsh",
"保加利亚语": "Bulgarian",
"阿姆哈拉语": "Amharic",
"阿萨姆语": "Assamese",
"南非荷兰语": "Afrikaans",
"阿塞拜疆语": "Azerbaijani",
"波斯尼亚语": "Bosnian",
"卡纳达语": "Kannada",
"爱沙尼亚语": "Estonian",
"巴斯克语": "Basque",
"菲律宾语": "Filipino",
"爱尔兰语": "Irish",
"加利西亚语": "Galician",
"古吉拉特语": "Gujarati",
"希伯来语": "Hebrew",
"亚美尼亚语": "Armenian",
"冰岛语": "Icelandic",
"爪哇语": "Javanese",
"格鲁吉亚语": "Georgian",
"哈萨克语": "Kazakh",
"高棉语": "Khmer",
"老挝语": "Lao",
"立陶宛语": "Lithuanian",
"拉脱维亚语": "Latvian",
"马其顿语": "Macedonian",
"马拉雅拉姆语": "Malayalam",
"蒙古语": "Mongolian",
"马耳他语": "Maltese",
"缅甸语": "Burmese",
"尼泊尔语": "Nepali",
"普什图语": "Pashto",
"僧伽罗语": "Sinhala",
"斯洛伐克语": "Slovak",
"斯洛文尼亚语": "Slovenian",
"索马里语": "Somali",
"阿尔巴尼亚语": "Albanian",
"塞尔维亚语": "Serbian",
"乌兹别克语": "Uzbek",
"祖鲁语": "Zulu",
// 国家列表
"邮箱": "Email",

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

@ -1496,6 +1496,46 @@ const Map<String, String> zhCN = {
"乌尔都语": "乌尔都语",
"波斯语": "波斯语",
"斯瓦希里语": "斯瓦希里语",
"粤语": "粤语",
"威尔士语": "威尔士语",
"保加利亚语": "保加利亚语",
"阿姆哈拉语": "阿姆哈拉语",
"阿萨姆语": "阿萨姆语",
"南非荷兰语": "南非荷兰语",
"阿塞拜疆语": "阿塞拜疆语",
"波斯尼亚语": "波斯尼亚语",
"卡纳达语": "卡纳达语",
"爱沙尼亚语": "爱沙尼亚语",
"巴斯克语": "巴斯克语",
"菲律宾语": "菲律宾语",
"爱尔兰语": "爱尔兰语",
"加利西亚语": "加利西亚语",
"古吉拉特语": "古吉拉特语",
"希伯来语": "希伯来语",
"亚美尼亚语": "亚美尼亚语",
"冰岛语": "冰岛语",
"爪哇语": "爪哇语",
"格鲁吉亚语": "格鲁吉亚语",
"哈萨克语": "哈萨克语",
"高棉语": "高棉语",
"老挝语": "老挝语",
"立陶宛语": "立陶宛语",
"拉脱维亚语": "拉脱维亚语",
"马其顿语": "马其顿语",
"马拉雅拉姆语": "马拉雅拉姆语",
"蒙古语": "蒙古语",
"马耳他语": "马耳他语",
"缅甸语": "缅甸语",
"尼泊尔语": "尼泊尔语",
"普什图语": "普什图语",
"僧伽罗语": "僧伽罗语",
"斯洛伐克语": "斯洛伐克语",
"斯洛文尼亚语": "斯洛文尼亚语",
"索马里语": "索马里语",
"阿尔巴尼亚语": "阿尔巴尼亚语",
"塞尔维亚语": "塞尔维亚语",
"乌兹别克语": "乌兹别克语",
"祖鲁语": "祖鲁语",
// 国家列表
"邮箱": "邮箱",
@ -1708,12 +1748,12 @@ const Map<String, String> zhCN = {
'holderPairHint': '请确保支架已开机并处于配对模式',
'holderRetryHint': '请确认设备已开机并靠近手机,稍后重试',
'autoConnectTitle': '设备自动连接',
'autoConnectDesc': '设备开机或靠近时自动唤醒应用回连并接收录音',
'autoConnectDesc': '应用打开时自动连接已记住的设备并接收录音。关掉应用后不会被设备唤醒',
'autoConnectConsentTitle': '开启设备自动连接',
'autoConnectConsentIntro':
'为了让你的设备(AI耳机 / AI手机支架)开机或靠近手机时能自动连上,并在你按设备上的录音键时把录音完整保存下来,需要你同意:',
'autoConnectConsentB1': '向系统注册蓝牙后台扫描,扫到你已绑定的这台设备时唤醒本应用(即自启动),完成回连与录音接收',
'autoConnectConsentB2': '唤醒后仅用于设备连接、录音采集与上传,不做其他用途,也不会拉起其他应用',
'为了让你的设备(AI耳机 / AI手机支架)在应用打开期间能自动连上,并在你按设备上的录音键时把录音完整保存下来,需要你同意:',
'autoConnectConsentB1': '应用处于打开状态时,自动连接你已记住的这台设备,完成回连与录音接收。应用被关掉或从最近任务划掉后,不会再被设备唤醒',
'autoConnectConsentB2': '自动连接只用于设备连接、录音采集与上传,不做其他用途,也不会拉起其他应用',
'autoConnectConsentB3': '不同意也可以正常使用:需要你每次打开应用手动连接设备',
'autoConnectConsentB4': '同意后可随时在「设置 → 设备自动连接」里关闭',
'autoConnectAgree': '同意并开启',
@ -1820,6 +1860,13 @@ const Map<String, String> zhCN = {
'recordingSavedDesc': '已存入语音纪要',
'faceToFaceLeftEar': '左耳',
'faceToFaceRightEar': '右耳',
'f2fEarNotReady': '耳机未就绪',
'f2fWearBothEars': '请确认双耳都已取出并佩戴',
'f2fEndOtherFeature': '请先结束当前功能',
'f2fFirmwareUpgrade': '耳机固件需升级',
'f2fIncomingCallEnded': '有来电,面对面翻译已结束',
'f2fPutBackInCase': '请把双耳放回盒中再取出重试',
'f2fEarDisconnected': '耳机已断开',
// EMAI 助手(阿里百炼多模态对话页)
'emaiReady': '已就绪',

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

@ -1471,6 +1471,46 @@ const Map<String, String> zhHK = {
"乌尔都语": "烏爾都語",
"波斯语": "波斯語",
"斯瓦希里语": "斯瓦希里語",
"粤语": "粵語",
"威尔士语": "威爾士語",
"保加利亚语": "保加利亞語",
"阿姆哈拉语": "阿姆哈拉語",
"阿萨姆语": "阿薩姆語",
"南非荷兰语": "南非荷蘭語",
"阿塞拜疆语": "阿塞拜疆語",
"波斯尼亚语": "波士尼亞語",
"卡纳达语": "卡納達語",
"爱沙尼亚语": "愛沙尼亞語",
"巴斯克语": "巴斯克語",
"菲律宾语": "菲律賓語",
"爱尔兰语": "愛爾蘭語",
"加利西亚语": "加利西亞語",
"古吉拉特语": "古吉拉特語",
"希伯来语": "希伯來語",
"亚美尼亚语": "亞美尼亞語",
"冰岛语": "冰島語",
"爪哇语": "爪哇語",
"格鲁吉亚语": "格魯吉亞語",
"哈萨克语": "哈薩克語",
"高棉语": "高棉語",
"老挝语": "老撾語",
"立陶宛语": "立陶宛語",
"拉脱维亚语": "拉脫維亞語",
"马其顿语": "馬其頓語",
"马拉雅拉姆语": "馬拉雅拉姆語",
"蒙古语": "蒙古語",
"马耳他语": "馬耳他語",
"缅甸语": "緬甸語",
"尼泊尔语": "尼泊爾語",
"普什图语": "普什圖語",
"僧伽罗语": "僧伽羅語",
"斯洛伐克语": "斯洛伐克語",
"斯洛文尼亚语": "斯洛維尼亞語",
"索马里语": "索馬利亞語",
"阿尔巴尼亚语": "阿爾巴尼亞語",
"塞尔维亚语": "塞爾維亞語",
"乌兹别克语": "烏茲別克語",
"祖鲁语": "祖魯語",
// 国家列表
"邮箱": "郵箱",
@ -1683,12 +1723,12 @@ const Map<String, String> zhHK = {
'holderPairHint': '請確認支架已開機並喺配對模式',
'holderRetryHint': '請確認裝置已開機並喺手機附近,遲啲再試',
'autoConnectTitle': '裝置自動連接',
'autoConnectDesc': '裝置開機或者行埋嚟時自動喚醒應用回連並接收錄音',
'autoConnectDesc': '應用開住嗰陣自動連接已記住嘅裝置並接收錄音。閂咗應用之後唔會被裝置喚醒',
'autoConnectConsentTitle': '開啟裝置自動連接',
'autoConnectConsentIntro':
'為咗令你嘅裝置(AI 耳機/AI 手機支架)開機或者埋身時自動連上,並喺你撳裝置錄音掣時完整保存錄音,需要你同意:',
'autoConnectConsentB1': '向系統註冊藍牙背景掃描,掃到你已綁定嘅裝置時喚醒本應用(即自啟動),完成回連同錄音接收',
'autoConnectConsentB2': '喚醒後淨係用嚟連接裝置、錄音同上傳,唔會做其他嘢,亦唔會拉起其他應用',
'為咗令你嘅裝置(AI 耳機/AI 手機支架)喺應用開住嗰陣自動連上,並喺你撳裝置錄音掣時完整保存錄音,需要你同意:',
'autoConnectConsentB1': '應用開住嗰陣,自動連接你已記住嘅呢部裝置,完成回連同錄音接收。應用被閂咗或者從最近任務劃走之後,唔會再被裝置喚醒',
'autoConnectConsentB2': '自動連接淨係用嚟連接裝置、錄音同上傳,唔會做其他嘢,亦唔會拉起其他應用',
'autoConnectConsentB3': '唔同意都用得:不過每次都要開應用手動連接',
'autoConnectConsentB4': '同意後可以隨時喺「設定 → 裝置自動連接」閂咗佢',
'autoConnectAgree': '同意並開啟',

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

@ -1471,6 +1471,46 @@ const Map<String, String> zhTW = {
"乌尔都语": "烏爾都語",
"波斯语": "波斯語",
"斯瓦希里语": "斯瓦希里語",
"粤语": "粵語",
"威尔士语": "威爾士語",
"保加利亚语": "保加利亞語",
"阿姆哈拉语": "阿姆哈拉語",
"阿萨姆语": "阿薩姆語",
"南非荷兰语": "南非荷蘭語",
"阿塞拜疆语": "阿塞拜疆語",
"波斯尼亚语": "波士尼亞語",
"卡纳达语": "卡納達語",
"爱沙尼亚语": "愛沙尼亞語",
"巴斯克语": "巴斯克語",
"菲律宾语": "菲律賓語",
"爱尔兰语": "愛爾蘭語",
"加利西亚语": "加利西亞語",
"古吉拉特语": "古吉拉特語",
"希伯来语": "希伯來語",
"亚美尼亚语": "亞美尼亞語",
"冰岛语": "冰島語",
"爪哇语": "爪哇語",
"格鲁吉亚语": "格魯吉亞語",
"哈萨克语": "哈薩克語",
"高棉语": "高棉語",
"老挝语": "老撾語",
"立陶宛语": "立陶宛語",
"拉脱维亚语": "拉脫維亞語",
"马其顿语": "馬其頓語",
"马拉雅拉姆语": "馬拉雅拉姆語",
"蒙古语": "蒙古語",
"马耳他语": "馬耳他語",
"缅甸语": "緬甸語",
"尼泊尔语": "尼泊爾語",
"普什图语": "普什圖語",
"僧伽罗语": "僧伽羅語",
"斯洛伐克语": "斯洛伐克語",
"斯洛文尼亚语": "斯洛維尼亞語",
"索马里语": "索馬利亞語",
"阿尔巴尼亚语": "阿爾巴尼亞語",
"塞尔维亚语": "塞爾維亞語",
"乌兹别克语": "烏茲別克語",
"祖鲁语": "祖魯語",
// 国家列表
"邮箱": "電子郵件",
@ -1683,12 +1723,12 @@ const Map<String, String> zhTW = {
'holderPairHint': '請確認支架已開機並處於配對模式',
'holderRetryHint': '請確認裝置已開機並靠近手機,稍後重試',
'autoConnectTitle': '裝置自動連線',
'autoConnectDesc': '裝置開機或靠近時自動喚醒應用回連並接收錄音',
'autoConnectDesc': '應用開啟時自動連線已記住的裝置並接收錄音。關掉應用後不會被裝置喚醒',
'autoConnectConsentTitle': '開啟裝置自動連線',
'autoConnectConsentIntro':
'為了讓你的裝置(AI 耳機/AI 手機支架)開機或靠近手機時自動連上,並在你按下裝置錄音鍵時完整保存錄音,需要你同意:',
'autoConnectConsentB1': '向系統註冊藍牙背景掃描,掃到你已綁定的裝置時喚醒本應用(即自啟動),完成回連與錄音接收',
'autoConnectConsentB2': '喚醒後僅用於裝置連線、錄音擷取與上傳,不作其他用途,也不會喚起其他應用',
'為了讓你的裝置(AI 耳機/AI 手機支架)在應用開啟期間自動連上,並在你按下裝置錄音鍵時完整保存錄音,需要你同意:',
'autoConnectConsentB1': '應用處於開啟狀態時,自動連線你已記住的這台裝置,完成回連與錄音接收。應用被關掉或從最近任務劃掉後,不會再被裝置喚醒',
'autoConnectConsentB2': '自動連線只用於裝置連線、錄音擷取與上傳,不作其他用途,也不會喚起其他應用',
'autoConnectConsentB3': '不同意也能正常使用:只是每次都要打開應用手動連線',
'autoConnectConsentB4': '同意後可隨時在「設定 → 裝置自動連線」關閉',
'autoConnectAgree': '同意並開啟',

12
apps/client/lib/data/models/device_category.dart

@ -84,7 +84,17 @@ class DeviceCategories {
gradient: [Color(0xFFAB47BC), Color(0xFF7B1FA2)],
imageAsset: '${DeviceImages.dir}Echo-one.png',
// deviceTypes 已失效(见上),耳机改由名字圈定
nameKeywords: ['echo-one', 'deepvoice', 'deepsound', 'buds', 'ai80', 'a80'],
// echo-translation 与 echo-one 是同一套恒玄协议,后台各配一个产品。
// 两个名字互不包含,品类关键字要分别写上,否则新名字进不了耳机这一类。
nameKeywords: [
'echo-translation',
'echo-one',
'deepvoice',
'deepsound',
'buds',
'ai80',
'a80',
],
),
// AI车载香薰 = EaiCar(广播名,旧名 Smartcar 已于 2026-09-15 停止接纳)。
DeviceCategory(

4
apps/client/lib/data/services/ast_service.dart

@ -62,6 +62,10 @@ abstract class AstService {
/// 原生不支持返回 false,调用方退回 Dart 路径。
Future<bool> setNativeCallBridge(bool enabled);
/// Echo-one 面对面 BLE 的 PCM 直连桥。只有恒玄双耳会话会开。
/// 原生不支持返回 false。
Future<bool> setNativeF2fBridge(bool enabled);
/// 桥的计数(两路帧数 / 丢帧 / 门限状态),会话结束时打进日志用。
Future<Map<String, dynamic>> nativeCallBridgeStats();

15
apps/client/lib/data/services/background_launch_consent.dart

@ -8,17 +8,12 @@ import '../../core/utils/logger.dart';
/// 「后台自动连接设备」的用户授权。
///
/// 为什么需要这个:连接支架/耳机时,原生侧会向系统注册
/// `BluetoothLeScanner.startScan(filters, settings, pendingIntent)` ——
/// 也就是「以后系统扫到这台设备就把 App 唤醒」。App 被唤醒后
/// [BleWakeReceiver] 会拉起前台服务接管连接与录音。
/// 为什么需要这个:应用打开期间会自动连接用户记住的设备。
/// 必须先向用户明示用途并取得同意。应用被关掉或从最近任务划掉之后,
/// 不会再被设备广播拉起来。
///
/// 这在应用商店的合规检测里属于**自启动 / 关联启动**行为(小米检测项
/// 「APP频繁自启动和关联启动」),必须先向用户明示用途并取得同意才能注册。
/// 所以三条蓝牙链路在注册后台扫连之前,都要先过这里。
///
/// 用户没同意时:App 依然可以在前台手动连接使用,只是设备开机/靠近时
/// 不会自动唤醒 App 回连。用户可以在「设置」里随时开关。
/// 用户没同意时:可以在前台手动连接,只是打开应用后不会自动回连。
/// 用户可以在「设置」里随时开关。
class BackgroundLaunchConsent {
BackgroundLaunchConsent._();

3
apps/client/lib/data/services/language_configs/alibaba35_language_config.dart

@ -3,7 +3,8 @@
///
/// 与旧版 [getAlibabaTranslationLanguageSpecs] 的关系见
/// LanguageManager.findBestMatchingProvider 的路由说明:
/// 3.5 优先 → 旧版兜底(只剩 yue/el)→ 微软 → 讯飞。
/// 3.5 优先 → 旧版兜底(希腊语)→ 微软 → 讯飞。
/// 扩展 40 语(含 fil / he / is)通话直接跳过阿里两档。
///
/// ⚠️ **这里只列 3.5 的「音频+文本输出」29 语**,不是它宣传的 60 语。
/// 另外 31 语(yue、el、uk、ro、hu…)是**仅文本输出**——通话翻译要把 TTS

5
apps/client/lib/data/services/language_configs/alibaba_language_config.dart

@ -4,9 +4,8 @@ import '../../models/user_Info.dart';
///
/// 路由上它是**第二档**:3.5(带声音复刻)不覆盖的语种才落到这里,
/// 见 LanguageManager.findBestMatchingProvider。旧版支持 18 语,
/// 其中 16 语已被 3.5 的「音频输出 29 语」覆盖,所以实际只有
/// **yue(粤语)和 el(希腊语)**会真的走到这一档——这两个在 3.5 里
/// 是「仅文本输出」,出不了 TTS 音频,通话翻译用不了。
/// 其中 16 语已被 3.5 的「音频输出 29 语」覆盖。界面上真正落到这一档的
/// 只剩 **el(希腊语)**。粤语也在这张表里,但通话选路会跳过阿里、改走微软。
/// 表里仍按阿里文档把 18 语列全,别按「反正走不到」删掉。
///
/// 音色(旧版没有声音复刻,只能挑固定音色):

5
apps/client/lib/data/services/language_configs/azure_language_config.dart

@ -804,9 +804,10 @@ List<Map<String, String>> getAzureTranslationLanguageSpecs() {
},
{
'shortCode': 'jv',
'chineseName': '爪哇语', //拉丁语、印度尼西亚)
'chineseName': '爪哇语',
'englishName': 'Javanese',
'translationCode': 'id',
// 微软翻译认 jv。写成 id 会把爪哇语当成印尼语去翻。
'translationCode': 'jv',
},
{
'shortCode': 'ka',

166
apps/client/lib/data/services/language_manager.dart

@ -72,6 +72,22 @@ class LanguageManager extends GetxService {
// 短代码到 ASR 代码的映射
final Map<String, String> _shortCodeToAsrCodeMap = {};
// 按短码 / 中文名 / 英文名各存一份。粤语和香港繁体的识别码都是 zh-HK,
// 不能再用识别码当唯一键,否则后加的那条会把前一条盖掉。
final Map<String, LanguageInfo> _byShortCode = {};
final Map<String, LanguageInfo> _byChineseName = {};
final Map<String, LanguageInfo> _byEnglishName = {};
/// 这 40 个语种只在微软识别 / 翻译 / 合成三张表里完整,界面此前没开放。
/// 其中菲律宾语、希伯来语、冰岛语也在阿里 3.5 音频档,粤语在阿里旧版。
/// 通话只要一边是这些语种,就整对走微软端到端,不让阿里把这一对抢走。
static const Set<String> azureCallOnlyShortCodes = {
'yue', 'cy', 'bg', 'am', 'as', 'af', 'az', 'bs', 'kn', 'et',
'eu', 'fil', 'ga', 'gl', 'gu', 'he', 'hy', 'is', 'jv', 'ka',
'kk', 'km', 'lo', 'lt', 'lv', 'mk', 'ml', 'mn', 'mt', 'my',
'ne', 'ps', 'si', 'sk', 'sl', 'so', 'sq', 'sr', 'uz', 'zu',
};
/// 初始化语言映射表
void _initLanguageMap() {
// 中文相关
@ -432,7 +448,66 @@ class LanguageManager extends GetxService {
ttsVoiceNameMale: 'sw-KE-RafikiNeural',
);
Logger.info('语言管理器初始化完成,共加载 ${_languageMap.length} 种语言');
// 微软三张表里有、界面原先没放出来的 40 种。男声没有单独列在音色表里的,
// 先用同一条女声,避免把不存在的音色名传给微软合成。
_addAzureExtendedLanguages();
Logger.info('语言管理器初始化完成,共加载 ${_byChineseName.length} 种语言');
}
void _addAzureExtendedLanguages() {
const rows = <List<String>>[
['yue', '粤语', 'Cantonese', 'zh-HK', 'yue-CN-XiaoMinNeural'],
['cy', '威尔士语', 'Welsh', 'cy-GB', 'cy-GB-NiaNeural'],
['bg', '保加利亚语', 'Bulgarian', 'bg-BG', 'bg-BG-KalinaNeural'],
['am', '阿姆哈拉语', 'Amharic', 'am-ET', 'am-ET-MekdesNeural'],
['as', '阿萨姆语', 'Assamese', 'as-IN', 'as-IN-YashicaNeural'],
['af', '南非荷兰语', 'Afrikaans', 'af-ZA', 'af-ZA-AdriNeural'],
['az', '阿塞拜疆语', 'Azerbaijani', 'az-AZ', 'az-AZ-BanuNeural'],
['bs', '波斯尼亚语', 'Bosnian', 'bs-BA', 'bs-BA-VesnaNeural'],
['kn', '卡纳达语', 'Kannada', 'kn-IN', 'kn-IN-SapnaNeural'],
['et', '爱沙尼亚语', 'Estonian', 'et-EE', 'et-EE-AnuNeural'],
['eu', '巴斯克语', 'Basque', 'eu-ES', 'eu-ES-AinhoaNeural'],
['fil', '菲律宾语', 'Filipino', 'fil-PH', 'fil-PH-BlessicaNeural'],
['ga', '爱尔兰语', 'Irish', 'ga-IE', 'ga-IE-OrlaNeural'],
['gl', '加利西亚语', 'Galician', 'gl-ES', 'gl-ES-SabelaNeural'],
['gu', '古吉拉特语', 'Gujarati', 'gu-IN', 'gu-IN-DhwaniNeural'],
['he', '希伯来语', 'Hebrew', 'he-IL', 'he-IL-HilaNeural'],
['hy', '亚美尼亚语', 'Armenian', 'hy-AM', 'hy-AM-AnahitNeural'],
['is', '冰岛语', 'Icelandic', 'is-IS', 'is-IS-GudrunNeural'],
['jv', '爪哇语', 'Javanese', 'jv-ID', 'jv-ID-SitiNeural'],
['ka', '格鲁吉亚语', 'Georgian', 'ka-GE', 'ka-GE-EkaNeural'],
['kk', '哈萨克语', 'Kazakh', 'kk-KZ', 'kk-KZ-AigulNeural'],
['km', '高棉语', 'Khmer', 'km-KH', 'km-KH-SreymomNeural'],
['lo', '老挝语', 'Lao', 'lo-LA', 'lo-LA-KeomanyNeural'],
['lt', '立陶宛语', 'Lithuanian', 'lt-LT', 'lt-LT-OnaNeural'],
['lv', '拉脱维亚语', 'Latvian', 'lv-LV', 'lv-LV-EveritaNeural'],
['mk', '马其顿语', 'Macedonian', 'mk-MK', 'mk-MK-MarijaNeural'],
['ml', '马拉雅拉姆语', 'Malayalam', 'ml-IN', 'ml-IN-SobhanaNeural'],
['mn', '蒙古语', 'Mongolian', 'mn-MN', 'mn-MN-YesuiNeural'],
['mt', '马耳他语', 'Maltese', 'mt-MT', 'mt-MT-GraceNeural'],
['my', '缅甸语', 'Burmese', 'my-MM', 'my-MM-NilarNeural'],
['ne', '尼泊尔语', 'Nepali', 'ne-NP', 'ne-NP-HemkalaNeural'],
['ps', '普什图语', 'Pashto', 'ps-AF', 'ps-AF-LatifaNeural'],
['si', '僧伽罗语', 'Sinhala', 'si-LK', 'si-LK-ThiliniNeural'],
['sk', '斯洛伐克语', 'Slovak', 'sk-SK', 'sk-SK-ViktoriaNeural'],
['sl', '斯洛文尼亚语', 'Slovenian', 'sl-SI', 'sl-SI-PetraNeural'],
['so', '索马里语', 'Somali', 'so-SO', 'so-SO-UbaxNeural'],
['sq', '阿尔巴尼亚语', 'Albanian', 'sq-AL', 'sq-AL-AnilaNeural'],
['sr', '塞尔维亚语', 'Serbian', 'sr-RS', 'sr-Latn-RS-SophieNeural'],
['uz', '乌兹别克语', 'Uzbek', 'uz-UZ', 'uz-UZ-MadinaNeural'],
['zu', '祖鲁语', 'Zulu', 'zu-ZA', 'zu-ZA-ThandoNeural'],
];
for (final row in rows) {
_addLanguage(
shortCode: row[0],
chineseName: row[1],
englishName: row[2],
asrCode: row[3],
ttsVoiceName: row[4],
ttsVoiceNameMale: row[4],
);
}
}
/// 添加语言信息到映射表
@ -455,7 +530,11 @@ class LanguageManager extends GetxService {
note: note,
);
_languageMap[asrCode] = languageInfo;
_byShortCode[shortCode] = languageInfo;
_byChineseName[chineseName] = languageInfo;
_byEnglishName[englishName] = languageInfo;
// 识别码冲突时保留先登记的那条(香港繁体先于粤语)。按名字查不受影响。
_languageMap.putIfAbsent(asrCode, () => languageInfo);
_chineseNameToAsrCodeMap[chineseName] = asrCode;
_englishNameToAsrCodeMap[englishName] = asrCode;
_shortCodeToAsrCodeMap[shortCode] = asrCode;
@ -483,23 +562,17 @@ class LanguageManager extends GetxService {
/// 根据中文名称获取语言信息
LanguageInfo? getLanguageByChineseName(String chineseName) {
final asrCode = _chineseNameToAsrCodeMap[chineseName];
if (asrCode == null) return null;
return _languageMap[asrCode];
return _byChineseName[chineseName];
}
/// 根据英文名称获取语言信息
LanguageInfo? getLanguageByEnglishName(String englishName) {
final asrCode = _englishNameToAsrCodeMap[englishName];
if (asrCode == null) return null;
return _languageMap[asrCode];
return _byEnglishName[englishName];
}
/// 根据短代码获取语言信息
LanguageInfo? getLanguageByShortCode(String shortCode) {
final asrCode = _shortCodeToAsrCodeMap[shortCode];
if (asrCode == null) return null;
return _languageMap[asrCode];
return _byShortCode[shortCode];
}
/// 根据中文名称获取 ASR 代码
@ -537,9 +610,7 @@ class LanguageManager extends GetxService {
/// 根据 shortCode 获取性别适配的 TTS 音色(用于 findBestMatchingProvider)
String? _getGenderAwareTtsCode(String shortCode) {
final asrCode = _shortCodeToAsrCodeMap[shortCode];
if (asrCode == null) return null;
return _getVoiceByGender(_languageMap[asrCode]);
return _getVoiceByGender(_byShortCode[shortCode]);
}
/// 根据用户性别获取对应的 TTS 语音名称
@ -564,30 +635,54 @@ class LanguageManager extends GetxService {
/// 根据中文名称获取 TTS 语音名称(根据用户性别自动选择)
String? getTtsVoiceNameByChineseName(String chineseName) {
final asrCode = _chineseNameToAsrCodeMap[chineseName];
if (asrCode == null) return null;
return _getVoiceByGender(_languageMap[asrCode]);
return _getVoiceByGender(_byChineseName[chineseName]);
}
/// 根据短代码获取中文名称
String? getChineseNameByShortCode(String shortCode) {
final asrCode = _shortCodeToAsrCodeMap[shortCode];
if (asrCode == null) return null;
return _languageMap[asrCode]?.chineseName;
return _byShortCode[shortCode]?.chineseName;
}
/// 根据短代码获取英文名称
String? getEnglishNameByShortCode(String shortCode) {
final asrCode = _shortCodeToAsrCodeMap[shortCode];
if (asrCode == null) return null;
return _languageMap[asrCode]?.englishName;
return _byShortCode[shortCode]?.englishName;
}
/// 根据短代码获取 TTS 语音名称(根据用户性别自动选择)
String? getTtsVoiceNameByShortCode(String shortCode) {
final asrCode = _shortCodeToAsrCodeMap[shortCode];
if (asrCode == null) return null;
return _getVoiceByGender(_languageMap[asrCode]);
return _getVoiceByGender(_byShortCode[shortCode]);
}
/// 微软文本翻译要用的语言码。表里没有的(繁体、挪威语)按微软翻译的写法补。
String microsoftTranslateCode(String shortCode) {
for (final spec in getAzureTranslationLanguageSpecs()) {
final code = spec['translationCode'];
if (spec['shortCode'] == shortCode && code != null && code.isNotEmpty) {
return code;
}
}
switch (shortCode) {
case 'zh-Hant':
case 'zh-Hant-hk':
return 'zh-Hant';
case 'no':
return 'nb';
default:
return shortCode;
}
}
/// 同声 / 面对面 / 音视频里,一边是扩展 40 语就整句走微软文本翻译。
/// 通话不走这里,通话有自己的端到端选路。
static bool microsoftTextPipeline(
String mode, String sourceShort, String targetShort) {
if (mode != 'simultaneous' &&
mode != 'faceToFace' &&
mode != 'audioVideo') {
return false;
}
return azureCallOnlyShortCodes.contains(sourceShort) ||
azureCallOnlyShortCodes.contains(targetShort);
}
/// 获取中文名称到 ASR 代码的映射
@ -629,11 +724,12 @@ class LanguageManager extends GetxService {
/// 1. **阿里 3.5**(qwen3.5-livetranslate-flash-realtime,带实时声音复刻):
/// 源/目标都在它的「音频+文本输出」29 语内。这一档译文用**说话人本人
/// 的声音**,男女音色天然对得上,不需要猜性别。
/// 2. **阿里旧版**(qwen3-livetranslate-flash-realtime):3.5 出不了音频的
/// 语种落这里。旧版 18 语里有 16 语被 3.5 覆盖,所以实际只有
/// **yue(粤语)/ el(希腊语)** 会走到——它俩在 3.5 里是「仅文本输出」。
/// 3. **微软**:上面两档都不覆盖的语种(app 一共开放 80 个语种,
/// 阿里两档合起来 31 个,剩下约 50 个都靠微软)。
/// 2. **阿里旧版**(qwen3-livetranslate-flash-realtime):3.5 出不了音频、
/// 又不在扩展 40 语里的语种落这里。界面上实际只有 **el(希腊语)**
/// 会走到。粤语在旧版表里,但通话选路会跳过阿里。
/// 3. **微软**:上面两档都不覆盖的语种,以及 [azureCallOnlyShortCodes]
/// 里的 40 个扩展语种。一边命中就整对走微软,不再让阿里 3.5 抢走
/// 菲律宾语 / 希伯来语 / 冰岛语。
/// 4. **讯飞**:兜底。
///
/// ⚠️ 「3.5 支持 60 语」这个宣传数字**不能直接拿来当路由依据**:其中 31 语
@ -650,7 +746,15 @@ class LanguageManager extends GetxService {
Logger.info(
'[TRANS-INIT] 开始匹配提供商: $sourceShortCode -> $targetShortCode, 策略=阿里3.5(声音复刻)>阿里旧版>微软>讯飞(豆包已停用)');
final forceAzure = azureCallOnlyShortCodes.contains(sourceShortCode) ||
azureCallOnlyShortCodes.contains(targetShortCode);
for (final provider in providers) {
if (forceAzure && _isAlibaba(provider)) {
Logger.info(
'[TRANS-INIT] 跳过 ${_providerStringFromType(provider)}: $sourceShortCode -> $targetShortCode 含扩展语种,通话走微软');
continue;
}
final providerName = _providerStringFromType(provider);
final transSpecs = _getTranslationSpecs(provider);

147
apps/client/lib/data/services/microsoft_text_translation.dart

@ -0,0 +1,147 @@
import 'dart:math';
import 'package:dio/dio.dart';
import '../../core/utils/logger.dart';
import '../models/appconfig.dart';
/// 微软文本翻译。同声 / 面对面 / 音视频里扩展语种走这条,不经服务端阿里。
///
/// 密钥与通话翻译同一套:`mt_azure` 的 subscription_key / region。
/// 请求形状与原生 `MicrosoftTranslationServiceImpl` 一致。
class MicrosoftTextTranslation {
static const defaultEndpoint =
'https://api.cognitive.microsofttranslator.com';
final Dio _dio = Dio(BaseOptions(
connectTimeout: const Duration(seconds: 10),
sendTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
));
Future<String?> translate({
required String text,
required String from,
required String to,
CancelToken? cancelToken,
}) async {
final src = text.trim();
if (src.isEmpty) return null;
final key =
AppConfig.cred('mt_azure', 'subscription_key', 'AZURE_TRANSLATION_KEY');
if (key == null || key.isEmpty) {
Logger.w('MsTrans', '微软翻译密钥未下发,跳过 $from→$to');
return null;
}
final region = AppConfig.cred('mt_azure', 'region', 'AZURE_TRANSLATION_REGION');
final endpoint =
AppConfig.cred('mt_azure', 'endpoint', 'AZURE_TRANSLATION_ENDPOINT') ??
defaultEndpoint;
final headers = <String, String>{
'Ocp-Apim-Subscription-Key': key,
'Content-Type': 'application/json',
'X-ClientTraceId': _traceId(),
};
if (region != null && region.isNotEmpty) {
headers['Ocp-Apim-Subscription-Region'] = region;
}
try {
final query = <String, String>{
'api-version': '3.0',
'to': to,
};
if (from.isNotEmpty) query['from'] = from;
final response = await _dio.post<dynamic>(
'${endpoint.replaceAll(RegExp(r'/+$'), '')}/translate',
queryParameters: query,
data: [
{'text': src},
],
options: Options(headers: headers),
cancelToken: cancelToken,
);
final out = microsoftTranslationText(response.data);
if (out == null) {
Logger.w('MsTrans', '微软翻译返回空: $from→$to');
}
return out;
} on DioException catch (e) {
if (e.type == DioExceptionType.cancel) return null;
Logger.w('MsTrans', '微软翻译失败($from→$to): $e');
return null;
} catch (e) {
Logger.w('MsTrans', '微软翻译失败($from→$to): $e');
return null;
}
}
}
/// 阿里机器翻译盖不住的语种:繁体会被收成简体,乌克兰语和克罗地亚语直接被拒。
bool microsoftMtRequired(String from, String to) {
bool hit(String raw) {
switch (raw.trim().toLowerCase()) {
case 'zh-hant':
case 'zh-hant-hk':
case 'zh-tw':
case 'zh-hk':
case 'uk':
case 'uk-ua':
case 'hr':
case 'hr-hr':
return true;
default:
return false;
}
}
return hit(from) || hit(to);
}
/// 收成微软翻译认的码。空串留给自动检测,请求里不带 from。
String microsoftMtCode(String raw) {
final code = raw.trim();
if (code.isEmpty) return '';
switch (code.toLowerCase()) {
case 'zh-hant':
case 'zh-hant-hk':
case 'zh-tw':
case 'zh-hk':
return 'zh-Hant';
case 'zh':
case 'zh-cn':
case 'zh-hans':
return 'zh-Hans';
case 'uk':
case 'uk-ua':
return 'uk';
case 'hr':
case 'hr-hr':
return 'hr';
default:
final dash = code.indexOf('-');
if (dash > 0) return code.substring(0, dash).toLowerCase();
return code.toLowerCase();
}
}
/// 解析微软翻译 3.0 的响应。失败或空译文返回 null。
String? microsoftTranslationText(dynamic data) {
if (data is! List || data.isEmpty) return null;
final first = data.first;
if (first is! Map) return null;
final translations = first['translations'];
if (translations is! List || translations.isEmpty) return null;
final item = translations.first;
if (item is! Map) return null;
final text = item['text'];
if (text is! String) return null;
final out = text.trim();
return out.isEmpty ? null : out;
}
String _traceId() {
final rand = Random();
String hex(int n) =>
List.generate(n, (_) => rand.nextInt(16).toRadixString(16)).join();
return '${hex(8)}-${hex(4)}-4${hex(3)}-${hex(4)}-${hex(12)}';
}

16
apps/client/lib/data/services/server_translation_service.dart

@ -2,6 +2,7 @@ import 'package:dio/dio.dart';
import 'package:get/get.dart';
import '../../core/utils/logger.dart';
import 'microsoft_text_translation.dart';
import 'network/api.dart';
/// 文本机器翻译:由**服务端**代调后台「第三方服务配置」里启用的 MT 服务。
@ -17,6 +18,8 @@ import 'network/api.dart';
class ServerTranslationService extends GetxService {
static ServerTranslationService get to => Get.find<ServerTranslationService>();
final MicrosoftTextTranslation _microsoft = MicrosoftTextTranslation();
static const _tag = 'ServerTrans';
/// 与旧实现保持同样的接口形态。服务端无需客户端预初始化,恒为 true。
@ -60,6 +63,19 @@ class ServerTranslationService extends GetxService {
}) async {
final src = text.trim();
if (src.isEmpty) return null;
if (microsoftMtRequired(sourceLanguageCode, targetLanguageCode)) {
final from = microsoftMtCode(sourceLanguageCode);
final to = microsoftMtCode(targetLanguageCode);
Logger.i(_tag, '阿里翻译覆盖不了,改走微软: $from→$to');
final direct = await _microsoft.translate(
text: src,
from: from,
to: to,
cancelToken: cancelToken,
);
if (direct != null) return direct;
Logger.w(_tag, '微软翻译没有结果,回退服务端: $from→$to');
}
try {
final data = await Api.userTranslate({
'text': src,

15
apps/client/lib/data/services/speech_impl/azure_ast_service.dart

@ -588,6 +588,21 @@ class AzureAstService extends GetxService implements AstService {
}
}
@override
Future<bool> setNativeF2fBridge(bool enabled) async {
try {
final ok = await _channel
.invokeMethod<bool>('setNativeF2fBridge', {'enabled': enabled});
Logger.w('AzureAST', '[F2F] 原生直连桥 ${enabled ? "开启" : "关闭"}: ${ok ?? false}');
return ok ?? false;
} on MissingPluginException {
return false;
} catch (e) {
Logger.error('setNativeF2fBridge($enabled) 失败: $e');
return false;
}
}
@override
Future<Map<String, dynamic>> nativeCallBridgeStats() async {
try {

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

@ -183,6 +183,14 @@ class BesBluetoothService extends GetxService {
void _listen() {
_statusSubscription = _manager.onDeviceStatusChanged().listen((event) async {
// 面对面且固件声明经典链路会断:这次掉线是拆 TWS 的副作用,不当成用户断开。
if (event == 0 && f2fSuppressClassicDrop) {
Logger.w(_tag, '面对面:经典链路按固件约定断开,保持会话');
return;
}
if (event == 2) {
f2fSuppressClassicDrop = false;
}
deviceStatus.value = event;
Logger.i(_tag, '恒玄设备状态变化: $event');
if (event == 2) {
@ -350,11 +358,11 @@ class BesBluetoothService extends GetxService {
// 0x06(固件版本)/0x0E(通话状态) **故意不跳过**:重扫是为了确认新固件上
// 哪些命令还活着,跳过它们就失去了对照组。
const skip = {0x51, 0x52, 0x56, 0x57, 0x58, 0x59, 0x5A,
0x5E, 0x5F, 0x64, 0x65, 0x66, 0x67, 0x69, 0x6A};
0x5E, 0x5F, 0x64, 0x65, 0x66, 0x67, 0x69, 0x6A, 0x6B, 0x6C};
Logger.w(_tag,
'[探测] 开始 0x${from.toRadixString(16)}~0x${to.toRadixString(16)}');
for (var cmd = from; cmd <= to; cmd++) {
if (cmd >= 0x51 && cmd <= 0x6A) continue; // 会切工作模式,别碰
if (cmd >= 0x51 && cmd <= 0x6C) continue; // 会切工作模式,别碰
if (skip.contains(cmd)) continue;
if (deviceStatus.value != 2) return;
Logger.w(_tag, '[探测] 发送 AA ${cmd.toRadixString(16).padLeft(2, '0')}');
@ -788,6 +796,27 @@ class BesBluetoothService extends GetxService {
/// 开着时 [micPcmStream] / [spkPcmStream] **不会再有帧**——它们在原生层直接
/// 去了翻译插件;译文 PCM 也不再需要 [pushTranslationTtsPcm]。
/// 老原生(没有这个方法)返回 false,调用方据此退回 Dart 流。
/// 面对面会话里固件把经典链路拆掉时为 true。只影响本服务的断连处理。
bool f2fSuppressClassicDrop = false;
/// Echo-one 双耳 BLE 面对面。`classicKeep == false` 时记下,避免把固件主动拆掉的
/// 经典链路当成用户断开。
Future<Map<String, dynamic>> startFaceToFaceBle() async {
final result = await _manager.startFaceToFaceBle();
if (result['ok'] == true && result['classicKeep'] == false) {
f2fSuppressClassicDrop = true;
}
return result;
}
Future<void> stopFaceToFaceBle() async {
try {
await _manager.stopFaceToFaceBle();
} finally {
f2fSuppressClassicDrop = false;
}
}
Future<bool> setNativeCallPcmBridge(bool enabled) async {
try {
return await _manager.setCallPcmBridge(enabled);

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

@ -9,6 +9,7 @@ import '../../core/utils/logger.dart';
import '../device_vendors.dart';
import 'bes_bluetooth_service.dart';
import 'bes_ota_port.dart';
import 'bes_f2f_protocol.dart';
import 'bes_protocol.dart';
/// 恒玄(BES) 耳机的厂商插件——把 [BesBluetoothService](协议实现,冻结)
@ -252,6 +253,7 @@ class BesDeviceSession extends DeviceSession {
StreamController<DeviceWakeEvent>.broadcast();
final List<Worker> _workers = [];
StreamSubscription? _aiSub;
StreamSubscription? _f2fSub;
DeviceConnectionState _state = DeviceConnectionState.ready;
/// 通话翻译下行的引用计数:A/B 两路都关了才 stopCallDownlink
@ -297,6 +299,9 @@ class BesDeviceSession extends DeviceSession {
// AI 通道:BB 64 唤醒 / BB E5 结束。应答 AA 6A 由业务层通过 ai.accept 发,
// 与原先 BesAiSessionService 的判断顺序一致(未配置时不应答、发 ai.exit)。
_f2fSub = _link.manager.onFaceToFaceEvent().listen((event) {
_feature(DeviceFeatureKeys.f2fEvent, Map<String, Object?>.from(event));
}, onError: (e) => Logger.e(_tag, '面对面事件流错误: $e'));
_aiSub = _link.manager.onDeviceAiEvent().listen((event) {
switch (event['type']) {
case 'startAI':
@ -347,6 +352,8 @@ class BesDeviceSession extends DeviceSession {
_workers.clear();
_aiSub?.cancel();
_aiSub = null;
_f2fSub?.cancel();
_f2fSub = null;
_ota?.onSessionGone();
if (!_events.isClosed) {
_events.add(DeviceSessionEvent(
@ -594,6 +601,16 @@ class BesDeviceSession extends DeviceSession {
throw DeviceException(DeviceErrorCode.notSupported, featureKey);
}
return {'enabled': ok && enabled};
case DeviceFeatures.f2fBleStart:
final started = await _link.startFaceToFaceBle();
final code = started['code'];
if (started['ok'] != true && code is int) {
started['messageKey'] = BesF2f.messageKey(code);
}
return started;
case DeviceFeatures.f2fBleStop:
await _link.stopFaceToFaceBle();
return const {'ok': true};
case DeviceFeatures.aiPcmNativeBridge:
// 同 callPcmNativeBridge:不是发给耳机的命令,不进 featureCommands。
// 原生没这个方法(老包)会返回 false → 业务层退回 Dart 链路。

267
apps/client/lib/devices/bes/bes_f2f_protocol.dart

@ -0,0 +1,267 @@
/// Echo-one 面对面 BLE 协议的纯函数编解码。
///
/// 字节布局以 `docs/face_to_face_ble_protocol.md` v0.2 为准,
/// 由 `test/devices/bes_f2f_protocol_test.dart` 钉死。原生组包必须与这里一致。
library;
class BesF2f {
BesF2f._();
static const int protoVer = 0x02;
static const int capG722 = 0x01;
static const int codecG722 = 0x01;
static const int earLeft = 0x01;
static const int earRight = 0x02;
static const int magic0 = 0xF2;
static const int magic1 = 0xF2;
/// 控制与音频分开的四个特征。UUID 按协议占位,固件已按此实现。
static const String serviceUuid = 'AEAF5F32-0001-4C4B-A100-000000000001';
static const String ctrlTxUuid = 'AEAF5F32-0002-4C4B-A100-000000000001';
static const String ctrlRxUuid = 'AEAF5F32-0003-4C4B-A100-000000000001';
static const String audioUpUuid = 'AEAF5F32-0004-4C4B-A100-000000000001';
static const String audioDownUuid = 'AEAF5F32-0005-4C4B-A100-000000000001';
static const int opHello = 0x01;
static const int opSessionCfg = 0x02;
static const int opStartStream = 0x03;
static const int opStopStream = 0x04;
static const int opKeepalive = 0x05;
static const int opExit = 0x06;
static const int opAudioUp = 0x90;
static const int opAudioDown = 0x91;
static const int opEvent = 0x87;
static const int opPace = 0x88;
static const int roleApp = 0x01;
static const int vadAlways = 0x00;
static const int triggerContinuous = 0x00;
static const int defaultGain = 80;
static const int flagVad = 0x10;
static const int flagSegBegin = 0x20;
static const int flagSegEnd = 0x40;
static const int flagOwnVoice = 0x80;
static const int flagPlayBegin = 0x10;
static const int flagPlayEnd = 0x20;
static const int flagFlush = 0x40;
static const int evtButton = 0x01;
static const int evtWear = 0x02;
static const int evtBattery = 0x03;
static const int evtStats = 0x04;
static const int evtFault = 0x05;
static const int evtExitRequest = 0x06;
static const int exitUser = 0x01;
static const int exitBattery = 0x02;
static const int exitIncomingCall = 0x03;
static const int exitPeerLost = 0x04;
static const int ok = 0x00;
static const int errUnsupported = 0x01;
static const int errBusy = 0x02;
static const int errEarUnavailable = 0x03;
static const int errToken = 0x04;
static const int errBleResource = 0x05;
static const int errCodec = 0x06;
static const int errTimeout = 0x07;
static const int errEarMismatch = 0x08;
static const int errOverflow = 0x09;
static const int errInternal = 0x0A;
static const int errTwsSplit = 0x0B;
/// `AA 6B 06 <ver> <cap> <token 4>`
static List<int> enterFrame(List<int> token) {
if (token.length != 4) {
throw ArgumentError('token must be 4 bytes');
}
return <int>[
0xAA, 0x6B, 0x06, protoVer, capG722,
token[0] & 0xFF, token[1] & 0xFF, token[2] & 0xFF, token[3] & 0xFF,
];
}
static List<int> exitFrame() => const <int>[0xAA, 0x6C];
/// 解析 `BB EB`。长度不够返回 null。
static BesF2fEnterAck? parseEnterAck(List<int> frame) {
if (frame.length < 10) return null;
if ((frame[0] & 0xFF) != 0xBB || (frame[1] & 0xFF) != 0xEB) return null;
return BesF2fEnterAck(
result: frame[3] & 0xFF,
earMask: frame[4] & 0xFF,
protoVer: frame[5] & 0xFF,
codec: frame[6] & 0xFF,
frameMs: frame[7] & 0xFF,
fwMinor: frame[8] & 0xFF,
classicKeep: (frame[9] & 0xFF) == 0x01,
);
}
static bool isExitAckOk(List<int> frame) {
if (frame.length < 4) return false;
return (frame[0] & 0xFF) == 0xBB &&
(frame[1] & 0xFF) == 0xEC &&
(frame[3] & 0xFF) == ok;
}
/// 广播里 Manufacturer Data 去掉 Company ID 之后的 9 字节。
static BesF2fAdvert? parseAdvertPayload(List<int> afterCompanyId) {
if (afterCompanyId.length < 9) return null;
if ((afterCompanyId[0] & 0xFF) != magic0 ||
(afterCompanyId[1] & 0xFF) != magic1) {
return null;
}
final ear = afterCompanyId[3] & 0xFF;
if (ear != earLeft && ear != earRight) return null;
return BesF2fAdvert(
version: afterCompanyId[2] & 0xFF,
ear: ear,
token: List<int>.unmodifiable(afterCompanyId.sublist(4, 8)),
battery: afterCompanyId[8] & 0x7F,
charging: (afterCompanyId[8] & 0x80) != 0,
);
}
static List<int> hello(List<int> token) => <int>[
0xAA, opHello, 0x06, protoVer,
token[0] & 0xFF, token[1] & 0xFF, token[2] & 0xFF, token[3] & 0xFF,
roleApp,
];
static List<int> sessionCfg() => <int>[
0xAA, opSessionCfg, 0x03, vadAlways, defaultGain, triggerContinuous,
];
static List<int> startStream() => <int>[0xAA, opStartStream, 0x00];
static List<int> stopStream() => <int>[0xAA, opStopStream, 0x00];
static List<int> keepalive(int seq) =>
<int>[0xAA, opKeepalive, 0x01, seq & 0xFF];
static List<int> exitEar() => <int>[0xAA, opExit, 0x00];
/// `AA 91` 下行音频。payload = flags(含耳位) + seq16le + g722。
static List<int> audioDown({
required int ear,
required int flags,
required int seq,
required List<int> g722,
}) {
final head = (ear & 0x0F) | (flags & 0xF0);
final body = <int>[head, seq & 0xFF, (seq >> 8) & 0xFF, ...g722];
return <int>[0xAA, opAudioDown, body.length & 0xFF, ...body];
}
/// 解析一条 BLE 通知帧。不是本协议返回 null。
static BesF2fBleFrame? parseBle(List<int> frame) {
if (frame.length < 3) return null;
final sof = frame[0] & 0xFF;
if (sof != 0xAA && sof != 0xBB) return null;
final op = frame[1] & 0xFF;
final len = frame[2] & 0xFF;
if (frame.length < 3 + len) return null;
return BesF2fBleFrame(op: op, payload: frame.sublist(3, 3 + len));
}
static BesF2fAudio? parseAudioUp(BesF2fBleFrame frame) {
if (frame.op != opAudioUp || frame.payload.length < 3) return null;
final b = frame.payload[0] & 0xFF;
return BesF2fAudio(
ear: b & 0x0F,
flags: b & 0xF0,
seq: (frame.payload[1] & 0xFF) | ((frame.payload[2] & 0xFF) << 8),
g722: List<int>.unmodifiable(frame.payload.sublist(3)),
);
}
/// 用户能看懂的失败文案键。未知码用超时。
static String messageKey(int code) {
switch (code) {
case errUnsupported:
return 'f2fFirmwareUpgrade';
case errBusy:
return 'f2fEndOtherFeature';
case errEarUnavailable:
return 'f2fWearBothEars';
case errTwsSplit:
return 'f2fPutBackInCase';
default:
return 'f2fEarNotReady';
}
}
}
class BesF2fEnterAck {
const BesF2fEnterAck({
required this.result,
required this.earMask,
required this.protoVer,
required this.codec,
required this.frameMs,
required this.fwMinor,
required this.classicKeep,
});
final int result;
final int earMask;
final int protoVer;
final int codec;
final int frameMs;
final int fwMinor;
final bool classicKeep;
bool get bothEars => (earMask & 0x03) == 0x03;
}
class BesF2fAdvert {
const BesF2fAdvert({
required this.version,
required this.ear,
required this.token,
required this.battery,
required this.charging,
});
final int version;
final int ear;
final List<int> token;
final int battery;
final bool charging;
bool tokenEquals(List<int> expected) {
if (token.length != expected.length) return false;
for (var i = 0; i < token.length; i++) {
if ((token[i] & 0xFF) != (expected[i] & 0xFF)) return false;
}
return true;
}
}
class BesF2fBleFrame {
const BesF2fBleFrame({required this.op, required this.payload});
final int op;
final List<int> payload;
int get result => payload.isEmpty ? 0xFF : payload[0] & 0xFF;
}
class BesF2fAudio {
const BesF2fAudio({
required this.ear,
required this.flags,
required this.seq,
required this.g722,
});
final int ear;
final int flags;
final int seq;
final List<int> g722;
bool get ownVoice => (flags & BesF2f.flagOwnVoice) != 0;
}

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

@ -66,8 +66,16 @@ class BesCmd {
/// 应答 AI 唤醒(收到 `BB 64` 后 **3 秒内**必须发)
static const int aiAccept = 0x6A;
/// 这一段会切换耳机工作模式(进通话/录音/AI),探测时一律不碰
static bool switchesMode(int cmd) => cmd >= 0x51 && cmd <= 0x6A;
/// 进入面对面 BLE(拆 TWS、两耳广播)。见 face_to_face_ble_protocol.md。
/// 旧版 `AA 66/67` 仍保留,两者互斥、互不修改。
static const int faceToFaceBleEnter = 0x6B;
/// 退出面对面 BLE。不在该模式时也必须回 `BB EC 00`(幂等)。
static const int faceToFaceBleExit = 0x6C;
/// 这一段会切换耳机工作模式(进通话/录音/AI/面对面),探测时一律不碰。
/// 上限到 `0x6C`:再往上扫会把耳机扫进面对面并拆掉 TWS,扫描进程不会发退出。
static bool switchesMode(int cmd) => cmd >= 0x51 && cmd <= 0x6C;
static List<int> frame(int cmd) => <int>[sof, cmd & 0xFF];
}

23
apps/client/lib/devices/holder/holder_device_service.dart

@ -167,10 +167,8 @@ class HolderDeviceService extends GetxService {
/// 尝试自动回连。
///
/// Android 上 SDK 的 connect() 走的是 registerBackgroundScan(mac),
/// 那本身就是系统级「后台扫到该 MAC 就连」的注册——设备开机进入广播范围时
/// 由原生层自动接上。所以自动回连的关键是:App 启动后 / 断连后重新注册一次。
/// 注册失败按退避重试,最多 [_maxAutoAttempts] 次。
/// 应用还在前台或后台活着时,直接 GATT 回连记住的支架。
/// 进程被杀掉之后不会再被设备广播拉起来。失败按退避重试,最多 [_maxAutoAttempts] 次。
static const int _maxAutoAttempts = 5;
/// 本次运行里用户拒绝过系统蓝牙权限。拒绝后不再自动重试、不再反复弹框,
@ -183,8 +181,7 @@ class HolderDeviceService extends GetxService {
if (reset) _autoAttempt = 0;
_autoConnectTimer?.cancel();
if (isConnected) return;
// 自动回连 = 向系统注册蓝牙后台扫描 = 设备靠近时自启动本应用,
// 未取得用户明示同意前不能做(应用商店合规检测项)。
// 自动回连会在应用打开期间直接连上记住的设备,未取得用户明示同意前不做。
// 这里只做静默检查,不弹窗——弹窗只在用户主动点连接时弹。
if (!BackgroundLaunchConsent.granted) {
Logger.i(_tag, '用户未开启设备自动连接,跳过后台扫连注册');
@ -225,16 +222,16 @@ class HolderDeviceService extends GetxService {
return;
}
connectedNameRx.value = name;
Logger.i(_tag, '自动回连注册: $name $address (第 ${_autoAttempt + 1} 次)');
Logger.i(_tag, '自动回连: $name $address (第 ${_autoAttempt + 1} 次)');
connectedAddressRx.value = address;
bool ok = false;
try {
ok = await _sdk.connect(name: name, address: address);
} catch (e) {
Logger.e(_tag, '自动回连注册异常: $e');
Logger.e(_tag, '自动回连异常: $e');
}
// 注册成功后由原生后台扫连接管;这里只在注册失败时退避重试
// 直连失败时退避重试;进程被杀掉之后这条链路不会再被系统拉起来
if (!ok && ++_autoAttempt < _maxAutoAttempts) {
final delay = Duration(seconds: 5 * _autoAttempt);
_autoConnectTimer = Timer(delay, () => tryAutoConnect(reset: false));
@ -397,6 +394,10 @@ class HolderDeviceService extends GetxService {
// 必须排在 ensureBound / 任何自动回连之前:清的就是会被回连拉起来的那份记录
_purgeUntrustedPairsOnce();
_sdk.ensureBound();
if (Platform.isAndroid) {
// 升级前登记过的 PendingIntent 扫描会在进程死后把应用拉起来,打开时清掉。
unawaited(_sdk.clearBackgroundScan());
}
_scanSub = _sdk.scanResults.listen((list) {
if (list.isEmpty) return; // 空批次不清空已发现列表
for (final d in list) {
@ -581,8 +582,8 @@ class HolderDeviceService extends GetxService {
/// 等待连接真正建立。
///
/// Android 上 [connect] 调的是 registerBackgroundScan,返回值只代表「后台扫连
/// 注册成功」,真正连上是异步的,要等 connectionState 流推 connected。
/// Android 上 [connect] 会立刻发起 GATT 直连,真正连上是异步的,
/// 要等 connectionState 流推 connected。
/// SDK 的 INTEGRATION.md 快速开始里也是这个流程。
Future<bool> waitConnected({
Duration timeout = const Duration(seconds: 25),

11
apps/client/lib/modules/devices/views/bes_scan_view.dart

@ -28,13 +28,18 @@ class BesScanView extends StatefulWidget {
}
class _BesScanViewState extends State<BesScanView> {
/// 展示用的设备名。当前固件的广播名。
static const String _targetName = 'Echo-one';
/// 配对提示里展示的名字。Echo-translation 与 Echo-one 走同一条恒玄链路。
static const String _targetName = 'Echo-one / Echo-translation';
/// 认哪些广播名:设备端 2026-08-31 把广播名从 `DEEPVOICE` 改成 `Echo-one`,
/// 但 Android 的配对列表给的是系统缓存的配对名,改名前配过对的手机在重新配对
/// 之前仍然报旧名。旧名可在存量设备全部换代后删除。
static const List<String> _acceptedNames = ['Echo-one', 'DEEPVOICE'];
/// Echo-translation 是同一固件线的另一个产品名,协议与 Echo-one 相同。
static const List<String> _acceptedNames = [
'Echo-translation',
'Echo-one',
'DEEPVOICE',
];
DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.bes)!;
final RxList<DiscoveredDevice> _devices = <DiscoveredDevice>[].obs;

4
apps/client/lib/modules/settings/controllers/settings_controller.dart

@ -49,8 +49,8 @@ class SettingsController extends GetxController {
DateTime? _lastClickTime; // 上次点击时间
final RxBool showDebugInfo = false.obs; // 是否显示调试信息
/// 设备自动连接(后台唤醒本应用回连)是否已获用户授权。
/// 关掉时要同时注销已经注册到系统的蓝牙后台扫描,否则系统照样会唤醒 App。
/// 应用打开期间是否自动连接已记住的设备。
/// 关掉时断开设备,并注销系统里可能还留着的旧扫描。
final RxBool autoConnectEnabled = BackgroundLaunchConsent.granted.obs;
Future<void> toggleAutoConnect(bool value) async {

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

@ -16,6 +16,7 @@ import 'package:path_provider/path_provider.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:device_info_plus/device_info_plus.dart';
import '../../../data/services/ast_service.dart';
import '../../../data/services/microsoft_text_translation.dart';
import '../../../data/services/server_translation_service.dart';
import '../../../data/services/music_manager.dart';
import '../../../data/services/tts_service.dart';
@ -29,6 +30,7 @@ import '../../../data/services/asr_service.dart';
import '../../../data/services/usage_stats_service.dart';
import 'package:device_plugin_interface/device_plugin_interface.dart';
import '../../../devices/device_hub.dart';
import '../../../devices/device_vendors.dart';
import '../../../data/services/recording_archive.dart';
import '../models/translation_export.dart';
import '../../../core/utils/background_session_notifier.dart';
@ -74,6 +76,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// 以前直连火山,AK/SK 要明文下发给每个客户端,换服务商还得发版。
final ServerTranslationService _translationService =
Get.find<ServerTranslationService>();
final MicrosoftTextTranslation _microsoftTranslation =
MicrosoftTextTranslation();
final TtsService _ttsService = Get.find<TtsService>();
final AstService _astService = Get.find<AstService>();
final LanguageManager _languageManager = Get.find<LanguageManager>();
@ -156,6 +160,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
RxInt lastActiveSpeaker = 0.obs; // 上一次状态
/// 面对面翻译当前的触发来源,见 [FaceToFaceTrigger]
final faceToFaceTrigger = FaceToFaceTrigger.client.obs;
/// Echo-one 双耳 BLE 面对面正在进行。为 true 时不走手机麦,也不走按住说话。
bool _f2fBleActive = false;
StreamSubscription<DeviceSessionEvent>? _f2fSub;
DeviceSession? _f2fSession;
final isRecognizing = false.obs;
final isPreparing = false.obs;
final isTranslating = false.obs;
@ -583,6 +592,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 设备端那一路(见 [_configureFaceToFaceMode])。固件就绪后把左右耳按键事件
/// 接到这里即可,其余业务逻辑不用动。
Future<void> setActiveSpeaker(int id) async {
// 双耳 BLE 是连续会话,按键只标出正在说的那只耳朵,不走按住说话。
if (_f2fBleActive) {
activeSpeaker.value = id;
return;
}
if (id != 0) {
faceToFaceTrigger.value = FaceToFaceTrigger.device;
}
@ -1064,10 +1078,19 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
try {
Logger.info('开始初始化通话模式语音翻译服务');
final sourceShort = _languageManager.getShortCodeByAsrCode(sourceLanguageCode.value) ?? 'zh';
final targetShort = _languageManager.getShortCodeByAsrCode(targetLanguageCode.value) ?? 'en';
final sourceName = _languageManager.getChineseNameByAsrCode(sourceLanguageCode.value) ?? '?';
final targetName = _languageManager.getChineseNameByAsrCode(targetLanguageCode.value) ?? '?';
// 按界面上的语言名取短码。粤语和香港繁体识别码都是 zh-HK,按识别码反查会串。
final sourceInfo =
_languageManager.getLanguageByChineseName(sourceLanguage.value);
final targetInfo =
_languageManager.getLanguageByChineseName(targetLanguage.value);
final sourceShort = sourceInfo?.shortCode ??
_languageManager.getShortCodeByAsrCode(sourceLanguageCode.value) ??
'zh';
final targetShort = targetInfo?.shortCode ??
_languageManager.getShortCodeByAsrCode(targetLanguageCode.value) ??
'en';
final sourceName = sourceLanguage.value;
final targetName = targetLanguage.value;
Logger.info(
'[TRANS-INIT] 通话翻译语言对: $sourceName($sourceShort/${sourceLanguageCode.value}) -> $targetName($targetShort/${targetLanguageCode.value})');
final bestProvider = _languageManager.findBestMatchingProvider(sourceShort, targetShort);
@ -1079,8 +1102,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
}
// 构建完整的语言参数:[asrCode0, asrCode1, transCode0, transCode1, ttsVoice0, ttsVoice1]
final transCode0 = bestProvider?.sourceSpec['translationCode'] ?? sourceLanguageCode.value;
final transCode1 = bestProvider?.targetSpec['translationCode'] ?? targetLanguageCode.value;
// 没匹配上提供商时按语言名取微软翻译码。繁体的识别码是 zh-TW / zh-HK,
// 直接拿去翻译会被当成简体。
final transCode0 = bestProvider?.sourceSpec['translationCode'] ??
_languageManager.microsoftTranslateCode(sourceShort);
final transCode1 = bestProvider?.targetSpec['translationCode'] ??
_languageManager.microsoftTranslateCode(targetShort);
final ttsVoice0 = bestProvider?.sourceSpec['ttsCode'] ??
(_languageManager.getTtsVoiceNameByAsrCode(sourceLanguageCode.value) ?? 'zh-CN-XiaoxiaoNeural');
final ttsVoice1 = bestProvider?.targetSpec['ttsCode'] ??
@ -1168,6 +1195,13 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
Logger.w('Translation', '[生命周期] → $state(通话翻译进行中)');
unawaited(_dumpCallBridgeStats('lifecycle:$state'));
}
if ((state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) &&
_f2fSession != null &&
!_f2fBleActive) {
// 扫描必须在前台。还没就绪就切走,把这次进入收回来。
unawaited(_stopBesFaceToFace());
}
if (state == AppLifecycleState.inactive ||
state == AppLifecycleState.paused ||
state == AppLifecycleState.detached) {
@ -1532,10 +1566,14 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
Logger.i('Translation', '开始语音识别,模式:同声传译,ASR phone_call');
await _startAsrService('phone_call');
} else if (currentMode.value == 'faceToFace') {
// 走到这里必然是客户端触发(设备端触发已被外层挡掉),收音走手机麦克风,
// 连续识别,方向由 ASR 语种检测自动判定。
Logger.i('Translation', '开始语音识别,模式:面对面(客户端触发/手机收音),ASR normal');
await _startAsrService('normal');
final bes = _besFaceToFaceSession();
if (bes != null) {
await _startBesFaceToFace(bes);
} else {
// 没有 Echo-one 时仍走手机麦克风,其它设备的面对面不受影响。
Logger.i('Translation', '开始语音识别,模式:面对面(客户端触发/手机收音),ASR normal');
await _startAsrService('normal');
}
} else if (currentMode.value == 'call') {
if (_astAutoStartPending) {
// initialize() 已经自动拉起过一次了,见 _astAutoStartPending 注释——
@ -1609,6 +1647,9 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
Logger.error('识别异常终止: $reason');
isPreparing.value = false;
isRecognizing.value = false;
if (_f2fBleActive || _f2fSession != null) {
await _stopBesFaceToFace();
}
if (isRecording.value) {
try {
await stopRecording();
@ -1692,6 +1733,109 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
'(触发来源=${faceToFaceTrigger.value.name})');
}
DeviceSession? _besFaceToFaceSession() {
for (final session in DeviceHub.to.sessions) {
if (session.vendor == DeviceVendors.bes &&
session.supports(DeviceCapability.faceToFace)) {
return session;
}
}
return null;
}
/// Echo-one 已连接时,面对面只走双耳 BLE + 阿里端到端。失败不退回手机麦:
/// AA 6B 已经把双耳拆开,再开手机麦会对着空耳机说话。
Future<void> _startBesFaceToFace(DeviceSession session) async {
Logger.w('Translation', '面对面:Echo-one 双耳 BLE');
await AudioForegroundService.start();
if (Platform.isIOS) {
await BackgroundKeepalive.acquire('faceToFace');
}
await _initializeCallModeTranslationService();
final astOn = await _astService.setNativeF2fBridge(true);
if (!astOn) {
await _stopBesFaceToFace();
Get.snackbar('tip'.tr, 'f2fEarNotReady'.tr);
throw StateError('f2f bridge');
}
_f2fSession = session;
final result = await session.invokeFeature(DeviceFeatures.f2fBleStart);
if (!identical(_f2fSession, session)) {
throw StateError('f2f cancelled');
}
if (result['ok'] != true) {
final key = (result['messageKey'] as String?) ?? 'f2fEarNotReady';
Get.snackbar('tip'.tr, key.tr);
await _stopBesFaceToFace();
throw StateError(key);
}
_f2fBleActive = true;
_f2fSub = session.eventStream.listen(_onBesFaceToFaceEvent);
if (_astEventSubscription == null) {
await _subscribeAstEvents();
}
await _astService.startContinuousTranslation();
}
void _onBesFaceToFaceEvent(DeviceSessionEvent event) {
final feature = event.feature;
if (feature == null || feature.key != DeviceFeatureKeys.f2fEvent) return;
final data = feature.data;
switch (data['type']) {
case 'button':
final ear = data['ear'];
final action = data['action'];
if (action == 1 || action == 0x01) {
setActiveSpeaker(ear == 1 ? 1 : 2);
} else {
setActiveSpeaker(0);
}
break;
case 'wear':
Get.snackbar('tip'.tr, 'f2fWearBothEars'.tr);
break;
case 'exitRequest':
if (data['reason'] == 3 || data['reason'] == 0x03) {
Get.snackbar('tip'.tr, 'f2fIncomingCallEnded'.tr);
unawaited(stopRecognition());
}
break;
case 'earLost':
Get.snackbar('tip'.tr, 'f2fEarDisconnected'.tr);
unawaited(stopRecognition());
break;
case 'error':
Get.snackbar('tip'.tr, 'f2fEarNotReady'.tr);
unawaited(stopRecognition());
break;
}
}
Future<void> _stopBesFaceToFace() async {
if (!_f2fBleActive && _f2fSession == null && _f2fSub == null) return;
_f2fBleActive = false;
await _f2fSub?.cancel();
_f2fSub = null;
final session = _f2fSession;
_f2fSession = null;
try {
if (session != null) {
await session.invokeFeature(DeviceFeatures.f2fBleStop);
}
} catch (e) {
Logger.w('Translation', '结束面对面 BLE 失败: $e');
}
try {
await _astService.setNativeF2fBridge(false);
await _astService.stopContinuousTranslation();
} catch (e) {
Logger.w('Translation', '结束面对面 AST 失败: $e');
}
if (Platform.isIOS) {
await BackgroundKeepalive.release('faceToFace');
}
}
/// 配置音视频模式
Future<void> _configureAudioVideoMode() async {
isPreparing.value = true;
@ -2386,6 +2530,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// 它挡住重复调用,提前翻只会更严。
isRecognizing.value = false;
if (_f2fBleActive || _f2fSession != null) {
await _stopBesFaceToFace();
}
try {
await stopRecording();
await _asrService.stopContinuousRecognition();
@ -2664,6 +2812,19 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 返回翻译结果
Future<String?> _performTranslation(String sourceText, String sourceCode,
String targetCode, CancelToken? cancelToken) async {
final directed = _directedShortCodes();
if (LanguageManager.microsoftTextPipeline(
currentMode.value, directed.source, directed.target)) {
final from = _languageManager.microsoftTranslateCode(directed.source);
final to = _languageManager.microsoftTranslateCode(directed.target);
Logger.info('[MT] 扩展语种走微软翻译: $from → $to');
return _microsoftTranslation.translate(
text: sourceText,
from: from,
to: to,
cancelToken: cancelToken,
);
}
return await _translationService.translateText(
text: sourceText,
sourceLanguageCode: sourceCode,
@ -2672,6 +2833,32 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
);
}
/// 这一句实际的翻译方向,用语言短码而不是识别码。
///
/// 粤语和香港繁体的识别码都是 zh-HK。微软翻译要的是 yue / zh-Hant,
/// 按识别码送过去会把粤语翻成繁体中文。
TranslationDirection _directedShortCodes() {
final sourceShort = _languageManager
.getLanguageByChineseName(sourceLanguage.value)
?.shortCode ??
'zh';
final targetShort = _languageManager
.getLanguageByChineseName(targetLanguage.value)
?.shortCode ??
'en';
if (_isOneWayMode || _isFaceToFaceSpeaker1()) {
return TranslationDirection(sourceShort, targetShort);
}
if (_isFaceToFaceSpeaker2()) {
return TranslationDirection(targetShort, sourceShort);
}
final shouldSwap = detectedLanguageCode == sourceLanguageCode.value;
if (shouldSwap) {
return TranslationDirection(sourceShort, targetShort);
}
return TranslationDirection(targetShort, sourceShort);
}
/// 更新翻译历史
/// [sourceText] 源文本
/// [translationResult] 翻译结果
@ -2807,7 +2994,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
!_isOneWayMode && detectedLanguageCode == targetLanguageCode.value;
final detectedTargetLanguageCode =
shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value;
final directed = _directedShortCodes();
final voiceName = _languageManager
.getTtsVoiceNameByShortCode(directed.target) ??
_languageManager
.getTtsVoiceNameByAsrCode(detectedTargetLanguageCode) ??
'en-US-AriaNeural';
Logger.info("voiceName=$voiceName,text=$text");
@ -2820,9 +3010,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// 记录TTS API调用统计
try {
final durationSeconds = endTime.difference(startTime).inSeconds;
final languageName = _languageManager
.getChineseNameByAsrCode(detectedTargetLanguageCode) ??
'未知';
final languageName =
_languageManager.getChineseNameByShortCode(directed.target) ??
_languageManager
.getChineseNameByAsrCode(detectedTargetLanguageCode) ??
'未知';
_usageService.recordTtsApiCall(
text: text,
@ -3091,9 +3283,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_lastPlayedItemKey = itemKey;
_lastPlayTime = currentTime;
final voiceName =
_languageManager.getTtsVoiceNameByAsrCode(item.targetLanguageCode) ??
'en-US-AriaNeural';
final voiceName = _voiceForStoredAsr(item.targetLanguageCode);
await _ttsService.setVoice(voiceName);
await _ttsService.speakOnce(currsessionid, item.translatedText);
} catch (e) {
@ -3101,6 +3291,24 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
}
}
/// 历史里存的是识别码。粤语和香港繁体都是 zh-HK,优先对上当前选中的那一种。
String _voiceForStoredAsr(String asrCode) {
final target =
_languageManager.getLanguageByChineseName(targetLanguage.value);
final source =
_languageManager.getLanguageByChineseName(sourceLanguage.value);
if (target?.asrCode == asrCode && source?.asrCode != asrCode) {
return _languageManager.getTtsVoiceNameByShortCode(target!.shortCode) ??
'en-US-AriaNeural';
}
if (source?.asrCode == asrCode && target?.asrCode != asrCode) {
return _languageManager.getTtsVoiceNameByShortCode(source!.shortCode) ??
'en-US-AriaNeural';
}
return _languageManager.getTtsVoiceNameByAsrCode(asrCode) ??
'en-US-AriaNeural';
}
/// 播放文本
/// [text] 要播放的文本
/// [languageCode] 语言代码(可选)
@ -3131,9 +3339,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_lastPlayTime = currentTime;
final voiceName = languageCode != null
? _languageManager.getTtsVoiceNameByAsrCode(languageCode) ??
'en-US-AriaNeural'
? _voiceForStoredAsr(languageCode)
: _languageManager
.getTtsVoiceNameByShortCode(_directedShortCodes().target) ??
_languageManager
.getTtsVoiceNameByAsrCode(targetLanguageCode.value) ??
'en-US-AriaNeural';

4
apps/client/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt

@ -527,9 +527,9 @@ private var companionManager: BleCompanionManager? = null
lastScanTime = currentTime
checkBleConnection()
}
return START_STICKY
return START_NOT_STICKY
}
return START_STICKY
return START_NOT_STICKY
}
/**

3
apps/client/local_plugins/amap_service/android/src/main/AndroidManifest.xml

@ -30,9 +30,6 @@
<!--用与导航状态中保持屏幕常亮-->
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!--允许写设备缓存,用于问题排查-->
<uses-permission android:name="android.permission.WRITE_SETTINGS" />
<application>
<activity
android:name="com.amap.api.navi.AmapRouteActivity"

2
apps/client/local_plugins/amap_service/android/src/main/kotlin/com/yunqiinnovation/amap_service/NavigationService.kt

@ -105,7 +105,7 @@ class NavigationService: Service(),AMapNaviListener {
return START_NOT_STICKY
}
}
return START_STICKY
return START_NOT_STICKY
}
private val controlReceiver = object : BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {

7
apps/client/local_plugins/azure_speech/android/src/main/AndroidManifest.xml

@ -6,11 +6,6 @@
<!-- 添加前台服务权限 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MICROPHONE" />
<!-- 通话类前台服务:国内 ROM(鸿蒙 Pged-Freezer 尤其激进)对「麦克风」类型照样冻结,
通话类通常更宽容。⚠️ Android 14+ 用这个类型还要求 App 是默认拨号或持有
MANAGE_OWN_CALLS,我们都不是,所以**运行时必然可能被拒**——
AudioRecordingForegroundService 里带了回退到纯麦克风的分支,别把它删了。 -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_PHONE_CALL" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
@ -20,7 +15,7 @@
android:name=".tools.AudioRecordingForegroundService"
android:enabled="true"
android:exported="false"
android:foregroundServiceType="microphone|phoneCall" />
android:foregroundServiceType="microphone" />
<!-- 注册屏幕/系统音频捕获前台服务(Android 14+ 必须 mediaProjection 类型) -->
<service
android:name=".tools.ScreenCaptureForegroundService"

31
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt

@ -103,6 +103,11 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
bridge.postDownlink(leg, data)
if (!isFinal) return
}
val f2f = f2fPcmBridge
if (f2f != null && f2f.isEnabled) {
f2f.postDownlink(leg, data)
if (!isFinal) return
}
val sink = astTtsEventSink ?: return
// EventChannel 在主线程 send,确保有效;TTS 段一段一段过来,量级在 KB,主线程开销可忽略
mainHandler.post {
@ -155,6 +160,8 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
private var currentAstProvider: String = "azure"
/** 通话翻译 PCM 原生直连桥,按会话开关(见 setNativeCallBridge) */
private var callPcmBridge: BesCallPcmBridge? = null
/** Echo-one 面对面 BLE 桥。和通话翻译桥互斥,由业务层保证不同时开。 */
private var f2fPcmBridge: BesF2fPcmBridge? = null
/**
* AST 两路 helper 是否已就绪(与 iOS 的 astSessionReady 同义)。
@ -175,6 +182,18 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
private fun markAstBridgeReady() {
astSessionReady = true
callPcmBridge?.markReady()
f2fPcmBridge?.markReady()
}
private fun ensureF2fPcmBridge(): BesF2fPcmBridge {
f2fPcmBridge?.let { return it }
val b = BesF2fPcmBridge(
onLegA = { pushAstAudioToA(it) },
onLegB = { pushAstAudioToB(it) },
)
f2fPcmBridge = b
if (astSessionReady) b.markReady()
return b
}
private fun ensureCallPcmBridge(): BesCallPcmBridge {
@ -1591,6 +1610,18 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
result.success(callPcmBridge?.stats() ?: mapOf("enabled" to false))
}
"setNativeF2fBridge" -> {
val enabled = call.argument<Boolean>("enabled") ?: false
if (enabled) {
val b = ensureF2fPcmBridge()
b.enable()
if (astSessionReady) b.markReady()
} else {
f2fPcmBridge?.disable()
}
result.success(true)
}
"pushExternalAudio" -> {
val leg = call.argument<String>("leg")?.uppercase() ?: ""
val pcm = call.argument<ByteArray>("pcm")

79
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BesF2fPcmBridge.kt

@ -0,0 +1,79 @@
package com.yunqiinnovation.azure_speech
import android.util.Log
import com.example.bluetooth_manager.BluetoothManager
import com.example.bluetooth_manager.FaceToFaceBleSession
/**
* Echo-one 面对面的 PCM 直连桥。和通话翻译的 [BesCallPcmBridge] 分开:
* 两只耳朵都是真人,不过 B 路门限。
*
* 左耳上行进 A,右耳上行进 B。译文由 [postDownlink] 交给 BLE 会话,
* 会话里再把 A 写到右耳、B 写到左耳。
*/
class BesF2fPcmBridge(
private val onLegA: (ByteArray) -> Unit,
private val onLegB: (ByteArray) -> Unit,
) {
@Volatile
var isEnabled: Boolean = false
private set
private val lock = Any()
private var ready = false
private val holdA = ArrayDeque<ByteArray>()
private val holdB = ArrayDeque<ByteArray>()
fun enable() {
if (isEnabled) return
isEnabled = true
BluetoothManager.f2fPcmTap = { leg, pcm -> enqueue(leg, pcm) }
Log.i(TAG, "面对面桥已开启")
}
fun disable() {
if (!isEnabled) return
isEnabled = false
BluetoothManager.f2fPcmTap = null
synchronized(lock) {
holdA.clear()
holdB.clear()
}
}
fun markReady() {
val a: List<ByteArray>
val b: List<ByteArray>
synchronized(lock) {
ready = true
a = holdA.toList()
b = holdB.toList()
holdA.clear()
holdB.clear()
}
for (pcm in a) onLegA(pcm)
for (pcm in b) onLegB(pcm)
}
fun postDownlink(leg: String, pcm: ByteArray) {
if (pcm.isEmpty()) return
FaceToFaceBleSession.pushPcm(leg, pcm)
}
private fun enqueue(leg: String, pcm: ByteArray) {
val isA = leg == "A"
synchronized(lock) {
if (!ready) {
val q = if (isA) holdA else holdB
if (q.size >= 150) q.removeFirst()
q.addLast(pcm)
return
}
}
if (isA) onLegA(pcm) else onLegB(pcm)
}
companion object {
private const val TAG = "BesF2fPcmBridge"
}
}

36
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AudioRecordingForegroundService.kt

@ -70,38 +70,22 @@ class AudioRecordingForegroundService : Service() {
}
/**
* 以「麦克风 + 通话」类型进入前台,被系统拒绝则回退到纯麦克风。
*
* ⚠️ 为什么要带通话类型:真机实测(2026-09-19,HarmonyOS / ALN-AL80),
* 纯 microphone(128) 类型的前台服务**照样**被 `Pged-Freezer` 冻结——
* 服务已登记、常驻通知也挂着,按 Home 约 5s 后进程就被冻住,
* WebSocket 当场断,通话翻译整条停摆。通话类型在多数 ROM 上待遇更好。
*
* ⚠️ 为什么必须能回退:Android 14+ 要求用 phoneCall 类型的 App 是默认拨号应用
* 或持有 MANAGE_OWN_CALLS,我们两样都不是。那时 startForeground 会抛
* SecurityException / ForegroundServiceTypeNotAllowedException,
* **不接住就是前台服务起不来**,比原来还糟。
* 以麦克风类型进入前台。通知权限被关或 ROM 拒绝时只记日志,不把进程带崩。
*/
private fun startForegroundCompat(notification: android.app.Notification) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
startForeground(NOTIFICATION_ID, notification)
return
}
val micOnly = ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE
val withCall = micOnly or ServiceInfo.FOREGROUND_SERVICE_TYPE_PHONE_CALL
try {
startForeground(NOTIFICATION_ID, notification, withCall)
Log.d(TAG, "前台服务已启动(类型=麦克风+通话)")
startForeground(
NOTIFICATION_ID,
notification,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MICROPHONE,
)
Log.d(TAG, "前台服务已启动(类型=麦克风)")
} catch (e: Exception) {
Log.w(TAG, "通话类型被拒,回退纯麦克风: ${e.message}")
try {
startForeground(NOTIFICATION_ID, notification, micOnly)
Log.d(TAG, "前台服务已启动(类型=麦克风)")
} catch (e2: Exception) {
// 连纯麦克风都起不来(多半是通知权限被关 + ROM 限制),
// 别让它把整个进程带崩——录音/翻译在前台照样能跑。
Log.e(TAG, "前台服务启动失败: ${e2.message}")
}
Log.e(TAG, "前台服务启动失败: ${e.message}")
}
}
@ -125,8 +109,8 @@ class AudioRecordingForegroundService : Service() {
return START_NOT_STICKY
}
// 返回START_STICKY确保服务被系统杀死后会重启
return START_STICKY
// 用户停掉或进程被杀之后不再由系统拉起来
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? {

2
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/ScreenCaptureForegroundService.kt

@ -163,7 +163,7 @@ class ScreenCaptureForegroundService : Service() {
}
}
return START_STICKY
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null

28
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift

@ -130,6 +130,7 @@ import ble_service
private var currentAstProvider: String = "azure"
/// 通话翻译 PCM 原生直连桥,按会话开关(见 setNativeCallBridge)
private var callPcmBridge: BesCallPcmBridge?
private var f2fPcmBridge: BesF2fPcmBridge?
/// AST 两路 helper 是否已就绪。
///
@ -154,6 +155,17 @@ import ble_service
private func markAstBridgeReady() {
setAstSessionReady(true)
callPcmBridge?.markReady()
f2fPcmBridge?.markReady()
}
private func ensureF2fPcmBridge() -> BesF2fPcmBridge {
if let b = f2fPcmBridge { return b }
let b = BesF2fPcmBridge()
b.onLegA = { [weak self] pcm in self?.pushAstAudioToA(pcm) }
b.onLegB = { [weak self] pcm in self?.pushAstAudioToB(pcm) }
f2fPcmBridge = b
if isAstSessionReady { b.markReady() }
return b
}
private func ensureCallPcmBridge() -> BesCallPcmBridge {
@ -1330,6 +1342,18 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
case "getNativeCallBridgeStats":
result(callPcmBridge?.stats() ?? ["enabled": false])
case "setNativeF2fBridge":
let f2fArgs = call.arguments as? [String: Any]
let f2fOn = (f2fArgs?["enabled"] as? Bool) ?? false
if f2fOn {
let b = ensureF2fPcmBridge()
b.enable()
if isAstSessionReady { b.markReady() }
} else {
f2fPcmBridge?.disable()
}
result(true)
case "dispose":
// 让 init 的 Task 在 sleep 之后比对 generation 时直接 return,避免落到已 dispose 的 helper 上
_ = nextAstInitGeneration()
@ -1588,6 +1612,10 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
bridge.postDownlink(leg: leg, pcm: pcm)
if !isFinal { return }
}
if let f2f = f2fPcmBridge, f2f.isEnabled {
f2f.postDownlink(leg: leg, pcm: pcm)
if !isFinal { return }
}
guard let sink = astTtsEventSink else {
// 调试:报警一次,避免刷屏
astTtsEmitCount &+= 1

85
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BesF2fPcmBridge.swift

@ -0,0 +1,85 @@
import Foundation
/// Echo-one 面对面 PCM 直连桥。通知名与 bluetooth_manager 的 FaceToFaceBleSession 对齐,
/// 两个插件不能互相 import。
final class BesF2fPcmBridge {
static let uplinkNote = Notification.Name("com.eaimar.bes.f2fpcm.uplink")
static let downlinkNote = Notification.Name("com.eaimar.bes.f2fpcm.downlink")
var onLegA: ((Data) -> Void)?
var onLegB: ((Data) -> Void)?
private(set) var isEnabled = false
private var ready = false
private var holdA: [Data] = []
private var holdB: [Data] = []
private let lock = NSLock()
private var observer: NSObjectProtocol?
func enable() {
if isEnabled { return }
isEnabled = true
observer = NotificationCenter.default.addObserver(
forName: BesF2fPcmBridge.uplinkNote,
object: nil,
queue: nil
) { [weak self] note in
guard let leg = note.userInfo?["leg"] as? String,
let pcm = note.userInfo?["pcm"] as? Data else { return }
self?.enqueue(leg: leg, pcm: pcm)
}
}
func disable() {
if !isEnabled { return }
isEnabled = false
if let observer = observer {
NotificationCenter.default.removeObserver(observer)
}
observer = nil
lock.lock()
holdA.removeAll()
holdB.removeAll()
lock.unlock()
}
func markReady() {
lock.lock()
ready = true
let a = holdA
let b = holdB
holdA.removeAll()
holdB.removeAll()
lock.unlock()
for pcm in a { onLegA?(pcm) }
for pcm in b { onLegB?(pcm) }
}
func postDownlink(leg: String, pcm: Data) {
if pcm.isEmpty { return }
NotificationCenter.default.post(
name: BesF2fPcmBridge.downlinkNote,
object: nil,
userInfo: ["leg": leg, "pcm": pcm]
)
}
private func enqueue(leg: String, pcm: Data) {
let isA = leg == "A"
lock.lock()
let go = ready
if !go {
if isA {
if holdA.count >= 150 { holdA.removeFirst() }
holdA.append(pcm)
} else {
if holdB.count >= 150 { holdB.removeFirst() }
holdB.append(pcm)
}
}
lock.unlock()
if go {
if isA { onLegA?(pcm) } else { onLegB?(pcm) }
}
}
}

5
apps/client/local_plugins/ble_service/android/src/main/AndroidManifest.xml

@ -12,11 +12,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Companion 特权:后台运行 & 启动前台服务 -->
<uses-permission android:name="android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND"/>
<uses-permission android:name="android.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND"/>
<uses-permission android:name="android.permission.REQUEST_COMPANION_START_FOREGROUND_SERVICES_FROM_BACKGROUND" />
<!-- BLE 基础权限(扫描 & 连接)-->
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"

79
apps/client/local_plugins/ble_service/android/src/main/kotlin/com/yunqiinnovation/ble_service/BleCompanionManager.kt

@ -4,8 +4,6 @@ import android.app.Activity
import android.app.PendingIntent
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanSettings
import android.companion.AssociationInfo
import android.companion.AssociationRequest
import android.companion.BluetoothDeviceFilter
@ -15,10 +13,8 @@ import android.content.Intent
import android.content.IntentSender
import android.content.SharedPreferences
import android.os.Build
import android.os.ParcelUuid
import androidx.annotation.RequiresApi
import android.util.Log
import java.util.UUID
import java.util.regex.Pattern
/**
@ -489,78 +485,11 @@ class BleCompanionManager private constructor(private val context: Context) {
Log.e(TAG, "注册后台扫描失败: 没有配对的设备MAC地址")
return
}
savePairedMacAddress(macAddress)
savePairedUuid(uuids)
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
val bluetoothAdapter = bluetoothManager?.adapter
val bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
if (bluetoothLeScanner == null) {
Log.e(TAG, "注册后台扫描失败: 无法获取蓝牙扫描器")
return
}
savePairedMacAddress(macAddress)
savePairedUuid(uuids)
// 不再 startScan(PendingIntent)。升级前登记过的扫描在这里注销。
unregisterBackgroundScan()
// 构建扫描过滤器 - 参考 registerBackgroundScan 的实现
val filters = mutableListOf<ScanFilter>()
// 如果提供了额外的UUID,也添加到过滤器中
uuids?.forEach { uuidStr ->
try {
val uuid = when {
uuidStr.length == 4 -> {
// 短UUID,转换为完整UUID
val fullUuid = "0000$uuidStr-0000-1000-8000-00805f9b34fb"
UUID.fromString(fullUuid)
}
uuidStr.length == 36 -> {
// 完整UUID
UUID.fromString(uuidStr)
}
else -> {
Log.w(TAG, "无效的UUID格式: $uuidStr")
return@forEach
}
}
val filter = ScanFilter.Builder()
.setServiceUuid(ParcelUuid(uuid))
.build()
filters.add(filter)
Log.d(TAG, "添加后台扫描过滤器UUID: $uuid")
} catch (e: IllegalArgumentException) {
Log.e(TAG, "构造UUID失败: $uuidStr", e)
}
}
// val filter = ScanFilter.Builder()
// //.setDeviceAddress(mac)
// // .setServiceUuid(ParcelUuid(java.util.UUID.fromString("0000FEAA-0000-1000-8000-00805F9B34FB")))
// //.setManufacturerData(0xEFAB, byteArrayOf(0xA0.toByte(), 0x01))
// .build()
// 扫描设置
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
.build()
// // 创建PendingIntent
// val scanIntent = Intent(BleConst.ACTION_SCAN_RESULT)
// .setClass(context, BleWakeReceiver::class.java)
// val pendingIntent = PendingIntent.getBroadcast(
// context,
// 0,
// scanIntent,
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
// } else {
// PendingIntent.FLAG_UPDATE_CURRENT
// }
// )
// 启动后台扫描
bluetoothLeScanner.startScan(filters, settings, pendingIntent)
Log.i(TAG, "已注册后台扫描,目标设备MAC: $macAddress")
Log.i(TAG, "已记住设备,不登记跨进程扫描: $macAddress")
} catch (e: Exception) {
Log.e(TAG, "注册后台扫描失败: ${e.message}", e)
}

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

@ -119,6 +119,13 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
@Volatile
var aiPcmTap: ((ByteArray) -> Unit)? = null
/**
* 面对面 BLE 上行 PCM。leg "A"=左耳佩戴者,"B"=右耳佩戴者。
* 与通话翻译的 [callPcmTap] 分开,两条链路不会同时开。
*/
@Volatile
var f2fPcmTap: ((String, ByteArray) -> Unit)? = null
@Volatile
private var aiPcmBridgedFrames: Long = 0L
@ -139,6 +146,8 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
val isConnected: Boolean
get() = deviceStatus == 2
internal fun appContext(): Context = application
private class ConnectedThread(private val btSocket: BluetoothSocket) : Thread() {
private val inputStream = btSocket.inputStream
private val outputStream = btSocket.outputStream
@ -308,6 +317,10 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
}
}
}
0xEB.toByte(), 0xEC.toByte() -> {
FaceToFaceBleSession.onClassicFrame(data)
BluetoothManager.publishBluetoothCmd(mapOf("type" to "unknown", "data" to data))
}
else -> BluetoothManager.publishBluetoothCmd(mapOf("type" to "unknown", "data" to data))
}
}
@ -640,6 +653,7 @@ object BluetoothManager : PluginRegistry.RequestPermissionsResultListener {
private fun publishBluetoothStatus(status: Int) {
deviceStatus = status
if (status == 2) FaceToFaceBleSession.onClassicLinkUp()
if (status == 0) {
// 链路断开立即通知 AI 会话停止。此路径可能从任意线程、重复触发,接收方须幂等
aiCallback?.invoke(mapOf("type" to "deviceDisconnected"))

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

@ -42,6 +42,8 @@ class BluetoothManagerPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
// 时长由 bytes 算(与文件实际长度永远一致),电平驱动波形。见 PcmWavRecorder.kt。
private lateinit var bluetoothRecorderChannel: EventChannel
private var bluetoothRecorderChannelSink: EventSink? = null
private lateinit var bluetoothF2fChannel: EventChannel
private var bluetoothF2fChannelSink: EventSink? = null
override fun onAttachedToEngine(flutterPluginBinding: FlutterPlugin.FlutterPluginBinding) {
channel = MethodChannel(flutterPluginBinding.binaryMessenger, "bluetooth_manager")
@ -57,6 +59,8 @@ class BluetoothManagerPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
EventChannel(flutterPluginBinding.binaryMessenger, "bluetooth_manager/ai")
bluetoothRecorderChannel =
EventChannel(flutterPluginBinding.binaryMessenger, "bluetooth_manager/recorder")
bluetoothF2fChannel =
EventChannel(flutterPluginBinding.binaryMessenger, "bluetooth_manager/f2f")
bluetoothRecorderChannel.setStreamHandler(object : StreamHandler {
override fun onCancel(arguments: Any?) {
bluetoothRecorderChannelSink = null
@ -66,6 +70,19 @@ class BluetoothManagerPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
bluetoothRecorderChannelSink = events
}
})
bluetoothF2fChannel.setStreamHandler(object : StreamHandler {
override fun onCancel(arguments: Any?) {
bluetoothF2fChannelSink = null
FaceToFaceBleSession.eventEmitter = null
}
override fun onListen(arguments: Any?, events: EventSink?) {
bluetoothF2fChannelSink = events
FaceToFaceBleSession.eventEmitter = { ev ->
mainHandler.post { bluetoothF2fChannelSink?.success(ev) }
}
}
})
channel.setMethodCallHandler(this)
BluetoothManager.setup(flutterPluginBinding.applicationContext)
bluetoothManagerListen()
@ -140,6 +157,11 @@ class BluetoothManagerPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
result.success(true)
}
// 通话翻译 PCM 原生直连桥(见 BluetoothManager.callPcmTap)。
"startFaceToFaceBle" -> FaceToFaceBleSession.start(result)
"stopFaceToFaceBle" -> {
FaceToFaceBleSession.stop()
result.success(true)
}
"setCallPcmBridge" -> {
val enabled = call.argument<Boolean>("enabled") ?: false
BluetoothManager.setCallPcmBridge(enabled)
@ -313,6 +335,10 @@ class BluetoothManagerPlugin : FlutterPlugin, MethodCallHandler, ActivityAware {
bluetoothStatusChannel.setStreamHandler(null)
bluetoothCmdChannel.setStreamHandler(null)
bluetoothAudioChannel.setStreamHandler(null)
FaceToFaceBleSession.eventEmitter = null
FaceToFaceBleSession.stop()
bluetoothF2fChannelSink = null
bluetoothF2fChannel.setStreamHandler(null)
CallTranslationDownlink.stop()
AiAudioDownlink.stop()
BluetoothManager.setCallPcmBridge(false)

950
apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/FaceToFaceBleSession.kt

@ -0,0 +1,950 @@
package com.example.bluetooth_manager
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.le.ScanCallback
import android.bluetooth.le.ScanFilter
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.ParcelUuid
import android.os.SystemClock
import android.util.Log
import io.flutter.plugin.common.MethodChannel
import java.security.SecureRandom
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.ScheduledFuture
import java.util.concurrent.TimeUnit
import kotlin.math.max
import kotlin.math.min
import kotlin.math.roundToInt
import kotlin.math.sqrt
/**
* Echo-one 面对面:经典链路发 AA 6B 拆 TWS,左右耳各一条 BLE。
*
* 左耳上行 → leg A,右耳上行 → leg B;译文 A 播右耳、译文 B 播左耳。
* 字节与 [docs/face_to_face_ble_protocol.md] 以及 Dart `BesF2f` 一致。
* 不碰通话翻译 / AI 的发送器。
*/
object FaceToFaceBleSession {
private const val TAG = "BesF2f"
private const val PREFS = "bes_f2f"
private const val KEY_PENDING_EXIT = "pending_classic_exit"
private val SERVICE: UUID = UUID.fromString("AEAF5F32-0001-4C4B-A100-000000000001")
private val CTRL_TX: UUID = UUID.fromString("AEAF5F32-0002-4C4B-A100-000000000001")
private val CTRL_RX: UUID = UUID.fromString("AEAF5F32-0003-4C4B-A100-000000000001")
private val AUDIO_UP: UUID = UUID.fromString("AEAF5F32-0004-4C4B-A100-000000000001")
private val AUDIO_DOWN: UUID = UUID.fromString("AEAF5F32-0005-4C4B-A100-000000000001")
private val CCCD: UUID = UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")
private val main = Handler(Looper.getMainLooper())
private val random = SecureRandom()
private val lock = Object()
@Volatile var eventEmitter: ((Map<String, Any?>) -> Unit)? = null
private var result: MethodChannel.Result? = null
private var token = ByteArray(4)
private var active = false
private var entered = false
private var classicKeep = true
private var frameMs = 40
private val ears = HashMap<Int, Ear>()
private var scanCallback: ScanCallback? = null
private var scanTimer: Runnable? = null
private var enterAck: ByteArray? = null
private val downlink = F2fDownlink()
private var keepaliveSeq = 0
private var keepaliveTask: ScheduledFuture<*>? = null
private val scheduler = Executors.newSingleThreadScheduledExecutor { r -> Thread(r, "bes.f2f") }
fun start(flutterResult: MethodChannel.Result) {
synchronized(lock) {
if (active) {
main.post { flutterResult.success(fail(0x02, "busy")) }
return
}
if (!BluetoothManager.isConnected) {
main.post { flutterResult.success(fail(0x07, "classic down")) }
return
}
active = true
entered = false
result = flutterResult
ears.clear()
token = ByteArray(4).also { random.nextBytes(it) }
enterAck = null
}
val frame = byteArrayOf(
0xAA.toByte(), 0x6B.toByte(), 0x06, 0x02, 0x01,
token[0], token[1], token[2], token[3],
)
BluetoothManager.writeBytes(frame)
scheduler.schedule({
synchronized(lock) {
if (!active || enterAck != null) return@schedule
}
failStart(0x07, "no BB EB")
}, 3, TimeUnit.SECONDS)
}
fun stop() {
val keep: Boolean
val wasEntered: Boolean
synchronized(lock) {
if (!active && ears.isEmpty()) {
sendClassicExitIfUp()
return
}
keep = classicKeep
wasEntered = entered
active = false
}
teardownLinks()
if (wasEntered) {
if (BluetoothManager.isConnected) {
BluetoothManager.writeBytes(byteArrayOf(0xAA.toByte(), 0x6C.toByte()))
prefs()?.edit()?.putBoolean(KEY_PENDING_EXIT, false)?.apply()
} else if (!keep) {
prefs()?.edit()?.putBoolean(KEY_PENDING_EXIT, true)?.apply()
}
}
emit("exited", emptyMap())
}
fun onClassicFrame(data: ByteArray) {
if (data.size < 2) return
val cmd = data[1].toInt() and 0xFF
if (cmd == 0xEB) {
synchronized(lock) { enterAck = data }
onEnterAck(data)
}
}
/** 经典链路重新连上。上次退出时链路已经断了,补一次幂等的 AA 6C。 */
fun onClassicLinkUp() {
if (active) return
val p = prefs() ?: return
if (!p.getBoolean(KEY_PENDING_EXIT, false)) return
p.edit().putBoolean(KEY_PENDING_EXIT, false).apply()
BluetoothManager.writeBytes(byteArrayOf(0xAA.toByte(), 0x6C.toByte()))
Log.i(TAG, "回连后补发 AA 6C")
}
/** AST 译文。leg A 播给右耳,leg B 播给左耳。 */
fun pushPcm(leg: String, pcm: ByteArray) {
if (!active || pcm.isEmpty()) return
val ear = if (leg == "A") 2 else 1
downlink.offer(ear, pcm)
}
private fun onEnterAck(data: ByteArray) {
if (data.size < 10) {
failStart(0x0A, "short BB EB")
return
}
val code = data[3].toInt() and 0xFF
if (code != 0) {
failStart(code, "BB EB $code")
return
}
val mask = data[4].toInt() and 0xFF
if ((mask and 0x03) != 0x03) {
failStart(0x03, "ear mask $mask")
return
}
frameMs = data[7].toInt() and 0xFF
if (frameMs != 20 && frameMs != 40) frameMs = 40
classicKeep = (data[9].toInt() and 0xFF) == 0x01
entered = true
if (!classicKeep) {
prefs()?.edit()?.putBoolean(KEY_PENDING_EXIT, true)?.apply()
}
emit("entered", mapOf("classicKeep" to classicKeep, "frameMs" to frameMs))
startScan()
}
private fun startScan() {
val adapter = BluetoothAdapter.getDefaultAdapter()
val scanner = adapter?.bluetoothLeScanner
if (scanner == null) {
failStart(0x05, "no scanner")
return
}
val cb = object : ScanCallback() {
override fun onScanResult(callbackType: Int, result: ScanResult) {
consider(result)
}
override fun onScanFailed(errorCode: Int) {
failStart(0x05, "scan $errorCode")
}
}
scanCallback = cb
val filter = ScanFilter.Builder().setServiceUuid(ParcelUuid(SERVICE)).build()
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_LATENCY)
.build()
try {
scanner.startScan(listOf(filter), settings, cb)
} catch (e: SecurityException) {
failStart(0x05, "scan permission")
return
}
emit("scanning", emptyMap())
val timeout = Runnable {
val have = synchronized(lock) { ears.keys.toSet() }
if (!have.contains(1) || !have.contains(2)) {
failStart(0x03, "scan timeout ears=$have")
}
}
scanTimer = timeout
main.postDelayed(timeout, 15_000)
}
private fun consider(result: ScanResult) {
val record = result.scanRecord ?: return
val ad = record.manufacturerSpecificData ?: return
var parsed: Adv? = null
for (i in 0 until ad.size()) {
parsed = parseAdv(ad.valueAt(i)) ?: continue
break
}
val adv = parsed ?: return
if (!adv.token.contentEquals(token)) return
if (adv.version != 0x02) return
synchronized(lock) {
if (!active) return
if (ears.containsKey(adv.ear)) return
if (ears.size >= 2) return
ears[adv.ear] = Ear(adv.ear, result.device)
val ready = ears.containsKey(1) && ears.containsKey(2)
if (!ready) return
}
stopScan()
connectBoth()
}
private fun parseAdv(payload: ByteArray?): Adv? {
if (payload == null || payload.size < 9) return null
if ((payload[0].toInt() and 0xFF) != 0xF2 || (payload[1].toInt() and 0xFF) != 0xF2) return null
val ear = payload[3].toInt() and 0xFF
if (ear != 1 && ear != 2) return null
return Adv(payload[2].toInt() and 0xFF, ear, payload.copyOfRange(4, 8))
}
private fun connectBoth() {
emit("connecting", emptyMap())
val list = synchronized(lock) { ears.values.toList() }
for (ear in list) ear.connect()
scheduler.schedule({
if (!active) return@schedule
val pending = synchronized(lock) { ears.values.any { !it.handshook } }
if (pending) failStart(0x07, "handshake timeout")
}, 12, TimeUnit.SECONDS)
}
private fun onEarHandshook(ear: Int) {
val both = synchronized(lock) { ears.values.all { it.handshook } && ears.size == 2 }
if (!both) return
stopScan()
downlink.start(frameMs)
keepaliveSeq = 0
keepaliveTask?.cancel(false)
keepaliveTask = scheduler.scheduleAtFixedRate({
if (!active) return@scheduleAtFixedRate
val seq = keepaliveSeq++ and 0xFF
for (e in synchronized(lock) { ears.values.toList() }) {
e.writeCtrl(byteArrayOf(0xAA.toByte(), 0x05, 0x01, seq.toByte()))
}
}, 5, 5, TimeUnit.SECONDS)
val ack = synchronized(lock) { enterAck }
val map = hashMapOf<String, Any>(
"ok" to true,
"classicKeep" to classicKeep,
"frameMs" to frameMs,
"fwMinor" to ((ack?.getOrNull(8)?.toInt() ?: 0) and 0xFF),
)
succeed(map)
emit("ready", map)
Log.i(TAG, "双耳握手完成 frameMs=$frameMs classicKeep=$classicKeep")
}
fun onUplink(ear: Int, pcm: ByteArray) {
val tap = BluetoothManager.f2fPcmTap ?: return
if (pcm.isEmpty()) return
tap.invoke(if (ear == 1) "A" else "B", pcm)
}
fun onEarLost(ear: Int) {
if (!active) return
emit("earLost", mapOf("ear" to ear))
// 一方听不到了就停整场,10 秒内由 Dart 决定要不要重进。这里先完整退出。
failStart(0x07, "ear $ear lost")
}
private fun succeed(map: Map<String, Any>) {
val r = synchronized(lock) { val x = result; result = null; x } ?: return
main.post { r.success(map) }
}
private fun failStart(code: Int, why: String) {
Log.w(TAG, "失败 $code $why")
val wasActive = synchronized(lock) { active }
if (!wasActive && result == null) return
synchronized(lock) { active = false }
teardownLinks()
if (entered && BluetoothManager.isConnected) {
BluetoothManager.writeBytes(byteArrayOf(0xAA.toByte(), 0x6C.toByte()))
}
val r = synchronized(lock) { val x = result; result = null; x }
emit("error", mapOf("code" to code, "message" to why))
if (r != null) main.post { r.success(fail(code, why)) }
}
private fun teardownLinks() {
stopScan()
keepaliveTask?.cancel(false)
keepaliveTask = null
downlink.stop()
val list = synchronized(lock) {
val snapshot = ears.values.toList()
ears.clear()
snapshot
}
for (ear in list) {
ear.handshook = false
ear.disconnect()
ear.release()
}
}
private fun stopScan() {
scanTimer?.let { main.removeCallbacks(it) }
scanTimer = null
val cb = scanCallback ?: return
scanCallback = null
try {
BluetoothAdapter.getDefaultAdapter()?.bluetoothLeScanner?.stopScan(cb)
} catch (_: SecurityException) {
}
}
private fun sendClassicExitIfUp() {
if (BluetoothManager.isConnected) {
BluetoothManager.writeBytes(byteArrayOf(0xAA.toByte(), 0x6C.toByte()))
}
}
private fun prefs() = try {
BluetoothManager.appContext().getSharedPreferences(PREFS, Context.MODE_PRIVATE)
} catch (_: Exception) {
null
}
private fun emit(type: String, extra: Map<String, Any?>) {
val m = HashMap<String, Any?>(extra)
m["type"] = type
val sink = eventEmitter ?: return
main.post { sink(m) }
}
private fun fail(code: Int, message: String) = mapOf(
"ok" to false, "code" to code, "message" to message,
)
private data class Adv(val version: Int, val ear: Int, val token: ByteArray)
private class Ear(val ear: Int, val device: BluetoothDevice) : BluetoothGattCallback() {
var gatt: BluetoothGatt? = null
var ctrlTx: BluetoothGattCharacteristic? = null
var audioDown: BluetoothGattCharacteristic? = null
var handshook = false
var seqExpected = -1
var seenOwn = false
private var notifyStep = 0
fun connect() {
try {
gatt = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
device.connectGatt(BluetoothManager.appContext(), false, this, BluetoothDevice.TRANSPORT_LE)
} else {
device.connectGatt(BluetoothManager.appContext(), false, this)
}
} catch (e: SecurityException) {
FaceToFaceBleSession.onEarLost(ear)
}
}
fun disconnect() {
try {
writeCtrl(byteArrayOf(0xAA.toByte(), 0x06, 0x00))
} catch (_: Exception) {
}
try {
gatt?.disconnect()
gatt?.close()
} catch (_: SecurityException) {
}
gatt = null
}
fun writeCtrl(bytes: ByteArray) {
main.post { writeNow(ctrlTx, bytes) }
}
fun writeAudio(bytes: ByteArray) {
main.post { writeNow(audioDown, bytes) }
}
private fun writeNow(c: BluetoothGattCharacteristic?, bytes: ByteArray) {
val ch = c ?: return
val g = gatt ?: return
ch.writeType = BluetoothGattCharacteristic.WRITE_TYPE_NO_RESPONSE
ch.value = bytes
try {
g.writeCharacteristic(ch)
} catch (_: SecurityException) {
}
}
override fun onConnectionStateChange(g: BluetoothGatt, status: Int, newState: Int) {
if (newState == BluetoothProfile.STATE_CONNECTED) {
try {
g.requestMtu(247)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
g.requestConnectionPriority(BluetoothGatt.CONNECTION_PRIORITY_HIGH)
}
} catch (_: SecurityException) {
FaceToFaceBleSession.onEarLost(ear)
}
} else if (newState == BluetoothProfile.STATE_DISCONNECTED) {
if (handshook) FaceToFaceBleSession.onEarLost(ear)
}
}
override fun onMtuChanged(g: BluetoothGatt, mtu: Int, status: Int) {
try {
g.discoverServices()
} catch (_: SecurityException) {
FaceToFaceBleSession.onEarLost(ear)
}
}
override fun onServicesDiscovered(g: BluetoothGatt, status: Int) {
val svc = g.getService(SERVICE)
if (svc == null) {
FaceToFaceBleSession.onEarLost(ear)
return
}
ctrlTx = svc.getCharacteristic(CTRL_TX)
audioDown = svc.getCharacteristic(AUDIO_DOWN)
val rx = svc.getCharacteristic(CTRL_RX)
val up = svc.getCharacteristic(AUDIO_UP)
if (ctrlTx == null || audioDown == null || rx == null || up == null) {
FaceToFaceBleSession.onEarLost(ear)
return
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
try {
g.setPreferredPhy(
BluetoothDevice.PHY_LE_2M_MASK,
BluetoothDevice.PHY_LE_2M_MASK,
BluetoothDevice.PHY_OPTION_NO_PREFERRED,
)
} catch (_: SecurityException) {
}
}
notifyStep = 0
enableNotify(g, rx)
}
private fun enableNotify(g: BluetoothGatt, c: BluetoothGattCharacteristic) {
try {
g.setCharacteristicNotification(c, true)
val d = c.getDescriptor(CCCD)
if (d == null) {
nextNotify(g)
return
}
d.value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE
g.writeDescriptor(d)
} catch (_: SecurityException) {
FaceToFaceBleSession.onEarLost(ear)
}
}
override fun onDescriptorWrite(g: BluetoothGatt, descriptor: BluetoothGattDescriptor, status: Int) {
nextNotify(g)
}
private fun nextNotify(g: BluetoothGatt) {
notifyStep++
if (notifyStep == 1) {
val up = g.getService(SERVICE)?.getCharacteristic(AUDIO_UP) ?: return
enableNotify(g, up)
return
}
sendHello()
}
private fun sendHello() {
val t = token
writeCtrl(byteArrayOf(
0xAA.toByte(), 0x01, 0x06, 0x02, t[0], t[1], t[2], t[3], 0x01,
))
}
override fun onCharacteristicChanged(g: BluetoothGatt, characteristic: BluetoothGattCharacteristic) {
val v = characteristic.value ?: return
if (characteristic.uuid == AUDIO_UP) onAudio(v) else onCtrl(v)
}
private fun onCtrl(frame: ByteArray) {
if (frame.size < 3) return
val op = frame[1].toInt() and 0xFF
val len = frame[2].toInt() and 0xFF
if (frame.size < 3 + len) return
val p = frame.copyOfRange(3, 3 + len)
when (op) {
0x81 -> {
if (p.isEmpty() || (p[0].toInt() and 0xFF) != 0) {
FaceToFaceBleSession.onEarLost(ear)
return
}
writeCtrl(byteArrayOf(0xAA.toByte(), 0x02, 0x03, 0x00, 80, 0x00))
}
0x82 -> writeCtrl(byteArrayOf(0xAA.toByte(), 0x03, 0x00))
0x83 -> {
if (p.isNotEmpty() && (p[0].toInt() and 0xFF) != 0) {
FaceToFaceBleSession.onEarLost(ear)
return
}
handshook = true
FaceToFaceBleSession.onEarHandshook(ear)
}
0x87 -> onEvent(p)
0x88 -> if (p.isNotEmpty()) downlink.onPace(ear, p[0].toInt())
}
}
private fun onEvent(p: ByteArray) {
if (p.isEmpty()) return
when (p[0].toInt() and 0xFF) {
0x01 -> if (p.size >= 3) emit("button", mapOf(
"ear" to ear,
"key" to (p[1].toInt() and 0xFF),
"action" to (p[2].toInt() and 0xFF),
))
0x02 -> if (p.size >= 2) emit("wear", mapOf(
"ear" to ear, "state" to (p[1].toInt() and 0xFF),
))
0x06 -> {
val reason = if (p.size >= 2) p[1].toInt() and 0xFF else 0
emit("exitRequest", mapOf("ear" to ear, "reason" to reason))
if (reason == 0x03) FaceToFaceBleSession.failStart(0x02, "incoming call")
}
}
}
private fun onAudio(frame: ByteArray) {
if (frame.size < 6) return
if ((frame[1].toInt() and 0xFF) != 0x90) return
val len = frame[2].toInt() and 0xFF
if (frame.size < 3 + len || len < 3) return
val flags = frame[3].toInt() and 0xFF
val earBits = flags and 0x0F
if (earBits != ear) return
val seq = (frame[4].toInt() and 0xFF) or ((frame[5].toInt() and 0xFF) shl 8)
val g722 = frame.copyOfRange(6, 3 + len)
val own = (flags and 0x80) != 0
if (own) seenOwn = true
if (seenOwn && !own) return
val chunks = g722.size / 40
if (seqExpected >= 0) {
var gap = (seq - seqExpected) and 0xFFFF
if (gap in 1..4) {
val silence = ByteArray(640)
repeat(gap) { FaceToFaceBleSession.onUplink(ear, silence) }
}
}
seqExpected = (seq + max(chunks, 1)) and 0xFFFF
if (g722Dec == 0L) g722Dec = G722Codec.init(16000)
var off = 0
while (off + 40 <= g722.size) {
val chunk = g722.copyOfRange(off, off + 40)
val pcm = G722Codec.decodeSync(g722Dec, chunk) ?: break
FaceToFaceBleSession.onUplink(ear, pcm)
off += 40
}
}
private var g722Dec = 0L
fun release() {
if (g722Dec != 0L) {
G722Codec.release(g722Dec)
g722Dec = 0L
}
}
}
/**
* 每耳一条 40ms 节拍的下行。积压超过 0.5s 裁掉超长静音;超过 1s 用 WSOLA 最高 1.3 倍追。
* 起播先垫 4 包编码静音,断句后续 500ms 静音。新一句到来且上一句还在队列里时置 FLUSH。
*/
private class F2fDownlink {
private val legs = arrayOf(Leg(1), Leg(2))
private var task: ScheduledFuture<*>? = null
private var packetMs = 40
fun start(ms: Int) {
stop()
packetMs = if (ms == 20) 20 else 40
for (l in legs) l.reset()
task = FaceToFaceBleSession.scheduler.scheduleAtFixedRate({ tick() }, 0, packetMs.toLong(), TimeUnit.MILLISECONDS)
}
fun stop() {
task?.cancel(false)
task = null
for (l in legs) l.release()
}
fun offer(ear: Int, pcm: ByteArray) {
legs.firstOrNull { it.ear == ear }?.offer(pcm)
}
fun onPace(ear: Int, adj: Int) {
val leg = legs.firstOrNull { it.ear == ear } ?: return
when (adj.toByte().toInt()) {
1 -> leg.extraPackets++
-1 -> leg.skipPackets++
0 -> leg.restart()
}
}
private fun tick() {
if (!FaceToFaceBleSession.active) return
for (leg in legs) {
val link = synchronized(FaceToFaceBleSession.lock) {
FaceToFaceBleSession.ears[leg.ear]
} ?: continue
leg.tick(packetMs, link)
}
}
}
private class Leg(val ear: Int) {
val frames = ArrayDeque<ByteArray>()
private companion object {
const val FRAME = 640
}
var enc = 0L
var seq = 0
var playing = false
var preroll = 0
var tail = 0
var extraPackets = 0
var skipPackets = 0
var flushNext = false
var lastOffer = 0L
val wsola = Wsola()
var silencePkt: ByteArray? = null
fun reset() {
frames.clear()
wsola.reset()
playing = false
preroll = 0
tail = 0
seq = 0
flushNext = false
if (enc == 0L) enc = G722Codec.init(16000)
}
fun release() {
if (enc != 0L) {
G722Codec.release(enc)
enc = 0L
}
frames.clear()
}
fun restart() {
frames.clear()
wsola.reset()
playing = false
preroll = 4
}
@Synchronized
fun offer(pcm: ByteArray) {
val now = SystemClock.elapsedRealtime()
if (playing && now - lastOffer > 150 && frames.isNotEmpty()) flushNext = true
lastOffer = now
var off = 0
while (off + FRAME <= pcm.size) {
frames.add(pcm.copyOfRange(off, off + FRAME))
off += FRAME
}
trimSilence()
}
private fun trimSilence() {
if (frames.size * 20 < 500) return
var silence = 0
for (f in frames) {
if (rms(f) < 300) silence += 20 else break
}
var drop = silence - 150
while (drop >= 20 && frames.isNotEmpty() && rms(frames.first()) < 300) {
frames.removeFirst()
drop -= 20
}
}
fun tick(packetMs: Int, earLink: Ear) {
val n = if (packetMs == 20) 1 else 2
if (skipPackets > 0) {
skipPackets--
return
}
var packets = 1 + extraPackets
extraPackets = 0
if (packets > 3) packets = 3
val backlogMs = frames.size * 20
val rate = when {
backlogMs <= 1000 -> 1.0
backlogMs >= 2500 -> 1.3
else -> 1.0 + (backlogMs - 1000) / 1500.0 * 0.3
}
repeat(packets) {
if (preroll > 0 || (!playing && frames.isNotEmpty())) {
if (!playing) {
preroll = 4
playing = true
}
send(earLink, silence(n), playBegin = preroll == 4, flush = false, playEnd = false)
preroll--
return@repeat
}
val pcm = pull(n, rate) ?: run {
if (playing && tail < 500) {
tail += packetMs
val end = tail >= 500
send(earLink, silence(n), playBegin = false, flush = false, playEnd = end)
if (end) {
playing = false
tail = 0
}
}
return@repeat
}
tail = 0
val flush = flushNext
flushNext = false
send(earLink, encode(pcm, n), playBegin = false, flush = flush, playEnd = false)
}
}
private fun pull(n: Int, rate: Double): ByteArray? {
if (frames.isEmpty() && wsola.idle()) return null
val out = ByteArray(FRAME * n)
var filled = 0
while (filled < n) {
val frame = wsola.next(rate, frames) ?: return if (filled == 0) null else out.copyOf(filled * FRAME)
System.arraycopy(frame, 0, out, filled * FRAME, min(FRAME, frame.size))
filled++
}
return out
}
private fun encode(pcm: ByteArray, n: Int): ByteArray {
val out = ByteArray(40 * n)
var i = 0
while (i < n) {
val encFrame = G722Codec.encodeSync(enc, pcm.copyOfRange(i * FRAME, (i + 1) * FRAME))
?: return silence(n)
System.arraycopy(encFrame, 0, out, i * 40, min(40, encFrame.size))
i++
}
return out
}
private fun silence(n: Int): ByteArray {
silencePkt?.let { if (it.size == 40 * n) return it }
val pcm = ByteArray(FRAME * n)
val s = encode(pcm, n)
silencePkt = s
return s
}
private fun send(link: Ear, g722: ByteArray, playBegin: Boolean, flush: Boolean, playEnd: Boolean) {
var flags = ear and 0x0F
if (playBegin) flags = flags or 0x10
if (playEnd) flags = flags or 0x20
if (flush) flags = flags or 0x40
val bodyLen = 3 + g722.size
val pkt = ByteArray(3 + bodyLen)
pkt[0] = 0xAA.toByte()
pkt[1] = 0x91.toByte()
pkt[2] = bodyLen.toByte()
pkt[3] = flags.toByte()
pkt[4] = (seq and 0xFF).toByte()
pkt[5] = ((seq shr 8) and 0xFF).toByte()
System.arraycopy(g722, 0, pkt, 6, g722.size)
seq = (seq + 1) and 0xFFFF
link.writeAudio(pkt)
}
private fun rms(pcm: ByteArray): Int {
var acc = 0.0
var i = 0
var n = 0
while (i + 1 < pcm.size) {
val s = (pcm[i].toInt() and 0xFF) or (pcm[i + 1].toInt() shl 8)
val v = s.toShort().toInt()
acc += v * v
n++
i += 8
}
if (n == 0) return 0
return sqrt(acc / n).toInt()
}
}
/** 20ms 输出的 WSOLA。rate=1 时重叠相加还原原信号。 */
private class Wsola {
private val n = 320
private val s = 160
private val t = 160
private var buf = ShortArray(n * 8)
private var len = 0
private var ana = 0.0
private var prev = -1
private val win = FloatArray(n) { i -> (0.5 - 0.5 * kotlin.math.cos(2 * Math.PI * i / n)).toFloat() }
private val acc = FloatArray(n)
fun reset() {
len = 0
ana = 0.0
prev = -1
java.util.Arrays.fill(acc, 0f)
}
fun idle(): Boolean = len <= 0
fun next(rate: Double, src: ArrayDeque<ByteArray>): ByteArray? {
val need = ana.roundToInt() + t + n
while (len < need && src.isNotEmpty()) append(src.removeFirst())
if (len < ana.roundToInt() + n) return null
val nominal = ana.roundToInt().coerceIn(0, max(0, len - n))
val start = pick(nominal)
var i = 0
while (i < n) {
acc[i] += win[i] * buf[start + i]
i++
}
val pcm = ByteArray(n * 2)
var j = 0
while (j < s) {
val v = acc[j].roundToInt().coerceIn(-32768, 32767)
pcm[j * 2] = (v and 0xFF).toByte()
pcm[j * 2 + 1] = ((v shr 8) and 0xFF).toByte()
j++
}
// 只吐出前 10ms 的话一帧就少一半。合成步长是 10ms,要两次才够 20ms。
System.arraycopy(acc, s, acc, 0, n - s)
java.util.Arrays.fill(acc, n - s, n, 0f)
prev = start + s
ana += s * rate
val start2 = pick(ana.roundToInt().coerceIn(0, max(0, len - n)))
i = 0
while (i < n && start2 + i < len) {
acc[i] += win[i] * buf[start2 + i]
i++
}
j = 0
while (j < s) {
val v = acc[j].roundToInt().coerceIn(-32768, 32767)
val o = (s + j) * 2
pcm[o] = (v and 0xFF).toByte()
pcm[o + 1] = ((v shr 8) and 0xFF).toByte()
j++
}
System.arraycopy(acc, s, acc, 0, n - s)
java.util.Arrays.fill(acc, n - s, n, 0f)
prev = start2 + s
ana += s * rate
compact()
return pcm
}
private fun pick(nominal: Int): Int {
if (prev < 0 || prev + s > len) return nominal
var best = 0
var bestScore = Double.NEGATIVE_INFINITY
val lo = max(-t, -nominal)
val hi = min(t, len - n - nominal)
var d = lo
while (d <= hi) {
val c = nominal + d
var xy = 0.0
var yy = 1e-6
var k = 0
while (k < s) {
val x = buf[prev + k].toDouble()
val y = buf[c + k].toDouble()
xy += x * y
yy += y * y
k++
}
val score = xy / sqrt(yy)
if (score > bestScore) {
bestScore = score
best = d
}
d++
}
return nominal + best
}
private fun append(block: ByteArray) {
val samples = block.size / 2
ensure(len + samples)
var i = 0
while (i < samples) {
val v = (block[i * 2].toInt() and 0xFF) or (block[i * 2 + 1].toInt() shl 8)
buf[len + i] = v.toShort()
i++
}
len += samples
}
private fun ensure(cap: Int) {
if (buf.size < cap) buf = buf.copyOf(max(cap, buf.size * 2))
}
private fun compact() {
val keep = min(ana.roundToInt(), if (prev < 0) ana.roundToInt() else prev) - t - n
if (keep > n * 4) {
System.arraycopy(buf, keep, buf, 0, len - keep)
len -= keep
ana -= keep
prev -= keep
}
}
}
}

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

@ -523,6 +523,10 @@ public final class BluetoothManager: NSObject {
break
}
}
case 0xEB, 0xEC:
// 面对面 BLE 的进入/退出应答。仍按 unknown 上抛,Dart 侧原有解析不受影响。
FaceToFaceBleSession.shared.onClassicFrame(frame)
publishCmd(["type": "unknown", "data": frame])
default:
publishCmd(["type": "unknown", "data": frame])
}

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

@ -88,6 +88,18 @@ class RecorderStreamHandler: NSObject, FlutterStreamHandler {
}
}
class F2fStreamHandler: NSObject, FlutterStreamHandler {
func onListen(withArguments arguments: Any?, eventSink events: @escaping FlutterEventSink) -> FlutterError? {
FaceToFaceBleSession.shared.eventSink = events
return nil
}
func onCancel(withArguments arguments: Any?) -> FlutterError? {
FaceToFaceBleSession.shared.eventSink = nil
return nil
}
}
class CmdStreamHandler: NSObject, FlutterStreamHandler {
weak var plugin: BluetoothManagerPlugin?
@ -123,6 +135,7 @@ public class BluetoothManagerPlugin: NSObject, FlutterPlugin {
private var audioHandler: AudioStreamHandler?
private var aiHandler: AiStreamHandler?
private var recorderHandler: RecorderStreamHandler?
private var f2fHandler: F2fStreamHandler?
public static func register(with registrar: FlutterPluginRegistrar) {
let channel = FlutterMethodChannel(name: "bluetooth_manager", binaryMessenger: registrar.messenger())
@ -132,6 +145,7 @@ public class BluetoothManagerPlugin: NSObject, FlutterPlugin {
let audioChannel = FlutterEventChannel(name: "bluetooth_manager/audio", binaryMessenger: registrar.messenger())
let aiChannel = FlutterEventChannel(name: "bluetooth_manager/ai", binaryMessenger: registrar.messenger())
let recChannel = FlutterEventChannel(name: "bluetooth_manager/recorder", binaryMessenger: registrar.messenger())
let f2fChannel = FlutterEventChannel(name: "bluetooth_manager/f2f", binaryMessenger: registrar.messenger())
let instance = BluetoothManagerPlugin()
registrar.addMethodCallDelegate(instance, channel: channel)
@ -142,8 +156,19 @@ public class BluetoothManagerPlugin: NSObject, FlutterPlugin {
instance.audioHandler = AudioStreamHandler(plugin: instance)
instance.aiHandler = AiStreamHandler(plugin: instance)
instance.recorderHandler = RecorderStreamHandler(plugin: instance)
instance.f2fHandler = F2fStreamHandler()
recChannel.setStreamHandler(instance.recorderHandler)
f2fChannel.setStreamHandler(instance.f2fHandler)
NotificationCenter.default.addObserver(
forName: FaceToFaceBleSession.downlinkNote,
object: nil,
queue: nil
) { note in
guard let leg = note.userInfo?["leg"] as? String,
let pcm = note.userInfo?["pcm"] as? Data else { return }
FaceToFaceBleSession.shared.pushDownlink(leg: leg, pcm: pcm)
}
statusChannel.setStreamHandler(instance.statusHandler)
readChannel.setStreamHandler(instance.readHandler)
cmdChannel.setStreamHandler(instance.cmdHandler)
@ -208,6 +233,7 @@ public class BluetoothManagerPlugin: NSObject, FlutterPlugin {
BluetoothManager.sharedInstance = bluetoothManager
bluetoothManager?.onConnected = { [weak self] _ in
FaceToFaceBleSession.shared.onClassicLinkUp()
DispatchQueue.main.async { self?.statusEventSink?(2) }
}
bluetoothManager?.onDisconnected = { [weak self] _, _ in
@ -257,6 +283,13 @@ public class BluetoothManagerPlugin: NSObject, FlutterPlugin {
// 通话翻译 PCM 原生直连桥(见 CallPcmBridge.swift)。
// 开着时 micData/spkData 不再经过 Dart;关掉即恢复原来的 EventChannel 路径。
case "startFaceToFaceBle":
FaceToFaceBleSession.shared.start { value in
result(value)
}
case "stopFaceToFaceBle":
FaceToFaceBleSession.shared.stop()
result(true)
case "setCallPcmBridge":
let args = call.arguments as? [String: Any]
let enabled = (args?["enabled"] as? Bool) ?? false

510
apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/FaceToFaceBleSession.swift

@ -0,0 +1,510 @@
import CoreBluetooth
import Flutter
import Foundation
import os.log
/// Echo-one 面对面:第二只耳朵只能走 BLE。和通话翻译的 CallPcmBridge 分开。
///
/// 左耳上行 → 通知 leg=A,右耳上行 → leg=B。下行通知 A 写右耳、B 写左耳。
/// 这只 central 不带 restore identifier,名额留给经典蓝牙那只。
final class FaceToFaceBleSession: NSObject, CBCentralManagerDelegate, CBPeripheralDelegate {
static let shared = FaceToFaceBleSession()
static let uplinkNote = Notification.Name("com.eaimar.bes.f2fpcm.uplink")
static let downlinkNote = Notification.Name("com.eaimar.bes.f2fpcm.downlink")
private let svc = CBUUID(string: "AEAF5F32-0001-4C4B-A100-000000000001")
private let ctrlTxUUID = CBUUID(string: "AEAF5F32-0002-4C4B-A100-000000000001")
private let ctrlRxUUID = CBUUID(string: "AEAF5F32-0003-4C4B-A100-000000000001")
private let audioUpUUID = CBUUID(string: "AEAF5F32-0004-4C4B-A100-000000000001")
private let audioDownUUID = CBUUID(string: "AEAF5F32-0005-4C4B-A100-000000000001")
private let cccd = CBUUID(string: "00002902-0000-1000-8000-00805F9B34FB")
private let queue = DispatchQueue(label: "com.eaimar.f2f.ble")
private var central: CBCentralManager?
private var token = Data()
private var frameMs = 40
private var classicKeep = true
private var active = false
private var result: FlutterResult?
private var ears: [Int: Ear] = [:]
private var seen = Set<Int>()
private var scanTimer: DispatchSourceTimer?
private var ackTimer: DispatchSourceTimer?
private var keepalive: DispatchSourceTimer?
private let downlink = Downlink()
var eventSink: FlutterEventSink?
private let pendingKey = "bes_f2f_pending_classic_exit"
private let log = OSLog(subsystem: "com.eaimar.bluetooth", category: "f2f")
func start(_ result: @escaping FlutterResult) {
queue.async {
if self.active {
self.finish(result, ok: false, code: 2, message: "面对面翻译已经在进行")
return
}
guard BluetoothManager.sharedInstance?.isConnected == true else {
self.finish(result, ok: false, code: 7, message: "请先连接耳机")
return
}
var raw = [UInt8](repeating: 0, count: 4)
_ = SecRandomCopyBytes(kSecRandomDefault, 4, &raw)
self.token = Data(raw)
self.active = true
self.result = result
self.classicKeep = true
self.frameMs = 40
var frame = Data([0xAA, 0x6B, 0x06, 0x02, 0x01])
frame.append(self.token)
BluetoothManager.writeBytes(frame)
self.armAck()
}
}
func stop() {
queue.async { self.shutdown(sendExit: true) }
}
func onClassicFrame(_ data: Data) {
queue.async {
guard data.count >= 4, data[0] == 0xBB else { return }
if data[1] == 0xEB { self.onEnterAck(data) }
if data[1] == 0xEC { self.onExitAck(data) }
}
}
func onClassicLinkUp() {
queue.async {
guard UserDefaults.standard.bool(forKey: self.pendingKey), !self.active else { return }
BluetoothManager.writeBytes(Data([0xAA, 0x6C]))
}
}
func pushDownlink(leg: String, pcm: Data) {
let ear = leg == "A" ? 2 : 1
downlink.offer(ear: ear, pcm: pcm)
}
private func onEnterAck(_ data: Data) {
ackTimer?.cancel()
ackTimer = nil
guard active, result != nil else { return }
let code = data.count > 3 ? Int(data[3]) : 0xFF
guard code == 0 else {
fail(code: code, message: "耳机拒绝进入面对面")
return
}
let mask = data.count > 4 ? Int(data[4]) : 0
guard mask & 0x03 == 0x03 else {
fail(code: 3, message: "请确认双耳都已取出并佩戴")
return
}
if data.count > 7, data[7] == 20 { frameMs = 20 }
classicKeep = data.count <= 9 || data[9] != 0
if !classicKeep {
UserDefaults.standard.set(true, forKey: pendingKey)
}
emit(["type": "scanning"])
startScan()
}
private func onExitAck(_ data: Data) {
if data.count > 3, data[3] == 0 {
UserDefaults.standard.set(false, forKey: pendingKey)
}
}
private func startScan() {
if central == nil {
central = CBCentralManager(delegate: self, queue: queue)
} else if central?.state == .poweredOn {
beginScan()
}
}
func centralManagerDidUpdateState(_ central: CBCentralManager) {
if central.state == .poweredOn, active, ears.count < 2 {
beginScan()
}
}
private func beginScan() {
central?.scanForPeripherals(withServices: [svc], options: [
CBCentralManagerScanOptionAllowDuplicatesKey: true
])
scanTimer?.cancel()
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 15)
timer.setEventHandler { [weak self] in self?.fail(code: 5, message: "没有找到双耳") }
timer.resume()
scanTimer = timer
}
func centralManager(
_ central: CBCentralManager,
didDiscover peripheral: CBPeripheral,
advertisementData: [String: Any],
rssi RSSI: NSNumber
) {
guard let parsed = parseAdv(advertisementData) else { return }
guard parsed.ver == 0x02, parsed.token == token, parsed.ear == 1 || parsed.ear == 2 else { return }
if seen.contains(parsed.ear) { return }
seen.insert(parsed.ear)
let ear = Ear(ear: parsed.ear, peripheral: peripheral, owner: self)
ears[parsed.ear] = ear
peripheral.delegate = ear
central.connect(peripheral, options: nil)
emit(["type": "connecting", "ear": parsed.ear])
if seen.count >= 2 {
central.stopScan()
scanTimer?.cancel()
scanTimer = nil
}
}
func centralManager(_ central: CBCentralManager, didConnect peripheral: CBPeripheral) {
peripheral.discoverServices([svc])
}
func centralManager(_ central: CBCentralManager, didFailToConnect peripheral: CBPeripheral, error: Error?) {
fail(code: 6, message: "耳机连接失败")
}
func centralManager(_ central: CBCentralManager, didDisconnectPeripheral peripheral: CBPeripheral, error: Error?) {
guard let ear = ears.values.first(where: { $0.peripheral.identifier == peripheral.identifier }) else { return }
if ear.handshook, active {
emit(["type": "earLost", "ear": ear.ear])
fail(code: 6, message: "耳机已断开")
}
}
fileprivate func earReady(_ ear: Int) {
guard ears.values.filter({ $0.handshook }).count >= 2 else { return }
downlink.start(frameMs: frameMs, session: self)
startKeepalive()
let pending = result
result = nil
emit(["type": "ready", "frameMs": frameMs])
DispatchQueue.main.async {
pending?(["ok": true, "classicKeep": self.classicKeep, "frameMs": self.frameMs])
}
os_log("面对面双耳就绪 frameMs=%d classicKeep=%d", log: log, type: .default, frameMs, classicKeep ? 1 : 0)
}
fileprivate func uplink(ear: Int, pcm: Data) {
let leg = ear == 1 ? "A" : "B"
NotificationCenter.default.post(
name: FaceToFaceBleSession.uplinkNote,
object: nil,
userInfo: ["leg": leg, "pcm": pcm]
)
}
fileprivate func emitEvent(_ map: [String: Any]) {
emit(map)
}
private func startKeepalive() {
keepalive?.cancel()
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 5, repeating: 5)
timer.setEventHandler { [weak self] in
guard let self = self else { return }
let seq = UInt8(Int.random(in: 0...255))
let frame = Data([0xAA, 0x05, 0x01, seq])
for ear in self.ears.values { ear.writeCtrl(frame) }
}
timer.resume()
keepalive = timer
}
private func armAck() {
ackTimer?.cancel()
let timer = DispatchSource.makeTimerSource(queue: queue)
timer.schedule(deadline: .now() + 3)
timer.setEventHandler { [weak self] in self?.fail(code: 4, message: "耳机没有应答") }
timer.resume()
ackTimer = timer
}
private func fail(code: Int, message: String) {
let pending = result
result = nil
shutdown(sendExit: true)
emit(["type": "error", "code": code, "message": message])
if let pending = pending {
DispatchQueue.main.async {
pending(["ok": false, "code": code, "message": message])
}
}
}
private func shutdown(sendExit: Bool) {
scanTimer?.cancel()
scanTimer = nil
ackTimer?.cancel()
ackTimer = nil
keepalive?.cancel()
keepalive = nil
central?.stopScan()
downlink.stop()
let snapshot = ears
ears.removeAll()
seen.removeAll()
for ear in snapshot.values {
ear.handshook = false
if sendExit { ear.writeCtrl(Data([0xAA, 0x06, 0x00])) }
if let central = central { central.cancelPeripheralConnection(ear.peripheral) }
}
if sendExit {
if BluetoothManager.sharedInstance?.isConnected == true {
BluetoothManager.writeBytes(Data([0xAA, 0x6C]))
} else if !classicKeep {
UserDefaults.standard.set(true, forKey: pendingKey)
}
}
let was = active
active = false
if was { emit(["type": "exited"]) }
}
private func emit(_ map: [String: Any]) {
let sink = eventSink
DispatchQueue.main.async { sink?(map) }
}
private func finish(_ result: @escaping FlutterResult, ok: Bool, code: Int, message: String) {
DispatchQueue.main.async {
result(["ok": ok, "code": code, "message": message])
}
}
private func parseAdv(_ adv: [String: Any]) -> (ver: Int, ear: Int, token: Data)? {
let raw = adv[CBAdvertisementDataManufacturerDataKey] as? Data
guard let raw = raw, raw.count >= 11 else { return nil }
let body = raw.subdata(in: 2..<raw.count)
guard body.count >= 9, body[0] == 0xF2, body[1] == 0xF2 else { return nil }
return (Int(body[2]), Int(body[3]), body.subdata(in: 4..<8))
}
fileprivate func writeAudio(ear: Int, packet: Data) {
ears[ear]?.writeAudio(packet)
}
}
private final class Ear: NSObject, CBPeripheralDelegate {
let ear: Int
let peripheral: CBPeripheral
weak var owner: FaceToFaceBleSession?
var handshook = false
private var ctrlTx: CBCharacteristic?
private var ctrlRx: CBCharacteristic?
private var audioUp: CBCharacteristic?
private var audioDown: CBCharacteristic?
private var notifyStep = 0
private let dec = G722Codec(sampleRate: 16000)
private var seqExpected = -1
private var seenOwn = false
init(ear: Int, peripheral: CBPeripheral, owner: FaceToFaceBleSession) {
self.ear = ear
self.peripheral = peripheral
self.owner = owner
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverServices error: Error?) {
guard let service = peripheral.services?.first else { return }
peripheral.discoverCharacteristics(nil, for: service)
}
func peripheral(_ peripheral: CBPeripheral, didDiscoverCharacteristicsFor service: CBService, error: Error?) {
for ch in service.characteristics ?? [] {
switch ch.uuid {
case CBUUID(string: "AEAF5F32-0002-4C4B-A100-000000000001"): ctrlTx = ch
case CBUUID(string: "AEAF5F32-0003-4C4B-A100-000000000001"): ctrlRx = ch
case CBUUID(string: "AEAF5F32-0004-4C4B-A100-000000000001"): audioUp = ch
case CBUUID(string: "AEAF5F32-0005-4C4B-A100-000000000001"): audioDown = ch
default: break
}
}
enableNext(peripheral)
}
func peripheral(_ peripheral: CBPeripheral, didUpdateNotificationStateFor characteristic: CBCharacteristic, error: Error?) {
enableNext(peripheral)
}
private func enableNext(_ peripheral: CBPeripheral) {
let order = [ctrlRx, audioUp]
if notifyStep < order.count, let ch = order[notifyStep] {
notifyStep += 1
peripheral.setNotifyValue(true, for: ch)
return
}
var hello = Data([0xAA, 0x01, 0x06, 0x02])
hello.append(ownerToken())
hello.append(0x01)
writeCtrl(hello)
}
func peripheral(_ peripheral: CBPeripheral, didUpdateValueFor characteristic: CBCharacteristic, error: Error?) {
guard let value = characteristic.value, value.count >= 3, value[0] == 0xBB else { return }
let op = value[1]
let len = Int(value[2])
guard value.count >= 3 + len else { return }
let payload = value.subdata(in: 3..<(3 + len))
if op == 0x90 { onAudio(payload); return }
if op == 0x81 {
writeCtrl(Data([0xAA, 0x02, 0x03, 0x00, 80, 0x00]))
} else if op == 0x82 {
writeCtrl(Data([0xAA, 0x03, 0x00]))
} else if op == 0x83 {
handshook = true
owner?.earReady(ear)
} else if op == 0x87, !payload.isEmpty {
let kind = Int(payload[0])
if kind == 0x01, payload.count >= 3 {
owner?.emitEvent([
"type": "button",
"ear": ear,
"action": Int(payload[2]),
])
} else if kind == 0x02, payload.count >= 2 {
owner?.emitEvent(["type": "wear", "ear": ear, "state": Int(payload[1])])
} else if kind == 0x06 {
let reason = payload.count >= 2 ? Int(payload[1]) : 0
owner?.emitEvent(["type": "exitRequest", "ear": ear, "reason": reason])
}
}
}
private func onAudio(_ p: Data) {
guard p.count >= 4 else { return }
let flags = Int(p[0])
let seq = Int(p[1]) | (Int(p[2]) << 8)
if seqExpected >= 0 {
var gap = (seq - seqExpected) & 0xFFFF
if gap > 4 { gap = 4 }
if gap > 1, let silence = dec.decode(Data(repeating: 0xFF, count: 40 * (gap - 1))) {
owner?.uplink(ear: ear, pcm: silence)
}
}
seqExpected = (seq + max(1, (p.count - 3) / 40)) & 0xFFFF
let own = (flags & 0x80) != 0
if own { seenOwn = true }
if seenOwn && !own { return }
var off = 3
while off + 40 <= p.count {
let chunk = p.subdata(in: off..<(off + 40))
if let pcm = dec.decode(chunk) {
owner?.uplink(ear: ear, pcm: pcm)
}
off += 40
}
}
func writeCtrl(_ data: Data) {
guard let ch = ctrlTx else { return }
peripheral.writeValue(data, for: ch, type: .withoutResponse)
}
func writeAudio(_ data: Data) {
guard let ch = audioDown else { return }
peripheral.writeValue(data, for: ch, type: .withoutResponse)
}
private func ownerToken() -> Data {
// HELLO 的 TOKEN 与 AA 6B 同一份。拿不到时发 4 个 0,握手会被耳机拒绝。
return FaceToFaceBleSession.shared.currentToken()
}
}
private final class Downlink {
private var legs: [Int: [Data]] = [1: [], 2: []]
private var seq: [Int: Int] = [1: 0, 2: 0]
private var primed = false
private var timer: DispatchSourceTimer?
private weak var session: FaceToFaceBleSession?
private var frameMs = 40
private let enc = G722Codec(sampleRate: 16000)
private let lock = NSLock()
func offer(ear: Int, pcm: Data) {
lock.lock()
let queued = (legs[ear]?.count ?? 0) * frameMs
if queued > 500 {
legs[ear]?.removeAll { $0.allSatisfy { $0 == 0xFF } }
}
if queued > 2500 {
// 积压太大时丢掉最旧的一包,避免越说越慢。Android 用变速,iOS 这里直接丢最旧。
if (legs[ear]?.count ?? 0) > 8 { legs[ear]?.removeFirst() }
}
var off = 0
while off + 640 <= pcm.count {
let slice = pcm.subdata(in: off..<(off + 640))
if let g = enc.encode(slice), g.count == 40 {
legs[ear, default: []].append(g)
}
off += 640
}
lock.unlock()
}
func start(frameMs: Int, session: FaceToFaceBleSession) {
self.frameMs = frameMs
self.session = session
primed = false
timer?.cancel()
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue(label: "com.eaimar.f2f.down"))
timer.schedule(deadline: .now() + .milliseconds(frameMs), repeating: .milliseconds(frameMs))
timer.setEventHandler { [weak self] in self?.tick() }
timer.resume()
self.timer = timer
}
func stop() {
timer?.cancel()
timer = nil
lock.lock()
legs = [1: [], 2: []]
lock.unlock()
primed = false
}
private func tick() {
let frames = frameMs == 20 ? 1 : 2
lock.lock()
var packets: [(Int, Data)] = []
for ear in [1, 2] {
var body = Data()
var flags = 0
for _ in 0..<frames {
if let next = legs[ear]?.first {
legs[ear]?.removeFirst()
body.append(next)
} else if primed {
body.append(Data(repeating: 0xFF, count: 40))
}
}
if body.isEmpty { continue }
if !primed { flags |= 0x50 }
let s = seq[ear] ?? 0
seq[ear] = (s + 1) & 0xFFFF
var pkt = Data([0xAA, 0x91, UInt8(3 + body.count), UInt8((ear & 0x0F) | flags)])
pkt.append(UInt8(s & 0xFF))
pkt.append(UInt8((s >> 8) & 0xFF))
pkt.append(body)
packets.append((ear, pkt))
}
lock.unlock()
if !packets.isEmpty { primed = true }
for (ear, pkt) in packets {
session?.writeAudio(ear: ear, packet: pkt)
}
}
}
extension FaceToFaceBleSession {
fileprivate func currentToken() -> Data { token }
}

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

@ -68,6 +68,19 @@ class BluetoothManager {
/// EMAI 会话 PCM 原生直连桥(原生 AiPcmBridge):开着时唤醒后的耳机麦 PCM
/// 不再经过 Dart,直接交给 azure_speech 的百炼客户端。
/// Echo-one 面对面:双耳拆成两条 BLE,手机只做字幕。其它设备不要调。
Future<Map<String, dynamic>> startFaceToFaceBle() {
return BluetoothManagerPlatform.instance.startFaceToFaceBle();
}
Future<bool> stopFaceToFaceBle() {
return BluetoothManagerPlatform.instance.stopFaceToFaceBle();
}
Stream<Map<String, dynamic>> onFaceToFaceEvent() {
return BluetoothManagerPlatform.instance.onFaceToFaceEvent();
}
Future<bool> setAiPcmBridge(bool enabled) {
return BluetoothManagerPlatform.instance.setAiPcmBridge(enabled);
}

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

@ -16,6 +16,7 @@ class MethodChannelBluetoothManager extends BluetoothManagerPlatform {
final deviceAudioChannel = const EventChannel("bluetooth_manager/audio");
final deviceAiChannel = const EventChannel("bluetooth_manager/ai");
final deviceRecorderChannel = const EventChannel("bluetooth_manager/recorder");
final deviceF2fChannel = const EventChannel("bluetooth_manager/f2f");
StreamSubscription? _deviceStatusSubscription;
StreamSubscription? _deviceDataReceivedSubscription;
@ -221,6 +222,25 @@ class MethodChannelBluetoothManager extends BluetoothManagerPlatform {
false;
}
@override
Future<Map<String, dynamic>> startFaceToFaceBle() async {
final raw = await methodChannel.invokeMethod<Map>('startFaceToFaceBle');
return raw == null ? const {'ok': false} : Map<String, dynamic>.from(raw);
}
@override
Future<bool> stopFaceToFaceBle() async {
return await methodChannel.invokeMethod<bool>('stopFaceToFaceBle') ?? false;
}
@override
Stream<Map<String, dynamic>> onFaceToFaceEvent() {
return deviceF2fChannel.receiveBroadcastStream().map((event) {
if (event is Map) return Map<String, dynamic>.from(event);
return <String, dynamic>{};
});
}
@override
Future<bool> setAiPcmBridge(bool enabled) async {
return await methodChannel

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

@ -75,6 +75,20 @@ abstract class BluetoothManagerPlatform extends PlatformInterface {
throw UnimplementedError('setCallPcmBridge() has not been implemented.');
}
/// Echo-one 面对面 BLE。返回 `{ok, code, message, classicKeep, frameMs}`。
/// 只有恒玄会话会调,其它设备走原来的手机麦。
Future<Map<String, dynamic>> startFaceToFaceBle() {
throw UnimplementedError('startFaceToFaceBle() has not been implemented.');
}
Future<bool> stopFaceToFaceBle() {
throw UnimplementedError('stopFaceToFaceBle() has not been implemented.');
}
Stream<Map<String, dynamic>> onFaceToFaceEvent() {
throw UnimplementedError('onFaceToFaceEvent() has not been implemented.');
}
/// EMAI 会话 PCM 原生直连桥:开着时 AI 模式的 micData 不再进 [onDeviceAiEvent],
/// 由原生直接交给 azure_speech 的百炼客户端;合成音频也直接回到 AI 下行发送器。
Future<bool> setAiPcmBridge(bool enabled) {

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

@ -136,6 +136,9 @@ class DeviceFeatureKeys {
/// 设备设置项变了(设备回了 `0x15` 这类汇总、或 app 写成功后本地回填)。
/// `data` 与 [DeviceFeatures.settingsQuery] 的返回值同形。
static const String settingsChanged = 'settings.changed';
/// Echo-one 面对面 BLE 会话事件(button / wear / earLost / error / exitRequest)。
static const String f2fEvent = 'f2f.event';
}
/// 通用 feature 调用 key(app → 设备)。
@ -153,6 +156,10 @@ class DeviceFeatures {
static const String aiExit = 'ai.exit';
static const String f2fStart = 'f2f.start';
static const String f2fStop = 'f2f.stop';
/// Echo-one 双耳 BLE 面对面。不是 AA 66/67,不进字节表。
static const String f2fBleStart = 'f2f.ble.start';
static const String f2fBleStop = 'f2f.ble.stop';
static const String batteryQuery = 'battery.query';
static const String stateRefresh = 'state.refresh';

2
apps/client/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt

@ -88,7 +88,7 @@ class FloatingWindowService : Service() {
ACTION_SET_POSITION -> setPosition(intent)
}
}
return START_STICKY
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null

2
apps/client/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt

@ -59,7 +59,7 @@ class RecordingFloatingService : Service() {
}
}
return START_STICKY
return START_NOT_STICKY
}
private fun showOrUpdate(duration: String?, recordingType: String?, paused: Boolean) {

4
apps/client/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt

@ -217,7 +217,7 @@ private fun updateProgress() {
intent?.action?.takeIf { it == Intent.ACTION_MEDIA_BUTTON }?.let {
// 分发给 MediaSession 处理媒体按钮
MediaButtonReceiver.handleIntent(mediaSession, intent)
return START_STICKY
return START_NOT_STICKY
}
val command = intent?.getStringExtra("command") ?: return START_NOT_STICKY
@ -267,7 +267,7 @@ private fun updateProgress() {
setVolume(volume)
}
}
return START_STICKY
return START_NOT_STICKY
}

5
apps/client/local_plugins/recorder_holder/android/src/main/AndroidManifest.xml

@ -12,11 +12,6 @@
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_DATA_SYNC" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<!-- Companion 特权:后台运行 & 启动前台服务 -->
<uses-permission android:name="android.permission.REQUEST_COMPANION_RUN_IN_BACKGROUND"/>
<uses-permission android:name="android.permission.REQUEST_COMPANION_USE_DATA_IN_BACKGROUND"/>
<uses-permission android:name="android.permission.REQUEST_COMPANION_START_FOREGROUND_SERVICES_FROM_BACKGROUND" />
<!-- BLE 基础权限 -->
<uses-permission android:name="android.permission.BLUETOOTH" />
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />

70
apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleCompanionManager.kt

@ -4,8 +4,6 @@ import android.app.Activity
import android.app.PendingIntent
import android.bluetooth.BluetoothDevice
import android.bluetooth.BluetoothManager
import android.bluetooth.le.ScanFilter
import android.bluetooth.le.ScanSettings
import android.companion.AssociationInfo
import android.companion.AssociationRequest
import android.companion.BluetoothDeviceFilter
@ -116,22 +114,10 @@ class BleCompanionManager(private val context: Context) {
return emptyList()
}
// API 33+ 用 getMyAssociations()(返回 List<AssociationInfo>),返回
// **本 app 自己的**关联,无需特殊权限。
// 之前用反射调 getAllAssociations() 是系统级方法,需要 system signature
// 权限 android.permission.MANAGE_COMPANION_DEVICES,普通 app 拿不到,
// 每次都抛 SecurityException —— 这里我们要清的就是自己 app 的关联,
// 用 getMyAssociations 才是对的 API。
// API 33+ 的 myAssociations 返回本应用自己的关联,不需要系统签名权限。
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
try {
// AssociationInfo 类在 API 33 才公开,用反射拿 id 保持向前兼容写法
val method = CompanionDeviceManager::class.java.getMethod("getMyAssociations")
@Suppress("UNCHECKED_CAST")
val associations = method.invoke(companionDeviceManager) as? List<Any>
associations?.map { association ->
val idField = association.javaClass.getField("id")
idField.get(association).toString()
} ?: emptyList()
companionDeviceManager.myAssociations.map { it.id.toString() }
} catch (e: Exception) {
Log.w(TAG, "获取本 app 关联失败: ${e.message}")
emptyList()
@ -374,58 +360,22 @@ class BleCompanionManager(private val context: Context) {
}
/**
* 注册后台扫描
* 记住这台设备的 MAC,并注销系统里可能还留着的 PendingIntent 扫描。
*
* @param macAddress 可选的MAC地址,如果不提供则使用已保存的MAC地址
* 不再调用 startScan(PendingIntent):那种扫描在进程死后仍由系统持有,
* 设备一广播就会把应用拉起来。进程还在时的连接走直连 GATT。
*/
fun registerBackgroundScan(mac: String) {
try {
savePairedMacAddress(mac)
if (mac.isNullOrEmpty()) {
Log.e(TAG, "注册后台扫描失败: 没有配对的设备MAC地址")
return
}
val bluetoothManager = context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager
val bluetoothAdapter = bluetoothManager?.adapter
val bluetoothLeScanner = bluetoothAdapter?.bluetoothLeScanner
if (bluetoothLeScanner == null) {
Log.e(TAG, "注册后台扫描失败: 无法获取蓝牙扫描器")
if (mac.isEmpty()) {
Log.e(TAG, "记住设备失败: 没有 MAC")
return
}
savePairedMacAddress(mac)
unregisterBackgroundScan()
// 构建过滤器
val filter = ScanFilter.Builder()
.setDeviceAddress(mac)
// .setManufacturerData(0xEFAB, byteArrayOf(0xA0.toByte(), 0x01))
.build()
// 扫描设置
val settings = ScanSettings.Builder()
.setScanMode(ScanSettings.SCAN_MODE_LOW_POWER)
.build()
// 创建PendingIntent
val scanIntent = Intent(BleConst.ACTION_SCAN_RESULT)
.setClass(context, BleWakeReceiver::class.java)
val pendingIntent = PendingIntent.getBroadcast(
context,
0,
scanIntent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE
} else {
PendingIntent.FLAG_UPDATE_CURRENT
}
)
// 启动后台扫描
bluetoothLeScanner.startScan(listOf(filter), settings, pendingIntent)
Log.i(TAG, "已注册后台扫描,目标设备MAC: $mac")
Log.i(TAG, "已记住设备,不登记跨进程扫描: $mac")
} catch (e: Exception) {
Log.e(TAG, "注册后台扫描失败: ${e.message}", e)
Log.e(TAG, "记住设备失败: ${e.message}", e)
}
}

4
apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleMediaService.kt

@ -70,10 +70,10 @@ class BleMediaService : Service(), CoroutineScope {
lastScanTime = currentTime
checkBleConnection()
}
return START_STICKY
return START_NOT_STICKY
}
return START_STICKY
return START_NOT_STICKY
}
override fun onBind(intent: Intent?): IBinder? = null

15
apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleRecord.kt

@ -98,18 +98,9 @@ class BleRecord private constructor(private val context: Context) : BleService.C
try {
Log.i(TAG, "🎙️ [startRecording]")
// 显式拉起前台服务,把"录音进程不被 OS 杀"的保障与录音生命周期绑死。
//
// 此前 BleMediaService 仅在 BleWakeReceiver / BleCompanionService /
// BootReceiver 这几条 OS 级回调里被启动;如果用户冷启 app 后立即开始
// 录音、还没有任何 BLE 扫描回调触发过,BleMediaService 没起来,进程
// 在内存紧张时可能被回收,长录音中断。
//
// startForegroundService 幂等:service 已在跑时仅触发 onStartCommand,
// 不会再次 startForeground,没副作用。
// 录音结束后不主动 stopService —— OS 级扫描注册(registerBackgroundScan)
// 每次设备广播都会让 BleWakeReceiver 再次 startForegroundService 拉回来,
// 短时间 stop+start 是无意义的 churn。
// 录音进行中拉起前台服务,让这次录音会话有常驻通知。
// 服务是 START_NOT_STICKY:用户停掉或进程被杀之后系统不会再拉起来。
// startForegroundService 在服务已在跑时只触发 onStartCommand。
try {
val intent = Intent(context, BleMediaService::class.java)
context.startForegroundService(intent)

26
apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BootReceiver.kt

@ -1,26 +0,0 @@
package com.recorder.holder
import android.content.*
import android.os.Build
import android.util.Log
/**
* 系统启动接收器
* 当系统重启完成后,自动启动BLE前台服务
* 注意:需要用户在设置中授予应用自启动权限才能生效
*/
class BootReceiver : BroadcastReceiver() {
private val TAG = "BootReceiver"
override fun onReceive(ctx: Context, i: Intent) {
val serviceClass = try {
Class.forName("com.recorder.holder.BleMediaService")
} catch (e: ClassNotFoundException) {
Log.i(TAG, "BleMediaService不存在")
return
}
if (i.action == Intent.ACTION_BOOT_COMPLETED) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O)
ctx.startForegroundService(Intent(ctx, serviceClass))
else ctx.startService(Intent(ctx, serviceClass))
}
}
}

26
apps/client/local_plugins/recorder_holder/sdk/lib/src/client.dart

@ -251,16 +251,9 @@ class EchoMeetDeviceSdk {
/// 连接设备。协议 §8.1 第 3 步:**扫描之后要真的发起 GATT 连接**。
///
/// - iOS:`startDeviceAssociation` → 原生 `connectToDevice(uuid:)`,本身就是直连。
/// - Android:`registerBackgroundScan` **只是注册系统后台扫描的 PendingIntent**
/// (`savePairedMacAddress` + `ScanFilter(mac)` + `SCAN_MODE_LOW_POWER`),
/// 它一次 `connectGatt` 都不发;真正连接要等系统扫到广播 → `BleWakeReceiver`
/// (还带 10s 去抖)→ 拉起前台服务才发生。
///
/// ⚠️ 以前这里到此为止就 `return true`,于是「点连接」立刻成功、
/// `waitConnected()` 干等 25 秒超时 —— 表现就是**扫得到但永远连不上**。
/// 现在注册完后台回连后**立刻再直连一次**(原生早就暴露了 `connectToDevice`
/// → `BleService.connect(mac)` → `device.connectGatt`,只是 Dart 侧没调)。
/// 两者不冲突:后台注册管「设备下次靠近自动连」,直连管「用户现在就要连」。
/// - Android:`registerBackgroundScan` 只记住 MAC,并注销系统里可能还留着的
/// PendingIntent 扫描(那种扫描会在进程死后把应用拉起来)。真正连接是紧接着的
/// `connectToDevice` → `BleService.connect(mac)` → `device.connectGatt`。
Future<bool> connect({
required String name,
required String address,
@ -282,11 +275,11 @@ class EchoMeetDeviceSdk {
macAddress: address,
);
} else {
// 后台回连注册失败不阻断本次连接:它只影响「下次设备靠近自动连」
// 记住 MAC 失败不阻断本次直连
final registered = await _plugin.registerBackgroundScan(macAddress: address);
if (!registered) {
// ignore: avoid_print
print('[EaiRec] 后台扫连注册失败,仅影响自动回连,继续直连');
print('[EaiRec] 记住设备失败,继续直连');
}
ok = await _plugin.connectToDevice(address);
}
@ -327,6 +320,15 @@ class EchoMeetDeviceSdk {
return ok;
}
/// 注销系统里可能还留着的 PendingIntent 扫描。本应用不再登记这种扫描。
Future<void> clearBackgroundScan() async {
if (!Platform.isAndroid) return;
ensureBound();
try {
await _plugin.unregisterBackgroundScan();
} catch (_) {}
}
Future<void> disconnect() async {
ensureBound();
if (Platform.isAndroid) {

2
apps/client/pubspec.yaml

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

108
apps/client/test/call_extended_languages_test.dart

@ -0,0 +1,108 @@
import 'package:eaimar/data/services/language_configs/azure_language_config.dart';
import 'package:eaimar/data/services/language_manager.dart';
import 'package:eaimar/data/services/microsoft_text_translation.dart';
import 'package:flutter_test/flutter_test.dart';
/// 界面语言表补上微软三表里原先没开放的 40 种。
/// 通话只要一边是这些语种,就整对走微软,原有语种的档位不动。
void main() {
late LanguageManager languages;
setUp(() {
languages = LanguageManager();
});
test('语言表是原来的 39 种加上这 40 种', () {
expect(languages.getAllChineseNames(), hasLength(79));
expect(LanguageManager.azureCallOnlyShortCodes, hasLength(40));
for (final code in LanguageManager.azureCallOnlyShortCodes) {
expect(languages.getLanguageByShortCode(code), isNotNull,
reason: code);
}
});
test('粤语和香港繁体共用识别码,按名字仍是两条', () {
final yue = languages.getLanguageByChineseName('粤语');
final hk = languages.getLanguageByChineseName('中文(香港繁体)');
expect(yue?.shortCode, 'yue');
expect(yue?.asrCode, 'zh-HK');
expect(yue?.ttsVoiceName, 'yue-CN-XiaoMinNeural');
expect(hk?.shortCode, 'zh-Hant-hk');
expect(hk?.asrCode, 'zh-HK');
expect(hk?.ttsVoiceName, 'zh-HK-HiuMaanNeural');
// 按识别码反查保留先登记的香港繁体,系统语言默认值还指得上它。
expect(languages.getChineseNameByAsrCode('zh-HK'), '中文(香港繁体)');
expect(languages.getChineseNameByShortCode('yue'), '粤语');
});
test('原有通话档位不变,扩展语种整对走微软', () {
expect(languages.findBestMatchingProvider('zh', 'en')?.provider, 'alibaba35');
expect(languages.findBestMatchingProvider('zh', 'el')?.provider, 'alibaba');
expect(languages.findBestMatchingProvider('zh', 'ro')?.provider, 'azure');
for (final code in ['fil', 'he', 'is', 'yue', 'cy', 'jv']) {
expect(languages.findBestMatchingProvider('zh', code)?.provider, 'azure',
reason: 'zh -> $code');
expect(languages.findBestMatchingProvider(code, 'en')?.provider, 'azure',
reason: '$code -> en');
}
});
test('爪哇语的微软翻译码是 jv', () {
final spec = getAzureTranslationLanguageSpecs()
.firstWhere((e) => e['shortCode'] == 'jv');
expect(spec['translationCode'], 'jv');
expect(languages.microsoftTranslateCode('yue'), 'yue');
expect(languages.microsoftTranslateCode('zh'), 'zh-Hans');
expect(languages.microsoftTranslateCode('zh-Hant-hk'), 'zh-Hant');
expect(languages.microsoftTranslateCode('jv'), 'jv');
});
test('同声面对面音视频的扩展语种走微软文本翻译,原有语种和通话不走', () {
for (final mode in ['simultaneous', 'faceToFace', 'audioVideo']) {
expect(LanguageManager.microsoftTextPipeline(mode, 'zh', 'fil'), isTrue);
expect(LanguageManager.microsoftTextPipeline(mode, 'yue', 'en'), isTrue);
expect(LanguageManager.microsoftTextPipeline(mode, 'zh', 'en'), isFalse);
expect(LanguageManager.microsoftTextPipeline(mode, 'zh', 'el'), isFalse);
}
expect(LanguageManager.microsoftTextPipeline('call', 'zh', 'fil'), isFalse);
});
test('粤语播报用粤语音色,不串到香港繁体', () {
expect(languages.getTtsVoiceNameByShortCode('yue'), 'yue-CN-XiaoMinNeural');
expect(languages.getTtsVoiceNameByShortCode('zh-Hant-hk'),
'zh-HK-HiuMaanNeural');
});
test('繁体、乌克兰语、克罗地亚语走微软翻译码', () {
expect(microsoftMtRequired('en-US', 'zh-TW'), isTrue);
expect(microsoftMtRequired('zh-HK', 'en-US'), isTrue);
expect(microsoftMtRequired('zh-CN', 'uk-UA'), isTrue);
expect(microsoftMtRequired('hr-HR', 'en'), isTrue);
expect(microsoftMtRequired('zh-CN', 'en-US'), isFalse);
expect(microsoftMtCode('zh-TW'), 'zh-Hant');
expect(microsoftMtCode('zh-HK'), 'zh-Hant');
expect(microsoftMtCode('zh-CN'), 'zh-Hans');
expect(microsoftMtCode('uk-UA'), 'uk');
expect(microsoftMtCode('hr-HR'), 'hr');
expect(microsoftMtCode(''), '');
});
test('解析微软翻译响应', () {
expect(
microsoftTranslationText([
{
'translations': [
{'text': ' hello ', 'to': 'en'}
]
}
]),
'hello',
);
expect(microsoftTranslationText([]), isNull);
expect(microsoftTranslationText([
{'translations': []}
]), isNull);
});
}

79
apps/client/test/devices/bes_f2f_protocol_test.dart

@ -0,0 +1,79 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:eaimar/devices/bes/bes_f2f_protocol.dart';
import 'package:eaimar/devices/bes/bes_protocol.dart';
void main() {
const token = [0x11, 0x22, 0x33, 0x44];
test('AA 6B / AA 6C 与探测区间', () {
expect(BesF2f.enterFrame(token),
[0xAA, 0x6B, 0x06, 0x02, 0x01, 0x11, 0x22, 0x33, 0x44]);
expect(BesF2f.exitFrame(), [0xAA, 0x6C]);
expect(BesCmd.switchesMode(0x6B), isTrue);
expect(BesCmd.switchesMode(0x6C), isTrue);
expect(BesCmd.switchesMode(0x6D), isFalse);
expect(BesCmd.frame(BesCmd.faceToFaceStart), [0xAA, 0x66]);
});
test('BB EB 双耳可用且经典链路保持', () {
final ack = BesF2f.parseEnterAck(
[0xBB, 0xEB, 0x07, 0x00, 0x03, 0x02, 0x01, 40, 0x03, 0x01]);
expect(ack, isNotNull);
expect(ack!.result, 0);
expect(ack.bothEars, isTrue);
expect(ack.frameMs, 40);
expect(ack.classicKeep, isTrue);
expect(ack.fwMinor, 3);
});
test('广播 token 对不上就不是这副耳机', () {
final ad = BesF2f.parseAdvertPayload(
[0xF2, 0xF2, 0x02, 0x01, 0x11, 0x22, 0x33, 0x44, 0x80 | 70]);
expect(ad, isNotNull);
expect(ad!.ear, BesF2f.earLeft);
expect(ad.battery, 70);
expect(ad.charging, isTrue);
expect(ad.tokenEquals(token), isTrue);
expect(ad.tokenEquals(const [0x11, 0x22, 0x33, 0x45]), isFalse);
expect(
BesF2f.parseAdvertPayload(
[0xF2, 0xF2, 0x02, 0x09, 0x11, 0x22, 0x33, 0x44, 0x10]),
isNull);
});
test('握手帧与下行音频耳位', () {
expect(BesF2f.hello(token),
[0xAA, 0x01, 0x06, 0x02, 0x11, 0x22, 0x33, 0x44, 0x01]);
expect(BesF2f.sessionCfg(), [0xAA, 0x02, 0x03, 0x00, 80, 0x00]);
expect(BesF2f.startStream(), [0xAA, 0x03, 0x00]);
expect(BesF2f.exitEar(), [0xAA, 0x06, 0x00]);
final down = BesF2f.audioDown(
ear: BesF2f.earRight,
flags: BesF2f.flagPlayBegin | BesF2f.flagFlush,
seq: 0x0102,
g722: const [0xAB, 0xCD],
);
expect(down.sublist(0, 6), [0xAA, 0x91, 0x05, 0x52, 0x02, 0x01]);
expect(down.sublist(6), [0xAB, 0xCD]);
});
test('上行音频 OWN_VOICE 与序号', () {
final frame = BesF2f.parseBle(
[0xBB, 0x90, 0x05, 0x91, 0x02, 0x01, 0x10, 0x20]);
final audio = BesF2f.parseAudioUp(frame!);
expect(audio!.ear, BesF2f.earLeft);
expect(audio.ownVoice, isTrue);
expect(audio.seq, 0x0102);
expect(audio.g722, [0x10, 0x20]);
});
test('来电退出事件', () {
final frame = BesF2f.parseBle([0xBB, 0x87, 0x02, 0x06, 0x03]);
expect(frame!.op, BesF2f.opEvent);
expect(frame.payload[0], BesF2f.evtExitRequest);
expect(frame.payload[1], BesF2f.exitIncomingCall);
expect(BesF2f.messageKey(BesF2f.errBusy), 'f2fEndOtherFeature');
expect(BesF2f.isExitAckOk([0xBB, 0xEC, 0x01, 0x00]), isTrue);
});
}

Some files were not shown because too many files changed in this diff

Loading…
Cancel
Save