import 'dart:async'; import 'dart:typed_data'; import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:get/get.dart'; import '../../core/utils/audio_foreground_service.dart'; import '../../core/utils/logger.dart'; import '../../devices/device_hub.dart'; import '../../devices/device_vendors.dart'; import '../models/agent_module.dart'; import '../models/emai_message.dart'; import '../models/user_Info.dart' as user_info; import '../utils/background_keepalive.dart'; import '../utils/cue_tone.dart'; import '../utils/peer_speech_gate.dart' show rmsOfPcm16; import 'agent_module_defaults.dart'; import 'assistant_directive_service.dart'; import 'bailian_multimodal_service.dart'; import 'bailian_native_dialog.dart'; import 'emai_chat_store.dart'; import 'mcp_token_service.dart'; /// 设备按键唤醒的 AI 实时对话会话——**厂商无关**,要求设备具备 /// [DeviceCapability.aiWake]。 /// /// 把「设备按键唤醒」接到阿里百炼多模态上:设备麦的音频由插件解码成 16k PCM /// (`openMic(ai)`)推给百炼,百炼回的 16k PCM 交给 `openSpeaker(ai)` 由插件/原生 /// 编码后按节奏发回设备播放。整条链路上这里只搬运 PCM。 /// /// **握手是设备主导的**,App 只能应答(恒玄时序,其它厂商在插件里对齐到同一语义): /// ``` /// 设备按键 ──► wakeEvents(ptt) 恒玄: BB 64 /// App ──► ai.accept 恒玄: AA 6A,必须 3 秒内,否则设备停止送音频 /// …会话进行中… /// App ──► ai.exit 恒玄: AA 65 /// 设备 ──► wakeEvents(hangup) 恒玄: BB E5;2 秒不回就本地强制结束 /// ``` /// /// 常驻服务:按键在任意页面(含 App 在后台、EMAI 页没打开)都要能唤醒。 class DeviceAiSessionService extends GetxService { static const String _tag = 'DeviceAiSession'; static DeviceAiSessionService get to => Get.find(); /// 设备侧的会话与手机 EMAI 页各用一条独立的 WebSocket。 /// 共用一个单例的话两边会互相 stop 掉对方、下行音频也会串。 /// /// ⚠️ **接哪个百炼应用要看是哪台设备唤醒的**:耳机/支架接 EMAI,车载香薰接 /// 「EaiCar 车载助手」(各自的 app_id 不同)。所以这里不能是 final 单例, /// 由 [_ensureService] 按厂商换实例;app_id 没变就复用,不白开 WebSocket。 BailianMultimodalService _svc = BailianMultimodalService(); /// 原生百炼客户端。非空 = 本次会话走原生直连:耳机麦 PCM 与合成音频都不经过 /// Dart,这边只收文本、状态、下行字节数。为 null 时整条链路退回 [_svc]。 /// /// ⚠️ 两条路**不会同时开**:[_useNative] 是本次会话的唯一开关, /// 两边都起来的话上行会被推两份,服务端 VAD 会把它当成一直有人在说话。 BailianNativeDialog? _native; bool get _useNative => _native != null; StreamSubscription? _nativeDownSub; StreamSubscription? _nativeMicSub; /// 原生上报的是**累计**下行字节数,这里存上一次的值好算增量 int _nativeDownSeen = 0; /// 下次开原生会话要用的百炼应用(跟着 [_ensureService] 换) String? _wantNativeAppId; String? _svcAppId; /// 厂商 → 该接哪个 agent 的 app_id。走的是「厂商 → 品类 → 默认编排第一个模块 /// → 模块声明的 bailianAppId」这条链,**设备层不认识 agent、agent 层不认识厂商**。 /// 查不到返回 null = 用全局默认(EMAI)。 String? _resolveAgentAppId(String vendor) { try { return bailianAppIdForModule(AgentDefaults.primaryModuleForVendor(vendor)); } catch (e) { Logger.w(_tag, '解析 agent app_id 失败(回退默认): $e'); return null; } } /// 按需换一个指向目标 app_id 的服务实例。 void _ensureService(String? appId) { final want = (appId ?? '').trim(); final cur = (_svcAppId ?? '').trim(); if (want == cur) return; Logger.i(_tag, '切换设备侧百炼应用: ${cur.isEmpty ? '(默认)' : cur} → ${want.isEmpty ? '(默认)' : want}'); // 旧实例可能还挂着 WebSocket,先停掉再丢 unawaited(_svc.stop()); _svc = BailianMultimodalService(appId: want.isEmpty ? null : want); // 原生那份也跟着换:两个 agent 用的是百炼里不同的应用,共用会串会话 unawaited(_native?.dispose()); _native = null; _wantNativeAppId = want.isEmpty ? null : want; _svcAppId = want; } /// 设备上行 PCM 就是 16k/16bit/mono,与百炼上行要求一致 static const int _deviceRate = 16000; /// 上行攒够这么多字节再发一包:3200B = 100ms @16k。设备每 20ms 上来一包, /// 逐包转发就是 50 条 WS 消息/秒,攒成 100ms 一条降到 1/5。 static const int _micBatchBytes = 3200; final BytesBuilder _micAccum = BytesBuilder(copy: false); /// 收到唤醒后必须在这个时间内应答。我们是立即回,这个常量只是把固件约束写在代码里。 static const Duration _ackDeadline = Duration(seconds: 3); /// 发出 ai.exit 后等设备回 hangup 的时间,超时就本地强制结束 static const Duration _stopAckTimeout = Duration(seconds: 2); StreamSubscription? _wakeSub; StreamSubscription? _hubSub; StreamSubscription? _micSub; StreamSubscription? _eventSub; StreamSubscription? _audioSub; DeviceSession? _session; DeviceAudioSource? _mic; DeviceAudioSink? _spk; Timer? _stopAckTimer; Timer? _playoutTimer; /// 会话是否在跑(已应答,还没收到/发出结束) final RxBool isActive = false.obs; /// 手机 EMAI 页(打开时由它自己 attach 上来)。 /// 不用 `Get.put(controller)`:GetX 的 put 会对实例调 /// onInit,而页面正是在自己的 onInit 里注册的,会递归到 Stack Overflow。 EmaiSessionOwner? _phonePage; int _respondingBytes = 0; DateTime? _respondingStartedAt; /// 上行是否被门控住(AI 正在设备里播报期间)。 /// 设备没有把下发的音频接进自己的 AEC,播出去的 AI 声音会被麦克风原样收回, /// 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); /// 双方多久没动静就自动结束。 /// /// ⚠️ 原来是 5s,而且只有**百炼事件**才刷新它——设备上行 PCM 到了不算:AI 说完、 /// 门控解除之后用户必须在 5 秒内让服务端 VAD 判到说话,否则会话被判「双方无声」 /// 直接结束。锁屏 + 蓝牙上行延迟 + 蜂窝 RTT,这 5 秒非常容易超, /// 这就是「锁屏后用耳机对话很容易中断」的直接原因(2026-09-20 苹果测试)。 /// 现在放到 15s,并且设备上行里**有声音**(RMS 过阈值)也算活动。 static const Duration _idleTimeout = Duration(seconds: 15); Timer? _idleTimer; /// 设备上行判「有人在说」的 RMS 阈值(与 PeerSpeechGate 同一档:静音 0~50, /// 正常说话 1000~8000),以及刷新空闲计时的节流间隔。 static const int _micActivityRms = 600; static const Duration _micActivityThrottle = Duration(milliseconds: 500); DateTime? _lastMicActivityAt; /// 百炼 WebSocket 掉了以后的自动重连(蜂窝网切换、弱网抖动)。 /// 原来 `closed` 一到就整个会话拆掉——用户还戴着耳机在说话。 static const int _wsReconnectMax = 2; static const List _wsReconnectDelays = [ Duration(seconds: 1), Duration(seconds: 3), ]; int _wsReconnectAttempts = 0; Timer? _wsReconnectTimer; /// 设备断连后的观察期:iOS 上 Echo-one 的系统级连接本来就会自行断续, /// 一抖就拆会话太激进;期间设备又回到 ready 就继续,到期没回来才拆。 static const Duration _deviceDropGrace = Duration(seconds: 3); Timer? _deviceDropTimer; DateTime? _firstAudioAt; int _micPackets = 0; int _downPackets = 0; /// 本次会话开了 Android 前台服务(结束时要关,别关掉别人开的) bool _holdsForeground = false; int _micPeakRms = 0; EmaiChatStore get _store => EmaiChatStore.to; EmaiMessage? _streamingUser; EmaiMessage? _streamingAssistant; @override void onInit() { super.onInit(); final hub = DeviceHub.to; _wakeSub = hub.wakeEvents.listen(_onWake, onError: (e) { Logger.e(_tag, '唤醒事件流错误: $e'); }); _onlineWorker = ever>(hub.sessions, _resetAiStateOnOnline); _hubSub = hub.sessionEvents.listen((e) { if (e.type != DeviceSessionEventType.connectionStateChanged) return; if (!isActive.value || _session?.deviceId != e.deviceId) return; if (e.connectionState == DeviceConnectionState.disconnected) { if (_deviceDropTimer != null) return; Logger.w(_tag, '设备断连,观察 ${_deviceDropGrace.inSeconds}s 看它回不回来'); _deviceDropTimer = Timer(_deviceDropGrace, () { _deviceDropTimer = null; if (!isActive.value) return; Logger.w(_tag, '设备 ${_deviceDropGrace.inSeconds}s 内没回来,结束 AI 会话'); _teardown(notifyDevice: false, playEndTone: false); }); } else if (e.connectionState == DeviceConnectionState.ready && _deviceDropTimer != null) { _deviceDropTimer!.cancel(); _deviceDropTimer = null; // 新的 session 对象(插件断开重连会重建),把引用换过来,之后的 ai.state / // ai.exit 才发得出去;上下行流是插件级的,不用重开。 final fresh = hub.sessions.firstWhereOrNull((s) => s.deviceId == e.deviceId); if (fresh != null) _session = fresh; Logger.w(_tag, '设备在观察期内回来了,AI 会话继续'); _touchActivity(); } }); Logger.i(_tag, '设备 AI 会话服务已就绪,等待按键唤醒'); } @override void onClose() { _onlineWorker?.dispose(); _wakeSub?.cancel(); _hubSub?.cancel(); _teardown(notifyDevice: true, playEndTone: false); super.onClose(); } // ---------------- 设备上线复位 ---------------- // // ⚠️ 2026-09-25 真机:对话中 App 被划掉(或被系统杀掉)时来不及通知设备退出 AI, // 设备一直停在 AI 模式;App 重开回连后再按唤醒键,设备照样报 0xCC 0x11, // 却一帧麦克风都不发(原生统计 framesIn=0),用户说什么都没反应。 // 所以设备一上线、且此刻没有会话在跑,就先把它的 AI 状态复位成「关闭」。 // 只对车载香薰做:它的退出是单向的 0x07 03(无副作用);恒玄的 AA 65 是另一套流程,不碰。 Worker? _onlineWorker; final Set _onlineSeen = {}; void _resetAiStateOnOnline(List list) { final ids = list.map((s) => s.deviceId).toSet(); _onlineSeen.removeWhere((id) => !ids.contains(id)); for (final s in list) { if (!_onlineSeen.add(s.deviceId)) continue; if (s.vendor != DeviceVendors.eaicar || isActive.value) continue; Logger.i(_tag, '设备上线,复位它的 AI 状态(防上次会话没收尾)'); s.invokeFeature(DeviceFeatures.aiExit).catchError((Object e) { Logger.w(_tag, '上线复位 AI 状态失败: $e'); return {}; }); } } // ---------------- 设备事件 ---------------- void _onWake(DeviceWakeEvent e) { switch (e.reason) { case WakeReason.ptt: case WakeReason.voiceWake: _handleStart(e.deviceId); break; case WakeReason.hangup: Logger.i(_tag, '设备通知结束 AI'); _teardown(notifyDevice: false); break; default: break; } } /// 起本次对话。两条路径的参数**必须一致**,所以只在这一处写。 Future _startDialog() async { final token = await McpTokenService.get(); if (_useNative) { return _native!.start( mode: 'duplex', downstreamRate: _deviceRate, userId: _currentUserId(), source: 'earphone', mcpToken: token, ); } return _svc.start( mode: 'duplex', downstreamRate: _deviceRate, userId: _currentUserId(), source: 'earphone', mcpToken: token, ); } /// 试着开原生直连。两端都要开: /// - azure_speech 那端([BailianNativeDialog])负责 WS 与攒包; /// - bluetooth_manager 那端(`DeviceFeatures.aiPcmNativeBridge`)负责把耳机麦 /// PCM 从 Dart 的 EventChannel 上摘下来、并接住回灌的合成音频。 /// /// 任一端不支持就整条退回 Dart,**不能只开一半**:只开翻译端的话上行一帧都收不到, /// 只开设备端的话 PCM 被摘走却没人接,两种都表现为「唤醒了但 AI 不说话」。 Future _tryOpenNativeDialog(DeviceSession session) async { if (!await BailianNativeDialog.isAvailable()) { Logger.i(_tag, '原生百炼客户端不可用,走 Dart 链路'); return null; } try { final r = await session.invokeFeature( DeviceFeatures.aiPcmNativeBridge, {'enabled': true}); if (r['enabled'] != true) { Logger.i(_tag, '设备端不支持 AI 原生桥,走 Dart 链路'); return null; } } on DeviceException catch (e) { Logger.i(_tag, '设备端不支持 AI 原生桥(${e.code}),走 Dart 链路'); return null; } catch (e) { Logger.w(_tag, '开设备端 AI 原生桥失败,走 Dart 链路: $e'); return null; } Logger.i(_tag, 'EMAI 走原生直连(耳机 PCM 不经过 Dart)'); return BailianNativeDialog(appId: _wantNativeAppId); } /// 告诉服务端「这一轮本地已经播完了」,它据此把对话状态推回 Listening/Idle。 /// /// ⚠️ 这里**不要**去动耳机的下行队列。原生 `AiAudioDownlink.flush()` 的语义是 /// **清空**(`outQueue.clear()`),不是"把尾包发出去"——名字容易读反, /// 在这里调它等于把每一句回复的尾音切掉。真正要丢弃待播的地方只有 /// 「真人插话」,那条走 `_spk?.discardPending()`,两种链路都有效 /// (下行发送器是同一个原生对象,跟谁喂的 PCM 无关)。 void _notifyLocalRespondingEnded() { if (_useNative) { unawaited(_native!.notifyLocalRespondingEnded()); } else { _svc.notifyLocalRespondingEnded(); } } /// 原生只报**累计**下行字节数(约 100ms 一条):PCM 已经直接回了耳机, /// Dart 这边要做的只有开门控、计时、计数,三件事都只依赖长度。 void _onNativeDownstreamTotal(int total) { final delta = total - _nativeDownSeen; _nativeDownSeen = total; if (delta > 0) _noteDownstream(delta); } void _onMicPcm(Uint8List pcm) { if (!isActive.value || pcm.isEmpty) return; _micPackets++; final rms = rmsOfPcm16(pcm); if (rms > _micPeakRms) _micPeakRms = rms; // 用户在说话 = 有活动。只看能量、带节流,静音帧不续命 if (!_uplinkGated && rms >= _micActivityRms) { final now = DateTime.now(); if (_lastMicActivityAt == null || now.difference(_lastMicActivityAt!) >= _micActivityThrottle) { _lastMicActivityAt = now; _touchActivity(); } } if (_micPackets == 1 || _micPackets % 50 == 0) { // 带上这段时间的峰值音量:排查「在说话却没识别」时,先分清是没声音还是服务端没认 Logger.i(_tag, '设备上行 #$_micPackets (${pcm.length}B/包, 峰值RMS=$_micPeakRms)${_uplinkGated ? ' [门控中]' : ''}'); _micPeakRms = 0; } // 门控期间推等长静音而不是断流:上行保持连续,服务端 VAD 收到的是 // "真的没人说话",而不是一段数据缺口 _micAccum.add(_uplinkGated ? Uint8List(pcm.length) : pcm); if (_micAccum.length >= _micBatchBytes) { _svc.pushAudio(_micAccum.takeBytes()); } } Future _handleStart(String deviceId) async { if (isActive.value) { // 会话中又按了一次唤醒键 = 用户要插话(「别说了,听我说」)。 // ⚠️ 2026-09-25 以前一律忽略:AI 正在念一段 60 多秒的回答,用户连按两次设备键 // 都没反应,设备以为进了新一轮、App 还在念旧的——两边状态对不上,看着就是卡死。 // 只在 Dart 链路这么做(车载香薰);恒玄原生直连的行为保持不变。 if (_native == null && _isEaiCar && _session?.deviceId == deviceId) { _bargeIn(); } else { Logger.w(_tag, '已有会话在跑,忽略重复唤醒'); } return; } final hub = DeviceHub.to; final session = hub.sessions.firstWhereOrNull((s) => s.deviceId == deviceId) ?? hub.sessionWith(DeviceCapability.aiWake); if (session == null || session.state != DeviceConnectionState.ready) { Logger.e(_tag, '设备未处于已连接状态,拒绝本次唤醒'); return; } // ★ 先按厂商选中该说话的那个 agent(车载香薰 → EaiCar 车载助手), // 再去看 isConfigured —— 反过来的话检查的是上一台设备的应用 _ensureService(_resolveAgentAppId(session.vendor)); Logger.i(_tag, '设备按键唤醒 AI (${session.vendor},应用=${_svcAppId?.isNotEmpty == true ? _svcAppId : '默认'})'); // 起不来就别应答,直接让设备退出 AI 模式——应答之后设备会一直送音频, // 而我们这边没有会话接,它会白等到超时 if (!_svc.isConfigured) { Logger.e(_tag, '百炼未配置(${_svc.configHint}),拒绝本次唤醒'); await _sendExit(session); return; } // 固件要求 3 秒内确认,先应答再做后面的初始化。 // 反过来的话,百炼握手(一次 WebSocket 往返)可能就吃掉大半个预算。 final ackAt = DateTime.now(); _session = session; try { await session.invokeFeature(DeviceFeatures.aiAccept); } catch (e) { Logger.e(_tag, '应答唤醒失败: $e'); _session = null; return; } isActive.value = true; _wsReconnectAttempts = 0; // 换了设备就重新试一次 ai.state(上一台不支持不代表这台也不支持) _aiStateUnsupported = false; _quitAfterPlayout = false; _greetPending = false; _ownRequestPending = false; _dropStaleDownlink = false; _micPackets = 0; _downPackets = 0; _uplinkGated = false; _micAccum.clear(); await _yieldPhonePage(); // iOS:会话期间保持一个静音的后台音频播放,否则锁屏几秒后进程被挂起, // BLE 上行与 WebSocket 一起停摆(见 BackgroundKeepalive)。Android 空操作。 await BackgroundKeepalive.acquire(_tag); // ★ 先试原生直连:上行 PCM 与合成音频都不经过 Dart。 // 插件老 / 设备不是恒玄 → 返回 null,整条链路退回下面的 Dart 路径。 _native = await _tryOpenNativeDialog(session); // Android 的等价物:前台服务把进程钉住。 // ⚠️ 2026-09-25 华为 ALN-AL80 实测:车载香薰对话中按 Home,约 2s 后进程被 Pged-Freezer // 冻住(上行 PCM 攒了 2s 才一次性冲出来),百炼 WS 随即断开,重连两次又各活 3s, // 最后放弃——用户看到的就是「切后台就聊不了了」。恒玄耳机走原生链路,不在这里管。 if (_native == null && _isEaiCar) { unawaited(AudioForegroundService.start()); _holdsForeground = true; } try { if (_useNative) { _eventSub = _native!.events.listen(_onBailianEvent); _nativeDownSub = _native!.downstreamBytes.listen(_onNativeDownstreamTotal); // 设备上行有声只用来续空闲计时,不能当 speechStarted(那会丢弃待播队列) _nativeMicSub = _native!.micActivity.listen((_) => _touchActivity()); } else { _mic = await session.openMic(route: AudioRoute.ai); _micSub = _mic!.pcm.listen(_onMicPcm); _eventSub = _svc.events.listen(_onBailianEvent); // 下行 16k:G.722 编码器就是 16k,让服务端直接出 16k 省掉一次重采样 _audioSub = _svc.audioOut.listen(_onDownstreamPcm); } // 两条路都要开扬声器:它会启动耳机的 AI 下行发送器(G.722 编码与节流)。 // 原生直连时我们不往里写 PCM,但那个发送器必须在跑,否则原生推下去的 // 合成音频没人发。提示音(叮/嘟)两种模式下都还是由 Dart 写。 _spk = await session.openSpeaker(route: AudioRoute.ai); } catch (e) { Logger.e(_tag, '打开设备音频通路失败: $e'); await _teardown(notifyDevice: true, playEndTone: false); return; } // 先给一声「叮」,让用户立刻知道已经在听了——百炼握手还要一次往返 await _playCue(CueTone.ding(sampleRate: _deviceRate)); _touchActivity(); // 握手一完成就让助手用自己的音色说一句「我在」(见 _greetOnWake)。 // 只在 Dart 链路做:原生直连(恒玄耳机)不经过 _svc,行为保持不变。 _greetPending = _native == null && _isEaiCar; final ok = await _startDialog(); final elapsed = DateTime.now().difference(ackAt); if (elapsed > _ackDeadline) { Logger.w( _tag, '会话初始化耗时 ${elapsed.inMilliseconds}ms,已超过固件 ' '${_ackDeadline.inSeconds}s 预算(应答已提前回,通常无碍)'); } if (!ok) { Logger.e(_tag, '百炼会话启动失败,结束'); await _teardown(notifyDevice: true); } } // ---------------- 百炼事件 ---------------- void _onBailianEvent(BailianEvent e) { Logger.i(_tag, '百炼事件 $e'); switch (e.type) { case BailianEventType.speechStarted: case BailianEventType.speechContent: case BailianEventType.speechEnded: case BailianEventType.respondingStarted: case BailianEventType.respondingContent: case BailianEventType.respondingEnded: _touchActivity(); break; default: break; } switch (e.type) { case BailianEventType.started: if (_greetPending) { _greetPending = false; _greetOnWake(); } break; case BailianEventType.requestAccepted: // 我们自己发的唤醒问候也会回一个 requestAccepted,那不是「服务端放弃回复」 if (_ownRequestPending) { _ownRequestPending = false; break; } // 服务端受理了一次新请求 = 它已经放弃正在回的那一轮(这一轮不会再有 // respondingEnded)。门若还开着,上行就一直是静音、服务端在 listening // 里干等,双方互等到 _gateSafety 的 90s 才解开(2026-09-25 车载香薰实测)。 // 所以:丢掉那一轮没播完的音频,立刻关门,把耳朵还给用户。 if (_uplinkGated && _isEaiCar) { Logger.w(_tag, '服务端放弃了本轮回复(requestAccepted),停播并解除门控'); _spk?.discardPending(); _cancelPlayoutTimer(); _store.finalize(_streamingAssistant); _streamingAssistant = null; _closeGate(); } break; case BailianEventType.speechStarted: // 只有真人插话才丢队列。门控期间的 speechStarted 是 AI 自己的回声触发的 if (!_uplinkGated) { _spk?.discardPending(); _cancelPlayoutTimer(); } _streamingUser = null; break; case BailianEventType.speechContent: _streamingUser ??= _store .append(EmaiMessage(isUser: true, text: '', fromEarphone: true)); _store.applyText(_streamingUser!, e.text); if (e.isFinal) { _store.finalize(_streamingUser); _streamingUser = null; } break; case BailianEventType.speechEnded: if (e.text.isNotEmpty && _streamingUser != null) { _store.applyText(_streamingUser!, e.text); } _store.finalize(_streamingUser); _streamingUser = null; break; case BailianEventType.respondingStarted: _dropStaleDownlink = false; _respondingBytes = 0; _firstAudioAt = null; _respondingStartedAt = DateTime.now(); _cancelPlayoutTimer(); _streamingAssistant = null; break; case BailianEventType.respondingContent: if (e.directives.isNotEmpty && Get.isRegistered()) { AssistantDirectiveService.to.handle(e.directives); } // 「关闭对话」:模型会下发 quit 指令并同时回一句告别语。 // 不能立刻结束——那样告别语还没播完就被掐了;标记下来,等播完再收尾。 if (e.directives.any((d) => d.name == _quitDirective)) { Logger.i(_tag, '收到 quit 指令,本轮播完后结束会话'); _quitAfterPlayout = true; } // 纯指令帧没有文本,别建空气泡 if (e.text.isEmpty && e.finishReason == 'command_calls') break; _streamingAssistant ??= _store .append(EmaiMessage(isUser: false, text: '', fromEarphone: true)); _store.applyText(_streamingAssistant!, e.text); break; case BailianEventType.respondingEnded: if (e.text.isNotEmpty && _streamingAssistant != null) { _store.applyText(_streamingAssistant!, e.text); } _store.finalize(_streamingAssistant); _streamingAssistant = null; _logResponseAudioStats(); _schedulePlayoutDone(); break; case BailianEventType.error: Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}'); // 网络类的致命错误(socket/连接被掐)先当掉线重连,不当「功能结束」 if (e.fatal && _looksLikeNetworkError(e.text) && _scheduleWsReconnect(e.text)) { _store.dropIfEmpty(_streamingUser); _streamingUser = null; _store.dropIfEmpty(_streamingAssistant); _streamingAssistant = null; break; } _store.dropIfEmpty(_streamingUser); _streamingUser = null; _store.dropIfEmpty(_streamingAssistant); _streamingAssistant = null; if (e.text.isNotEmpty) { _store.append(EmaiMessage( isUser: false, text: e.text, isFinal: true, isError: true, fromEarphone: true)); _store.persist(); } if (e.fatal) { _teardown(notifyDevice: true); } else { // 非致命错误这一轮不会再有 respondingEnded 了,门必须在这里关, // 否则上行永远是静音(见 _gateSafety 的说明)。 _cancelPlayoutTimer(); _closeGate(); } break; case BailianEventType.stateChanged: // 把会话状态同步给设备,驱动它的指示灯/表情动画。 // ⚠️ 只有声明了这个 feature 的厂商会真发字节,其余抛 not_supported 被吞掉。 switch (e.state) { case BailianDialogState.listening: _pushAiState(DeviceAiState.listening); break; case BailianDialogState.thinking: _pushAiState(DeviceAiState.thinking); break; case BailianDialogState.responding: _pushAiState(DeviceAiState.speaking); break; default: break; } break; case BailianEventType.closed: if (isActive.value && !_tearingDown) { if (_scheduleWsReconnect('closed')) break; Logger.w(_tag, '百炼会话关闭,结束设备 AI'); _teardown(notifyDevice: true); } break; } } /// 退回 Dart 链路时才会跑(原生直连时 PCM 不经过这里) void _onDownstreamPcm(Uint8List pcm) { if (pcm.isEmpty) return; // 被按键打断的那一轮,服务端可能还在往下发尾巴,丢掉,等下一轮 respondingStarted if (_dropStaleDownlink) return; _noteDownstream(pcm.length); _spk?.write(pcm); } /// 收到 [n] 字节下行音频时要做的记账。两条路径共用: /// 走 Dart 时由 [_onDownstreamPcm] 调,走原生时由 [_onNativeDownstreamTotal] 调。 /// 开门控、估播完时长、计数——都只依赖字节数,不需要 PCM 本身。 void _noteDownstream(int n) { if (!isActive.value || n <= 0) return; if (_firstAudioAt == null) { _firstAudioAt = DateTime.now(); _openGate(); } _respondingBytes += n; _downPackets++; if (_downPackets == 1 || _downPackets % 50 == 0) { Logger.i(_tag, '下行给设备 #$_downPackets (${n}B)'); } } /// 有动静就把静默计时重新拨回 5 秒。播报期间不计时。 /// 设备是否支持 [DeviceFeatures.aiState]。不支持就别每次都试、别每次都打日志。 bool _aiStateUnsupported = false; /// 模型下发的「结束对话」指令名(`extra_info.commands` 里的 `name`) static const String _quitDirective = 'quit'; /// 当前会话是不是车载香薰。2026-09-25 为香薰加的会话行为(唤醒问候、按键插话、 /// 服务端放弃回复时解门控、前台服务、0 包诊断)**一律只对它生效**, /// 恒玄耳机的会话流程保持原样、一个分支都不走。 bool get _isEaiCar => _session?.vendor == DeviceVendors.eaicar; /// 按键插话后,丢弃被打断那一轮剩下的下行音频(见 [_bargeIn])。 bool _dropStaleDownlink = false; /// 会话中再次按唤醒键:停掉正在播的回复、立刻重新聆听,并把状态同步给设备。 void _bargeIn() { Logger.i(_tag, '会话中再次按键唤醒:打断当前播报,重新聆听'); // respondingEnded 已到 = 服务端发完了,只剩本地在播(_playoutTimer 在等播完) final serverStillSending = _svc.state.value == BailianDialogState.responding && _playoutTimer == null; final localPlaying = _uplinkGated || _playoutTimer != null; _spk?.discardPending(); _cancelPlayoutTimer(); _quitAfterPlayout = false; if (serverStillSending) { // 服务端还在生成:请它停下,并丢掉这一轮之后到的尾巴 _dropStaleDownlink = true; _svc.interrupt(); } if (serverStillSending || localPlaying) { // 告诉服务端本地已经播完,它才会回到 Listening _notifyLocalRespondingEnded(); } _store.finalize(_streamingAssistant); _streamingAssistant = null; _closeGate(); _pushAiState(DeviceAiState.listening); _touchActivity(); } // ---------------- 唤醒问候 ---------------- // // 2026-09-25 用户要求:唤醒后马上随机回一句「我在 / 有什么可以帮你」, // 让人知道唤醒成功了。走 RequestToRespond(type=transcript):服务端用**应用自己的音色** // 直接合成这段文字,不经过大模型,所以快、也不会被模型改写。 // 它走的是正常的回复链路(respondingStarted → 下行 → respondingEnded), // 门控、播完估算、LocalRespondingEnded 都不用另写。 /// 本次会话握手完成后要不要问候(每次唤醒只一次;WS 重连后的 Started 不再问候)。 bool _greetPending = false; /// 刚发出问候、等它的 requestAccepted——那一条不能当成「服务端放弃了本轮回复」。 bool _ownRequestPending = false; static const List _greetingsZh = [ '我在', '我在呢', '你好呀,有什么可以帮你?', '在的,请说', '嗨,我在听', ]; static const List _greetingsEn = [ "I'm here", 'Hi, how can I help?', "Yes? I'm listening", ]; void _greetOnWake() { final zh = (Get.locale?.languageCode ?? 'zh') == 'zh'; final list = zh ? _greetingsZh : _greetingsEn; final text = list[DateTime.now().microsecond % list.length]; Logger.i(_tag, '唤醒问候: $text'); _ownRequestPending = true; _svc.sendText(text, speakDirectly: true); // 服务端万一不回 requestAccepted,别让这个标记吞掉之后真正的那一次 Timer(const Duration(seconds: 3), () => _ownRequestPending = false); } /// 收到 quit 指令,等这一轮告别语播完就收尾 bool _quitAfterPlayout = false; /// 把 AI 会话状态推给设备(驱动指示灯/表情)。 /// /// **fire-and-forget**:设备不支持、或这条命令失败,都不该影响对话本身, /// 所以既不 await 也不往上抛。 void _pushAiState(DeviceAiState state) { if (_aiStateUnsupported) return; final s = _session; if (s == null || s.state != DeviceConnectionState.ready) return; s.invokeFeature(DeviceFeatures.aiState, {'state': state}).catchError( (Object e) { if (e is DeviceException && e.code == DeviceErrorCode.notSupported) { // 这台设备没有表情/指示灯,记一次就够了 _aiStateUnsupported = true; Logger.i(_tag, '设备不支持 ai.state,后续不再推送会话状态'); } else { Logger.w(_tag, '推送 ai.state($state) 失败: $e'); } return {}; }, ); } static bool _looksLikeNetworkError(String text) { final t = text.toLowerCase(); const markers = [ 'socket', 'connection', 'network', 'timed out', 'timeout', 'reset', 'broken pipe', 'unreachable', 'host lookup', 'websocket', 'closed', ]; return markers.any(t.contains); } /// 百炼 WS 掉了:会话还活着、设备还连着,就换条连接接着聊。 /// 返回 false 表示预算用完,调用方按原来的方式结束会话。 bool _scheduleWsReconnect(String reason) { if (!isActive.value || _tearingDown) return false; if (_wsReconnectAttempts >= _wsReconnectMax) { Logger.w(_tag, '百炼 WS 重连 $_wsReconnectMax 次仍失败($reason),放弃'); return false; } if (_wsReconnectTimer != null) return true; // 已在排队 final delay = _wsReconnectDelays[_wsReconnectAttempts]; _wsReconnectAttempts++; Logger.w(_tag, '百炼 WS 掉线($reason),${delay.inSeconds}s 后第 $_wsReconnectAttempts 次重连'); // 重连期间别让空闲计时把会话判死 _idleTimer?.cancel(); _idleTimer = null; _wsReconnectTimer = Timer(delay, () async { _wsReconnectTimer = null; if (!isActive.value || _tearingDown) return; _cancelPlayoutTimer(); _closeGate(); final ok = await _startDialog(); if (!isActive.value) return; if (ok) { Logger.w(_tag, '百炼 WS 第 $_wsReconnectAttempts 次重连成功'); _touchActivity(); } else if (!_scheduleWsReconnect('start failed')) { _teardown(notifyDevice: true); } }); return true; } void _touchActivity() { _idleTimer?.cancel(); _idleTimer = null; if (!isActive.value || _uplinkGated) return; _idleTimer = Timer(_idleTimeout, () { if (!isActive.value) return; Logger.i(_tag, '双方 ${_idleTimeout.inSeconds}s 无声,自动结束 AI 会话'); requestStop(); }); } /// 在设备里放一段提示音(走 AI 下行通道)。播放期间开门控。 Future _playCue(Uint8List pcm, {bool waitForPlayout = false}) async { final spk = _spk; if (spk == null || _session?.state != DeviceConnectionState.ready) return; _openGate(); await spk.write(pcm); final ms = pcm.length * 1000 ~/ (_deviceRate * 2); if (waitForPlayout) { // 结束音要等它真的发完+播完,否则紧接着关通道会把它清掉 await Future.delayed(Duration(milliseconds: ms + 400)); } else { _gateTimer?.cancel(); _gateTimer = Timer(Duration(milliseconds: ms + 300), _closeGate); } } void _openGate() { _gateTimer?.cancel(); _gateTimer = null; _gateSafety?.cancel(); _gateSafety = Timer(_gateMax, () { if (!_uplinkGated) return; Logger.w(_tag, '门控开了 ${_gateMax.inSeconds}s 还没等到播完信号,强制解除'); _cancelPlayoutTimer(); _notifyLocalRespondingEnded(); _closeGate(); }); if (_uplinkGated) return; _uplinkGated = true; // 原生直连时逐帧替换静音在原生做,这里只把结论推下去 unawaited(_native?.setGated(true)); _idleTimer?.cancel(); _idleTimer = null; Logger.i(_tag, '上行门控开启(AI 播报中)'); } void _closeGate() { _gateTimer?.cancel(); _gateTimer = null; _gateSafety?.cancel(); _gateSafety = null; if (!_uplinkGated) return; _uplinkGated = false; unawaited(_native?.setGated(false)); Logger.i(_tag, '上行门控解除'); _touchActivity(); } void _logResponseAudioStats() { final start = _firstAudioAt; if (start == null || _respondingBytes == 0) return; final ms = DateTime.now().difference(start).inMilliseconds; final audioMs = _respondingBytes * 1000 ~/ (_deviceRate * 2); Logger.i(_tag, '本轮下行音频 ${_respondingBytes}B ≈ ${audioMs}ms(按16k算),实际用时 ${ms}ms'); } /// 估算设备什么时候把这一轮播完,然后回 `LocalRespondingEnded`。 void _schedulePlayoutDone() { _cancelPlayoutTimer(); const bytesPerSecond = _deviceRate * 2; final totalMs = _respondingBytes * 1000 ~/ bytesPerSecond; final elapsedMs = DateTime.now() .difference(_respondingStartedAt ?? DateTime.now()) .inMilliseconds; // 上限 10 分钟,跟原生下行队列的容量对齐 final remainMs = (totalMs - elapsedMs).clamp(0, 600000); Logger.i(_tag, '预计还需 ${remainMs}ms 播完(已推 ${totalMs}ms 音频)'); _playoutTimer = Timer(Duration(milliseconds: remainMs), () { if (!isActive.value) return; _notifyLocalRespondingEnded(); _gateTimer = Timer(_gateTail, _closeGate); if (_quitAfterPlayout) { _quitAfterPlayout = false; Logger.i(_tag, '告别语已播完,按 quit 指令结束会话'); _teardown(notifyDevice: true, playEndTone: false); } }); } void _cancelPlayoutTimer() { _playoutTimer?.cancel(); _playoutTimer = null; } // ---------------- 结束 ---------------- /// 主动结束:发 ai.exit,等设备回 hangup;超时就本地强制收尾。 Future requestStop() async { if (!isActive.value) return; Logger.i(_tag, '请求设备退出 AI 模式'); final s = _session; final noAck = s != null && await _sendExit(s); if (noAck) { // 车载香薰的「退出」是单向的 0x07 03,设备从不回应答—— // 干等 2s 只会让 App 比设备晚 2s 结束(2026-09-25 日志里每次都是超时收尾) await _teardown(notifyDevice: false); return; } _stopAckTimer?.cancel(); _stopAckTimer = Timer(_stopAckTimeout, () { if (!isActive.value) return; Logger.w(_tag, '${_stopAckTimeout.inSeconds}s 未收到设备的结束应答,本地强制结束'); _teardown(notifyDevice: false); }); } /// 发 ai.exit。返回 true 表示该设备**不会回结束应答**(插件回了 `noAck`), /// 调用方可以直接收尾。 Future _sendExit(DeviceSession s) async { if (s.state != DeviceConnectionState.ready) return false; try { final r = await s.invokeFeature(DeviceFeatures.aiExit); return r['noAck'] == true; } catch (e) { Logger.w(_tag, '发送 ai.exit 失败: $e'); return false; } } /// [notifyDevice] 为 true 时补发 ai.exit:不是设备主导的结束,必须告诉设备退出。 /// [playEndTone] 为 false 用于设备已经断连的场景。 Future _teardown({ required bool notifyDevice, bool playEndTone = true, }) async { if (_tearingDown) return; _tearingDown = true; try { await _doTeardown(notifyDevice: notifyDevice, playEndTone: playEndTone); } finally { _tearingDown = false; } } Future _doTeardown({ required bool notifyDevice, bool playEndTone = true, }) async { _stopAckTimer?.cancel(); _stopAckTimer = null; _idleTimer?.cancel(); _idleTimer = null; _gateSafety?.cancel(); _gateSafety = null; _wsReconnectTimer?.cancel(); _wsReconnectTimer = null; _wsReconnectAttempts = 0; _deviceDropTimer?.cancel(); _deviceDropTimer = null; _lastMicActivityAt = null; _cancelPlayoutTimer(); _micAccum.clear(); if (isActive.value && playEndTone) { // 先把还没播完的 AI 语音丢掉,不然「咚」要排在几十秒的队尾 await _spk?.discardPending(); await _playCue(CueTone.dong(sampleRate: _deviceRate), waitForPlayout: true); } _closeGate(); final wasActive = isActive.value; isActive.value = false; _store.dropIfEmpty(_streamingUser); _streamingUser = null; _store.dropIfEmpty(_streamingAssistant); _streamingAssistant = null; if (wasActive) _store.persist(); await _eventSub?.cancel(); _eventSub = null; await _audioSub?.cancel(); _audioSub = null; await _micSub?.cancel(); _micSub = null; await _nativeDownSub?.cancel(); _nativeDownSub = null; await _nativeMicSub?.cancel(); _nativeMicSub = null; _nativeDownSeen = 0; await _spk?.close(); _spk = null; await _mic?.close(); _mic = null; // 原生直连时两端都要关:只关一端会把耳机麦的 PCM 一直摘在原生里没人收 final nat = _native; _native = null; if (nat != null) { await nat.stop(); final s0 = _session; if (s0 != null) { try { await s0.invokeFeature( DeviceFeatures.aiPcmNativeBridge, {'enabled': false}); } catch (e) { Logger.w(_tag, '关设备端 AI 原生桥失败: $e'); } } } await _svc.stop(); final s = _session; // 整场一帧设备上行都没有(2026-09-25 真机出现过「上行 0 包」): // 趁会话还在,把原生解码统计打出来,分清是设备没发、BLE 没收到还是没解出来 if (wasActive && _micPackets == 0 && s != null && _isEaiCar) { try { final st = await s.invokeFeature('debug.audioStats'); Logger.w(_tag, '本场设备上行 0 包,原生音频统计: $st'); } catch (_) {} } if (wasActive && notifyDevice && s != null) await _sendExit(s); _session = null; await BackgroundKeepalive.release(_tag); if (_holdsForeground) { _holdsForeground = false; unawaited(AudioForegroundService.stop()); } if (wasActive) { Logger.i(_tag, '设备 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)'); await _resumePhonePage(); } } // ---------------- 与手机 EMAI 页互斥 ---------------- void attachPhonePage(EmaiSessionOwner page) => _phonePage = page; /// [page] 是不是当前登记的那个聊天页(同一时刻可能有新旧两个实例: /// 新页面 onInit 先登记,旧页面 onClose 后到)。 bool isAttachedPage(EmaiSessionOwner page) => identical(_phonePage, page); /// 正在进行的是不是车载香薰的会话(聊天页据此决定要不要用香薰那套交互)。 bool get activeIsEaiCar => isActive.value && _isEaiCar; void detachPhonePage(EmaiSessionOwner page) { if (identical(_phonePage, page)) _phonePage = null; } Future _yieldPhonePage() async { final ctrl = _phonePage; if (ctrl == null) return; Logger.i(_tag, '手机 EMAI 页正开着,让它让出会话'); try { await ctrl.yieldToEarphone(); } catch (e) { Logger.w(_tag, '通知 EMAI 页让出失败: $e'); } } Future _resumePhonePage() async { final ctrl = _phonePage; if (ctrl == null) return; try { await ctrl.resumeFromEarphone(); } catch (e) { Logger.w(_tag, '通知 EMAI 页恢复失败: $e'); } } String _currentUserId() { try { return user_info.User.isLoggedIn() ? user_info.User.instance.uid : 'eaimar-guest'; } catch (_) { return 'eaimar-guest'; } } } /// 手机 EMAI 页实现这个接口,让设备会话能在不反向依赖 modules 层的前提下 /// 通知它让出/恢复。EmaiController 在 onInit/onClose 里 /// 调 [DeviceAiSessionService.attachPhonePage] / [DeviceAiSessionService.detachPhonePage]。 abstract class EmaiSessionOwner { /// 设备接管:停掉本页的播放与会话,并且不要自动重连 Future yieldToEarphone(); /// 设备结束:把本页的会话接回来 Future resumeFromEarphone(); }