import 'dart:async'; import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import '../avatar_bridge.dart'; import 'chat_message.dart'; import 'secrets.dart'; import 'speech_engine_plugin.dart'; enum VoiceChatState { idle, listening, thinking, speaking, error } /// 把火山实时语音 SDK 的事件流编排成 avatar 状态机 + /// 把消息流转交给 UI。 /// /// 按住说话生命周期: /// 按下: /// - 若 state==speaking 先 cancelCurrentDialog (打断) /// - startTalking + 进 listening /// 抬起: /// - finishTalking + 进 thinking(最少 600ms 守底) /// 收到 ai_voice_begin 或 tts_start: 进 speaking /// 收到 ai_voice_end 或 tts_finish: 回 idle class VoiceChatController extends ChangeNotifier { final SpeechEnginePlugin _plugin = SpeechEnginePlugin(); final AvatarBridge bridge; VoiceChatController({required this.bridge}); StreamSubscription>? _eventSub; VoiceChatState _state = VoiceChatState.idle; VoiceChatState get state => _state; final List _messages = []; List get messages => List.unmodifiable(_messages); bool _engineReady = false; bool get engineReady => _engineReady; bool _simulatorMode = false; bool get simulatorMode => _simulatorMode; /// 已加入 RTC 房间(channel_joined 触发后才为 true) bool _connected = false; bool get connected => _connected; /// 正在试图建立会话(startSession 已调用但还没 channel_joined) bool _connecting = false; bool get connecting => _connecting; bool _helloFired = false; String? _lastError; String? get lastError => _lastError; /// 最近 30 条原始 SDK 事件(用于调试 UI 显示) final List _debugLog = []; List get debugLog => List.unmodifiable(_debugLog); DateTime? _thinkingStartedAt; Timer? _thinkingMinTimer; Timer? _thinkingTimeoutTimer; static const _thinkingMinDuration = Duration(milliseconds: 250); static const _thinkingTimeout = Duration(seconds: 8); // ---------------------- 生命周期 ---------------------- Future initialize() async { _pushDebug('Controller.initialize()'); _eventSub ??= _plugin.events.listen(_onEvent, onError: _onEventStreamError); try { _pushDebug('→ plugin.init(resource=${VoiceSecrets.dialogId})'); await _plugin.init( appId: VoiceSecrets.appId, appKey: VoiceSecrets.appKey, token: VoiceSecrets.token, dialogId: VoiceSecrets.dialogId, uid: VoiceSecrets.uid, address: VoiceSecrets.address, uri: VoiceSecrets.uri, botName: VoiceSecrets.role.isEmpty ? '豆包' : VoiceSecrets.role, // aec 模型路径留空,让 Plugin 端自己从 Flutter assets 定位 ); _pushDebug('plugin.init 返回成功'); _engineReady = true; notifyListeners(); } on PlatformException catch (e) { if (e.code == 'SIMULATOR_NOT_SUPPORTED') { _simulatorMode = true; _pushDebug('运行于模拟器:SDK 已禁用,仅供 UI 调试'); notifyListeners(); } else { _setError('引擎初始化失败:${e.code} ${e.message ?? ''}'); } } catch (e) { _setError('引擎初始化失败:$e'); } } /// 进入聊天抽屉后调用:启动会话 Future startSession() async { if (_connecting || _connected) return; _pushDebug('Controller.startSession()'); if (!_engineReady && !_simulatorMode) await initialize(); if (_simulatorMode) { _setState(VoiceChatState.idle); return; } if (!_engineReady) { _pushDebug('startSession 中止:引擎未 ready'); return; } _connecting = true; notifyListeners(); try { await _plugin.start(); _setState(VoiceChatState.idle); } catch (e) { _connecting = false; notifyListeners(); _setError('会话启动失败:$e'); } } /// 退出抽屉前调用 Future stopSession() async { _thinkingMinTimer?.cancel(); _thinkingTimeoutTimer?.cancel(); _helloFired = false; _connected = false; _connecting = false; try { await _plugin.stop(); } catch (_) {} _setState(VoiceChatState.idle); notifyListeners(); } /// 手动打断 AI 说话(外部可调用,如点击屏幕) Future interruptAi() async { if (_state != VoiceChatState.speaking) return; try { await _plugin.cancelCurrentDialog(); } catch (_) {} } // ---------------------- 事件处理 ---------------------- Future _maybeFireHello() async { if (_helloFired) return; if (VoiceSecrets.helloText.isEmpty) return; _helloFired = true; try { _pushDebug('→ sayHello "${VoiceSecrets.helloText}"'); await _plugin.sayHello(VoiceSecrets.helloText); } catch (e) { _pushDebug('sayHello 异常 $e'); } } void _pushDebug(String line) { // 仅写入 buffer,不主动 notify。UI 由具体业务事件(消息/状态)触发刷新即可。 final ts = DateTime.now().toString().substring(11, 19); _debugLog.add('[$ts] $line'); if (_debugLog.length > 120) _debugLog.removeAt(0); } void _onEvent(Map evt) { final type = (evt['type'] ?? '').toString(); if (kDebugMode) debugPrint('[VoiceChat] ← $type ${evt.toString().substring(0, evt.toString().length.clamp(0, 200))}'); // 所有事件都进 debug 缓冲,让 release 模式也能在 UI 上看 if (type == 'sdk_log_line' || type == 'plugin_log') { // SDK / Plugin 日志完整显示,不截断 _pushDebug(evt['line']?.toString() ?? evt.toString()); } else { final preview = evt.entries .where((e) => e.key != 'type') .map((e) => '${e.key}=${e.value.toString().length > 80 ? "${e.value.toString().substring(0, 80)}…" : e.value}') .join(' '); _pushDebug('$type${preview.isEmpty ? '' : ' $preview'}'); } switch (type) { case 'engine_start': case 'channel_joined': case 'dialog_begin': // SpeechEngineToB 不一定回 channel_joined;engine_start 已经表示 SDK 在工作 if (!_connected) { _connecting = false; _connected = true; notifyListeners(); _maybeFireHello(); } break; case 'engine_stop': _setState(VoiceChatState.idle); break; case 'engine_error': final code = evt['code']?.toString() ?? '?'; final msg = evt['message']?.toString() ?? ''; final raw = evt['raw']?.toString() ?? ''; _pushDebug('engine_error code=$code\n msg=$msg\n raw=$raw'); _setError('SDK 错误 [$code] $msg\n$raw'); break; case 'subtitle_on': _handleSubtitle(evt['payload']?.toString(), isFinal: false); break; case 'subtitle_off': _handleSubtitle(evt['payload']?.toString(), isFinal: true); break; case 'ai_voice_begin': case 'tts_start': _enterSpeaking(); break; case 'ai_voice_end': case 'tts_finish': _enterIdle(); break; case 'dialog_cancelled': _enterIdle(); break; case 'asr_partial': _handleSubtitle(evt['payload']?.toString(), isFinal: false, forceRole: ChatRole.user); break; case 'asr_final': _handleSubtitle(evt['payload']?.toString(), isFinal: true, forceRole: ChatRole.user); break; case 'mouth': final v = (evt['value'] as num?)?.toDouble() ?? 0; bridge.setMouthOpen(v.clamp(0.0, 1.0)); break; case 'vad_begin': if (_state == VoiceChatState.idle) _setState(VoiceChatState.listening); break; case 'vad_end': // SDK 自动判停 → 服务端开始处理 → 等待 AI 回复 if (_state == VoiceChatState.listening) _enterThinking(); break; case 'dialog_end': case 'dialog_init': case 'sdk_log': case 'sdk_log_line': case 'plugin_log': case 'raw': break; default: break; } } void _onEventStreamError(Object err) { _setError('事件流异常:$err'); } /// SDK 字幕 JSON 一般含 role + text (字段名要根据真机数据调整) /// 兼容几种常见形态。 void _handleSubtitle(String? rawJson, {required bool isFinal, ChatRole? forceRole}) { if (rawJson == null || rawJson.isEmpty) return; Map? data; try { data = jsonDecode(rawJson) as Map; } catch (_) { data = null; } final text = data?['text']?.toString() ?? data?['content']?.toString() ?? data?['subtitle']?.toString() ?? ''; if (text.isEmpty) return; final roleStr = (data?['role'] ?? data?['speaker'] ?? '').toString().toLowerCase(); final role = forceRole ?? (roleStr.contains('user') ? ChatRole.user : ChatRole.assistant); if (role == ChatRole.user) { // 看到 user 字幕意味着用户正在/已经说话 if (_state == VoiceChatState.idle) _setState(VoiceChatState.listening); _appendOrUpdateUser(text, isFinal); } else { _appendOrUpdateAssistant(text, isFinal); // 收到 AI 字幕意味着思考结束、即将开口 if (_state == VoiceChatState.thinking) _enterSpeaking(); } } // ---------------------- 消息列表维护 ---------------------- void _appendOrUpdateUser(String text, bool isFinal) { ChatMessage? target; for (final m in _messages.reversed) { if (m.role == ChatRole.user && !m.isFinal) { target = m; break; } } if (target == null) { _messages.add(ChatMessage(role: ChatRole.user, text: text, isFinal: isFinal)); } else { target.text = text; if (isFinal) target.isFinal = true; } notifyListeners(); } void _appendOrUpdateAssistant(String text, bool isFinal) { ChatMessage? target; for (final m in _messages.reversed) { if (m.role == ChatRole.assistant && !m.isFinal) { target = m; break; } } if (target == null) { _messages.add(ChatMessage(role: ChatRole.assistant, text: text, isFinal: isFinal)); } else { target.text = text; if (isFinal) target.isFinal = true; } notifyListeners(); } // ---------------------- 状态转移 ---------------------- void _setState(VoiceChatState next) { if (_state == next) return; _state = next; _syncAvatar(); notifyListeners(); } void _enterThinking() { _thinkingStartedAt = DateTime.now(); _thinkingMinTimer?.cancel(); _thinkingTimeoutTimer?.cancel(); _thinkingTimeoutTimer = Timer(_thinkingTimeout, () { if (_state == VoiceChatState.thinking) { _pushDebug('thinking timeout → idle (服务端无响应 ${_thinkingTimeout.inSeconds}s)'); _setState(VoiceChatState.idle); } }); _setState(VoiceChatState.thinking); } void _enterSpeaking() { _thinkingTimeoutTimer?.cancel(); final waited = _thinkingStartedAt == null ? Duration.zero : DateTime.now().difference(_thinkingStartedAt!); final remaining = _thinkingMinDuration - waited; if (remaining > Duration.zero && _state == VoiceChatState.thinking) { _thinkingMinTimer?.cancel(); _thinkingMinTimer = Timer(remaining, () => _setState(VoiceChatState.speaking)); } else { _setState(VoiceChatState.speaking); } } void _enterIdle() { _thinkingMinTimer?.cancel(); _thinkingTimeoutTimer?.cancel(); _setState(VoiceChatState.idle); } void _syncAvatar() { switch (_state) { case VoiceChatState.idle: bridge.setState('idle'); bridge.setMouthOpen(0); break; case VoiceChatState.listening: bridge.setState('listening'); bridge.setMouthOpen(0); break; case VoiceChatState.thinking: bridge.setState('thinking'); bridge.setMouthOpen(0); break; case VoiceChatState.speaking: bridge.setState('speaking'); // 嘴型由 Phase 2 的 mouth_value 事件单独驱动 break; case VoiceChatState.error: bridge.setState('idle'); break; } } void _setError(String msg) { _lastError = msg; if (kDebugMode) debugPrint('[VoiceChat] ERROR: $msg'); _setState(VoiceChatState.error); } @override void dispose() { _thinkingMinTimer?.cancel(); _thinkingTimeoutTimer?.cancel(); _eventSub?.cancel(); _plugin.destroy(); super.dispose(); } }