diff --git a/apps/client/.gitignore b/apps/client/.gitignore index ced95f15..411d23e4 100644 --- a/apps/client/.gitignore +++ b/apps/client/.gitignore @@ -72,3 +72,8 @@ app.*.map.json # 多渠道打包产物(build_channels.sh 的默认输出目录) build_outputs/ + +# 本地插件(库)的 pubspec.lock 不入库——仓库里其它插件都没有, +# 主工程 apps/client/pubspec.lock 才该锁版本。 +# 不写这条的话,一次 `git add apps/client` 就会把它们顺手带进提交。 +local_plugins/**/pubspec.lock diff --git a/apps/client/ios/Runner.xcodeproj/project.pbxproj b/apps/client/ios/Runner.xcodeproj/project.pbxproj index b676f650..29d91f93 100644 --- a/apps/client/ios/Runner.xcodeproj/project.pbxproj +++ b/apps/client/ios/Runner.xcodeproj/project.pbxproj @@ -641,7 +641,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = B8AJQ7ACU7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -835,7 +835,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = B8AJQ7ACU7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; @@ -869,7 +869,7 @@ CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = B8AJQ7ACU7; ENABLE_BITCODE = NO; INFOPLIST_FILE = Runner/Info.plist; diff --git a/apps/client/lib/core/translations/language/ar_sa.dart b/apps/client/lib/core/translations/language/ar_sa.dart index b8a4c387..20f8bce3 100644 --- a/apps/client/lib/core/translations/language/ar_sa.dart +++ b/apps/client/lib/core/translations/language/ar_sa.dart @@ -2073,4 +2073,5 @@ const Map arSA = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'الترجمة قيد التشغيل. أوقفها قبل تغيير اللغة.', }; diff --git a/apps/client/lib/core/translations/language/bg_bg.dart b/apps/client/lib/core/translations/language/bg_bg.dart index 3a9c38f2..9e2deb5b 100644 --- a/apps/client/lib/core/translations/language/bg_bg.dart +++ b/apps/client/lib/core/translations/language/bg_bg.dart @@ -2136,4 +2136,5 @@ const Map bgBG = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Преводът е в ход. Спрете го, преди да смените езика.', }; diff --git a/apps/client/lib/core/translations/language/cs_cz.dart b/apps/client/lib/core/translations/language/cs_cz.dart index ec485a7d..0a67417a 100644 --- a/apps/client/lib/core/translations/language/cs_cz.dart +++ b/apps/client/lib/core/translations/language/cs_cz.dart @@ -2104,4 +2104,5 @@ const Map csCZ = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Překlad běží. Před změnou jazyka jej zastavte.', }; diff --git a/apps/client/lib/core/translations/language/da_dk.dart b/apps/client/lib/core/translations/language/da_dk.dart index 539b90df..b7e47976 100644 --- a/apps/client/lib/core/translations/language/da_dk.dart +++ b/apps/client/lib/core/translations/language/da_dk.dart @@ -2106,4 +2106,5 @@ const Map daDK = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Oversættelse kører. Stop den, før du skifter sprog.', }; diff --git a/apps/client/lib/core/translations/language/de_de.dart b/apps/client/lib/core/translations/language/de_de.dart index 92993cbe..0f817a4d 100644 --- a/apps/client/lib/core/translations/language/de_de.dart +++ b/apps/client/lib/core/translations/language/de_de.dart @@ -2173,4 +2173,5 @@ const Map deDE = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Übersetzung läuft. Stoppen Sie sie, bevor Sie die Sprache wechseln.', }; diff --git a/apps/client/lib/core/translations/language/el_gr.dart b/apps/client/lib/core/translations/language/el_gr.dart index 55e9b07a..6eb53c52 100644 --- a/apps/client/lib/core/translations/language/el_gr.dart +++ b/apps/client/lib/core/translations/language/el_gr.dart @@ -2140,4 +2140,5 @@ const Map elGR = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Η μετάφραση εκτελείται. Σταματήστε την πριν αλλάξετε γλώσσα.', }; diff --git a/apps/client/lib/core/translations/language/en_us.dart b/apps/client/lib/core/translations/language/en_us.dart index 4502b50b..2290d9ff 100644 --- a/apps/client/lib/core/translations/language/en_us.dart +++ b/apps/client/lib/core/translations/language/en_us.dart @@ -2129,4 +2129,5 @@ const Map enUS = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Translation is running. Stop it before changing languages.', }; diff --git a/apps/client/lib/core/translations/language/es_es.dart b/apps/client/lib/core/translations/language/es_es.dart index 16248043..89d59672 100644 --- a/apps/client/lib/core/translations/language/es_es.dart +++ b/apps/client/lib/core/translations/language/es_es.dart @@ -2167,4 +2167,5 @@ const Map esES = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'La traducción está en curso. Deténla antes de cambiar de idioma.', }; diff --git a/apps/client/lib/core/translations/language/et_ee.dart b/apps/client/lib/core/translations/language/et_ee.dart index 574fc48b..39acc8ee 100644 --- a/apps/client/lib/core/translations/language/et_ee.dart +++ b/apps/client/lib/core/translations/language/et_ee.dart @@ -2100,4 +2100,5 @@ const Map etEE = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Tõlkimine käib. Peata see enne keele vahetamist.', }; diff --git a/apps/client/lib/core/translations/language/fa_ir.dart b/apps/client/lib/core/translations/language/fa_ir.dart index 04b4d44a..25ce6131 100644 --- a/apps/client/lib/core/translations/language/fa_ir.dart +++ b/apps/client/lib/core/translations/language/fa_ir.dart @@ -2091,4 +2091,5 @@ const Map faIR = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'ترجمه در حال اجراست. قبل از تغییر زبان آن را متوقف کنید.', }; diff --git a/apps/client/lib/core/translations/language/fi_fi.dart b/apps/client/lib/core/translations/language/fi_fi.dart index 2f29ea8f..13471d58 100644 --- a/apps/client/lib/core/translations/language/fi_fi.dart +++ b/apps/client/lib/core/translations/language/fi_fi.dart @@ -2110,4 +2110,5 @@ const Map fiFI = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Käännös on käynnissä. Pysäytä se ennen kielen vaihtamista.', }; diff --git a/apps/client/lib/core/translations/language/fr_fr.dart b/apps/client/lib/core/translations/language/fr_fr.dart index f51b2700..8d9c5f36 100644 --- a/apps/client/lib/core/translations/language/fr_fr.dart +++ b/apps/client/lib/core/translations/language/fr_fr.dart @@ -2186,4 +2186,5 @@ const Map frFR = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Traduction en cours. Arrêtez-la avant de changer de langue.', }; diff --git a/apps/client/lib/core/translations/language/hi_in.dart b/apps/client/lib/core/translations/language/hi_in.dart index 63e75ae4..963feac2 100644 --- a/apps/client/lib/core/translations/language/hi_in.dart +++ b/apps/client/lib/core/translations/language/hi_in.dart @@ -2109,4 +2109,5 @@ const Map hiIN = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'अनुवाद चल रहा है। भाषा बदलने से पहले इसे रोकें।', }; diff --git a/apps/client/lib/core/translations/language/hr_hr.dart b/apps/client/lib/core/translations/language/hr_hr.dart index 9205eebd..a0abb9ca 100644 --- a/apps/client/lib/core/translations/language/hr_hr.dart +++ b/apps/client/lib/core/translations/language/hr_hr.dart @@ -2125,4 +2125,5 @@ const Map hrHR = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Prijevod je u tijeku. Zaustavite ga prije promjene jezika.', }; diff --git a/apps/client/lib/core/translations/language/hu_hu.dart b/apps/client/lib/core/translations/language/hu_hu.dart index 76dc0173..a2e65875 100644 --- a/apps/client/lib/core/translations/language/hu_hu.dart +++ b/apps/client/lib/core/translations/language/hu_hu.dart @@ -2128,4 +2128,5 @@ const Map huHU = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'A fordítás fut. Nyelvváltás előtt állítsa le.', }; diff --git a/apps/client/lib/core/translations/language/id_id.dart b/apps/client/lib/core/translations/language/id_id.dart index f0a69442..21b4a0c3 100644 --- a/apps/client/lib/core/translations/language/id_id.dart +++ b/apps/client/lib/core/translations/language/id_id.dart @@ -2109,4 +2109,5 @@ const Map idID = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Terjemahan sedang berjalan. Hentikan dulu sebelum mengganti bahasa.', }; diff --git a/apps/client/lib/core/translations/language/is_is.dart b/apps/client/lib/core/translations/language/is_is.dart index 73aba74a..37d9a57f 100644 --- a/apps/client/lib/core/translations/language/is_is.dart +++ b/apps/client/lib/core/translations/language/is_is.dart @@ -2106,4 +2106,5 @@ const Map isIS = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Þýðing er í gangi. Stöðvaðu hana áður en þú skiptir um tungumál.', }; diff --git a/apps/client/lib/core/translations/language/it_it.dart b/apps/client/lib/core/translations/language/it_it.dart index b1acd342..a9abc503 100644 --- a/apps/client/lib/core/translations/language/it_it.dart +++ b/apps/client/lib/core/translations/language/it_it.dart @@ -2147,4 +2147,5 @@ const Map itIT = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Traduzione in corso. Interrompila prima di cambiare lingua.', }; diff --git a/apps/client/lib/core/translations/language/ja_jp.dart b/apps/client/lib/core/translations/language/ja_jp.dart index a3216602..d337373d 100644 --- a/apps/client/lib/core/translations/language/ja_jp.dart +++ b/apps/client/lib/core/translations/language/ja_jp.dart @@ -1968,4 +1968,5 @@ const Map jaJP = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': '翻訳中です。言語を切り替える前に翻訳を停止してください。', }; diff --git a/apps/client/lib/core/translations/language/ko_kr.dart b/apps/client/lib/core/translations/language/ko_kr.dart index a3315182..f53ccf96 100644 --- a/apps/client/lib/core/translations/language/ko_kr.dart +++ b/apps/client/lib/core/translations/language/ko_kr.dart @@ -1961,4 +1961,5 @@ const Map koKR = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': '번역이 진행 중입니다. 언어를 바꾸려면 먼저 번역을 중지하세요.', }; diff --git a/apps/client/lib/core/translations/language/lt_lt.dart b/apps/client/lib/core/translations/language/lt_lt.dart index 2751a4ba..179c1822 100644 --- a/apps/client/lib/core/translations/language/lt_lt.dart +++ b/apps/client/lib/core/translations/language/lt_lt.dart @@ -2124,4 +2124,5 @@ const Map ltLT = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Vyksta vertimas. Sustabdykite jį prieš keisdami kalbą.', }; diff --git a/apps/client/lib/core/translations/language/lv_lv.dart b/apps/client/lib/core/translations/language/lv_lv.dart index 6f86c369..37420ac0 100644 --- a/apps/client/lib/core/translations/language/lv_lv.dart +++ b/apps/client/lib/core/translations/language/lv_lv.dart @@ -2129,4 +2129,5 @@ const Map lvLV = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Notiek tulkošana. Apturiet to pirms valodas maiņas.', }; diff --git a/apps/client/lib/core/translations/language/ms_my.dart b/apps/client/lib/core/translations/language/ms_my.dart index febc1137..731f5cc8 100644 --- a/apps/client/lib/core/translations/language/ms_my.dart +++ b/apps/client/lib/core/translations/language/ms_my.dart @@ -2113,4 +2113,5 @@ const Map msMY = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Terjemahan sedang berjalan. Hentikan dahulu sebelum menukar bahasa.', }; diff --git a/apps/client/lib/core/translations/language/nb_no.dart b/apps/client/lib/core/translations/language/nb_no.dart index a386e0fa..340864d8 100644 --- a/apps/client/lib/core/translations/language/nb_no.dart +++ b/apps/client/lib/core/translations/language/nb_no.dart @@ -2107,4 +2107,5 @@ const Map nbNO = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Oversettelse pågår. Stopp den før du bytter språk.', }; diff --git a/apps/client/lib/core/translations/language/nl_nl.dart b/apps/client/lib/core/translations/language/nl_nl.dart index 2ca36e3a..95bb289f 100644 --- a/apps/client/lib/core/translations/language/nl_nl.dart +++ b/apps/client/lib/core/translations/language/nl_nl.dart @@ -2161,4 +2161,5 @@ const Map nlNL = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Vertaling loopt. Stop deze voordat u van taal wisselt.', }; diff --git a/apps/client/lib/core/translations/language/pl_pl.dart b/apps/client/lib/core/translations/language/pl_pl.dart index fe665506..aacffca7 100644 --- a/apps/client/lib/core/translations/language/pl_pl.dart +++ b/apps/client/lib/core/translations/language/pl_pl.dart @@ -2135,4 +2135,5 @@ const Map plPL = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Tłumaczenie trwa. Zatrzymaj je przed zmianą języka.', }; diff --git a/apps/client/lib/core/translations/language/pt_pt.dart b/apps/client/lib/core/translations/language/pt_pt.dart index f6105579..6bdbca2e 100644 --- a/apps/client/lib/core/translations/language/pt_pt.dart +++ b/apps/client/lib/core/translations/language/pt_pt.dart @@ -2173,4 +2173,5 @@ const Map ptPT = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'A tradução está em curso. Pare-a antes de mudar de idioma.', }; diff --git a/apps/client/lib/core/translations/language/ro_ro.dart b/apps/client/lib/core/translations/language/ro_ro.dart index 71ec74d5..5d00788c 100644 --- a/apps/client/lib/core/translations/language/ro_ro.dart +++ b/apps/client/lib/core/translations/language/ro_ro.dart @@ -2137,4 +2137,5 @@ const Map roRO = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Traducerea este în curs. Opriți-o înainte de a schimba limba.', }; diff --git a/apps/client/lib/core/translations/language/ru_ru.dart b/apps/client/lib/core/translations/language/ru_ru.dart index 2663a5aa..3da0b223 100644 --- a/apps/client/lib/core/translations/language/ru_ru.dart +++ b/apps/client/lib/core/translations/language/ru_ru.dart @@ -2148,4 +2148,5 @@ const Map ruRU = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Идёт перевод. Остановите его, прежде чем менять язык.', }; diff --git a/apps/client/lib/core/translations/language/sk_sk.dart b/apps/client/lib/core/translations/language/sk_sk.dart index c2db8b33..7addfeb6 100644 --- a/apps/client/lib/core/translations/language/sk_sk.dart +++ b/apps/client/lib/core/translations/language/sk_sk.dart @@ -2118,4 +2118,5 @@ const Map skSK = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Preklad beží. Pred zmenou jazyka ho zastavte.', }; diff --git a/apps/client/lib/core/translations/language/sl_si.dart b/apps/client/lib/core/translations/language/sl_si.dart index a6bda45a..1ae9f208 100644 --- a/apps/client/lib/core/translations/language/sl_si.dart +++ b/apps/client/lib/core/translations/language/sl_si.dart @@ -2121,4 +2121,5 @@ const Map slSI = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Prevajanje poteka. Ustavite ga, preden zamenjate jezik.', }; diff --git a/apps/client/lib/core/translations/language/sr_rs.dart b/apps/client/lib/core/translations/language/sr_rs.dart index cd07a221..729c5bdb 100644 --- a/apps/client/lib/core/translations/language/sr_rs.dart +++ b/apps/client/lib/core/translations/language/sr_rs.dart @@ -2117,4 +2117,5 @@ const Map srRS = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Prevod je u toku. Zaustavite ga pre promene jezika.', }; diff --git a/apps/client/lib/core/translations/language/sv_se.dart b/apps/client/lib/core/translations/language/sv_se.dart index 32ca578d..e88e88b2 100644 --- a/apps/client/lib/core/translations/language/sv_se.dart +++ b/apps/client/lib/core/translations/language/sv_se.dart @@ -2113,4 +2113,5 @@ const Map svSE = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Översättning pågår. Stoppa den innan du byter språk.', }; diff --git a/apps/client/lib/core/translations/language/th_th.dart b/apps/client/lib/core/translations/language/th_th.dart index 07ca643a..f3ac8bcd 100644 --- a/apps/client/lib/core/translations/language/th_th.dart +++ b/apps/client/lib/core/translations/language/th_th.dart @@ -2066,4 +2066,5 @@ const Map thTH = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'กำลังแปลอยู่ โปรดหยุดการแปลก่อนเปลี่ยนภาษา', }; diff --git a/apps/client/lib/core/translations/language/tl_ph.dart b/apps/client/lib/core/translations/language/tl_ph.dart index 2b4e27ed..524d6dad 100644 --- a/apps/client/lib/core/translations/language/tl_ph.dart +++ b/apps/client/lib/core/translations/language/tl_ph.dart @@ -2151,4 +2151,5 @@ const Map tlPH = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Tumatakbo ang pagsasalin. Ihinto muna ito bago magpalit ng wika.', }; diff --git a/apps/client/lib/core/translations/language/tr_tr.dart b/apps/client/lib/core/translations/language/tr_tr.dart index 5fc9e7d7..b3ebf80e 100644 --- a/apps/client/lib/core/translations/language/tr_tr.dart +++ b/apps/client/lib/core/translations/language/tr_tr.dart @@ -2107,4 +2107,5 @@ const Map trTR = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Çeviri devam ediyor. Dili değiştirmeden önce çeviriyi durdurun.', }; diff --git a/apps/client/lib/core/translations/language/uk_ua.dart b/apps/client/lib/core/translations/language/uk_ua.dart index e8f38c77..eea573c1 100644 --- a/apps/client/lib/core/translations/language/uk_ua.dart +++ b/apps/client/lib/core/translations/language/uk_ua.dart @@ -2125,4 +2125,5 @@ const Map ukUA = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Триває переклад. Зупиніть його, перш ніж змінювати мову.', }; diff --git a/apps/client/lib/core/translations/language/vi_vn.dart b/apps/client/lib/core/translations/language/vi_vn.dart index 998828f2..880bf858 100644 --- a/apps/client/lib/core/translations/language/vi_vn.dart +++ b/apps/client/lib/core/translations/language/vi_vn.dart @@ -2085,4 +2085,5 @@ const Map viVN = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': 'Đang dịch. Hãy dừng dịch trước khi đổi ngôn ngữ.', }; diff --git a/apps/client/lib/core/translations/language/zh_cn.dart b/apps/client/lib/core/translations/language/zh_cn.dart index 997fcda6..27f9dc5c 100644 --- a/apps/client/lib/core/translations/language/zh_cn.dart +++ b/apps/client/lib/core/translations/language/zh_cn.dart @@ -1941,4 +1941,5 @@ const Map zhCN = { 'computePack': '算力包', 'vipExpiringSoon': '会员还有 @days 天到期,续费后可继续使用翻译与会议纪要', 'computeLow': '算力不多了,用完后将无法继续使用', + 'stopTranslationBeforeLanguageChange': '翻译进行中,请先停止翻译再切换语言', }; diff --git a/apps/client/lib/core/translations/language/zh_hk.dart b/apps/client/lib/core/translations/language/zh_hk.dart index 1b76f84c..013ab86f 100644 --- a/apps/client/lib/core/translations/language/zh_hk.dart +++ b/apps/client/lib/core/translations/language/zh_hk.dart @@ -1938,4 +1938,5 @@ const Map zhHK = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': '翻譯進行中,請先停止翻譯再切換語言', }; diff --git a/apps/client/lib/core/translations/language/zh_tw.dart b/apps/client/lib/core/translations/language/zh_tw.dart index 6ecb171b..e9171034 100644 --- a/apps/client/lib/core/translations/language/zh_tw.dart +++ b/apps/client/lib/core/translations/language/zh_tw.dart @@ -1938,4 +1938,5 @@ const Map zhTW = { 'computePack': 'Compute packs', '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.', + 'stopTranslationBeforeLanguageChange': '翻譯進行中,請先停止翻譯再切換語言', }; diff --git a/apps/client/lib/core/utils/synchrodata.dart b/apps/client/lib/core/utils/synchrodata.dart index b4c1da1e..e8762a33 100644 --- a/apps/client/lib/core/utils/synchrodata.dart +++ b/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_storage/get_storage.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_upload_service.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 { - static final GetStorage _storage = GetStorage(); + static const String _tag = 'Synchrodata'; + static bool _running = false; - static List> _records = - (_storage.read("operation_records") as List?) - ?.map((e) => Map.from(e as Map)) - .toList() ?? - []; - static List _delids = _storage.read("operation_delids") ?? []; - static bool _isSynchro = false; - - // 保存操作记录 - static Future saveOperationRecord( - 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.from(params), - 'id': id, - 'number': params['id'], - 'uid': userId, - }); - _storage.write("operation_records", _records); - break; - case 'update': - Map? foundElement = _records.firstWhere( - (element) => element['id'] == id, - orElse: () => {}, - ); - if (foundElement.isNotEmpty) { - foundElement.addAll({ - ...Map.from(params), - 'id': id, - 'number': params['id'], - }); - } else { - _records.add({ - ...Map.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 sync() async { + if (_running) return; + _running = true; + try { + await _sync(); + } catch (e, st) { + Logger.w(_tag, '列表同步失败(沿用本地缓存): $e\n$st'); + } finally { + _running = false; } } - // 上传操作记录 - static Future uploadOperationRecords({bool isSynchro = false}) async { - if (_isSynchro) return; - _isSynchro = true; - // if (_records.isNotEmpty || _delids.isNotEmpty) { - // // 获取需要修改的全部数据 - // List putRecords = []; - // 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.from(result), - // ...Map.from(result['details']), - // 'rtype': result['type'], - // 'original': jsonEncode(speakerList), - // ...Map.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(); + static Future _sync() async { + final result = await Api.getOperationAllRecord(); + // ⚠️ 业务错误时 AuthInterceptor 把 data 置 null 后 resolve(不抛),必须判空—— + // 把 null 当「服务端没有记录」会把本地整个清空。 + if (result == null) { + Logger.w(_tag, '列表接口返回业务错误,本轮不对齐'); + return; } - _isSynchro = false; - } + final List records = result['records'] ?? []; + final serverIds = {}; + final taskList = >[]; - // 同步数据 - static Future _synchrodata() async { - var result = await Api.getOperationAllRecord(); - List records = result['records'] ?? []; - if (records.isEmpty) return; - final meetingTaskService = Get.find(); - List taskList = []; final db = await DatabaseHelper().database; await db.transaction((txn) async { - for (var element in records) { - if (element['state'] > 0 && element['state'] < 5) { - taskList.add({ - 'id': element['id'], - 'tasktype': element['state'], - }); + for (final element in records) { + final int id = element['id']; + final int state = element['state'] ?? 0; + serverIds.add(id); + if (state > 0 && state < 5) { + taskList.add({'id': id, 'tasktype': state}); } - final List existList = await txn.query( - 'meeting', - where: 'id = ?', - whereArgs: [element['id']], - ); - Map meetingData = { - 'id': element['id'], + final meeting = { + 'id': id, 'title': element['title'] ?? '', 'type': element['rtype'] ?? '', 'seconds': element['seconds'] ?? 0, 'audiourl': element['audiourl'] ?? '', - 'tasktype': element['state'] ?? 0, + 'tasktype': state, + // 服务端秒,本地毫秒 'creationtime': (element['creationtime'] ?? 0) * 1000, }; - Map meetingDetailsData = { - 'meetingid': element['id'], - 'address': element['address'] ?? '', - 'personnel': element['personnel'] ?? '', - 'taskid': element['taskid'] ?? '', - 'tasktype': element['state'] ?? 0, - 'overview': element['overview'] ?? '', - 'summary': element['summary'] ?? '', - 'mindmap': '', - }; - if (existList.isNotEmpty) { - await txn.update( - 'meeting', - meetingData, - where: 'id = ?', - whereArgs: [element['id']], - ); - await txn.update( - 'meetingdetails', - meetingDetailsData, - where: 'meetingid = ?', - whereArgs: [element['id']], - ); + final exists = await txn.query('meeting', + columns: ['id'], where: 'id = ?', whereArgs: [id], limit: 1); + if (exists.isNotEmpty) { + // 不碰 filepath(本机录音的路径只有本机知道) + await txn.update('meeting', meeting, where: 'id = ?', whereArgs: [id]); + // 状态在两张表各存一份,以 meeting 表为准,details 跟着走(详情页读的是这份) + await txn.update('meetingdetails', {'tasktype': state}, + where: 'meetingid = ?', whereArgs: [id]); } else { - await txn.insert('meeting', { - ...meetingData, - 'filepath': '', + await txn.insert('meeting', {...meeting, 'filepath': ''}); + await txn.insert('meetingdetails', { + '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()) { + Get.find().startTask(taskList); + } + Logger.i(_tag, '列表已对齐:服务端 ${records.length} 条,进行中 ${taskList.length} 条'); + } + + /// 删掉「本地有、服务端没有」的记录:连本地音频、上传队列、轮询任务一起清。 + static Future _removeLocalOrphans(Set 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()) { + Get.find().removeUpload(id, isDeleteTask: true); + } + if (Get.isRegistered()) { + Get.find().removeLocalTask(id); + } + Logger.i(_tag, '服务端已不存在,删除本地记录 id=$id'); + } } } diff --git a/apps/client/lib/data/services/account_service.dart b/apps/client/lib/data/services/account_service.dart index 92418fd4..e03a542a 100644 --- a/apps/client/lib/data/services/account_service.dart +++ b/apps/client/lib/data/services/account_service.dart @@ -3,7 +3,6 @@ import 'dart:async'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:logger/logger.dart'; -import '../../core/utils/synchrodata.dart'; import '../../modules/login/controllers/googleLogin.dart'; import '../services/network/api.dart'; import 'db/database_helper.dart'; @@ -119,9 +118,6 @@ class AccountService extends GetxService { final uploadService = Get.find(); uploadService.cancelUpload(); - // 上传会议记录操作记录 - await Synchrodata.uploadOperationRecords(); - // 关闭数据库连接 await DatabaseHelper().close(); diff --git a/apps/client/lib/data/services/deapsound_ai_service.dart b/apps/client/lib/data/services/deapsound_ai_service.dart index 9d8e16e8..c31fcf67 100644 --- a/apps/client/lib/data/services/deapsound_ai_service.dart +++ b/apps/client/lib/data/services/deapsound_ai_service.dart @@ -1,19 +1,46 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:http/http.dart' as http; import '../models/appconfig.dart'; import 'ai_service.dart'; +/// 语音纪要「Ask AI」用的文本大模型(OpenAI 兼容接口,客户端直连)。 +/// +/// 2026-09-17 起只读后台「第三方服务配置」里的 **`llm_qwen`**(通义千问), +/// 与服务端会议总结用的是同一家(服务端走 `llmv_qwen`)。火山(`llm_doubao`) +/// 已从后台删除,这里不再兜底、也不再退回 `OPENAI_*` 那几个老 env 键。 class DeapsoundAIService implements AiService { + static const String svcId = 'llm_qwen'; + late final String baseUrl; late final String apiKey; late final String model; DeapsoundAIService() { - apiKey = AppConfig.cred('llm_doubao', 'api_key', 'OPENAI_API_KEY') ?? ''; - baseUrl = AppConfig.cred('llm_doubao', 'base_url', 'OPENAI_BASE_URL') ?? ''; - model = AppConfig.cred('llm_doubao', 'model', 'OPENAI_MODEL') ?? ''; + apiKey = AppConfig.cred(svcId, 'api_key', '') ?? ''; + baseUrl = AppConfig.cred(svcId, 'base_url', '') ?? ''; + 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 @@ -22,7 +49,7 @@ class DeapsoundAIService implements AiService { String? systemPrompt, String? userProperties, }) async { - final url = Uri.parse(baseUrl); + final url = chatUrl; final headers = { 'Content-Type': 'application/json', @@ -59,7 +86,7 @@ class DeapsoundAIService implements AiService { String? userProperties, }) async* { final client = http.Client(); - final url = Uri.parse(baseUrl); + final url = chatUrl; final headers = { 'Content-Type': 'application/json', @@ -72,6 +99,13 @@ class DeapsoundAIService implements AiService { "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 { final request = http.Request('POST', url) ..headers.addAll(headers) @@ -79,7 +113,14 @@ class DeapsoundAIService implements AiService { final response = await client.send(request); 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 @@ -101,7 +142,8 @@ class DeapsoundAIService implements AiService { } } } catch (e) { - throw Exception('Unknown error'); + // 原样抛出(上面 HTTP 分支已经带了状态码),不要再包一层 'Unknown error' + rethrow; } finally { client.close(); } diff --git a/apps/client/lib/data/services/device_ai_session_service.dart b/apps/client/lib/data/services/device_ai_session_service.dart index 3d2bad31..e2b3365a 100644 --- a/apps/client/lib/data/services/device_ai_session_service.dart +++ b/apps/client/lib/data/services/device_ai_session_service.dart @@ -112,6 +112,16 @@ class DeviceAiSessionService extends GetxService { /// duplex 模式下服务端 VAD 把它当成用户插话。没有 AEC 就只能做半双工。 bool _uplinkGated = false; 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; static const Duration _gateTail = Duration(milliseconds: 500); static const Duration _idleTimeout = Duration(seconds: 5); @@ -363,7 +373,14 @@ class DeviceAiSessionService extends GetxService { fromEarphone: true)); _store.persist(); } - if (e.fatal) _teardown(notifyDevice: true); + if (e.fatal) { + _teardown(notifyDevice: true); + } else { + // 非致命错误这一轮不会再有 respondingEnded 了,门必须在这里关, + // 否则上行永远是静音(见 _gateSafety 的说明)。 + _cancelPlayoutTimer(); + _closeGate(); + } break; case BailianEventType.stateChanged: @@ -472,6 +489,14 @@ class DeviceAiSessionService extends GetxService { void _openGate() { _gateTimer?.cancel(); _gateTimer = null; + _gateSafety?.cancel(); + _gateSafety = Timer(_gateMax, () { + if (!_uplinkGated) return; + Logger.w(_tag, '门控开了 ${_gateMax.inSeconds}s 还没等到播完信号,强制解除'); + _cancelPlayoutTimer(); + _svc.notifyLocalRespondingEnded(); + _closeGate(); + }); if (_uplinkGated) return; _uplinkGated = true; _idleTimer?.cancel(); @@ -482,6 +507,8 @@ class DeviceAiSessionService extends GetxService { void _closeGate() { _gateTimer?.cancel(); _gateTimer = null; + _gateSafety?.cancel(); + _gateSafety = null; if (!_uplinkGated) return; _uplinkGated = false; Logger.i(_tag, '上行门控解除'); @@ -573,6 +600,8 @@ class DeviceAiSessionService extends GetxService { _stopAckTimer = null; _idleTimer?.cancel(); _idleTimer = null; + _gateSafety?.cancel(); + _gateSafety = null; _cancelPlayoutTimer(); _micAccum.clear(); diff --git a/apps/client/lib/data/services/device_call_recording_service.dart b/apps/client/lib/data/services/device_call_recording_service.dart index da25dd18..cf78ddd2 100644 --- a/apps/client/lib/data/services/device_call_recording_service.dart +++ b/apps/client/lib/data/services/device_call_recording_service.dart @@ -40,7 +40,6 @@ class DeviceCallRecordingService extends GetxService { StreamSubscription? _peerSub; /// 暂存的对端帧,等本端帧到齐后一起交织写盘 - Uint8List? _pendingPeer; int _frameCount = 0; Timer? _silenceCheck; WavWriter? _wav; @@ -135,7 +134,7 @@ class DeviceCallRecordingService extends GetxService { _path = '${dir.path}/call_$ts.wav'; _wav = await WavWriter.open(_path!, channels: 2); - _pendingPeer = null; + _pairer.clear(); _frameCount = 0; _peer = await session.openMic(route: AudioRoute.callPeer); _local = await session.openMic(route: AudioRoute.callLocal); @@ -249,10 +248,19 @@ class DeviceCallRecordingService extends GetxService { _path = null; } + /// 两路各自排队、按字节对齐后成对写入(左=本端麦克风,右=对端)。 + /// + /// ⚠️ 原先是「只留最后一帧对端、本端来一帧配一帧」:对端连来两帧就丢一帧, + /// 本端来了对端没到就整帧扔掉。两路在 BLE 上是同一个帧里交替的 40 字节块, + /// 到 Dart 侧却经过各自的 broadcast stream,到达顺序并不保证严格交替 —— + /// 结果右声道(对端)被断续抽掉,转写出来只剩自己这一方。 + final StereoPairer _pairer = StereoPairer(); + void _onPeer(Uint8List pcm) { if (_wav == null || pcm.isEmpty) return; _frameCount++; - _pendingPeer = pcm; + _pairer.pushRight(pcm); + _flushPairs(); } void _onLocal(Uint8List pcm) { @@ -261,12 +269,15 @@ class DeviceCallRecordingService extends GetxService { if (_frameCount <= 3 || _frameCount % 200 == 0) { Logger.d(_tag, '收到通话音频帧 #$_frameCount len=${pcm.length}'); } - final peer = _pendingPeer; - _pendingPeer = null; - if (peer == null) return; - // 左声道=本端麦克风,右声道=对端声音 - _wav!.write(WavWriter.interleave(pcm, peer), onWritten: () { - level.value = WavWriter.rmsLevel(pcm); + _pairer.pushLeft(pcm); + _flushPairs(); + } + + void _flushPairs() { + final pair = _pairer.take(); + if (pair == null) return; + _wav!.write(WavWriter.interleave(pair.left, pair.right), onWritten: () { + level.value = WavWriter.rmsLevel(pair.left); }); } diff --git a/apps/client/lib/data/services/device_stereo_recorder.dart b/apps/client/lib/data/services/device_stereo_recorder.dart index eaf8eb81..76f4e5a2 100644 --- a/apps/client/lib/data/services/device_stereo_recorder.dart +++ b/apps/client/lib/data/services/device_stereo_recorder.dart @@ -27,7 +27,8 @@ class DeviceStereoRecorder { StreamSubscription? _localSub; StreamSubscription? _peerSub; WavWriter? _wav; - Uint8List? _pendingPeer; + /// 两路各自排队、按字节对齐后成对写入,见 [StereoPairer] + final StereoPairer _pairer = StereoPairer(); bool _paused = false; bool get isRunning => _wav != null; @@ -43,21 +44,19 @@ class DeviceStereoRecorder { try { _session = session; _wav = await WavWriter.open(filePath, channels: 2); - _pendingPeer = null; + _pairer.clear(); _paused = false; _peer = await session.openMic(route: AudioRoute.callPeer); _local = await session.openMic(route: AudioRoute.callLocal); _peerSub = _peer!.pcm.listen((pcm) { if (_paused || pcm.isEmpty) return; - _pendingPeer = pcm; + _pairer.pushRight(pcm); + _flushPairs(); }); _localSub = _local!.pcm.listen((pcm) { if (_paused || pcm.isEmpty) return; - final peer = _pendingPeer; - _pendingPeer = null; - if (peer == null) return; - _wav?.write(WavWriter.interleave(pcm, peer), - onWritten: () => onWavePcm?.call(pcm)); + _pairer.pushLeft(pcm); + _flushPairs(); }); await session.invokeFeature(DeviceFeatures.callStart); 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 resume() => _paused = false; diff --git a/apps/client/lib/data/services/meeting/meeting_task_service.dart b/apps/client/lib/data/services/meeting/meeting_task_service.dart index b1ef7680..6fc2cb49 100644 --- a/apps/client/lib/data/services/meeting/meeting_task_service.dart +++ b/apps/client/lib/data/services/meeting/meeting_task_service.dart @@ -99,7 +99,37 @@ class MeetingTaskService extends GetxService { } } - Future submitTask( + /// 智能总结模板的 tid(后台「Autopilot智能总结」,41 种语言各有一版,按 tid 反查)。 + static const String smartTemplateTid = 'General-Autopilot'; + + /// 记录建的时候已经带了转写(翻译归档,`rtype=VOICETRANSLAT`): + /// 服务端跳过识别,直接用智能模板总结。源=目标语言,连翻译那一段也跳过。 + /// + /// 模板只有 App 的 41 个界面语言有版本,翻译语种(粤语、繁中…)超出这个集合时 + /// 退到英文——否则服务端回 TemplateNotFound,记录停在「未处理」且没人知道为什么。 + Future 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 submitTask( int id, String formlanguage, String tolanguage, @@ -107,7 +137,7 @@ class MeetingTaskService extends GetxService { int templateid, String tid, ) async { - await Api.startTaskEchomeet({ + final resp = await Api.startTaskEchomeet({ 'id': id, 'formlanguage': formlanguage, 'tolanguage': tolanguage, @@ -115,6 +145,11 @@ class MeetingTaskService extends GetxService { 'templateid': templateid, 'tid': tid, }); + if (resp == null) { + Logger.w('MeetingTask', + '任务未被服务端受理 id=$id lang=$formlanguage→$tolanguage tid=$tid,记录保持未处理'); + return false; + } final seconds = await _getMeetingSeconds(id); _decrementMeetingIntegral(cost: seconds); _taskList.add({ @@ -124,6 +159,7 @@ class MeetingTaskService extends GetxService { await SqfliteApi.setMeetingTaskType(id, 1); updateListState.value++; _executeTask(); + return true; } Future 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) { _taskList = data; _executeTask(); diff --git a/apps/client/lib/data/services/memory_service.dart b/apps/client/lib/data/services/memory_service.dart index df2c66a8..c4b25aee 100644 --- a/apps/client/lib/data/services/memory_service.dart +++ b/apps/client/lib/data/services/memory_service.dart @@ -1,5 +1,7 @@ +import 'dart:async'; import 'dart:convert'; +import 'package:flutter_timezone/flutter_timezone.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; @@ -42,6 +44,8 @@ class MemoryService extends GetxService { void onInit() { super.onInit(); _restore(); + // 时区只问一次系统,不 await:拿不到就按 _tz() 的退路走,不该拖住启动。 + unawaited(warmTz()); } // ===================== 本地缓存 ===================== @@ -96,9 +100,30 @@ class MemoryService extends GetxService { '${d.month.toString().padLeft(2, '0')}-' '${d.day.toString().padLeft(2, '0')}'; - /// 本机时区名。拿不到就传空串——服务端会退到它自己的本地时区, - /// **不会**退成 UTC(那会让所有时刻静默偏移 8 小时)。 + /// 本机时区的 IANA 名(`Asia/Shanghai`)。启动时问一次系统,进程内缓存。 + static String? _ianaTz; + + /// 问系统要 IANA 时区名。失败不抛,留给 [_tz] 退回缩写。 + static Future 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() { + if (_ianaTz != null) return _ianaTz!; try { return DateTime.now().timeZoneName; } catch (_) { diff --git a/apps/client/lib/data/services/network/api.dart b/apps/client/lib/data/services/network/api.dart index b7bb7013..20f5cfde 100644 --- a/apps/client/lib/data/services/network/api.dart +++ b/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]) { @@ -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]) { return DioManager().request( NWMethod.post, diff --git a/apps/client/lib/data/services/recording_archive.dart b/apps/client/lib/data/services/recording_archive.dart index d47a5f37..eede7db6 100644 --- a/apps/client/lib/data/services/recording_archive.dart +++ b/apps/client/lib/data/services/recording_archive.dart @@ -24,6 +24,10 @@ import 'network/api.dart'; /// - `call_*` 通话录音 /// - `trans_call_*` 通话翻译 /// - `trans_holder_*` 同声翻译 / 面对面翻译 +/// +/// 翻译那两条还会带上**现成的转写**([archive] 的 `transcript`): +/// 翻译时 ASR 已经逐句出过整句,再让纪要把同一段音频识别一遍既慢又白花算力, +/// 所以纪要那边只做总结(2026-09-18 产品口径)。 class RecordingArchive { RecordingArchive._(); @@ -34,12 +38,21 @@ class RecordingArchive { /// [createdAt] 这段录音**实际录制**的时刻。边录边存的四条链路不用传(就是现在), /// 但从设备存储同步进来的历史录音必须传 —— 否则列表上一条 8 月 15 日录的东西 /// 会显示成「刚刚」,用户按时间根本找不到它。 + /// + /// [transcript] 这段音频**已经有的转写**(通话/同声/面对面翻译边说边出的整句), + /// 形状见 `buildMeetingTranscript`。给了就以 `rtype=VOICETRANSLAT` 建记录: + /// 服务端 `echomeet_starttask` 对这种记录**跳过识别**、只跑总结, + /// 本地也直接写进 `meetingspeaker`,打开纪要立刻能看到原文。 + /// [summaryLanguage] 非空且转写非空时,归档完立刻用智能模板起总结任务, + /// 用户不用再进纪要点「生成」。 static Future archive({ required String path, required int seconds, String tag = 'RecordingArchive', bool notify = true, DateTime? createdAt, + List>? transcript, + String summaryLanguage = '', }) async { try { final file = File(path); @@ -69,8 +82,11 @@ class RecordingArchive { } final stat = await File(newPath).stat(); + final hasTranscript = transcript != null && transcript.isNotEmpty; final data = { 'title': fileName, + // 本地 type 一律 LOCAL:它只决定列表分组和上传桶(LocalAudio), + // 「有没有现成转写」是服务端 rtype 的事,两者别混。 'type': 'LOCAL', 'seconds': seconds, 'filepath': newPath, @@ -79,8 +95,10 @@ class RecordingArchive { 'creationtime': resolveRecordingCreationTime(createdAt), }; final resp = await Api.addRecordEchomeet({ - 'rtype': 'LOCAL', + // VOICETRANSLAT 是服务端 StartTask 里「转写已存在,只翻译+总结」的分支 + 'rtype': hasTranscript ? 'VOICETRANSLAT' : 'LOCAL', 'size': stat.size, + if (hasTranscript) 'original': transcript, ...data, }); final meetingId = resp?['record']?['id']; @@ -89,6 +107,20 @@ class RecordingArchive { return null; } 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()) { Get.find() .addUpload(meetingId, newPath, 'LocalAudio'); @@ -99,13 +131,30 @@ class RecordingArchive { if (Get.isRegistered()) { Get.find().updateListState.value++; } - Logger.i(tag, '已归档到语音记录助手 id=$meetingId title=$fileName'); + Logger.i(tag, + '已归档到语音记录助手 id=$meetingId title=$fileName 转写段=${transcript?.length ?? 0}'); if (notify) { Get.snackbar('recordingSavedTitle'.tr, 'recordingSavedDesc'.tr, snackPosition: SnackPosition.TOP, 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()) { + try { + final ok = await Get.find() + .startSummaryForTranscript(id, summaryLanguage); + Logger.i(tag, '自动总结${ok ? '已提交' : '未能提交'} id=$id lang=$summaryLanguage'); + } catch (e) { + Logger.w(tag, '自动总结提交失败(记录已保留) id=$id: $e'); + } + } + return id; } catch (e) { Logger.e(tag, '归档失败: $e'); return null; diff --git a/apps/client/lib/data/services/wav_writer.dart b/apps/client/lib/data/services/wav_writer.dart index 09fbf0e8..8de2a964 100644 --- a/apps/client/lib/data/services/wav_writer.dart +++ b/apps/client/lib/data/services/wav_writer.dart @@ -134,3 +134,54 @@ class WavWriter { 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(); + } +} diff --git a/apps/client/lib/data/utils/emai_audio_session.dart b/apps/client/lib/data/utils/emai_audio_session.dart index de6b79c4..bf114906 100644 --- a/apps/client/lib/data/utils/emai_audio_session.dart +++ b/apps/client/lib/data/utils/emai_audio_session.dart @@ -34,7 +34,15 @@ class 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 bool get _apply => !kIsWeb && (Platform.isIOS || Platform.isAndroid); @@ -54,20 +62,67 @@ class EmaiAudioSession { /// /// phoneMic 那一档保留 defaultToSpeaker(去掉会退回听筒,那是更糟的退化), /// 只补上 allowBluetoothA2dp。 - static Future 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 ensure( {EmaiAudioProfile profile = EmaiAudioProfile.phoneMic}) async { - if (!_apply) return; + if (!_apply) return false; try { final session = await AudioSession.instance; if (_configured != profile) { - _configured = profile; - await session.configure(profile == EmaiAudioProfile.playbackOnly - ? _playbackOnlyConfig() - : _phoneMicConfig()); + Logger.i(_tag, '音频会话档位 ${_configured?.name ?? '-'} → ${profile.name}'); } + _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); + 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) { Logger.w(_tag, '音频会话配置失败(继续尝试): $e'); + return false; + } + } + + /// 当前会话的输出里有没有蓝牙 A2DP 口。只有 iOS 有这个查询;Android 的 + /// 媒体流本来就由系统自动路到 A2DP,不需要客户端判。 + static Future _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, ); - static AudioSessionConfiguration _phoneMicConfig() => + static AudioSessionConfiguration _phoneMicConfig({required bool a2dpOutput}) => AudioSessionConfiguration( // iOS 必须 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.defaultToSpeaker | - AVAudioSessionCategoryOptions.allowBluetooth | AVAudioSessionCategoryOptions.allowBluetoothA2dp, - // ⚠️ iOS 固定 voiceChat:record 开了 echoCancel,它会打开 - // voice-processing,系统随之把 mode 改成 voiceChat 并一直持有 - // (真机 dump 确认)。播放这边如果写回 default,就是两边互相改会话。 - // defaultToSpeaker 在 voiceChat 下仍然走扬声器,实测不会被拽到听筒。 - avAudioSessionMode: Platform.isIOS + // ⚠️ iOS 两种模式二选一(见 ensure 的说明): + // - 走 A2DP 时必须是 default:voiceChat = 语音处理,A2DP 输出会被系统剔掉; + // - 不走 A2DP 时固定 voiceChat:record 开了 echoCancel 会打开 + // voice-processing,系统随之把 mode 改成 voiceChat 并一直持有 + // (真机 dump 确认)。播放这边如果写回 default,就是两边互相改会话。 + // defaultToSpeaker 在 voiceChat 下仍然走扬声器,实测不会被拽到听筒。 + avAudioSessionMode: Platform.isIOS && !a2dpOutput ? AVAudioSessionMode.voiceChat : AVAudioSessionMode.defaultMode, // ⚠️ Android 的 usage 千万别用 voiceCommunication:华为 diff --git a/apps/client/lib/data/utils/mic_pcm_streamer.dart b/apps/client/lib/data/utils/mic_pcm_streamer.dart index 2e2474bb..3664626b 100644 --- a/apps/client/lib/data/utils/mic_pcm_streamer.dart +++ b/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:typed_data'; @@ -59,20 +62,32 @@ class MicPcmStreamer { try { // ⚠️ 必须先把会话配好并激活再开麦:会话停在 soloAmbient 且未激活时, // 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()) { Logger.w(_tag, '没有麦克风权限'); return false; } + // ⚠️ iOS:record 拿 inputNode.inputFormat 去 installTap,格式是 0Hz(会话刚 + // 切类别 / 路由正在切换)时那是 ObjC 断言,这里的 try/catch 接不住,直接崩。 + // 先问原生一声输入格式是否就绪,不就绪等一小会儿,还不行就报失败而不是崩。 + if (!await _waitIosInputReady()) { + Logger.e(_tag, '输入音频格式无效(会话/路由切换中),本次不开麦'); + return false; + } final stream = await _recorder.startStream( RecordConfig( encoder: AudioEncoder.pcm16bits, sampleRate: sampleRate, numChannels: 1, // 语音助手场景:开回声消除和降噪,否则外放播报会被自己的麦克风收回去, - // duplex 模式下会直接把服务端 VAD 打乱 - echoCancel: true, - noiseSuppress: true, + // duplex 模式下会直接把服务端 VAD 打乱。 + // ⚠️ iOS 上回复走 A2DP 耳机时**必须关**:这两项会开 voice-processing, + // 而语音处理模式下 A2DP 不可作输出。耳机里放的声音手机麦本来就收不到多少。 + echoCancel: voiceProcessing, + noiseSuppress: voiceProcessing, // ⚠️ iOS 上不让 record 碰 AVAudioSession(会话由 EmaiAudioSession 统一配)。 // 它自己那套是 `setCategory(.playAndRecord, options:)` → `setActive(true)`, // 而 setCategory 会把 mode 重置成 default;紧接着 echoCancel 触发的 @@ -104,6 +119,29 @@ class MicPcmStreamer { } } + /// 见 [_start] 里的说明。非 iOS 直接放行;原生不认这个方法(老插件)也放行。 + static const MethodChannel _iosProbe = MethodChannel('azure_speech/pcm_player'); + + Future _waitIosInputReady() async { + if (kIsWeb || !Platform.isIOS) return true; + for (var i = 0; i < 12; i++) { + try { + final r = await _iosProbe.invokeMethod('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] 一定是流断了。 DateTime? _lastChunkAt; diff --git a/apps/client/lib/modules/common/widgets/language_selector_bar.dart b/apps/client/lib/modules/common/widgets/language_selector_bar.dart index 4b787828..d56ce20f 100644 --- a/apps/client/lib/modules/common/widgets/language_selector_bar.dart +++ b/apps/client/lib/modules/common/widgets/language_selector_bar.dart @@ -19,6 +19,7 @@ class LanguageSelectorBar extends StatelessWidget { this.fontSizeSp = 15, this.sourceSuffix, this.targetSuffix, + this.swapIcon, }) : super(key: key); /// 返回源语言显示文本(建议返回语言中文名或翻译键) @@ -48,6 +49,14 @@ class LanguageSelectorBar extends StatelessWidget { /// 目标语言后缀(可选),用于追加图标等 final Widget? targetSuffix; + /// 中间那个按钮显示什么图标。默认是双向的「互换」图标。 + /// + /// 单向翻译的模式(同声翻译 / 多媒体翻译)传一个单向箭头进来,让用户一眼看出 + /// 「只会从左边翻到右边」——双向图标在那里会让人以为反着说也能翻。 + /// 做成回调而不是固定值,是为了能在 [Obx] 里读到响应式的当前模式。 + /// 按钮本身仍然可点(换方向依旧有意义),只是图标语义变了。 + final IconData Function()? swapIcon; + @override Widget build(BuildContext context) { final isDarkMode = Get.isDarkMode; @@ -120,11 +129,16 @@ class LanguageSelectorBar extends StatelessWidget { color: swapBg, shape: BoxShape.circle, ), - child: Icon( - Icons.swap_horiz, - color: swapIconColor, - size: 20.sp, - ), + // ⚠️ 只有传了 swapIcon 才包 Obx:Obx 里一个响应式变量都没读到时 + // GetX 会直接抛「improper use of a GetX」,而图片/文本翻译两个页面 + // 本来就不传这个参数。 + child: swapIcon == null + ? Icon(Icons.swap_horiz, color: swapIconColor, size: 20.sp) + : Obx(() => Icon( + swapIcon!.call(), + color: swapIconColor, + size: 20.sp, + )), ), ), diff --git a/apps/client/lib/modules/login/controllers/login_controller.dart b/apps/client/lib/modules/login/controllers/login_controller.dart index 89814752..38057b6f 100644 --- a/apps/client/lib/modules/login/controllers/login_controller.dart +++ b/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:get_storage/get_storage.dart'; import 'package:device_info_plus/device_info_plus.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:crypto/crypto.dart'; // 用于生成设备指纹 import 'dart:convert'; 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 '../../../modules/common/widgets/privacy_policy_dialog.dart'; import '../constants/country_codes.dart'; +import '../models/login_methods.dart'; class LoginController extends GetxController { static LoginController get to => Get.find(); @@ -33,6 +35,29 @@ class LoginController extends GetxController { final _isGuestLoginVisible = false.obs; bool get isGuestLoginVisible => _isGuestLoginVisible.value; + /// 这个包提供哪些登录入口,见 [resolveLoginMethods]。 + /// + /// 读 `.env` 的 Channel(打包时写死)而不是 `versionService.cachedChannel` + /// (安卓上异步按品牌推导、推导前兜底是 GLOBAL_GOOGLE),理由见那个函数的注释。 + /// 游客那一项依赖服务端配置(Rx),所以这个 getter 要在 Obx 里读才会随之刷新。 + Set 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 _deviceModel = ''; String _appVersion = ''; // APP版本号 @@ -56,6 +81,13 @@ class LoginController extends GetxController { // 初始化默认选择的国家代码 void _initDefaultCountryCode() { + if (isEmailOnlyLogin) { + // 谷歌包只留邮箱登录,不给区号 + _selectedCountryCode.value = ''; + _selectedCountryFlag.value = '✉️'; + loginType = 0; + return; + } if (isChinaMainland.value) { // 中国大陆用户默认选择+86 _selectedCountryCode.value = '+86'; diff --git a/apps/client/lib/modules/login/models/login_methods.dart b/apps/client/lib/modules/login/models/login_methods.dart new file mode 100644 index 00000000..3f3b0ca3 --- /dev/null +++ b/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 resolveLoginMethods({ + required bool isIOS, + required String envChannel, + required bool guestVisible, +}) { + final methods = {}; + 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; +} diff --git a/apps/client/lib/modules/login/views/login_view.dart b/apps/client/lib/modules/login/views/login_view.dart index fcb27e8e..f84d8a09 100644 --- a/apps/client/lib/modules/login/views/login_view.dart +++ b/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:font_awesome_flutter/font_awesome_flutter.dart'; import '../controllers/login_controller.dart'; +import '../models/login_methods.dart'; import '../../common/webview_page.dart'; import '../../../core/utils/doc_url_helper.dart'; @@ -42,13 +43,17 @@ class LoginView extends GetView { SizedBox(height: 32.h), _buildWelcomeText(isDarkMode), - // 输入框和继续按钮 - SizedBox(height: 24.h), - _buildInputField(isDarkMode), - SizedBox(height: 16.h), - _buildVerificationInputField(isDarkMode), - SizedBox(height: 16.h), - _buildContinueButton(isDarkMode), + // 输入框和继续按钮。 + // 苹果包 / 安卓国内包**不提供**手机号·邮箱登录(2026-09-18 口径, + // 见 resolveLoginMethods),整组藏掉而不是留个空壳。 + if (controller.showContactLogin) ...[ + SizedBox(height: 24.h), + _buildInputField(isDarkMode), + SizedBox(height: 16.h), + _buildVerificationInputField(isDarkMode), + SizedBox(height: 16.h), + _buildContinueButton(isDarkMode), + ], // 社交媒体登录 SizedBox(height: 32.h), @@ -130,7 +135,8 @@ class LoginView extends GetView { ), ), SizedBox(height: 4.h), - if (!controller.isChinaMainland.value) + // 这句说的就是上面那组输入框,输入框藏了它也得跟着藏 + if (controller.showContactLogin && !controller.isChinaMainland.value) Text( 'loginTips'.tr, // 请使用手机号或邮箱登录/注册 style: TextStyle( @@ -184,7 +190,8 @@ class LoginView extends GetView { color: isDarkMode ? Colors.white : Colors.black, ), )), - if (!controller.isChinaMainland.value) + if (!controller.isChinaMainland.value && + !controller.isEmailOnlyLogin) Icon( Icons.arrow_drop_down, color: isDarkMode ? Colors.grey[400] : Colors.black54, @@ -238,6 +245,10 @@ class LoginView extends GetView { if (controller.isChinaMainland.value) { return; } + // 谷歌包只留邮箱登录:能选出区号就等于还能用手机号登录,等于没隐藏 + if (controller.isEmailOnlyLogin) { + return; + } Get.bottomSheet( Container( height: 400.h, @@ -430,47 +441,54 @@ class LoginView extends GetView { Widget _buildSocialLoginSection(bool isDarkMode) { return Column( children: [ - Row( - children: [ - Expanded( - child: Divider( - color: isDarkMode ? Colors.grey[800] : Colors.grey[300])), - Padding( - padding: EdgeInsets.symmetric(horizontal: 16.w), - child: Text( - 'otherWays'.tr, // 或使用以下方式登录 - style: TextStyle( - fontSize: 12.sp, - color: isDarkMode ? Colors.grey[500] : Colors.grey[600], + // 「或使用以下方式登录」是相对上面那组输入框说的。输入框藏掉时 + // 这几个按钮就是**唯一**的登录方式,再写「其他方式」会读不懂。 + if (controller.showContactLogin) ...[ + Row( + children: [ + Expanded( + child: Divider( + color: isDarkMode ? Colors.grey[800] : Colors.grey[300])), + Padding( + padding: EdgeInsets.symmetric(horizontal: 16.w), + child: Text( + 'otherWays'.tr, // 或使用以下方式登录 + style: TextStyle( + fontSize: 12.sp, + color: isDarkMode ? Colors.grey[500] : Colors.grey[600], + ), ), ), - ), - Expanded( - child: Divider( - color: isDarkMode ? Colors.grey[800] : Colors.grey[300])), - ], - ), - SizedBox(height: 24.h), + Expanded( + child: Divider( + color: isDarkMode ? Colors.grey[800] : Colors.grey[300])), + ], + ), + SizedBox(height: 24.h), + ], // 必须是 Obx:游客登录入口的显隐要等服务端配置回来(异步),用 Builder 只会渲染 // onInit 时的初值 false,入口再也不会出现。isChinaMainland 同为 .obs,一并受益。 + // 必须是 Obx:游客入口要等服务端配置回来(异步)。 Obx( () { + // ⚠️ 按「包」给按钮,不再按 isChinaMainland / 平台各判一次: + // 原来的写法是「国内→微信、其余安卓→谷歌、iOS→苹果」, + // 三条件互不排斥,谷歌包装在中国区手机上会同时露出微信和谷歌。 + // 现在唯一入口是 resolveLoginMethods,一个包只留一种。 + final methods = controller.loginMethods; final buttons = []; - if (controller.isChinaMainland.value) { + if (methods.contains(LoginMethod.wechat)) { buttons.add(_buildSocialButton( 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( FontAwesomeIcons.apple, Colors.black, isDarkMode)); } - if (controller.isGuestLoginVisible) { + if (methods.contains(LoginMethod.guest)) { buttons.add(_buildSocialButton( FontAwesomeIcons.userSecret, Colors.black, isDarkMode)); } diff --git a/apps/client/lib/modules/meeting/controllers/meeting_details_controller.dart b/apps/client/lib/modules/meeting/controllers/meeting_details_controller.dart index 93cdad10..a7f8e9ab 100644 --- a/apps/client/lib/modules/meeting/controllers/meeting_details_controller.dart +++ b/apps/client/lib/modules/meeting/controllers/meeting_details_controller.dart @@ -158,6 +158,91 @@ class MeetingDetailsController extends GetxController textList.value = List.from(speakerList); } isLoading.value = false; + // 本地先出,服务端整条再覆盖(不 await:页面已经能用了) + unawaited(_refreshFromServer()); + } + + /// 用服务端整条记录刷新本地缓存与页面。 + /// + /// 列表接口只回骨架(见 Synchrodata),转写和总结**只从这里进本地库**: + /// 每次进详情拉一次 `echomeet_getrecord`,服务端是真源,别的设备重新生成的总结、 + /// 改过的标题都在这一步跟过来。拉不到就沿用本地,不报错。 + Future _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>? _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.from(e as Map)).toList(); + } catch (e) { + Logger.w('MeetingDetails', 'translate 解析失败,转写沿用本地: $e'); + return null; + } } Future _loadPlayerController() async { diff --git a/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart b/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart index 55edea9a..9dd95d4f 100644 --- a/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart +++ b/apps/client/lib/modules/meeting/controllers/meeting_home_controller.dart @@ -143,17 +143,20 @@ class MeetingHomeController extends GetxController { } void onPopInvokedWithResult(didPop, result) { - // Synchrodata.uploadOperationRecords(isSynchro: true); meetingTaskService.stopTask(); } + /// 先用本地缓存出列表,再后台跟服务端对齐,对齐完再刷一次。 + /// 首屏不等网络:原来是 await 全量同步(含逐条重建转写)之后才查本地, + /// 记录一多进页面就是几秒白屏。 void _initGetDataList() async { - await Synchrodata.uploadOperationRecords(isSynchro: true); - _getDataList(); + await _getDataList(); + await Synchrodata.sync(); + await _getDataList(); } // 请求本地数据 - void _getDataList() async { + Future _getDataList() async { List list = await SqfliteApi.getMeetingList(); originalDataList.value = List.from(list); dataList.value = List.from(list); diff --git a/apps/client/lib/modules/meeting/views/bottomSheet/ai_bottom_sheet.dart b/apps/client/lib/modules/meeting/views/bottomSheet/ai_bottom_sheet.dart index 156635de..32c6134b 100644 --- a/apps/client/lib/modules/meeting/views/bottomSheet/ai_bottom_sheet.dart +++ b/apps/client/lib/modules/meeting/views/bottomSheet/ai_bottom_sheet.dart @@ -470,6 +470,23 @@ class _AIBottomSheetState extends State { _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, ); } diff --git a/apps/client/lib/modules/meeting/views/meeting_home_view.dart b/apps/client/lib/modules/meeting/views/meeting_home_view.dart index 8266526b..fe7d0955 100644 --- a/apps/client/lib/modules/meeting/views/meeting_home_view.dart +++ b/apps/client/lib/modules/meeting/views/meeting_home_view.dart @@ -521,14 +521,38 @@ class _MeetingHomeViewState extends State child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text( - data.title, - style: TextStyle( - color: MeetingUIUtils.getTextColor(isDarkMode), - fontSize: 18.sp, - ), - overflow: TextOverflow.ellipsis, - ), + Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + child: Text( + data.title, + 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: EdgeInsets.symmetric(vertical: 5.w), child: Row( @@ -650,93 +674,21 @@ class _MeetingHomeViewState extends State } // ignore: unused_element - void _onLongPressItem(BuildContext context, bool isDarkMode, - LongPressStartDetails details, MeetingModel data) { - Haptics.vibrate(HapticsType.success); - RenderBox? renderBox = - Overlay.of(context).context.findRenderObject() as RenderBox; - final RelativeRect position = RelativeRect.fromRect( - Rect.fromLTRB( - details.globalPosition.dx, - details.globalPosition.dy, - details.globalPosition.dx + 110, // 菜单显示位置X轴坐标 - details.globalPosition.dy - 40, // 菜单显示位置Y轴坐标 - ), - Offset.zero & renderBox.size, - ); - - showMenu( - context: context, - position: position, - color: isDarkMode ? AppColors.cardBackground : Colors.white, + /// 打开改名弹层。标题按「主名 + 扩展名」拆开,只让用户改主名, + /// 扩展名原样拼回去(editMeetingTitle 里 `titleController.text + titleCSuffix`)。 + void _openRename(bool isDarkMode, MeetingModel data) { + final dotIndex = data.title.lastIndexOf('.'); + _controller.titleController.text = + dotIndex != -1 ? data.title.substring(0, dotIndex) : data.title; + _controller.titleCSuffix = + dotIndex != -1 ? data.title.substring(dotIndex) : ''; + Get.bottomSheet( + EditTitleBottomSheet(data.id), + isScrollControlled: true, + backgroundColor: isDarkMode ? Colors.grey[800] : Colors.grey[100], shape: RoundedRectangleBorder( - borderRadius: BorderRadius.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, - ), - ), - ], + borderRadius: BorderRadius.vertical( + top: Radius.circular(12.r), ), ), ); diff --git a/apps/client/lib/modules/meeting/views/tabs/summary_tab.dart b/apps/client/lib/modules/meeting/views/tabs/summary_tab.dart index e8c5a931..066b589b 100644 --- a/apps/client/lib/modules/meeting/views/tabs/summary_tab.dart +++ b/apps/client/lib/modules/meeting/views/tabs/summary_tab.dart @@ -161,14 +161,16 @@ class _SummaryTabState extends State { if (markdown.trim().isEmpty && current.trim().isEmpty) return; if (markdown.trim() == current.trim()) return; - Api.modifyRecordEchomeet({ - 'id': _controller.id, - 'summary': markdown, - }); + // 先落本地再等服务端确认:详情页每次进都会用服务端整条覆盖本地, + // 这里不 await 的话「改完立刻退出再进」会拿到旧值把刚改的冲掉。 SqfliteApi.editMeeting( _controller.id, {'summary': markdown}, ); + await Api.modifyRecordEchomeet({ + 'id': _controller.id, + 'summary': markdown, + }); _controller.meetingDetails.value.summary = markdown; _controller.summaryNumberModifications.value++; _summaryDirty = false; diff --git a/apps/client/lib/modules/translation/controllers/translation_controller.dart b/apps/client/lib/modules/translation/controllers/translation_controller.dart index c0daccf3..4eb0ebe9 100644 --- a/apps/client/lib/modules/translation/controllers/translation_controller.dart +++ b/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 '../../../core/utils/logger.dart'; import '../../../core/utils/permission_util.dart'; +import '../../../core/utils/toast_throttle.dart'; import '../../../core/services/floating_window_manager.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 '../../../devices/device_hub.dart'; import '../../../data/services/recording_archive.dart'; +import '../models/translation_export.dart'; import '../../../core/utils/device_connection.dart'; import '../../../core/utils/background_session_notifier.dart'; import 'record_timer_controller.dart'; import 'translation_history_controller.dart'; import '../models/translation_models.dart'; +import '../models/translation_direction.dart'; import '../views/dialogs/translation_mode_selector.dart'; import '../../../data/models/appconfig.dart'; import '../../../data/models/appconfig_model.dart'; @@ -137,6 +140,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver { final isCreateRecord = false.obs; final hasRecordPermission = false.obs; // 本次录音文件路径,供 stopRecording 归档时用;null 表示本轮没开过录音 + // 录音起点与暂停区间:归档时把翻译条目的时间戳换算成「音频里的毫秒偏移」要用。 + // 面对面翻译逐句按住说话、松开就暂停录音,不记暂停的话偏移越往后越大。 + DateTime? _recordingStartedAt; + final List<(DateTime, DateTime)> _recordingPauses = []; + DateTime? _pauseStartedAt; String? _currentRecordingPath; // 添加悬浮按钮位置状态 @@ -540,7 +548,8 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// [speakerId] 说话者ID Future _startFaceToFaceRecognition(int speakerId) async { try { - if (User.isLoggedIn() && User.instance.tradeintegral <= 0) { + // 同上:旧额度桶已停用,不能再当闸门(见 startRecognition 的说明) + if (!_hasTranslationQuota()) { showRechargeDialog('translation'); activeSpeaker.value = 0; return; @@ -1020,14 +1029,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { await _astService.initialize(supportedLanguages: callModeLanguages, provider: astProvider); // 获取识别事件流(端到端服务已包含 ASR+翻译+TTS,不需要单独启动 ASR) - var 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}'); + await _subscribeAstEvents(); // 初始化完成后给 2s 宽限期,过滤掉底层异步 dispose 旧 provider 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()); // 文件名即语音记录助手里的标题,前缀按 RecordingArchive 的约定走: // trans_call_* 通话翻译 - // trans_holder_* 同声翻译 - // 其它模式(音视频/面对面)暂不归档,沿用本地化标题命名。 + // trans_holder_* 同声翻译 / 面对面翻译 + // 音视频翻译录的是系统播放声,暂不归档,沿用本地化标题命名。 // ⚠️ 别把这里的前缀和下面 stopRecording 的归档条件改岔了—— // 命名对不上约定,归档进去的标题就成了「同声传译_2026...」这种, // 列表里跟别的来源混在一起认不出。 @@ -1139,19 +1141,30 @@ class TranslationController extends GetxController with WidgetsBindingObserver { await _asrService.enableRecord(audioSourceType, filePath, false); } isCreateRecord.value = true; + _recordingStartedAt = DateTime.now(); + _recordingPauses.clear(); + _pauseStartedAt = null; } /// 停止录音 /// - /// 通话翻译的录音停止后归档进「语音记录助手」,跟通话录音 - /// ([BesCallRecordingService])看齐——之前只是写进了 App 自己的 - /// TranslatAudio 目录,主列表里看不到。录音本身仍然是用户手动开的 - /// (录音按钮),不是通话一开始就默认录,合规要求不变。 + /// 通话 / 同声 / 面对面翻译的录音停止后归档进「语音纪要」,**连同这段 + /// 时间里翻译出的整句一起送过去**:纪要那边不再重新识别,直接用智能模板总结 + /// (2026-09-18 产品口径,见 [RecordingArchive.archive] 的 `transcript`)。 + /// 录音在这三个模式下默认开(见 [_initializeSession]),用户本轮可随时关。 Future stopRecording() async { // stopTimer() 会把 _seconds 清零,归档要用的时长必须在它之前取 final int seconds = _timerManager.currentSeconds; final String? path = _currentRecordingPath; _currentRecordingPath = null; + // 面对面翻译的会话是在「暂停」状态下结束的(最后一句松手就暂停了), + // 把这段收尾进暂停区间,最后一句之后的空档不算进偏移。 + if (_pauseStartedAt != null) { + _recordingPauses.add((_pauseStartedAt!, DateTime.now())); + _pauseStartedAt = null; + } + final DateTime? startedAt = _recordingStartedAt; + _recordingStartedAt = null; _timerManager.stopTimer(); if (currentMode.value == 'call') { @@ -1168,6 +1181,19 @@ class TranslationController extends GetxController with WidgetsBindingObserver { Logger.info('录音已停止,计时器已重置'); if (_shouldArchiveRecording && path != null && seconds > 0) { + // 转写在归档**之前**就地算好:列表和暂停区间都是 controller 的状态, + // 归档是异步的,等它跑到的时候页面可能已经退了、下一轮也可能已经开始。 + final transcript = startedAt == null + ? const >[] + : 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 // connect + 30s receive),await 会把「点结束」一路堵到超时。 // 归档本身不依赖 controller(RecordingArchive 是静态的), @@ -1176,17 +1202,27 @@ class TranslationController extends GetxController with WidgetsBindingObserver { path: path, seconds: seconds, tag: 'TranslationController', + transcript: transcript, + // 总结用用户自己的语言(源语言);对方说的外语那几句原文+译文都在转写里 + summaryLanguage: sourceLanguageCode.value, )); } + _recordingPauses.clear(); } - /// 这个模式的录音要不要进「语音记录助手」。 + /// 这个模式的录音要不要进「语音纪要」。 /// - /// 通话翻译和同声翻译要;音视频翻译录的是系统声音、面对面翻译是逐句 - /// 按轮次开停的(见 _startFaceToFaceRecognition),两者的归档语义还没定, - /// 先不动。 + /// 通话 / 同声 / 面对面翻译都要(2026-09-18 起面对面也归档:它逐句按轮次 + /// 暂停/继续,偏移由 [_recordingPauses] 扣掉)。音视频翻译录的是系统播放声, + /// 归档语义还没定,先不动。 bool get _shouldArchiveRecording => - currentMode.value == 'call' || currentMode.value == 'simultaneous'; + currentMode.value == 'call' || + currentMode.value == 'simultaneous' || + currentMode.value == 'faceToFace'; + + /// 这三个模式默认开录音:不录就没有文件,也就没有东西进语音纪要, + /// 而产品口径是「翻译完在纪要里能看到音频 + 转写 + 总结」。 + bool get _recordsByDefault => _shouldArchiveRecording; /// 归档文件名前缀,取值见 RecordingArchive 的约定。 String get _archivePrefix => @@ -1194,6 +1230,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 暂停录音 Future pauseRecording() async { + _pauseStartedAt ??= DateTime.now(); _timerManager.pauseTimer(); if (currentMode.value == 'call') { await _astService.pauseRecord(); @@ -1206,6 +1243,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 继续录音 Future continueRecording() async { + if (_pauseStartedAt != null) { + _recordingPauses.add((_pauseStartedAt!, DateTime.now())); + _pauseStartedAt = null; + } _timerManager.resumeTimer(); if (currentMode.value == 'call') { await _astService.resumeRecord(); @@ -1216,6 +1257,16 @@ class TranslationController extends GetxController with WidgetsBindingObserver { Logger.info(' 录音已继续'); } + /// 本地还允不允许开翻译。 + /// + /// 旧的「翻译额度」桶(tradeintegral)已停用、不再发放,拿它当闸门会把所有 + /// 新账号挡在门外。真正的准入在服务端:无 VIP 时 user_translate 直接返回 + /// VipRequired,算力不足按后台开关决定放不放行。客户端这层只保留一个 + /// 「老账号还有存量额度」的兼容判断:**有存量就放行,没有也放行**, + /// 即恒为 true —— 保留这个函数是为了把「为什么不再判额度」写在一个地方, + /// 将来要恢复本地闸门也只改这里。 + bool _hasTranslationQuota() => true; + /// 开始语音识别 /// 同声翻译、通话翻译、多媒体翻译的语音识别调用入口 Future startRecognition() async { @@ -1230,8 +1281,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver { Logger.info( '[TRANS-INIT] startRecognition: mode=${currentMode.value}, 语言对=$srcName(${sourceLanguageCode.value}) -> $tgtName(${targetLanguageCode.value})'); } - //检查用户是否登录且积分是否足够,不足则提示充值 - if (User.isLoggedIn() && User.instance.tradeintegral <= 0) { + // ⚠️ 这里原来判的是 `User.instance.tradeintegral <= 0`(旧「翻译额度」桶)。 + // 那三个旧桶(tradeintegral/meetintegral/aichatintegral)已于会员与算力改造时 + // **停用**:余额一次性折进算力,之后再也不发放,新账号恒为 0。于是新用户一点 + // 「开始」就弹充值、识别根本不启动——表现就是「没语音识别、也没翻译」。 + // 准入现在由服务端管(VIP 判 user_translate,算力记流水),客户端不再自己拦。 + if (!_hasTranslationQuota()) { showRechargeDialog('translation'); return; } @@ -1335,6 +1390,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver { _astAutoStartPending = false; } else { Logger.i('Translation', '通话模式:启动 AST 端到端服务'); + // 出错终止过的页面订阅已经没了,不补上这一轮就是「聋」的 + if (_astEventSubscription == null) { + await _subscribeAstEvents(); + } await _astService.startContinuousTranslation(); } } else if (isIosAudioVideo) { @@ -1403,6 +1462,15 @@ class TranslationController extends GetxController with WidgetsBindingObserver { 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 _initializeSession() async { currentSessionId = DateTime.now().millisecondsSinceEpoch.toString(); + // 通话 / 同声 / 面对面默认开录音,每轮开始回到默认开(本轮关掉只管本轮)。 + // 放在这里而不是 _finalizeRecognitionStart:设备端触发的面对面(按住说话) + // 不经过后者,但第一句一定经过这里。 + // ⚠️ 通话翻译录的是通话双方的声音。默认录是 2026-09-18 的产品决定 + // (翻译完要能在纪要里回听),上线前请确认录音告知/合规口径。 + if (_recordsByDefault && !isRecording.value) { + isRecording.value = true; + } try { _usageService.startNewSession(); @@ -1646,18 +1722,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { _startAsrActiveTracking(); isRecognizing.value = true; - // 同声翻译**默认开录音**:不录就没有文件,也就没有东西归档进「语音记录助手」, - // 而用户的预期是translate完能在助手里找到这段音频。 - // 录音按钮仍然在,用户本轮可以随时关掉;下一轮开始时回到默认开。 - // - // ⚠️ 只对同声翻译这么做,**通话翻译保持手动**:那录的是通话双方的声音, - // 默认开启属于「未经告知录音」,合规风险完全不是一个量级。 - // 音视频翻译录的是系统播放声,面对面是逐句按轮次开停,两者归档语义都还没定, - // 一并保持原样。 - if (currentMode.value == 'simultaneous' && !isRecording.value) { - isRecording.value = true; - } - + // 默认录音的开关在 _initializeSession 里定,这里只按开关开文件。 if (isRecording.value && isRecognizing.value) { startRecording(); } @@ -1713,7 +1778,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver { case RecognitionEventType.error: final errorMsg = event.error; var displayMsg = errorMsg; + // 原生报上来的两种写法都要认:receive 失败是「connection failed」, + // URLSession 整个任务被系统掐断(退后台被挂起、ECONNABORTED)是「URLSession failed」。 + // 只匹配前者时后者会把 `[NSPOSIXErrorDomain:53] …` 原样甩给用户。 if (errorMsg.contains('Xunfei connection failed') || + errorMsg.contains('Xunfei URLSession failed') || + errorMsg.contains('NSPOSIXErrorDomain') || errorMsg.contains('Socket is not connected')) { displayMsg = 'asrConnectionFailed'.tr; // 语音识别服务连接失败,请检查网络或稍后重试 } @@ -2179,7 +2249,25 @@ class TranslationController extends GetxController with WidgetsBindingObserver { String detectedSourceLanguageCode; 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; detectedSourceLanguageCode = sourceLanguageCode.value; detectedTargetLanguageCode = targetLanguageCode.value; @@ -2214,6 +2302,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver { _isFaceToFaceDeviceMode && activeSpeaker.value == 0; /// 判断是否为面对面说话者1 + /// 单向翻译的模式:同声翻译、多媒体(音视频)翻译。 + /// + /// 这两个模式的语义是「把听到的内容翻成目标语言播出来」,方向固定。 + /// 面对面要判断谁在说(双向),通话翻译两条腿各自定死方向,都不在此列。 + bool get _isOneWayMode => isOneWayTranslationMode(currentMode.value); + bool _isFaceToFaceSpeaker1() { return (currentMode.value == 'faceToFace' && activeSpeaker.value == 1) || (currentMode.value == 'faceToFace' && @@ -2374,7 +2468,9 @@ class TranslationController extends GetxController with WidgetsBindingObserver { if (text.isEmpty) return; try { - final bool shouldSwap = detectedLanguageCode == targetLanguageCode.value; + // 单向模式播报的永远是目标语言,不做方向推断(见 _isOneWayMode) + final bool shouldSwap = + !_isOneWayMode && detectedLanguageCode == targetLanguageCode.value; final detectedTargetLanguageCode = shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value; final voiceName = _languageManager @@ -2419,6 +2515,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } if (isRecognizing.value) { await stopRecognition(); + } else if (isCreateRecord.value) { + // 面对面翻译每句松手就把识别停了(isRecognizing=false),录音只是暂停、 + // 文件还开着——原来 stopAll 一看识别没在跑就什么都不做,文件永远收不了尾, + // 自然也从来没进过纪要。 + await stopRecording(); } _stopAsrActiveTracking(); @@ -2429,6 +2530,24 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } } + /// 订阅 AST 事件流(幂等:已有订阅先取消再订)。 + /// + /// ⚠️ 除了初始化,**每次启动通话翻译前都要确保订阅还在**: + /// [_terminateCallOnError] 会把订阅取消置空,页面还在、用户再点一次开始, + /// AST 照常握手、识别/翻译日志(service 层打的)一行不少,但 controller + /// 一条事件都收不到——不上屏、不进历史、归档时「转写 0 段」。 + /// 2026-09-18 真机:第一轮 socket 1011 终止后重开,六句话全丢。 + Future _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 _terminateCallOnError(String reason) async { Logger.error('[STS] 通话错误,终止服务: $reason'); @@ -2441,6 +2560,17 @@ class TranslationController extends GetxController with WidgetsBindingObserver { _astAutoStartPending = false; isRecognizing.value = false; isPreparing.value = false; + // 录音跟着识别一起收:原来这条路径不管录音,AST 挂了以后识别停了、 + // 原生录音还在往文件里写,右上角一直显示在录,直到用户退页面才被 onClose + // 的 stopAll 收掉——而且那时 isRecognizing 已经是 false,老的 stopAll 根本 + // 不会去停录音。已录到的部分照常归档(转写有多少算多少)。 + if (isCreateRecord.value) { + try { + await stopRecording(); + } catch (e) { + Logger.error('[STS] 终止时停止录音失败(忽略): $e'); + } + } _stopAsrActiveTracking(); _translationDebounceTimer?.cancel(); await _ttsService.stop(); @@ -2637,8 +2767,28 @@ class TranslationController extends GetxController with WidgetsBindingObserver { 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 swapLanguages() => _runLanguageSwitch(() async { + if (!guardLanguageChange()) return; final wasRecognizing = isRecognizing.value; await stopAll(); @@ -2659,6 +2809,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// [language] 语言名称 Future setSourceLanguage(String language) => _runLanguageSwitch(() async { if (sourceLanguage.value == language) return; + if (!guardLanguageChange()) return; final wasRecognizing = isRecognizing.value; await stopAll(); @@ -2677,6 +2828,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// [language] 语言名称 Future setTargetLanguage(String language) => _runLanguageSwitch(() async { if (targetLanguage.value == language) return; + if (!guardLanguageChange()) return; final wasRecognizing = isRecognizing.value; await stopAll(); @@ -2701,9 +2853,9 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// 所以只有真正需要判断「现在是谁在说」的模式才开: /// - faceToFace:两个人轮流说,方向靠检测结果定,**必须**双语; /// - simultaneous / audioVideo:单向,方向固定,开了纯亏延迟; - /// 固定语种时原生上报的 detectedLanguage 就是 supportedLanguages[0] - /// (见 AzureAsrHelper 的 onResult),[_determineLanguageCodes] 据此 - /// 算出 shouldSwap=true,方向仍然正确。 + /// 方向由 [_determineLanguageCodes] 的 [_isOneWayMode] 分支直接钉成 + /// 「源 → 目标」,**不再看原生上报的 detectedLanguage**(它在固定语种下 + /// 本应等于 supportedLanguages[0],但形式不保证逐字相等,靠它推方向会翻车)。 /// /// ⚠️ 代价:同传下用户若改说目标语种,不会再被自动识别成反向。 /// 同传本来就是单向场景,需要反向请切面对面。 diff --git a/apps/client/lib/modules/translation/models/translation_direction.dart b/apps/client/lib/modules/translation/models/translation_direction.dart new file mode 100644 index 00000000..e516c185 --- /dev/null +++ b/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); +} diff --git a/apps/client/lib/modules/translation/models/translation_export.dart b/apps/client/lib/modules/translation/models/translation_export.dart new file mode 100644 index 00000000..13c00a21 --- /dev/null +++ b/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> buildMeetingTranscript({ + required List 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 = >[]; + 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_` 同一口径。 +const String kSpeakerSelf = 'Speaker_0'; +const String kSpeakerPeer = 'Speaker_1'; diff --git a/apps/client/lib/modules/translation/views/translation_view.dart b/apps/client/lib/modules/translation/views/translation_view.dart index b0e16d90..36b0b415 100644 --- a/apps/client/lib/modules/translation/views/translation_view.dart +++ b/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 '../controllers/translation_controller.dart'; import '../models/translation_models.dart'; +import '../models/translation_direction.dart'; import '../views/translation_history_view.dart'; import '../bindings/translation_history_binding.dart'; import '../../../core/theme/app_colors.dart'; +import '../../../core/utils/logger.dart'; import '../../../routes/app_routes.dart'; import '../../../core/utils/background_session_notifier.dart'; import '../../common/widgets/language_selector_bar.dart'; @@ -168,13 +170,33 @@ class TranslationView extends GetView { ); } + /// 停止翻译,但**绝不让返回键卡死**。 + /// + /// ⚠️ 这个页面是 `PopScope(canPop: false)`,返回只能由这里的 `Get.back()` 放行。 + /// 原来直接 `await controller.stopAll()`,而这条链末端是一串原生往返 + /// (关 ASR 连接、收音频引擎、停录屏接收、归档),任何一处不返回, + /// 返回键就变成永久空操作 —— 用户的说法是「退不出去」。 + /// 现在给它一个上限:超时就先退出去,收尾让它在后台继续跑完。 + static const Duration _stopAllMaxWait = Duration(seconds: 3); + + Future _stopAllSafely() async { + try { + await controller.stopAll().timeout(_stopAllMaxWait); + } on TimeoutException { + Logger.w('Translation', + '停止翻译超过 ${_stopAllMaxWait.inSeconds}s 仍未返回,先退出页面,收尾继续在后台进行'); + } catch (e) { + Logger.e('Translation', '停止翻译失败(忽略,继续退出): $e'); + } + } + Future _handlePop(bool isDarkMode) async { // 只有通话翻译在识别中才需要问;其它模式、或通话翻译还没开始识别, // 跟以前一样直接停止退出。 final isActiveCall = controller.currentMode.value == 'call' && (controller.isRecognizing.value || controller.isPreparing.value); if (!isActiveCall) { - await controller.stopAll(); + await _stopAllSafely(); Get.back(); _deleteControllerAfterPop(); return; @@ -183,7 +205,7 @@ class TranslationView extends GetView { final action = await _showExitConfirmDialog(isDarkMode); switch (action) { case _TranslationExitAction.end: - await controller.stopAll(); + await _stopAllSafely(); Get.back(); _deleteControllerAfterPop(); return; @@ -418,15 +440,26 @@ class TranslationView extends GetView { LanguageSelectorBar( sourceText: () => controller.sourceLanguage.value.split(' ')[0], targetText: () => controller.targetLanguage.value.split(' ')[0], - onTapSource: () => LanguageSelectionDialog.show( - true, isDarkMode, - excludeLanguage: controller.targetLanguage.value, - translationController: controller), - onTapTarget: () => LanguageSelectionDialog.show( - false, isDarkMode, - excludeLanguage: controller.sourceLanguage.value, - translationController: controller), + // 翻译进行中不开弹窗(见 controller.guardLanguageChange) + onTapSource: () { + if (!controller.guardLanguageChange()) return; + LanguageSelectionDialog.show(true, isDarkMode, + excludeLanguage: controller.targetLanguage.value, + translationController: controller); + }, + onTapTarget: () { + if (!controller.guardLanguageChange()) return; + LanguageSelectionDialog.show(false, isDarkMode, + excludeLanguage: controller.sourceLanguage.value, + translationController: controller); + }, onSwap: controller.swapLanguages, + // 同声翻译 / 多媒体翻译是单向的,图标跟着变成单向箭头, + // 免得双向图标让人以为反着说也能翻(见 isOneWayTranslationMode) + swapIcon: () => + isOneWayTranslationMode(controller.currentMode.value) + ? Icons.arrow_forward + : Icons.swap_horiz, translateLanguageNames: true, // 是否翻译语言名称 fontSizeSp: 15, // 面对面翻译:左边胶囊 = 左耳,右边胶囊 = 右耳。 diff --git a/apps/client/lib/modules/translation/views/widgets/face_to_face_layout.dart b/apps/client/lib/modules/translation/views/widgets/face_to_face_layout.dart index 09b86e4c..b758b6ab 100644 --- a/apps/client/lib/modules/translation/views/widgets/face_to_face_layout.dart +++ b/apps/client/lib/modules/translation/views/widgets/face_to_face_layout.dart @@ -258,6 +258,8 @@ class _FaceToFaceLayoutState extends State { /// 显示语言选择对话框 void _showLanguageSelectionDialog(bool isSource, bool isDarkMode) { + // 翻译进行中不开弹窗(见 controller.guardLanguageChange) + if (!widget.controller.guardLanguageChange()) return; LanguageSelectionDialog.show(isSource, isDarkMode, excludeLanguage: isSource ? widget.controller.targetLanguage.value diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt index 60ff8eb8..61308867 100644 --- a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AstCallbacks.kt +++ b/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} " + "finalText=\"$finalText\" finalAudio=${finalAudio.size}B → markEnd()" ) - eventSender.send( - mapOf( - "type" to "translated", - "serviceId" to serviceId, - "direction" to direction, - "utteranceId" to sessionId, - "originalText" to (doubaoFinalSourceTextCache.remove("$serviceId:$sessionId") ?: ""), - "translatedText" to finalText, - "targetLanguage" to targetLanguage - ) - ) + // 只清缓存,**不发 "translated"**:finalText 是 helper 的 fullTextBuffer——整个会话 + // 每一句 `.done` 译文拼起来的全文,每一句早已各自经 onFinalTranslatedText 上报过。 + // utteranceId 又是会话 id、对不上任何条目,Dart 侧只能新建一条「原文为空、 + // 译文是全部句子拼接」的记录,通话一结束界面上就多出 A/B 各一大段乱文。 + // 与 iOS AzureSpeechPlugin.swift 的 onSessionFinished 同改(2026-09-18)。 + doubaoFinalSourceTextCache.remove("$serviceId:$sessionId") // if (finalAudio.isNotEmpty()) { // audioWriter?.write(finalAudio) diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index a05fca3a..e365fb9a 100644 --- a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/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 协程作用域 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 bleRightMutex = Mutex() @@ -1101,12 +1112,20 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, } "stopSpeaking" -> { - // 直接调用stop方法 - try { - val success = azureTtsHelper.stop() - result.success(success) - } catch (e: Exception) { - result.error("STOP_ERROR", "停止语音合成失败: ${e.message}", null) + // ⚠️ 不能在平台线程(Android 主线程)上直接调:helper.stop() 里是 + // `StopSpeakingAsync().get(1500ms)`,正在播报时会实打实等满。 + // 「结束翻译」一次要走 ASR stop(≤1s) + TTS stop(≤1.5s) + + // TTS dispose(内部再 stop 一次,≤1.5s),叠起来逼近 ANR 的 5s 输入阈值, + // 表现是点了返回键长时间无响应、甚至系统弹「应用无响应」。 + // 挪到串行后台线程,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" -> { - azureTtsHelper.release() - result.success(true) + // 同上:release() 内部会先 stop(),同样会阻塞。 + ttsControlScope.launch { + try { + azureTtsHelper.release() + } catch (e: Exception) { + FileLogger.e(tag, "释放 TTS 资源失败: ${e.message}") + } + mainHandler.post { result.success(true) } + } } "setAudioOutputDevice" -> { diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift index 1213ee58..265e3b84 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureAsrHelper.swift +++ b/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-") } ) 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 = onlySupportedByXunfei && xunfeiConfigReady && !hasMultipleChineseVariants + && !azureConfigReady let initMsg = "[XunFei_ASR] ASR initialize: supportedLanguages=\(supportedLanguages.joined(separator: ",")), audioSourceType=\(audioSourceType)" os_log("%{public}@", log: log, type: .info, initMsg) @@ -902,8 +914,10 @@ public class AzureAsrHelper: NSObject { self.supportedLanguages = [validatedSourceLang, validatedTargetLang] self.currentLanguage = validatedSourceLang - // 更新是否使用讯飞 - self.useXunfei = supportedLanguages.allSatisfy { $0 == "zh-CN" || $0 == "en-US" } + // 更新是否使用讯飞(口径与 initialize 一致:Azure 可用就不走讯飞) + let azureConfigReady = !self.subscriptionKey.isEmpty && !self.region.isEmpty + self.useXunfei = !azureConfigReady + && supportedLanguages.allSatisfy { $0 == "zh-CN" || $0 == "en-US" } // 重新配置语言检测 self.isAutoDetectLanguage = (validatedSourceLang != validatedTargetLang) diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index 33b1b95e..755c11d8 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift @@ -90,6 +90,19 @@ import ble_service private var ttsEventChannel: FlutterEventChannel? internal var ttsEventSink: FlutterEventSink? 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事件监听器 private var isTtsListenerAdded = false @@ -409,6 +422,20 @@ internal func handlePcmPlayerMethodCall(_ call: FlutterMethodCall, result: @esca case "dispose": pcmSpeakerPlayer.dispose() 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: result(FlutterMethodNotImplemented) } @@ -959,14 +986,20 @@ private func sendAudioDataEvent(_ event: [String: Any]) { result(success) case "stopSpeaking": - let success = azureTtsHelper.stop() - result(success) - + // 见 ttsControlQueue 的说明:不能在平台线程上等它,会冻住整个界面。 + // 立刻回 true,真正的收尾在队列上跑完;调用方(stopAll)不依赖它的返回值。 + ttsControlQueue.async { [weak self] in + _ = self?.azureTtsHelper.stop() + } + result(true) + case "isSpeaking": result(azureTtsHelper.isSpeaking()) - + case "dispose": - azureTtsHelper.dispose() + ttsControlQueue.async { [weak self] in + self?.azureTtsHelper.dispose() + } result(true) case "setAudioOutputDevice": guard let args = call.arguments as? [String: Any], @@ -1855,15 +1888,7 @@ private class DoubaoCallbackProxy: DoubaoE2ETranslateHelper.Callback { // 段尾合约(与 Android markEnd 对齐):**不**重复写 finalAudio,只 emit 空 PCM + isFinal=true。 // partial 阶段已累积到 native,这里只是告诉 device_jieli “这段结束了,可以整段下发”。 plugin?.pushAstTtsFrame(leg: serviceId, pcm: Data(), isFinal: true) - plugin?.sendAstEvent([ - "type": "translated", - "serviceId": serviceId, - "direction": direction, - "translatedText": finalText, - "targetLanguage": targetLanguage, - "utteranceId": sessionId, - "originalText": original - ]) + // 不再发 "translated":finalText 是整个会话的拼接全文,理由见下方 AliyunCallback 的同名方法。 } 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) // 段尾合约(与 Android markEnd 对齐):**不**重复写 finalAudio,只 emit 空 PCM + isFinal=true。 plugin?.pushAstTtsFrame(leg: serviceId, pcm: Data(), isFinal: true) - plugin?.sendAstEvent([ - "type": "translated", - "serviceId": serviceId, - "direction": direction, - "translatedText": finalText, - "targetLanguage": targetLanguage, - "utteranceId": sessionId, - "originalText": original - ]) + // ⚠️ 这里**不能**再发 "translated" 事件。finalText 是 helper 的 fullTextBuffer—— + // 整个会话里每一句 `.done` 译文用空格拼起来的全文,而每一句早就各自经 + // onFinalTranslatedText 上报过了。utteranceId 又是会话 id、对不上任何条目, + // Dart 侧只能新建一条「原文为空、译文是全部句子拼接」的记录:通话一结束 + // 界面上就多出 A/B 各一大段乱文(2026-09-18 用户报的「结束后出现一段总结」)。 } func onSessionError(sessionId: String, code: Int, message: String) { diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift index 4c173a65..c7ff037b 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift +++ b/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 let streamChunkBytes = 3200 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 pushStreamCallbackCount = 0 private var synthEventAudioCount = 0 @@ -335,6 +353,8 @@ public class AzureTtsHelper: NSObject, ITtsService, AVAudioPlayerDelegate { */ public func stop() -> Bool { os_log("liwei------------- 停止语音合成 session=%{public}@", log: self.log, type: .info, sessionid) + setStopInProgress(true) + defer { setStopInProgress(false) } speaking = false sessionid = "" synthesisSessionIdSnapshot = "" @@ -883,6 +903,8 @@ public class AzureTtsHelper: NSObject, ITtsService, AVAudioPlayerDelegate { * - Throws: 无 */ private func scheduleAvailablePcmBuffers() { + // 正在停止就别再去建引擎/激活会话了 —— stop() 正等着这条队列(见 stopInProgress) + if stopInProgress { return } guard ensureStreamingEngineIfNeeded() else { return } if #available(iOS 18.0, *) { diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/XunFeiAsrHelper.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/XunFeiAsrHelper.swift index 44154ae2..25033230 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/XunFeiAsrHelper.swift +++ b/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 } + /// 本轮是否已经就「连接被我们自己取消」记过一条日志,见 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) { _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.sessionId = "" self.audioFrameCount = 0 + self.loggedCancelOnce = false if !isConfigValid() { _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?.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() } @@ -132,10 +163,18 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate { guard let webSocketTask = webSocketTask else { return } let message = URLSessionWebSocketTask.Message.data(frameBuffer) webSocketTask.send(message) { [weak self] error in - guard let self = self else { return } - if let error = error { - self._log("ERROR", "[XunFei_ASR] Send audio failed (frame #\(self.audioFrameCount)): \(error.localizedDescription)") + guard let self = self, let error = error else { return } + // ⚠️ 停止时把连接 cancel 掉,**所有还排在队列里没发出去的帧**都会在这里 + // 一起报 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 // 每 200 帧打一条日志 @@ -167,6 +206,12 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate { case .failure(let error): let elapsed = self.connectStartTime.map { Int(Date().timeIntervalSince($0) * 1000) } ?? -1 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.callback?.onError(sessionid: self.currentSessionId, 1004, "Xunfei connection failed (\(elapsed)ms): [\(nsError.domain):\(nsError.code)] \(error.localizedDescription)") @@ -210,6 +255,11 @@ class XunFeiAsrHelper: NSObject, URLSessionWebSocketDelegate { if let error = error { let elapsed = connectStartTime.map { Int(Date().timeIntervalSince($0) * 1000) } ?? -1 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)") callback?.onError(sessionid: currentSessionId, 1004, "Xunfei URLSession failed (\(elapsed)ms): [\(nsError.domain):\(nsError.code)] \(error.localizedDescription)") diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MediaKeepAlivePlayer.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/MediaKeepAlivePlayer.swift new file mode 100644 index 00000000..e9cfa33d --- /dev/null +++ b/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() + } +} diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift index 41546667..f4e7a1c2 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift +++ b/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 recordfile: RecordFile? private var currentRecognitionMode = "normal" // 当前识别模式:normal, ble_wakeup, phone_call, push_to_talk + /// media 模式的后台保活(见 MediaKeepAlivePlayer 的说明),其它模式恒为 nil + private var mediaKeepAlive: MediaKeepAlivePlayer? public var isHeadphones = true /** @@ -417,6 +419,16 @@ public class SimpleAudioReceiver: NSObject { // 停止麦克风采集,确保不走通话音道 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() _isWriting=false micCapture.stopCapture() + mediaKeepAlive?.stop() + mediaKeepAlive = nil print("stopMicrophoneCapture") } diff --git a/apps/client/pubspec.yaml b/apps/client/pubspec.yaml index e3194779..fd1655a1 100644 --- a/apps/client/pubspec.yaml +++ b/apps/client/pubspec.yaml @@ -1,7 +1,7 @@ name: eaimar description: "Eaimar - AI Voice Assistant." publish_to: "none" -version: 1.0.1+10 +version: 1.0.1+14 environment: sdk: ">=3.3.0 <4.0.0" diff --git a/apps/client/test/deapsound_ai_url_test.dart b/apps/client/test/deapsound_ai_url_test.dart new file mode 100644 index 00000000..9f8f9a54 --- /dev/null +++ b/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'); + }); +} diff --git a/apps/client/test/login_methods_test.dart b/apps/client/test/login_methods_test.dart new file mode 100644 index 00000000..02ca6bf9 --- /dev/null +++ b/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 ios({bool guest = true}) => + resolveLoginMethods(isIOS: true, envChannel: 'CHINA_XIAOMI', guestVisible: guest); + Set google({bool guest = true}) => resolveLoginMethods( + isIOS: false, envChannel: 'GLOBAL_GOOGLE', guestVisible: guest); + Set 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); + } + }); +} diff --git a/apps/client/test/stereo_pairer_test.dart b/apps/client/test/stereo_pairer_test.dart new file mode 100644 index 00000000..ef1b8373 --- /dev/null +++ b/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); // 留下的是最新的那一段 + }); +} diff --git a/apps/client/test/translation_direction_test.dart b/apps/client/test/translation_direction_test.dart new file mode 100644 index 00000000..df3f7c63 --- /dev/null +++ b/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); + }); +} diff --git a/apps/client/test/translation_export_test.dart b/apps/client/test/translation_export_test.dart new file mode 100644 index 00000000..9533570b --- /dev/null +++ b/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, + ); + }); +}