diff --git a/apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart b/apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart index 44203f2d..e85f586c 100644 --- a/apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart +++ b/apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart @@ -29,6 +29,8 @@ class EaiCarChatBinding extends Bindings { Get.lazyPut( () => EmaiController( service: Get.find(tag: kEaiCarTag), + // 手机麦也和设备一样半双工:AI 说话时关麦,说完才开 + halfDuplexMic: true, ), tag: kEaiCarTag, ); diff --git a/apps/client/lib/modules/emai/controllers/emai_controller.dart b/apps/client/lib/modules/emai/controllers/emai_controller.dart index 3c775fb6..b6c114f5 100644 --- a/apps/client/lib/modules/emai/controllers/emai_controller.dart +++ b/apps/client/lib/modules/emai/controllers/emai_controller.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:get/get.dart'; @@ -45,9 +46,17 @@ class EmaiController extends GetxController /// 传了才用传的——给「同一套对话 UI、不同百炼应用」的 agent 复用(如 EaiCar /// 车载助手)。两个 agent 各自 new 一个 service 实例,不能共用,理由见 /// [BailianMultimodalService] 的类注释。 - EmaiController({BailianMultimodalService? service}) + EmaiController({BailianMultimodalService? service, this.halfDuplexMic = false}) : _svc = service ?? Get.find(); + /// 手机麦半双工:AI 播报期间上行换成等长静音,本地播完再放开(2026-09-26)。 + /// + /// 只有车载香薰页开([EaiCarChatBinding] 传 true),与设备那条链路的门控口径一致: + /// 外放 + 两台设备互聊时,AEC 挡不住对方/自己的声音,服务端 VAD 会把它当成插话, + /// 一轮没说完就被打断,下一轮的识别和回复插进来,字幕气泡互相串。 + /// EMAI 页保持原样(靠 AEC 全双工,用户可以随时插话)。 + final bool halfDuplexMic; + static const String _tag = 'EmaiController'; final BailianMultimodalService _svc; @@ -129,6 +138,98 @@ class EmaiController extends GetxController Timer? _handshakeTimer; static const Duration _handshakeTimeout = Duration(seconds: 15); + // ---- 手机麦半双工门(仅 [halfDuplexMic])---- + bool _micGated = false; + Timer? _micGateOpenTimer; + Timer? _micGateWatchdog; + + /// 播完之后再等这么久才开麦:外放尾音与房间混响 + static const Duration _micGateTail = Duration(milliseconds: 300); + + /// 本轮开始收下行音频的时刻,与累计收到的音频秒数:用来估「这一轮应该什么时候播完」 + DateTime? _roundAudioStart; + double _roundAudioSec = 0; + + /// 预计播完时刻之后再宽限这么久,还没等到播放器的完成回调就判定卡住 + static const Duration _playbackGrace = Duration(seconds: 8); + + /// 回复开始后一包音频都没来(纯文字回复、或下行断了)时的兜底 + static const Duration _noAudioTimeout = Duration(seconds: 30); + + void _closeMicGate() { + _micGateOpenTimer?.cancel(); + _micGateOpenTimer = null; + if (!_micGated) { + _micGated = true; + _roundAudioStart = null; + _roundAudioSec = 0; + Logger.i(_tag, '半双工:AI 开口,手机麦静音'); + } + _armPlaybackWatchdog(); + } + + /// 每收到一包下行音频调一次:把这包的时长记进本轮 + void _onDownlinkAudio(int bytes) { + _roundAudioStart ??= DateTime.now(); + // 16bit 单声道:每秒 sampleRate*2 字节 + _roundAudioSec += bytes / (BailianMultimodalService.downstreamSampleRate * 2); + _armPlaybackWatchdog(); + } + + /// 播放兜底:按「开始收音频 + 已收音频总时长 + 宽限」算截止时刻(没音频就按 [_noAudioTimeout])。 + /// + /// ⚠️ 不能按「多久没来新音频」算:百炼合成比实时快,一段 60 秒的长回复可能 10 秒就全到了, + /// 那样会在还在播的时候就误判卡住、把麦打开。 + void _armPlaybackWatchdog() { + _micGateWatchdog?.cancel(); + final start = _roundAudioStart; + final Duration wait; + if (start == null) { + wait = _noAudioTimeout; + } else { + final end = start.add(Duration(milliseconds: (_roundAudioSec * 1000).round()) + _playbackGrace); + final left = end.difference(DateTime.now()); + wait = left.isNegative ? Duration.zero : left; + } + _micGateWatchdog = Timer(wait, _onPlaybackStuck); + } + + /// 过了预计播完时刻还没等到播放器报完成:播放卡住了。 + /// + /// 只开麦是不够的——百炼 duplex 要等客户端发 LocalRespondingEnded 才回到聆听, + /// 播放器不报完成、这条通知就永远不发,服务端一直停在「回复中」,之后说什么都没反应 + /// (2026-09-26 真机:第 50 轮左右卡住,再也不回复)。所以这里替播放器收尾: + /// 停掉播放、补发通知、再开麦。 + void _onPlaybackStuck() { + if (!_micGated) return; + Logger.w(_tag, + '半双工:本轮音频 ${_roundAudioSec.toStringAsFixed(1)}s 已过预计播完时刻仍未收到播完回调,' + '判定播放卡住:停播放、补发本地播完、开麦'); + unawaited(_player.stopNow()); + _svc.notifyLocalRespondingEnded(); + level.value = 0; + _openMicGate(); + } + + void _openMicGate({Duration after = Duration.zero}) { + _micGateOpenTimer?.cancel(); + void open() { + _micGateOpenTimer = null; + _micGateWatchdog?.cancel(); + _micGateWatchdog = null; + if (_micGated) { + _micGated = false; + Logger.i(_tag, '半双工:AI 说完,手机麦恢复'); + } + } + + if (after == Duration.zero) { + open(); + } else { + _micGateOpenTimer = Timer(after, open); + } + } + /// 连续对话的空闲上限:这么久没有任何对话往来就自动结束,别让麦克风一直开着。 static const Duration _continuousIdleTimeout = Duration(seconds: 10); @@ -177,8 +278,17 @@ class EmaiController extends GetxController } _subs.add(_svc.events.listen(_onEvent)); - _subs.add(_svc.audioOut.listen(_player.feed)); - _subs.add(_mic.packets.listen(_svc.pushAudio)); + _subs.add(_svc.audioOut.listen((pcm) { + if (halfDuplexMic) { + _closeMicGate(); + _onDownlinkAudio(pcm.length); + } + _player.feed(pcm); + })); + _subs.add(_mic.packets.listen((pcm) { + // 门关着就推等长静音,不停推流:服务端的 VAD / 断句按时间走,断流会被当成网络问题 + _svc.pushAudio(_micGated ? Uint8List(pcm.length) : pcm); + })); // ⚠️ 音浪的门控不能只看 isTalking:连续对话下它恒为 false, // 麦克风明明开着,界面上却一点动静都没有,用户完全看不出在不在收音。 _subs.add(_mic.level.listen((v) { @@ -191,6 +301,8 @@ class EmaiController extends GetxController // 告诉服务端本地播报结束,它才会把对话状态推回 Listening/Idle _svc.notifyLocalRespondingEnded(); level.value = 0; + // 真正播完再开麦,多等一点让外放的尾音散掉,别被自己的麦收回去 + if (halfDuplexMic) _openMicGate(after: _micGateTail); })); _jumpToBottomOnEnter(); @@ -225,6 +337,8 @@ class EmaiController extends GetxController } } _handshakeTimer?.cancel(); + _micGateOpenTimer?.cancel(); + _micGateWatchdog?.cancel(); _cancelIdle(); for (final s in _subs) { s.cancel(); @@ -327,6 +441,7 @@ class EmaiController extends GetxController _autoRetried = true; // 挡掉 closed 事件触发的自动重连,这次断开是有意的 await _abortTalk(); await _player.stopNow(); + if (halfDuplexMic) _openMicGate(); await _svc.stop(); linkState.value = EmaiLinkState.onEarphone; } @@ -539,6 +654,7 @@ class EmaiController extends GetxController // 助手还在说话时按下 = 打断:先停本地播放,再让服务端交出发言权 if (isResponding) { await _player.stopNow(); + if (halfDuplexMic) _openMicGate(); _svc.interrupt(); } if (gen != _talkGen) return; @@ -643,6 +759,7 @@ class EmaiController extends GetxController if (isResponding) { _player.stopNow(); + if (halfDuplexMic) _openMicGate(); _svc.interrupt(); } @@ -687,6 +804,7 @@ class EmaiController extends GetxController Future interrupt() async { if (!isResponding) return; await _player.stopNow(); + if (halfDuplexMic) _openMicGate(); _svc.interrupt(); _streamingAssistant?.isFinal = true; _streamingAssistant = null; @@ -759,6 +877,15 @@ class EmaiController extends GetxController break; case BailianEventType.respondingStarted: + if (halfDuplexMic) _closeMicGate(); + // 上一轮回复没等到 RespondingEnded 就被新一轮顶掉(被插话打断):先把它收尾, + // 否则它一直是「流式中」,新旧两轮的字幕会交错写进列表(2026-09-26 真机「字幕乱串」) + final prev = _streamingAssistant; + if (prev != null) { + _dropIfEmpty(prev); + prev.isFinal = true; + _store.finalize(prev); + } _streamingAssistant = EmaiMessage(isUser: false, text: ''); _append(_streamingAssistant!); break; @@ -799,6 +926,7 @@ class EmaiController extends GetxController case BailianEventType.error: Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}'); _abortTalk(); + if (halfDuplexMic) _openMicGate(); if (e.fatal) { // 会话真的废了(task-failed / 连不上 / 没配置):停掉握手看门狗, // 状态条转成可重连的错误态 @@ -835,6 +963,7 @@ class EmaiController extends GetxController // 断线时麦克风还开着的话必须放掉,否则会一直占用录音设备 _abortTalk(); _player.stopNow(); + if (halfDuplexMic) _openMicGate(); if (linkState.value == EmaiLinkState.ready) { linkState.value = EmaiLinkState.idle; // 网络抖一下就掉的情况很常见,自动补一次;再断才让用户手动点