Browse Source

client:EaiCar 车载助手手机麦改半双工——AI 播报时静音上行,播完再开;被插话时先收尾上一轮,修字幕串行

Co-authored-by: Cursor <cursoragent@cursor.com>
along-test
Rodger-Wang 2 weeks ago
parent
commit
10779843e9
  1. 2
      apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart
  2. 135
      apps/client/lib/modules/emai/controllers/emai_controller.dart

2
apps/client/lib/modules/eaicar/bindings/eaicar_chat_binding.dart

@ -29,6 +29,8 @@ class EaiCarChatBinding extends Bindings {
Get.lazyPut<EmaiController>(
() => EmaiController(
service: Get.find<BailianMultimodalService>(tag: kEaiCarTag),
// 手机麦也和设备一样半双工:AI 说话时关麦,说完才开
halfDuplexMic: true,
),
tag: kEaiCarTag,
);

135
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<BailianMultimodalService>();
/// 手机麦半双工: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<void> 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;
// 网络抖一下就掉的情况很常见,自动补一次;再断才让用户手动点

Loading…
Cancel
Save