Browse Source

client: 翻译默认归档进语音纪要;列表改本地优先;登录按包收敛;EMAI 走 A2DP

## 通话/同声/面对面翻译 → 语音纪要

三种翻译模式默认边翻边录,结束时把音频**连同已有转写**一起归档
(rtype=VOICETRANSLAT),服务端跳过识别、直接用智能模板(General-Autopilot)总结,
在会议纪要里就能看到。转写段的构造见 translation_export.dart(纯函数,带金测试):

- 时间是**相对录音起点的毫秒**并扣掉暂停区间——面对面是逐句按住说话、松开就暂停的,
  不扣的话越往后偏得越多;写成绝对时间戳会让点一句跳到音频末尾之外;
- 说话人用 Speaker_0/Speaker_1,与服务端转写同一口径(转写页直接把这个字段当文案显示,
  裸 0/1 会和别的纪要长得不一样,喂给 LLM 抽待办时也会混出 Speaker2 这种负责人);
- AST socket 断开后 _terminateCallOnError 会取消事件订阅,重开时收不到任何转写
  (真机上表现为「有日志但转写 0 段」),改成重新订阅。

## 语音纪要列表:服务端是真源、本地 sqlite 是缓存

列表接口改回骨架字段后(服务端同批),客户端改成本地优先渲染 + 后台对齐:

- Synchrodata 重写:拉骨架 → upsert → 删掉「本地有服务端没有」的记录(连本地音频、
  上传队列、轮询任务一起清)。**业务错误时 data 是 null 不是空列表**,判空错了
  会把本地整个清空;
- 详情进页面时才拉整条;离线操作队列那套上行是死代码(服务端接口整个注释掉了),删除。

## 登录按包收敛

苹果包只留苹果+游客、安卓包只留微信+游客、谷歌包只留邮箱+游客。
判定抽成 resolveLoginMethods 纯函数(login_methods.dart + 8 条测试),
邮箱包还要锁死国家区号选择。

## EMAI 与其它

- 手机侧触发的对话走 A2DP 而不是语音处理通道:iOS 上 A2DP 输出与 voiceChat 模式
  互斥,开了后者蓝牙耳机会当场切到手机扬声器。按当前实际输出路由决定要不要开
  voiceProcessing/回声消除;
- EMAI「开始对话」闪退:探测输入格式时 new 了一个一次性 AVAudioEngine 去读 inputNode,
  配置变更时崩在 AVAudioIONodeImpl(EXC_BAD_ACCESS)。改成直接问 AVAudioSession;
- 语音纪要文件名可改:每行右侧加铅笔图标,复用已有的重命名弹窗(原来只有长按菜单,
  实际入口已不可达);
- 拾忆上报 IANA 时区名:DateTime.now().timeZoneName 给的是「CST」这种缩写,
  服务端 LoadLocation 解析不了、静默退回容器时区,海外用户的提醒时刻会全按北京时间算。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 3 weeks ago
parent
commit
f9b9c8c72d
  1. 5
      apps/client/.gitignore
  2. 6
      apps/client/ios/Runner.xcodeproj/project.pbxproj
  3. 1
      apps/client/lib/core/translations/language/ar_sa.dart
  4. 1
      apps/client/lib/core/translations/language/bg_bg.dart
  5. 1
      apps/client/lib/core/translations/language/cs_cz.dart
  6. 1
      apps/client/lib/core/translations/language/da_dk.dart
  7. 1
      apps/client/lib/core/translations/language/de_de.dart
  8. 1
      apps/client/lib/core/translations/language/el_gr.dart
  9. 1
      apps/client/lib/core/translations/language/en_us.dart
  10. 1
      apps/client/lib/core/translations/language/es_es.dart
  11. 1
      apps/client/lib/core/translations/language/et_ee.dart
  12. 1
      apps/client/lib/core/translations/language/fa_ir.dart
  13. 1
      apps/client/lib/core/translations/language/fi_fi.dart
  14. 1
      apps/client/lib/core/translations/language/fr_fr.dart
  15. 1
      apps/client/lib/core/translations/language/hi_in.dart
  16. 1
      apps/client/lib/core/translations/language/hr_hr.dart
  17. 1
      apps/client/lib/core/translations/language/hu_hu.dart
  18. 1
      apps/client/lib/core/translations/language/id_id.dart
  19. 1
      apps/client/lib/core/translations/language/is_is.dart
  20. 1
      apps/client/lib/core/translations/language/it_it.dart
  21. 1
      apps/client/lib/core/translations/language/ja_jp.dart
  22. 1
      apps/client/lib/core/translations/language/ko_kr.dart
  23. 1
      apps/client/lib/core/translations/language/lt_lt.dart
  24. 1
      apps/client/lib/core/translations/language/lv_lv.dart
  25. 1
      apps/client/lib/core/translations/language/ms_my.dart
  26. 1
      apps/client/lib/core/translations/language/nb_no.dart
  27. 1
      apps/client/lib/core/translations/language/nl_nl.dart
  28. 1
      apps/client/lib/core/translations/language/pl_pl.dart
  29. 1
      apps/client/lib/core/translations/language/pt_pt.dart
  30. 1
      apps/client/lib/core/translations/language/ro_ro.dart
  31. 1
      apps/client/lib/core/translations/language/ru_ru.dart
  32. 1
      apps/client/lib/core/translations/language/sk_sk.dart
  33. 1
      apps/client/lib/core/translations/language/sl_si.dart
  34. 1
      apps/client/lib/core/translations/language/sr_rs.dart
  35. 1
      apps/client/lib/core/translations/language/sv_se.dart
  36. 1
      apps/client/lib/core/translations/language/th_th.dart
  37. 1
      apps/client/lib/core/translations/language/tl_ph.dart
  38. 1
      apps/client/lib/core/translations/language/tr_tr.dart
  39. 1
      apps/client/lib/core/translations/language/uk_ua.dart
  40. 1
      apps/client/lib/core/translations/language/vi_vn.dart
  41. 1
      apps/client/lib/core/translations/language/zh_cn.dart
  42. 1
      apps/client/lib/core/translations/language/zh_hk.dart
  43. 1
      apps/client/lib/core/translations/language/zh_tw.dart
  44. 266
      apps/client/lib/core/utils/synchrodata.dart
  45. 4
      apps/client/lib/data/services/account_service.dart
  46. 56
      apps/client/lib/data/services/deapsound_ai_service.dart
  47. 31
      apps/client/lib/data/services/device_ai_session_service.dart
  48. 29
      apps/client/lib/data/services/device_call_recording_service.dart
  49. 22
      apps/client/lib/data/services/device_stereo_recorder.dart
  50. 45
      apps/client/lib/data/services/meeting/meeting_task_service.dart
  51. 29
      apps/client/lib/data/services/memory_service.dart
  52. 17
      apps/client/lib/data/services/network/api.dart
  53. 55
      apps/client/lib/data/services/recording_archive.dart
  54. 51
      apps/client/lib/data/services/wav_writer.dart
  55. 93
      apps/client/lib/data/utils/emai_audio_session.dart
  56. 46
      apps/client/lib/data/utils/mic_pcm_streamer.dart
  57. 24
      apps/client/lib/modules/common/widgets/language_selector_bar.dart
  58. 32
      apps/client/lib/modules/login/controllers/login_controller.dart
  59. 48
      apps/client/lib/modules/login/models/login_methods.dart
  60. 86
      apps/client/lib/modules/login/views/login_view.dart
  61. 85
      apps/client/lib/modules/meeting/controllers/meeting_details_controller.dart
  62. 11
      apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart
  63. 17
      apps/client/lib/modules/meeting/views/bottomSheet/ai_bottom_sheet.dart
  64. 140
      apps/client/lib/modules/meeting/views/meeting_home_view.dart
  65. 10
      apps/client/lib/modules/meeting/views/tabs/summary_tab.dart
  66. 230
      apps/client/lib/modules/translation/controllers/translation_controller.dart
  67. 60
      apps/client/lib/modules/translation/models/translation_direction.dart
  68. 98
      apps/client/lib/modules/translation/models/translation_export.dart
  69. 53
      apps/client/lib/modules/translation/views/translation_view.dart
  70. 2
      apps/client/lib/modules/translation/views/widgets/face_to_face_layout.dart
  71. 17
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt
  72. 42
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  73. 18
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift
  74. 67
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift
  75. 22
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift
  76. 56
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/XunFeiAsrHelper.swift
  77. 109
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MediaKeepAlivePlayer.swift
  78. 14
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift
  79. 2
      apps/client/pubspec.yaml
  80. 23
      apps/client/test/deapsound_ai_url_test.dart
  81. 55
      apps/client/test/login_methods_test.dart
  82. 58
      apps/client/test/stereo_pairer_test.dart
  83. 88
      apps/client/test/translation_direction_test.dart
  84. 117
      apps/client/test/translation_export_test.dart

5
apps/client/.gitignore

@ -72,3 +72,8 @@ app.*.map.json
# 多渠道打包产物(build_channels.sh 的默认输出目录) # 多渠道打包产物(build_channels.sh 的默认输出目录)
build_outputs/ build_outputs/
# 本地插件(库)的 pubspec.lock 不入库——仓库里其它插件都没有,
# 主工程 apps/client/pubspec.lock 才该锁版本。
# 不写这条的话,一次 `git add apps/client` 就会把它们顺手带进提交。
local_plugins/**/pubspec.lock

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

@ -641,7 +641,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = B8AJQ7ACU7; DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -835,7 +835,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = B8AJQ7ACU7; DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;
@ -869,7 +869,7 @@
CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements;
CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_IDENTITY = "Apple Development";
CODE_SIGN_STYLE = Automatic; CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 10; CURRENT_PROJECT_VERSION = 12;
DEVELOPMENT_TEAM = B8AJQ7ACU7; DEVELOPMENT_TEAM = B8AJQ7ACU7;
ENABLE_BITCODE = NO; ENABLE_BITCODE = NO;
INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_FILE = Runner/Info.plist;

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

@ -2073,4 +2073,5 @@ const Map<String, String> arSA = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'الترجمة قيد التشغيل. أوقفها قبل تغيير اللغة.',
}; };

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

@ -2136,4 +2136,5 @@ const Map<String, String> bgBG = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Преводът е в ход. Спрете го, преди да смените езика.',
}; };

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

@ -2104,4 +2104,5 @@ const Map<String, String> csCZ = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Překlad běží. Před změnou jazyka jej zastavte.',
}; };

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

@ -2106,4 +2106,5 @@ const Map<String, String> daDK = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Oversættelse kører. Stop den, før du skifter sprog.',
}; };

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

@ -2173,4 +2173,5 @@ const Map<String, String> deDE = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Übersetzung läuft. Stoppen Sie sie, bevor Sie die Sprache wechseln.',
}; };

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

@ -2140,4 +2140,5 @@ const Map<String, String> elGR = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Η μετάφραση εκτελείται. Σταματήστε την πριν αλλάξετε γλώσσα.',
}; };

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

@ -2129,4 +2129,5 @@ const Map<String, String> enUS = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Translation is running. Stop it before changing languages.',
}; };

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

@ -2167,4 +2167,5 @@ const Map<String, String> esES = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'La traducción está en curso. Deténla antes de cambiar de idioma.',
}; };

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

@ -2100,4 +2100,5 @@ const Map<String, String> etEE = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Tõlkimine käib. Peata see enne keele vahetamist.',
}; };

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

@ -2091,4 +2091,5 @@ const Map<String, String> faIR = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'ترجمه در حال اجراست. قبل از تغییر زبان آن را متوقف کنید.',
}; };

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

@ -2110,4 +2110,5 @@ const Map<String, String> fiFI = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Käännös on käynnissä. Pysäytä se ennen kielen vaihtamista.',
}; };

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

@ -2186,4 +2186,5 @@ const Map<String, String> frFR = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Traduction en cours. Arrêtez-la avant de changer de langue.',
}; };

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

@ -2109,4 +2109,5 @@ const Map<String, String> hiIN = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'अनुवाद चल रहा है। भाषा बदलने से पहले इसे रोकें।',
}; };

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

@ -2125,4 +2125,5 @@ const Map<String, String> hrHR = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Prijevod je u tijeku. Zaustavite ga prije promjene jezika.',
}; };

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

@ -2128,4 +2128,5 @@ const Map<String, String> huHU = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'A fordítás fut. Nyelvváltás előtt állítsa le.',
}; };

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

@ -2109,4 +2109,5 @@ const Map<String, String> idID = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Terjemahan sedang berjalan. Hentikan dulu sebelum mengganti bahasa.',
}; };

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

@ -2106,4 +2106,5 @@ const Map<String, String> isIS = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Þýðing er í gangi. Stöðvaðu hana áður en þú skiptir um tungumál.',
}; };

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

@ -2147,4 +2147,5 @@ const Map<String, String> itIT = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Traduzione in corso. Interrompila prima di cambiare lingua.',
}; };

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

@ -1968,4 +1968,5 @@ const Map<String, String> jaJP = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': '翻訳中です。言語を切り替える前に翻訳を停止してください。',
}; };

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

@ -1961,4 +1961,5 @@ const Map<String, String> koKR = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': '번역이 진행 중입니다. 언어를 바꾸려면 먼저 번역을 중지하세요.',
}; };

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

@ -2124,4 +2124,5 @@ const Map<String, String> ltLT = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Vyksta vertimas. Sustabdykite jį prieš keisdami kalbą.',
}; };

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

@ -2129,4 +2129,5 @@ const Map<String, String> lvLV = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Notiek tulkošana. Apturiet to pirms valodas maiņas.',
}; };

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

@ -2113,4 +2113,5 @@ const Map<String, String> msMY = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Terjemahan sedang berjalan. Hentikan dahulu sebelum menukar bahasa.',
}; };

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

@ -2107,4 +2107,5 @@ const Map<String, String> nbNO = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Oversettelse pågår. Stopp den før du bytter språk.',
}; };

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

@ -2161,4 +2161,5 @@ const Map<String, String> nlNL = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Vertaling loopt. Stop deze voordat u van taal wisselt.',
}; };

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

@ -2135,4 +2135,5 @@ const Map<String, String> plPL = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Tłumaczenie trwa. Zatrzymaj je przed zmianą języka.',
}; };

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

@ -2173,4 +2173,5 @@ const Map<String, String> ptPT = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'A tradução está em curso. Pare-a antes de mudar de idioma.',
}; };

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

@ -2137,4 +2137,5 @@ const Map<String, String> roRO = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Traducerea este în curs. Opriți-o înainte de a schimba limba.',
}; };

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

@ -2148,4 +2148,5 @@ const Map<String, String> ruRU = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Идёт перевод. Остановите его, прежде чем менять язык.',
}; };

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

@ -2118,4 +2118,5 @@ const Map<String, String> skSK = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Preklad beží. Pred zmenou jazyka ho zastavte.',
}; };

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

@ -2121,4 +2121,5 @@ const Map<String, String> slSI = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Prevajanje poteka. Ustavite ga, preden zamenjate jezik.',
}; };

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

@ -2117,4 +2117,5 @@ const Map<String, String> srRS = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Prevod je u toku. Zaustavite ga pre promene jezika.',
}; };

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

@ -2113,4 +2113,5 @@ const Map<String, String> svSE = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Översättning pågår. Stoppa den innan du byter språk.',
}; };

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

@ -2066,4 +2066,5 @@ const Map<String, String> thTH = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'กำลังแปลอยู่ โปรดหยุดการแปลก่อนเปลี่ยนภาษา',
}; };

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

@ -2151,4 +2151,5 @@ const Map<String, String> tlPH = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Tumatakbo ang pagsasalin. Ihinto muna ito bago magpalit ng wika.',
}; };

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

@ -2107,4 +2107,5 @@ const Map<String, String> trTR = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Çeviri devam ediyor. Dili değiştirmeden önce çeviriyi durdurun.',
}; };

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

@ -2125,4 +2125,5 @@ const Map<String, String> ukUA = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Триває переклад. Зупиніть його, перш ніж змінювати мову.',
}; };

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

@ -2085,4 +2085,5 @@ const Map<String, String> viVN = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': 'Đang dịch. Hãy dừng dịch trước khi đổi ngôn ngữ.',
}; };

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

@ -1941,4 +1941,5 @@ const Map<String, String> zhCN = {
'computePack': '算力包', 'computePack': '算力包',
'vipExpiringSoon': '会员还有 @days 天到期,续费后可继续使用翻译与会议纪要', 'vipExpiringSoon': '会员还有 @days 天到期,续费后可继续使用翻译与会议纪要',
'computeLow': '算力不多了,用完后将无法继续使用', 'computeLow': '算力不多了,用完后将无法继续使用',
'stopTranslationBeforeLanguageChange': '翻译进行中,请先停止翻译再切换语言',
}; };

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

@ -1938,4 +1938,5 @@ const Map<String, String> zhHK = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': '翻譯進行中,請先停止翻譯再切換語言',
}; };

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

@ -1938,4 +1938,5 @@ const Map<String, String> zhTW = {
'computePack': 'Compute packs', 'computePack': 'Compute packs',
'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.', 'vipExpiringSoon': 'Membership expires in @days day(s). Renew to keep using translation and meeting notes.',
'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.', 'computeLow': 'Running low on compute. You won\'t be able to continue once it runs out.',
'stopTranslationBeforeLanguageChange': '翻譯進行中,請先停止翻譯再切換語言',
}; };

266
apps/client/lib/core/utils/synchrodata.dart

@ -1,193 +1,133 @@
import 'dart:convert'; import 'dart:io';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart';
import '../../data/services/db/database_helper.dart'; import '../../data/services/db/database_helper.dart';
import '../../data/services/db/sqflite_api.dart';
import '../../data/services/meeting/meeting_task_service.dart'; import '../../data/services/meeting/meeting_task_service.dart';
import '../../data/services/meeting/meeting_upload_service.dart';
import '../../data/services/network/api.dart'; import '../../data/services/network/api.dart';
import 'logger.dart';
/// 语音纪要列表与服务端对齐。
///
/// 模型:**服务端是唯一真源,本地 sqlite 只是缓存。**
/// - `echomeet_getallrecords` 只回骨架(title/rtype/state/seconds/audiourl/creationtime),
/// 每条 ~250 字节,上千条也是一次拉完,所以没有分页、没有增量、没有软删除;
/// - 转写(`meetingspeaker`) 与总结(`summary/overview/personnel`) **不在这里写**,
/// 详情页进页面时 `echomeet_getrecord` 拉整条再落库(见 MeetingDetailsController);
/// - 本地有、服务端没有的记录 = 在别的设备上删了,这里跟着删。
/// 本地行必然带服务端 id(归档是先 `addrecord` 拿到 id 才 `insertMeeting`),不会误删归档中的。
///
/// 原先这个类还带一套「离线操作队列 → echomeet_putrecords」的上行逻辑,服务端那个接口整个
/// 注释掉了、客户端上传段也注释掉了,是死代码,2026-09-18 一并删除。
class Synchrodata { class Synchrodata {
static final GetStorage _storage = GetStorage(); static const String _tag = 'Synchrodata';
static bool _running = false;
static List<Map<String, dynamic>> _records = /// 拉一次骨架并对齐本地库。并发调用直接返回;失败只记日志(列表照用本地缓存)。
(_storage.read("operation_records") as List?) static Future<void> sync() async {
?.map((e) => Map<String, dynamic>.from(e as Map)) if (_running) return;
.toList() ?? _running = true;
[]; try {
static List _delids = _storage.read("operation_delids") ?? []; await _sync();
static bool _isSynchro = false; } catch (e, st) {
Logger.w(_tag, '列表同步失败(沿用本地缓存): $e\n$st');
// 保存操作记录 } finally {
static Future saveOperationRecord( _running = false;
String method,
dynamic params,
) async {
String userId = '';
Map? userInfo = _storage.read("user_info");
if (userInfo != null) {
userId = userInfo['user']['uid'];
}
String id = '${userId}_${params['id']}';
if (params['type'] != null) {
params['rtype'] = params['type'];
}
switch (method) {
case 'insert':
_records.add({
...Map<String, dynamic>.from(params),
'id': id,
'number': params['id'],
'uid': userId,
});
_storage.write("operation_records", _records);
break;
case 'update':
Map<String, dynamic>? foundElement = _records.firstWhere(
(element) => element['id'] == id,
orElse: () => {},
);
if (foundElement.isNotEmpty) {
foundElement.addAll({
...Map<String, dynamic>.from(params),
'id': id,
'number': params['id'],
});
} else {
_records.add({
...Map<String, dynamic>.from(params),
'id': id,
'number': params['id'],
'uid': userId,
});
}
_storage.write("operation_records", _records);
break;
case 'delete':
_delids.add(id);
_storage.write("operation_delids", _delids);
break;
default:
break;
} }
} }
// 上传操作记录 static Future<void> _sync() async {
static Future uploadOperationRecords({bool isSynchro = false}) async { final result = await Api.getOperationAllRecord();
if (_isSynchro) return; // ⚠️ 业务错误时 AuthInterceptor 把 data 置 null 后 resolve(不抛),必须判空——
_isSynchro = true; // 把 null 当「服务端没有记录」会把本地整个清空。
// if (_records.isNotEmpty || _delids.isNotEmpty) { if (result == null) {
// // 获取需要修改的全部数据 Logger.w(_tag, '列表接口返回业务错误,本轮不对齐');
// List putRecords = []; return;
// for (var element in _records) {
// Map result = {'details': {}};
// List speakerList = [];
// if (!_delids.contains(element['id'])) {
// result = await SqfliteApi.getMeetingDetails(element['number']);
// speakerList = await SqfliteApi.getMeetingSpeaker(element['number']);
// }
// putRecords.add({
// ...Map<String, dynamic>.from(result),
// ...Map<String, dynamic>.from(result['details']),
// 'rtype': result['type'],
// 'original': jsonEncode(speakerList),
// ...Map<String, dynamic>.from(element),
// });
// }
// var result = await Api.putOperationRecord({
// 'delids': _delids,
// 'records': putRecords,
// });
// if (result != null) {
// _records = [];
// _delids = [];
// _storage.write("operation_records", _records);
// _storage.write("operation_delids", _delids);
// }
// }
if (isSynchro) {
await _synchrodata();
} }
_isSynchro = false; final List records = result['records'] ?? [];
} final serverIds = <int>{};
final taskList = <Map<String, Object?>>[];
// 同步数据
static Future _synchrodata() async {
var result = await Api.getOperationAllRecord();
List records = result['records'] ?? [];
if (records.isEmpty) return;
final meetingTaskService = Get.find<MeetingTaskService>();
List taskList = [];
final db = await DatabaseHelper().database; final db = await DatabaseHelper().database;
await db.transaction((txn) async { await db.transaction((txn) async {
for (var element in records) { for (final element in records) {
if (element['state'] > 0 && element['state'] < 5) { final int id = element['id'];
taskList.add({ final int state = element['state'] ?? 0;
'id': element['id'], serverIds.add(id);
'tasktype': element['state'], if (state > 0 && state < 5) {
}); taskList.add({'id': id, 'tasktype': state});
} }
final List existList = await txn.query( final meeting = <String, Object?>{
'meeting', 'id': id,
where: 'id = ?',
whereArgs: [element['id']],
);
Map<String, Object?> meetingData = {
'id': element['id'],
'title': element['title'] ?? '', 'title': element['title'] ?? '',
'type': element['rtype'] ?? '', 'type': element['rtype'] ?? '',
'seconds': element['seconds'] ?? 0, 'seconds': element['seconds'] ?? 0,
'audiourl': element['audiourl'] ?? '', 'audiourl': element['audiourl'] ?? '',
'tasktype': element['state'] ?? 0, 'tasktype': state,
// 服务端秒,本地毫秒
'creationtime': (element['creationtime'] ?? 0) * 1000, 'creationtime': (element['creationtime'] ?? 0) * 1000,
}; };
Map<String, Object?> meetingDetailsData = { final exists = await txn.query('meeting',
'meetingid': element['id'], columns: ['id'], where: 'id = ?', whereArgs: [id], limit: 1);
'address': element['address'] ?? '', if (exists.isNotEmpty) {
'personnel': element['personnel'] ?? '', // 不碰 filepath(本机录音的路径只有本机知道)
'taskid': element['taskid'] ?? '', await txn.update('meeting', meeting, where: 'id = ?', whereArgs: [id]);
'tasktype': element['state'] ?? 0, // 状态在两张表各存一份,以 meeting 表为准,details 跟着走(详情页读的是这份)
'overview': element['overview'] ?? '', await txn.update('meetingdetails', {'tasktype': state},
'summary': element['summary'] ?? '', where: 'meetingid = ?', whereArgs: [id]);
'mindmap': '',
};
if (existList.isNotEmpty) {
await txn.update(
'meeting',
meetingData,
where: 'id = ?',
whereArgs: [element['id']],
);
await txn.update(
'meetingdetails',
meetingDetailsData,
where: 'meetingid = ?',
whereArgs: [element['id']],
);
} else { } else {
await txn.insert('meeting', { await txn.insert('meeting', {...meeting, 'filepath': ''});
...meetingData, await txn.insert('meetingdetails', {
'filepath': '', 'meetingid': id,
'address': element['address'] ?? '',
'personnel': element['personnel'] ?? '',
'taskid': element['taskid'] ?? '',
'tasktype': state,
'overview': '',
'summary': '',
'mindmap': '',
}); });
await txn.insert('meetingdetails', meetingDetailsData);
}
if (element['translate'].isNotEmpty) {
List speakerList = jsonDecode(element['translate']);
await txn.delete(
'meetingspeaker',
where: 'meetingid = ?',
whereArgs: [element['id']],
);
final batch = txn.batch();
for (var item in speakerList) {
batch.insert('meetingspeaker', item);
}
await batch.commit();
} }
} }
}); });
meetingTaskService.startTask(taskList);
await _removeLocalOrphans(serverIds);
if (taskList.isNotEmpty && Get.isRegistered<MeetingTaskService>()) {
Get.find<MeetingTaskService>().startTask(taskList);
}
Logger.i(_tag, '列表已对齐:服务端 ${records.length} 条,进行中 ${taskList.length} 条');
}
/// 删掉「本地有、服务端没有」的记录:连本地音频、上传队列、轮询任务一起清。
static Future<void> _removeLocalOrphans(Set<int> serverIds) async {
final db = await DatabaseHelper().database;
final local = await db.query('meeting', columns: ['id', 'filepath']);
final orphans = local.where((r) => !serverIds.contains(r['id'] as int));
for (final row in orphans) {
final int id = row['id'] as int;
final String path = (row['filepath'] as String?) ?? '';
await db.transaction((txn) async {
await txn.delete('meeting', where: 'id = ?', whereArgs: [id]);
await txn.delete('meetingdetails', where: 'meetingid = ?', whereArgs: [id]);
await txn.delete('meetingspeaker', where: 'meetingid = ?', whereArgs: [id]);
});
if (path.isNotEmpty) {
try {
final f = File(path);
if (await f.exists()) await f.delete();
} catch (e) {
Logger.w(_tag, '删除孤儿录音文件失败 $path: $e');
}
}
if (Get.isRegistered<MeetingUploadService>()) {
Get.find<MeetingUploadService>().removeUpload(id, isDeleteTask: true);
}
if (Get.isRegistered<MeetingTaskService>()) {
Get.find<MeetingTaskService>().removeLocalTask(id);
}
Logger.i(_tag, '服务端已不存在,删除本地记录 id=$id');
}
} }
} }

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

@ -3,7 +3,6 @@ import 'dart:async';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import '../../core/utils/synchrodata.dart';
import '../../modules/login/controllers/googleLogin.dart'; import '../../modules/login/controllers/googleLogin.dart';
import '../services/network/api.dart'; import '../services/network/api.dart';
import 'db/database_helper.dart'; import 'db/database_helper.dart';
@ -119,9 +118,6 @@ class AccountService extends GetxService {
final uploadService = Get.find<MeetingUploadService>(); final uploadService = Get.find<MeetingUploadService>();
uploadService.cancelUpload(); uploadService.cancelUpload();
// 上传会议记录操作记录
await Synchrodata.uploadOperationRecords();
// 关闭数据库连接 // 关闭数据库连接
await DatabaseHelper().close(); await DatabaseHelper().close();

56
apps/client/lib/data/services/deapsound_ai_service.dart

@ -1,19 +1,46 @@
import 'dart:convert'; import 'dart:convert';
import 'package:flutter/foundation.dart' show visibleForTesting;
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import '../models/appconfig.dart'; import '../models/appconfig.dart';
import 'ai_service.dart'; import 'ai_service.dart';
/// 语音纪要「Ask AI」用的文本大模型(OpenAI 兼容接口,客户端直连)。
///
/// 2026-09-17 起只读后台「第三方服务配置」里的 **`llm_qwen`**(通义千问),
/// 与服务端会议总结用的是同一家(服务端走 `llmv_qwen`)。火山(`llm_doubao`)
/// 已从后台删除,这里不再兜底、也不再退回 `OPENAI_*` 那几个老 env 键。
class DeapsoundAIService implements AiService { class DeapsoundAIService implements AiService {
static const String svcId = 'llm_qwen';
late final String baseUrl; late final String baseUrl;
late final String apiKey; late final String apiKey;
late final String model; late final String model;
DeapsoundAIService() { DeapsoundAIService() {
apiKey = AppConfig.cred('llm_doubao', 'api_key', 'OPENAI_API_KEY') ?? ''; apiKey = AppConfig.cred(svcId, 'api_key', '') ?? '';
baseUrl = AppConfig.cred('llm_doubao', 'base_url', 'OPENAI_BASE_URL') ?? ''; baseUrl = AppConfig.cred(svcId, 'base_url', '') ?? '';
model = AppConfig.cred('llm_doubao', 'model', 'OPENAI_MODEL') ?? ''; model = AppConfig.cred(svcId, 'model', '') ?? '';
}
/// 测试用:不读 AppConfig,只为验证 [chatUrl] 的拼接
@visibleForTesting
DeapsoundAIService.debugWithBaseUrl(String base)
: baseUrl = base,
apiKey = '',
model = '';
/// 后台 `llm_qwen.base_url` 填的是不带路径的根地址
/// (`https://dashscope.aliyuncs.com/compatible-mode/v1`),这里补上
/// `/chat/completions`;已经带了的原样用。
Uri get chatUrl {
var base = baseUrl.trim();
while (base.endsWith('/')) {
base = base.substring(0, base.length - 1);
}
if (!base.endsWith('/chat/completions')) base = '$base/chat/completions';
return Uri.parse(base);
} }
@override @override
@ -22,7 +49,7 @@ class DeapsoundAIService implements AiService {
String? systemPrompt, String? systemPrompt,
String? userProperties, String? userProperties,
}) async { }) async {
final url = Uri.parse(baseUrl); final url = chatUrl;
final headers = { final headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -59,7 +86,7 @@ class DeapsoundAIService implements AiService {
String? userProperties, String? userProperties,
}) async* { }) async* {
final client = http.Client(); final client = http.Client();
final url = Uri.parse(baseUrl); final url = chatUrl;
final headers = { final headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
@ -72,6 +99,13 @@ class DeapsoundAIService implements AiService {
"stream": true, "stream": true,
}; };
// ⚠️ 凭据来自 user_getappconfig_v3 的 llm_qwen;没拿到时 baseUrl 是空串,
// Uri.parse('') 能过、client.send 才抛 "No host specified"。这里先给一句能看懂的。
if (baseUrl.isEmpty || apiKey.isEmpty || model.isEmpty) {
throw Exception(
'AI 服务未配置($svcId: base_url/api_key/model 缺失),请检查后台「第三方服务配置」');
}
try { try {
final request = http.Request('POST', url) final request = http.Request('POST', url)
..headers.addAll(headers) ..headers.addAll(headers)
@ -79,7 +113,14 @@ class DeapsoundAIService implements AiService {
final response = await client.send(request); final response = await client.send(request);
if (response.statusCode != 200) { if (response.statusCode != 200) {
throw Exception('Unknown error'); // 把服务商的状态码和错误体带出去:401/403 是 key 不对、400 多半是 model 不存在,
// 全压成 'Unknown error' 就只能靠猜。
String body = '';
try {
body = await response.stream.bytesToString();
} catch (_) {}
if (body.length > 300) body = body.substring(0, 300);
throw Exception('HTTP ${response.statusCode} $body');
} }
await for (final chunk in response.stream await for (final chunk in response.stream
@ -101,7 +142,8 @@ class DeapsoundAIService implements AiService {
} }
} }
} catch (e) { } catch (e) {
throw Exception('Unknown error'); // 原样抛出(上面 HTTP 分支已经带了状态码),不要再包一层 'Unknown error'
rethrow;
} finally { } finally {
client.close(); client.close();
} }

31
apps/client/lib/data/services/device_ai_session_service.dart

@ -112,6 +112,16 @@ class DeviceAiSessionService extends GetxService {
/// duplex 模式下服务端 VAD 把它当成用户插话。没有 AEC 就只能做半双工。 /// duplex 模式下服务端 VAD 把它当成用户插话。没有 AEC 就只能做半双工。
bool _uplinkGated = false; bool _uplinkGated = false;
Timer? _gateTimer; Timer? _gateTimer;
/// 门控的兜底计时:开门后无论如何在这个时限内解除。
///
/// ⚠️ 原先门控**只**在 respondingEnded → 播完估算 → _gateTail 这一条链上解除。
/// 任何一轮 AI 出了声却没等到 respondingEnded(非致命 error、WS 抖一下、纯指令帧
/// 收尾),门就永远开着:上行从此全是静音(_onMicPcm 里替换成零),空闲计时也不再
/// 重新武装(_touchActivity 见 _uplinkGated 直接 return),会话活着但是聋的 ——
/// 就是「对着耳机说话,AI 再也不回」。
Timer? _gateSafety;
static const Duration _gateMax = Duration(seconds: 90);
bool _tearingDown = false; bool _tearingDown = false;
static const Duration _gateTail = Duration(milliseconds: 500); static const Duration _gateTail = Duration(milliseconds: 500);
static const Duration _idleTimeout = Duration(seconds: 5); static const Duration _idleTimeout = Duration(seconds: 5);
@ -363,7 +373,14 @@ class DeviceAiSessionService extends GetxService {
fromEarphone: true)); fromEarphone: true));
_store.persist(); _store.persist();
} }
if (e.fatal) _teardown(notifyDevice: true); if (e.fatal) {
_teardown(notifyDevice: true);
} else {
// 非致命错误这一轮不会再有 respondingEnded 了,门必须在这里关,
// 否则上行永远是静音(见 _gateSafety 的说明)。
_cancelPlayoutTimer();
_closeGate();
}
break; break;
case BailianEventType.stateChanged: case BailianEventType.stateChanged:
@ -472,6 +489,14 @@ class DeviceAiSessionService extends GetxService {
void _openGate() { void _openGate() {
_gateTimer?.cancel(); _gateTimer?.cancel();
_gateTimer = null; _gateTimer = null;
_gateSafety?.cancel();
_gateSafety = Timer(_gateMax, () {
if (!_uplinkGated) return;
Logger.w(_tag, '门控开了 ${_gateMax.inSeconds}s 还没等到播完信号,强制解除');
_cancelPlayoutTimer();
_svc.notifyLocalRespondingEnded();
_closeGate();
});
if (_uplinkGated) return; if (_uplinkGated) return;
_uplinkGated = true; _uplinkGated = true;
_idleTimer?.cancel(); _idleTimer?.cancel();
@ -482,6 +507,8 @@ class DeviceAiSessionService extends GetxService {
void _closeGate() { void _closeGate() {
_gateTimer?.cancel(); _gateTimer?.cancel();
_gateTimer = null; _gateTimer = null;
_gateSafety?.cancel();
_gateSafety = null;
if (!_uplinkGated) return; if (!_uplinkGated) return;
_uplinkGated = false; _uplinkGated = false;
Logger.i(_tag, '上行门控解除'); Logger.i(_tag, '上行门控解除');
@ -573,6 +600,8 @@ class DeviceAiSessionService extends GetxService {
_stopAckTimer = null; _stopAckTimer = null;
_idleTimer?.cancel(); _idleTimer?.cancel();
_idleTimer = null; _idleTimer = null;
_gateSafety?.cancel();
_gateSafety = null;
_cancelPlayoutTimer(); _cancelPlayoutTimer();
_micAccum.clear(); _micAccum.clear();

29
apps/client/lib/data/services/device_call_recording_service.dart

@ -40,7 +40,6 @@ class DeviceCallRecordingService extends GetxService {
StreamSubscription<Uint8List>? _peerSub; StreamSubscription<Uint8List>? _peerSub;
/// 暂存的对端帧,等本端帧到齐后一起交织写盘 /// 暂存的对端帧,等本端帧到齐后一起交织写盘
Uint8List? _pendingPeer;
int _frameCount = 0; int _frameCount = 0;
Timer? _silenceCheck; Timer? _silenceCheck;
WavWriter? _wav; WavWriter? _wav;
@ -135,7 +134,7 @@ class DeviceCallRecordingService extends GetxService {
_path = '${dir.path}/call_$ts.wav'; _path = '${dir.path}/call_$ts.wav';
_wav = await WavWriter.open(_path!, channels: 2); _wav = await WavWriter.open(_path!, channels: 2);
_pendingPeer = null; _pairer.clear();
_frameCount = 0; _frameCount = 0;
_peer = await session.openMic(route: AudioRoute.callPeer); _peer = await session.openMic(route: AudioRoute.callPeer);
_local = await session.openMic(route: AudioRoute.callLocal); _local = await session.openMic(route: AudioRoute.callLocal);
@ -249,10 +248,19 @@ class DeviceCallRecordingService extends GetxService {
_path = null; _path = null;
} }
/// 两路各自排队、按字节对齐后成对写入(左=本端麦克风,右=对端)。
///
/// ⚠️ 原先是「只留最后一帧对端、本端来一帧配一帧」:对端连来两帧就丢一帧,
/// 本端来了对端没到就整帧扔掉。两路在 BLE 上是同一个帧里交替的 40 字节块,
/// 到 Dart 侧却经过各自的 broadcast stream,到达顺序并不保证严格交替 ——
/// 结果右声道(对端)被断续抽掉,转写出来只剩自己这一方。
final StereoPairer _pairer = StereoPairer();
void _onPeer(Uint8List pcm) { void _onPeer(Uint8List pcm) {
if (_wav == null || pcm.isEmpty) return; if (_wav == null || pcm.isEmpty) return;
_frameCount++; _frameCount++;
_pendingPeer = pcm; _pairer.pushRight(pcm);
_flushPairs();
} }
void _onLocal(Uint8List pcm) { void _onLocal(Uint8List pcm) {
@ -261,12 +269,15 @@ class DeviceCallRecordingService extends GetxService {
if (_frameCount <= 3 || _frameCount % 200 == 0) { if (_frameCount <= 3 || _frameCount % 200 == 0) {
Logger.d(_tag, '收到通话音频帧 #$_frameCount len=${pcm.length}'); Logger.d(_tag, '收到通话音频帧 #$_frameCount len=${pcm.length}');
} }
final peer = _pendingPeer; _pairer.pushLeft(pcm);
_pendingPeer = null; _flushPairs();
if (peer == null) return; }
// 左声道=本端麦克风,右声道=对端声音
_wav!.write(WavWriter.interleave(pcm, peer), onWritten: () { void _flushPairs() {
level.value = WavWriter.rmsLevel(pcm); final pair = _pairer.take();
if (pair == null) return;
_wav!.write(WavWriter.interleave(pair.left, pair.right), onWritten: () {
level.value = WavWriter.rmsLevel(pair.left);
}); });
} }

22
apps/client/lib/data/services/device_stereo_recorder.dart

@ -27,7 +27,8 @@ class DeviceStereoRecorder {
StreamSubscription? _localSub; StreamSubscription? _localSub;
StreamSubscription? _peerSub; StreamSubscription? _peerSub;
WavWriter? _wav; WavWriter? _wav;
Uint8List? _pendingPeer; /// 两路各自排队、按字节对齐后成对写入,见 [StereoPairer]
final StereoPairer _pairer = StereoPairer();
bool _paused = false; bool _paused = false;
bool get isRunning => _wav != null; bool get isRunning => _wav != null;
@ -43,21 +44,19 @@ class DeviceStereoRecorder {
try { try {
_session = session; _session = session;
_wav = await WavWriter.open(filePath, channels: 2); _wav = await WavWriter.open(filePath, channels: 2);
_pendingPeer = null; _pairer.clear();
_paused = false; _paused = false;
_peer = await session.openMic(route: AudioRoute.callPeer); _peer = await session.openMic(route: AudioRoute.callPeer);
_local = await session.openMic(route: AudioRoute.callLocal); _local = await session.openMic(route: AudioRoute.callLocal);
_peerSub = _peer!.pcm.listen((pcm) { _peerSub = _peer!.pcm.listen((pcm) {
if (_paused || pcm.isEmpty) return; if (_paused || pcm.isEmpty) return;
_pendingPeer = pcm; _pairer.pushRight(pcm);
_flushPairs();
}); });
_localSub = _local!.pcm.listen((pcm) { _localSub = _local!.pcm.listen((pcm) {
if (_paused || pcm.isEmpty) return; if (_paused || pcm.isEmpty) return;
final peer = _pendingPeer; _pairer.pushLeft(pcm);
_pendingPeer = null; _flushPairs();
if (peer == null) return;
_wav?.write(WavWriter.interleave(pcm, peer),
onWritten: () => onWavePcm?.call(pcm));
}); });
await session.invokeFeature(DeviceFeatures.callStart); await session.invokeFeature(DeviceFeatures.callStart);
return true; return true;
@ -68,6 +67,13 @@ class DeviceStereoRecorder {
} }
} }
void _flushPairs() {
final pair = _pairer.take();
if (pair == null) return;
_wav?.write(WavWriter.interleave(pair.left, pair.right),
onWritten: () => onWavePcm?.call(pair.left));
}
void pause() => _paused = true; void pause() => _paused = true;
void resume() => _paused = false; void resume() => _paused = false;

45
apps/client/lib/data/services/meeting/meeting_task_service.dart

@ -99,7 +99,37 @@ class MeetingTaskService extends GetxService {
} }
} }
Future<void> submitTask( /// 智能总结模板的 tid(后台「Autopilot智能总结」,41 种语言各有一版,按 tid 反查)。
static const String smartTemplateTid = 'General-Autopilot';
/// 记录建的时候已经带了转写(翻译归档,`rtype=VOICETRANSLAT`):
/// 服务端跳过识别,直接用智能模板总结。源=目标语言,连翻译那一段也跳过。
///
/// 模板只有 App 的 41 个界面语言有版本,翻译语种(粤语、繁中…)超出这个集合时
/// 退到英文——否则服务端回 TemplateNotFound,记录停在「未处理」且没人知道为什么。
Future<bool> startSummaryForTranscript(int id, String language) async {
final lang = _templateLanguageFor(language);
return submitTask(id, lang, lang, false, 0, smartTemplateTid);
}
/// 模板语言集合 == App 界面语言集合(服务端按 41 语种各建一份公共模板)。
String _templateLanguageFor(String code) {
final key = code.trim().replaceAll('-', '_');
if (key.isEmpty) return 'en-US';
if (Get.translations.containsKey(key)) return code.trim();
// zh-HK / zh-TW 这种带地区的:先按语言前缀找同语系的界面语言
final prefix = key.split('_').first;
for (final k in Get.translations.keys) {
if (k.split('_').first == prefix) return k.replaceAll('_', '-');
}
return 'en-US';
}
/// 返回服务端是否受理。**没受理就不进轮询**:`AuthInterceptor` 在业务错误
/// (无会员 5302、模板不存在…)时把 data 置 null 后 resolve,原来这里不判、
/// 照样把任务塞进 _taskList 并把本地状态写成 1,列表就永远显示「等待转写中」,
/// 而服务端那条记录其实还停在 0。
Future<bool> submitTask(
int id, int id,
String formlanguage, String formlanguage,
String tolanguage, String tolanguage,
@ -107,7 +137,7 @@ class MeetingTaskService extends GetxService {
int templateid, int templateid,
String tid, String tid,
) async { ) async {
await Api.startTaskEchomeet({ final resp = await Api.startTaskEchomeet({
'id': id, 'id': id,
'formlanguage': formlanguage, 'formlanguage': formlanguage,
'tolanguage': tolanguage, 'tolanguage': tolanguage,
@ -115,6 +145,11 @@ class MeetingTaskService extends GetxService {
'templateid': templateid, 'templateid': templateid,
'tid': tid, 'tid': tid,
}); });
if (resp == null) {
Logger.w('MeetingTask',
'任务未被服务端受理 id=$id lang=$formlanguage→$tolanguage tid=$tid,记录保持未处理');
return false;
}
final seconds = await _getMeetingSeconds(id); final seconds = await _getMeetingSeconds(id);
_decrementMeetingIntegral(cost: seconds); _decrementMeetingIntegral(cost: seconds);
_taskList.add({ _taskList.add({
@ -124,6 +159,7 @@ class MeetingTaskService extends GetxService {
await SqfliteApi.setMeetingTaskType(id, 1); await SqfliteApi.setMeetingTaskType(id, 1);
updateListState.value++; updateListState.value++;
_executeTask(); _executeTask();
return true;
} }
Future<void> refreshTask(int id, int templateid, String tid, String tolanguage) async { Future<void> refreshTask(int id, int templateid, String tid, String tolanguage) async {
@ -195,6 +231,11 @@ class MeetingTaskService extends GetxService {
}); });
} }
/// 只把任务从轮询队列摘掉,不打删除接口——记录在服务端已经没了(别的设备删的)。
void removeLocalTask(int id) {
_taskList.removeWhere((element) => element['id'] == id);
}
void startTask(List data) { void startTask(List data) {
_taskList = data; _taskList = data;
_executeTask(); _executeTask();

29
apps/client/lib/data/services/memory_service.dart

@ -1,5 +1,7 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'package:flutter_timezone/flutter_timezone.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
@ -42,6 +44,8 @@ class MemoryService extends GetxService {
void onInit() { void onInit() {
super.onInit(); super.onInit();
_restore(); _restore();
// 时区只问一次系统,不 await:拿不到就按 _tz() 的退路走,不该拖住启动。
unawaited(warmTz());
} }
// ===================== 本地缓存 ===================== // ===================== 本地缓存 =====================
@ -96,9 +100,30 @@ class MemoryService extends GetxService {
'${d.month.toString().padLeft(2, '0')}-' '${d.month.toString().padLeft(2, '0')}-'
'${d.day.toString().padLeft(2, '0')}'; '${d.day.toString().padLeft(2, '0')}';
/// 本机时区名。拿不到就传空串——服务端会退到它自己的本地时区, /// 本机时区的 IANA 名(`Asia/Shanghai`)。启动时问一次系统,进程内缓存。
/// **不会**退成 UTC(那会让所有时刻静默偏移 8 小时)。 static String? _ianaTz;
/// 问系统要 IANA 时区名。失败不抛,留给 [_tz] 退回缩写。
static Future<void> warmTz() async {
try {
final name = (await FlutterTimezone.getLocalTimezone()).trim();
if (name.isNotEmpty) _ianaTz = name;
} catch (e) {
Logger.w(_tag, '取本机 IANA 时区失败,将退回时区缩写: $e');
}
}
/// 上报给服务端的时区。
///
/// ⚠️ **必须是 IANA 名,不能是 `DateTime.now().timeZoneName`** —— 后者给的是
/// 缩写(国内是 `CST`),服务端 `time.LoadLocation("CST")` 解析不出来、**静默**
/// 退回容器时区。国内用户碰巧没事(容器就是 Asia/Shanghai),海外用户的提醒
/// 时刻会全部按北京时间算,且不报任何错。库里 tz 列至今只有空串和 `CST` 两种值。
///
/// 拿不到 IANA 名时仍退回缩写而不是空串:服务端认得 `CST` 并按上海解释,
/// 比什么都不给多一点信息。
String _tz() { String _tz() {
if (_ianaTz != null) return _ianaTz!;
try { try {
return DateTime.now().timeZoneName; return DateTime.now().timeZoneName;
} catch (_) { } catch (_) {

17
apps/client/lib/data/services/network/api.dart

@ -280,14 +280,6 @@ class Api {
); );
} }
// 上传会议助手操作记录
static putOperationRecord([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_putrecords',
params: params,
);
}
// 获取会议助手数据 // 获取会议助手数据
static getOperationAllRecord([params]) { static getOperationAllRecord([params]) {
@ -518,6 +510,15 @@ class Api {
); );
} }
//拉一条会议记录的整条数据(含 original/translate/summary)。列表接口只回骨架,详情页进页面时调这个
static getRecordEchomeet([params]) {
return DioManager().request(
NWMethod.post,
'/api/home/echomeet_getrecord',
params: params,
);
}
static readRecordEchomeet([params]) { static readRecordEchomeet([params]) {
return DioManager().request( return DioManager().request(
NWMethod.post, NWMethod.post,

55
apps/client/lib/data/services/recording_archive.dart

@ -24,6 +24,10 @@ import 'network/api.dart';
/// - `call_*` 通话录音 /// - `call_*` 通话录音
/// - `trans_call_*` 通话翻译 /// - `trans_call_*` 通话翻译
/// - `trans_holder_*` 同声翻译 / 面对面翻译 /// - `trans_holder_*` 同声翻译 / 面对面翻译
///
/// 翻译那两条还会带上**现成的转写**([archive] 的 `transcript`):
/// 翻译时 ASR 已经逐句出过整句,再让纪要把同一段音频识别一遍既慢又白花算力,
/// 所以纪要那边只做总结(2026-09-18 产品口径)。
class RecordingArchive { class RecordingArchive {
RecordingArchive._(); RecordingArchive._();
@ -34,12 +38,21 @@ class RecordingArchive {
/// [createdAt] 这段录音**实际录制**的时刻。边录边存的四条链路不用传(就是现在), /// [createdAt] 这段录音**实际录制**的时刻。边录边存的四条链路不用传(就是现在),
/// 但从设备存储同步进来的历史录音必须传 —— 否则列表上一条 8 月 15 日录的东西 /// 但从设备存储同步进来的历史录音必须传 —— 否则列表上一条 8 月 15 日录的东西
/// 会显示成「刚刚」,用户按时间根本找不到它。 /// 会显示成「刚刚」,用户按时间根本找不到它。
///
/// [transcript] 这段音频**已经有的转写**(通话/同声/面对面翻译边说边出的整句),
/// 形状见 `buildMeetingTranscript`。给了就以 `rtype=VOICETRANSLAT` 建记录:
/// 服务端 `echomeet_starttask` 对这种记录**跳过识别**、只跑总结,
/// 本地也直接写进 `meetingspeaker`,打开纪要立刻能看到原文。
/// [summaryLanguage] 非空且转写非空时,归档完立刻用智能模板起总结任务,
/// 用户不用再进纪要点「生成」。
static Future<int?> archive({ static Future<int?> archive({
required String path, required String path,
required int seconds, required int seconds,
String tag = 'RecordingArchive', String tag = 'RecordingArchive',
bool notify = true, bool notify = true,
DateTime? createdAt, DateTime? createdAt,
List<Map<String, Object?>>? transcript,
String summaryLanguage = '',
}) async { }) async {
try { try {
final file = File(path); final file = File(path);
@ -69,8 +82,11 @@ class RecordingArchive {
} }
final stat = await File(newPath).stat(); final stat = await File(newPath).stat();
final hasTranscript = transcript != null && transcript.isNotEmpty;
final data = <String, Object?>{ final data = <String, Object?>{
'title': fileName, 'title': fileName,
// 本地 type 一律 LOCAL:它只决定列表分组和上传桶(LocalAudio),
// 「有没有现成转写」是服务端 rtype 的事,两者别混。
'type': 'LOCAL', 'type': 'LOCAL',
'seconds': seconds, 'seconds': seconds,
'filepath': newPath, 'filepath': newPath,
@ -79,8 +95,10 @@ class RecordingArchive {
'creationtime': resolveRecordingCreationTime(createdAt), 'creationtime': resolveRecordingCreationTime(createdAt),
}; };
final resp = await Api.addRecordEchomeet({ final resp = await Api.addRecordEchomeet({
'rtype': 'LOCAL', // VOICETRANSLAT 是服务端 StartTask 里「转写已存在,只翻译+总结」的分支
'rtype': hasTranscript ? 'VOICETRANSLAT' : 'LOCAL',
'size': stat.size, 'size': stat.size,
if (hasTranscript) 'original': transcript,
...data, ...data,
}); });
final meetingId = resp?['record']?['id']; final meetingId = resp?['record']?['id'];
@ -89,6 +107,20 @@ class RecordingArchive {
return null; return null;
} }
await SqfliteApi.insertMeeting({'id': meetingId, ...data}); await SqfliteApi.insertMeeting({'id': meetingId, ...data});
if (hasTranscript) {
// 服务端确认过 id 才落本地转写,键与 MeetingTaskService._queryTask
// 从 translate 解出来的行完全一致——总结完成那一拍它会删掉重插一遍。
await SqfliteApi.insertMeetingSpeaker([
for (final seg in transcript)
{
'meetingid': meetingId,
'content': seg['content'],
'starttime': seg['starttime'],
'endtime': seg['endtime'],
'speaker': seg['speaker'] ?? '',
}
]);
}
if (Get.isRegistered<MeetingUploadService>()) { if (Get.isRegistered<MeetingUploadService>()) {
Get.find<MeetingUploadService>() Get.find<MeetingUploadService>()
.addUpload(meetingId, newPath, 'LocalAudio'); .addUpload(meetingId, newPath, 'LocalAudio');
@ -99,13 +131,30 @@ class RecordingArchive {
if (Get.isRegistered<MeetingTaskService>()) { if (Get.isRegistered<MeetingTaskService>()) {
Get.find<MeetingTaskService>().updateListState.value++; Get.find<MeetingTaskService>().updateListState.value++;
} }
Logger.i(tag, '已归档到语音记录助手 id=$meetingId title=$fileName'); Logger.i(tag,
'已归档到语音记录助手 id=$meetingId title=$fileName 转写段=${transcript?.length ?? 0}');
if (notify) { if (notify) {
Get.snackbar('recordingSavedTitle'.tr, 'recordingSavedDesc'.tr, Get.snackbar('recordingSavedTitle'.tr, 'recordingSavedDesc'.tr,
snackPosition: SnackPosition.TOP, snackPosition: SnackPosition.TOP,
duration: const Duration(seconds: 2)); duration: const Duration(seconds: 2));
} }
return meetingId is int ? meetingId : int.tryParse('$meetingId'); final int? id = meetingId is int ? meetingId : int.tryParse('$meetingId');
// 总结放在列表已经刷新之后:任务一提交状态就会变「等待总结」,
// 用户在纪要首页看到的是「条目出现 → 转成等待态」,而不是凭空多出一条等待中的。
// 失败不影响归档结果(记录和转写都在),用户仍可进纪要手动生成。
if (id != null &&
hasTranscript &&
summaryLanguage.isNotEmpty &&
Get.isRegistered<MeetingTaskService>()) {
try {
final ok = await Get.find<MeetingTaskService>()
.startSummaryForTranscript(id, summaryLanguage);
Logger.i(tag, '自动总结${ok ? '已提交' : '未能提交'} id=$id lang=$summaryLanguage');
} catch (e) {
Logger.w(tag, '自动总结提交失败(记录已保留) id=$id: $e');
}
}
return id;
} catch (e) { } catch (e) {
Logger.e(tag, '归档失败: $e'); Logger.e(tag, '归档失败: $e');
return null; return null;

51
apps/client/lib/data/services/wav_writer.dart

@ -134,3 +134,54 @@ class WavWriter {
return out; return out;
} }
} }
/// 一对已对齐的左右声道 PCM(等长、偶数字节)。
class StereoPair {
StereoPair(this.left, this.right);
final Uint8List left;
final Uint8List right;
}
/// 把两路各自到达的 PCM 按字节对齐成对:两路都排队,谁都不丢,
/// 每次取出两边都已有的那一段(长度取较短者、对齐到整样本)。
///
/// 两路只在**开头**可能不齐(先到的一路先攒着),之后按各自到达速率自然对齐;
/// 一路长时间缺席时另一路会一直攒着(上限 [maxPending],超过就丢最旧的,
/// 避免对端根本没数据时本端无限堆积)。
class StereoPairer {
StereoPairer({this.maxPending = 16000 * 2 * 10}); // 10 秒 @16k/16bit
final int maxPending;
final BytesBuilder _left = BytesBuilder(copy: false);
final BytesBuilder _right = BytesBuilder(copy: false);
void pushLeft(Uint8List pcm) => _push(_left, pcm);
void pushRight(Uint8List pcm) => _push(_right, pcm);
void _push(BytesBuilder b, Uint8List pcm) {
b.add(pcm);
if (b.length > maxPending) {
final all = b.takeBytes();
b.add(Uint8List.sublistView(all, all.length - maxPending));
}
}
/// 两边都有数据才返回;否则 null。
StereoPair? take() {
final n = (_left.length < _right.length ? _left.length : _right.length) & ~1;
if (n <= 0) return null;
final l = _left.takeBytes();
final r = _right.takeBytes();
final pair = StereoPair(
Uint8List.sublistView(l, 0, n), Uint8List.sublistView(r, 0, n));
if (l.length > n) _left.add(Uint8List.sublistView(l, n));
if (r.length > n) _right.add(Uint8List.sublistView(r, n));
return pair;
}
void clear() {
_left.clear();
_right.clear();
}
}

93
apps/client/lib/data/utils/emai_audio_session.dart

@ -34,7 +34,15 @@ class EmaiAudioSession {
static const String _tag = 'EmaiAudioSession'; static const String _tag = 'EmaiAudioSession';
/// 当前生效的档位;换档要重新 configure,不能像原来那样只配一次。 /// 最近一次配置的档位,只用于日志。
///
/// ⚠️ 不能拿它当「已配好就跳过」的缓存:这个值是 Dart 进程内的,而 AVAudioSession
/// 是全进程共享的——翻译页 / 支架录音 / 通话翻译的原生代码都会直接改类别
/// (`.record` / `.playback`…),改完这里毫不知情。缓存命中就只 setActive,
/// 会话却停在一个**没有输入**的类别上,接下来 record 的 installTap 触发
/// AVAudioEngine 的 ObjC 断言,Dart 的 try/catch 接不住,进程直接崩。
/// 「耳机对话完切回手机就崩」就是这条。setCategory 传相同值在 iOS 上是空操作,
/// 每次都配没有代价。
static EmaiAudioProfile? _configured; static EmaiAudioProfile? _configured;
static bool get _apply => !kIsWeb && (Platform.isIOS || Platform.isAndroid); static bool get _apply => !kIsWeb && (Platform.isIOS || Platform.isAndroid);
@ -54,20 +62,67 @@ class EmaiAudioSession {
/// ///
/// phoneMic 那一档保留 defaultToSpeaker(去掉会退回听筒,那是更糟的退化), /// phoneMic 那一档保留 defaultToSpeaker(去掉会退回听筒,那是更糟的退化),
/// 只补上 allowBluetoothA2dp。 /// 只补上 allowBluetoothA2dp。
static Future<void> ensure( ///
/// 返回值:**输出此刻是不是走蓝牙 A2DP**(只有 iOS 的 phoneMic 档会为 true)。
/// 调用方据此决定要不要开 voice-processing(回声消除)——见下面的说明。
///
/// ⚠️ **iOS 上 A2DP 输出和 voice-processing 是二选一的**(2026-09-18 定性):
/// 手机麦档位原来固定 `.voiceChat` + record 的 echoCancel,而 iOS 一旦进入
/// 语音处理模式就**只认 HFP / 听筒 / 扬声器**,A2DP 设备直接从可选输出里消失。
/// 于是耳机连着、用手机跟 EMAI 对话(哪怕是打字),回复一响手机就从耳机
/// 切到扬声器。产品口径是「手机触发的对话走 A2DP」,所以:
/// - 先按 `.default` 模式 + allowBluetoothA2dp 配一次,看当前输出有没有 A2DP;
/// - 有 → 保持 default 模式,**不开** voice-processing(回复从耳机出,手机麦
/// 离扬声器远,回声本来就小);
/// - 没有 → 退回 `.voiceChat`,让 record 开回声消除(外放场景必须有 AEC,
/// 否则连续对话里助手一开口就被自己的声音打断)。
static Future<bool> ensure(
{EmaiAudioProfile profile = EmaiAudioProfile.phoneMic}) async { {EmaiAudioProfile profile = EmaiAudioProfile.phoneMic}) async {
if (!_apply) return; if (!_apply) return false;
try { try {
final session = await AudioSession.instance; final session = await AudioSession.instance;
if (_configured != profile) { if (_configured != profile) {
_configured = profile; Logger.i(_tag, '音频会话档位 ${_configured?.name ?? '-'} → ${profile.name}');
await session.configure(profile == EmaiAudioProfile.playbackOnly
? _playbackOnlyConfig()
: _phoneMicConfig());
} }
_configured = profile;
if (profile == EmaiAudioProfile.playbackOnly) {
await session.configure(_playbackOnlyConfig());
await session.setActive(true);
return false;
}
// phoneMic:先按「允许 A2DP」的形状配,再看系统实际把输出路到了哪
await session.configure(_phoneMicConfig(a2dpOutput: true));
await session.setActive(true); await session.setActive(true);
final a2dp = await _outputIsA2dp();
if (a2dp) {
Logger.i(_tag, '输出走蓝牙 A2DP,手机麦档位保持 default 模式(不开语音处理)');
return true;
}
if (Platform.isIOS) {
// 没有 A2DP 设备:退回语音处理模式,外放要靠它做回声消除
await session.configure(_phoneMicConfig(a2dpOutput: false));
await session.setActive(true);
}
return false;
} catch (e) { } catch (e) {
Logger.w(_tag, '音频会话配置失败(继续尝试): $e'); Logger.w(_tag, '音频会话配置失败(继续尝试): $e');
return false;
}
}
/// 当前会话的输出里有没有蓝牙 A2DP 口。只有 iOS 有这个查询;Android 的
/// 媒体流本来就由系统自动路到 A2DP,不需要客户端判。
static Future<bool> _outputIsA2dp() async {
if (!Platform.isIOS) return false;
try {
final route = await AVAudioSession().currentRoute;
final ports = route.outputs.map((o) => o.portType).toList();
final a2dp = ports.contains(AVAudioSessionPort.bluetoothA2dp);
Logger.i(_tag, '当前输出: ${ports.map((p) => p.name).join(',')} a2dp=$a2dp');
return a2dp;
} catch (e) {
Logger.w(_tag, '查询输出路由失败(按无 A2DP 处理): $e');
return false;
} }
} }
@ -88,21 +143,27 @@ class EmaiAudioSession {
androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient, androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient,
); );
static AudioSessionConfiguration _phoneMicConfig() => static AudioSessionConfiguration _phoneMicConfig({required bool a2dpOutput}) =>
AudioSessionConfiguration( AudioSessionConfiguration(
// iOS 必须 playAndRecord:上行要录音、下行要外放,缺一不可。 // iOS 必须 playAndRecord:上行要录音、下行要外放,缺一不可。
avAudioSessionCategory: AVAudioSessionCategory.playAndRecord, avAudioSessionCategory: AVAudioSessionCategory.playAndRecord,
// allowBluetooth 是 HFP(免提,双向窄带);allowBluetoothA2dp 才是高音质 // ⚠️ 这一档刻意**不给 allowBluetooth(HFP)**,只给 A2DP:
// 单向输出。两个都给,让系统按当前是否真的在录音去挑。 // 这是「用手机麦对话」的档位。给了 HFP,耳机还连着时 iOS 会在 setActive
// 之后异步把输入切到耳机的 HFP 麦(SCO 建链、采样率变 8k/16k),
// 而 record 恰好在这个窗口里读 inputFormat 再 installTap ——
// 格式对不上就是 `format.sampleRate == hwFormat.sampleRate` 的 ObjC 断言,
// 进程直接崩。只走 A2DP 的话输入固定在手机麦,路由不会中途翻转;
// 回复照样从 A2DP 耳机出来(耳机没配 A2DP 就走扬声器)。
avAudioSessionCategoryOptions: avAudioSessionCategoryOptions:
AVAudioSessionCategoryOptions.defaultToSpeaker | AVAudioSessionCategoryOptions.defaultToSpeaker |
AVAudioSessionCategoryOptions.allowBluetooth |
AVAudioSessionCategoryOptions.allowBluetoothA2dp, AVAudioSessionCategoryOptions.allowBluetoothA2dp,
// ⚠️ iOS 固定 voiceChat:record 开了 echoCancel,它会打开 // ⚠️ iOS 两种模式二选一(见 ensure 的说明):
// voice-processing,系统随之把 mode 改成 voiceChat 并一直持有 // - 走 A2DP 时必须是 default:voiceChat = 语音处理,A2DP 输出会被系统剔掉;
// (真机 dump 确认)。播放这边如果写回 default,就是两边互相改会话。 // - 不走 A2DP 时固定 voiceChat:record 开了 echoCancel 会打开
// defaultToSpeaker 在 voiceChat 下仍然走扬声器,实测不会被拽到听筒。 // voice-processing,系统随之把 mode 改成 voiceChat 并一直持有
avAudioSessionMode: Platform.isIOS // (真机 dump 确认)。播放这边如果写回 default,就是两边互相改会话。
// defaultToSpeaker 在 voiceChat 下仍然走扬声器,实测不会被拽到听筒。
avAudioSessionMode: Platform.isIOS && !a2dpOutput
? AVAudioSessionMode.voiceChat ? AVAudioSessionMode.voiceChat
: AVAudioSessionMode.defaultMode, : AVAudioSessionMode.defaultMode,
// ⚠️ Android 的 usage 千万别用 voiceCommunication:华为 // ⚠️ Android 的 usage 千万别用 voiceCommunication:华为

46
apps/client/lib/data/utils/mic_pcm_streamer.dart

@ -1,3 +1,6 @@
import 'dart:io' show Platform;
import 'package:flutter/services.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'dart:async'; import 'dart:async';
import 'dart:typed_data'; import 'dart:typed_data';
@ -59,20 +62,32 @@ class MicPcmStreamer {
try { try {
// ⚠️ 必须先把会话配好并激活再开麦:会话停在 soloAmbient 且未激活时, // ⚠️ 必须先把会话配好并激活再开麦:会话停在 soloAmbient 且未激活时,
// startStream 会成功返回、引擎也起得来,但输入 tap 一个 buffer 都不回调。 // startStream 会成功返回、引擎也起得来,但输入 tap 一个 buffer 都不回调。
await EmaiAudioSession.ensure(); // 返回值是「输出此刻走不走 A2DP」:走的话 iOS 不能开 voice-processing,
// 否则系统把 A2DP 从输出里剔掉、回复又回到扬声器(见 EmaiAudioSession.ensure)。
final a2dpOutput = await EmaiAudioSession.ensure();
final bool voiceProcessing = !(Platform.isIOS && a2dpOutput);
if (!await _recorder.hasPermission()) { if (!await _recorder.hasPermission()) {
Logger.w(_tag, '没有麦克风权限'); Logger.w(_tag, '没有麦克风权限');
return false; return false;
} }
// ⚠️ iOS:record 拿 inputNode.inputFormat 去 installTap,格式是 0Hz(会话刚
// 切类别 / 路由正在切换)时那是 ObjC 断言,这里的 try/catch 接不住,直接崩。
// 先问原生一声输入格式是否就绪,不就绪等一小会儿,还不行就报失败而不是崩。
if (!await _waitIosInputReady()) {
Logger.e(_tag, '输入音频格式无效(会话/路由切换中),本次不开麦');
return false;
}
final stream = await _recorder.startStream( final stream = await _recorder.startStream(
RecordConfig( RecordConfig(
encoder: AudioEncoder.pcm16bits, encoder: AudioEncoder.pcm16bits,
sampleRate: sampleRate, sampleRate: sampleRate,
numChannels: 1, numChannels: 1,
// 语音助手场景:开回声消除和降噪,否则外放播报会被自己的麦克风收回去, // 语音助手场景:开回声消除和降噪,否则外放播报会被自己的麦克风收回去,
// duplex 模式下会直接把服务端 VAD 打乱 // duplex 模式下会直接把服务端 VAD 打乱。
echoCancel: true, // ⚠️ iOS 上回复走 A2DP 耳机时**必须关**:这两项会开 voice-processing,
noiseSuppress: true, // 而语音处理模式下 A2DP 不可作输出。耳机里放的声音手机麦本来就收不到多少。
echoCancel: voiceProcessing,
noiseSuppress: voiceProcessing,
// ⚠️ iOS 上不让 record 碰 AVAudioSession(会话由 EmaiAudioSession 统一配)。 // ⚠️ iOS 上不让 record 碰 AVAudioSession(会话由 EmaiAudioSession 统一配)。
// 它自己那套是 `setCategory(.playAndRecord, options:)` → `setActive(true)`, // 它自己那套是 `setCategory(.playAndRecord, options:)` → `setActive(true)`,
// 而 setCategory 会把 mode 重置成 default;紧接着 echoCancel 触发的 // 而 setCategory 会把 mode 重置成 default;紧接着 echoCancel 触发的
@ -104,6 +119,29 @@ class MicPcmStreamer {
} }
} }
/// 见 [_start] 里的说明。非 iOS 直接放行;原生不认这个方法(老插件)也放行。
static const MethodChannel _iosProbe = MethodChannel('azure_speech/pcm_player');
Future<bool> _waitIosInputReady() async {
if (kIsWeb || !Platform.isIOS) return true;
for (var i = 0; i < 12; i++) {
try {
final r = await _iosProbe.invokeMethod<Map>('inputFormat');
final rate = (r?['sampleRate'] as num?)?.toDouble() ?? 0;
final ch = (r?['channels'] as num?)?.toInt() ?? 0;
if (rate > 0 && ch > 0) return true;
Logger.w(_tag, '输入格式未就绪 rate=$rate ch=$ch,等待重试 #${i + 1}');
} on MissingPluginException {
return true;
} catch (e) {
Logger.w(_tag, '探测输入格式失败(放行): $e');
return true;
}
await Future.delayed(const Duration(milliseconds: 50));
}
return false;
}
/// 最近一次收到原始音频块的时刻。用来发现「流无声死亡」—— /// 最近一次收到原始音频块的时刻。用来发现「流无声死亡」——
/// 麦克风即使没人说话也会持续出包,所以静默超过 [_silenceLimit] 一定是流断了。 /// 麦克风即使没人说话也会持续出包,所以静默超过 [_silenceLimit] 一定是流断了。
DateTime? _lastChunkAt; DateTime? _lastChunkAt;

24
apps/client/lib/modules/common/widgets/language_selector_bar.dart

@ -19,6 +19,7 @@ class LanguageSelectorBar extends StatelessWidget {
this.fontSizeSp = 15, this.fontSizeSp = 15,
this.sourceSuffix, this.sourceSuffix,
this.targetSuffix, this.targetSuffix,
this.swapIcon,
}) : super(key: key); }) : super(key: key);
/// 返回源语言显示文本(建议返回语言中文名或翻译键) /// 返回源语言显示文本(建议返回语言中文名或翻译键)
@ -48,6 +49,14 @@ class LanguageSelectorBar extends StatelessWidget {
/// 目标语言后缀(可选),用于追加图标等 /// 目标语言后缀(可选),用于追加图标等
final Widget? targetSuffix; final Widget? targetSuffix;
/// 中间那个按钮显示什么图标。默认是双向的「互换」图标。
///
/// 单向翻译的模式(同声翻译 / 多媒体翻译)传一个单向箭头进来,让用户一眼看出
/// 「只会从左边翻到右边」——双向图标在那里会让人以为反着说也能翻。
/// 做成回调而不是固定值,是为了能在 [Obx] 里读到响应式的当前模式。
/// 按钮本身仍然可点(换方向依旧有意义),只是图标语义变了。
final IconData Function()? swapIcon;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final isDarkMode = Get.isDarkMode; final isDarkMode = Get.isDarkMode;
@ -120,11 +129,16 @@ class LanguageSelectorBar extends StatelessWidget {
color: swapBg, color: swapBg,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( // ⚠️ 只有传了 swapIcon 才包 Obx:Obx 里一个响应式变量都没读到时
Icons.swap_horiz, // GetX 会直接抛「improper use of a GetX」,而图片/文本翻译两个页面
color: swapIconColor, // 本来就不传这个参数。
size: 20.sp, child: swapIcon == null
), ? Icon(Icons.swap_horiz, color: swapIconColor, size: 20.sp)
: Obx(() => Icon(
swapIcon!.call(),
color: swapIconColor,
size: 20.sp,
)),
), ),
), ),

32
apps/client/lib/modules/login/controllers/login_controller.dart

@ -7,6 +7,7 @@ import 'package:flutter/foundation.dart';
import 'package:logger/web.dart'; import 'package:logger/web.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter_dotenv/flutter_dotenv.dart';
import 'package:crypto/crypto.dart'; // 用于生成设备指纹 import 'package:crypto/crypto.dart'; // 用于生成设备指纹
import 'dart:convert'; import 'dart:convert';
import '../../../data/services/db/sqflite_api.dart'; import '../../../data/services/db/sqflite_api.dart';
@ -24,6 +25,7 @@ import '../../../core/utils/device_brand.dart';
import '../../../data/services/version_update_service.dart'; import '../../../data/services/version_update_service.dart';
import '../../../modules/common/widgets/privacy_policy_dialog.dart'; import '../../../modules/common/widgets/privacy_policy_dialog.dart';
import '../constants/country_codes.dart'; import '../constants/country_codes.dart';
import '../models/login_methods.dart';
class LoginController extends GetxController { class LoginController extends GetxController {
static LoginController get to => Get.find(); static LoginController get to => Get.find();
@ -33,6 +35,29 @@ class LoginController extends GetxController {
final _isGuestLoginVisible = false.obs; final _isGuestLoginVisible = false.obs;
bool get isGuestLoginVisible => _isGuestLoginVisible.value; bool get isGuestLoginVisible => _isGuestLoginVisible.value;
/// 这个包提供哪些登录入口,见 [resolveLoginMethods]。
///
/// 读 `.env` 的 Channel(打包时写死)而不是 `versionService.cachedChannel`
/// (安卓上异步按品牌推导、推导前兜底是 GLOBAL_GOOGLE),理由见那个函数的注释。
/// 游客那一项依赖服务端配置(Rx),所以这个 getter 要在 Obx 里读才会随之刷新。
Set<LoginMethod> get loginMethods => resolveLoginMethods(
isIOS: GetPlatform.isIOS,
envChannel: dotenv.env['Channel'] ?? '',
guestVisible: isGuestLoginVisible,
);
/// 要不要显示「手机号/邮箱 + 验证码 + 继续」那一组。
/// 不依赖游客开关,所以是同步的、不会闪。
bool get showContactLogin => resolveLoginMethods(
isIOS: GetPlatform.isIOS,
envChannel: dotenv.env['Channel'] ?? '',
guestVisible: false,
).contains(LoginMethod.contact);
/// contact 这一档当前只有谷歌包才有,且口径是**只留邮箱**:
/// 国家区号选择器要锁死在 ✉️,否则用户能选出手机号登录,等于没隐藏。
bool get isEmailOnlyLogin => showContactLogin;
String _deviceId = ''; String _deviceId = '';
String _deviceModel = ''; String _deviceModel = '';
String _appVersion = ''; // APP版本号 String _appVersion = ''; // APP版本号
@ -56,6 +81,13 @@ class LoginController extends GetxController {
// 初始化默认选择的国家代码 // 初始化默认选择的国家代码
void _initDefaultCountryCode() { void _initDefaultCountryCode() {
if (isEmailOnlyLogin) {
// 谷歌包只留邮箱登录,不给区号
_selectedCountryCode.value = '';
_selectedCountryFlag.value = '✉️';
loginType = 0;
return;
}
if (isChinaMainland.value) { if (isChinaMainland.value) {
// 中国大陆用户默认选择+86 // 中国大陆用户默认选择+86
_selectedCountryCode.value = '+86'; _selectedCountryCode.value = '+86';

48
apps/client/lib/modules/login/models/login_methods.dart

@ -0,0 +1,48 @@
/// 登录页上可能出现的入口。
enum LoginMethod {
/// 页面上方那组「手机号/邮箱 + 验证码 + 继续」。
contact,
wechat,
apple,
guest,
}
/// 这个**包**提供哪些登录方式(2026-09-18 产品口径)。
///
/// | 包 | 留下的入口 |
/// |---|---|
/// | 苹果包(iOS) | 苹果登录 + 游客 |
/// | 安卓国内包 | 微信登录 + 游客 |
/// | 谷歌包 | 邮箱登录 + 游客 |
///
/// ⚠️ **判据是 `.env` 的 `Channel`,不是 `VersionUpdateService.cachedChannel`**。
/// 后者在安卓上由设备品牌**异步**推导,推导完成之前 `_channel` 还是 null,
/// 而它的兜底恰好是 `GLOBAL_GOOGLE` —— 拿它做判据的话,国内包在进入登录页的
/// 头几百毫秒会显示成谷歌包(露出邮箱登录、藏掉微信),品牌识别完再跳回来。
/// `.env` 的 Channel 是打包时由 `build_channels.sh` 写死的(google → `GLOBAL_GOOGLE`),
/// 同步可读、不会变,正是「这是哪个包」本身。
///
/// ⚠️ iOS 必须**先判平台**:iOS 不走 `build_channels.sh`,`.env` 里留的还是
/// 国内渠道值(`CHINA_XIAOMI`),只看 Channel 会把苹果包判成国内包。
///
/// [guestVisible] 由服务端下发(「游客显隐控制」按应用+版本配),与包无关,
/// 所以单独传进来而不是在这里推。
Set<LoginMethod> resolveLoginMethods({
required bool isIOS,
required String envChannel,
required bool guestVisible,
}) {
final methods = <LoginMethod>{};
if (isIOS) {
methods.add(LoginMethod.apple);
} else if (envChannel.trim().toUpperCase() == 'GLOBAL_GOOGLE') {
// 谷歌包:邮箱登录(contact 这一档在非 iOS 只可能是谷歌包,故恒为邮箱,
// 见 LoginController.isEmailOnlyLogin)
methods.add(LoginMethod.contact);
} else {
// 其余安卓包(国内五家共用一个 APK,Channel 为 CHINA_*;认不出也按国内算)
methods.add(LoginMethod.wechat);
}
if (guestVisible) methods.add(LoginMethod.guest);
return methods;
}

86
apps/client/lib/modules/login/views/login_view.dart

@ -3,6 +3,7 @@ import 'package:get/get.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart'; import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import '../controllers/login_controller.dart'; import '../controllers/login_controller.dart';
import '../models/login_methods.dart';
import '../../common/webview_page.dart'; import '../../common/webview_page.dart';
import '../../../core/utils/doc_url_helper.dart'; import '../../../core/utils/doc_url_helper.dart';
@ -42,13 +43,17 @@ class LoginView extends GetView<LoginController> {
SizedBox(height: 32.h), SizedBox(height: 32.h),
_buildWelcomeText(isDarkMode), _buildWelcomeText(isDarkMode),
// 输入框和继续按钮 // 输入框和继续按钮。
SizedBox(height: 24.h), // 苹果包 / 安卓国内包**不提供**手机号·邮箱登录(2026-09-18 口径,
_buildInputField(isDarkMode), // 见 resolveLoginMethods),整组藏掉而不是留个空壳。
SizedBox(height: 16.h), if (controller.showContactLogin) ...[
_buildVerificationInputField(isDarkMode), SizedBox(height: 24.h),
SizedBox(height: 16.h), _buildInputField(isDarkMode),
_buildContinueButton(isDarkMode), SizedBox(height: 16.h),
_buildVerificationInputField(isDarkMode),
SizedBox(height: 16.h),
_buildContinueButton(isDarkMode),
],
// 社交媒体登录 // 社交媒体登录
SizedBox(height: 32.h), SizedBox(height: 32.h),
@ -130,7 +135,8 @@ class LoginView extends GetView<LoginController> {
), ),
), ),
SizedBox(height: 4.h), SizedBox(height: 4.h),
if (!controller.isChinaMainland.value) // 这句说的就是上面那组输入框,输入框藏了它也得跟着藏
if (controller.showContactLogin && !controller.isChinaMainland.value)
Text( Text(
'loginTips'.tr, // 请使用手机号或邮箱登录/注册 'loginTips'.tr, // 请使用手机号或邮箱登录/注册
style: TextStyle( style: TextStyle(
@ -184,7 +190,8 @@ class LoginView extends GetView<LoginController> {
color: isDarkMode ? Colors.white : Colors.black, color: isDarkMode ? Colors.white : Colors.black,
), ),
)), )),
if (!controller.isChinaMainland.value) if (!controller.isChinaMainland.value &&
!controller.isEmailOnlyLogin)
Icon( Icon(
Icons.arrow_drop_down, Icons.arrow_drop_down,
color: isDarkMode ? Colors.grey[400] : Colors.black54, color: isDarkMode ? Colors.grey[400] : Colors.black54,
@ -238,6 +245,10 @@ class LoginView extends GetView<LoginController> {
if (controller.isChinaMainland.value) { if (controller.isChinaMainland.value) {
return; return;
} }
// 谷歌包只留邮箱登录:能选出区号就等于还能用手机号登录,等于没隐藏
if (controller.isEmailOnlyLogin) {
return;
}
Get.bottomSheet( Get.bottomSheet(
Container( Container(
height: 400.h, height: 400.h,
@ -430,47 +441,54 @@ class LoginView extends GetView<LoginController> {
Widget _buildSocialLoginSection(bool isDarkMode) { Widget _buildSocialLoginSection(bool isDarkMode) {
return Column( return Column(
children: [ children: [
Row( // 「或使用以下方式登录」是相对上面那组输入框说的。输入框藏掉时
children: [ // 这几个按钮就是**唯一**的登录方式,再写「其他方式」会读不懂。
Expanded( if (controller.showContactLogin) ...[
child: Divider( Row(
color: isDarkMode ? Colors.grey[800] : Colors.grey[300])), children: [
Padding( Expanded(
padding: EdgeInsets.symmetric(horizontal: 16.w), child: Divider(
child: Text( color: isDarkMode ? Colors.grey[800] : Colors.grey[300])),
'otherWays'.tr, // 或使用以下方式登录 Padding(
style: TextStyle( padding: EdgeInsets.symmetric(horizontal: 16.w),
fontSize: 12.sp, child: Text(
color: isDarkMode ? Colors.grey[500] : Colors.grey[600], 'otherWays'.tr, // 或使用以下方式登录
style: TextStyle(
fontSize: 12.sp,
color: isDarkMode ? Colors.grey[500] : Colors.grey[600],
),
), ),
), ),
), Expanded(
Expanded( child: Divider(
child: Divider( color: isDarkMode ? Colors.grey[800] : Colors.grey[300])),
color: isDarkMode ? Colors.grey[800] : Colors.grey[300])), ],
], ),
), SizedBox(height: 24.h),
SizedBox(height: 24.h), ],
// 必须是 Obx:游客登录入口的显隐要等服务端配置回来(异步),用 Builder 只会渲染 // 必须是 Obx:游客登录入口的显隐要等服务端配置回来(异步),用 Builder 只会渲染
// onInit 时的初值 false,入口再也不会出现。isChinaMainland 同为 .obs,一并受益。 // onInit 时的初值 false,入口再也不会出现。isChinaMainland 同为 .obs,一并受益。
// 必须是 Obx:游客入口要等服务端配置回来(异步)。
Obx( Obx(
() { () {
// ⚠️ 按「包」给按钮,不再按 isChinaMainland / 平台各判一次:
// 原来的写法是「国内→微信、其余安卓→谷歌、iOS→苹果」,
// 三条件互不排斥,谷歌包装在中国区手机上会同时露出微信和谷歌。
// 现在唯一入口是 resolveLoginMethods,一个包只留一种。
final methods = controller.loginMethods;
final buttons = <Widget>[]; final buttons = <Widget>[];
if (controller.isChinaMainland.value) { if (methods.contains(LoginMethod.wechat)) {
buttons.add(_buildSocialButton( buttons.add(_buildSocialButton(
FontAwesomeIcons.weixin, Colors.green, isDarkMode)); FontAwesomeIcons.weixin, Colors.green, isDarkMode));
} else if (GetPlatform.isAndroid) {
buttons.add(_buildSocialButton(
FontAwesomeIcons.google, Colors.red, isDarkMode));
} }
if (GetPlatform.isIOS) { if (methods.contains(LoginMethod.apple)) {
buttons.add(_buildSocialButton( buttons.add(_buildSocialButton(
FontAwesomeIcons.apple, Colors.black, isDarkMode)); FontAwesomeIcons.apple, Colors.black, isDarkMode));
} }
if (controller.isGuestLoginVisible) { if (methods.contains(LoginMethod.guest)) {
buttons.add(_buildSocialButton( buttons.add(_buildSocialButton(
FontAwesomeIcons.userSecret, Colors.black, isDarkMode)); FontAwesomeIcons.userSecret, Colors.black, isDarkMode));
} }

85
apps/client/lib/modules/meeting/controllers/meeting_details_controller.dart

@ -158,6 +158,91 @@ class MeetingDetailsController extends GetxController
textList.value = List.from(speakerList); textList.value = List.from(speakerList);
} }
isLoading.value = false; isLoading.value = false;
// 本地先出,服务端整条再覆盖(不 await:页面已经能用了)
unawaited(_refreshFromServer());
}
/// 用服务端整条记录刷新本地缓存与页面。
///
/// 列表接口只回骨架(见 Synchrodata),转写和总结**只从这里进本地库**:
/// 每次进详情拉一次 `echomeet_getrecord`,服务端是真源,别的设备重新生成的总结、
/// 改过的标题都在这一步跟过来。拉不到就沿用本地,不报错。
Future<void> _refreshFromServer() async {
dynamic resp;
try {
resp = await Api.getRecordEchomeet({'id': id});
} catch (e) {
Logger.w('MeetingDetails', '拉取整条记录失败(沿用本地): $e');
return;
}
final record = resp?['record'];
if (record is! Map) return;
if (isClosed) return;
final int state = record['state'] ?? meetingDetails.value.tasktype;
final String title = record['title'] ?? meetingData.value.title;
final String audiourl = record['audiourl'] ?? meetingData.value.audiourl;
final String summary = record['summary'] ?? '';
final String overview = record['overview'] ?? '';
final String personnel = record['personnel'] ?? '';
await SqfliteApi.editMeetingTitle(id, {
'title': title,
'audiourl': audiourl,
'seconds': record['seconds'] ?? meetingData.value.seconds,
});
await SqfliteApi.setMeetingTaskType(id, state);
// 转写:translate 是服务端翻译/整理后的版本(无翻译时等于 original),
// 键与 meetingspeaker 列一一对应(meetingid/content/starttime/endtime/speaker)
final speakers = _decodeSpeakers(record['translate']);
if (speakers != null) {
await SqfliteApi.deleteMeetingSpeaker(id);
await SqfliteApi.insertMeetingSpeaker(speakers);
}
if (state >= 5 && state < 10000) {
await SqfliteApi.editMeeting(id, {
'personnel': personnel,
'summary': summary,
'overview': overview,
});
}
if (isClosed) return;
meetingData.value.title = title;
meetingData.value.audiourl = audiourl;
meetingData.value.tasktype = state;
meetingData.refresh();
if (speakers != null && state >= 3 && state < 10000) {
textList.value = List.from(await SqfliteApi.getMeetingSpeaker(id));
}
final bool summaryChanged = state >= 5 &&
state < 10000 &&
(meetingDetails.value.summary != summary ||
meetingDetails.value.overview != overview ||
meetingDetails.value.personnel != personnel);
if (summaryChanged) {
meetingDetails.value.summary = summary;
meetingDetails.value.overview = overview;
meetingDetails.value.personnel = personnel;
summaryNumberModifications.value++;
}
if (meetingDetails.value.tasktype != state) {
meetingDetails.value.tasktype = state;
_meetingTaskService.tasktype.value = state;
}
meetingDetails.refresh();
}
List<Map<String, Object?>>? _decodeSpeakers(dynamic raw) {
if (raw is! String || raw.trim().isEmpty) return null;
try {
final decoded = jsonDecode(raw);
if (decoded is! List || decoded.isEmpty) return null;
return decoded.map((e) => Map<String, Object?>.from(e as Map)).toList();
} catch (e) {
Logger.w('MeetingDetails', 'translate 解析失败,转写沿用本地: $e');
return null;
}
} }
Future<void> _loadPlayerController() async { Future<void> _loadPlayerController() async {

11
apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart

@ -143,17 +143,20 @@ class MeetingHomeController extends GetxController {
} }
void onPopInvokedWithResult(didPop, result) { void onPopInvokedWithResult(didPop, result) {
// Synchrodata.uploadOperationRecords(isSynchro: true);
meetingTaskService.stopTask(); meetingTaskService.stopTask();
} }
/// 先用本地缓存出列表,再后台跟服务端对齐,对齐完再刷一次。
/// 首屏不等网络:原来是 await 全量同步(含逐条重建转写)之后才查本地,
/// 记录一多进页面就是几秒白屏。
void _initGetDataList() async { void _initGetDataList() async {
await Synchrodata.uploadOperationRecords(isSynchro: true); await _getDataList();
_getDataList(); await Synchrodata.sync();
await _getDataList();
} }
// 请求本地数据 // 请求本地数据
void _getDataList() async { Future<void> _getDataList() async {
List list = await SqfliteApi.getMeetingList(); List list = await SqfliteApi.getMeetingList();
originalDataList.value = List.from(list); originalDataList.value = List.from(list);
dataList.value = List.from(list); dataList.value = List.from(list);

17
apps/client/lib/modules/meeting/views/bottomSheet/ai_bottom_sheet.dart

@ -470,6 +470,23 @@ class _AIBottomSheetState extends State<AIBottomSheet> {
_isLoading = false; _isLoading = false;
}); });
}, },
// ⚠️ 原先没有 onError:请求失败(凭据没下发、key 失效、模型名不对)是未捕获的
// 异步异常,onDone 不会触发,_isLoading 永远 true —— 表现就是「Ask AI 点了没反应,
// 再点也没反应」,且不报任何错。现在把错误写进气泡并恢复可用。
onError: (Object e) {
final msg = e.toString().replaceFirst('Exception: ', '');
if (mounted) {
setState(() {
if (_dataList.isNotEmpty &&
(_dataList.first['assistant'] as String? ?? '').isEmpty) {
_dataList.first['assistant'] = msg;
}
_isLoading = false;
});
}
Get.snackbar('error'.tr, msg);
},
cancelOnError: true,
); );
} }

140
apps/client/lib/modules/meeting/views/meeting_home_view.dart

@ -521,14 +521,38 @@ class _MeetingHomeViewState extends State<MeetingHomeView>
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Row(
data.title, crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle( children: [
color: MeetingUIUtils.getTextColor(isDarkMode), Expanded(
fontSize: 18.sp, child: Text(
), data.title,
overflow: TextOverflow.ellipsis, style: TextStyle(
), color: MeetingUIUtils.getTextColor(isDarkMode),
fontSize: 18.sp,
),
overflow: TextOverflow.ellipsis,
),
),
// 改名入口。原来藏在长按菜单里,长按后来改成了多选,
// 那个菜单就没人调得到了——改名功能等于消失。放到右上角常显。
if (!selectionMode)
GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _openRename(isDarkMode, data),
child: Padding(
padding: EdgeInsets.only(left: 8.w, top: 2.w),
child: Icon(
Icons.edit_outlined,
size: 18.w,
color: isDarkMode
? Colors.grey[400]
: Colors.grey[500],
),
),
),
],
),
Padding( Padding(
padding: EdgeInsets.symmetric(vertical: 5.w), padding: EdgeInsets.symmetric(vertical: 5.w),
child: Row( child: Row(
@ -650,93 +674,21 @@ class _MeetingHomeViewState extends State<MeetingHomeView>
} }
// ignore: unused_element // ignore: unused_element
void _onLongPressItem(BuildContext context, bool isDarkMode, /// 打开改名弹层。标题按「主名 + 扩展名」拆开,只让用户改主名,
LongPressStartDetails details, MeetingModel data) { /// 扩展名原样拼回去(editMeetingTitle 里 `titleController.text + titleCSuffix`)。
Haptics.vibrate(HapticsType.success); void _openRename(bool isDarkMode, MeetingModel data) {
RenderBox? renderBox = final dotIndex = data.title.lastIndexOf('.');
Overlay.of(context).context.findRenderObject() as RenderBox; _controller.titleController.text =
final RelativeRect position = RelativeRect.fromRect( dotIndex != -1 ? data.title.substring(0, dotIndex) : data.title;
Rect.fromLTRB( _controller.titleCSuffix =
details.globalPosition.dx, dotIndex != -1 ? data.title.substring(dotIndex) : '';
details.globalPosition.dy, Get.bottomSheet(
details.globalPosition.dx + 110, // 菜单显示位置X轴坐标 EditTitleBottomSheet(data.id),
details.globalPosition.dy - 40, // 菜单显示位置Y轴坐标 isScrollControlled: true,
), backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100],
Offset.zero & renderBox.size,
);
showMenu(
context: context,
position: position,
color: isDarkMode ? AppColors.cardBackground : Colors.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12.r), borderRadius: BorderRadius.vertical(
), top: Radius.circular(12.r),
items: [
_popupMenuEntry(
Icon(
Icons.edit_calendar,
size: 20.w,
color: isDarkMode ? Colors.white : Colors.black87,
),
'edit'.tr, // 编辑
isDarkMode ? Colors.white : Colors.black87,
() {
final dotIndex = data.title.lastIndexOf('.');
_controller.titleController.text =
dotIndex != -1 ? data.title.substring(0, dotIndex) : data.title;
_controller.titleCSuffix =
data.title.substring(dotIndex, data.title.length);
Get.bottomSheet(
EditTitleBottomSheet(data.id),
isScrollControlled: true,
backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100],
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(
top: Radius.circular(12.r),
),
),
);
},
),
_popupMenuEntry(
Icon(
Icons.delete_outline,
size: 20.w,
color: isDarkMode ? Colors.red[700] : Colors.red,
),
'delete'.tr, // 删除
isDarkMode ? Colors.red[700]! : Colors.red,
() {
_controller.deleteMeeting(data.id, data.filepath);
},
),
],
);
}
PopupMenuEntry _popupMenuEntry(
Icon icon,
String title,
Color color,
Function() onTap,
) {
return PopupMenuItem(
onTap: onTap,
child: SizedBox(
width: 140.w,
child: Row(
children: [
icon,
8.horizontalSpace,
Text(
title,
style: TextStyle(
color: color,
fontSize: 14.sp,
),
),
],
), ),
), ),
); );

10
apps/client/lib/modules/meeting/views/tabs/summary_tab.dart

@ -161,14 +161,16 @@ class _SummaryTabState extends State<SummaryTab> {
if (markdown.trim().isEmpty && current.trim().isEmpty) return; if (markdown.trim().isEmpty && current.trim().isEmpty) return;
if (markdown.trim() == current.trim()) return; if (markdown.trim() == current.trim()) return;
Api.modifyRecordEchomeet({ // 先落本地再等服务端确认:详情页每次进都会用服务端整条覆盖本地,
'id': _controller.id, // 这里不 await 的话「改完立刻退出再进」会拿到旧值把刚改的冲掉。
'summary': markdown,
});
SqfliteApi.editMeeting( SqfliteApi.editMeeting(
_controller.id, _controller.id,
{'summary': markdown}, {'summary': markdown},
); );
await Api.modifyRecordEchomeet({
'id': _controller.id,
'summary': markdown,
});
_controller.meetingDetails.value.summary = markdown; _controller.meetingDetails.value.summary = markdown;
_controller.summaryNumberModifications.value++; _controller.summaryNumberModifications.value++;
_summaryDirty = false; _summaryDirty = false;

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

@ -20,6 +20,7 @@ import '../../../data/services/tts_service.dart';
import '../../../data/services/language_manager.dart'; import '../../../data/services/language_manager.dart';
import '../../../core/utils/logger.dart'; import '../../../core/utils/logger.dart';
import '../../../core/utils/permission_util.dart'; import '../../../core/utils/permission_util.dart';
import '../../../core/utils/toast_throttle.dart';
import '../../../core/services/floating_window_manager.dart'; import '../../../core/services/floating_window_manager.dart';
import '../../../data/services/asr_service.dart'; import '../../../data/services/asr_service.dart';
@ -27,11 +28,13 @@ import '../../../data/services/usage_stats_service.dart';
import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:device_plugin_interface/device_plugin_interface.dart';
import '../../../devices/device_hub.dart'; import '../../../devices/device_hub.dart';
import '../../../data/services/recording_archive.dart'; import '../../../data/services/recording_archive.dart';
import '../models/translation_export.dart';
import '../../../core/utils/device_connection.dart'; import '../../../core/utils/device_connection.dart';
import '../../../core/utils/background_session_notifier.dart'; import '../../../core/utils/background_session_notifier.dart';
import 'record_timer_controller.dart'; import 'record_timer_controller.dart';
import 'translation_history_controller.dart'; import 'translation_history_controller.dart';
import '../models/translation_models.dart'; import '../models/translation_models.dart';
import '../models/translation_direction.dart';
import '../views/dialogs/translation_mode_selector.dart'; import '../views/dialogs/translation_mode_selector.dart';
import '../../../data/models/appconfig.dart'; import '../../../data/models/appconfig.dart';
import '../../../data/models/appconfig_model.dart'; import '../../../data/models/appconfig_model.dart';
@ -137,6 +140,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
final isCreateRecord = false.obs; final isCreateRecord = false.obs;
final hasRecordPermission = false.obs; final hasRecordPermission = false.obs;
// 本次录音文件路径,供 stopRecording 归档时用;null 表示本轮没开过录音 // 本次录音文件路径,供 stopRecording 归档时用;null 表示本轮没开过录音
// 录音起点与暂停区间:归档时把翻译条目的时间戳换算成「音频里的毫秒偏移」要用。
// 面对面翻译逐句按住说话、松开就暂停录音,不记暂停的话偏移越往后越大。
DateTime? _recordingStartedAt;
final List<(DateTime, DateTime)> _recordingPauses = [];
DateTime? _pauseStartedAt;
String? _currentRecordingPath; String? _currentRecordingPath;
// 添加悬浮按钮位置状态 // 添加悬浮按钮位置状态
@ -540,7 +548,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// [speakerId] 说话者ID /// [speakerId] 说话者ID
Future<void> _startFaceToFaceRecognition(int speakerId) async { Future<void> _startFaceToFaceRecognition(int speakerId) async {
try { try {
if (User.isLoggedIn() && User.instance.tradeintegral <= 0) { // 同上:旧额度桶已停用,不能再当闸门(见 startRecognition 的说明)
if (!_hasTranslationQuota()) {
showRechargeDialog('translation'); showRechargeDialog('translation');
activeSpeaker.value = 0; activeSpeaker.value = 0;
return; return;
@ -1020,14 +1029,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
await _astService.initialize(supportedLanguages: callModeLanguages, provider: astProvider); await _astService.initialize(supportedLanguages: callModeLanguages, provider: astProvider);
// 获取识别事件流(端到端服务已包含 ASR+翻译+TTS,不需要单独启动 ASR) // 获取识别事件流(端到端服务已包含 ASR+翻译+TTS,不需要单独启动 ASR)
var astStream = await _astService.recognizeCallback(); await _subscribeAstEvents();
_astEventSubscription?.cancel();
_astEventSubscription = astStream.listen(_handleAstEvent, onError: (e) {
Logger.error('[STS] astStream 错误: $e');
}, onDone: () {
Logger.info('[STS] astStream 已关闭');
});
Logger.info('[STS] 已订阅 astStream, subscription=${_astEventSubscription.hashCode}');
// 初始化完成后给 2s 宽限期,过滤掉底层异步 dispose 旧 provider session 时产生的 // 初始化完成后给 2s 宽限期,过滤掉底层异步 dispose 旧 provider session 时产生的
// cancelled(1012) / Socket not connected(1011) 残留事件,避免被误判为新 session 错误 // cancelled(1012) / Socket not connected(1011) 残留事件,避免被误判为新 session 错误
@ -1117,8 +1119,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now());
// 文件名即语音记录助手里的标题,前缀按 RecordingArchive 的约定走: // 文件名即语音记录助手里的标题,前缀按 RecordingArchive 的约定走:
// trans_call_* 通话翻译 // trans_call_* 通话翻译
// trans_holder_* 同声翻译 // trans_holder_* 同声翻译 / 面对面翻译
// 其它模式(音视频/面对面)暂不归档,沿用本地化标题命名。 // 音视频翻译录的是系统播放声,暂不归档,沿用本地化标题命名。
// ⚠️ 别把这里的前缀和下面 stopRecording 的归档条件改岔了—— // ⚠️ 别把这里的前缀和下面 stopRecording 的归档条件改岔了——
// 命名对不上约定,归档进去的标题就成了「同声传译_2026...」这种, // 命名对不上约定,归档进去的标题就成了「同声传译_2026...」这种,
// 列表里跟别的来源混在一起认不出。 // 列表里跟别的来源混在一起认不出。
@ -1139,19 +1141,30 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
await _asrService.enableRecord(audioSourceType, filePath, false); await _asrService.enableRecord(audioSourceType, filePath, false);
} }
isCreateRecord.value = true; isCreateRecord.value = true;
_recordingStartedAt = DateTime.now();
_recordingPauses.clear();
_pauseStartedAt = null;
} }
/// 停止录音 /// 停止录音
/// ///
/// 通话翻译的录音停止后归档进「语音记录助手」,跟通话录音 /// 通话 / 同声 / 面对面翻译的录音停止后归档进「语音纪要」,**连同这段
/// ([BesCallRecordingService])看齐——之前只是写进了 App 自己的 /// 时间里翻译出的整句一起送过去**:纪要那边不再重新识别,直接用智能模板总结
/// TranslatAudio 目录,主列表里看不到。录音本身仍然是用户手动开的 /// (2026-09-18 产品口径,见 [RecordingArchive.archive] 的 `transcript`)。
/// (录音按钮),不是通话一开始就默认录,合规要求不变。 /// 录音在这三个模式下默认开(见 [_initializeSession]),用户本轮可随时关。
Future<void> stopRecording() async { Future<void> stopRecording() async {
// stopTimer() 会把 _seconds 清零,归档要用的时长必须在它之前取 // stopTimer() 会把 _seconds 清零,归档要用的时长必须在它之前取
final int seconds = _timerManager.currentSeconds; final int seconds = _timerManager.currentSeconds;
final String? path = _currentRecordingPath; final String? path = _currentRecordingPath;
_currentRecordingPath = null; _currentRecordingPath = null;
// 面对面翻译的会话是在「暂停」状态下结束的(最后一句松手就暂停了),
// 把这段收尾进暂停区间,最后一句之后的空档不算进偏移。
if (_pauseStartedAt != null) {
_recordingPauses.add((_pauseStartedAt!, DateTime.now()));
_pauseStartedAt = null;
}
final DateTime? startedAt = _recordingStartedAt;
_recordingStartedAt = null;
_timerManager.stopTimer(); _timerManager.stopTimer();
if (currentMode.value == 'call') { if (currentMode.value == 'call') {
@ -1168,6 +1181,19 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
Logger.info('录音已停止,计时器已重置'); Logger.info('录音已停止,计时器已重置');
if (_shouldArchiveRecording && path != null && seconds > 0) { if (_shouldArchiveRecording && path != null && seconds > 0) {
// 转写在归档**之前**就地算好:列表和暂停区间都是 controller 的状态,
// 归档是异步的,等它跑到的时候页面可能已经退了、下一轮也可能已经开始。
final transcript = startedAt == null
? const <Map<String, Object?>>[]
: buildMeetingTranscript(
items: translationHistory.toList(),
recordingStartedAt: startedAt,
mode: currentMode.value,
ownLanguageCode: sourceLanguageCode.value,
pauses: List.of(_recordingPauses),
);
Logger.i('Translation',
'归档翻译录音:${seconds}s 转写 ${transcript.length} 段 模式=${currentMode.value}');
// ⚠️ 不 await:归档里有一次 echomeet_addrecord 网络请求(Dio 30s // ⚠️ 不 await:归档里有一次 echomeet_addrecord 网络请求(Dio 30s
// connect + 30s receive),await 会把「点结束」一路堵到超时。 // connect + 30s receive),await 会把「点结束」一路堵到超时。
// 归档本身不依赖 controller(RecordingArchive 是静态的), // 归档本身不依赖 controller(RecordingArchive 是静态的),
@ -1176,17 +1202,27 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
path: path, path: path,
seconds: seconds, seconds: seconds,
tag: 'TranslationController', tag: 'TranslationController',
transcript: transcript,
// 总结用用户自己的语言(源语言);对方说的外语那几句原文+译文都在转写里
summaryLanguage: sourceLanguageCode.value,
)); ));
} }
_recordingPauses.clear();
} }
/// 这个模式的录音要不要进「语音记录助手」。 /// 这个模式的录音要不要进「语音纪要」。
/// ///
/// 通话翻译和同声翻译要;音视频翻译录的是系统声音、面对面翻译是逐句 /// 通话 / 同声 / 面对面翻译都要(2026-09-18 起面对面也归档:它逐句按轮次
/// 按轮次开停的(见 _startFaceToFaceRecognition),两者的归档语义还没定, /// 暂停/继续,偏移由 [_recordingPauses] 扣掉)。音视频翻译录的是系统播放声,
/// 先不动。 /// 归档语义还没定,先不动。
bool get _shouldArchiveRecording => bool get _shouldArchiveRecording =>
currentMode.value == 'call' || currentMode.value == 'simultaneous'; currentMode.value == 'call' ||
currentMode.value == 'simultaneous' ||
currentMode.value == 'faceToFace';
/// 这三个模式默认开录音:不录就没有文件,也就没有东西进语音纪要,
/// 而产品口径是「翻译完在纪要里能看到音频 + 转写 + 总结」。
bool get _recordsByDefault => _shouldArchiveRecording;
/// 归档文件名前缀,取值见 RecordingArchive 的约定。 /// 归档文件名前缀,取值见 RecordingArchive 的约定。
String get _archivePrefix => String get _archivePrefix =>
@ -1194,6 +1230,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 暂停录音 /// 暂停录音
Future<void> pauseRecording() async { Future<void> pauseRecording() async {
_pauseStartedAt ??= DateTime.now();
_timerManager.pauseTimer(); _timerManager.pauseTimer();
if (currentMode.value == 'call') { if (currentMode.value == 'call') {
await _astService.pauseRecord(); await _astService.pauseRecord();
@ -1206,6 +1243,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 继续录音 /// 继续录音
Future<void> continueRecording() async { Future<void> continueRecording() async {
if (_pauseStartedAt != null) {
_recordingPauses.add((_pauseStartedAt!, DateTime.now()));
_pauseStartedAt = null;
}
_timerManager.resumeTimer(); _timerManager.resumeTimer();
if (currentMode.value == 'call') { if (currentMode.value == 'call') {
await _astService.resumeRecord(); await _astService.resumeRecord();
@ -1216,6 +1257,16 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
Logger.info(' 录音已继续'); Logger.info(' 录音已继续');
} }
/// 本地还允不允许开翻译。
///
/// 旧的「翻译额度」桶(tradeintegral)已停用、不再发放,拿它当闸门会把所有
/// 新账号挡在门外。真正的准入在服务端:无 VIP 时 user_translate 直接返回
/// VipRequired,算力不足按后台开关决定放不放行。客户端这层只保留一个
/// 「老账号还有存量额度」的兼容判断:**有存量就放行,没有也放行**,
/// 即恒为 true —— 保留这个函数是为了把「为什么不再判额度」写在一个地方,
/// 将来要恢复本地闸门也只改这里。
bool _hasTranslationQuota() => true;
/// 开始语音识别 /// 开始语音识别
/// 同声翻译、通话翻译、多媒体翻译的语音识别调用入口 /// 同声翻译、通话翻译、多媒体翻译的语音识别调用入口
Future<void> startRecognition() async { Future<void> startRecognition() async {
@ -1230,8 +1281,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
Logger.info( Logger.info(
'[TRANS-INIT] startRecognition: mode=${currentMode.value}, 语言对=$srcName(${sourceLanguageCode.value}) -> $tgtName(${targetLanguageCode.value})'); '[TRANS-INIT] startRecognition: mode=${currentMode.value}, 语言对=$srcName(${sourceLanguageCode.value}) -> $tgtName(${targetLanguageCode.value})');
} }
//检查用户是否登录且积分是否足够,不足则提示充值 // ⚠️ 这里原来判的是 `User.instance.tradeintegral <= 0`(旧「翻译额度」桶)。
if (User.isLoggedIn() && User.instance.tradeintegral <= 0) { // 那三个旧桶(tradeintegral/meetintegral/aichatintegral)已于会员与算力改造时
// **停用**:余额一次性折进算力,之后再也不发放,新账号恒为 0。于是新用户一点
// 「开始」就弹充值、识别根本不启动——表现就是「没语音识别、也没翻译」。
// 准入现在由服务端管(VIP 判 user_translate,算力记流水),客户端不再自己拦。
if (!_hasTranslationQuota()) {
showRechargeDialog('translation'); showRechargeDialog('translation');
return; return;
} }
@ -1335,6 +1390,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_astAutoStartPending = false; _astAutoStartPending = false;
} else { } else {
Logger.i('Translation', '通话模式:启动 AST 端到端服务'); Logger.i('Translation', '通话模式:启动 AST 端到端服务');
// 出错终止过的页面订阅已经没了,不补上这一轮就是「聋」的
if (_astEventSubscription == null) {
await _subscribeAstEvents();
}
await _astService.startContinuousTranslation(); await _astService.startContinuousTranslation();
} }
} else if (isIosAudioVideo) { } else if (isIosAudioVideo) {
@ -1403,6 +1462,15 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
Logger.error('异常终止时停止录音失败(忽略): $e'); Logger.error('异常终止时停止录音失败(忽略): $e');
} }
} }
// 音视频/通话模式:识别挂了,设备侧/录屏广播那条链路也得收掉,
// 否则 socket 接收端还开着、没人消费(正常 stopAll 走的是同一个收尾,幂等)。
if (currentMode.value == "call" || currentMode.value == "audioVideo") {
try {
await _teardownCallModeAudio();
} catch (e) {
Logger.error('异常终止时收尾音频链路失败(忽略): $e');
}
}
} }
/// 检查权限 /// 检查权限
@ -1418,6 +1486,14 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 初始化会话 /// 初始化会话
Future<void> _initializeSession() async { Future<void> _initializeSession() async {
currentSessionId = DateTime.now().millisecondsSinceEpoch.toString(); currentSessionId = DateTime.now().millisecondsSinceEpoch.toString();
// 通话 / 同声 / 面对面默认开录音,每轮开始回到默认开(本轮关掉只管本轮)。
// 放在这里而不是 _finalizeRecognitionStart:设备端触发的面对面(按住说话)
// 不经过后者,但第一句一定经过这里。
// ⚠️ 通话翻译录的是通话双方的声音。默认录是 2026-09-18 的产品决定
// (翻译完要能在纪要里回听),上线前请确认录音告知/合规口径。
if (_recordsByDefault && !isRecording.value) {
isRecording.value = true;
}
try { try {
_usageService.startNewSession(); _usageService.startNewSession();
@ -1646,18 +1722,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_startAsrActiveTracking(); _startAsrActiveTracking();
isRecognizing.value = true; isRecognizing.value = true;
// 同声翻译**默认开录音**:不录就没有文件,也就没有东西归档进「语音记录助手」, // 默认录音的开关在 _initializeSession 里定,这里只按开关开文件。
// 而用户的预期是translate完能在助手里找到这段音频。
// 录音按钮仍然在,用户本轮可以随时关掉;下一轮开始时回到默认开。
//
// ⚠️ 只对同声翻译这么做,**通话翻译保持手动**:那录的是通话双方的声音,
// 默认开启属于「未经告知录音」,合规风险完全不是一个量级。
// 音视频翻译录的是系统播放声,面对面是逐句按轮次开停,两者归档语义都还没定,
// 一并保持原样。
if (currentMode.value == 'simultaneous' && !isRecording.value) {
isRecording.value = true;
}
if (isRecording.value && isRecognizing.value) { if (isRecording.value && isRecognizing.value) {
startRecording(); startRecording();
} }
@ -1713,7 +1778,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
case RecognitionEventType.error: case RecognitionEventType.error:
final errorMsg = event.error; final errorMsg = event.error;
var displayMsg = errorMsg; var displayMsg = errorMsg;
// 原生报上来的两种写法都要认:receive 失败是「connection failed」,
// URLSession 整个任务被系统掐断(退后台被挂起、ECONNABORTED)是「URLSession failed」。
// 只匹配前者时后者会把 `[NSPOSIXErrorDomain:53] …` 原样甩给用户。
if (errorMsg.contains('Xunfei connection failed') || if (errorMsg.contains('Xunfei connection failed') ||
errorMsg.contains('Xunfei URLSession failed') ||
errorMsg.contains('NSPOSIXErrorDomain') ||
errorMsg.contains('Socket is not connected')) { errorMsg.contains('Socket is not connected')) {
displayMsg = 'asrConnectionFailed'.tr; // 语音识别服务连接失败,请检查网络或稍后重试 displayMsg = 'asrConnectionFailed'.tr; // 语音识别服务连接失败,请检查网络或稍后重试
} }
@ -2179,7 +2249,25 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
String detectedSourceLanguageCode; String detectedSourceLanguageCode;
String detectedTargetLanguageCode; String detectedTargetLanguageCode;
if (_isFaceToFaceSpeaker1()) { // ⚠️ 同声翻译 / 多媒体翻译是**单向**的:收到的音频一律按「源语言 → 目标语言」翻,
// 不再拿 ASR 报上来的语种去反推方向。
//
// 原来走下面那条 `shouldSwap = detectedLanguageCode == sourceLanguageCode` 的分支,
// 而这两个模式给 ASR 的本来就只有一种语言(见 _asrLanguagesForCurrentMode),
// 原生固定语种时上报的 detectedLanguage 形式并不保证与 sourceLanguageCode 逐字相等
// (空串 / 大小写 / 带不带地区都出现过)——一旦不相等就判成 shouldSwap=false,
// 于是把源语言的句子当成目标语言送去翻译,方向整个反过来:
// 表现是「识别出字了,译文却不出来或者是原文」。单向之后这个竞态不存在。
if (_isOneWayMode) {
detectedLanguageCode = sourceLanguageCode.value;
final d = resolveTranslationDirection(
mode: currentMode.value,
sourceCode: sourceLanguageCode.value,
targetCode: targetLanguageCode.value,
);
detectedSourceLanguageCode = d.source;
detectedTargetLanguageCode = d.target;
} else if (_isFaceToFaceSpeaker1()) {
detectedLanguageCode = sourceLanguageCode.value; detectedLanguageCode = sourceLanguageCode.value;
detectedSourceLanguageCode = sourceLanguageCode.value; detectedSourceLanguageCode = sourceLanguageCode.value;
detectedTargetLanguageCode = targetLanguageCode.value; detectedTargetLanguageCode = targetLanguageCode.value;
@ -2214,6 +2302,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_isFaceToFaceDeviceMode && activeSpeaker.value == 0; _isFaceToFaceDeviceMode && activeSpeaker.value == 0;
/// 判断是否为面对面说话者1 /// 判断是否为面对面说话者1
/// 单向翻译的模式:同声翻译、多媒体(音视频)翻译。
///
/// 这两个模式的语义是「把听到的内容翻成目标语言播出来」,方向固定。
/// 面对面要判断谁在说(双向),通话翻译两条腿各自定死方向,都不在此列。
bool get _isOneWayMode => isOneWayTranslationMode(currentMode.value);
bool _isFaceToFaceSpeaker1() { bool _isFaceToFaceSpeaker1() {
return (currentMode.value == 'faceToFace' && activeSpeaker.value == 1) || return (currentMode.value == 'faceToFace' && activeSpeaker.value == 1) ||
(currentMode.value == 'faceToFace' && (currentMode.value == 'faceToFace' &&
@ -2374,7 +2468,9 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
if (text.isEmpty) return; if (text.isEmpty) return;
try { try {
final bool shouldSwap = detectedLanguageCode == targetLanguageCode.value; // 单向模式播报的永远是目标语言,不做方向推断(见 _isOneWayMode)
final bool shouldSwap =
!_isOneWayMode && detectedLanguageCode == targetLanguageCode.value;
final detectedTargetLanguageCode = final detectedTargetLanguageCode =
shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value; shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value;
final voiceName = _languageManager final voiceName = _languageManager
@ -2419,6 +2515,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
} }
if (isRecognizing.value) { if (isRecognizing.value) {
await stopRecognition(); await stopRecognition();
} else if (isCreateRecord.value) {
// 面对面翻译每句松手就把识别停了(isRecognizing=false),录音只是暂停、
// 文件还开着——原来 stopAll 一看识别没在跑就什么都不做,文件永远收不了尾,
// 自然也从来没进过纪要。
await stopRecording();
} }
_stopAsrActiveTracking(); _stopAsrActiveTracking();
@ -2429,6 +2530,24 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
} }
} }
/// 订阅 AST 事件流(幂等:已有订阅先取消再订)。
///
/// ⚠️ 除了初始化,**每次启动通话翻译前都要确保订阅还在**:
/// [_terminateCallOnError] 会把订阅取消置空,页面还在、用户再点一次开始,
/// AST 照常握手、识别/翻译日志(service 层打的)一行不少,但 controller
/// 一条事件都收不到——不上屏、不进历史、归档时「转写 0 段」。
/// 2026-09-18 真机:第一轮 socket 1011 终止后重开,六句话全丢。
Future<void> _subscribeAstEvents() async {
final astStream = await _astService.recognizeCallback();
_astEventSubscription?.cancel();
_astEventSubscription = astStream.listen(_handleAstEvent, onError: (e) {
Logger.error('[STS] astStream 错误: $e');
}, onDone: () {
Logger.info('[STS] astStream 已关闭');
});
Logger.info('[STS] 已订阅 astStream, subscription=${_astEventSubscription.hashCode}');
}
/// 通话模式发生错误时终止服务并还原状态 /// 通话模式发生错误时终止服务并还原状态
Future<void> _terminateCallOnError(String reason) async { Future<void> _terminateCallOnError(String reason) async {
Logger.error('[STS] 通话错误,终止服务: $reason'); Logger.error('[STS] 通话错误,终止服务: $reason');
@ -2441,6 +2560,17 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
_astAutoStartPending = false; _astAutoStartPending = false;
isRecognizing.value = false; isRecognizing.value = false;
isPreparing.value = false; isPreparing.value = false;
// 录音跟着识别一起收:原来这条路径不管录音,AST 挂了以后识别停了、
// 原生录音还在往文件里写,右上角一直显示在录,直到用户退页面才被 onClose
// 的 stopAll 收掉——而且那时 isRecognizing 已经是 false,老的 stopAll 根本
// 不会去停录音。已录到的部分照常归档(转写有多少算多少)。
if (isCreateRecord.value) {
try {
await stopRecording();
} catch (e) {
Logger.error('[STS] 终止时停止录音失败(忽略): $e');
}
}
_stopAsrActiveTracking(); _stopAsrActiveTracking();
_translationDebounceTimer?.cancel(); _translationDebounceTimer?.cancel();
await _ttsService.stop(); await _ttsService.stop();
@ -2637,8 +2767,28 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
return completer.future; return completer.future;
} }
/// 翻译进行中能不能换语言 —— **不能**。
///
/// 原先允许:换一次 = stopAll(顺带把这段录音归档)→ 重建 ASR → 自动恢复识别。
/// 这条链上每一步都是异步的原生往返(关 ASR 连接、收音频引擎、归档上传、再开连接),
/// 用户连着切几次就把几条链叠在一起跑,真机上表现为 App 卡死。
/// 现在翻译中一律拦住、提示先停止;切换只在停止状态下发生,链路只跑一条。
///
/// 三个入口(选源语言 / 选目标语言 / 互换)和两处 UI(通用语言栏、面对面布局)都过这一道,
/// UI 那层是为了连弹窗都不打开,controller 这层是兜底。
bool guardLanguageChange() {
if (!isRecognizing.value && !isPreparing.value) return true;
ToastThrottle.show(
'translation.language_locked',
title: 'tip'.tr,
message: 'stopTranslationBeforeLanguageChange'.tr,
);
return false;
}
/// 切换源语言和目标语言 /// 切换源语言和目标语言
Future<void> swapLanguages() => _runLanguageSwitch(() async { Future<void> swapLanguages() => _runLanguageSwitch(() async {
if (!guardLanguageChange()) return;
final wasRecognizing = isRecognizing.value; final wasRecognizing = isRecognizing.value;
await stopAll(); await stopAll();
@ -2659,6 +2809,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// [language] 语言名称 /// [language] 语言名称
Future<void> setSourceLanguage(String language) => _runLanguageSwitch(() async { Future<void> setSourceLanguage(String language) => _runLanguageSwitch(() async {
if (sourceLanguage.value == language) return; if (sourceLanguage.value == language) return;
if (!guardLanguageChange()) return;
final wasRecognizing = isRecognizing.value; final wasRecognizing = isRecognizing.value;
await stopAll(); await stopAll();
@ -2677,6 +2828,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// [language] 语言名称 /// [language] 语言名称
Future<void> setTargetLanguage(String language) => _runLanguageSwitch(() async { Future<void> setTargetLanguage(String language) => _runLanguageSwitch(() async {
if (targetLanguage.value == language) return; if (targetLanguage.value == language) return;
if (!guardLanguageChange()) return;
final wasRecognizing = isRecognizing.value; final wasRecognizing = isRecognizing.value;
await stopAll(); await stopAll();
@ -2701,9 +2853,9 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// 所以只有真正需要判断「现在是谁在说」的模式才开: /// 所以只有真正需要判断「现在是谁在说」的模式才开:
/// - faceToFace:两个人轮流说,方向靠检测结果定,**必须**双语; /// - faceToFace:两个人轮流说,方向靠检测结果定,**必须**双语;
/// - simultaneous / audioVideo:单向,方向固定,开了纯亏延迟; /// - simultaneous / audioVideo:单向,方向固定,开了纯亏延迟;
/// 固定语种时原生上报的 detectedLanguage 就是 supportedLanguages[0] /// 方向由 [_determineLanguageCodes] 的 [_isOneWayMode] 分支直接钉成
/// (见 AzureAsrHelper 的 onResult),[_determineLanguageCodes] 据此 /// 「源 → 目标」,**不再看原生上报的 detectedLanguage**(它在固定语种下
/// 算出 shouldSwap=true,方向仍然正确。 /// 本应等于 supportedLanguages[0],但形式不保证逐字相等,靠它推方向会翻车)。
/// ///
/// ⚠️ 代价:同传下用户若改说目标语种,不会再被自动识别成反向。 /// ⚠️ 代价:同传下用户若改说目标语种,不会再被自动识别成反向。
/// 同传本来就是单向场景,需要反向请切面对面。 /// 同传本来就是单向场景,需要反向请切面对面。

60
apps/client/lib/modules/translation/models/translation_direction.dart

@ -0,0 +1,60 @@
/// 一次识别结果该按哪个方向翻译。
///
/// 抽成纯函数是为了能脱离设备/网络单测:方向判错不会报任何错,
/// 只表现为「识别出字了、译文不出来或者是原文」,靠真机盲测极难发现。
class TranslationDirection {
const TranslationDirection(this.source, this.target);
final String source;
final String target;
@override
bool operator ==(Object other) =>
other is TranslationDirection &&
other.source == source &&
other.target == target;
@override
int get hashCode => Object.hash(source, target);
@override
String toString() => '$source -> $target';
}
/// 单向翻译的模式:同声翻译、多媒体(音视频)翻译。
///
/// 这两个模式的语义是「把听到的内容翻成目标语言播出来」,方向固定。
/// 面对面要判断谁在说(双向),通话翻译两条腿各自定死方向,都不在此列。
bool isOneWayTranslationMode(String mode) =>
mode == 'simultaneous' || mode == 'audioVideo';
/// 计算这一句的翻译方向。
///
/// [speaker] 仅面对面模式有意义:1 = 源语言那一方,2 = 目标语言那一方,0 = 未知。
/// [detectedLanguageCode] 是 ASR 上报的语种,**只在面对面的未知说话人分支才采信**。
TranslationDirection resolveTranslationDirection({
required String mode,
required String sourceCode,
required String targetCode,
String detectedLanguageCode = '',
int speaker = 0,
}) {
// 单向:永远 源 → 目标,不看 ASR 报的语种。
//
// ⚠️ 这两个模式给 ASR 的本来就只有一种语言,固定语种下原生上报的
// detectedLanguage 形式并不保证与 sourceCode 逐字相等(空串 / 大小写 /
// 带不带地区都出现过)。老实现拿它去比,一旦不相等就判成反向,
// 把源语言的句子当成目标语言送去翻译 —— 方向整个颠倒。
if (isOneWayTranslationMode(mode)) {
return TranslationDirection(sourceCode, targetCode);
}
if (mode == 'faceToFace') {
if (speaker == 1) return TranslationDirection(sourceCode, targetCode);
if (speaker == 2) return TranslationDirection(targetCode, sourceCode);
}
// 面对面且说不清是谁在说(或通话模式):按 ASR 检测结果定方向。
final fromSource = detectedLanguageCode == sourceCode;
return fromSource
? TranslationDirection(sourceCode, targetCode)
: TranslationDirection(targetCode, sourceCode);
}

98
apps/client/lib/modules/translation/models/translation_export.dart

@ -0,0 +1,98 @@
import 'translation_models.dart';
/// 把一段翻译会话的条目转成语音纪要的转写段。
///
/// 输出形状与服务端 `ContextStruct`(`echomeet_addrecord` 的 `original`)
/// 和本地 `meetingspeaker` 表一致:`content / starttime / endtime / speaker`,
/// 时间是**相对录音起点的毫秒**——纪要详情页拿它做 seekTo,写成绝对时间戳
/// 会让点一句跳到音频末尾之外。
///
/// 纯函数,不碰 controller,见 `test/translation_export_test.dart`。
List<Map<String, Object?>> buildMeetingTranscript({
required List<TranslationItem> items,
required DateTime recordingStartedAt,
required String mode,
required String ownLanguageCode,
List<(DateTime, DateTime)> pauses = const [],
}) {
// 只要**这段录音**里说过的整句:中间结果是同一句话的半成品,
// 录音开始前的是上一轮留在列表里的历史。
final finals = items
.where((e) =>
!e.isIntermediate &&
e.sourceText.trim().isNotEmpty &&
!e.timestamp.isBefore(recordingStartedAt))
.toList()
..sort((a, b) => a.timestamp.compareTo(b.timestamp));
if (finals.isEmpty) return const [];
final twoParty = mode == 'call' || mode == 'faceToFace';
final out = <Map<String, Object?>>[];
for (var i = 0; i < finals.length; i++) {
final item = finals[i];
final start = _offsetMs(item.timestamp, recordingStartedAt, pauses);
// 结束时间取下一句开头;最后一句按字数估一个上限,别让它盖到音频末尾之外
final int end = i + 1 < finals.length
? _offsetMs(finals[i + 1].timestamp, recordingStartedAt, pauses)
: start + _estimateSpeechMs(item.sourceText);
out.add({
'content': _content(item),
'starttime': start,
'endtime': end < start ? start : end,
'speaker': twoParty ? _speakerOf(item, ownLanguageCode) : '',
});
}
return out;
}
/// 相对录音起点的毫秒数,扣掉在这句话之前发生过的暂停时长。
/// 面对面翻译是逐句按住说话、松开就暂停录音的,不扣的话越往后偏得越多。
int _offsetMs(
DateTime at, DateTime startedAt, List<(DateTime, DateTime)> pauses) {
var ms = at.difference(startedAt).inMilliseconds;
for (final (from, to) in pauses) {
if (!to.isAfter(at)) {
ms -= to.difference(from).inMilliseconds;
} else if (from.isBefore(at)) {
ms -= at.difference(from).inMilliseconds;
}
}
return ms < 0 ? 0 : ms;
}
/// 按字数估一句话的时长:中文约 4 字/秒,取 1~30 秒。
int _estimateSpeechMs(String text) {
final ms = text.trim().length * 250;
if (ms < 1000) return 1000;
if (ms > 30000) return 30000;
return ms;
}
/// 原文一行、译文一行。纪要里两种语言都留着,翻译记录的价值就在对照。
String _content(TranslationItem item) {
final src = item.sourceText.trim();
final dst = item.translatedText.trim();
if (dst.isEmpty || dst == src) return src;
return '$src\n$dst';
}
/// 说话人标签:`Speaker_0` = 本端(机主),`Speaker_1` = 对端。
/// 通话翻译按 AST 的 serviceId 分(A=己方 B=对方),面对面按这句话说的是哪种语言分。
///
/// ⚠️ 格式必须与服务端转写一致(`tasks.go` 的 `normalizeSpeakers`)。
/// 原来这里写的是裸 `0` / `1`,三处后果都不在纪要本身:
/// - 转写页直接把这个字段当文案显示,别的纪要显示 `Speaker_0`,翻译来的显示 `0`;
/// - 会议纪要喂给 LLM 抽待办时 `[0]:` 和 `[Speaker_1]` 混着,抽出来的负责人
/// 就是 `Speaker2`、`[Speaker_0]` 各种样;
/// - MCP 的 meaningfulPersonnel 只认 `Speaker_` 前缀,裸编号会被当成
/// 「用户命名过的真人」塞给模型。
String _speakerOf(TranslationItem item, String ownLanguageCode) {
final sid = item.serviceId.trim().toUpperCase();
if (sid == 'A') return kSpeakerSelf;
if (sid == 'B') return kSpeakerPeer;
return item.sourceLanguageCode == ownLanguageCode ? kSpeakerSelf : kSpeakerPeer;
}
/// 说话人标签的两个取值。与服务端 `Speaker_<n>` 同一口径。
const String kSpeakerSelf = 'Speaker_0';
const String kSpeakerPeer = 'Speaker_1';

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

@ -9,9 +9,11 @@ import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:haptic_feedback/haptic_feedback.dart'; import 'package:haptic_feedback/haptic_feedback.dart';
import '../controllers/translation_controller.dart'; import '../controllers/translation_controller.dart';
import '../models/translation_models.dart'; import '../models/translation_models.dart';
import '../models/translation_direction.dart';
import '../views/translation_history_view.dart'; import '../views/translation_history_view.dart';
import '../bindings/translation_history_binding.dart'; import '../bindings/translation_history_binding.dart';
import '../../../core/theme/app_colors.dart'; import '../../../core/theme/app_colors.dart';
import '../../../core/utils/logger.dart';
import '../../../routes/app_routes.dart'; import '../../../routes/app_routes.dart';
import '../../../core/utils/background_session_notifier.dart'; import '../../../core/utils/background_session_notifier.dart';
import '../../common/widgets/language_selector_bar.dart'; import '../../common/widgets/language_selector_bar.dart';
@ -168,13 +170,33 @@ class TranslationView extends GetView<TranslationController> {
); );
} }
/// 停止翻译,但**绝不让返回键卡死**。
///
/// ⚠️ 这个页面是 `PopScope(canPop: false)`,返回只能由这里的 `Get.back()` 放行。
/// 原来直接 `await controller.stopAll()`,而这条链末端是一串原生往返
/// (关 ASR 连接、收音频引擎、停录屏接收、归档),任何一处不返回,
/// 返回键就变成永久空操作 —— 用户的说法是「退不出去」。
/// 现在给它一个上限:超时就先退出去,收尾让它在后台继续跑完。
static const Duration _stopAllMaxWait = Duration(seconds: 3);
Future<void> _stopAllSafely() async {
try {
await controller.stopAll().timeout(_stopAllMaxWait);
} on TimeoutException {
Logger.w('Translation',
'停止翻译超过 ${_stopAllMaxWait.inSeconds}s 仍未返回,先退出页面,收尾继续在后台进行');
} catch (e) {
Logger.e('Translation', '停止翻译失败(忽略,继续退出): $e');
}
}
Future<void> _handlePop(bool isDarkMode) async { Future<void> _handlePop(bool isDarkMode) async {
// 只有通话翻译在识别中才需要问;其它模式、或通话翻译还没开始识别, // 只有通话翻译在识别中才需要问;其它模式、或通话翻译还没开始识别,
// 跟以前一样直接停止退出。 // 跟以前一样直接停止退出。
final isActiveCall = controller.currentMode.value == 'call' && final isActiveCall = controller.currentMode.value == 'call' &&
(controller.isRecognizing.value || controller.isPreparing.value); (controller.isRecognizing.value || controller.isPreparing.value);
if (!isActiveCall) { if (!isActiveCall) {
await controller.stopAll(); await _stopAllSafely();
Get.back(); Get.back();
_deleteControllerAfterPop(); _deleteControllerAfterPop();
return; return;
@ -183,7 +205,7 @@ class TranslationView extends GetView<TranslationController> {
final action = await _showExitConfirmDialog(isDarkMode); final action = await _showExitConfirmDialog(isDarkMode);
switch (action) { switch (action) {
case _TranslationExitAction.end: case _TranslationExitAction.end:
await controller.stopAll(); await _stopAllSafely();
Get.back(); Get.back();
_deleteControllerAfterPop(); _deleteControllerAfterPop();
return; return;
@ -418,15 +440,26 @@ class TranslationView extends GetView<TranslationController> {
LanguageSelectorBar( LanguageSelectorBar(
sourceText: () => controller.sourceLanguage.value.split(' ')[0], sourceText: () => controller.sourceLanguage.value.split(' ')[0],
targetText: () => controller.targetLanguage.value.split(' ')[0], targetText: () => controller.targetLanguage.value.split(' ')[0],
onTapSource: () => LanguageSelectionDialog.show( // 翻译进行中不开弹窗(见 controller.guardLanguageChange)
true, isDarkMode, onTapSource: () {
excludeLanguage: controller.targetLanguage.value, if (!controller.guardLanguageChange()) return;
translationController: controller), LanguageSelectionDialog.show(true, isDarkMode,
onTapTarget: () => LanguageSelectionDialog.show( excludeLanguage: controller.targetLanguage.value,
false, isDarkMode, translationController: controller);
excludeLanguage: controller.sourceLanguage.value, },
translationController: controller), onTapTarget: () {
if (!controller.guardLanguageChange()) return;
LanguageSelectionDialog.show(false, isDarkMode,
excludeLanguage: controller.sourceLanguage.value,
translationController: controller);
},
onSwap: controller.swapLanguages, onSwap: controller.swapLanguages,
// 同声翻译 / 多媒体翻译是单向的,图标跟着变成单向箭头,
// 免得双向图标让人以为反着说也能翻(见 isOneWayTranslationMode)
swapIcon: () =>
isOneWayTranslationMode(controller.currentMode.value)
? Icons.arrow_forward
: Icons.swap_horiz,
translateLanguageNames: true, // 是否翻译语言名称 translateLanguageNames: true, // 是否翻译语言名称
fontSizeSp: 15, fontSizeSp: 15,
// 面对面翻译:左边胶囊 = 左耳,右边胶囊 = 右耳。 // 面对面翻译:左边胶囊 = 左耳,右边胶囊 = 右耳。

2
apps/client/lib/modules/translation/views/widgets/face_to_face_layout.dart

@ -258,6 +258,8 @@ class _FaceToFaceLayoutState extends State<FaceToFaceLayout> {
/// 显示语言选择对话框 /// 显示语言选择对话框
void _showLanguageSelectionDialog(bool isSource, bool isDarkMode) { void _showLanguageSelectionDialog(bool isSource, bool isDarkMode) {
// 翻译进行中不开弹窗(见 controller.guardLanguageChange)
if (!widget.controller.guardLanguageChange()) return;
LanguageSelectionDialog.show(isSource, isDarkMode, LanguageSelectionDialog.show(isSource, isDarkMode,
excludeLanguage: isSource excludeLanguage: isSource
? widget.controller.targetLanguage.value ? widget.controller.targetLanguage.value

17
apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt

@ -670,17 +670,12 @@ class DoubaoAstCallback(
"finalSrcHit=${s.finalSourceTextHit} finalTransHit=${s.finalTranslatedTextHit} " + "finalSrcHit=${s.finalSourceTextHit} finalTransHit=${s.finalTranslatedTextHit} " +
"finalText=\"$finalText\" finalAudio=${finalAudio.size}B → markEnd()" "finalText=\"$finalText\" finalAudio=${finalAudio.size}B → markEnd()"
) )
eventSender.send( // 只清缓存,**不发 "translated"**:finalText 是 helper 的 fullTextBuffer——整个会话
mapOf( // 每一句 `.done` 译文拼起来的全文,每一句早已各自经 onFinalTranslatedText 上报过。
"type" to "translated", // utteranceId 又是会话 id、对不上任何条目,Dart 侧只能新建一条「原文为空、
"serviceId" to serviceId, // 译文是全部句子拼接」的记录,通话一结束界面上就多出 A/B 各一大段乱文。
"direction" to direction, // 与 iOS AzureSpeechPlugin.swift 的 onSessionFinished 同改(2026-09-18)。
"utteranceId" to sessionId, doubaoFinalSourceTextCache.remove("$serviceId:$sessionId")
"originalText" to (doubaoFinalSourceTextCache.remove("$serviceId:$sessionId") ?: ""),
"translatedText" to finalText,
"targetLanguage" to targetLanguage
)
)
// if (finalAudio.isNotEmpty()) { // if (finalAudio.isNotEmpty()) {
// audioWriter?.write(finalAudio) // audioWriter?.write(finalAudio)

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

@ -52,6 +52,17 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
// 用于 BLE 写入的单独 IO 协程作用域 // 用于 BLE 写入的单独 IO 协程作用域
private val bleWriteScope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val bleWriteScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
// TTS 的 stop / dispose 专用串行作用域。
//
// ⚠️ 必须是**单线程**,不能用 Dispatchers.IO:
// AzureTtsHelper.release() 内部先 stop() 再 close() 合成器,两个请求并发落到不同
// 线程上会重复 close 同一个 SpeechSynthesizer。与 iOS 侧那条串行队列同一口径。
private val ttsControlDispatcher =
java.util.concurrent.Executors.newSingleThreadExecutor { r ->
Thread(r, "azure-tts-control").apply { isDaemon = true }
}.asCoroutineDispatcher()
private val ttsControlScope = CoroutineScope(ttsControlDispatcher + SupervisorJob())
// 左右声道各自的互斥锁,避免并发写入争用 // 左右声道各自的互斥锁,避免并发写入争用
private val bleLeftMutex = Mutex() private val bleLeftMutex = Mutex()
private val bleRightMutex = Mutex() private val bleRightMutex = Mutex()
@ -1101,12 +1112,20 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
} }
"stopSpeaking" -> { "stopSpeaking" -> {
// 直接调用stop方法 // ⚠️ 不能在平台线程(Android 主线程)上直接调:helper.stop() 里是
try { // `StopSpeakingAsync().get(1500ms)`,正在播报时会实打实等满。
val success = azureTtsHelper.stop() // 「结束翻译」一次要走 ASR stop(≤1s) + TTS stop(≤1.5s) +
result.success(success) // TTS dispose(内部再 stop 一次,≤1.5s),叠起来逼近 ANR 的 5s 输入阈值,
} catch (e: Exception) { // 表现是点了返回键长时间无响应、甚至系统弹「应用无响应」。
result.error("STOP_ERROR", "停止语音合成失败: ${e.message}", null) // 挪到串行后台线程,result 回主线程给。与 iOS 的 ttsControlQueue 同一口径。
ttsControlScope.launch {
val success = try {
azureTtsHelper.stop()
} catch (e: Exception) {
FileLogger.e(tag, "停止语音合成失败: ${e.message}")
false
}
mainHandler.post { result.success(success) }
} }
} }
@ -1115,8 +1134,15 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware,
} }
"dispose" -> { "dispose" -> {
azureTtsHelper.release() // 同上:release() 内部会先 stop(),同样会阻塞。
result.success(true) ttsControlScope.launch {
try {
azureTtsHelper.release()
} catch (e: Exception) {
FileLogger.e(tag, "释放 TTS 资源失败: ${e.message}")
}
mainHandler.post { result.success(true) }
}
} }
"setAudioOutputDevice" -> { "setAudioOutputDevice" -> {

18
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift

@ -154,8 +154,20 @@ public class AzureAsrHelper: NSObject {
.filter { $0 == "zh" || $0.hasPrefix("zh-") } .filter { $0 == "zh" || $0.hasPrefix("zh-") }
) )
let hasMultipleChineseVariants = chineseVariants.count >= 2 let hasMultipleChineseVariants = chineseVariants.count >= 2
// ⚠️ **Azure 配上了就用 Azure,讯飞只在 Azure 缺凭据时兜底。**
//
// 原来的条件是「语言全在 zh/en 里 + 讯飞配了凭据」就切讯飞。同声翻译为了降延迟
// 只给 ASR 传一种语言(见 Dart 的 _asrLanguagesForCurrentMode),于是源语言选中文
// 时这个条件必然成立 —— 整个同声翻译被悄悄切到讯飞,而这是**实现的副作用,
// 不是产品选择**。
//
// 2026-09-17 真机实测:讯飞的 WebSocket 握手从头到尾没完成,65 帧音频全卡在
// 发送队列里,最后一起以 cancelled 失败(服务端一条 msg_type=action 都没回),
// 表现就是「说了半天一个字都不出」,且不报任何错。Azure 的密钥同日验证有效。
let azureConfigReady = !self.subscriptionKey.isEmpty && !self.region.isEmpty
self.useXunfei = self.useXunfei =
onlySupportedByXunfei && xunfeiConfigReady && !hasMultipleChineseVariants onlySupportedByXunfei && xunfeiConfigReady && !hasMultipleChineseVariants
&& !azureConfigReady
let initMsg = "[XunFei_ASR] ASR initialize: supportedLanguages=\(supportedLanguages.joined(separator: ",")), audioSourceType=\(audioSourceType)" let initMsg = "[XunFei_ASR] ASR initialize: supportedLanguages=\(supportedLanguages.joined(separator: ",")), audioSourceType=\(audioSourceType)"
os_log("%{public}@", log: log, type: .info, initMsg) os_log("%{public}@", log: log, type: .info, initMsg)
@ -902,8 +914,10 @@ public class AzureAsrHelper: NSObject {
self.supportedLanguages = [validatedSourceLang, validatedTargetLang] self.supportedLanguages = [validatedSourceLang, validatedTargetLang]
self.currentLanguage = validatedSourceLang self.currentLanguage = validatedSourceLang
// 更新是否使用讯飞 // 更新是否使用讯飞(口径与 initialize 一致:Azure 可用就不走讯飞)
self.useXunfei = supportedLanguages.allSatisfy { $0 == "zh-CN" || $0 == "en-US" } let azureConfigReady = !self.subscriptionKey.isEmpty && !self.region.isEmpty
self.useXunfei = !azureConfigReady
&& supportedLanguages.allSatisfy { $0 == "zh-CN" || $0 == "en-US" }
// 重新配置语言检测 // 重新配置语言检测
self.isAutoDetectLanguage = (validatedSourceLang != validatedTargetLang) self.isAutoDetectLanguage = (validatedSourceLang != validatedTargetLang)

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

@ -90,6 +90,19 @@ import ble_service
private var ttsEventChannel: FlutterEventChannel? private var ttsEventChannel: FlutterEventChannel?
internal var ttsEventSink: FlutterEventSink? internal var ttsEventSink: FlutterEventSink?
private let azureTtsHelper = AzureTtsHelper() private let azureTtsHelper = AzureTtsHelper()
/// TTS 的「停止 / 释放」专用串行队列。
///
/// ⚠️ **这两个调用绝不能在 Flutter 平台线程(主线程)上跑。**
/// `AzureTtsHelper.stop()` 内部是 `audioPlaybackQueue.sync`,而那条队列上正跑着
/// `scheduleAvailablePcmBuffers → ensureStreamingEngineIfNeeded →
/// applyAudioSessionForTTS`,后者又是 `audioSessionQueue.sync`,里面是
/// AVAudioSession 的 setCategory/setActive(带 3 次重试 + Thread.sleep)。
/// 于是主线程被两层串行队列拖住 —— 表现不是「某个按钮没反应」,而是**整个界面冻住**:
/// 同声翻译点完「结束」再点返回,返回键点不动,正是这条。
/// `dispose()` 更狠,它还有 `synthesisGroup.wait()`。
/// 放到自己的串行队列上,既不阻塞 UI,多次 stop 之间也仍然有序。
private let ttsControlQueue = DispatchQueue(label: "azure_speech.tts_control")
// 是否已添加TTS事件监听器 // 是否已添加TTS事件监听器
private var isTtsListenerAdded = false private var isTtsListenerAdded = false
@ -409,6 +422,20 @@ internal func handlePcmPlayerMethodCall(_ call: FlutterMethodCall, result: @esca
case "dispose": case "dispose":
pcmSpeakerPlayer.dispose() pcmSpeakerPlayer.dispose()
result(nil) result(nil)
case "inputFormat":
// 给 Dart 侧 MicPcmStreamer 探路:record 插件会拿输入格式去 installTap,
// 0Hz 时那是 ObjC 断言必崩,所以开麦前先在这里看一眼。
//
// ⚠️ **不能**为了探格式临时 `AVAudioEngine().inputNode`:碰一下 inputNode 就会
// 实例化 IO 单元并向音频会话登记,语句一结束引擎又立刻释放;紧接着会话
// 切类别/路由(EmaiAudioSession.ensure 正在做的事)发出的配置变更通知
// 会投递给这个已经死掉的引擎,主线程 `AVAudioIONodeImpl::AUI()` 直接
// EXC_BAD_ACCESS——2026-09-18 真机复现的「EMAI 点开始对话就闪退」就是它,
// 而且 Dart 侧最多轮询 12 次,等于连造 12 个临时引擎。
// 改问会话本身:硬件采样率 + 当前路由的输入声道数,没有输入时就是 0。
let session = AVAudioSession.sharedInstance()
let rate = session.isInputAvailable ? session.sampleRate : 0
result(["sampleRate": rate, "channels": session.inputNumberOfChannels])
default: default:
result(FlutterMethodNotImplemented) result(FlutterMethodNotImplemented)
} }
@ -959,14 +986,20 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
result(success) result(success)
case "stopSpeaking": case "stopSpeaking":
let success = azureTtsHelper.stop() // 见 ttsControlQueue 的说明:不能在平台线程上等它,会冻住整个界面。
result(success) // 立刻回 true,真正的收尾在队列上跑完;调用方(stopAll)不依赖它的返回值。
ttsControlQueue.async { [weak self] in
_ = self?.azureTtsHelper.stop()
}
result(true)
case "isSpeaking": case "isSpeaking":
result(azureTtsHelper.isSpeaking()) result(azureTtsHelper.isSpeaking())
case "dispose": case "dispose":
azureTtsHelper.dispose() ttsControlQueue.async { [weak self] in
self?.azureTtsHelper.dispose()
}
result(true) result(true)
case "setAudioOutputDevice": case "setAudioOutputDevice":
guard let args = call.arguments as? [String: Any], guard let args = call.arguments as? [String: Any],
@ -1855,15 +1888,7 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback {
// 段尾合约(与 Android markEnd 对齐):**不**重复写 finalAudio,只 emit 空 PCM + isFinal=true。 // 段尾合约(与 Android markEnd 对齐):**不**重复写 finalAudio,只 emit 空 PCM + isFinal=true。
// partial 阶段已累积到 native,这里只是告诉 device_jieli “这段结束了,可以整段下发”。 // partial 阶段已累积到 native,这里只是告诉 device_jieli “这段结束了,可以整段下发”。
plugin?.pushAstTtsFrame(leg: serviceId, pcm: Data(), isFinal: true) plugin?.pushAstTtsFrame(leg: serviceId, pcm: Data(), isFinal: true)
plugin?.sendAstEvent([ // 不再发 "translated":finalText 是整个会话的拼接全文,理由见下方 AliyunCallback 的同名方法。
"type": "translated",
"serviceId": serviceId,
"direction": direction,
"translatedText": finalText,
"targetLanguage": targetLanguage,
"utteranceId": sessionId,
"originalText": original
])
} }
func onSessionError(sessionId: String, code: Int, message: String) { func onSessionError(sessionId: String, code: Int, message: String) {
@ -2001,15 +2026,11 @@ private class AliyunCallbackProxy: AliyunBailianE2EHelper.Callback {
log: ctLog, type: .info, serviceId, finalText, original, finalAudio.count) log: ctLog, type: .info, serviceId, finalText, original, finalAudio.count)
// 段尾合约(与 Android markEnd 对齐):**不**重复写 finalAudio,只 emit 空 PCM + isFinal=true。 // 段尾合约(与 Android markEnd 对齐):**不**重复写 finalAudio,只 emit 空 PCM + isFinal=true。
plugin?.pushAstTtsFrame(leg: serviceId, pcm: Data(), isFinal: true) plugin?.pushAstTtsFrame(leg: serviceId, pcm: Data(), isFinal: true)
plugin?.sendAstEvent([ // ⚠️ 这里**不能**再发 "translated" 事件。finalText 是 helper 的 fullTextBuffer——
"type": "translated", // 整个会话里每一句 `.done` 译文用空格拼起来的全文,而每一句早就各自经
"serviceId": serviceId, // onFinalTranslatedText 上报过了。utteranceId 又是会话 id、对不上任何条目,
"direction": direction, // Dart 侧只能新建一条「原文为空、译文是全部句子拼接」的记录:通话一结束
"translatedText": finalText, // 界面上就多出 A/B 各一大段乱文(2026-09-18 用户报的「结束后出现一段总结」)。
"targetLanguage": targetLanguage,
"utteranceId": sessionId,
"originalText": original
])
} }
func onSessionError(sessionId: String, code: Int, message: String) { func onSessionError(sessionId: String, code: Int, message: String) {

22
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift

@ -79,6 +79,24 @@ public class AzureTtsHelper: NSObject, ITtsService, AVAudioPlayerDelegate {
private var scheduledBufferCount = 0 private var scheduledBufferCount = 0
private let streamChunkBytes = 3200 private let streamChunkBytes = 3200
private var suppressStreamPlayback = false private var suppressStreamPlayback = false
/// 正在执行 stop() 的窗口内为 true —— 播放队列上的调度看到它立刻返回。
///
/// ⚠️ stop() 要 `audioPlaybackQueue.sync` 才能拆掉流式引擎,而那条队列上
/// 常常正卡在 `ensureStreamingEngineIfNeeded → applyAudioSessionForTTS`
/// (AVAudioSession 的 setCategory/setActive,带重试和 sleep)。不短路的话
/// stop 要一直等到那段音频会话折腾完。
/// 只在 stop() 自己的执行期间为 true,出了 stop() 立刻复位,
/// 不会出现「标志忘了清、以后再也不出声」。
private let stopFlagLock = NSLock()
private var _stopInProgress = false
private var stopInProgress: Bool {
stopFlagLock.lock(); defer { stopFlagLock.unlock() }
return _stopInProgress
}
private func setStopInProgress(_ v: Bool) {
stopFlagLock.lock(); _stopInProgress = v; stopFlagLock.unlock()
}
private var pushStreamChunkCount = 0 private var pushStreamChunkCount = 0
private var pushStreamCallbackCount = 0 private var pushStreamCallbackCount = 0
private var synthEventAudioCount = 0 private var synthEventAudioCount = 0
@ -335,6 +353,8 @@ public class AzureTtsHelper: NSObject, ITtsService, AVAudioPlayerDelegate {
*/ */
public func stop() -> Bool { public func stop() -> Bool {
os_log("liwei------------- 停止语音合成 session=%{public}@", log: self.log, type: .info, sessionid) os_log("liwei------------- 停止语音合成 session=%{public}@", log: self.log, type: .info, sessionid)
setStopInProgress(true)
defer { setStopInProgress(false) }
speaking = false speaking = false
sessionid = "" sessionid = ""
synthesisSessionIdSnapshot = "" synthesisSessionIdSnapshot = ""
@ -883,6 +903,8 @@ public class AzureTtsHelper: NSObject, ITtsService, AVAudioPlayerDelegate {
* - Throws: 无 * - Throws: 无
*/ */
private func scheduleAvailablePcmBuffers() { private func scheduleAvailablePcmBuffers() {
// 正在停止就别再去建引擎/激活会话了 —— stop() 正等着这条队列(见 stopInProgress)
if stopInProgress { return }
guard ensureStreamingEngineIfNeeded() else { return } guard ensureStreamingEngineIfNeeded() else { return }
if #available(iOS 18.0, *) { if #available(iOS 18.0, *) {

56
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/XunFeiAsrHelper.swift

@ -89,6 +89,20 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
return !appId.isEmpty && !accessKeyId.isEmpty && !accessKeySecret.isEmpty return !appId.isEmpty && !accessKeyId.isEmpty && !accessKeySecret.isEmpty
} }
/// 本轮是否已经就「连接被我们自己取消」记过一条日志,见 sendAudio
private var loggedCancelOnce = false
/// 握手看门狗的超时时长,见 start()。
/// 不用 Timer:`start()` 不保证在带 run loop 的线程上被调用,Timer 可能永远不触发。
private static let handshakeTimeout: TimeInterval = 6
/// 这个 error 是不是「我们自己 cancel 掉连接」造成的(NSURLErrorCancelled)。
/// 是的话不该报给用户 —— 用户看到的会是一条指向网络故障的红条,而实际什么事都没有。
static func isCancellationError(_ error: Error) -> Bool {
let e = error as NSError
return e.domain == NSURLErrorDomain && e.code == NSURLErrorCancelled
}
func start(callback: AzureAsrHelper.ContinuousRecognizeCallback, language: String, supportedLanguages: [String], isAutoDetect: Bool, isRemoveFirstPunctuation: Bool) { func start(callback: AzureAsrHelper.ContinuousRecognizeCallback, language: String, supportedLanguages: [String], isAutoDetect: Bool, isRemoveFirstPunctuation: Bool) {
_log("INFO", "[XunFei_ASR] start() called: lang=\(language), supportedLangs=\(supportedLanguages.joined(separator: ",")), autoDetect=\(isAutoDetect)") _log("INFO", "[XunFei_ASR] start() called: lang=\(language), supportedLangs=\(supportedLanguages.joined(separator: ",")), autoDetect=\(isAutoDetect)")
@ -103,6 +117,7 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
self.lastIntermediateResult = "" self.lastIntermediateResult = ""
self.sessionId = "" self.sessionId = ""
self.audioFrameCount = 0 self.audioFrameCount = 0
self.loggedCancelOnce = false
if !isConfigValid() { if !isConfigValid() {
_log("ERROR", "[XunFei_ASR] Config invalid: appId=\(!appId.isEmpty), keyId=\(!accessKeyId.isEmpty), secret=\(!accessKeySecret.isEmpty)") _log("ERROR", "[XunFei_ASR] Config invalid: appId=\(!appId.isEmpty), keyId=\(!accessKeyId.isEmpty), secret=\(!accessKeySecret.isEmpty)")
@ -125,6 +140,22 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
webSocketTask = session.webSocketTask(with: request) webSocketTask = session.webSocketTask(with: request)
webSocketTask?.resume() webSocketTask?.resume()
// ⚠️ 握手超时必须有。URLSessionWebSocketTask 在握手完成前会把 send 全部排队,
// 服务端不回应时既不报错也不断开 —— 真机上就这么静静躺了 8.7s(用户停止才暴露),
// 测试人员那次是 41s,全程「说了半天一个字不出」且不报任何错。
// 超时后主动报错,上层才有机会提示或降级。
// 本轮 sessionId 做代际:超时回调醒来时如果已经换了会话、或握手已成功
// (sessionId 被服务端填上了),就什么都不做。
let sid = currentSessionId
DispatchQueue.global().asyncAfter(deadline: .now() + Self.handshakeTimeout) { [weak self] in
guard let self = self, self.currentSessionId == sid, self.sessionId.isEmpty,
self.webSocketTask != nil else { return }
let elapsed = Int(Self.handshakeTimeout * 1000)
self._log("ERROR", "[XunFei_ASR] 握手超时(\(elapsed)ms 内没收到 msg_type=action),本次识别不可用")
self.callback?.onError(sessionid: sid, 1010,
"Xunfei handshake timeout (\(elapsed)ms)")
}
receiveMessage() receiveMessage()
} }
@ -132,10 +163,18 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
guard let webSocketTask = webSocketTask else { return } guard let webSocketTask = webSocketTask else { return }
let message = URLSessionWebSocketTask.Message.data(frameBuffer) let message = URLSessionWebSocketTask.Message.data(frameBuffer)
webSocketTask.send(message) { [weak self] error in webSocketTask.send(message) { [weak self] error in
guard let self = self else { return } guard let self = self, let error = error else { return }
if let error = error { // ⚠️ 停止时把连接 cancel 掉,**所有还排在队列里没发出去的帧**都会在这里
self._log("ERROR", "[XunFei_ASR] Send audio failed (frame #\(self.audioFrameCount)): \(error.localizedDescription)") // 一起报 cancelled。真机上一次就刷了 60 多条一模一样的 ERROR,把真正的
// 错误埋掉了。这类只记一条,并且降级成 INFO —— 它是我们自己停的,不是故障。
if Self.isCancellationError(error) {
if !self.loggedCancelOnce {
self.loggedCancelOnce = true
self._log("INFO", "[XunFei_ASR] 连接已取消,丢弃队列中未发出的音频帧(自身停止,非故障)")
}
return
} }
self._log("ERROR", "[XunFei_ASR] Send audio failed (frame #\(self.audioFrameCount)): \(error.localizedDescription)")
} }
audioFrameCount += 1 audioFrameCount += 1
// 每 200 帧打一条日志 // 每 200 帧打一条日志
@ -167,6 +206,12 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
case .failure(let error): case .failure(let error):
let elapsed = self.connectStartTime.map { Int(Date().timeIntervalSince($0) * 1000) } ?? -1 let elapsed = self.connectStartTime.map { Int(Date().timeIntervalSince($0) * 1000) } ?? -1
let nsError = error as NSError let nsError = error as NSError
// 我们自己停止时会 cancel 连接,这里必然收到一次 -999。
// 它不是故障,报上去只会让用户在停止之后看到一条红色的网络错误。
if Self.isCancellationError(error) {
self._log("INFO", "[XunFei_ASR] 接收循环随连接取消结束(自身停止)")
return
}
self._log("ERROR", "[XunFei_ASR] WebSocket Receive Error (\(elapsed)ms): domain=\(nsError.domain), code=\(nsError.code), \(error.localizedDescription)") self._log("ERROR", "[XunFei_ASR] WebSocket Receive Error (\(elapsed)ms): domain=\(nsError.domain), code=\(nsError.code), \(error.localizedDescription)")
self.callback?.onError(sessionid: self.currentSessionId, 1004, self.callback?.onError(sessionid: self.currentSessionId, 1004,
"Xunfei connection failed (\(elapsed)ms): [\(nsError.domain):\(nsError.code)] \(error.localizedDescription)") "Xunfei connection failed (\(elapsed)ms): [\(nsError.domain):\(nsError.code)] \(error.localizedDescription)")
@ -210,6 +255,11 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate {
if let error = error { if let error = error {
let elapsed = connectStartTime.map { Int(Date().timeIntervalSince($0) * 1000) } ?? -1 let elapsed = connectStartTime.map { Int(Date().timeIntervalSince($0) * 1000) } ?? -1
let nsError = error as NSError let nsError = error as NSError
// 同 receiveMessage:-999 是我们自己 cancel 的,不报给用户
if Self.isCancellationError(error) {
_log("INFO", "[XunFei_ASR] 连接已按请求关闭(\(elapsed)ms)")
return
}
_log("ERROR", "[XunFei_ASR] URLSession task failed (\(elapsed)ms): domain=\(nsError.domain), code=\(nsError.code), \(error.localizedDescription)") _log("ERROR", "[XunFei_ASR] URLSession task failed (\(elapsed)ms): domain=\(nsError.domain), code=\(nsError.code), \(error.localizedDescription)")
callback?.onError(sessionid: currentSessionId, 1004, callback?.onError(sessionid: currentSessionId, 1004,
"Xunfei URLSession failed (\(elapsed)ms): [\(nsError.domain):\(nsError.code)] \(error.localizedDescription)") "Xunfei URLSession failed (\(elapsed)ms): [\(nsError.domain):\(nsError.code)] \(error.localizedDescription)")

109
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MediaKeepAlivePlayer.swift

@ -0,0 +1,109 @@
import AVFoundation
import Foundation
import os.log
/// 多媒体翻译(音视频翻译)模式下让 App 在后台活着的静音播放器。
///
/// 这个模式的音频来自录屏广播扩展(走本地 socket 进来),主 App 自己**没有任何
/// 真实的音频 I/O**:麦克风被显式停掉、也没有播放器。Info.plist 的
/// `UIBackgroundModes: audio` 只保活「正在播放或录音」的进程,一个激活着却空转的
/// AVAudioSession 不算数。于是用户把视频 App 切到全屏、我们退到后台的那一刻进程就被
/// 挂起,讯飞/Azure 的 WebSocket 被系统掐断,回到前台看到的就是
/// `Xunfei URLSession failed … [NSPOSIXErrorDomain:53] Software caused connection abort`。
///
/// 这里起一台只挂了一个 AVAudioPlayerNode 的引擎,循环播一段全零 buffer、混音器音量 0。
/// 会话本来就是 `.playAndRecord + .mixWithOthers`(不打断正在播的视频),静音输出对
/// 用户零感知,但对系统来说进程「正在播放」,后台就不会被挂起。
///
/// ⚠️ 不要碰 `engine.inputNode`:一碰就会让引擎去配置输入链路,这个模式下没有输入。
final class MediaKeepAlivePlayer {
private let log = OSLog(subsystem: "com.azure.speech", category: "MediaKeepAlive")
private let queue = DispatchQueue(label: "azure_speech.media_keepalive")
private var engine: AVAudioEngine?
private var node: AVAudioPlayerNode?
private var observers: [NSObjectProtocol] = []
private var wanted = false
func start() {
queue.async { [weak self] in
guard let self = self else { return }
self.wanted = true
self.startEngineLocked()
self.installObserversLocked()
}
}
func stop() {
queue.async { [weak self] in
guard let self = self else { return }
self.wanted = false
self.removeObserversLocked()
self.node?.stop()
self.engine?.stop()
self.node = nil
self.engine = nil
}
}
// MARK: - 内部(都在 queue 上)
private func startEngineLocked() {
if let e = engine, e.isRunning { return }
node?.stop()
engine?.stop()
let e = AVAudioEngine()
let n = AVAudioPlayerNode()
e.attach(n)
let fmt = e.mainMixerNode.outputFormat(forBus: 0)
// 0Hz(会话未激活/路由切换瞬间)拿去 connect 是 ObjC 断言,Swift 接不住
guard fmt.sampleRate > 0, fmt.channelCount > 0 else {
os_log("保活引擎启动失败:混音器输出格式无效", log: log, type: .error)
return
}
e.connect(n, to: e.mainMixerNode, format: fmt)
e.mainMixerNode.outputVolume = 0
let frames = AVAudioFrameCount(fmt.sampleRate) // 1 秒静音
guard let buffer = AVAudioPCMBuffer(pcmFormat: fmt, frameCapacity: frames) else {
os_log("保活引擎启动失败:分配静音缓冲失败", log: log, type: .error)
return
}
buffer.frameLength = frames // 新分配的 buffer 内容即全零
do {
try e.start()
} catch {
os_log("保活引擎启动失败: %{public}@", log: log, type: .error, error.localizedDescription)
return
}
n.scheduleBuffer(buffer, at: nil, options: .loops, completionHandler: nil)
n.play()
engine = e
node = n
os_log("后台保活引擎已启动", log: log, type: .info)
}
private func installObserversLocked() {
guard observers.isEmpty else { return }
let center = NotificationCenter.default
// 系统打断(来电/Siri)结束后引擎是停着的,要重新拉起来
observers.append(center.addObserver(forName: AVAudioSession.interruptionNotification,
object: nil, queue: nil) { [weak self] n in
guard let self = self,
let raw = n.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
AVAudioSession.InterruptionType(rawValue: raw) == .ended else { return }
self.queue.async { if self.wanted { self.startEngineLocked() } }
})
// 路由/采样率变化时 AVAudioEngine 会自己停掉并发这个通知
observers.append(center.addObserver(forName: .AVAudioEngineConfigurationChange,
object: nil, queue: nil) { [weak self] _ in
guard let self = self else { return }
self.queue.async { if self.wanted { self.startEngineLocked() } }
})
}
private func removeObserversLocked() {
observers.forEach { NotificationCenter.default.removeObserver($0) }
observers.removeAll()
}
}

14
apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift

@ -55,6 +55,8 @@ public class SimpleAudioReceiver: NSObject {
//public var onAudioData: ((Data) -> Void)? //public var onAudioData: ((Data) -> Void)?
public var recordfile: RecordFile? public var recordfile: RecordFile?
private var currentRecognitionMode = "normal" // 当前识别模式:normal, ble_wakeup, phone_call, push_to_talk private var currentRecognitionMode = "normal" // 当前识别模式:normal, ble_wakeup, phone_call, push_to_talk
/// media 模式的后台保活(见 MediaKeepAlivePlayer 的说明),其它模式恒为 nil
private var mediaKeepAlive: MediaKeepAlivePlayer?
public var isHeadphones = true public var isHeadphones = true
/** /**
@ -417,6 +419,16 @@ public class SimpleAudioReceiver: NSObject {
// 停止麦克风采集,确保不走通话音道 // 停止麦克风采集,确保不走通话音道
micCapture.stopCapture() micCapture.stopCapture()
} }
// ⚠️ 多媒体翻译:音频来自录屏广播扩展,主 App 自己没有任何音频 I/O,
// 光靠 UIBackgroundModes: audio + 一个激活的会话保不住后台 —— 退后台即被挂起,
// ASR 的 WebSocket 被系统掐断(ECONNABORTED)。起一台静音引擎让进程算「正在播放」。
if currentRecognitionMode == "media" {
if mediaKeepAlive == nil { mediaKeepAlive = MediaKeepAlivePlayer() }
mediaKeepAlive?.start()
} else {
mediaKeepAlive?.stop()
mediaKeepAlive = nil
}
} }
/** /**
@ -506,6 +518,8 @@ public func restoreOriginalAudioState() {
self.writeQueue.clear() self.writeQueue.clear()
_isWriting=false _isWriting=false
micCapture.stopCapture() micCapture.stopCapture()
mediaKeepAlive?.stop()
mediaKeepAlive = nil
print("stopMicrophoneCapture") print("stopMicrophoneCapture")
} }

2
apps/client/pubspec.yaml

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

23
apps/client/test/deapsound_ai_url_test.dart

@ -0,0 +1,23 @@
import 'package:eaimar/data/services/deapsound_ai_service.dart';
import 'package:flutter_test/flutter_test.dart';
/// 后台 llm_qwen.base_url 是不带路径的根地址,客户端要自己补 /chat/completions;
/// 万一运维填了完整地址也不能拼成两段。
void main() {
Uri build(String base) {
final s = DeapsoundAIService.debugWithBaseUrl(base);
return s.chatUrl;
}
test('根地址补路径', () {
expect(build('https://dashscope.aliyuncs.com/compatible-mode/v1').toString(),
'https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions');
});
test('尾部斜杠不重复', () {
expect(build('https://x/v1/').toString(), 'https://x/v1/chat/completions');
});
test('已带完整路径原样用', () {
expect(build('https://x/v1/chat/completions').toString(),
'https://x/v1/chat/completions');
});
}

55
apps/client/test/login_methods_test.dart

@ -0,0 +1,55 @@
import 'package:eaimar/modules/login/models/login_methods.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
Set<LoginMethod> ios({bool guest = true}) =>
resolveLoginMethods(isIOS: true, envChannel: 'CHINA_XIAOMI', guestVisible: guest);
Set<LoginMethod> google({bool guest = true}) => resolveLoginMethods(
isIOS: false, envChannel: 'GLOBAL_GOOGLE', guestVisible: guest);
Set<LoginMethod> domestic(String ch, {bool guest = true}) =>
resolveLoginMethods(isIOS: false, envChannel: ch, guestVisible: guest);
test('苹果包:只有苹果登录 + 游客', () {
expect(ios(), {LoginMethod.apple, LoginMethod.guest});
});
test('苹果包不受 .env Channel 影响(iOS 不跑 build_channels.sh,值还是国内渠道)', () {
expect(
resolveLoginMethods(
isIOS: true, envChannel: 'GLOBAL_GOOGLE', guestVisible: false),
{LoginMethod.apple},
);
});
test('安卓国内包:只有微信登录 + 游客', () {
for (final ch in ['CHINA_XIAOMI', 'CHINA_HUAWEI', 'CHINA_HONOR', 'OFFICIAL']) {
expect(domestic(ch), {LoginMethod.wechat, LoginMethod.guest},
reason: 'channel=$ch');
}
});
test('谷歌包:只有邮箱(contact)登录 + 游客,没有微信也没有苹果', () {
expect(google(), {LoginMethod.contact, LoginMethod.guest});
});
test('Channel 认不出时按国内包处理(失败方向:宁可留微信,不要露出邮箱登录)', () {
expect(domestic(''), {LoginMethod.wechat, LoginMethod.guest});
expect(domestic('xiaomi'), {LoginMethod.wechat, LoginMethod.guest});
});
test('Channel 的大小写与空白不影响判定', () {
expect(domestic(' global_google ', guest: false), {LoginMethod.contact});
});
test('游客开关由服务端给,关掉时每个包各自只剩一个入口', () {
expect(ios(guest: false), {LoginMethod.apple});
expect(google(guest: false), {LoginMethod.contact});
expect(domestic('CHINA_VIVO', guest: false), {LoginMethod.wechat});
});
test('任何组合下都至少有一个登录入口,且三种包互不重叠', () {
for (final m in [ios(), google(), domestic('CHINA_OPPO')]) {
expect(m.where((e) => e != LoginMethod.guest).length, 1);
}
});
}

58
apps/client/test/stereo_pairer_test.dart

@ -0,0 +1,58 @@
import 'dart:typed_data';
import 'package:eaimar/data/services/wav_writer.dart';
import 'package:flutter_test/flutter_test.dart';
Uint8List _bytes(int n, int fill) => Uint8List.fromList(List.filled(n, fill));
void main() {
test('两路顺序到达:逐对取出,谁都不丢', () {
final p = StereoPairer();
p.pushLeft(_bytes(640, 1));
expect(p.take(), isNull); // 只有一路时不出对
p.pushRight(_bytes(640, 2));
final pair = p.take()!;
expect(pair.left.length, 640);
expect(pair.right.length, 640);
expect(pair.left.first, 1);
expect(pair.right.first, 2);
expect(p.take(), isNull);
});
test('对端连来两帧、本端才来一帧:第一帧不再被覆盖丢掉', () {
final p = StereoPairer();
p.pushRight(_bytes(640, 2));
p.pushRight(_bytes(640, 3));
p.pushLeft(_bytes(640, 1));
final first = p.take()!;
expect(first.right.first, 2); // 老实现这里会拿到 3(第一帧被覆盖)
expect(p.take(), isNull);
p.pushLeft(_bytes(640, 1));
final second = p.take()!;
expect(second.right.first, 3);
});
test('长度不等时按较短者对齐到整样本,余量留到下一次', () {
final p = StereoPairer();
p.pushLeft(_bytes(1000, 1));
p.pushRight(_bytes(301, 2));
final a = p.take()!;
expect(a.left.length, 300);
expect(a.right.length, 300);
p.pushRight(_bytes(699, 2));
final b = p.take()!;
expect(b.left.length, 700);
expect(b.right.length, 700);
});
test('一路长期缺席:另一路只保留最近 maxPending 字节', () {
final p = StereoPairer(maxPending: 1000);
for (var i = 0; i < 5; i++) {
p.pushLeft(_bytes(640, i));
}
p.pushRight(_bytes(1000, 9));
final pair = p.take()!;
expect(pair.left.length, 1000);
expect(pair.left.last, 4); // 留下的是最新的那一段
});
}

88
apps/client/test/translation_direction_test.dart

@ -0,0 +1,88 @@
import 'package:eaimar/modules/translation/models/translation_direction.dart';
import 'package:flutter_test/flutter_test.dart';
const zh = 'zh-CN';
const en = 'en-US';
void main() {
group('单向模式:同声翻译 / 多媒体翻译', () {
for (final mode in ['simultaneous', 'audioVideo']) {
test('$mode 永远是 源→目标,与 ASR 报的语种无关', () {
// ASR 报对了
expect(
resolveTranslationDirection(
mode: mode,
sourceCode: zh,
targetCode: en,
detectedLanguageCode: zh),
const TranslationDirection(zh, en),
);
// ASR 没报(空串)—— 老实现在这里会判成反向
expect(
resolveTranslationDirection(
mode: mode, sourceCode: zh, targetCode: en),
const TranslationDirection(zh, en),
);
// ASR 报成了目标语言 —— 老实现同样会反向
expect(
resolveTranslationDirection(
mode: mode,
sourceCode: zh,
targetCode: en,
detectedLanguageCode: en),
const TranslationDirection(zh, en),
);
// 大小写/地区不一致
expect(
resolveTranslationDirection(
mode: mode,
sourceCode: zh,
targetCode: en,
detectedLanguageCode: 'zh'),
const TranslationDirection(zh, en),
);
});
}
test('换了语言对照样只认源→目标', () {
expect(
resolveTranslationDirection(
mode: 'simultaneous', sourceCode: en, targetCode: zh),
const TranslationDirection(en, zh),
);
});
});
group('面对面仍是双向', () {
test('说话人 1 → 源到目标;说话人 2 → 目标到源', () {
expect(
resolveTranslationDirection(
mode: 'faceToFace', sourceCode: zh, targetCode: en, speaker: 1),
const TranslationDirection(zh, en),
);
expect(
resolveTranslationDirection(
mode: 'faceToFace', sourceCode: zh, targetCode: en, speaker: 2),
const TranslationDirection(en, zh),
);
});
test('说话人未知时才看 ASR 检测结果', () {
expect(
resolveTranslationDirection(
mode: 'faceToFace',
sourceCode: zh,
targetCode: en,
detectedLanguageCode: en),
const TranslationDirection(en, zh),
);
});
});
test('isOneWayTranslationMode 只覆盖这两个模式', () {
expect(isOneWayTranslationMode('simultaneous'), isTrue);
expect(isOneWayTranslationMode('audioVideo'), isTrue);
expect(isOneWayTranslationMode('faceToFace'), isFalse);
expect(isOneWayTranslationMode('call'), isFalse);
});
}

117
apps/client/test/translation_export_test.dart

@ -0,0 +1,117 @@
import 'package:eaimar/modules/translation/models/translation_export.dart';
import 'package:eaimar/modules/translation/models/translation_models.dart';
import 'package:flutter_test/flutter_test.dart';
TranslationItem _item(
DateTime at, {
String src = '你好',
String dst = 'Hello',
String srcLang = 'zh-CN',
String serviceId = '',
bool intermediate = false,
}) =>
TranslationItem(
sourceText: src,
translatedText: dst,
sourceLanguageCode: srcLang,
targetLanguageCode: srcLang == 'zh-CN' ? 'en-US' : 'zh-CN',
timestamp: at,
sessionId: 's',
serviceId: serviceId,
isIntermediate: intermediate,
);
void main() {
final t0 = DateTime(2026, 9, 18, 10, 0, 0);
test('只取录音开始之后的整句,时间是相对录音起点的毫秒', () {
final out = buildMeetingTranscript(
items: [
_item(t0.subtract(const Duration(seconds: 5)), src: '上一轮的'),
_item(t0.add(const Duration(seconds: 2))),
_item(t0.add(const Duration(seconds: 6)), src: '半句', intermediate: true),
_item(t0.add(const Duration(seconds: 9)), src: '第二句', dst: 'Second'),
],
recordingStartedAt: t0,
mode: 'simultaneous',
ownLanguageCode: 'zh-CN',
);
expect(out.length, 2);
expect(out[0]['starttime'], 2000);
expect(out[0]['endtime'], 9000); // 下一句开头
expect(out[0]['content'], '你好\nHello');
expect(out[0]['speaker'], ''); // 同传单向,不标说话人
expect(out[1]['starttime'], 9000);
expect(out[1]['endtime'], greaterThan(9000)); // 最后一句按字数估
});
test('通话翻译按 AST 的 serviceId 分说话人:A=0 本端 B=1 对端', () {
final out = buildMeetingTranscript(
items: [
_item(t0.add(const Duration(seconds: 1)), serviceId: 'A'),
_item(t0.add(const Duration(seconds: 3)),
src: 'Hi', dst: '嗨', srcLang: 'en-US', serviceId: 'B'),
],
recordingStartedAt: t0,
mode: 'call',
ownLanguageCode: 'zh-CN',
);
expect(out.map((e) => e['speaker']), ['Speaker_0', 'Speaker_1']);
});
test('面对面没有 serviceId,按这句话的语言分:说源语言的是本端', () {
final out = buildMeetingTranscript(
items: [
_item(t0.add(const Duration(seconds: 1))),
_item(t0.add(const Duration(seconds: 3)),
src: 'Hi', dst: '嗨', srcLang: 'en-US'),
],
recordingStartedAt: t0,
mode: 'faceToFace',
ownLanguageCode: 'zh-CN',
);
expect(out.map((e) => e['speaker']), ['Speaker_0', 'Speaker_1']);
});
test('暂停区间要从偏移里扣掉(面对面逐句松手即暂停)', () {
final out = buildMeetingTranscript(
items: [
_item(t0.add(const Duration(seconds: 2))),
// 3s~13s 暂停了 10 秒,这句在 15s 说的,音频里其实是第 5 秒
_item(t0.add(const Duration(seconds: 15)), src: '第二句', dst: ''),
],
recordingStartedAt: t0,
mode: 'faceToFace',
ownLanguageCode: 'zh-CN',
pauses: [
(t0.add(const Duration(seconds: 3)), t0.add(const Duration(seconds: 13)))
],
);
expect(out[0]['starttime'], 2000);
expect(out[0]['endtime'], 5000);
expect(out[1]['starttime'], 5000);
expect(out[1]['content'], '第二句'); // 没译文就只有原文一行
});
test('译文与原文相同时不重复', () {
final out = buildMeetingTranscript(
items: [_item(t0.add(const Duration(seconds: 1)), src: 'OK', dst: 'OK')],
recordingStartedAt: t0,
mode: 'simultaneous',
ownLanguageCode: 'zh-CN',
);
expect(out.single['content'], 'OK');
});
test('没有整句就返回空,调用方据此退回普通录音归档', () {
expect(
buildMeetingTranscript(
items: [_item(t0.add(const Duration(seconds: 1)), src: ' ')],
recordingStartedAt: t0,
mode: 'call',
ownLanguageCode: 'zh-CN',
),
isEmpty,
);
});
}
Loading…
Cancel
Save