From 714a05b6d477d10c64c1ed529b8d5e18759e0432 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Tue, 8 Sep 2026 09:43:18 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=20iOS=20EMAI=20=E6=B2=A1=E6=9C=89?= =?UTF-8?q?=E8=AF=AD=E9=9F=B3=E6=92=AD=E6=8A=A5=E3=80=81=E8=BF=9E=E7=BB=AD?= =?UTF-8?q?=E5=AF=B9=E8=AF=9D=E4=B8=8A=E8=A1=8C=E6=96=AD=E4=BE=9B=EF=BC=9B?= =?UTF-8?q?=E6=8E=A5=E5=85=A5=20MCP=20=E4=BB=A4=E7=89=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iOS 上助手一直不出声,和这次改连续对话无关,是播报本来就坏的。三轮真机对照 实测:just_audio 喂的是「长度未知的直播 WAV 流」,AVPlayer 只发一次 `Range: bytes=0-1` 的嗅探请求就判定资源不可播,回 -11850 且 processingState 直接落 idle,一次都没真正起播。声明长度、支持 range、把收到的字节全缓存下来 允许重放,三种都试过,结论一样。ExoPlayer 不发这个嗅探请求,所以安卓一直是 好的——这就是「安卓有声、苹果没声」的全部原因。 iOS 改走原生 AVAudioEngine 排队播 PCM(PcmSpeakerPlayer),安卓不动。 验证方式:一次性灌 3 秒音频,7ms 喂完但 3708ms 才播完,证明是实时渲染不是丢弃。 连续对话的上行也是断的,服务端一直回 ClientAudioTimeout。两个原因: - _ensureContinuousMic 被 Started 事件和 _enterContinuous 并发调用,两边都在 `_micRunning` 置位前通过守卫,同一个 recorder 被 startStream 两遍,第二次把 第一次那条流顶掉。控制器侧改成同步置位,MicPcmStreamer 内部再加一层 in-flight 去重。 - 开麦前没配好并激活音频会话。会话停在 soloAmbient 且未激活时,startStream 会**成功返回**、引擎也起得来,但输入 tap 一个 buffer 都不回调,客户端毫无报错。 麦克风和播放器统一到 EmaiAudioSession 一份会话配置:各配各的会在连续对话下 互相覆盖。iOS 固定 voiceChat——record 的 echoCancel 会打开 voice-processing 并 把 mode 改成 voiceChat 持有着,播放这边写回 default 就是两边互相掀桌子。 androidAudioAttributes 仍是 media,别改成 voiceCommunication(华为会静音)。 连续对话交互按要求改:进页面只建会话不开麦,短按开始、再按结束、可反复, 退出即停,10 秒无对话往来自动结束。计时依据必须是对话事件不是音频包—— 麦克风没人说话也在稳定出包,拿它续命这个超时永远不会触发。 另修 _enterContinuous 在 reconnect() 一返回就判 `!= ready`:ready 要等服务端回 Started(一次网络往返),几乎每次都命中,于是刚打开的连续对话被当场关掉, 而 Started 稍后到达又会把麦开起来,留下一个界面上看不见的常开麦克风。 Co-Authored-By: Claude Opus 5 --- .../lib/core/translations/language/ar_sa.dart | 2 + .../lib/core/translations/language/bg_bg.dart | 2 + .../lib/core/translations/language/cs_cz.dart | 2 + .../lib/core/translations/language/da_dk.dart | 2 + .../lib/core/translations/language/de_de.dart | 2 + .../lib/core/translations/language/el_gr.dart | 2 + .../lib/core/translations/language/en_us.dart | 2 + .../lib/core/translations/language/es_es.dart | 2 + .../lib/core/translations/language/et_ee.dart | 2 + .../lib/core/translations/language/fa_ir.dart | 2 + .../lib/core/translations/language/fi_fi.dart | 2 + .../lib/core/translations/language/fr_fr.dart | 2 + .../lib/core/translations/language/hi_in.dart | 2 + .../lib/core/translations/language/hr_hr.dart | 2 + .../lib/core/translations/language/hu_hu.dart | 2 + .../lib/core/translations/language/id_id.dart | 2 + .../lib/core/translations/language/is_is.dart | 2 + .../lib/core/translations/language/it_it.dart | 2 + .../lib/core/translations/language/ja_jp.dart | 2 + .../lib/core/translations/language/ko_kr.dart | 2 + .../lib/core/translations/language/lt_lt.dart | 2 + .../lib/core/translations/language/lv_lv.dart | 2 + .../lib/core/translations/language/ms_my.dart | 2 + .../lib/core/translations/language/nb_no.dart | 2 + .../lib/core/translations/language/nl_nl.dart | 2 + .../lib/core/translations/language/pl_pl.dart | 2 + .../lib/core/translations/language/pt_pt.dart | 2 + .../lib/core/translations/language/ro_ro.dart | 2 + .../lib/core/translations/language/ru_ru.dart | 2 + .../lib/core/translations/language/sk_sk.dart | 2 + .../lib/core/translations/language/sl_si.dart | 2 + .../lib/core/translations/language/sr_rs.dart | 2 + .../lib/core/translations/language/sv_se.dart | 2 + .../lib/core/translations/language/th_th.dart | 2 + .../lib/core/translations/language/tl_ph.dart | 2 + .../lib/core/translations/language/tr_tr.dart | 2 + .../lib/core/translations/language/uk_ua.dart | 2 + .../lib/core/translations/language/vi_vn.dart | 2 + .../lib/core/translations/language/zh_cn.dart | 2 + .../lib/core/translations/language/zh_hk.dart | 2 + .../lib/core/translations/language/zh_tw.dart | 2 + .../lib/data/services/account_service.dart | 5 + .../services/bailian_multimodal_service.dart | 46 +++- .../data/services/bes_ai_session_service.dart | 2 + .../lib/data/services/mcp_token_service.dart | 61 +++++ .../meeting/meeting_task_service.dart | 67 +++++- .../client/lib/data/services/network/api.dart | 9 + .../lib/data/utils/emai_audio_session.dart | 67 ++++++ .../lib/data/utils/mic_pcm_streamer.dart | 64 ++++- .../lib/data/utils/pcm_stream_player.dart | 130 ++++++---- .../emai/controllers/emai_controller.dart | 223 +++++++++++++++++- .../lib/modules/emai/views/emai_view.dart | 44 +++- .../views/widgets/memory_edit_sheet.dart | 134 ++++++----- .../meeting/views/tabs/summary_tab.dart | 53 ++++- .../azure_speech/AzureSpeechPlugin.swift | 43 ++++ .../azure_speech/PcmSpeakerPlayer.swift | 187 +++++++++++++++ apps/client/pubspec.yaml | 2 +- 57 files changed, 1080 insertions(+), 139 deletions(-) create mode 100644 apps/client/lib/data/services/mcp_token_service.dart create mode 100644 apps/client/lib/data/utils/emai_audio_session.dart create mode 100644 apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/PcmSpeakerPlayer.swift diff --git a/apps/client/lib/core/translations/language/ar_sa.dart b/apps/client/lib/core/translations/language/ar_sa.dart index 5b5b36d9..8f53cefe 100644 --- a/apps/client/lib/core/translations/language/ar_sa.dart +++ b/apps/client/lib/core/translations/language/ar_sa.dart @@ -1770,6 +1770,8 @@ const Map arSA = { 'emaiThinking': 'يفكر…', 'emaiResponding': 'يرد…', 'emaiHoldToTalk': 'اضغط مع الاستمرار للتحدث', + 'emaiTapForContinuous': 'اضغط للمحادثة المستمرة', + 'emaiContinuousOn': 'محادثة مستمرة · اضغط للإيقاف', 'emaiReleaseToSend': 'ارفع إصبعك للإرسال', 'emaiReleaseToCancel': 'ارفع إصبعك للإلغاء', 'emaiInputHint': 'قل شيئًا لـ EMAI…', diff --git a/apps/client/lib/core/translations/language/bg_bg.dart b/apps/client/lib/core/translations/language/bg_bg.dart index 388cf5c9..d16abd28 100644 --- a/apps/client/lib/core/translations/language/bg_bg.dart +++ b/apps/client/lib/core/translations/language/bg_bg.dart @@ -1832,6 +1832,8 @@ const Map bgBG = { 'emaiThinking': 'Мисля…', 'emaiResponding': 'Отговарям…', 'emaiHoldToTalk': 'Задръжте, за да говорите', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Пуснете, за да изпратите', 'emaiReleaseToCancel': 'Пуснете, за да откажете', 'emaiInputHint': 'Кажете нещо на EMAI…', diff --git a/apps/client/lib/core/translations/language/cs_cz.dart b/apps/client/lib/core/translations/language/cs_cz.dart index f8a864f2..ec4d5e58 100644 --- a/apps/client/lib/core/translations/language/cs_cz.dart +++ b/apps/client/lib/core/translations/language/cs_cz.dart @@ -1801,6 +1801,8 @@ const Map csCZ = { 'emaiThinking': 'Přemýšlím…', 'emaiResponding': 'Odpovídám…', 'emaiHoldToTalk': 'Podržte a mluvte', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Uvolněním odešlete', 'emaiReleaseToCancel': 'Uvolněním zrušíte', 'emaiInputHint': 'Řekněte něco EMAI…', diff --git a/apps/client/lib/core/translations/language/da_dk.dart b/apps/client/lib/core/translations/language/da_dk.dart index 39fd2c84..8c7869f5 100644 --- a/apps/client/lib/core/translations/language/da_dk.dart +++ b/apps/client/lib/core/translations/language/da_dk.dart @@ -1802,6 +1802,8 @@ const Map daDK = { 'emaiThinking': 'Tænker…', 'emaiResponding': 'Svarer…', 'emaiHoldToTalk': 'Hold nede for at tale', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Slip for at sende', 'emaiReleaseToCancel': 'Slip for at annullere', 'emaiInputHint': 'Sig noget til EMAI…', diff --git a/apps/client/lib/core/translations/language/de_de.dart b/apps/client/lib/core/translations/language/de_de.dart index 4b079dc6..3e836b4f 100644 --- a/apps/client/lib/core/translations/language/de_de.dart +++ b/apps/client/lib/core/translations/language/de_de.dart @@ -2044,6 +2044,8 @@ const Map deDE = { 'emaiThinking': 'Denke nach…', 'emaiResponding': 'Antwortet…', 'emaiHoldToTalk': 'Zum Sprechen gedrückt halten', + 'emaiTapForContinuous': 'Tippen für Dauergespräch', + 'emaiContinuousOn': 'Dauergespräch · zum Beenden tippen', 'emaiReleaseToSend': 'Loslassen zum Senden', 'emaiReleaseToCancel': 'Loslassen zum Abbrechen', 'emaiInputHint': 'Sag etwas zu EMAI…', diff --git a/apps/client/lib/core/translations/language/el_gr.dart b/apps/client/lib/core/translations/language/el_gr.dart index 2a2aa703..dd80a72c 100644 --- a/apps/client/lib/core/translations/language/el_gr.dart +++ b/apps/client/lib/core/translations/language/el_gr.dart @@ -1836,6 +1836,8 @@ const Map elGR = { 'emaiThinking': 'Σκέφτομαι…', 'emaiResponding': 'Απαντώ…', 'emaiHoldToTalk': 'Κρατήστε πατημένο για ομιλία', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Αφήστε για αποστολή', 'emaiReleaseToCancel': 'Αφήστε για ακύρωση', 'emaiInputHint': 'Πείτε κάτι στο EMAI…', diff --git a/apps/client/lib/core/translations/language/en_us.dart b/apps/client/lib/core/translations/language/en_us.dart index b7c0beaf..8388ca28 100644 --- a/apps/client/lib/core/translations/language/en_us.dart +++ b/apps/client/lib/core/translations/language/en_us.dart @@ -2012,6 +2012,8 @@ const Map enUS = { 'emaiThinking': 'Thinking…', 'emaiResponding': 'Responding…', 'emaiHoldToTalk': 'Hold to talk', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Release to send', 'emaiReleaseToCancel': 'Release to cancel', 'emaiInputHint': 'Say something to EMAI…', diff --git a/apps/client/lib/core/translations/language/es_es.dart b/apps/client/lib/core/translations/language/es_es.dart index 9787193f..664a5fbd 100644 --- a/apps/client/lib/core/translations/language/es_es.dart +++ b/apps/client/lib/core/translations/language/es_es.dart @@ -2038,6 +2038,8 @@ const Map esES = { 'emaiThinking': 'Pensando…', 'emaiResponding': 'Respondiendo…', 'emaiHoldToTalk': 'Mantén pulsado para hablar', + 'emaiTapForContinuous': 'Toca para chat continuo', + 'emaiContinuousOn': 'Chat continuo · toca para parar', 'emaiReleaseToSend': 'Suelta para enviar', 'emaiReleaseToCancel': 'Suelta para cancelar', 'emaiInputHint': 'Dile algo a EMAI…', diff --git a/apps/client/lib/core/translations/language/et_ee.dart b/apps/client/lib/core/translations/language/et_ee.dart index e6a34dc2..1a5b1d87 100644 --- a/apps/client/lib/core/translations/language/et_ee.dart +++ b/apps/client/lib/core/translations/language/et_ee.dart @@ -1797,6 +1797,8 @@ const Map etEE = { 'emaiThinking': 'Mõtlen…', 'emaiResponding': 'Vastan…', 'emaiHoldToTalk': 'Rääkimiseks hoia all', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Saatmiseks vabasta', 'emaiReleaseToCancel': 'Tühistamiseks vabasta', 'emaiInputHint': 'Ütle EMAI-le midagi…', diff --git a/apps/client/lib/core/translations/language/fa_ir.dart b/apps/client/lib/core/translations/language/fa_ir.dart index 8b7254e1..876aebb9 100644 --- a/apps/client/lib/core/translations/language/fa_ir.dart +++ b/apps/client/lib/core/translations/language/fa_ir.dart @@ -1787,6 +1787,8 @@ const Map faIR = { 'emaiThinking': 'در حال فکر کردن…', 'emaiResponding': 'در حال پاسخ…', 'emaiHoldToTalk': 'برای صحبت نگه دارید', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'برای ارسال رها کنید', 'emaiReleaseToCancel': 'برای لغو رها کنید', 'emaiInputHint': 'چیزی به EMAI بگویید…', diff --git a/apps/client/lib/core/translations/language/fi_fi.dart b/apps/client/lib/core/translations/language/fi_fi.dart index 116c6ba2..e11508cd 100644 --- a/apps/client/lib/core/translations/language/fi_fi.dart +++ b/apps/client/lib/core/translations/language/fi_fi.dart @@ -1807,6 +1807,8 @@ const Map fiFI = { 'emaiThinking': 'Miettii…', 'emaiResponding': 'Vastaa…', 'emaiHoldToTalk': 'Puhu painamalla ja pitämällä', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Lähetä vapauttamalla', 'emaiReleaseToCancel': 'Peruuta vapauttamalla', 'emaiInputHint': 'Sano jotain EMAIlle…', diff --git a/apps/client/lib/core/translations/language/fr_fr.dart b/apps/client/lib/core/translations/language/fr_fr.dart index 3bae2a87..53c3ec6f 100644 --- a/apps/client/lib/core/translations/language/fr_fr.dart +++ b/apps/client/lib/core/translations/language/fr_fr.dart @@ -2056,6 +2056,8 @@ const Map frFR = { 'emaiThinking': 'Réflexion…', 'emaiResponding': 'Réponse…', 'emaiHoldToTalk': 'Maintenir pour parler', + 'emaiTapForContinuous': 'Toucher pour discuter en continu', + 'emaiContinuousOn': 'Mode continu · toucher pour arrêter', 'emaiReleaseToSend': 'Relâcher pour envoyer', 'emaiReleaseToCancel': 'Relâcher pour annuler', 'emaiInputHint': 'Dites quelque chose à EMAI…', diff --git a/apps/client/lib/core/translations/language/hi_in.dart b/apps/client/lib/core/translations/language/hi_in.dart index 7573f1c9..8d05653d 100644 --- a/apps/client/lib/core/translations/language/hi_in.dart +++ b/apps/client/lib/core/translations/language/hi_in.dart @@ -1806,6 +1806,8 @@ const Map hiIN = { 'emaiThinking': 'सोच रहा है…', 'emaiResponding': 'जवाब दे रहा है…', 'emaiHoldToTalk': 'बोलने के लिए दबाकर रखें', + 'emaiTapForContinuous': 'निरंतर बातचीत के लिए टैप करें', + 'emaiContinuousOn': 'निरंतर बातचीत · रोकने के लिए टैप करें', 'emaiReleaseToSend': 'भेजने के लिए छोड़ें', 'emaiReleaseToCancel': 'रद्द करने के लिए छोड़ें', 'emaiInputHint': 'EMAI से कुछ कहें…', diff --git a/apps/client/lib/core/translations/language/hr_hr.dart b/apps/client/lib/core/translations/language/hr_hr.dart index deaddb0d..3621a2bc 100644 --- a/apps/client/lib/core/translations/language/hr_hr.dart +++ b/apps/client/lib/core/translations/language/hr_hr.dart @@ -1822,6 +1822,8 @@ const Map hrHR = { 'emaiThinking': 'Razmišljam…', 'emaiResponding': 'Odgovaram…', 'emaiHoldToTalk': 'Držite za govor', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Pustite za slanje', 'emaiReleaseToCancel': 'Pustite za odustajanje', 'emaiInputHint': 'Recite nešto EMAI-ju…', diff --git a/apps/client/lib/core/translations/language/hu_hu.dart b/apps/client/lib/core/translations/language/hu_hu.dart index 831a13e2..92d66b5b 100644 --- a/apps/client/lib/core/translations/language/hu_hu.dart +++ b/apps/client/lib/core/translations/language/hu_hu.dart @@ -1824,6 +1824,8 @@ const Map huHU = { 'emaiThinking': 'Gondolkodom…', 'emaiResponding': 'Válaszolok…', 'emaiHoldToTalk': 'Tartsd nyomva a beszédhez', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Engedd el a küldéshez', 'emaiReleaseToCancel': 'Engedd el a megszakításhoz', 'emaiInputHint': 'Mondj valamit az EMAI-nak…', diff --git a/apps/client/lib/core/translations/language/id_id.dart b/apps/client/lib/core/translations/language/id_id.dart index 5e453825..7348fa17 100644 --- a/apps/client/lib/core/translations/language/id_id.dart +++ b/apps/client/lib/core/translations/language/id_id.dart @@ -1806,6 +1806,8 @@ const Map idID = { 'emaiThinking': 'Berpikir…', 'emaiResponding': 'Menjawab…', 'emaiHoldToTalk': 'Tahan untuk bicara', + 'emaiTapForContinuous': 'Ketuk untuk obrolan kontinu', + 'emaiContinuousOn': 'Obrolan kontinu · ketuk untuk berhenti', 'emaiReleaseToSend': 'Lepaskan untuk kirim', 'emaiReleaseToCancel': 'Lepaskan untuk batal', 'emaiInputHint': 'Katakan sesuatu ke EMAI…', diff --git a/apps/client/lib/core/translations/language/is_is.dart b/apps/client/lib/core/translations/language/is_is.dart index ac9b7842..59f45a38 100644 --- a/apps/client/lib/core/translations/language/is_is.dart +++ b/apps/client/lib/core/translations/language/is_is.dart @@ -1803,6 +1803,8 @@ const Map isIS = { 'emaiThinking': 'Hugsa…', 'emaiResponding': 'Svara…', 'emaiHoldToTalk': 'Haltu inni til að tala', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Slepptu til að senda', 'emaiReleaseToCancel': 'Slepptu til að hætta við', 'emaiInputHint': 'Segðu eitthvað við EMAI…', diff --git a/apps/client/lib/core/translations/language/it_it.dart b/apps/client/lib/core/translations/language/it_it.dart index 1547c2e6..636ddbbe 100644 --- a/apps/client/lib/core/translations/language/it_it.dart +++ b/apps/client/lib/core/translations/language/it_it.dart @@ -1844,6 +1844,8 @@ const Map itIT = { 'emaiThinking': 'Sto pensando…', 'emaiResponding': 'Sto rispondendo…', 'emaiHoldToTalk': 'Tieni premuto per parlare', + 'emaiTapForContinuous': 'Tocca per chat continua', + 'emaiContinuousOn': 'Chat continua · tocca per fermare', 'emaiReleaseToSend': 'Rilascia per inviare', 'emaiReleaseToCancel': 'Rilascia per annullare', 'emaiInputHint': 'Di\' qualcosa a EMAI…', diff --git a/apps/client/lib/core/translations/language/ja_jp.dart b/apps/client/lib/core/translations/language/ja_jp.dart index 6e995c6c..191e152d 100644 --- a/apps/client/lib/core/translations/language/ja_jp.dart +++ b/apps/client/lib/core/translations/language/ja_jp.dart @@ -1841,6 +1841,8 @@ const Map jaJP = { 'emaiThinking': '考え中…', 'emaiResponding': '応答中…', 'emaiHoldToTalk': '押しながら話す', + 'emaiTapForContinuous': 'タップで連続会話', + 'emaiContinuousOn': '連続会話中 · タップで終了', 'emaiReleaseToSend': '離すと送信', 'emaiReleaseToCancel': '離すとキャンセル', 'emaiInputHint': 'EMAI に話しかけてみましょう…', diff --git a/apps/client/lib/core/translations/language/ko_kr.dart b/apps/client/lib/core/translations/language/ko_kr.dart index 8800cc14..78d3b956 100644 --- a/apps/client/lib/core/translations/language/ko_kr.dart +++ b/apps/client/lib/core/translations/language/ko_kr.dart @@ -1834,6 +1834,8 @@ const Map koKR = { 'emaiThinking': '생각 중…', 'emaiResponding': '응답 중…', 'emaiHoldToTalk': '길게 눌러 말하기', + 'emaiTapForContinuous': '탭하여 연속 대화', + 'emaiContinuousOn': '연속 대화 중 · 탭하여 종료', 'emaiReleaseToSend': '손을 떼면 전송', 'emaiReleaseToCancel': '손을 떼면 취소', 'emaiInputHint': 'EMAI에게 말을 걸어 보세요…', diff --git a/apps/client/lib/core/translations/language/lt_lt.dart b/apps/client/lib/core/translations/language/lt_lt.dart index 44751817..3d9f07a6 100644 --- a/apps/client/lib/core/translations/language/lt_lt.dart +++ b/apps/client/lib/core/translations/language/lt_lt.dart @@ -1820,6 +1820,8 @@ const Map ltLT = { 'emaiThinking': 'Galvoju…', 'emaiResponding': 'Atsakau…', 'emaiHoldToTalk': 'Laikykite, kad kalbėtumėte', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Atleiskite, kad išsiųstumėte', 'emaiReleaseToCancel': 'Atleiskite, kad atšauktumėte', 'emaiInputHint': 'Pasakykite ką nors EMAI…', diff --git a/apps/client/lib/core/translations/language/lv_lv.dart b/apps/client/lib/core/translations/language/lv_lv.dart index ba7b59b0..894d4b6a 100644 --- a/apps/client/lib/core/translations/language/lv_lv.dart +++ b/apps/client/lib/core/translations/language/lv_lv.dart @@ -1825,6 +1825,8 @@ const Map lvLV = { 'emaiThinking': 'Domāju…', 'emaiResponding': 'Atbildu…', 'emaiHoldToTalk': 'Turiet, lai runātu', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Atlaidiet, lai nosūtītu', 'emaiReleaseToCancel': 'Atlaidiet, lai atceltu', 'emaiInputHint': 'Pasakiet kaut ko EMAI…', diff --git a/apps/client/lib/core/translations/language/ms_my.dart b/apps/client/lib/core/translations/language/ms_my.dart index 548d2ca6..e9155df3 100644 --- a/apps/client/lib/core/translations/language/ms_my.dart +++ b/apps/client/lib/core/translations/language/ms_my.dart @@ -1810,6 +1810,8 @@ const Map msMY = { 'emaiThinking': 'Berfikir…', 'emaiResponding': 'Menjawab…', 'emaiHoldToTalk': 'Tahan untuk bercakap', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Lepaskan untuk hantar', 'emaiReleaseToCancel': 'Lepaskan untuk batal', 'emaiInputHint': 'Katakan sesuatu kepada EMAI…', diff --git a/apps/client/lib/core/translations/language/nb_no.dart b/apps/client/lib/core/translations/language/nb_no.dart index dd4582fe..7bb92fb6 100644 --- a/apps/client/lib/core/translations/language/nb_no.dart +++ b/apps/client/lib/core/translations/language/nb_no.dart @@ -1804,6 +1804,8 @@ const Map nbNO = { 'emaiThinking': 'Tenker…', 'emaiResponding': 'Svarer…', 'emaiHoldToTalk': 'Hold inne for å snakke', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Slipp for å sende', 'emaiReleaseToCancel': 'Slipp for å avbryte', 'emaiInputHint': 'Si noe til EMAI…', diff --git a/apps/client/lib/core/translations/language/nl_nl.dart b/apps/client/lib/core/translations/language/nl_nl.dart index 1168c7bd..8e27eaff 100644 --- a/apps/client/lib/core/translations/language/nl_nl.dart +++ b/apps/client/lib/core/translations/language/nl_nl.dart @@ -1701,6 +1701,8 @@ const Map nlNL = { 'emaiThinking': 'Nadenken…', 'emaiResponding': 'Antwoorden…', 'emaiHoldToTalk': 'Ingedrukt houden om te praten', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Loslaten om te versturen', 'emaiReleaseToCancel': 'Loslaten om te annuleren', 'emaiInputHint': 'Zeg iets tegen EMAI…', diff --git a/apps/client/lib/core/translations/language/pl_pl.dart b/apps/client/lib/core/translations/language/pl_pl.dart index 100bfbbc..75e22a95 100644 --- a/apps/client/lib/core/translations/language/pl_pl.dart +++ b/apps/client/lib/core/translations/language/pl_pl.dart @@ -1831,6 +1831,8 @@ const Map plPL = { 'emaiThinking': 'Myślę…', 'emaiResponding': 'Odpowiadam…', 'emaiHoldToTalk': 'Przytrzymaj, aby mówić', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Puść, aby wysłać', 'emaiReleaseToCancel': 'Puść, aby anulować', 'emaiInputHint': 'Powiedz coś do EMAI…', diff --git a/apps/client/lib/core/translations/language/pt_pt.dart b/apps/client/lib/core/translations/language/pt_pt.dart index 9b55a5c4..61a1f01f 100644 --- a/apps/client/lib/core/translations/language/pt_pt.dart +++ b/apps/client/lib/core/translations/language/pt_pt.dart @@ -2043,6 +2043,8 @@ const Map ptPT = { 'emaiThinking': 'A pensar…', 'emaiResponding': 'A responder…', 'emaiHoldToTalk': 'Manter premido para falar', + 'emaiTapForContinuous': 'Toque para conversa contínua', + 'emaiContinuousOn': 'Conversa contínua · toque para parar', 'emaiReleaseToSend': 'Largar para enviar', 'emaiReleaseToCancel': 'Largar para cancelar', 'emaiInputHint': 'Diz algo ao EMAI…', diff --git a/apps/client/lib/core/translations/language/ro_ro.dart b/apps/client/lib/core/translations/language/ro_ro.dart index 3607830c..fa55bad9 100644 --- a/apps/client/lib/core/translations/language/ro_ro.dart +++ b/apps/client/lib/core/translations/language/ro_ro.dart @@ -1833,6 +1833,8 @@ const Map roRO = { 'emaiThinking': 'Mă gândesc…', 'emaiResponding': 'Răspund…', 'emaiHoldToTalk': 'Ține apăsat pentru a vorbi', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Eliberează pentru a trimite', 'emaiReleaseToCancel': 'Eliberează pentru a anula', 'emaiInputHint': 'Spune-i ceva lui EMAI…', diff --git a/apps/client/lib/core/translations/language/ru_ru.dart b/apps/client/lib/core/translations/language/ru_ru.dart index c99e5aa0..bc5767a2 100644 --- a/apps/client/lib/core/translations/language/ru_ru.dart +++ b/apps/client/lib/core/translations/language/ru_ru.dart @@ -2019,6 +2019,8 @@ const Map ruRU = { 'emaiThinking': 'Думаю…', 'emaiResponding': 'Отвечаю…', 'emaiHoldToTalk': 'Удерживайте, чтобы говорить', + 'emaiTapForContinuous': 'Нажмите для непрерывного диалога', + 'emaiContinuousOn': 'Непрерывный диалог · нажмите для остановки', 'emaiReleaseToSend': 'Отпустите для отправки', 'emaiReleaseToCancel': 'Отпустите для отмены', 'emaiInputHint': 'Скажите что-нибудь EMAI…', diff --git a/apps/client/lib/core/translations/language/sk_sk.dart b/apps/client/lib/core/translations/language/sk_sk.dart index 728c75f9..03e4abaa 100644 --- a/apps/client/lib/core/translations/language/sk_sk.dart +++ b/apps/client/lib/core/translations/language/sk_sk.dart @@ -1814,6 +1814,8 @@ const Map skSK = { 'emaiThinking': 'Rozmýšľam…', 'emaiResponding': 'Odpovedám…', 'emaiHoldToTalk': 'Podržte a hovorte', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Uvoľnením odošlete', 'emaiReleaseToCancel': 'Uvoľnením zrušíte', 'emaiInputHint': 'Povedzte niečo EMAI…', diff --git a/apps/client/lib/core/translations/language/sl_si.dart b/apps/client/lib/core/translations/language/sl_si.dart index 0bfcc84c..d5d33e54 100644 --- a/apps/client/lib/core/translations/language/sl_si.dart +++ b/apps/client/lib/core/translations/language/sl_si.dart @@ -1817,6 +1817,8 @@ const Map slSI = { 'emaiThinking': 'Razmišljam…', 'emaiResponding': 'Odgovarjam…', 'emaiHoldToTalk': 'Pridržite za govor', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Spustite za pošiljanje', 'emaiReleaseToCancel': 'Spustite za preklic', 'emaiInputHint': 'Povejte kaj EMAI-ju…', diff --git a/apps/client/lib/core/translations/language/sr_rs.dart b/apps/client/lib/core/translations/language/sr_rs.dart index 379fd856..7bcefea8 100644 --- a/apps/client/lib/core/translations/language/sr_rs.dart +++ b/apps/client/lib/core/translations/language/sr_rs.dart @@ -1813,6 +1813,8 @@ const Map srRS = { 'emaiThinking': 'Размишљам…', 'emaiResponding': 'Одговарам…', 'emaiHoldToTalk': 'Држите да бисте говорили', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Пустите да пошаљете', 'emaiReleaseToCancel': 'Пустите да откажете', 'emaiInputHint': 'Реците нешто EMAI-ју…', diff --git a/apps/client/lib/core/translations/language/sv_se.dart b/apps/client/lib/core/translations/language/sv_se.dart index c48f4816..b6ee7574 100644 --- a/apps/client/lib/core/translations/language/sv_se.dart +++ b/apps/client/lib/core/translations/language/sv_se.dart @@ -1809,6 +1809,8 @@ const Map svSE = { 'emaiThinking': 'Tänker…', 'emaiResponding': 'Svarar…', 'emaiHoldToTalk': 'Håll in för att tala', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Släpp för att skicka', 'emaiReleaseToCancel': 'Släpp för att avbryta', 'emaiInputHint': 'Säg något till EMAI…', diff --git a/apps/client/lib/core/translations/language/th_th.dart b/apps/client/lib/core/translations/language/th_th.dart index 14a979e0..401b97ef 100644 --- a/apps/client/lib/core/translations/language/th_th.dart +++ b/apps/client/lib/core/translations/language/th_th.dart @@ -1764,6 +1764,8 @@ const Map thTH = { 'emaiThinking': 'กำลังคิด…', 'emaiResponding': 'กำลังตอบ…', 'emaiHoldToTalk': 'กดค้างเพื่อพูด', + 'emaiTapForContinuous': 'แตะเพื่อสนทนาต่อเนื่อง', + 'emaiContinuousOn': 'สนทนาต่อเนื่อง · แตะเพื่อหยุด', 'emaiReleaseToSend': 'ปล่อยเพื่อส่ง', 'emaiReleaseToCancel': 'ปล่อยเพื่อยกเลิก', 'emaiInputHint': 'พูดอะไรกับ EMAI สักหน่อย…', diff --git a/apps/client/lib/core/translations/language/tl_ph.dart b/apps/client/lib/core/translations/language/tl_ph.dart index 968886a7..442d835d 100644 --- a/apps/client/lib/core/translations/language/tl_ph.dart +++ b/apps/client/lib/core/translations/language/tl_ph.dart @@ -1847,6 +1847,8 @@ const Map tlPH = { 'emaiThinking': 'Nag-iisip…', 'emaiResponding': 'Sumasagot…', 'emaiHoldToTalk': 'Pindutin nang matagal para magsalita', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Bitawan para ipadala', 'emaiReleaseToCancel': 'Bitawan para kanselahin', 'emaiInputHint': 'Magsabi ng kahit ano kay EMAI…', diff --git a/apps/client/lib/core/translations/language/tr_tr.dart b/apps/client/lib/core/translations/language/tr_tr.dart index 05e0d3bb..3b2f7730 100644 --- a/apps/client/lib/core/translations/language/tr_tr.dart +++ b/apps/client/lib/core/translations/language/tr_tr.dart @@ -1802,6 +1802,8 @@ const Map trTR = { 'emaiThinking': 'Düşünüyor…', 'emaiResponding': 'Yanıtlıyor…', 'emaiHoldToTalk': 'Konuşmak için basılı tut', + 'emaiTapForContinuous': 'Sürekli sohbet için dokunun', + 'emaiContinuousOn': 'Sürekli sohbet · durdurmak için dokunun', 'emaiReleaseToSend': 'Göndermek için bırak', 'emaiReleaseToCancel': 'İptal etmek için bırak', 'emaiInputHint': 'EMAI\'ye bir şeyler söyle…', diff --git a/apps/client/lib/core/translations/language/uk_ua.dart b/apps/client/lib/core/translations/language/uk_ua.dart index 4243bd76..c8a34fc0 100644 --- a/apps/client/lib/core/translations/language/uk_ua.dart +++ b/apps/client/lib/core/translations/language/uk_ua.dart @@ -1820,6 +1820,8 @@ const Map ukUA = { 'emaiThinking': 'Думаю…', 'emaiResponding': 'Відповідаю…', 'emaiHoldToTalk': 'Утримуйте, щоб говорити', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': 'Відпустіть, щоб надіслати', 'emaiReleaseToCancel': 'Відпустіть, щоб скасувати', 'emaiInputHint': 'Скажіть щось EMAI…', diff --git a/apps/client/lib/core/translations/language/vi_vn.dart b/apps/client/lib/core/translations/language/vi_vn.dart index 916b68ab..c0036871 100644 --- a/apps/client/lib/core/translations/language/vi_vn.dart +++ b/apps/client/lib/core/translations/language/vi_vn.dart @@ -1783,6 +1783,8 @@ const Map viVN = { 'emaiThinking': 'Đang suy nghĩ…', 'emaiResponding': 'Đang trả lời…', 'emaiHoldToTalk': 'Giữ để nói', + 'emaiTapForContinuous': 'Chạm để trò chuyện liên tục', + 'emaiContinuousOn': 'Đang liên tục · chạm để dừng', 'emaiReleaseToSend': 'Thả ra để gửi', 'emaiReleaseToCancel': 'Thả ra để hủy', 'emaiInputHint': 'Nói gì đó với EMAI…', diff --git a/apps/client/lib/core/translations/language/zh_cn.dart b/apps/client/lib/core/translations/language/zh_cn.dart index 5170de22..88858199 100644 --- a/apps/client/lib/core/translations/language/zh_cn.dart +++ b/apps/client/lib/core/translations/language/zh_cn.dart @@ -1827,6 +1827,8 @@ const Map zhCN = { 'emaiThinking': '思考中…', 'emaiResponding': '回答中…', 'emaiHoldToTalk': '按住说话', + 'emaiTapForContinuous': '点击开始连续对话', + 'emaiContinuousOn': '连续对话中 · 点击结束', 'emaiReleaseToSend': '松开发送', 'emaiReleaseToCancel': '松开取消', 'emaiInputHint': '和 EMAI 说点什么…', diff --git a/apps/client/lib/core/translations/language/zh_hk.dart b/apps/client/lib/core/translations/language/zh_hk.dart index 854bafb2..0d215b9e 100644 --- a/apps/client/lib/core/translations/language/zh_hk.dart +++ b/apps/client/lib/core/translations/language/zh_hk.dart @@ -1811,6 +1811,8 @@ const Map zhHK = { 'emaiThinking': '思考中…', 'emaiResponding': '回答中…', 'emaiHoldToTalk': '按住說話', + 'emaiTapForContinuous': 'Tap for continuous chat', + 'emaiContinuousOn': 'Continuous · tap to stop', 'emaiReleaseToSend': '放開傳送', 'emaiReleaseToCancel': '放開取消', 'emaiInputHint': '同 EMAI 講啲嘢…', diff --git a/apps/client/lib/core/translations/language/zh_tw.dart b/apps/client/lib/core/translations/language/zh_tw.dart index 8afb49f8..5b85cf02 100644 --- a/apps/client/lib/core/translations/language/zh_tw.dart +++ b/apps/client/lib/core/translations/language/zh_tw.dart @@ -1811,6 +1811,8 @@ const Map zhTW = { 'emaiThinking': '思考中…', 'emaiResponding': '回答中…', 'emaiHoldToTalk': '按住說話', + 'emaiTapForContinuous': '點擊開始連續對話', + 'emaiContinuousOn': '連續對話中 · 點擊結束', 'emaiReleaseToSend': '放開傳送', 'emaiReleaseToCancel': '放開取消', 'emaiInputHint': '和 EMAI 說點什麼…', diff --git a/apps/client/lib/data/services/account_service.dart b/apps/client/lib/data/services/account_service.dart index 46988950..73f1a444 100644 --- a/apps/client/lib/data/services/account_service.dart +++ b/apps/client/lib/data/services/account_service.dart @@ -11,6 +11,7 @@ import '../../data/models/user_Info.dart'; import 'meeting/meeting_upload_service.dart'; import '../../core/utils/toast_throttle.dart'; import 'bes_device_auth.dart'; +import 'mcp_token_service.dart'; import 'memory_reminder_scheduler.dart'; import 'memory_service.dart'; @@ -43,6 +44,9 @@ class AccountService extends GetxService { // 换账号后同一台耳机要重新登记到新账号名下, // 不清这个 set 的话「本进程已登记过」会把新账号的绑定吞掉。 BesDeviceAuth.reset(); + // MCP 令牌是按 uid 签的:不清的话新账号会拿着上一个人的令牌去调 MCP, + // 读到的是别人的会议纪要和待办。 + McpTokenService.reset(); // 拾忆是账号数据:不清的话换账号后会看到上一个人的日程、花销和灵感, // 排定的提醒也会继续按上一个账号的安排响。 if (Get.isRegistered()) { @@ -67,6 +71,7 @@ class AccountService extends GetxService { await _storage.erase(); User.clearUserSession(); BesDeviceAuth.reset(); + McpTokenService.reset(); if (Get.isRegistered()) { MemoryService.to.clear(); } diff --git a/apps/client/lib/data/services/bailian_multimodal_service.dart b/apps/client/lib/data/services/bailian_multimodal_service.dart index 31d6f190..4c30e7f5 100644 --- a/apps/client/lib/data/services/bailian_multimodal_service.dart +++ b/apps/client/lib/data/services/bailian_multimodal_service.dart @@ -170,6 +170,7 @@ class BailianMultimodalService extends GetxService { bool enableWebSearch = true, int downstreamRate = downstreamSampleRate, String source = 'emai', + String? mcpToken, }) async { if (!isConfigured) { Logger.e(_tag, '配置不全,无法启动会话:$configHint'); @@ -247,13 +248,32 @@ class BailianMultimodalService extends GetxService { 'client_info': { 'user_id': _safeUserId(userId), }, - // biz_params 是可选块。关掉联网搜索时整块不发—— - // 万一应用侧没开这个能力,多带一个字段可能让 Start 直接被拒, + // biz_params 是可选块,两件事都没有时整块不发—— + // 万一应用侧没开这些能力,多带一个字段可能让 Start 直接被拒, // 那样整个功能都起不来,比少一个搜索严重得多。 - if (enableWebSearch) + if (enableWebSearch || _hasMcpToken(mcpToken)) 'biz_params': { 'user_defined_params': { - 'extra_config': {'enable_web_search': true}, + // MCP 会话令牌:百炼把 user_defined_params 透传给 MCP 工具, + // 我们的工具据此认出「这是哪个用户」。 + // + // ⚠️ **必须按工具名嵌套**,平铺写 `'auth_token': xxx` 会被静默丢弃。 + // 文档只含糊说「mcp 传递参数依照 mcp 服务本身所需的参数」, + // 实测(2026-09-07,四种形状逐个试)才确定是这个形状: + // {"<工具名>": {"auth_token": "..."}} + // 平铺 / 按 MCP 服务名嵌套 / 放 mcp 子节点,三种都收不到。 + // + // ⚠️ 这是整条链路上**唯一**能按用户变的通道:客户端连百炼只带 + // 百炼的 API Key,百炼调 MCP 的 Authorization 头是控制台里 + // 配死的、所有用户共用。不带它,这些工具一律「身份校验失败」。 + // + // ⚠️ 放的是 user_getmcptoken 签发的**短时效专用令牌**, + // 绝不能换成登录 JWT —— 这条路要经过百炼(第三方)。 + if (_hasMcpToken(mcpToken)) + for (final tool in _mcpAuthTools) + tool: {'auth_token': mcpToken}, + if (enableWebSearch) + 'extra_config': {'enable_web_search': true}, }, }, }, @@ -531,6 +551,24 @@ class BailianMultimodalService extends GetxService { } /// 协议限制 user_id 不超过 36 字符 + /// 需要用户身份的 MCP 工具名。令牌要**按工具名逐个挂**,因为百炼是按这个 + /// key 去匹配的(见 start() 里 user_defined_params 的说明)。 + /// + /// ⚠️ 服务端新增需要身份的工具时,这里要同步加一行,否则那个工具永远 + /// 拿不到令牌、永远返回「身份校验失败」,而且客户端不会有任何报错。 + /// 服务端工具清单见 apps/services/modules/mcp/tool_*.go。 + static const List _mcpAuthTools = [ + 'search_meeting_notes', + 'get_memory_items', + 'get_memory_stats', + 'get_memory_report', + 'get_user_tasks', + 'add_user_task', + 'cancel_user_task', + ]; + + static bool _hasMcpToken(String? v) => (v ?? '').trim().isNotEmpty; + static String _safeUserId(String? raw) { final v = (raw ?? '').trim(); if (v.isEmpty) return 'eaimar-guest'; diff --git a/apps/client/lib/data/services/bes_ai_session_service.dart b/apps/client/lib/data/services/bes_ai_session_service.dart index 4f3a3409..d538bcda 100644 --- a/apps/client/lib/data/services/bes_ai_session_service.dart +++ b/apps/client/lib/data/services/bes_ai_session_service.dart @@ -9,6 +9,7 @@ import '../models/emai_message.dart'; import '../models/user_Info.dart' as user_info; import 'assistant_directive_service.dart'; import 'bailian_multimodal_service.dart'; +import 'mcp_token_service.dart'; import '../utils/cue_tone.dart'; import 'bes_bluetooth_service.dart'; import 'emai_chat_store.dart'; @@ -220,6 +221,7 @@ class BesAiSessionService extends GetxService { downstreamRate: _earphoneRate, userId: _currentUserId(), source: 'earphone', + mcpToken: await McpTokenService.get(), ); final elapsed = DateTime.now().difference(ackAt); if (elapsed > _ackDeadline) { diff --git a/apps/client/lib/data/services/mcp_token_service.dart b/apps/client/lib/data/services/mcp_token_service.dart new file mode 100644 index 00000000..2b38d425 --- /dev/null +++ b/apps/client/lib/data/services/mcp_token_service.dart @@ -0,0 +1,61 @@ +import '../../core/utils/logger.dart'; +import 'network/api.dart'; + +/// EMAI 会话用的 MCP 令牌。 +/// +/// 百炼调我们的 MCP 工具(查会议纪要、查待办…)时,需要知道「这是哪个用户」。 +/// 整条链路上唯一能按用户变的通道是 Start 指令的 +/// `biz_params.user_defined_params` —— 客户端连百炼只带百炼的 API Key, +/// 百炼调 MCP 的 Authorization 头又是控制台里配死的、所有用户共用。 +/// +/// ⚠️ 这里拿的是服务端 `user_getmcptoken` 签发的**短时效、仅限 MCP 用途**的令牌, +/// 不是登录 JWT —— 这条路要经过百炼(第三方),登录 JWT 等价于账号全部权限。 +class McpTokenService { + const McpTokenService._(); + + static const String _tag = 'McpToken'; + + static String? _token; + static int _expiresAt = 0; + + /// 提前多久就当它过期。会话可能持续很久,卡着过期时刻去用必然中途失效。 + static const int _skewSeconds = 300; + + static bool get _fresh { + if (_token == null || _token!.isEmpty) return false; + final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; + return _expiresAt - _skewSeconds > now; + } + + /// 取令牌。缓存未过期就直接用,否则重新申请。 + /// + /// 拿不到时返回 null 而不是抛异常 —— MCP 工具用不了只是少几项能力, + /// 不该因此让整个 EMAI 会话建立不起来。 + static Future get() async { + if (_fresh) return _token; + try { + final resp = await Api.getMcpToken(); + // ⚠️ AuthInterceptor 在业务码 != 0 时是把 data 置 null 后 resolve(不抛), + // 所以这里必须判 null;只 catch 是接不住业务错误的。 + if (resp == null) { + Logger.w(_tag, '服务端未签发 MCP 令牌(多半是没配 McpTokenKey)'); + return null; + } + final token = (resp['token'] ?? '').toString(); + if (token.isEmpty) return null; + _token = token; + _expiresAt = (resp['expiresat'] as num?)?.toInt() ?? 0; + Logger.i(_tag, 'MCP 令牌已更新,过期时刻=$_expiresAt'); + return _token; + } catch (e) { + Logger.w(_tag, '申请 MCP 令牌失败(MCP 工具本次不可用): $e'); + return null; + } + } + + /// 换账号必须清掉,否则新账号会拿着上一个人的令牌去调 MCP。 + static void reset() { + _token = null; + _expiresAt = 0; + } +} 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 971a85c5..493156da 100644 --- a/apps/client/lib/data/services/meeting/meeting_task_service.dart +++ b/apps/client/lib/data/services/meeting/meeting_task_service.dart @@ -3,6 +3,7 @@ import 'dart:convert'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; +import '../../../core/utils/logger.dart'; import '../db/sqflite_api.dart'; import '../network/api.dart'; import '../../models/user_Info.dart'; @@ -160,11 +161,40 @@ class MeetingTaskService extends GetxService { void _executeTask() async { if (_isTask) return; _isTask = true; - while (_taskList.isNotEmpty) { - await Future.delayed(const Duration(seconds: 5)); - await _queryTask(); + try { + while (_taskList.isNotEmpty) { + await Future.delayed(const Duration(seconds: 5)); + try { + await _queryTask(); + } catch (e, st) { + // ⚠️ 单轮失败绝不能终止整个轮询。 + // + // 原来这里没有 try/catch:_queryTask 里任何一次抛异常都会把 while 掀掉, + // 而 `_isTask = false` 写在循环之后、根本执行不到 —— 于是 _isTask 永远 + // 停在 true,开头那句 `if (_isTask) return;` 会挡住之后所有 startTask(), + // **整个进程生命周期内轮询再也起不来,且一声不吭**。 + // 2026-09-06 真机就是这么中的:服务端 15 秒就跑完了,App 一直显示「转写中」。 + Logger.w('MeetingTask', '本轮任务查询失败,跳过继续: $e\n$st'); + } + } + } finally { + // 放 finally:无论正常退出还是异常穿出,标志位都必须复位 + _isTask = false; + } + } + + /// 把服务端的 translate 解析成 speaker 列表;数据没齐或格式不对返回 null, + /// 由调用方决定「本轮跳过、下轮再来」,而不是抛异常掀掉轮询。 + 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('MeetingTask', 'translate 解析失败,本轮跳过: $e'); + return null; } - _isTask = false; } Future removeTask(int id) async { @@ -189,6 +219,13 @@ class MeetingTaskService extends GetxService { var response = await Api.getMultitermRecord({ 'ids': ids, }); + // ⚠️ 业务错误时 AuthInterceptor 是把 data 置 null 后 **resolve**(不抛), + // 所以这里必须判空。直接 response['records'] 会在 null 上抛 + // NoSuchMethodError,配合上面那条就是轮询永久死亡。 + if (response == null) { + Logger.w('MeetingTask', '查询任务返回空(业务错误),本轮跳过'); + return; + } List queryList = response['records'] ?? []; for (var element in queryList) { int id = element['id']; @@ -202,9 +239,19 @@ class MeetingTaskService extends GetxService { if (oldTasktype != newTasktype) { if (oldTasktype < 3 && newTasktype >= 3) { // 转写完成 - List decodeList = jsonDecode(element['translate']); - List> speakerList = - decodeList.map((e) => Map.from(e)).toList(); + // + // ⚠️ translate 可能还没落库。服务端 PollTranscribe 是先把 state 推到 + // AwaitSummarizing 并保存、再 `go finishTranscribe(...)` 在后台写 + // translate 的,所以「状态说转写完了、数据还没到」这一拍是必然存在的 + // (服务端已加 hideHalfDoneTranscribe 挡在响应侧,这里是第二道保险, + // 老服务端 / 其它入口同样能兜住)。 + // 直接 jsonDecode 空串会抛 FormatException,把整个轮询掀掉。 + final speakerList = _decodeSpeakers(element['translate']); + if (speakerList == null) { + // 数据还没齐:**本轮不推进 tasktype**,下一轮拿到完整数据再处理。 + // 推进了的话 oldTasktype 就跨过 3 了,这段转写文本永远补不回来。 + continue; + } await SqfliteApi.deleteMeetingSpeaker(id); await SqfliteApi.insertMeetingSpeaker(speakerList); await SqfliteApi.editMeeting( @@ -217,7 +264,11 @@ class MeetingTaskService extends GetxService { tasktype.value = newTasktype; } } - if (oldTasktype < 5 && newTasktype >= 5) { + // ⚠️ 判据用「正好是完成/已阅」而不是 >=5:失败态 SummarizFail=10001、 + // TranscribeFail=10002 数值上也 >= 5,会被当成「总结完成」, + // 然后拿响应里的空 summary/overview 覆盖本地已有内容 —— 与这次 + // 丢总结是同一类数据损失。 + if (oldTasktype < 5 && (newTasktype == 5 || newTasktype == 6)) { // 总结完成 await SqfliteApi.editMeeting(id, { 'tasktype': newTasktype, diff --git a/apps/client/lib/data/services/network/api.dart b/apps/client/lib/data/services/network/api.dart index 419fdfe4..c4a43644 100644 --- a/apps/client/lib/data/services/network/api.dart +++ b/apps/client/lib/data/services/network/api.dart @@ -388,6 +388,15 @@ class Api { ); } + //申请 MCP 会话令牌(短时效,放进百炼会话的 biz_params 透传给 MCP 工具) + static getMcpToken() { + return DioManager().request( + NWMethod.post, + '/api/home/user_getmcptoken', + params: {}, + ); + } + //添加模版数据 static addEchomeetTemplates([params]) { return DioManager().request( diff --git a/apps/client/lib/data/utils/emai_audio_session.dart b/apps/client/lib/data/utils/emai_audio_session.dart new file mode 100644 index 00000000..a4684c9e --- /dev/null +++ b/apps/client/lib/data/utils/emai_audio_session.dart @@ -0,0 +1,67 @@ +import 'dart:io' show Platform; + +import 'package:audio_session/audio_session.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; + +import '../../core/utils/logger.dart'; + +/// EMAI 语音链路(麦克风上行 + TTS 下行)共用的一份 AVAudioSession 配置。 +/// +/// 为什么要抽出来:麦克风和播放器是两个模块,各自配一次会话就会互相覆盖。 +/// 连续对话下两者同时在跑,谁最后写谁生效,表现是「录着录着没声了」或 +/// 「播着播着麦死了」。统一到这里,两边写的是同一份配置,重复调用无害。 +/// +/// ⚠️ **开麦之前必须先调一次**。真机实测:会话停在 `soloAmbient` 且未激活时, +/// `record` 的 `startStream` 会**成功返回**、AVAudioEngine 也起得来,但输入 +/// tap 一个 buffer 都不回调 —— 上行一个字节都发不出去,服务端 30 秒后回 +/// `ClientAudioTimeout`,而客户端这边没有任何报错,只看到「麦克风采集启动」 +/// 那行日志,非常难查。 +class EmaiAudioSession { + const EmaiAudioSession._(); + + static const String _tag = 'EmaiAudioSession'; + + static bool _configured = false; + + static bool get _apply => !kIsWeb && (Platform.isIOS || Platform.isAndroid); + + /// 配置(只做一次)并激活会话。失败只记日志,不抛—— + /// 会话配不上时让调用方继续尝试,总比整条链路直接不启动好。 + static Future ensure() async { + if (!_apply) return; + try { + final session = await AudioSession.instance; + if (!_configured) { + _configured = true; + await session.configure(AudioSessionConfiguration( + // iOS 必须 playAndRecord:上行要录音、下行要外放,缺一不可。 + avAudioSessionCategory: AVAudioSessionCategory.playAndRecord, + avAudioSessionCategoryOptions: + AVAudioSessionCategoryOptions.defaultToSpeaker | + AVAudioSessionCategoryOptions.allowBluetooth, + // ⚠️ iOS 固定 voiceChat:record 开了 echoCancel,它会打开 + // voice-processing,系统随之把 mode 改成 voiceChat 并一直持有 + // (真机 dump 确认)。播放这边如果写回 default,就是两边互相改会话。 + // defaultToSpeaker 在 voiceChat 下仍然走扬声器,实测不会被拽到听筒。 + avAudioSessionMode: Platform.isIOS + ? AVAudioSessionMode.voiceChat + : AVAudioSessionMode.defaultMode, + // ⚠️ Android 的 usage 千万别用 voiceCommunication:华为 + // (EMUI/HarmonyOS) 对通话流卡得很严,不在 MODE_IN_COMMUNICATION 下建的 + // VOICE_COMMUNICATION 音轨会被直接静音,logcat 只留一行 + // `AudioTrack: set playback slient`,音轨建得出来、数据也在写,就是没声音。 + // ALN-AL80 上实测复现过,用 media 就没这问题。 + androidAudioAttributes: const AndroidAudioAttributes( + contentType: AndroidAudioContentType.speech, + usage: AndroidAudioUsage.media, + ), + // 助手说话时把背景音乐暂停,而不是压低——压低了在嘈杂环境还是听不清 + androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient, + )); + } + await session.setActive(true); + } catch (e) { + Logger.w(_tag, '音频会话配置失败(继续尝试): $e'); + } + } +} diff --git a/apps/client/lib/data/utils/mic_pcm_streamer.dart b/apps/client/lib/data/utils/mic_pcm_streamer.dart index e25a3ef0..2e2474bb 100644 --- a/apps/client/lib/data/utils/mic_pcm_streamer.dart +++ b/apps/client/lib/data/utils/mic_pcm_streamer.dart @@ -4,6 +4,7 @@ import 'dart:typed_data'; import 'package:record/record.dart'; import '../../core/utils/logger.dart'; +import 'emai_audio_session.dart'; import 'pcm_stream_player.dart'; /// 手机麦克风的 PCM 流采集,专供百炼上行使用。 @@ -40,9 +41,25 @@ class MicPcmStreamer { Future hasPermission() => _recorder.hasPermission(); - Future start() async { - if (_running) return true; + /// 正在进行中的 [start]。并发调用共用同一个 future。 + /// + /// ⚠️ 这个去重不能省。`_running` 是在 `await startStream()` **返回之后**才置位的, + /// 两个调用方几乎同时进来时都会看到 `_running == false`、都往下走,于是同一个 + /// `AudioRecorder` 被 startStream 两遍——第二次把第一次那条流顶掉,上行当场断供。 + /// 真机表现是「麦克风采集启动」打两行、上行只出几包就没了,服务端 30 秒后回 + /// `ClientAudioTimeout`,客户端不报任何错。 + Future? _startFuture; + + Future start() { + if (_running) return Future.value(true); + return _startFuture ??= _start().whenComplete(() => _startFuture = null); + } + + Future _start() async { try { + // ⚠️ 必须先把会话配好并激活再开麦:会话停在 soloAmbient 且未激活时, + // startStream 会成功返回、引擎也起得来,但输入 tap 一个 buffer 都不回调。 + await EmaiAudioSession.ensure(); if (!await _recorder.hasPermission()) { Logger.w(_tag, '没有麦克风权限'); return false; @@ -56,10 +73,24 @@ class MicPcmStreamer { // duplex 模式下会直接把服务端 VAD 打乱 echoCancel: true, noiseSuppress: true, + // ⚠️ iOS 上不让 record 碰 AVAudioSession(会话由 EmaiAudioSession 统一配)。 + // 它自己那套是 `setCategory(.playAndRecord, options:)` → `setActive(true)`, + // 而 setCategory 会把 mode 重置成 default;紧接着 echoCancel 触发的 + // `setVoiceProcessingEnabled(true)` 又把 mode 拽回 voiceChat。这一来一回 + // 是一次 AVAudioEngine 配置变更,而 record 没有监听 + // `AVAudioEngineConfigurationChange` —— 引擎「还在运行」,输入 tap 却 + // 从此不再回调。真机表现:上行送了几十包(两三秒)后彻底停住, + // 没有任何报错,服务端随后回 ClientAudioTimeout。 + // 我们先把会话配成 playAndRecord + voiceChat 并激活,voice-processing + // 打开时 mode 已经就位,就不会再触发那次变更。 + // (Android 侧忽略 iosConfig,这里不影响。) + // ignore: deprecated_member_use + iosConfig: const IosRecordConfig(manageAudioSession: false), ), ); _running = true; _buffer.clear(); + _startWatchdog(); _sub = stream.listen( _onChunk, onError: (e) => Logger.e(_tag, '录音流错误: $e'), @@ -73,8 +104,35 @@ class MicPcmStreamer { } } + /// 最近一次收到原始音频块的时刻。用来发现「流无声死亡」—— + /// 麦克风即使没人说话也会持续出包,所以静默超过 [_silenceLimit] 一定是流断了。 + DateTime? _lastChunkAt; + Timer? _watchdog; + + static const Duration _silenceLimit = Duration(seconds: 3); + + void _startWatchdog() { + _watchdog?.cancel(); + _lastChunkAt = DateTime.now(); + _watchdog = Timer.periodic(const Duration(seconds: 1), (_) { + if (!_running) return; + final last = _lastChunkAt; + if (last == null) return; + if (DateTime.now().difference(last) < _silenceLimit) return; + Logger.e(_tag, '录音流已静默 ${_silenceLimit.inSeconds}s,判定断流,尝试重启'); + _lastChunkAt = DateTime.now(); + unawaited(_restart()); + }); + } + + Future _restart() async { + await stop(); + await start(); + } + void _onChunk(Uint8List chunk) { if (chunk.isEmpty) return; + _lastChunkAt = DateTime.now(); if (!_levelCtl.isClosed) _levelCtl.add(PcmStreamPlayer.rmsLevel(chunk)); _buffer.add(chunk); @@ -101,6 +159,8 @@ class MicPcmStreamer { Future stop() async { if (!_running) return; _running = false; + _watchdog?.cancel(); + _watchdog = null; await _sub?.cancel(); _sub = null; try { diff --git a/apps/client/lib/data/utils/pcm_stream_player.dart b/apps/client/lib/data/utils/pcm_stream_player.dart index adc30df2..3ec4aeea 100644 --- a/apps/client/lib/data/utils/pcm_stream_player.dart +++ b/apps/client/lib/data/utils/pcm_stream_player.dart @@ -1,11 +1,14 @@ import 'dart:async'; +import 'dart:io' show Platform; import 'dart:math' as math; import 'dart:typed_data'; -import 'package:audio_session/audio_session.dart'; +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/services.dart'; import 'package:just_audio/just_audio.dart'; import '../../core/utils/logger.dart'; +import 'emai_audio_session.dart'; /// 流式 PCM 播放器:一边收字节一边出声,用于百炼下行的 TTS 音频。 /// @@ -35,11 +38,17 @@ class PcmStreamPlayer { final int sampleRate; final int channels; + /// iOS 专用的原生 PCM 播放通道,见 [_iosNative] 的说明 + static const MethodChannel _iosChannel = + MethodChannel('azure_speech/pcm_player'); + + /// iOS 上不能用 just_audio 播这条流(原因见 [_iosNative]) + bool get _iosNative => !kIsWeb && Platform.isIOS; + final AudioPlayer _player = AudioPlayer(); _LivePcmSource? _source; StreamSubscription? _stateSub; StreamSubscription? _errSub; - bool _sessionConfigured = false; /// 当前这轮是否已经建好 source bool _active = false; @@ -51,7 +60,7 @@ class PcmStreamPlayer { /// begin 期间到达的音频,等 source 建好再补进去,避免开头被吞 final _pending = BytesBuilder(copy: false); - bool get isPlaying => _player.playing; + bool get isPlaying => _iosNative ? _active : _player.playing; final _levelCtl = StreamController.broadcast(); @@ -76,52 +85,40 @@ class PcmStreamPlayer { /// /// 显式 setActive(true) 是把会话按本方的配置重新激活一次, /// 相当于「录音刚放开,播放把它拿回来」。 - Future _ensureSession() async { - try { - final session = await AudioSession.instance; - if (_sessionConfigured) { - await session.setActive(true); - return; - } - _sessionConfigured = true; - // ⚠️ usage 千万别用 voiceCommunication。 - // 华为(EMUI/HarmonyOS)的音频策略对通话流卡得很严:不在 - // MODE_IN_COMMUNICATION 下建的 VOICE_COMMUNICATION 音轨会被直接静音, - // logcat 里表现为 `AudioTrack: set playback slient` —— 音轨建得出来、 - // 数据也在写,就是没声音,非常难查。ALN-AL80 上实测复现。 - // - // 用 media 就没这问题。手机这条路径上录音和播放本来也不并发: - // 按住说话时 startTalk() 会先把播放停掉,所以不需要占通话流来共存。 - // (耳机那条路径的下行走 SPP/G.722,压根不经过 AudioTrack。) - await session.configure(const AudioSessionConfiguration( - // iOS 仍要 playAndRecord:按住说话要录音,纯 playback 会把录音会话踢掉。 - // 但 mode 从 voiceChat 换成 default —— voiceChat 会上通话处理链, - // 在部分机型上把输出拽到听筒。 - avAudioSessionCategory: AVAudioSessionCategory.playAndRecord, - avAudioSessionCategoryOptions: - AVAudioSessionCategoryOptions.defaultToSpeaker, - avAudioSessionMode: AVAudioSessionMode.defaultMode, - androidAudioAttributes: AndroidAudioAttributes( - contentType: AndroidAudioContentType.speech, - usage: AndroidAudioUsage.media, - ), - // 助手说话时把背景音乐暂停掉,而不是压低——压低了在嘈杂环境里还是听不清 - androidAudioFocusGainType: AndroidAudioFocusGainType.gainTransient, - )); - await session.setActive(true); - } catch (e) { - Logger.w(_tag, '音频会话配置失败(继续尝试播放): $e'); - } - } + Future _ensureSession() => EmaiAudioSession.ensure(); - /// 开一轮新的播放。并发调用只有第一次生效。 - Future begin() async { - if (_starting) return; + /// 正在进行中的 [begin]。[end] 要等它落地才能判断这一轮有没有音频—— + /// 回复很短时(音频首包和 RespondingEnded 几乎同时到)`_active` 还是 false, + /// 不等就会把整轮回复当成「没有音频」直接报完成,声音全丢。 + Future? _startFuture; + + /// 开一轮新的播放。并发调用共用同一个 future。 + Future begin() { + if (_starting) return _startFuture ?? Future.value(); _starting = true; + return _startFuture = _begin(); + } + + Future _begin() async { try { if (_active) await _teardown(); await _ensureSession(); + if (_iosNative) { + _iosChannel.setMethodCallHandler(_onIosCall); + final ok = await _iosChannel + .invokeMethod('start', {'sampleRate': sampleRate}); + if (ok != true) { + Logger.e(_tag, 'iOS 原生播放器启动失败'); + return; + } + _active = true; + if (_pending.length > 0) { + await _iosChannel.invokeMethod('feed', {'data': _pending.takeBytes()}); + } + return; + } + final src = _LivePcmSource(_wavHeader()); _source = src; _active = true; @@ -147,10 +144,6 @@ class PcmStreamPlayer { Logger.e(_tag, '启动播放失败: $e'); _active = false; _pending.clear(); - // 起播失败多半是会话被别人(录音的 voice-processing)占着。 - // 复位标志,下一轮重新走完整的 configure + setActive, - // 而不是只补一次 setActive —— 只激活救不回被改过 category/mode 的会话。 - _sessionConfigured = false; } finally { _starting = false; } @@ -161,6 +154,16 @@ class PcmStreamPlayer { if (pcm.isEmpty) return; _emitLevel(pcm); + if (_iosNative) { + if (_active) { + _iosChannel.invokeMethod('feed', {'data': pcm}); + } else { + _pending.add(pcm); + if (!_starting) unawaited(begin()); + } + return; + } + final src = _source; if (_active && src != null) { src.add(pcm); @@ -174,6 +177,21 @@ class PcmStreamPlayer { /// 本轮下行结束:关掉输入流,剩余缓冲播完后会触发 [onCompleted]。 /// 这一轮一个音频字节都没有时直接报完成——不然调用方会一直等一个不会来的回调。 Future end() async { + if (_iosNative) { + // 起播还在路上就先等它:不等的话短回复会被判成「没有音频」 + if (_starting) { + try { + await _startFuture; + } catch (_) {} + } + if (!_active) { + // 这一轮一个字节都没来过:原生侧没东西可播,直接报完成 + _emitCompleted(); + return; + } + await _iosChannel.invokeMethod('end'); + return; + } final src = _source; if (src == null || !src.hasData) { await _teardown(); @@ -183,6 +201,12 @@ class PcmStreamPlayer { await src.close(); } + /// 原生播放器回调:本轮排队的 PCM 全部播完 + Future _onIosCall(MethodCall call) async { + if (call.method == 'onCompleted') _finish(); + return null; + } + /// 打断:立刻断流停声,不报完成 Future stopNow() async { await _teardown(); @@ -193,12 +217,24 @@ class PcmStreamPlayer { await _teardown(); await _levelCtl.close(); await _completedCtl.close(); + if (_iosNative) { + try { + await _iosChannel.invokeMethod('dispose'); + } catch (_) {} + return; + } await _player.dispose(); } Future _teardown() async { _active = false; _pending.clear(); + if (_iosNative) { + try { + await _iosChannel.invokeMethod('stop'); + } catch (_) {} + return; + } await _stateSub?.cancel(); _stateSub = null; await _errSub?.cancel(); diff --git a/apps/client/lib/modules/emai/controllers/emai_controller.dart b/apps/client/lib/modules/emai/controllers/emai_controller.dart index 53c5c42f..1d1528cb 100644 --- a/apps/client/lib/modules/emai/controllers/emai_controller.dart +++ b/apps/client/lib/modules/emai/controllers/emai_controller.dart @@ -13,6 +13,7 @@ import '../../../data/models/emai_message.dart'; import '../../../data/services/bes_ai_session_service.dart'; import '../../../data/services/emai_chat_store.dart'; import '../../../data/utils/mic_pcm_streamer.dart'; +import '../../../data/services/mcp_token_service.dart'; import '../../../data/utils/pcm_stream_player.dart'; /// 会话链路状态(跟 [BailianDialogState] 是两回事:这个说的是"连上了没", @@ -67,6 +68,28 @@ class EmaiController extends GetxController /// 音浪振幅(0~1):说话时取麦克风,回答时取下行音频 final level = 0.0.obs; + /// 连续(长期)对话开关。 + /// + /// 关:`push2talk` —— 按住说话,客户端用 beginSpeech/endSpeech 自己划段。 + /// 开:`duplex` —— 麦克风一直开着,切句交给服务端 VAD,用户可以一直说下去。 + /// + /// ⚠️ 切换必须重连会话:模式是 `start()` 时定死的会话级参数,中途改不了。 + /// + /// ⚠️ duplex 在手机上能成立的前提是 AEC —— 外放的播报会被自己的麦收回去, + /// 服务端 VAD 会当成用户插话,每次 AI 一开口就被自己打断。 + /// [MicPcmStreamer] 已经开了 echoCancel/noiseSuppress,所以不需要像耳机那条 + /// 链路那样做「播报期间门控上行」的半双工妥协(见 BesAiSessionService 的注释: + /// 耳机没把下行接进 AEC 参考路径,只能门控)。 + /// + /// **默认关**:进页面只建立会话,不开麦。连续对话必须由用户点一下中间按钮 + /// 显式开始,再点一下结束,可以反复开关;退出页面时 [onClose] 一并收掉。 + /// + /// ⚠️ 别再改回默认开。进页面即开麦意味着用户还没表达意图,麦克风就已经在 + /// 录音并往服务端推流了——既是隐私问题,也会让「不小心点进来」变成一次 + /// 计费的对话。耳机唤醒那条链路不走这里,它有自己的会话生命周期 + /// (见 BesAiSessionService)。 + final isContinuous = false.obs; + /// 文字输入模式 / 语音输入模式 final isTextMode = false.obs; final hasInputText = false.obs; @@ -98,6 +121,15 @@ class EmaiController extends GetxController Timer? _handshakeTimer; static const Duration _handshakeTimeout = Duration(seconds: 15); + /// 连续对话的空闲上限:这么久没有任何对话往来就自动结束,别让麦克风一直开着。 + static const Duration _continuousIdleTimeout = Duration(seconds: 10); + + /// 空闲计时器。只在连续对话期间跑。 + /// + /// ⚠️ 计时的依据必须是**对话事件**,不能是上行音频包——连续模式下麦克风 + /// 即使没人说话也在稳定出包,拿它续命等于这个超时永远不会触发。 + Timer? _idleTimer; + /// 连上之后被动断线时自动重连一次;再断就交给用户手动点 bool _autoRetried = false; @@ -139,8 +171,10 @@ class EmaiController extends GetxController _subs.add(_svc.events.listen(_onEvent)); _subs.add(_svc.audioOut.listen(_player.feed)); _subs.add(_mic.packets.listen(_svc.pushAudio)); + // ⚠️ 音浪的门控不能只看 isTalking:连续对话下它恒为 false, + // 麦克风明明开着,界面上却一点动静都没有,用户完全看不出在不在收音。 _subs.add(_mic.level.listen((v) { - if (isTalking.value) level.value = v; + if (isTalking.value || isContinuous.value) level.value = v; })); _subs.add(_player.level.listen((v) { if (!isTalking.value) level.value = v; @@ -162,6 +196,7 @@ class EmaiController extends GetxController BesAiSessionService.to.detachPhonePage(this); } _handshakeTimer?.cancel(); + _cancelIdle(); for (final s in _subs) { s.cancel(); } @@ -182,6 +217,25 @@ class EmaiController extends GetxController if (state == AppLifecycleState.paused || state == AppLifecycleState.inactive) { if (isTalking.value) _abortTalk(); + // 连续模式下麦克风常开,切后台必须松开:iOS 后台持麦既费电,也可能被系统 + // 直接掐掉会话。 + // + // ⚠️ 这里只停麦、**不动 isContinuous**,也不重连。连续是手机端的默认模式, + // 切一次后台就把它降级成「按住说话」的话,用户回来会发现说话没反应, + // 还得自己再点一下——而且重连要一秒多,纯属白费。 + if (_micRunning) { + _micRunning = false; + _mic.stop(); + level.value = 0; + } + // 后台不计空闲:回来时再重新开始数,否则挂后台十秒就被判定结束了 + _cancelIdle(); + } else if (state == AppLifecycleState.resumed) { + // 回前台把麦接回来。会话还在(duplex 会话本身没断),只是麦停了。 + if (isContinuous.value) { + unawaited(_ensureContinuousMic()); + _touchIdle(); + } } } @@ -204,9 +258,13 @@ class EmaiController extends GetxController } linkState.value = EmaiLinkState.connecting; + // MCP 令牌要在 Start 之前拿到:biz_params 是握手时定死的, + // 会话建起来之后再补就没地方放了。 + final mcpToken = await McpTokenService.get(); final ok = await _svc.start( - mode: 'push2talk', + mode: isContinuous.value ? 'duplex' : 'push2talk', userId: _currentUserId(), + mcpToken: mcpToken, ); if (!ok) { linkState.value = EmaiLinkState.failed; @@ -285,8 +343,136 @@ class EmaiController extends GetxController // ---------------- 语音交互(push2talk) ---------------- + /// 切换连续对话。 + /// + /// 走的是「停会话 → 换模式重连 → 开麦」这条路,因为模式是会话级参数。 + /// 重连期间界面会短暂回到「正在连接」,这是预期的。 + Future toggleContinuous() async { + if (isContinuous.value) { + await _exitContinuous(); + } else { + await _enterContinuous(); + } + } + + Future _enterContinuous() async { + if (!canTalk) return; + // 手上还有一段按住说话没结束的,按取消收掉——它属于旧模式 + await _abortTalk(); + isContinuous.value = true; + _buzz(HapticsType.light); + await reconnect(); + if (!isContinuous.value) return; // 重连期间用户又关掉了 + // ⚠️ 必须等 Started 事件,不能在 reconnect() 一返回就判断状态。 + // reconnect() 只负责把 Start 指令发出去,`ready` 要等服务端回 Started 才置位, + // 那是一次网络往返。原来这里直接判 `!= ready`,几乎每次都命中,于是刚打开的 + // 连续对话被当场关掉——按钮闪一下就退回「点击开始连续对话」,而 Started 稍后 + // 到达时又会把麦克风开起来,留下一个界面上看不见的常开麦克风。 + if (!await _waitLinkReady(_handshakeTimeout)) { + // 没连上就别开麦,否则麦克风白开着还没人收 + isContinuous.value = false; + // Started 可能已经抢先把麦开起来了,一并收掉,别留孤儿 + if (_micRunning) { + _micRunning = false; + await _mic.stop(); + level.value = 0; + } + return; + } + await _ensureContinuousMic(); + _touchIdle(); + } + + /// 有对话往来就把空闲计时重新拨到头 + void _touchIdle() { + _idleTimer?.cancel(); + if (!isContinuous.value) return; + _idleTimer = Timer(_continuousIdleTimeout, _onIdleTimeout); + } + + void _cancelIdle() { + _idleTimer?.cancel(); + _idleTimer = null; + } + + void _onIdleTimeout() { + if (!isContinuous.value) return; + // 助手还在想/还在说(或者本地还在播它的音频)不算空闲—— + // 一段长回复中间可能十几秒没有任何新事件,这时结束会把话掐断。 + if (isResponding || _player.isPlaying) { + _touchIdle(); + return; + } + Logger.i(_tag, '连续对话空闲 ${_continuousIdleTimeout.inSeconds}s,自动结束'); + unawaited(_exitContinuous()); + } + + /// 等握手落地。ready 返回 true;失败/超时返回 false。 + Future _waitLinkReady(Duration timeout) async { + if (linkState.value == EmaiLinkState.ready) return true; + final done = Completer(); + final worker = ever(linkState, (s) { + if (done.isCompleted) return; + if (s == EmaiLinkState.ready) { + done.complete(true); + } else if (s == EmaiLinkState.failed || + s == EmaiLinkState.unconfigured || + s == EmaiLinkState.onEarphone) { + done.complete(false); + } + }); + try { + return await done.future.timeout(timeout, onTimeout: () => false); + } finally { + worker.dispose(); + } + } + + /// 连续模式下把麦克风拉起来(幂等,重复调用无副作用)。 + /// + /// ⚠️ duplex 下**不发** beginSpeech/endSpeech:切句由服务端 VAD 决定, + /// 客户端再去划段会和它打架(见 BailianMultimodalService.start 的说明)。 + Future _ensureContinuousMic() async { + if (!isContinuous.value) return; + if (linkState.value != EmaiLinkState.ready) return; + if (_micRunning) return; + if (!await _mic.hasPermission()) { + isContinuous.value = false; + _pushError('emaiMicPermission'.tr); + return; + } + if (!isContinuous.value) return; // 等权限期间用户关掉了 + // ⚠️ 必须在 await 之前同步置位。`Started` 事件和 `_enterContinuous` 会几乎 + // 同时调进来,都在这里 await 的话两边都会看到 false、都去开麦, + // 同一个 recorder 被 startStream 两遍,第二次顶掉第一次那条流。 + // (MicPcmStreamer 内部也做了一层去重,两层都留着——这里少一次无谓的往返。) + _micRunning = true; + if (!await _mic.start()) { + _micRunning = false; + isContinuous.value = false; + _pushError('emaiMicPermission'.tr); + } + } + + /// 连续模式下麦克风是否已经开着。[MicPcmStreamer] 自己不暴露状态, + /// 重复 start 会重开一路采集,必须在这里记账。 + bool _micRunning = false; + + + Future _exitContinuous() async { + isContinuous.value = false; + _cancelIdle(); + _buzz(HapticsType.light); + await _mic.stop(); + _micRunning = false; + level.value = 0; + await reconnect(); + } + Future startTalk() async { if (!canTalk || isTalking.value) return; + // 连续模式下麦克风本来就开着,再走按住说话那套会重复开麦 + if (isContinuous.value) return; // isTalking 必须在任何 await 之前同步置位。 // 否则快按快放时 endTalk() 会看到 false 直接 return,而这边 await 回来后 @@ -294,6 +480,7 @@ class EmaiController extends GetxController final gen = ++_talkGen; isTalking.value = true; isCancelPending.value = false; + _talkPressAt = DateTime.now(); _buzz(HapticsType.light); // 助手还在说话时按下 = 打断:先停本地播放,再让服务端交出发言权 @@ -340,11 +527,29 @@ class EmaiController extends GetxController _buzz(HapticsType.warning); } - Future endTalk() { - if (!isTalking.value) return Future.value(); + /// 按下到松开短于这个时长,算「点一下」而不是「按住说话」。 + /// + /// 250ms 是取舍出来的:再短容易把用户想按住说话时手抖的一下误判成点击, + /// 再长会让真正的点击有明显延迟。反正短于 250ms 的语音本来也没有内容。 + static const Duration _tapThreshold = Duration(milliseconds: 250); + DateTime? _talkPressAt; + + Future endTalk() async { + if (!isTalking.value) return; + final pressed = _talkPressAt; + final isTap = !isCancelPending.value && + pressed != null && + DateTime.now().difference(pressed) < _tapThreshold; + if (isTap) { + // 点一下 = 切换连续对话。刚才 onPointerDown 已经开了一小段语音, + // 按取消收掉——这半秒不到的音频没有任何内容,发上去只会多一轮空回复。 + await _finishTalk(cancel: true); + await toggleContinuous(); + return; + } final cancelled = isCancelPending.value; _buzz(cancelled ? HapticsType.warning : HapticsType.light); - return _finishTalk(cancel: cancelled); + await _finishTalk(cancel: cancelled); } /// 异常路径下的收手(切后台、断线、页面销毁):一律当取消处理 @@ -363,6 +568,7 @@ class EmaiController extends GetxController // 先停麦克风,它会把不足一包的尾音补发出去,再发收尾指令, // 保证最后半个字也进了服务端 await _mic.stop(); + _micRunning = false; if (_speechOpen) { _speechOpen = false; @@ -424,6 +630,10 @@ class EmaiController extends GetxController // ---------------- 事件处理 ---------------- void _onEvent(BailianEvent e) { + // 任何一条对话事件都算「还在聊」,把空闲计时拨回去。 + // 到这里的事件都是真实往来(SpeechXxx / RespondingXxx / 状态变化), + // 心跳在协议层就被吞掉了,不会误续命。 + _touchIdle(); switch (e.type) { case BailianEventType.started: _handshakeTimer?.cancel(); @@ -432,6 +642,9 @@ class EmaiController extends GetxController // 新会话,上一条连接里没收尾的语音段跟着作废 _speechOpen = false; linkState.value = EmaiLinkState.ready; + // duplex 会话就绪 = 该开麦了。服务端此刻开始做 VAD,麦克风不开的话 + // 它永远收不到音频,界面上就是「已就绪但说什么都没反应」。 + if (isContinuous.value) unawaited(_ensureContinuousMic()); break; case BailianEventType.speechStarted: diff --git a/apps/client/lib/modules/emai/views/emai_view.dart b/apps/client/lib/modules/emai/views/emai_view.dart index e9b5c879..9eb74920 100644 --- a/apps/client/lib/modules/emai/views/emai_view.dart +++ b/apps/client/lib/modules/emai/views/emai_view.dart @@ -379,9 +379,47 @@ class EmaiView extends GetView { ); } - /// 按住说话。手指上滑离开按钮区域 = 取消这段语音。 + /// 语音输入按钮。两种用法共用这一个控件: + /// + /// - **点一下** → 进入/退出「连续对话」(服务端 VAD 切句,麦克风一直开着) + /// - **按住不放** → 单句模式(客户端划段),上滑取消 + /// + /// 点击与按住的区分放在 controller 的 [EmaiController.endTalk] 里做 + /// (按下到松开短于 250ms 算点击)。放在那边是因为按下的瞬间就已经开麦并 + /// 开了一段语音,判成点击时要把那一段取消掉——这是 controller 的事。 Widget _buildTalkButton(bool isDark) { final enabled = controller.canTalk; + final continuous = controller.isContinuous.value; + + // 连续对话中:整条就是一个「点击结束」的开关,不再有按住/上滑那套语义 + if (continuous) { + return GestureDetector( + onTap: controller.toggleContinuous, + child: Container( + height: 40.h, + alignment: Alignment.center, + decoration: BoxDecoration( + color: AppPalette.accentDark, + borderRadius: BorderRadius.circular(20.r), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + EmaiLevelBar(color: Colors.white, level: controller.level.value), + SizedBox(width: 10.w), + Text( + 'emaiContinuousOn'.tr, + style: TextStyle( + fontSize: 14.sp, + fontWeight: FontWeight.w500, + color: Colors.white), + ), + ], + ), + ), + ); + } + final talking = controller.isTalking.value; final cancelling = controller.isCancelPending.value; @@ -399,7 +437,9 @@ class EmaiView extends GetView { ? 'emaiReleaseToCancel'.tr : talking ? 'emaiReleaseToSend'.tr - : 'emaiHoldToTalk'.tr; + // 空闲态展示「点击开始连续对话」而不是「按住说话」: + // 点击是这个按钮现在的主用法,按住说话仍然可用但不再是主推的 + : 'emaiTapForContinuous'.tr; return Listener( onPointerDown: enabled ? (_) => controller.startTalk() : null, diff --git a/apps/client/lib/modules/main_tab/views/widgets/memory_edit_sheet.dart b/apps/client/lib/modules/main_tab/views/widgets/memory_edit_sheet.dart index 16152aac..45717685 100644 --- a/apps/client/lib/modules/main_tab/views/widgets/memory_edit_sheet.dart +++ b/apps/client/lib/modules/main_tab/views/widgets/memory_edit_sheet.dart @@ -122,67 +122,87 @@ class _MemoryEditSheetState extends State { color: bg, borderRadius: BorderRadius.vertical(top: Radius.circular(20.r)), ), + // ⚠️ 必须给高度上限。不设的话内容 + 键盘会超过屏幕,弹窗被路由裁掉, + // 表现就是「上面看不见、下面点不到」。 + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.85, + ), padding: EdgeInsets.fromLTRB(20.w, 12.h, 20.w, 20.h), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Center( - child: Container( - width: 36.w, - height: 4.h, - decoration: BoxDecoration( - color: isDark ? Colors.white24 : Colors.black12, - borderRadius: BorderRadius.circular(2.r), + // ⚠️ 操作栏必须在滚动区**外面**。 + // + // 原来整块(字段 + 确认/取消)都塞在一个 SingleChildScrollView 里, + // 而「备注」正好是最后一个字段:点它弹出键盘后,按钮被顶到可视区之外, + // 而滚动区已经到底、再怎么滑也够不着 —— 用户改完找不到确认按钮, + // 关掉弹窗修改就丢了(服务端一次 memory_update 都没收到过)。 + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Flexible( + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 36.w, + height: 4.h, + decoration: BoxDecoration( + color: isDark ? Colors.white24 : Colors.black12, + borderRadius: BorderRadius.circular(2.r), + ), + ), ), - ), - ), - SizedBox(height: 16.h), - Text( - (_isEdit ? 'memEditTitle' : 'memAddTitle').tr, - style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700), - ), - SizedBox(height: 16.h), - _categoryPicker(isDark), - SizedBox(height: 16.h), - _field(_title, 'memFieldTitle'.tr, isDark, autofocus: !_isEdit), - SizedBox(height: 12.h), - _dateTimeRow(isDark), - if (_showAmount) ...[ - SizedBox(height: 12.h), - _field( - _amount, - 'memFieldAmount'.tr, - isDark, - keyboardType: - const TextInputType.numberWithOptions(decimal: true), - inputFormatters: [ - // 只允许数字与至多两位小数:金额存的是「分」, - // 多余位数换算时会被静默截断,不如在输入处就挡住 - FilteringTextInputFormatter.allow( - RegExp(r'^\d*\.?\d{0,2}')), + SizedBox(height: 16.h), + Text( + (_isEdit ? 'memEditTitle' : 'memAddTitle').tr, + style: TextStyle(fontSize: 17.sp, fontWeight: FontWeight.w700), + ), + SizedBox(height: 16.h), + _categoryPicker(isDark), + SizedBox(height: 16.h), + _field(_title, 'memFieldTitle'.tr, isDark, autofocus: !_isEdit), + SizedBox(height: 12.h), + _dateTimeRow(isDark), + if (_showAmount) ...[ + SizedBox(height: 12.h), + _field( + _amount, + 'memFieldAmount'.tr, + isDark, + keyboardType: + const TextInputType.numberWithOptions(decimal: true), + inputFormatters: [ + // 只允许数字与至多两位小数:金额存的是「分」, + // 多余位数换算时会被静默截断,不如在输入处就挡住 + FilteringTextInputFormatter.allow( + RegExp(r'^\d*\.?\d{0,2}')), + ], + ), + ], + if (_showOwner) ...[ + SizedBox(height: 12.h), + _field(_owner, 'memFieldOwner'.tr, isDark), + ], + if (_showRepeat) ...[ + SizedBox(height: 12.h), + _repeatPicker(isDark), + ], + if (_showRemind) ...[ + SizedBox(height: 12.h), + _remindPicker(isDark), + ], + SizedBox(height: 12.h), + _field(_detail, 'memFieldDetail'.tr, isDark, maxLines: 3), ], ), - ], - if (_showOwner) ...[ - SizedBox(height: 12.h), - _field(_owner, 'memFieldOwner'.tr, isDark), - ], - if (_showRepeat) ...[ - SizedBox(height: 12.h), - _repeatPicker(isDark), - ], - if (_showRemind) ...[ - SizedBox(height: 12.h), - _remindPicker(isDark), - ], - SizedBox(height: 12.h), - _field(_detail, 'memFieldDetail'.tr, isDark, maxLines: 3), - SizedBox(height: 20.h), - _actions(isDark), - ], - ), + ), + ), + SizedBox(height: 12.h), + // 常驻底部,键盘弹起时也一直可见 + _actions(isDark), + ], ), ), ); 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 352afc71..e8c5a931 100644 --- a/apps/client/lib/modules/meeting/views/tabs/summary_tab.dart +++ b/apps/client/lib/modules/meeting/views/tabs/summary_tab.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'package:common_utils/common_utils.dart'; import 'package:fleather/fleather.dart'; import 'package:flutter/material.dart'; @@ -66,11 +67,25 @@ class _SummaryTabState extends State { : '[${'inputAttendees'.tr}]'); // 对应中文:输入与会人员 final markdown = const ParchmentMarkdownCodec() .decode(_controller.meetingDetails.value.summary); + final fc = FleatherController(document: markdown); + // 只有用户真的动过编辑器才允许回写。理由见 _saveSummary 的注释。 + _summaryDirty = false; + _summaryChangeSub?.cancel(); + _summaryChangeSub = fc.document.changes.listen((_) => _summaryDirty = true); setState(() { - _fleatherController = FleatherController(document: markdown); + _fleatherController = fc; }); } + /// 用户是否真的编辑过纪要正文。 + /// + /// ⚠️ 不能只靠「编码结果和原文不一样」来判断:**空文档编码出来是 `"\n\n"`**, + /// 与空字符串永远不相等,于是只要进过一次详情页、退出时就会把服务端的纪要 + /// 覆盖成 `"\n\n"`。2026-09-06 真机丢过一次总结就是这么来的: + /// 客户端轮询死了没拿到总结 → 详情页里是空的 → 用户退出 → dispose 回写空值。 + bool _summaryDirty = false; + StreamSubscription? _summaryChangeSub; + void _handleAddressChanged() { String currentText = _addressController.text; TextSelection currentSelection = _addressController.selection; @@ -132,26 +147,38 @@ class _SummaryTabState extends State { } void _saveSummary() async { + if (_fleatherController == null) return; final markdown = const ParchmentMarkdownCodec().encode(_fleatherController!.document); - if (markdown != _controller.meetingDetails.value.summary) { - Api.modifyRecordEchomeet({ - 'id': _controller.id, - 'summary': markdown, - }); - SqfliteApi.editMeeting( - _controller.id, - {'summary': markdown}, - ); - _controller.meetingDetails.value.summary = markdown; - _controller.summaryNumberModifications.value++; - } + final current = _controller.meetingDetails.value.summary; + // 三道闸,缺一不可: + // 1. 用户没动过编辑器就绝不回写——dispose 是无条件调用的,页面只是被看了 + // 一眼也会走到这里; + // 2. 两边都是空白(含只有换行)时视为没变化——空文档编码是 "\n\n", + // 直接和 "" 比永远不等; + // 3. 比较前 trim,避免尾部空行造成的假变化。 + if (!_summaryDirty) return; + if (markdown.trim().isEmpty && current.trim().isEmpty) return; + if (markdown.trim() == current.trim()) return; + + Api.modifyRecordEchomeet({ + 'id': _controller.id, + 'summary': markdown, + }); + SqfliteApi.editMeeting( + _controller.id, + {'summary': markdown}, + ); + _controller.meetingDetails.value.summary = markdown; + _controller.summaryNumberModifications.value++; + _summaryDirty = false; } @override void dispose() { _personnelListener.dispose(); _saveSummary(); + _summaryChangeSub?.cancel(); _fleatherController?.dispose(); _focusNode.dispose(); _addressController.dispose(); 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 eb581dea..33b1b95e 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 @@ -227,6 +227,10 @@ import ble_service // 音频数据相关 private var audioDataEventChannel: FlutterEventChannel? + + /// EMAI 助手下行 PCM 的外放播放器(iOS 专用,见 PcmSpeakerPlayer 的说明) + private let pcmSpeakerPlayer = PcmSpeakerPlayer() + private var pcmPlayerChannel: FlutterMethodChannel? internal var audioDataEventSink: FlutterEventSink? // 通话翻译音频统计(日志用) @@ -365,12 +369,51 @@ private func sendEvent(_ event: [String: Any], type: EventType) { astTtsEventChannel.setStreamHandler(BaseEventStreamHandler(instance: instance, eventType: .astTts)) instance.astTtsEventChannel = astTtsEventChannel + // EMAI 下行 PCM 播放(iOS 专用):just_audio 在 iOS 放不了直播 PCM 流, + // 详见 PcmSpeakerPlayer。只有方法通道,完成回调用 invokeMethod 回传。 + let pcmPlayerChannel = FlutterMethodChannel( + name: "azure_speech/pcm_player", binaryMessenger: registrar.messenger()) + pcmPlayerChannel.setMethodCallHandler { [weak instance] call, result in + instance?.handlePcmPlayerMethodCall(call, result: result) + } + instance.pcmPlayerChannel = pcmPlayerChannel + instance.pcmSpeakerPlayer.onCompleted = { [weak instance] in + instance?.pcmPlayerChannel?.invokeMethod("onCompleted", arguments: nil) + } + // 设置讯飞原生日志回调,通过 EventChannel 回传 Dart instance.azureAsrHelper.nativeLogCallback = { [weak instance] level, message in instance?.sendAsrEvent(["type": "nativeLog", "level": level, "message": message]) } } +// EMAI 下行 PCM 播放的方法分发 +internal func handlePcmPlayerMethodCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "start": + let args = call.arguments as? [String: Any] + let rate = (args?["sampleRate"] as? NSNumber)?.doubleValue ?? 24000 + result(pcmSpeakerPlayer.start(sampleRate: rate)) + case "feed": + if let args = call.arguments as? [String: Any], + let data = args["data"] as? FlutterStandardTypedData { + pcmSpeakerPlayer.feed(data.data) + } + result(nil) + case "end": + pcmSpeakerPlayer.end() + result(nil) + case "stop": + pcmSpeakerPlayer.stop() + result(nil) + case "dispose": + pcmSpeakerPlayer.dispose() + result(nil) + default: + result(FlutterMethodNotImplemented) + } +} + // 简化的事件发送方法 internal func sendAsrEvent(_ event: [String: Any]) { sendEvent(event, type: .asr) diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/PcmSpeakerPlayer.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/PcmSpeakerPlayer.swift new file mode 100644 index 00000000..4aea8f02 --- /dev/null +++ b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/PcmSpeakerPlayer.swift @@ -0,0 +1,187 @@ +import AVFoundation +import Foundation + +/// 流式 PCM 外放播放器(16bit 小端、单声道)。 +/// +/// 存在的理由:iOS 上 **just_audio 放不了「长度未知的直播 WAV 流」**。 +/// 真机实测(iPhone 12 / iOS 26.6)AVPlayer 拿到 just_audio 代理服务给的 +/// chunked 响应后,只发一次 `Range: bytes=0-1` 的嗅探请求就判定资源不可播, +/// 立刻回 `-11850 Operation Stopped` 且 processingState 直接落到 idle, +/// **一次都没真正起播**。声明长度、支持 range、把收到的字节全缓存下来允许 +/// 重放,三种都试过,结论一样。ExoPlayer 不发这个嗅探请求,所以 Android +/// 那条路一直是好的 —— 这就是「安卓有声、苹果没声」的全部原因。 +/// +/// 这里绕开 AVPlayer + HTTP 代理,直接 AVAudioEngine + AVAudioPlayerNode +/// 排队播放,和 `AzureTtsHelper` 里那套流式播放同源。 +/// +/// ⚠️ **不碰 AVAudioSession**:会话由 Dart 侧的 audio_session 统一配置 +/// (连续对话时录音的 voice-processing 也在同一个会话上)。这里再去 +/// setCategory/setActive 就会和录音互相掀桌子。 +class PcmSpeakerPlayer { + private let queue = DispatchQueue(label: "azure_speech.pcm_speaker") + + private var engine: AVAudioEngine? + private var node: AVAudioPlayerNode? + private var format: AVAudioFormat? + + /// 已排队但还没播完的缓冲数 + private var pending = 0 + /// 上层已经调过 end():pending 归零就算这一轮播完 + private var ended = false + /// 本轮代际,stop() 之后迟到的完成回调按代际丢弃 + private var generation = 0 + + /// 一轮音频播完(或本轮压根没有音频) + var onCompleted: (() -> Void)? + + // MARK: - 对外接口 + + func start(sampleRate: Double) -> Bool { + var ok = false + queue.sync { + generation += 1 + pending = 0 + ended = false + ok = ensureEngine(sampleRate: sampleRate) + } + return ok + } + + func feed(_ data: Data) { + queue.async { [weak self] in + guard let self = self else { return } + guard let node = self.node, let engine = self.engine, engine.isRunning else { return } + guard let buffer = self.makeBuffer(from: data) else { return } + let gen = self.generation + self.pending += 1 + // ⚠️ 必须用 .dataPlayedBack 而不是默认的 .dataRendered: + // 播完这一轮要顺手停引擎(见 finishIfDone),用 dataRendered 会在 + // 声音真正出完之前就回调,尾音被切掉。 + node.scheduleBuffer(buffer, completionCallbackType: .dataPlayedBack) { [weak self] _ in + self?.queue.async { + guard let self = self, gen == self.generation else { return } + self.pending -= 1 + self.finishIfDone() + } + } + if !node.isPlaying { node.play() } + } + } + + /// 上层不再喂数据了:已排队的播完就报完成 + func end() { + queue.async { [weak self] in + guard let self = self else { return } + self.ended = true + self.finishIfDone() + } + } + + /// 打断:立刻停声,不报完成 + func stop() { + queue.async { [weak self] in + guard let self = self else { return } + self.generation += 1 + self.pending = 0 + self.ended = false + self.node?.stop() + self.engine?.stop() + } + } + + func dispose() { + queue.async { [weak self] in + guard let self = self else { return } + self.generation += 1 + self.node?.stop() + self.engine?.stop() + self.node = nil + self.engine = nil + self.format = nil + } + } + + // MARK: - 内部 + + private func finishIfDone() { + guard ended, pending <= 0 else { return } + ended = false + // ⚠️ 一轮播完必须把引擎停掉,不能让它空跑着占住音频 IO。 + // 连续对话下 record 要在同一个会话上开 voice-processing, + // 我们这台引擎还占着的话它会拿不到 IO,报 + // `avfaudio error 2003329396`(FourCC 'what')—— 表现就是 + // 「助手说完话之后麦克风再也开不起来」。真机上间歇复现过。 + // 对象留着不销毁,下一轮 ensureEngine 直接 start() 就好。 + node?.stop() + engine?.stop() + let cb = onCompleted + DispatchQueue.main.async { cb?() } + } + + /// 采样率变了要整条重建:AVAudioEngine 的连接格式是建链路时定死的 + private func ensureEngine(sampleRate: Double) -> Bool { + if let engine = engine, let node = node, let fmt = format, + fmt.sampleRate == sampleRate { + if !engine.isRunning { + do { try engine.start() } catch { tearDown(); return buildEngine(sampleRate: sampleRate) } + } + if !node.isPlaying { node.play() } + return true + } + tearDown() + return buildEngine(sampleRate: sampleRate) + } + + private func buildEngine(sampleRate: Double) -> Bool { + guard let fmt = AVAudioFormat(standardFormatWithSampleRate: sampleRate, channels: 1) else { + return false + } + let e = AVAudioEngine() + let n = AVAudioPlayerNode() + e.attach(n) + e.connect(n, to: e.mainMixerNode, format: fmt) + do { + try e.start() + } catch { + NSLog("[PcmSpeakerPlayer] 引擎启动失败: \(error.localizedDescription)") + return false + } + n.play() + engine = e + node = n + format = fmt + return true + } + + private func tearDown() { + node?.stop() + engine?.stop() + node = nil + engine = nil + format = nil + } + + /// Int16 小端裸 PCM → Float32 单声道 buffer + private func makeBuffer(from data: Data) -> AVAudioPCMBuffer? { + guard let fmt = format else { return nil } + let frames = data.count / 2 + guard frames > 0, + let buffer = AVAudioPCMBuffer(pcmFormat: fmt, + frameCapacity: AVAudioFrameCount(frames)), + let dst = buffer.floatChannelData else { return nil } + buffer.frameLength = AVAudioFrameCount(frames) + data.withUnsafeBytes { raw in + guard let base = raw.baseAddress else { return } + // ⚠️ 不能 bindMemory(to: Int16.self):Data 的起始地址不保证 2 字节对齐, + // 未对齐访问在 arm64 上是未定义行为。逐字节取再拼。 + let bytes = base.assumingMemoryBound(to: UInt8.self) + for i in 0..=3.3.0 <4.0.0"