|
|
@ -1,4 +1,5 @@ |
|
|
import 'dart:async'; |
|
|
import 'dart:async'; |
|
|
|
|
|
import 'dart:typed_data'; |
|
|
|
|
|
|
|
|
import 'package:flutter/material.dart'; |
|
|
import 'package:flutter/material.dart'; |
|
|
import 'package:get/get.dart'; |
|
|
import 'package:get/get.dart'; |
|
|
@ -45,9 +46,17 @@ class EmaiController extends GetxController |
|
|
/// 传了才用传的——给「同一套对话 UI、不同百炼应用」的 agent 复用(如 EaiCar |
|
|
/// 传了才用传的——给「同一套对话 UI、不同百炼应用」的 agent 复用(如 EaiCar |
|
|
/// 车载助手)。两个 agent 各自 new 一个 service 实例,不能共用,理由见 |
|
|
/// 车载助手)。两个 agent 各自 new 一个 service 实例,不能共用,理由见 |
|
|
/// [BailianMultimodalService] 的类注释。 |
|
|
/// [BailianMultimodalService] 的类注释。 |
|
|
EmaiController({BailianMultimodalService? service}) |
|
|
EmaiController({BailianMultimodalService? service, this.halfDuplexMic = false}) |
|
|
: _svc = service ?? Get.find<BailianMultimodalService>(); |
|
|
: _svc = service ?? Get.find<BailianMultimodalService>(); |
|
|
|
|
|
|
|
|
|
|
|
/// 手机麦半双工:AI 播报期间上行换成等长静音,本地播完再放开(2026-09-26)。 |
|
|
|
|
|
/// |
|
|
|
|
|
/// 只有车载香薰页开([EaiCarChatBinding] 传 true),与设备那条链路的门控口径一致: |
|
|
|
|
|
/// 外放 + 两台设备互聊时,AEC 挡不住对方/自己的声音,服务端 VAD 会把它当成插话, |
|
|
|
|
|
/// 一轮没说完就被打断,下一轮的识别和回复插进来,字幕气泡互相串。 |
|
|
|
|
|
/// EMAI 页保持原样(靠 AEC 全双工,用户可以随时插话)。 |
|
|
|
|
|
final bool halfDuplexMic; |
|
|
|
|
|
|
|
|
static const String _tag = 'EmaiController'; |
|
|
static const String _tag = 'EmaiController'; |
|
|
|
|
|
|
|
|
final BailianMultimodalService _svc; |
|
|
final BailianMultimodalService _svc; |
|
|
@ -129,6 +138,98 @@ class EmaiController extends GetxController |
|
|
Timer? _handshakeTimer; |
|
|
Timer? _handshakeTimer; |
|
|
static const Duration _handshakeTimeout = Duration(seconds: 15); |
|
|
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); |
|
|
static const Duration _continuousIdleTimeout = Duration(seconds: 10); |
|
|
|
|
|
|
|
|
@ -177,8 +278,17 @@ class EmaiController extends GetxController |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
_subs.add(_svc.events.listen(_onEvent)); |
|
|
_subs.add(_svc.events.listen(_onEvent)); |
|
|
_subs.add(_svc.audioOut.listen(_player.feed)); |
|
|
_subs.add(_svc.audioOut.listen((pcm) { |
|
|
_subs.add(_mic.packets.listen(_svc.pushAudio)); |
|
|
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, |
|
|
// ⚠️ 音浪的门控不能只看 isTalking:连续对话下它恒为 false, |
|
|
// 麦克风明明开着,界面上却一点动静都没有,用户完全看不出在不在收音。 |
|
|
// 麦克风明明开着,界面上却一点动静都没有,用户完全看不出在不在收音。 |
|
|
_subs.add(_mic.level.listen((v) { |
|
|
_subs.add(_mic.level.listen((v) { |
|
|
@ -191,6 +301,8 @@ class EmaiController extends GetxController |
|
|
// 告诉服务端本地播报结束,它才会把对话状态推回 Listening/Idle |
|
|
// 告诉服务端本地播报结束,它才会把对话状态推回 Listening/Idle |
|
|
_svc.notifyLocalRespondingEnded(); |
|
|
_svc.notifyLocalRespondingEnded(); |
|
|
level.value = 0; |
|
|
level.value = 0; |
|
|
|
|
|
// 真正播完再开麦,多等一点让外放的尾音散掉,别被自己的麦收回去 |
|
|
|
|
|
if (halfDuplexMic) _openMicGate(after: _micGateTail); |
|
|
})); |
|
|
})); |
|
|
|
|
|
|
|
|
_jumpToBottomOnEnter(); |
|
|
_jumpToBottomOnEnter(); |
|
|
@ -225,6 +337,8 @@ class EmaiController extends GetxController |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
_handshakeTimer?.cancel(); |
|
|
_handshakeTimer?.cancel(); |
|
|
|
|
|
_micGateOpenTimer?.cancel(); |
|
|
|
|
|
_micGateWatchdog?.cancel(); |
|
|
_cancelIdle(); |
|
|
_cancelIdle(); |
|
|
for (final s in _subs) { |
|
|
for (final s in _subs) { |
|
|
s.cancel(); |
|
|
s.cancel(); |
|
|
@ -327,6 +441,7 @@ class EmaiController extends GetxController |
|
|
_autoRetried = true; // 挡掉 closed 事件触发的自动重连,这次断开是有意的 |
|
|
_autoRetried = true; // 挡掉 closed 事件触发的自动重连,这次断开是有意的 |
|
|
await _abortTalk(); |
|
|
await _abortTalk(); |
|
|
await _player.stopNow(); |
|
|
await _player.stopNow(); |
|
|
|
|
|
if (halfDuplexMic) _openMicGate(); |
|
|
await _svc.stop(); |
|
|
await _svc.stop(); |
|
|
linkState.value = EmaiLinkState.onEarphone; |
|
|
linkState.value = EmaiLinkState.onEarphone; |
|
|
} |
|
|
} |
|
|
@ -539,6 +654,7 @@ class EmaiController extends GetxController |
|
|
// 助手还在说话时按下 = 打断:先停本地播放,再让服务端交出发言权 |
|
|
// 助手还在说话时按下 = 打断:先停本地播放,再让服务端交出发言权 |
|
|
if (isResponding) { |
|
|
if (isResponding) { |
|
|
await _player.stopNow(); |
|
|
await _player.stopNow(); |
|
|
|
|
|
if (halfDuplexMic) _openMicGate(); |
|
|
_svc.interrupt(); |
|
|
_svc.interrupt(); |
|
|
} |
|
|
} |
|
|
if (gen != _talkGen) return; |
|
|
if (gen != _talkGen) return; |
|
|
@ -643,6 +759,7 @@ class EmaiController extends GetxController |
|
|
|
|
|
|
|
|
if (isResponding) { |
|
|
if (isResponding) { |
|
|
_player.stopNow(); |
|
|
_player.stopNow(); |
|
|
|
|
|
if (halfDuplexMic) _openMicGate(); |
|
|
_svc.interrupt(); |
|
|
_svc.interrupt(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
@ -687,6 +804,7 @@ class EmaiController extends GetxController |
|
|
Future<void> interrupt() async { |
|
|
Future<void> interrupt() async { |
|
|
if (!isResponding) return; |
|
|
if (!isResponding) return; |
|
|
await _player.stopNow(); |
|
|
await _player.stopNow(); |
|
|
|
|
|
if (halfDuplexMic) _openMicGate(); |
|
|
_svc.interrupt(); |
|
|
_svc.interrupt(); |
|
|
_streamingAssistant?.isFinal = true; |
|
|
_streamingAssistant?.isFinal = true; |
|
|
_streamingAssistant = null; |
|
|
_streamingAssistant = null; |
|
|
@ -759,6 +877,15 @@ class EmaiController extends GetxController |
|
|
break; |
|
|
break; |
|
|
|
|
|
|
|
|
case BailianEventType.respondingStarted: |
|
|
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: ''); |
|
|
_streamingAssistant = EmaiMessage(isUser: false, text: ''); |
|
|
_append(_streamingAssistant!); |
|
|
_append(_streamingAssistant!); |
|
|
break; |
|
|
break; |
|
|
@ -799,6 +926,7 @@ class EmaiController extends GetxController |
|
|
case BailianEventType.error: |
|
|
case BailianEventType.error: |
|
|
Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}'); |
|
|
Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}'); |
|
|
_abortTalk(); |
|
|
_abortTalk(); |
|
|
|
|
|
if (halfDuplexMic) _openMicGate(); |
|
|
if (e.fatal) { |
|
|
if (e.fatal) { |
|
|
// 会话真的废了(task-failed / 连不上 / 没配置):停掉握手看门狗, |
|
|
// 会话真的废了(task-failed / 连不上 / 没配置):停掉握手看门狗, |
|
|
// 状态条转成可重连的错误态 |
|
|
// 状态条转成可重连的错误态 |
|
|
@ -835,6 +963,7 @@ class EmaiController extends GetxController |
|
|
// 断线时麦克风还开着的话必须放掉,否则会一直占用录音设备 |
|
|
// 断线时麦克风还开着的话必须放掉,否则会一直占用录音设备 |
|
|
_abortTalk(); |
|
|
_abortTalk(); |
|
|
_player.stopNow(); |
|
|
_player.stopNow(); |
|
|
|
|
|
if (halfDuplexMic) _openMicGate(); |
|
|
if (linkState.value == EmaiLinkState.ready) { |
|
|
if (linkState.value == EmaiLinkState.ready) { |
|
|
linkState.value = EmaiLinkState.idle; |
|
|
linkState.value = EmaiLinkState.idle; |
|
|
// 网络抖一下就掉的情况很常见,自动补一次;再断才让用户手动点 |
|
|
// 网络抖一下就掉的情况很常见,自动补一次;再断才让用户手动点 |
|
|
|