import 'dart:async'; import 'dart:typed_data'; import 'package:bluetooth_manager/bluetooth_manager.dart'; import 'package:get/get.dart'; import '../../core/utils/logger.dart'; 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'; /// 恒玄(BES)耳机的 AI 实时对话会话。 /// /// 把「耳机按键唤醒」接到阿里百炼多模态上:耳机麦的 G.722 上行由原生解码成 /// 16k PCM 后推给百炼,百炼回的 16k PCM 再交给原生的 `AiAudioDownlink` /// 编成 G.722 按 20ms 节奏发回耳机播放。整条链路上 Dart 只搬运 PCM。 /// /// **握手是耳机主导的**,App 只能应答(时序来自 deepvoice `AgentSessionCoordinator`): /// ``` /// 耳机按键 ──► BB 64 (startAI) /// App ──► AA 6A 必须 3 秒内,否则耳机停止送音频 /// …会话进行中,耳机持续上行 mic,App 持续下行 AI 语音… /// App ──► AA 65 请求退出 /// 耳机 ──► BB E5 (stopAI) 正常收尾;2 秒不回就本地强制结束 /// ``` /// /// 常驻服务:耳机按键在任意页面(含 App 在后台、EMAI 页没打开)都要能唤醒。 class BesAiSessionService extends GetxService { static const String _tag = 'BesAiSession'; static BesAiSessionService get to => Get.find(); /// 耳机侧的会话与手机 EMAI 页各用一条独立的 WebSocket。 /// /// 共用一个 [BailianMultimodalService] 单例的话,两边会互相把对方的会话 /// `stop()` 掉(`start()` 里第一件事就是 stop),而且下行音频会串到对方的 /// 播放器上。独立实例最省心,代价只是极端情况下两条会话并存—— /// 而这种情况已经由 [_yieldPhonePage] 挡掉了。 final BailianMultimodalService _svc = BailianMultimodalService(); final BluetoothManager _bt = BluetoothManager(); /// 耳机上行 PCM 就是 16k/16bit/mono,与百炼上行要求一致,不用转换 static const int _earphoneRate = 16000; /// 上行攒够这么多字节再发一包:3200B = 100ms @16k/16bit/mono。 /// 对齐 deepvoice `HeadsetAudioLink.MIC_BATCH_BYTES`,也正好是百炼文档 /// 建议的上行包大小。耳机每 20ms 上来一包,逐包转发就是 50 条 WS 消息/秒, /// 攒成 100ms 一条能把消息数降到 1/5。 static const int _micBatchBytes = 3200; final BytesBuilder _micAccum = BytesBuilder(copy: false); /// 收到 `0x64` 后必须在这个时间内回 `AA 6A`。我们是立即回, /// 这个常量只是把固件约束写在代码里,改动时别超过它。 static const Duration _ackDeadline = Duration(seconds: 3); /// 发出 `AA 65` 后等耳机回 `0xE5` 的时间,超时就本地强制结束 static const Duration _stopAckTimeout = Duration(seconds: 2); StreamSubscription? _aiSub; StreamSubscription? _eventSub; StreamSubscription? _audioSub; Timer? _stopAckTimer; Timer? _playoutTimer; /// 会话是否在跑(已回过 `AA 6A`,还没收到/发出结束) final RxBool isActive = false.obs; /// 手机 EMAI 页(打开时由它自己 attach 上来)。 /// /// 这里刻意**不**用 `Get.put(controller)` 注册: /// GetX 的 put 会对实例调一次 onStart→onInit,而页面正是在自己的 onInit 里 /// 注册的,等于 onInit 递归调 onInit —— 实测直接 Stack Overflow。 EmaiSessionOwner? _phonePage; /// 本轮回复累计推给耳机的 PCM 字节数,用来估算播完的时刻 int _respondingBytes = 0; DateTime? _respondingStartedAt; /// 上行是否被门控住(AI 正在耳机里播报期间)。 /// /// 耳机没有把 SPP 下发的音频接进自己的 AEC 参考路径,播出去的 AI 声音会被 /// 自己的麦克风原样收回来;duplex 模式下服务端 VAD 把它当成用户插话, /// 于是每次 AI 一开口就被自己打断——实测 respondingStarted 之后 0.9s /// 必定收到 speechStarted,一轮回复被切成碎片,听起来就是杂音。 /// /// 没有 AEC 就只能做半双工:播报期间不把耳机麦的音频交上去。 /// 代价是这段时间没法插话打断,但在没有回声消除的前提下本来也做不到。 bool _uplinkGated = false; Timer? _gateTimer; /// teardown 重入保护。结束路径不止一条(空闲超时、耳机回 0xE5、 /// requestStop 超时、百炼断开),而 teardown 里要 await 播完「咚」, /// 这段时间足够第二条路径挤进来,不挡的话会放两声提示音、收两次尾。 bool _tearingDown = false; /// 播报结束后再多门控一会儿:耳机自己还有一小段抖动缓冲要播完 static const Duration _gateTail = Duration(milliseconds: 500); /// 双方都没出声超过这个时长就自动结束会话。 /// "没出声"指:用户没说话(无 ASR 事件)且 AI 也不在播报(门控已解除)。 static const Duration _idleTimeout = Duration(seconds: 5); Timer? _idleTimer; /// 本轮**首包音频**到达的时刻。用它而不是 respondingStarted: /// 实测服务端会先回 RespondingContent 再回 RespondingStarted, /// 音频也可能先于 RespondingStarted 到,用后者算时长会偏。 DateTime? _firstAudioAt; /// 上行/下行的包计数,只为排查用: /// 会话起来了却没有任何语音事件时,第一件要确认的就是耳机到底有没有在送音频。 int _micPackets = 0; int _downPackets = 0; /// 耳机这轮对话也要落进共享的聊天记录里。 /// 用户明确要求:不管是耳机直接跟 AI 聊的还是在 App 里聊的, /// 打开 EMAI 界面都得能看到内容——所以这里不能只做音频转发。 EmaiChatStore get _store => EmaiChatStore.to; /// 当前正在流式接收的两条消息 EmaiMessage? _streamingUser; EmaiMessage? _streamingAssistant; @override void onInit() { super.onInit(); _aiSub = _bt.onDeviceAiEvent().listen(_onAiEvent, onError: (e) { Logger.e(_tag, 'AI 事件流错误: $e'); }); Logger.i(_tag, '耳机 AI 会话服务已就绪,等待耳机按键唤醒'); } @override void onClose() { _aiSub?.cancel(); _teardown(notifyEarphone: true, playEndTone: false); super.onClose(); } // ---------------- 耳机事件 ---------------- void _onAiEvent(Map event) { switch (event['type']) { case 'startAI': _handleStartAI(); break; case 'stopAI': Logger.i(_tag, '耳机通知结束 AI(0xE5)'); _teardown(notifyEarphone: false); break; case 'micData': final pcm = event['data']; if (!isActive.value || pcm is! Uint8List) break; _micPackets++; // 20ms 一包,50 包 = 1s 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()); } break; case 'deviceDisconnected': if (isActive.value) { Logger.w(_tag, '耳机断连,结束 AI 会话'); _teardown(notifyEarphone: false, playEndTone: false); } break; } } Future _handleStartAI() async { if (isActive.value) { Logger.w(_tag, '已有会话在跑,忽略重复的 0x64'); return; } Logger.i(_tag, '耳机按键唤醒 AI(0x64)'); // 起不来就别应答,直接让耳机退出 AI 模式——回了 AA 6A 之后耳机会一直 // 送音频,而我们这边没有会话接,它会白等到超时 if (!_svc.isConfigured) { Logger.e(_tag, '百炼未配置(${_svc.configHint}),拒绝本次唤醒'); await _sendExitAi(); return; } if (BesBluetoothService.to.deviceStatus.value != 2) { Logger.e(_tag, '耳机未处于已连接状态,拒绝本次唤醒'); return; } // 固件要求 3 秒内确认,先回 AA 6A 再做后面的初始化。 // 反过来的话,百炼握手(一次 WebSocket 往返)可能就吃掉大半个预算。 final ackAt = DateTime.now(); await BesBluetoothService.to.sendData([0xAA, 0x6A]); isActive.value = true; _micPackets = 0; _downPackets = 0; _uplinkGated = false; _micAccum.clear(); await _yieldPhonePage(); _eventSub = _svc.events.listen(_onBailianEvent); // 下行 16k:G.722 编码器就是 16k,让服务端直接出 16k 省掉一次重采样 _audioSub = _svc.audioOut.listen(_onDownstreamPcm); await _bt.startAiDownlink(); // 先给一声「叮」,让用户立刻知道已经在听了—— // 百炼握手还要一次 WebSocket 往返,不出声的话这段空档很像没反应 await _playCue(CueTone.ding(sampleRate: _earphoneRate)); _touchActivity(); final ok = await _svc.start( mode: 'duplex', downstreamRate: _earphoneRate, 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 预算(AA 6A 已提前回,通常无碍)'); } if (!ok) { Logger.e(_tag, '百炼会话启动失败,结束'); await _teardown(notifyEarphone: 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 自己的回声 // 触发的,这时候 flush 等于自己把自己的话掐断——之前 45% 的下行音频 // (push 5336 帧只发出 2917 包)就是这么丢掉的。 if (!_uplinkGated) { _bt.flushAiDownlink(); _cancelPlayoutTimer(); } // 气泡等第一个 speechContent 再懒建:ASR 一句都没识别出来时 // 服务端回的是 Error 而不是 SpeechEnded,预建的空气泡没人清理 _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); } // 纯指令帧没有文本,别建空气泡(详见 BailianEvent.finishReason) 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(notifyEarphone: true); break; case BailianEventType.closed: if (isActive.value) { Logger.w(_tag, '百炼会话关闭,结束耳机 AI'); _teardown(notifyEarphone: 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)'); } _bt.pushAiPcm(pcm); } /// 有动静就把静默计时重新拨回 5 秒。 /// /// AI 正在耳机里播报([_uplinkGated] 为真)时不计时——那期间本来就有声音, /// 而且长回复可能播好几分钟,一计时准误杀。门控解除时会再调一次把表开起来。 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 下行通道(原生会 G.722 编码后按节奏发),所以必须在 /// [BluetoothManager.startAiDownlink] 之后、[BluetoothManager.stopAiDownlink] /// 之前调用。播放期间开门控,免得提示音被耳机麦收回去触发 VAD。 Future _playCue(Uint8List pcm, {bool waitForPlayout = false}) async { if (BesBluetoothService.to.deviceStatus.value != 2) return; _openGate(); await _bt.pushAiPcm(pcm); final ms = pcm.length * 1000 ~/ (_earphoneRate * 2); if (waitForPlayout) { // 结束音要等它真的发完+播完,否则紧接着 stopAiDownlink 会把它清掉 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(); } /// 把这一轮下行音频的字节数/时长打出来。 /// /// 用来核对服务端到底按哪个采样率在发:我们请求的是 16k /// (16000 × 2 = 32000 B/s),如果实测速率接近 48000 B/s, /// 说明 `sample_rate` 没被采纳、实际还是 24k —— 那样喂给 16k 的 /// G.722 编码器出来就是变速变调的声音,必须在这里重采样。 void _logResponseAudioStats() { final start = _firstAudioAt; if (start == null || _respondingBytes == 0) return; final ms = DateTime.now().difference(start).inMilliseconds; final audioMs = _respondingBytes * 1000 ~/ (_earphoneRate * 2); Logger.i( _tag, '本轮下行音频 ${_respondingBytes}B ≈ ${audioMs}ms(按16k算) ' ',实际用时 ${ms}ms'); } /// 估算耳机什么时候把这一轮播完,然后回 `LocalRespondingEnded`。 /// /// 拿不到耳机的真实播放进度,只能按实时速率算:16k/16bit/mono = 32000 B/s, /// 服务端下发通常快于实时,所以「已推字节对应的时长 − 从开播到现在的时长」 /// 就是还没播完的部分。多等一点没坏处(服务端只是晚一点回到 Listening), /// 早回则会让它以为已经播完而提前接话。 void _schedulePlayoutDone() { _cancelPlayoutTimer(); const bytesPerSecond = _earphoneRate * 2; final totalMs = _respondingBytes * 1000 ~/ bytesPerSecond; final elapsedMs = DateTime.now() .difference(_respondingStartedAt ?? DateTime.now()) .inMilliseconds; // 上限给 10 分钟,跟原生下行队列的容量对齐。 // 原先 clamp 到 30 秒,遇到 158 秒的长回复时门控提前 84 秒解除, // 耳机还在播故事,回环声又被服务端当成用户插话。 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); }); } void _cancelPlayoutTimer() { _playoutTimer?.cancel(); _playoutTimer = null; } // ---------------- 结束 ---------------- /// 主动结束(比如 App 侧要收回控制权):发 `AA 65`,等耳机回 `0xE5`; /// 超时就本地强制收尾,避免卡在「耳机不理我」的状态。 Future requestStop() async { if (!isActive.value) return; Logger.i(_tag, '请求耳机退出 AI 模式(AA 65)'); await _sendExitAi(); _stopAckTimer?.cancel(); _stopAckTimer = Timer(_stopAckTimeout, () { if (!isActive.value) return; Logger.w(_tag, '${_stopAckTimeout.inSeconds}s 未收到 0xE5,本地强制结束'); _teardown(notifyEarphone: false); }); } Future _sendExitAi() async { try { await BesBluetoothService.to.sendData([0xAA, 0x65]); } catch (e) { Logger.w(_tag, '发送 AA 65 失败: $e'); } } /// [notifyEarphone] 为 true 时补发 `AA 65`:不是耳机主导的结束 /// (百炼断开、配置失败、服务销毁),必须告诉耳机退出, /// 否则它会继续朝一个已经没人接的会话推流。 /// [playEndTone] 为 false 用于耳机已经断连的场景——那时候放「咚」没有意义, /// 还会白等一次超时。 Future _teardown({ required bool notifyEarphone, bool playEndTone = true, }) async { if (_tearingDown) return; _tearingDown = true; try { await _doTeardown( notifyEarphone: notifyEarphone, playEndTone: playEndTone); } finally { _tearingDown = false; } } Future _doTeardown({ required bool notifyEarphone, bool playEndTone = true, }) async { _stopAckTimer?.cancel(); _stopAckTimer = null; _idleTimer?.cancel(); _idleTimer = null; _cancelPlayoutTimer(); _micAccum.clear(); if (isActive.value && playEndTone) { // 先把还没播完的 AI 语音丢掉,不然「咚」要排在几十秒的队尾 await _bt.flushAiDownlink(); await _playCue(CueTone.dong(sampleRate: _earphoneRate), 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 _bt.stopAiDownlink(); await _svc.stop(); if (wasActive && notifyEarphone) await _sendExitAi(); if (wasActive) { Logger.i(_tag, '耳机 AI 会话已结束 (上行 $_micPackets 包 / 下行 $_downPackets 包)'); await _resumePhonePage(); } } // ---------------- 与手机 EMAI 页互斥 ---------------- /// 耳机会话优先:手机上的 EMAI 页正开着就让它先让出去。 /// /// 用动态查找而不是直接 import EmaiController,是为了不让 data 层反向依赖 /// modules 层——这个服务是常驻的,EMAI 页只是它的可选邻居。 /// EMAI 页打开时把自己挂上来 void attachPhonePage(EmaiSessionOwner page) => _phonePage = page; /// EMAI 页销毁时摘掉。带实例校验:页面快速重建时新页可能已经 attach, /// 旧页的 detach 不该把新页摘掉。 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 里 /// 调 [BesAiSessionService.attachPhonePage] / [BesAiSessionService.detachPhonePage]。 abstract class EmaiSessionOwner { /// 耳机接管:停掉本页的播放与会话,并且不要自动重连 Future yieldToEarphone(); /// 耳机结束:把本页的会话接回来 Future resumeFromEarphone(); }