diff --git a/apps/client/android/app/src/main/AndroidManifest.xml b/apps/client/android/app/src/main/AndroidManifest.xml index b7c37f8b..e73ac9a2 100644 --- a/apps/client/android/app/src/main/AndroidManifest.xml +++ b/apps/client/android/app/src/main/AndroidManifest.xml @@ -48,9 +48,8 @@ - + tools:node="remove" /> @@ -61,10 +60,17 @@ - - - - + + + + + + - - + + +``` + +| 字节 | 值 | 含义 | +|---|---|---| +| PROTO_VER | `0x02` | 本协议版本 | +| CAP | `0x01` | 只要 G.722 | + +App 等 `BB EB` 的上限是 **3 秒**。超时就放弃,并补发 `AA 6C`。 + +### 4.2 应答 `BB EB` + +整帧 10 字节: + +```text +BB EB 07 +``` + +| 字段 | 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 +``` + +`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 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 ` | 可不回 | 两只都 `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 +``` + +### 8.2 下行 `AUDIO_DOWN`(App → 耳机) + +```text +AA 91 +``` + +`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 +``` + +| ADJ | 含义 | +|---|---| +| `+1` (`0x01`) | 缓冲少一包,马上再补一包 | +| `-1` (`0xFF`) | 缓冲多一包,下一拍停发一包 | +| `0` | 缓冲已经空,App 重新垫静音再起播 | + +这是补包 / 扣包,不是把速率改成 1.25 倍。 + +--- + +## 9. 事件 + +写在 `CTRL_RX`: + +```text +BB 87 +``` + +| EVENT | 含义 | PARAM | App 行为 | +|---|---|---|---| +| `0x01` | 按键 | ` ` | ACTION `0x01` 按下、`0x02` 抬起。当前是连续拾音,按键只更新界面上的左右标记 | +| `0x02` | 佩戴 | ` ` | `0x00` 摘下,`0x01` 戴上,`0x02` 入盒。App 提示确认双耳都戴好 | +| `0x03` | 电量 | ` ` | 低 7 位电量,最高位充电。App 暂不展示 | +| `0x06` | 请求退出 | `` | `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`(经典链路还在时),把耳机退回普通状态。 diff --git a/apps/client/ios/Podfile.lock b/apps/client/ios/Podfile.lock index a7b6b0fb..c7cddc18 100644 --- a/apps/client/ios/Podfile.lock +++ b/apps/client/ios/Podfile.lock @@ -110,6 +110,6 @@ SPEC CHECKSUMS: WechatOpenSDK-XCFramework: 5df9b250e9839dcc306ad8b00f46822eead1ed47 wifi_iot: b5aafd6f9b52f8a357383a1deabab45f31cd602d -PODFILE CHECKSUM: 401345823f88247f8534d70ffc8240014a8b14fb +PODFILE CHECKSUM: 615a00a0854e6c6a2c6db75ecb9a29206ab3e341 COCOAPODS: 1.16.2 diff --git a/apps/client/ios/Runner.xcodeproj/project.pbxproj b/apps/client/ios/Runner.xcodeproj/project.pbxproj index 8d8cf0e6..0c9877cb 100644 --- a/apps/client/ios/Runner.xcodeproj/project.pbxproj +++ b/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 */ diff --git a/apps/client/lib/core/translations/language/ar_sa.dart b/apps/client/lib/core/translations/language/ar_sa.dart index 003f3cfc..65d23d01 100644 --- a/apps/client/lib/core/translations/language/ar_sa.dart +++ b/apps/client/lib/core/translations/language/ar_sa.dart @@ -1603,6 +1603,46 @@ const Map 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", // 国家列表 "邮箱": "البريد الإلكتروني", diff --git a/apps/client/lib/core/translations/language/bg_bg.dart b/apps/client/lib/core/translations/language/bg_bg.dart index fa9c6fec..473883e5 100644 --- a/apps/client/lib/core/translations/language/bg_bg.dart +++ b/apps/client/lib/core/translations/language/bg_bg.dart @@ -1663,6 +1663,46 @@ const Map 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", // 国家列表 "邮箱": "Имейл", diff --git a/apps/client/lib/core/translations/language/cs_cz.dart b/apps/client/lib/core/translations/language/cs_cz.dart index fe4507c4..2941860c 100644 --- a/apps/client/lib/core/translations/language/cs_cz.dart +++ b/apps/client/lib/core/translations/language/cs_cz.dart @@ -1632,6 +1632,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/da_dk.dart b/apps/client/lib/core/translations/language/da_dk.dart index 1106b9dd..c078850c 100644 --- a/apps/client/lib/core/translations/language/da_dk.dart +++ b/apps/client/lib/core/translations/language/da_dk.dart @@ -1633,6 +1633,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/de_de.dart b/apps/client/lib/core/translations/language/de_de.dart index 8bdc2015..0147659d 100644 --- a/apps/client/lib/core/translations/language/de_de.dart +++ b/apps/client/lib/core/translations/language/de_de.dart @@ -1675,6 +1675,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/el_gr.dart b/apps/client/lib/core/translations/language/el_gr.dart index df534d57..77b314be 100644 --- a/apps/client/lib/core/translations/language/el_gr.dart +++ b/apps/client/lib/core/translations/language/el_gr.dart @@ -1667,6 +1667,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/en_us.dart b/apps/client/lib/core/translations/language/en_us.dart index f9fffae7..5ec866c9 100644 --- a/apps/client/lib/core/translations/language/en_us.dart +++ b/apps/client/lib/core/translations/language/en_us.dart @@ -1666,6 +1666,46 @@ const Map 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 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 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', diff --git a/apps/client/lib/core/translations/language/es_es.dart b/apps/client/lib/core/translations/language/es_es.dart index 3e79e8bd..bbe9a30e 100644 --- a/apps/client/lib/core/translations/language/es_es.dart +++ b/apps/client/lib/core/translations/language/es_es.dart @@ -1666,6 +1666,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/et_ee.dart b/apps/client/lib/core/translations/language/et_ee.dart index b73a9d7a..80ed7d5b 100644 --- a/apps/client/lib/core/translations/language/et_ee.dart +++ b/apps/client/lib/core/translations/language/et_ee.dart @@ -1628,6 +1628,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/fa_ir.dart b/apps/client/lib/core/translations/language/fa_ir.dart index 78901bf0..3d904265 100644 --- a/apps/client/lib/core/translations/language/fa_ir.dart +++ b/apps/client/lib/core/translations/language/fa_ir.dart @@ -1618,6 +1618,46 @@ const Map 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", // 国家列表 "邮箱": "ایمیل", diff --git a/apps/client/lib/core/translations/language/fi_fi.dart b/apps/client/lib/core/translations/language/fi_fi.dart index b83ef828..6d88f89a 100644 --- a/apps/client/lib/core/translations/language/fi_fi.dart +++ b/apps/client/lib/core/translations/language/fi_fi.dart @@ -1638,6 +1638,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/fr_fr.dart b/apps/client/lib/core/translations/language/fr_fr.dart index ccc9c571..951ba5fd 100644 --- a/apps/client/lib/core/translations/language/fr_fr.dart +++ b/apps/client/lib/core/translations/language/fr_fr.dart @@ -1686,6 +1686,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/hi_in.dart b/apps/client/lib/core/translations/language/hi_in.dart index 8e8e2495..926c2803 100644 --- a/apps/client/lib/core/translations/language/hi_in.dart +++ b/apps/client/lib/core/translations/language/hi_in.dart @@ -1639,6 +1639,46 @@ const Map 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", // 国家列表 "邮箱": "ईमेल", diff --git a/apps/client/lib/core/translations/language/hr_hr.dart b/apps/client/lib/core/translations/language/hr_hr.dart index c300c351..9a740f89 100644 --- a/apps/client/lib/core/translations/language/hr_hr.dart +++ b/apps/client/lib/core/translations/language/hr_hr.dart @@ -1653,6 +1653,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/hu_hu.dart b/apps/client/lib/core/translations/language/hu_hu.dart index d6d0f1f9..4b121b8d 100644 --- a/apps/client/lib/core/translations/language/hu_hu.dart +++ b/apps/client/lib/core/translations/language/hu_hu.dart @@ -1655,6 +1655,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/id_id.dart b/apps/client/lib/core/translations/language/id_id.dart index efe8ebf8..6c38e595 100644 --- a/apps/client/lib/core/translations/language/id_id.dart +++ b/apps/client/lib/core/translations/language/id_id.dart @@ -1637,6 +1637,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/is_is.dart b/apps/client/lib/core/translations/language/is_is.dart index e5a5d665..5d5c79c6 100644 --- a/apps/client/lib/core/translations/language/is_is.dart +++ b/apps/client/lib/core/translations/language/is_is.dart @@ -1634,6 +1634,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/it_it.dart b/apps/client/lib/core/translations/language/it_it.dart index 8466154c..c1eba0dc 100644 --- a/apps/client/lib/core/translations/language/it_it.dart +++ b/apps/client/lib/core/translations/language/it_it.dart @@ -1674,6 +1674,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/ja_jp.dart b/apps/client/lib/core/translations/language/ja_jp.dart index 798c0ccf..e62a6a23 100644 --- a/apps/client/lib/core/translations/language/ja_jp.dart +++ b/apps/client/lib/core/translations/language/ja_jp.dart @@ -1492,6 +1492,46 @@ const Map 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", // 国家列表 "邮箱": "メールアドレス", diff --git a/apps/client/lib/core/translations/language/ko_kr.dart b/apps/client/lib/core/translations/language/ko_kr.dart index 4dc2ed08..91606531 100644 --- a/apps/client/lib/core/translations/language/ko_kr.dart +++ b/apps/client/lib/core/translations/language/ko_kr.dart @@ -1483,6 +1483,46 @@ const Map 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", // 国家列表 "邮箱": "이메일", diff --git a/apps/client/lib/core/translations/language/lt_lt.dart b/apps/client/lib/core/translations/language/lt_lt.dart index 8f39f215..d75e1ef6 100644 --- a/apps/client/lib/core/translations/language/lt_lt.dart +++ b/apps/client/lib/core/translations/language/lt_lt.dart @@ -1651,6 +1651,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/lv_lv.dart b/apps/client/lib/core/translations/language/lv_lv.dart index 1f14f813..87cde260 100644 --- a/apps/client/lib/core/translations/language/lv_lv.dart +++ b/apps/client/lib/core/translations/language/lv_lv.dart @@ -1656,6 +1656,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/ms_my.dart b/apps/client/lib/core/translations/language/ms_my.dart index 48ea0c5e..6d420601 100644 --- a/apps/client/lib/core/translations/language/ms_my.dart +++ b/apps/client/lib/core/translations/language/ms_my.dart @@ -1642,6 +1642,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/nb_no.dart b/apps/client/lib/core/translations/language/nb_no.dart index 3e84045d..78d532b5 100644 --- a/apps/client/lib/core/translations/language/nb_no.dart +++ b/apps/client/lib/core/translations/language/nb_no.dart @@ -1635,6 +1635,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/nl_nl.dart b/apps/client/lib/core/translations/language/nl_nl.dart index 1eb61288..72a71a3f 100644 --- a/apps/client/lib/core/translations/language/nl_nl.dart +++ b/apps/client/lib/core/translations/language/nl_nl.dart @@ -1532,6 +1532,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/pl_pl.dart b/apps/client/lib/core/translations/language/pl_pl.dart index 55a542f1..1343e9de 100644 --- a/apps/client/lib/core/translations/language/pl_pl.dart +++ b/apps/client/lib/core/translations/language/pl_pl.dart @@ -1663,6 +1663,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/pt_pt.dart b/apps/client/lib/core/translations/language/pt_pt.dart index d50bdac3..7ab92973 100644 --- a/apps/client/lib/core/translations/language/pt_pt.dart +++ b/apps/client/lib/core/translations/language/pt_pt.dart @@ -1673,6 +1673,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/ro_ro.dart b/apps/client/lib/core/translations/language/ro_ro.dart index 7f2321fd..331172f5 100644 --- a/apps/client/lib/core/translations/language/ro_ro.dart +++ b/apps/client/lib/core/translations/language/ro_ro.dart @@ -1664,6 +1664,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/ru_ru.dart b/apps/client/lib/core/translations/language/ru_ru.dart index 1ba0181d..bdf3b517 100644 --- a/apps/client/lib/core/translations/language/ru_ru.dart +++ b/apps/client/lib/core/translations/language/ru_ru.dart @@ -1651,6 +1651,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/sk_sk.dart b/apps/client/lib/core/translations/language/sk_sk.dart index 506c29a9..09ec2730 100644 --- a/apps/client/lib/core/translations/language/sk_sk.dart +++ b/apps/client/lib/core/translations/language/sk_sk.dart @@ -1645,6 +1645,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/sl_si.dart b/apps/client/lib/core/translations/language/sl_si.dart index 98be2ba9..60460ec9 100644 --- a/apps/client/lib/core/translations/language/sl_si.dart +++ b/apps/client/lib/core/translations/language/sl_si.dart @@ -1648,6 +1648,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/sr_rs.dart b/apps/client/lib/core/translations/language/sr_rs.dart index 9e352192..67be81b8 100644 --- a/apps/client/lib/core/translations/language/sr_rs.dart +++ b/apps/client/lib/core/translations/language/sr_rs.dart @@ -1644,6 +1644,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/sv_se.dart b/apps/client/lib/core/translations/language/sv_se.dart index 79e24064..06f0a3ef 100644 --- a/apps/client/lib/core/translations/language/sv_se.dart +++ b/apps/client/lib/core/translations/language/sv_se.dart @@ -1640,6 +1640,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/th_th.dart b/apps/client/lib/core/translations/language/th_th.dart index 5d97acb2..7af6fca2 100644 --- a/apps/client/lib/core/translations/language/th_th.dart +++ b/apps/client/lib/core/translations/language/th_th.dart @@ -1597,6 +1597,46 @@ const Map 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", // 国家列表 "邮箱": "อีเมล", diff --git a/apps/client/lib/core/translations/language/tl_ph.dart b/apps/client/lib/core/translations/language/tl_ph.dart index 6ee4fbde..8afd8041 100644 --- a/apps/client/lib/core/translations/language/tl_ph.dart +++ b/apps/client/lib/core/translations/language/tl_ph.dart @@ -1678,6 +1678,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/tr_tr.dart b/apps/client/lib/core/translations/language/tr_tr.dart index ba5e2dca..079b7977 100644 --- a/apps/client/lib/core/translations/language/tr_tr.dart +++ b/apps/client/lib/core/translations/language/tr_tr.dart @@ -1634,6 +1634,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/uk_ua.dart b/apps/client/lib/core/translations/language/uk_ua.dart index 83828b43..4a8be740 100644 --- a/apps/client/lib/core/translations/language/uk_ua.dart +++ b/apps/client/lib/core/translations/language/uk_ua.dart @@ -1651,6 +1651,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/vi_vn.dart b/apps/client/lib/core/translations/language/vi_vn.dart index 7d38d9e9..66a75142 100644 --- a/apps/client/lib/core/translations/language/vi_vn.dart +++ b/apps/client/lib/core/translations/language/vi_vn.dart @@ -1616,6 +1616,46 @@ const Map 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", diff --git a/apps/client/lib/core/translations/language/zh_cn.dart b/apps/client/lib/core/translations/language/zh_cn.dart index ce16990a..22be5902 100644 --- a/apps/client/lib/core/translations/language/zh_cn.dart +++ b/apps/client/lib/core/translations/language/zh_cn.dart @@ -1496,6 +1496,46 @@ const Map zhCN = { "乌尔都语": "乌尔都语", "波斯语": "波斯语", "斯瓦希里语": "斯瓦希里语", + "粤语": "粤语", + "威尔士语": "威尔士语", + "保加利亚语": "保加利亚语", + "阿姆哈拉语": "阿姆哈拉语", + "阿萨姆语": "阿萨姆语", + "南非荷兰语": "南非荷兰语", + "阿塞拜疆语": "阿塞拜疆语", + "波斯尼亚语": "波斯尼亚语", + "卡纳达语": "卡纳达语", + "爱沙尼亚语": "爱沙尼亚语", + "巴斯克语": "巴斯克语", + "菲律宾语": "菲律宾语", + "爱尔兰语": "爱尔兰语", + "加利西亚语": "加利西亚语", + "古吉拉特语": "古吉拉特语", + "希伯来语": "希伯来语", + "亚美尼亚语": "亚美尼亚语", + "冰岛语": "冰岛语", + "爪哇语": "爪哇语", + "格鲁吉亚语": "格鲁吉亚语", + "哈萨克语": "哈萨克语", + "高棉语": "高棉语", + "老挝语": "老挝语", + "立陶宛语": "立陶宛语", + "拉脱维亚语": "拉脱维亚语", + "马其顿语": "马其顿语", + "马拉雅拉姆语": "马拉雅拉姆语", + "蒙古语": "蒙古语", + "马耳他语": "马耳他语", + "缅甸语": "缅甸语", + "尼泊尔语": "尼泊尔语", + "普什图语": "普什图语", + "僧伽罗语": "僧伽罗语", + "斯洛伐克语": "斯洛伐克语", + "斯洛文尼亚语": "斯洛文尼亚语", + "索马里语": "索马里语", + "阿尔巴尼亚语": "阿尔巴尼亚语", + "塞尔维亚语": "塞尔维亚语", + "乌兹别克语": "乌兹别克语", + "祖鲁语": "祖鲁语", // 国家列表 "邮箱": "邮箱", @@ -1708,12 +1748,12 @@ const Map 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 zhCN = { 'recordingSavedDesc': '已存入语音纪要', 'faceToFaceLeftEar': '左耳', 'faceToFaceRightEar': '右耳', + 'f2fEarNotReady': '耳机未就绪', + 'f2fWearBothEars': '请确认双耳都已取出并佩戴', + 'f2fEndOtherFeature': '请先结束当前功能', + 'f2fFirmwareUpgrade': '耳机固件需升级', + 'f2fIncomingCallEnded': '有来电,面对面翻译已结束', + 'f2fPutBackInCase': '请把双耳放回盒中再取出重试', + 'f2fEarDisconnected': '耳机已断开', // EMAI 助手(阿里百炼多模态对话页) 'emaiReady': '已就绪', diff --git a/apps/client/lib/core/translations/language/zh_hk.dart b/apps/client/lib/core/translations/language/zh_hk.dart index 6178ebac..8d25980e 100644 --- a/apps/client/lib/core/translations/language/zh_hk.dart +++ b/apps/client/lib/core/translations/language/zh_hk.dart @@ -1471,6 +1471,46 @@ const Map zhHK = { "乌尔都语": "烏爾都語", "波斯语": "波斯語", "斯瓦希里语": "斯瓦希里語", + "粤语": "粵語", + "威尔士语": "威爾士語", + "保加利亚语": "保加利亞語", + "阿姆哈拉语": "阿姆哈拉語", + "阿萨姆语": "阿薩姆語", + "南非荷兰语": "南非荷蘭語", + "阿塞拜疆语": "阿塞拜疆語", + "波斯尼亚语": "波士尼亞語", + "卡纳达语": "卡納達語", + "爱沙尼亚语": "愛沙尼亞語", + "巴斯克语": "巴斯克語", + "菲律宾语": "菲律賓語", + "爱尔兰语": "愛爾蘭語", + "加利西亚语": "加利西亞語", + "古吉拉特语": "古吉拉特語", + "希伯来语": "希伯來語", + "亚美尼亚语": "亞美尼亞語", + "冰岛语": "冰島語", + "爪哇语": "爪哇語", + "格鲁吉亚语": "格魯吉亞語", + "哈萨克语": "哈薩克語", + "高棉语": "高棉語", + "老挝语": "老撾語", + "立陶宛语": "立陶宛語", + "拉脱维亚语": "拉脫維亞語", + "马其顿语": "馬其頓語", + "马拉雅拉姆语": "馬拉雅拉姆語", + "蒙古语": "蒙古語", + "马耳他语": "馬耳他語", + "缅甸语": "緬甸語", + "尼泊尔语": "尼泊爾語", + "普什图语": "普什圖語", + "僧伽罗语": "僧伽羅語", + "斯洛伐克语": "斯洛伐克語", + "斯洛文尼亚语": "斯洛維尼亞語", + "索马里语": "索馬利亞語", + "阿尔巴尼亚语": "阿爾巴尼亞語", + "塞尔维亚语": "塞爾維亞語", + "乌兹别克语": "烏茲別克語", + "祖鲁语": "祖魯語", // 国家列表 "邮箱": "郵箱", @@ -1683,12 +1723,12 @@ const Map zhHK = { 'holderPairHint': '請確認支架已開機並喺配對模式', 'holderRetryHint': '請確認裝置已開機並喺手機附近,遲啲再試', 'autoConnectTitle': '裝置自動連接', - 'autoConnectDesc': '裝置開機或者行埋嚟時自動喚醒應用回連並接收錄音', + 'autoConnectDesc': '應用開住嗰陣自動連接已記住嘅裝置並接收錄音。閂咗應用之後唔會被裝置喚醒', 'autoConnectConsentTitle': '開啟裝置自動連接', 'autoConnectConsentIntro': - '為咗令你嘅裝置(AI 耳機/AI 手機支架)開機或者埋身時自動連上,並喺你撳裝置錄音掣時完整保存錄音,需要你同意:', - 'autoConnectConsentB1': '向系統註冊藍牙背景掃描,掃到你已綁定嘅裝置時喚醒本應用(即自啟動),完成回連同錄音接收', - 'autoConnectConsentB2': '喚醒後淨係用嚟連接裝置、錄音同上傳,唔會做其他嘢,亦唔會拉起其他應用', + '為咗令你嘅裝置(AI 耳機/AI 手機支架)喺應用開住嗰陣自動連上,並喺你撳裝置錄音掣時完整保存錄音,需要你同意:', + 'autoConnectConsentB1': '應用開住嗰陣,自動連接你已記住嘅呢部裝置,完成回連同錄音接收。應用被閂咗或者從最近任務劃走之後,唔會再被裝置喚醒', + 'autoConnectConsentB2': '自動連接淨係用嚟連接裝置、錄音同上傳,唔會做其他嘢,亦唔會拉起其他應用', 'autoConnectConsentB3': '唔同意都用得:不過每次都要開應用手動連接', 'autoConnectConsentB4': '同意後可以隨時喺「設定 → 裝置自動連接」閂咗佢', 'autoConnectAgree': '同意並開啟', diff --git a/apps/client/lib/core/translations/language/zh_tw.dart b/apps/client/lib/core/translations/language/zh_tw.dart index 3b4271ee..38bf8308 100644 --- a/apps/client/lib/core/translations/language/zh_tw.dart +++ b/apps/client/lib/core/translations/language/zh_tw.dart @@ -1471,6 +1471,46 @@ const Map zhTW = { "乌尔都语": "烏爾都語", "波斯语": "波斯語", "斯瓦希里语": "斯瓦希里語", + "粤语": "粵語", + "威尔士语": "威爾士語", + "保加利亚语": "保加利亞語", + "阿姆哈拉语": "阿姆哈拉語", + "阿萨姆语": "阿薩姆語", + "南非荷兰语": "南非荷蘭語", + "阿塞拜疆语": "阿塞拜疆語", + "波斯尼亚语": "波士尼亞語", + "卡纳达语": "卡納達語", + "爱沙尼亚语": "愛沙尼亞語", + "巴斯克语": "巴斯克語", + "菲律宾语": "菲律賓語", + "爱尔兰语": "愛爾蘭語", + "加利西亚语": "加利西亞語", + "古吉拉特语": "古吉拉特語", + "希伯来语": "希伯來語", + "亚美尼亚语": "亞美尼亞語", + "冰岛语": "冰島語", + "爪哇语": "爪哇語", + "格鲁吉亚语": "格魯吉亞語", + "哈萨克语": "哈薩克語", + "高棉语": "高棉語", + "老挝语": "老撾語", + "立陶宛语": "立陶宛語", + "拉脱维亚语": "拉脫維亞語", + "马其顿语": "馬其頓語", + "马拉雅拉姆语": "馬拉雅拉姆語", + "蒙古语": "蒙古語", + "马耳他语": "馬耳他語", + "缅甸语": "緬甸語", + "尼泊尔语": "尼泊爾語", + "普什图语": "普什圖語", + "僧伽罗语": "僧伽羅語", + "斯洛伐克语": "斯洛伐克語", + "斯洛文尼亚语": "斯洛維尼亞語", + "索马里语": "索馬利亞語", + "阿尔巴尼亚语": "阿爾巴尼亞語", + "塞尔维亚语": "塞爾維亞語", + "乌兹别克语": "烏茲別克語", + "祖鲁语": "祖魯語", // 国家列表 "邮箱": "電子郵件", @@ -1683,12 +1723,12 @@ const Map zhTW = { 'holderPairHint': '請確認支架已開機並處於配對模式', 'holderRetryHint': '請確認裝置已開機並靠近手機,稍後重試', 'autoConnectTitle': '裝置自動連線', - 'autoConnectDesc': '裝置開機或靠近時自動喚醒應用回連並接收錄音', + 'autoConnectDesc': '應用開啟時自動連線已記住的裝置並接收錄音。關掉應用後不會被裝置喚醒', 'autoConnectConsentTitle': '開啟裝置自動連線', 'autoConnectConsentIntro': - '為了讓你的裝置(AI 耳機/AI 手機支架)開機或靠近手機時自動連上,並在你按下裝置錄音鍵時完整保存錄音,需要你同意:', - 'autoConnectConsentB1': '向系統註冊藍牙背景掃描,掃到你已綁定的裝置時喚醒本應用(即自啟動),完成回連與錄音接收', - 'autoConnectConsentB2': '喚醒後僅用於裝置連線、錄音擷取與上傳,不作其他用途,也不會喚起其他應用', + '為了讓你的裝置(AI 耳機/AI 手機支架)在應用開啟期間自動連上,並在你按下裝置錄音鍵時完整保存錄音,需要你同意:', + 'autoConnectConsentB1': '應用處於開啟狀態時,自動連線你已記住的這台裝置,完成回連與錄音接收。應用被關掉或從最近任務劃掉後,不會再被裝置喚醒', + 'autoConnectConsentB2': '自動連線只用於裝置連線、錄音擷取與上傳,不作其他用途,也不會喚起其他應用', 'autoConnectConsentB3': '不同意也能正常使用:只是每次都要打開應用手動連線', 'autoConnectConsentB4': '同意後可隨時在「設定 → 裝置自動連線」關閉', 'autoConnectAgree': '同意並開啟', diff --git a/apps/client/lib/data/models/device_category.dart b/apps/client/lib/data/models/device_category.dart index 9d493df3..df985294 100644 --- a/apps/client/lib/data/models/device_category.dart +++ b/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( diff --git a/apps/client/lib/data/services/ast_service.dart b/apps/client/lib/data/services/ast_service.dart index 28b182d1..22f91c20 100644 --- a/apps/client/lib/data/services/ast_service.dart +++ b/apps/client/lib/data/services/ast_service.dart @@ -62,6 +62,10 @@ abstract class AstService { /// 原生不支持返回 false,调用方退回 Dart 路径。 Future setNativeCallBridge(bool enabled); + /// Echo-one 面对面 BLE 的 PCM 直连桥。只有恒玄双耳会话会开。 + /// 原生不支持返回 false。 + Future setNativeF2fBridge(bool enabled); + /// 桥的计数(两路帧数 / 丢帧 / 门限状态),会话结束时打进日志用。 Future> nativeCallBridgeStats(); diff --git a/apps/client/lib/data/services/background_launch_consent.dart b/apps/client/lib/data/services/background_launch_consent.dart index 3eafb7b3..30ef53cc 100644 --- a/apps/client/lib/data/services/background_launch_consent.dart +++ b/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._(); diff --git a/apps/client/lib/data/services/language_configs/alibaba35_language_config.dart b/apps/client/lib/data/services/language_configs/alibaba35_language_config.dart index 411b7e40..77646edf 100644 --- a/apps/client/lib/data/services/language_configs/alibaba35_language_config.dart +++ b/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 diff --git a/apps/client/lib/data/services/language_configs/alibaba_language_config.dart b/apps/client/lib/data/services/language_configs/alibaba_language_config.dart index a774b0e9..23126fda 100644 --- a/apps/client/lib/data/services/language_configs/alibaba_language_config.dart +++ b/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 语列全,别按「反正走不到」删掉。 /// /// 音色(旧版没有声音复刻,只能挑固定音色): diff --git a/apps/client/lib/data/services/language_configs/azure_language_config.dart b/apps/client/lib/data/services/language_configs/azure_language_config.dart index 298902df..35e2f4d9 100644 --- a/apps/client/lib/data/services/language_configs/azure_language_config.dart +++ b/apps/client/lib/data/services/language_configs/azure_language_config.dart @@ -804,9 +804,10 @@ List> getAzureTranslationLanguageSpecs() { }, { 'shortCode': 'jv', - 'chineseName': '爪哇语', //拉丁语、印度尼西亚) + 'chineseName': '爪哇语', 'englishName': 'Javanese', - 'translationCode': 'id', + // 微软翻译认 jv。写成 id 会把爪哇语当成印尼语去翻。 + 'translationCode': 'jv', }, { 'shortCode': 'ka', diff --git a/apps/client/lib/data/services/language_manager.dart b/apps/client/lib/data/services/language_manager.dart index 89733536..37136720 100644 --- a/apps/client/lib/data/services/language_manager.dart +++ b/apps/client/lib/data/services/language_manager.dart @@ -72,6 +72,22 @@ class LanguageManager extends GetxService { // 短代码到 ASR 代码的映射 final Map _shortCodeToAsrCodeMap = {}; + // 按短码 / 中文名 / 英文名各存一份。粤语和香港繁体的识别码都是 zh-HK, + // 不能再用识别码当唯一键,否则后加的那条会把前一条盖掉。 + final Map _byShortCode = {}; + final Map _byChineseName = {}; + final Map _byEnglishName = {}; + + /// 这 40 个语种只在微软识别 / 翻译 / 合成三张表里完整,界面此前没开放。 + /// 其中菲律宾语、希伯来语、冰岛语也在阿里 3.5 音频档,粤语在阿里旧版。 + /// 通话只要一边是这些语种,就整对走微软端到端,不让阿里把这一对抢走。 + static const Set 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 = >[ + ['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); diff --git a/apps/client/lib/data/services/microsoft_text_translation.dart b/apps/client/lib/data/services/microsoft_text_translation.dart new file mode 100644 index 00000000..ee09466f --- /dev/null +++ b/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 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 = { + '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 = { + 'api-version': '3.0', + 'to': to, + }; + if (from.isNotEmpty) query['from'] = from; + final response = await _dio.post( + '${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)}'; +} diff --git a/apps/client/lib/data/services/server_translation_service.dart b/apps/client/lib/data/services/server_translation_service.dart index 938d9b3c..33478406 100644 --- a/apps/client/lib/data/services/server_translation_service.dart +++ b/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(); + 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, diff --git a/apps/client/lib/data/services/speech_impl/azure_ast_service.dart b/apps/client/lib/data/services/speech_impl/azure_ast_service.dart index 6317a2cc..0c042cc8 100644 --- a/apps/client/lib/data/services/speech_impl/azure_ast_service.dart +++ b/apps/client/lib/data/services/speech_impl/azure_ast_service.dart @@ -588,6 +588,21 @@ class AzureAstService extends GetxService implements AstService { } } + @override + Future setNativeF2fBridge(bool enabled) async { + try { + final ok = await _channel + .invokeMethod('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> nativeCallBridgeStats() async { try { diff --git a/apps/client/lib/devices/bes/bes_bluetooth_service.dart b/apps/client/lib/devices/bes/bes_bluetooth_service.dart index a61864d1..3505eb1a 100644 --- a/apps/client/lib/devices/bes/bes_bluetooth_service.dart +++ b/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> startFaceToFaceBle() async { + final result = await _manager.startFaceToFaceBle(); + if (result['ok'] == true && result['classicKeep'] == false) { + f2fSuppressClassicDrop = true; + } + return result; + } + + Future stopFaceToFaceBle() async { + try { + await _manager.stopFaceToFaceBle(); + } finally { + f2fSuppressClassicDrop = false; + } + } + Future setNativeCallPcmBridge(bool enabled) async { try { return await _manager.setCallPcmBridge(enabled); diff --git a/apps/client/lib/devices/bes/bes_device_plugin.dart b/apps/client/lib/devices/bes/bes_device_plugin.dart index b1ba95b6..b21b4ffb 100644 --- a/apps/client/lib/devices/bes/bes_device_plugin.dart +++ b/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.broadcast(); final List _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.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 链路。 diff --git a/apps/client/lib/devices/bes/bes_f2f_protocol.dart b/apps/client/lib/devices/bes/bes_f2f_protocol.dart new file mode 100644 index 00000000..78b40645 --- /dev/null +++ b/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 ` + static List enterFrame(List token) { + if (token.length != 4) { + throw ArgumentError('token must be 4 bytes'); + } + return [ + 0xAA, 0x6B, 0x06, protoVer, capG722, + token[0] & 0xFF, token[1] & 0xFF, token[2] & 0xFF, token[3] & 0xFF, + ]; + } + + static List exitFrame() => const [0xAA, 0x6C]; + + /// 解析 `BB EB`。长度不够返回 null。 + static BesF2fEnterAck? parseEnterAck(List 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 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 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.unmodifiable(afterCompanyId.sublist(4, 8)), + battery: afterCompanyId[8] & 0x7F, + charging: (afterCompanyId[8] & 0x80) != 0, + ); + } + + static List hello(List token) => [ + 0xAA, opHello, 0x06, protoVer, + token[0] & 0xFF, token[1] & 0xFF, token[2] & 0xFF, token[3] & 0xFF, + roleApp, + ]; + + static List sessionCfg() => [ + 0xAA, opSessionCfg, 0x03, vadAlways, defaultGain, triggerContinuous, + ]; + + static List startStream() => [0xAA, opStartStream, 0x00]; + + static List stopStream() => [0xAA, opStopStream, 0x00]; + + static List keepalive(int seq) => + [0xAA, opKeepalive, 0x01, seq & 0xFF]; + + static List exitEar() => [0xAA, opExit, 0x00]; + + /// `AA 91` 下行音频。payload = flags(含耳位) + seq16le + g722。 + static List audioDown({ + required int ear, + required int flags, + required int seq, + required List g722, + }) { + final head = (ear & 0x0F) | (flags & 0xF0); + final body = [head, seq & 0xFF, (seq >> 8) & 0xFF, ...g722]; + return [0xAA, opAudioDown, body.length & 0xFF, ...body]; + } + + /// 解析一条 BLE 通知帧。不是本协议返回 null。 + static BesF2fBleFrame? parseBle(List 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.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 token; + final int battery; + final bool charging; + + bool tokenEquals(List 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 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 g722; + + bool get ownVoice => (flags & BesF2f.flagOwnVoice) != 0; +} diff --git a/apps/client/lib/devices/bes/bes_protocol.dart b/apps/client/lib/devices/bes/bes_protocol.dart index f0cd1080..2b5e799b 100644 --- a/apps/client/lib/devices/bes/bes_protocol.dart +++ b/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 frame(int cmd) => [sof, cmd & 0xFF]; } diff --git a/apps/client/lib/devices/holder/holder_device_service.dart b/apps/client/lib/devices/holder/holder_device_service.dart index afcdc5c2..2f50fe1f 100644 --- a/apps/client/lib/devices/holder/holder_device_service.dart +++ b/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 waitConnected({ Duration timeout = const Duration(seconds: 25), diff --git a/apps/client/lib/modules/devices/views/bes_scan_view.dart b/apps/client/lib/modules/devices/views/bes_scan_view.dart index 72e119d4..25bdfa35 100644 --- a/apps/client/lib/modules/devices/views/bes_scan_view.dart +++ b/apps/client/lib/modules/devices/views/bes_scan_view.dart @@ -28,13 +28,18 @@ class BesScanView extends StatefulWidget { } class _BesScanViewState extends State { - /// 展示用的设备名。当前固件的广播名。 - 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 _acceptedNames = ['Echo-one', 'DEEPVOICE']; + /// Echo-translation 是同一固件线的另一个产品名,协议与 Echo-one 相同。 + static const List _acceptedNames = [ + 'Echo-translation', + 'Echo-one', + 'DEEPVOICE', + ]; DevicePlugin get _plugin => DeviceHub.to.plugin(DeviceVendors.bes)!; final RxList _devices = [].obs; diff --git a/apps/client/lib/modules/settings/controllers/settings_controller.dart b/apps/client/lib/modules/settings/controllers/settings_controller.dart index ab61d907..d0a9e46a 100644 --- a/apps/client/lib/modules/settings/controllers/settings_controller.dart +++ b/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 toggleAutoConnect(bool value) async { diff --git a/apps/client/lib/modules/translation/controllers/translation_controller.dart b/apps/client/lib/modules/translation/controllers/translation_controller.dart index 467d3c95..d143f686 100644 --- a/apps/client/lib/modules/translation/controllers/translation_controller.dart +++ b/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(); + final MicrosoftTextTranslation _microsoftTranslation = + MicrosoftTextTranslation(); final TtsService _ttsService = Get.find(); final AstService _astService = Get.find(); final LanguageManager _languageManager = Get.find(); @@ -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? _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 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 _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 _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 _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 _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'; diff --git a/apps/client/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt b/apps/client/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt index e8bf261a..0b880bea 100644 --- a/apps/client/local_plugins/agent_service/android/src/main/kotlin/com/yunqiinnovation/agent_service/BleMediaService.kt +++ b/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 } /** diff --git a/apps/client/local_plugins/amap_service/android/src/main/AndroidManifest.xml b/apps/client/local_plugins/amap_service/android/src/main/AndroidManifest.xml index 624e0986..c484eb4b 100644 --- a/apps/client/local_plugins/amap_service/android/src/main/AndroidManifest.xml +++ b/apps/client/local_plugins/amap_service/android/src/main/AndroidManifest.xml @@ -30,9 +30,6 @@ - - - - - @@ -20,7 +15,7 @@ android:name=".tools.AudioRecordingForegroundService" android:enabled="true" android:exported="false" - android:foregroundServiceType="microphone|phoneCall" /> + android:foregroundServiceType="microphone" /> { + val enabled = call.argument("enabled") ?: false + if (enabled) { + val b = ensureF2fPcmBridge() + b.enable() + if (astSessionReady) b.markReady() + } else { + f2fPcmBridge?.disable() + } + result.success(true) + } + "pushExternalAudio" -> { val leg = call.argument("leg")?.uppercase() ?: "" val pcm = call.argument("pcm") diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BesF2fPcmBridge.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/BesF2fPcmBridge.kt new file mode 100644 index 00000000..592ba841 --- /dev/null +++ b/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() + private val holdB = ArrayDeque() + + 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 + val b: List + 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" + } +} diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AudioRecordingForegroundService.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AudioRecordingForegroundService.kt index d101235b..16c768d5 100644 --- a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/AudioRecordingForegroundService.kt +++ b/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? { diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/ScreenCaptureForegroundService.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/ScreenCaptureForegroundService.kt index 366acf93..48ab863d 100644 --- a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/tools/ScreenCaptureForegroundService.kt +++ b/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 diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index 0d808b80..daa355c3 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/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 diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BesF2fPcmBridge.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/BesF2fPcmBridge.swift new file mode 100644 index 00000000..b814dc47 --- /dev/null +++ b/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) } + } + } +} diff --git a/apps/client/local_plugins/ble_service/android/src/main/AndroidManifest.xml b/apps/client/local_plugins/ble_service/android/src/main/AndroidManifest.xml index c29d8194..2662b575 100644 --- a/apps/client/local_plugins/ble_service/android/src/main/AndroidManifest.xml +++ b/apps/client/local_plugins/ble_service/android/src/main/AndroidManifest.xml @@ -12,11 +12,6 @@ - - - - - () - // 如果提供了额外的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) } diff --git a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt index eecfa5a0..eb779422 100644 --- a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt +++ b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManager.kt @@ -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")) diff --git a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt index c0a4a9ec..91007882 100644 --- a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt +++ b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/BluetoothManagerPlugin.kt @@ -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("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) diff --git a/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/FaceToFaceBleSession.kt b/apps/client/local_plugins/bluetooth_manager/android/src/main/kotlin/com/example/bluetooth_manager/FaceToFaceBleSession.kt new file mode 100644 index 00000000..ea4308eb --- /dev/null +++ b/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) -> 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() + 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( + "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) { + 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) { + val m = HashMap(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() + 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? { + 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 + } + } + } +} diff --git a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManager.swift b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManager.swift index 2464f58d..08d8ead0 100644 --- a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManager.swift +++ b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManager.swift @@ -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]) } diff --git a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift index c3aa0e2e..e40353e6 100644 --- a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift +++ b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/BluetoothManagerPlugin.swift @@ -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 diff --git a/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/FaceToFaceBleSession.swift b/apps/client/local_plugins/bluetooth_manager/ios/bluetooth_manager/Sources/bluetooth_manager/FaceToFaceBleSession.swift new file mode 100644 index 00000000..abcc1dea --- /dev/null +++ b/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() + 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..= 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..> 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 } +} diff --git a/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager.dart b/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager.dart index 4e0b6fbf..b6caf62f 100644 --- a/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager.dart +++ b/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> startFaceToFaceBle() { + return BluetoothManagerPlatform.instance.startFaceToFaceBle(); + } + + Future stopFaceToFaceBle() { + return BluetoothManagerPlatform.instance.stopFaceToFaceBle(); + } + + Stream> onFaceToFaceEvent() { + return BluetoothManagerPlatform.instance.onFaceToFaceEvent(); + } + Future setAiPcmBridge(bool enabled) { return BluetoothManagerPlatform.instance.setAiPcmBridge(enabled); } diff --git a/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_method_channel.dart b/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_method_channel.dart index 08f5decc..de21f8c4 100644 --- a/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_method_channel.dart +++ b/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_method_channel.dart @@ -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> startFaceToFaceBle() async { + final raw = await methodChannel.invokeMethod('startFaceToFaceBle'); + return raw == null ? const {'ok': false} : Map.from(raw); + } + + @override + Future stopFaceToFaceBle() async { + return await methodChannel.invokeMethod('stopFaceToFaceBle') ?? false; + } + + @override + Stream> onFaceToFaceEvent() { + return deviceF2fChannel.receiveBroadcastStream().map((event) { + if (event is Map) return Map.from(event); + return {}; + }); + } + @override Future setAiPcmBridge(bool enabled) async { return await methodChannel diff --git a/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_platform_interface.dart b/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_platform_interface.dart index 4bb38af7..b57d5754 100644 --- a/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_platform_interface.dart +++ b/apps/client/local_plugins/bluetooth_manager/lib/bluetooth_manager_platform_interface.dart @@ -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> startFaceToFaceBle() { + throw UnimplementedError('startFaceToFaceBle() has not been implemented.'); + } + + Future stopFaceToFaceBle() { + throw UnimplementedError('stopFaceToFaceBle() has not been implemented.'); + } + + Stream> onFaceToFaceEvent() { + throw UnimplementedError('onFaceToFaceEvent() has not been implemented.'); + } + /// EMAI 会话 PCM 原生直连桥:开着时 AI 模式的 micData 不再进 [onDeviceAiEvent], /// 由原生直接交给 azure_speech 的百炼客户端;合成音频也直接回到 AI 下行发送器。 Future setAiPcmBridge(bool enabled) { diff --git a/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart b/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart index fa4b83d2..3b774d31 100644 --- a/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart +++ b/apps/client/local_plugins/device_plugin_interface/lib/src/device_session.dart @@ -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'; diff --git a/apps/client/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt b/apps/client/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt index ae9af4d3..d2355251 100644 --- a/apps/client/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/FloatingWindowService.kt +++ b/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 diff --git a/apps/client/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt b/apps/client/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt index b35eebf8..1ab38e4d 100644 --- a/apps/client/local_plugins/floating_ui_plugin/android/src/main/kotlin/com/example/native_plugin/RecordingFloatingService.kt +++ b/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) { diff --git a/apps/client/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt b/apps/client/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt index 0ab6f319..524e9c1f 100644 --- a/apps/client/local_plugins/music_service/android/src/main/kotlin/com/yunqiinnovation/music_service/MusicService.kt +++ b/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 } diff --git a/apps/client/local_plugins/recorder_holder/android/src/main/AndroidManifest.xml b/apps/client/local_plugins/recorder_holder/android/src/main/AndroidManifest.xml index 96fad04a..f048cb66 100644 --- a/apps/client/local_plugins/recorder_holder/android/src/main/AndroidManifest.xml +++ b/apps/client/local_plugins/recorder_holder/android/src/main/AndroidManifest.xml @@ -12,11 +12,6 @@ - - - - - diff --git a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleCompanionManager.kt b/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleCompanionManager.kt index f0d52b99..b384eb97 100644 --- a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleCompanionManager.kt +++ b/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),返回 - // **本 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 - 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) } } diff --git a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleMediaService.kt b/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleMediaService.kt index 49ad2383..cf42adb9 100644 --- a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleMediaService.kt +++ b/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 diff --git a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleRecord.kt b/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleRecord.kt index e575efd8..a97eaabb 100644 --- a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BleRecord.kt +++ b/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) diff --git a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BootReceiver.kt b/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BootReceiver.kt deleted file mode 100644 index 3451999e..00000000 --- a/apps/client/local_plugins/recorder_holder/android/src/main/kotlin/com/recorder/holder/BootReceiver.kt +++ /dev/null @@ -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)) - } - } -} \ No newline at end of file diff --git a/apps/client/local_plugins/recorder_holder/sdk/lib/src/client.dart b/apps/client/local_plugins/recorder_holder/sdk/lib/src/client.dart index a9213de9..a30a092d 100644 --- a/apps/client/local_plugins/recorder_holder/sdk/lib/src/client.dart +++ b/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 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 clearBackgroundScan() async { + if (!Platform.isAndroid) return; + ensureBound(); + try { + await _plugin.unregisterBackgroundScan(); + } catch (_) {} + } + Future disconnect() async { ensureBound(); if (Platform.isAndroid) { diff --git a/apps/client/pubspec.yaml b/apps/client/pubspec.yaml index aac1c5ee..76240f76 100644 --- a/apps/client/pubspec.yaml +++ b/apps/client/pubspec.yaml @@ -1,7 +1,7 @@ name: eaimar description: "Eaimar - AI Voice Assistant." publish_to: "none" -version: 1.0.2+26 +version: 1.0.2+27 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/apps/client/test/call_extended_languages_test.dart b/apps/client/test/call_extended_languages_test.dart new file mode 100644 index 00000000..71dc9a96 --- /dev/null +++ b/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); + }); +} diff --git a/apps/client/test/devices/bes_f2f_protocol_test.dart b/apps/client/test/devices/bes_f2f_protocol_test.dart new file mode 100644 index 00000000..2ea93f9c --- /dev/null +++ b/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); + }); +} diff --git a/apps/client/test/devices/bes_protocol_test.dart b/apps/client/test/devices/bes_protocol_test.dart index 553a7f7d..0908e22a 100644 --- a/apps/client/test/devices/bes_protocol_test.dart +++ b/apps/client/test/devices/bes_protocol_test.dart @@ -99,12 +99,13 @@ void main() { expect(BesCmd.status | 0x80, isNot(BesResp.status)); }); - test('0x51~0x6A 会切工作模式,探测时必须跳过', () { - for (var c = 0x51; c <= 0x6A; c++) { + test('0x51~0x6C 会切工作模式,探测时必须跳过', () { + for (var c = 0x51; c <= 0x6C; c++) { expect(BesCmd.switchesMode(c), isTrue, reason: '0x${c.toRadixString(16)}'); } expect(BesCmd.switchesMode(0x09), isFalse); expect(BesCmd.switchesMode(0x06), isFalse); + expect(BesCmd.switchesMode(0x6D), isFalse); }); }); } diff --git a/apps/client/test/devices/eaicar_name_test.dart b/apps/client/test/devices/eaicar_name_test.dart index 12a8f27f..7ec979cf 100644 --- a/apps/client/test/devices/eaicar_name_test.dart +++ b/apps/client/test/devices/eaicar_name_test.dart @@ -25,6 +25,7 @@ void main() { test('别家设备拒绝', () { expect(isAllowedEaiCarName('EaiRec'), isFalse); expect(isAllowedEaiCarName('Echo-one'), isFalse); + expect(isAllowedEaiCarName('Echo-translation'), isFalse); }); }); } diff --git a/apps/client/test/devices/echo_translation_name_test.dart b/apps/client/test/devices/echo_translation_name_test.dart new file mode 100644 index 00000000..f75c4c7b --- /dev/null +++ b/apps/client/test/devices/echo_translation_name_test.dart @@ -0,0 +1,26 @@ +import 'package:eaimar/data/models/device_category.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('Echo-translation 与 Echo-one 都归到耳机品类', () { + expect( + DeviceCategories.categoryIdForName('Echo-translation'), + DeviceCategories.earphone, + ); + expect( + DeviceCategories.categoryIdForName('Echo-one'), + DeviceCategories.earphone, + ); + expect( + DeviceCategories.categoryIdForName('ECHO-TRANSLATION-3F2A'), + DeviceCategories.earphone, + ); + }); + + test('两个产品名互不包含,不会把 Echo-one 认成 Echo-translation', () { + const echoOne = 'echo-one'; + const echoTranslation = 'echo-translation'; + expect(echoTranslation.contains(echoOne), isFalse); + expect(echoOne.contains(echoTranslation), isFalse); + }); +} diff --git a/apps/client/test/devices/holder_name_test.dart b/apps/client/test/devices/holder_name_test.dart index eb921e2e..01bb9e53 100644 --- a/apps/client/test/devices/holder_name_test.dart +++ b/apps/client/test/devices/holder_name_test.dart @@ -35,6 +35,7 @@ void main() { test('无关设备拒绝', () { expect(isAllowedHolderName('Echo-one'), isFalse); + expect(isAllowedHolderName('Echo-translation'), isFalse); expect(isAllowedHolderName('Smartcar'), isFalse); }); }); diff --git a/apps/services/comm/lang.go b/apps/services/comm/lang.go index 145c7e74..b0775263 100644 --- a/apps/services/comm/lang.go +++ b/apps/services/comm/lang.go @@ -11,7 +11,10 @@ import "strings" // 这份表原先在 modules/echomeet 里私有,实时翻译接口也要用,提到 comm 共用。 var bcp47ToShort = map[string]string{ - "zh-CN": "zh", "zh-TW": "zh", "zh-HK": "zh", + "zh-CN": "zh", + // 阿里机器翻译的繁体码是 zh-tw。收成 zh 会把繁体译文翻成简体。 + // 台湾繁体和香港繁体写成同一套字,口语差异由客户端的音色区分。 + "zh-TW": "zh-tw", "zh-HK": "zh-tw", "en-US": "en", "en-GB": "en", "ja-JP": "ja", "ko-KR": "ko", diff --git a/apps/services/comm/lang_test.go b/apps/services/comm/lang_test.go index 7d79609b..0b78f2d1 100644 --- a/apps/services/comm/lang_test.go +++ b/apps/services/comm/lang_test.go @@ -16,7 +16,8 @@ func TestBCP47ToShortLangProviderCodes(t *testing.T) { // 表里显式收录的 "uk-UA": "uk", "zh-CN": "zh", - "zh-TW": "zh", + "zh-TW": "zh-tw", + "zh-HK": "zh-tw", // 没收录的走取主标签兜底 "hr-HR": "hr", "sv-SE": "sv",