import 'dart:async'; import 'dart:typed_data'; import 'package:device_plugin_interface/device_plugin_interface.dart'; import 'package:get/get.dart'; import '../../core/utils/logger.dart'; import '../../devices/device_hub.dart'; import '../models/agent_module.dart'; import '../models/emai_message.dart'; import '../models/user_Info.dart' as user_info; import '../utils/cue_tone.dart'; import 'agent_module_defaults.dart'; import 'assistant_directive_service.dart'; import 'bailian_multimodal_service.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,车载香薰接 /// 「Smartcar 车载助手」(各自的 app_id 不同)。所以这里不能是 final 单例, /// 由 [_ensureService] 按厂商换实例;app_id 没变就复用,不白开 WebSocket。 BailianMultimodalService _svc = BailianMultimodalService(); 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); _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; bool _tearingDown = false; static const Duration _gateTail = Duration(milliseconds: 500); static const Duration _idleTimeout = Duration(seconds: 5); Timer? _idleTimer; DateTime? _firstAudioAt; int _micPackets = 0; int _downPackets = 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'); }); _hubSub = hub.sessionEvents.listen((e) { if (e.type == DeviceSessionEventType.connectionStateChanged && e.connectionState == DeviceConnectionState.disconnected && isActive.value && _session?.deviceId == e.deviceId) { Logger.w(_tag, '设备断连,结束 AI 会话'); _teardown(notifyDevice: false, playEndTone: false); } }); Logger.i(_tag, '设备 AI 会话服务已就绪,等待按键唤醒'); } @override void onClose() { _wakeSub?.cancel(); _hubSub?.cancel(); _teardown(notifyDevice: true, playEndTone: false); super.onClose(); } // ---------------- 设备事件 ---------------- 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; } } void _onMicPcm(Uint8List pcm) { if (!isActive.value || pcm.isEmpty) return; _micPackets++; if (_micPackets == 1 || _micPackets % 50 == 0) { Logger.i(_tag, '设备上行 #$_micPackets (${pcm.length}B/包)${_uplinkGated ? ' [门控中]' : ''}'); } // 门控期间推等长静音而不是断流:上行保持连续,服务端 VAD 收到的是 // "真的没人说话",而不是一段数据缺口 _micAccum.add(_uplinkGated ? Uint8List(pcm.length) : pcm); if (_micAccum.length >= _micBatchBytes) { _svc.pushAudio(_micAccum.takeBytes()); } } Future _handleStart(String deviceId) async { if (isActive.value) { 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(车载香薰 → Smartcar 车载助手), // 再去看 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; // 换了设备就重新试一次 ai.state(上一台不支持不代表这台也不支持) _aiStateUnsupported = false; _quitAfterPlayout = false; _micPackets = 0; _downPackets = 0; _uplinkGated = false; _micAccum.clear(); await _yieldPhonePage(); try { _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); _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(); final ok = await _svc.start( mode: 'duplex', downstreamRate: _deviceRate, userId: _currentUserId(), source: 'earphone', mcpToken: await McpTokenService.get(), ); 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.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: _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}'); _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); 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) { Logger.w(_tag, '百炼会话关闭,结束设备 AI'); _teardown(notifyDevice: true); } break; default: break; } } void _onDownstreamPcm(Uint8List pcm) { if (!isActive.value || pcm.isEmpty) return; if (_firstAudioAt == null) { _firstAudioAt = DateTime.now(); _openGate(); } _respondingBytes += pcm.length; _downPackets++; if (_downPackets == 1 || _downPackets % 50 == 0) { Logger.i(_tag, '下行给设备 #$_downPackets (${pcm.length}B)'); } _spk?.write(pcm); } /// 有动静就把静默计时重新拨回 5 秒。播报期间不计时。 /// 设备是否支持 [DeviceFeatures.aiState]。不支持就别每次都试、别每次都打日志。 bool _aiStateUnsupported = false; /// 模型下发的「结束对话」指令名(`extra_info.commands` 里的 `name`) static const String _quitDirective = 'quit'; /// 收到 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 {}; }, ); } 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; if (_uplinkGated) return; _uplinkGated = true; _idleTimer?.cancel(); _idleTimer = null; Logger.i(_tag, '上行门控开启(AI 播报中)'); } void _closeGate() { _gateTimer?.cancel(); _gateTimer = null; if (!_uplinkGated) return; _uplinkGated = 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; _svc.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; if (s != null) await _sendExit(s); _stopAckTimer?.cancel(); _stopAckTimer = Timer(_stopAckTimeout, () { if (!isActive.value) return; Logger.w(_tag, '${_stopAckTimeout.inSeconds}s 未收到设备的结束应答,本地强制结束'); _teardown(notifyDevice: false); }); } Future _sendExit(DeviceSession s) async { if (s.state != DeviceConnectionState.ready) return; try { await s.invokeFeature(DeviceFeatures.aiExit); } catch (e) { Logger.w(_tag, '发送 ai.exit 失败: $e'); } } /// [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; _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 _spk?.close(); _spk = null; await _mic?.close(); _mic = null; await _svc.stop(); final s = _session; if (wasActive && notifyDevice && s != null) await _sendExit(s); _session = null; if (wasActive) { Logger.i(_tag, '设备 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)'); await _resumePhonePage(); } } // ---------------- 与手机 EMAI 页互斥 ---------------- void attachPhonePage(EmaiSessionOwner page) => _phonePage = page; 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(); }