397 lines
12 KiB
Dart
397 lines
12 KiB
Dart
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<Map<String, dynamic>>? _eventSub;
|
||
|
||
VoiceChatState _state = VoiceChatState.idle;
|
||
VoiceChatState get state => _state;
|
||
|
||
final List<ChatMessage> _messages = [];
|
||
List<ChatMessage> 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<String> _debugLog = [];
|
||
List<String> get debugLog => List.unmodifiable(_debugLog);
|
||
|
||
DateTime? _thinkingStartedAt;
|
||
Timer? _thinkingMinTimer;
|
||
Timer? _thinkingTimeoutTimer;
|
||
static const _thinkingMinDuration = Duration(milliseconds: 250);
|
||
static const _thinkingTimeout = Duration(seconds: 8);
|
||
|
||
// ---------------------- 生命周期 ----------------------
|
||
|
||
Future<void> 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<void> 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<void> stopSession() async {
|
||
_thinkingMinTimer?.cancel();
|
||
_thinkingTimeoutTimer?.cancel();
|
||
_helloFired = false;
|
||
_connected = false;
|
||
_connecting = false;
|
||
try {
|
||
await _plugin.stop();
|
||
} catch (_) {}
|
||
_setState(VoiceChatState.idle);
|
||
notifyListeners();
|
||
}
|
||
|
||
/// 手动打断 AI 说话(外部可调用,如点击屏幕)
|
||
Future<void> interruptAi() async {
|
||
if (_state != VoiceChatState.speaking) return;
|
||
try { await _plugin.cancelCurrentDialog(); } catch (_) {}
|
||
}
|
||
|
||
// ---------------------- 事件处理 ----------------------
|
||
|
||
Future<void> _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<String, dynamic> 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<String, dynamic>? data;
|
||
try {
|
||
data = jsonDecode(rawJson) as Map<String, dynamic>;
|
||
} 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();
|
||
}
|
||
}
|