You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

652 lines
23 KiB

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:haptic_feedback/haptic_feedback.dart';
import '../../../core/utils/logger.dart';
import '../../../data/models/appconfig.dart';
import '../../../data/models/user_Info.dart' as user_info;
import '../../../data/services/assistant_directive_service.dart';
import '../../../data/services/bailian_multimodal_service.dart';
import '../../../data/models/emai_message.dart';
import '../../../data/services/bes_ai_session_service.dart';
import '../../../data/services/emai_chat_store.dart';
import '../../../data/utils/mic_pcm_streamer.dart';
import '../../../data/utils/pcm_stream_player.dart';
/// 会话链路状态(跟 [BailianDialogState] 是两回事:这个说的是"连上了没",
/// 那个说的是"连上之后现在在听/在想/在答")
enum EmaiLinkState {
idle,
connecting,
ready,
failed,
unconfigured,
/// 耳机按键唤醒了 AI,会话让给耳机了;耳机那边结束后本页自动接回来
onEarphone,
}
/// EMAI 助手控制器。
///
/// 整条链路只有一个外部依赖——[BailianMultimodalService](阿里百炼多模态交互协议),
/// ASR / LLM / TTS 全在服务端完成。客户端要做的三件事:
/// 1. 上行:麦克风 PCM([MicPcmStreamer])按 push2talk 的 SendSpeech/StopSpeech 划段推上去
/// 2. 下行:服务端推回的 PCM 交给 [PcmStreamPlayer] 边收边播
/// 3. 文本:SpeechContent / RespondingContent 流式拼进消息列表
class EmaiController extends GetxController
with WidgetsBindingObserver
implements EmaiSessionOwner {
static const String _tag = 'EmaiController';
static EmaiController get to => Get.find();
final BailianMultimodalService _svc = Get.find<BailianMultimodalService>();
final MicPcmStreamer _mic =
MicPcmStreamer(sampleRate: BailianMultimodalService.upstreamSampleRate);
final PcmStreamPlayer _player = PcmStreamPlayer(
sampleRate: BailianMultimodalService.downstreamSampleRate);
// ---------------- UI 状态 ----------------
/// 对话记录来自共享 store:耳机按键唤醒的那些轮次也在里面,
/// 页面没打开时发生的对话,打开后照样看得到
final EmaiChatStore _store = Get.find<EmaiChatStore>();
RxList<EmaiMessage> get messages => _store.messages;
final linkState = EmaiLinkState.idle.obs;
/// 服务端对话状态:聆听 / 思考 / 回答
Rx<BailianDialogState> get dialogState => _svc.state;
/// 是否处于"按住说话"中
final isTalking = false.obs;
/// 手指是否已滑出按钮区域(松手将取消这段语音)
final isCancelPending = false.obs;
/// 音浪振幅(0~1):说话时取麦克风,回答时取下行音频
final level = 0.0.obs;
/// 文字输入模式 / 语音输入模式
final isTextMode = false.obs;
final hasInputText = false.obs;
final TextEditingController textController = TextEditingController();
final ScrollController scrollController = ScrollController();
/// 用户是不是手动往上翻历史去了。
/// 翻上去之后就别再把他拽回底部,直到他自己滑回底部附近。
final isPinnedToBottom = true.obs;
/// 距底部多少像素以内算"还贴着底"
static const double _bottomSlack = 80;
bool get isResponding =>
dialogState.value == BailianDialogState.responding ||
dialogState.value == BailianDialogState.thinking;
bool get canTalk => linkState.value == EmaiLinkState.ready;
// ---------------- 内部 ----------------
final _subs = <StreamSubscription>[];
Worker? _storeWorker;
/// Start 发出去之后等 Started 事件的看门狗。
/// 服务端偶尔会既不回 Started 也不回 task-failed(鉴权被网关拦掉时),
/// 没有这个定时器 UI 会永远停在"正在连接",连重连入口都点不到。
Timer? _handshakeTimer;
static const Duration _handshakeTimeout = Duration(seconds: 15);
/// 连上之后被动断线时自动重连一次;再断就交给用户手动点
bool _autoRetried = false;
/// 一次"按住说话"的代际号。
///
/// [startTalk] 里要 await 麦克风启动,用户完全可能在这期间就松手了;
/// 没有这个号的话,await 回来后仍会补发 SendSpeech —— 排在 StopSpeech 之后,
/// 服务端的语音段就错位了,而且麦克风开着再没人去关。
int _talkGen = 0;
/// SendSpeech 已发出、还没 StopSpeech/CancelSpeech 收尾。
/// 没开过段就不该发收尾指令,否则服务端会收到一条无主的 StopSpeech。
bool _speechOpen = false;
/// 当前正在流式接收的两条消息(收完就置空)
EmaiMessage? _streamingUser;
EmaiMessage? _streamingAssistant;
@override
void onInit() {
super.onInit();
WidgetsBinding.instance.addObserver(this);
textController.addListener(() {
hasInputText.value = textController.text.trim().isNotEmpty;
});
scrollController.addListener(_onScroll);
// 耳机会话是往同一个 store 里写的(页面开着时耳机也可能在聊),
// 那条路径不经过本控制器的事件处理,得单独盯着 store 才能跟到底
_storeWorker = ever(_store.messages, (_) => _scrollToBottom());
// 让 BesAiSessionService 能在耳机唤醒时找到本页并要求让出会话,
// 同时避免 data 层反向 import modules 层。
// 用普通引用而不是 Get.put<EmaiSessionOwner>(this):后者会让 GetX
// 再跑一次 onStart→onInit,而我们就在 onInit 里,直接递归爆栈。
if (Get.isRegistered<BesAiSessionService>()) {
BesAiSessionService.to.attachPhonePage(this);
}
_subs.add(_svc.events.listen(_onEvent));
_subs.add(_svc.audioOut.listen(_player.feed));
_subs.add(_mic.packets.listen(_svc.pushAudio));
_subs.add(_mic.level.listen((v) {
if (isTalking.value) level.value = v;
}));
_subs.add(_player.level.listen((v) {
if (!isTalking.value) level.value = v;
}));
_subs.add(_player.onCompleted.listen((_) {
// 告诉服务端本地播报结束,它才会把对话状态推回 Listening/Idle
_svc.notifyLocalRespondingEnded();
level.value = 0;
}));
_jumpToBottomOnEnter();
connect();
}
@override
void onClose() {
WidgetsBinding.instance.removeObserver(this);
if (Get.isRegistered<BesAiSessionService>()) {
BesAiSessionService.to.detachPhonePage(this);
}
_handshakeTimer?.cancel();
for (final s in _subs) {
s.cancel();
}
_subs.clear();
_mic.dispose();
_player.dispose();
_svc.stop();
_storeWorker?.dispose();
textController.dispose();
scrollController.removeListener(_onScroll);
scrollController.dispose();
super.onClose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
// 退到后台时松开麦克风:iOS 后台录音要额外能力,Android 上也没必要占着
if (state == AppLifecycleState.paused ||
state == AppLifecycleState.inactive) {
if (isTalking.value) _abortTalk();
}
}
// ---------------- 连接 ----------------
Future<void> connect() async {
if (linkState.value == EmaiLinkState.connecting) return;
// 耳机会话正在跑:本页不去抢,等它结束后 resumeFromEarphone 会接回来
if (linkState.value == EmaiLinkState.onEarphone) return;
if (Get.isRegistered<BesAiSessionService>() &&
BesAiSessionService.to.isActive.value) {
linkState.value = EmaiLinkState.onEarphone;
return;
}
if (!AppConfig.isInitialized() || !_svc.isConfigured) {
linkState.value = EmaiLinkState.unconfigured;
Logger.w(_tag, 'EMAI 助手未配置:${_svc.configHint}');
return;
}
linkState.value = EmaiLinkState.connecting;
final ok = await _svc.start(
mode: 'push2talk',
userId: _currentUserId(),
);
if (!ok) {
linkState.value = EmaiLinkState.failed;
return;
}
// ok == true 只代表 Start 发出去了,真正就绪要等 Started 事件回来
_handshakeTimer?.cancel();
_handshakeTimer = Timer(_handshakeTimeout, () {
if (linkState.value != EmaiLinkState.connecting) return;
Logger.e(_tag, '握手超时:${_handshakeTimeout.inSeconds}s 内没有收到 Started');
linkState.value = EmaiLinkState.failed;
_pushError('emaiConnectFailed'.tr);
});
}
Future<void> reconnect() async {
_handshakeTimer?.cancel();
_autoRetried = false;
await _abortTalk();
await _svc.stop();
linkState.value = EmaiLinkState.idle;
await connect();
}
// ---------------- 与耳机 AI 会话互斥 ----------------
@override
Future<void> yieldToEarphone() async {
_handshakeTimer?.cancel();
_handshakeTimer = null;
_autoRetried = true; // 挡掉 closed 事件触发的自动重连,这次断开是有意的
await _abortTalk();
await _player.stopNow();
await _svc.stop();
linkState.value = EmaiLinkState.onEarphone;
}
/// 手机页面主动把控制权要回来:让耳机退出 AI 模式。
///
/// 收尾是异步的——[BesAiSessionService.requestStop] 发完 `AA 65` 要等耳机回
/// `0xE5`(或 2s 超时),它的 teardown 会回调 [resumeFromEarphone] 把本页接回来,
/// 所以这里不用自己 connect(),否则会和耳机会话抢。
Future<void> takeBackFromEarphone() async {
if (!Get.isRegistered<BesAiSessionService>()) {
// 服务没注册(理论上不会),至少别让页面卡在 onEarphone
linkState.value = EmaiLinkState.idle;
await connect();
return;
}
linkState.value = EmaiLinkState.connecting;
await BesAiSessionService.to.requestStop();
}
@override
Future<void> resumeFromEarphone() async {
// onEarphone = 耳机自己结束了;connecting = 本页刚点了"收回控制"。
// 其它状态说明页面已经在正常跑,不要打断。
if (linkState.value != EmaiLinkState.onEarphone &&
linkState.value != EmaiLinkState.connecting) {
return;
}
linkState.value = EmaiLinkState.idle;
_autoRetried = false;
await connect();
}
String _currentUserId() {
try {
return user_info.User.isLoggedIn()
? user_info.User.instance.uid
: 'eaimar-guest';
} catch (_) {
return 'eaimar-guest';
}
}
// ---------------- 语音交互(push2talk) ----------------
Future<void> startTalk() async {
if (!canTalk || isTalking.value) return;
// isTalking 必须在任何 await 之前同步置位。
// 否则快按快放时 endTalk() 会看到 false 直接 return,而这边 await 回来后
// 照样把麦克风开起来 —— 按钮永远停在"松开发送",麦克风再没人关得掉。
final gen = ++_talkGen;
isTalking.value = true;
isCancelPending.value = false;
_buzz(HapticsType.light);
// 助手还在说话时按下 = 打断:先停本地播放,再让服务端交出发言权
if (isResponding) {
await _player.stopNow();
_svc.interrupt();
}
if (gen != _talkGen) return;
if (!await _mic.hasPermission()) {
if (gen == _talkGen) {
isTalking.value = false;
_pushError('emaiMicPermission'.tr);
}
return;
}
if (gen != _talkGen) return;
// 先把麦克风拉起来再通知服务端开始收音:反过来的话,
// SendSpeech 到首包音频之间的空档会被服务端 VAD 当成静音
final started = await _mic.start();
// 启动期间已经松手:把刚拉起来的麦克风关掉就完事,
// 这一段语音当没发生过,不要再向服务端开段
if (gen != _talkGen) {
await _mic.stop();
return;
}
if (!started) {
isTalking.value = false;
_pushError('emaiMicPermission'.tr);
return;
}
_svc.beginSpeech();
_speechOpen = true;
}
/// 手指滑出/滑回按钮区域
void updateCancelPending(bool pending) {
if (!isTalking.value) return;
if (isCancelPending.value == pending) return;
isCancelPending.value = pending;
// 越过取消线时给一下反馈,用户不用低头看也知道松手会取消
_buzz(HapticsType.warning);
}
Future<void> endTalk() {
if (!isTalking.value) return Future.value();
final cancelled = isCancelPending.value;
_buzz(cancelled ? HapticsType.warning : HapticsType.light);
return _finishTalk(cancel: cancelled);
}
/// 异常路径下的收手(切后台、断线、页面销毁):一律当取消处理
Future<void> _abortTalk() {
if (!isTalking.value) return Future.value();
return _finishTalk(cancel: true);
}
Future<void> _finishTalk({required bool cancel}) async {
// 先推进代际:还卡在 _mic.start() 里的那次 startTalk 会因此作废
_talkGen++;
isTalking.value = false;
isCancelPending.value = false;
level.value = 0;
// 先停麦克风,它会把不足一包的尾音补发出去,再发收尾指令,
// 保证最后半个字也进了服务端
await _mic.stop();
if (_speechOpen) {
_speechOpen = false;
if (cancel) {
_svc.cancelSpeech();
} else {
_svc.endSpeech();
}
}
}
// ---------------- 文字交互 ----------------
void sendTextMessage() {
final text = textController.text.trim();
if (text.isEmpty || !canTalk) return;
textController.clear();
hasInputText.value = false;
if (isResponding) {
_player.stopNow();
_svc.interrupt();
}
// 服务端会把这条 prompt 当成"用户说的话"再回一遍 SpeechContent。
// 把本地这条挂到 _streamingUser 上,回显就并进同一个气泡,
// 否则同一句话会在列表里出现两次。
final bubble = EmaiMessage(isUser: true, text: text, isFinal: true);
_store.append(bubble);
_scrollToBottom(force: true); // 自己发的消息一定要看到
_streamingUser = bubble;
_svc.sendText(text);
}
/// 打断当前回复
Future<void> interrupt() async {
if (!isResponding) return;
await _player.stopNow();
_svc.interrupt();
_streamingAssistant?.isFinal = true;
_streamingAssistant = null;
messages.refresh();
}
void toggleInputMode() {
isTextMode.value = !isTextMode.value;
if (!isTextMode.value) {
FocusManager.instance.primaryFocus?.unfocus();
}
}
void clearMessages() {
_store.clear();
_streamingUser = null;
_streamingAssistant = null;
}
// ---------------- 事件处理 ----------------
void _onEvent(BailianEvent e) {
switch (e.type) {
case BailianEventType.started:
_handshakeTimer?.cancel();
_handshakeTimer = null;
_autoRetried = false;
// 新会话,上一条连接里没收尾的语音段跟着作废
_speechOpen = false;
linkState.value = EmaiLinkState.ready;
break;
case BailianEventType.speechStarted:
// 这里**不**预先建气泡:ASR 一句都没识别出来时,服务端回的是
// Error("No speech recognized from audio!")而不是 SpeechEnded,
// 预建的空气泡就没人清理,会永久留在列表里。
// 真正的气泡等第一个 SpeechContent 到了再懒建(见下面的分支)。
_streamingUser = null;
break;
case BailianEventType.speechContent:
_streamingUser ??= () {
final m = EmaiMessage(isUser: true, text: '');
_append(m);
return m;
}();
_mergeInto(_streamingUser!, e.text);
if (e.isFinal) {
_streamingUser!.isFinal = true;
_streamingUser = null;
}
messages.refresh();
break;
case BailianEventType.speechEnded:
final m = _streamingUser;
if (m != null) {
if (e.text.isNotEmpty) _mergeInto(m, e.text);
// ASR 一个字都没出来(误触/纯噪声):把空气泡撤掉,别留一条空消息
_store.finalize(m);
_streamingUser = null;
}
break;
case BailianEventType.respondingStarted:
_streamingAssistant = EmaiMessage(isUser: false, text: '');
_append(_streamingAssistant!);
break;
case BailianEventType.respondingContent:
_dispatchDirectives(e);
// finish_reason=command_calls 的纯指令帧 text 是空的,而且这一轮
// 不会再来 RespondingStarted/Ended。照常建气泡的话会在列表里留一个
// 永远填不上的空泡(就是聊天记录里那几个孤零零的 “• • •”)。
if (e.text.isEmpty && e.finishReason == 'command_calls') break;
_streamingAssistant ??= () {
final m = EmaiMessage(isUser: false, text: '');
_append(m);
return m;
}();
_mergeInto(_streamingAssistant!, e.text);
messages.refresh();
_scrollToBottom();
break;
case BailianEventType.respondingEnded:
final m = _streamingAssistant;
if (m != null) {
if (e.text.isNotEmpty) _mergeInto(m, e.text);
_store.finalize(m);
_streamingAssistant = null;
}
// 文本收完不代表音频收完,但下行音频到此为止,让播放器把缓冲播完
_player.end();
break;
case BailianEventType.stateChanged:
// dialogState 直接绑的是 service 里的 Rx,这里只需要驱动一次滚动
_scrollToBottom();
break;
case BailianEventType.error:
Logger.e(_tag, '会话错误: ${e.code} ${e.text} fatal=${e.fatal}');
_abortTalk();
if (e.fatal) {
// 会话真的废了(task-failed / 连不上 / 没配置):停掉握手看门狗,
// 状态条转成可重连的错误态
_handshakeTimer?.cancel();
_handshakeTimer = null;
linkState.value = e.code == 'NOT_CONFIGURED'
? EmaiLinkState.unconfigured
: EmaiLinkState.failed;
} else {
// 轮次级错误(音色不对、没识别到语音、模型超时之类):连接还在,
// 把这一轮的流式气泡收干净就行,不要把整页判成"连接失败"
_player.stopNow();
_dropIfEmpty(_streamingUser);
_streamingUser = null;
_dropIfEmpty(_streamingAssistant);
_streamingAssistant?.isFinal = true;
_streamingAssistant = null;
messages.refresh();
}
// 只有致命错误才在对话里留一条红色提示。
//
// 轮次级错误("No speech recognized from audio!"、音色不对、模型超时之类)
// 属于正常噪声:按住说话没出声、误触都会触发,连接还好好的。上面那段已经
// 把这一轮的空气泡收干净了,再塞一条红色英文报错只会吓到用户,
// 而且它会永久留在聊天记录里。这类错误只记日志(本分支开头已记)。
if (e.fatal) {
_pushError(e.text.isEmpty ? 'emaiConnectFailed'.tr : e.text);
}
break;
case BailianEventType.closed:
_handshakeTimer?.cancel();
_handshakeTimer = null;
// 断线时麦克风还开着的话必须放掉,否则会一直占用录音设备
_abortTalk();
_player.stopNow();
if (linkState.value == EmaiLinkState.ready) {
linkState.value = EmaiLinkState.idle;
// 网络抖一下就掉的情况很常见,自动补一次;再断才让用户手动点
if (!_autoRetried) {
_autoRetried = true;
Logger.i(_tag, '连接断开,自动重连一次');
connect();
}
}
break;
case BailianEventType.requestAccepted:
break;
}
}
/// 把流式文本写进气泡。
///
/// 一律整体替换,不做任何拼接。会话是按 `incremental_response=false` 起的
/// (见 [BailianMultimodalService.start]),每一帧回的都是"到目前为止的全文"。
///
/// 之前这里用「新文本以旧文本开头就替换,否则追加」来两种都兼容,
/// 结果在 ASR 上翻车了:识别中间结果是会**回改**的,
/// "That's right" 下一帧可能变成 "Thats right",不构成前缀就被当增量拼上去,
/// 真机上拼出了 "ThatThat's rightThatThat's right" 这种东西。
/// 把本帧携带的端侧指令交给 [AssistantDirectiveService] 落地。
///
/// 只做转发:解析在协议层、分发与落库在 service,控制器不碰业务。
void _dispatchDirectives(BailianEvent e) {
if (e.directives.isEmpty) return;
if (!Get.isRegistered<AssistantDirectiveService>()) {
Logger.w(_tag, '指令服务未注册,丢弃 ${e.directives.length} 条指令');
return;
}
AssistantDirectiveService.to.handle(e.directives);
}
void _mergeInto(EmaiMessage m, String incoming) =>
_store.applyText(m, incoming);
void _dropIfEmpty(EmaiMessage? m) => _store.dropIfEmpty(m);
void _append(EmaiMessage m) {
_store.append(m);
_scrollToBottom();
}
void _pushError(String text) {
_append(
EmaiMessage(isUser: false, text: text, isFinal: true, isError: true));
}
/// 流式回复每来一帧就滚一次,用 animateTo 会不停打断上一个动画、
/// 看起来一顿一顿的。这里合并到下一帧再跳,并且只在用户本来就贴着底部时跳——
/// 他往上翻看历史的时候不该被硬拽回来。
bool _scrollScheduled = false;
void _onScroll() {
if (!scrollController.hasClients) return;
final pos = scrollController.position;
// 滑回底部附近就重新跟随;往上翻就停住
isPinnedToBottom.value = pos.maxScrollExtent - pos.pixels <= _bottomSlack;
}
/// 滚到底部。
///
/// [force] = true 用于进入页面和自己发消息这两种情况——那时必须落到最新一条,
/// 不管之前停在哪。其余情况(流式刷新)只在用户还贴着底部时才跟随,
/// 他正在翻历史就别打扰。
void _scrollToBottom({bool force = false}) {
if (!force && !isPinnedToBottom.value) return;
if (_scrollScheduled) return;
_scrollScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollScheduled = false;
if (!scrollController.hasClients) return;
final pos = scrollController.position;
if (!force && !isPinnedToBottom.value) return;
scrollController.jumpTo(pos.maxScrollExtent);
if (force) isPinnedToBottom.value = true;
});
}
/// 「回到最新」按钮
void jumpToLatest() => _scrollToBottom(force: true);
/// 进入页面时直接落到最底部。
///
/// 历史记录可能有上百条,列表默认停在第一行,用户得一路拉到底才看得到
/// 最新内容——这里在首帧之后直接跳到底,往上翻才是去看历史。
void _jumpToBottomOnEnter() {
if (messages.isEmpty) return;
// 首帧布局完成前 maxScrollExtent 还不准,连跳两帧兜住图片/Markdown
// 撑开高度带来的二次布局
for (final delay in const [Duration.zero, Duration(milliseconds: 120)]) {
Future.delayed(delay, () {
if (!scrollController.hasClients) return;
scrollController.jumpTo(scrollController.position.maxScrollExtent);
isPinnedToBottom.value = true;
});
}
}
void _buzz(HapticsType type) {
// 震动失败(设备不支持/被系统禁用)不该影响对话
Haptics.vibrate(type).catchError((_) {});
}
}