AR-Test-Demo/avatar_flutter_app/lib/voice_chat/voice_chat_controller.dart
zyc 689fa8936b Integrate Volcengine realtime voice + Live2D mouth driving
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-14 15:39:23 +08:00

397 lines
12 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_joinedengine_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();
}
}