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.
406 lines
11 KiB
406 lines
11 KiB
import 'dart:async';
|
|
import 'dart:convert';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:get/get.dart';
|
|
import 'package:realtime/realtime.dart';
|
|
|
|
import '../../../core/utils/logger.dart';
|
|
|
|
class RealtimeController extends GetxController with GetTickerProviderStateMixin {
|
|
static const int _sampleRate = 16000;
|
|
|
|
// Realtime服务实例
|
|
final RealtimeService _realtimeService = RealtimeService();
|
|
|
|
// 音频振幅值(RMS)
|
|
final RxDouble audioAmplitude = 0.0.obs;
|
|
|
|
// 连接状态
|
|
final RxBool isConnected = false.obs;
|
|
final RxBool isConnecting = false.obs;
|
|
|
|
// 语音状态
|
|
final RxBool isListening = false.obs;
|
|
final RxBool isSpeaking = false.obs;
|
|
|
|
// UI状态
|
|
final RxString currentStatus = ''.obs; // 移除默认文字
|
|
final RxString userSpeechText = ''.obs;
|
|
final RxBool isUserSpeechFinal = true.obs;
|
|
final RxList<String> aiResponseMessages = <String>[].obs;
|
|
final RxBool isSubtitleMode = true.obs;
|
|
|
|
// 字幕显示用的最新消息
|
|
final RxString latestUserMessage = ''.obs;
|
|
final RxString latestAiMessage = ''.obs;
|
|
|
|
// 实时字幕更新用的临时消息
|
|
final RxString partialUserMessage = ''.obs;
|
|
final RxString streamingAiMessage = ''.obs;
|
|
|
|
// 状态监听
|
|
StreamSubscription<RealtimeEvent>? _eventSubscription;
|
|
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
_setupEventListener();
|
|
|
|
// 自动开始连接
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_autoConnect();
|
|
});
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
_cleanup();
|
|
super.onClose();
|
|
}
|
|
|
|
/// 设置事件监听器
|
|
void _setupEventListener() {
|
|
_eventSubscription = _realtimeService.eventStream.listen(
|
|
(event) {
|
|
_handleRealtimeEvent(event);
|
|
},
|
|
onError: (error) {
|
|
Logger.error('Realtime事件流错误: $error');
|
|
},
|
|
);
|
|
}
|
|
|
|
/// 处理Realtime事件
|
|
void _handleRealtimeEvent(RealtimeEvent event) {
|
|
switch (event.type) {
|
|
case 'connectionStatusChanged':
|
|
_handleConnectionStatusChanged(event.data);
|
|
break;
|
|
case 'voiceStatusChanged':
|
|
_handleVoiceStatusChanged(event.data);
|
|
break;
|
|
case 'textReceived':
|
|
_handleTextReceived(event.data);
|
|
break;
|
|
case 'audioRms':
|
|
_handleAudioRms(event.data);
|
|
break;
|
|
case 'error':
|
|
_handleError(event.data);
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// 自动连接
|
|
Future<void> _autoConnect() async {
|
|
currentStatus.value = ''; // 不显示任何文字
|
|
await connect();
|
|
}
|
|
|
|
/// 处理连接状态变化
|
|
void _handleConnectionStatusChanged(String status) {
|
|
switch (status) {
|
|
case 'connected':
|
|
isConnected.value = true;
|
|
isConnecting.value = false;
|
|
currentStatus.value = ''; // 不显示任何文字
|
|
// 连接成功后自动开始录音
|
|
Timer(const Duration(milliseconds: 500), () {
|
|
startListening();
|
|
});
|
|
break;
|
|
case 'connecting':
|
|
isConnecting.value = true;
|
|
currentStatus.value = ''; // 不显示任何文字
|
|
break;
|
|
case 'disconnected':
|
|
isConnected.value = false;
|
|
isConnecting.value = false;
|
|
currentStatus.value = ''; // 不显示任何文字
|
|
_resetState();
|
|
break;
|
|
case 'error':
|
|
isConnected.value = false;
|
|
isConnecting.value = false;
|
|
currentStatus.value = '连接失败,请检查网络'; // 保留错误提示
|
|
_resetState();
|
|
Logger.error('Vocode服务器连接错误');
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// 处理语音状态变化
|
|
void _handleVoiceStatusChanged(String status) {
|
|
switch (status) {
|
|
case 'recording':
|
|
isListening.value = true;
|
|
isSpeaking.value = false;
|
|
currentStatus.value = ''; // 不显示任何文字
|
|
break;
|
|
case 'processing':
|
|
isListening.value = false;
|
|
currentStatus.value = ''; // 不显示任何文字
|
|
break;
|
|
case 'playing':
|
|
isListening.value = false;
|
|
isSpeaking.value = true;
|
|
currentStatus.value = ''; // 不显示任何文字
|
|
break;
|
|
case 'idle':
|
|
isListening.value = false;
|
|
isSpeaking.value = false;
|
|
currentStatus.value = ''; // 不显示任何文字
|
|
// 空闲时音频振幅归零
|
|
audioAmplitude.value = 0.0;
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// 处理收到的文本消息 - 标准Vocode WebSocket协议
|
|
void _handleTextReceived(dynamic payload) {
|
|
if (payload is String) {
|
|
try {
|
|
// 解析JSON消息
|
|
final Map<String, dynamic> json = jsonDecode(payload);
|
|
final String messageType = json['type'] ?? '';
|
|
|
|
switch (messageType) {
|
|
case 'websocket_transcript':
|
|
// 处理统一的文本消息格式
|
|
final String text = json['text'] ?? '';
|
|
final String sender = json['sender'] ?? '';
|
|
final bool isFinal = json['is_final'] ?? false;
|
|
// final dynamic timestamp = json['timestamp']; // 保留供未来使用
|
|
|
|
if (sender.toLowerCase() == 'human') {
|
|
// 用户语音识别消息
|
|
if (isFinal) {
|
|
// ASR最终结果
|
|
if (text.isNotEmpty) {
|
|
latestUserMessage.value = text;
|
|
userSpeechText.value = text;
|
|
isUserSpeechFinal.value = true;
|
|
}
|
|
// 清空部分消息
|
|
partialUserMessage.value = '';
|
|
} else {
|
|
// ASR实时识别
|
|
if (text.isNotEmpty) {
|
|
partialUserMessage.value = text;
|
|
isUserSpeechFinal.value = false;
|
|
}
|
|
}
|
|
} else if (sender.toLowerCase() == 'bot') {
|
|
// AI回复消息
|
|
if (isFinal) {
|
|
// LLM回复完成标识 (text为空)
|
|
if (streamingAiMessage.value.isNotEmpty) {
|
|
// 将流式消息确认为最终消息
|
|
latestAiMessage.value = streamingAiMessage.value;
|
|
if (aiResponseMessages.isNotEmpty) {
|
|
aiResponseMessages[aiResponseMessages.length - 1] = streamingAiMessage.value;
|
|
} else {
|
|
aiResponseMessages.add(streamingAiMessage.value);
|
|
}
|
|
streamingAiMessage.value = '';
|
|
}
|
|
} else {
|
|
// LLM流式回复
|
|
if (text.isNotEmpty) {
|
|
streamingAiMessage.value += text;
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
|
|
case 'websocket_ready':
|
|
// 连接就绪
|
|
break;
|
|
|
|
case 'websocket_stop':
|
|
// 会话结束
|
|
break;
|
|
|
|
case 'websocket_start':
|
|
// 会话开始
|
|
break;
|
|
|
|
default:
|
|
Logger.error('收到未知消息类型: "$messageType"');
|
|
}
|
|
} catch (e) {
|
|
Logger.error('解析WebSocket消息失败: $e');
|
|
}
|
|
} else {
|
|
Logger.error('收到非字符串类型的payload: ${payload.runtimeType}');
|
|
}
|
|
}
|
|
|
|
/// 处理音频RMS值
|
|
void _handleAudioRms(dynamic rms) {
|
|
if (rms is double || rms is num) {
|
|
// 平滑处理RMS值,避免抖动太大,增大幅度
|
|
final double rawValue = rms.toDouble().clamp(0.0, 1.0);
|
|
// 增强幅度:平方根函数减少小值,对数函数增强大值
|
|
final double enhancedValue = (rawValue * 3.0).clamp(0.0, 1.0);
|
|
// 更强的平滑处理,减少抖动
|
|
audioAmplitude.value = audioAmplitude.value * 0.85 + enhancedValue * 0.15;
|
|
}
|
|
}
|
|
|
|
/// 处理错误
|
|
void _handleError(String error) {
|
|
Logger.error('Realtime错误: $error');
|
|
}
|
|
|
|
/// 切换字幕模式
|
|
void toggleSubtitleMode() {
|
|
isSubtitleMode.value = !isSubtitleMode.value;
|
|
}
|
|
|
|
/// 清空聊天记录
|
|
void clearChat() {
|
|
userSpeechText.value = '';
|
|
isUserSpeechFinal.value = true;
|
|
aiResponseMessages.clear();
|
|
latestUserMessage.value = '';
|
|
latestAiMessage.value = '';
|
|
partialUserMessage.value = '';
|
|
streamingAiMessage.value = '';
|
|
HapticFeedback.lightImpact();
|
|
}
|
|
|
|
/// 连接到AI服务
|
|
Future<void> connect() async {
|
|
if (isConnecting.value || isConnected.value) return;
|
|
|
|
try {
|
|
HapticFeedback.lightImpact();
|
|
|
|
// 初始化Realtime服务
|
|
bool initialized = await _realtimeService.initialize(
|
|
serverUrl: 'ws://192.168.1.11:8000/ws',
|
|
sampleRate: _sampleRate,
|
|
channels: 1,
|
|
bitsPerSample: 16,
|
|
);
|
|
|
|
if (!initialized) {
|
|
throw Exception('初始化Realtime服务失败');
|
|
}
|
|
|
|
// 连接服务器
|
|
final connected = await _realtimeService.connect();
|
|
if (!connected) {
|
|
throw Exception('连接Vocode服务器失败');
|
|
}
|
|
|
|
} catch (e) {
|
|
Logger.error('连接失败: $e');
|
|
isConnecting.value = false;
|
|
_resetState();
|
|
}
|
|
}
|
|
|
|
/// 断开连接
|
|
Future<void> disconnect() async {
|
|
try {
|
|
HapticFeedback.mediumImpact();
|
|
|
|
if (isListening.value) {
|
|
await _realtimeService.stopRecording();
|
|
}
|
|
|
|
await _realtimeService.stopPlaying();
|
|
await _realtimeService.disconnect();
|
|
|
|
} catch (e) {
|
|
Logger.error('断开连接失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 开始聆听
|
|
void startListening() {
|
|
if (!isConnected.value || isListening.value) return;
|
|
|
|
HapticFeedback.lightImpact();
|
|
|
|
_realtimeService.startRecording().then((success) {
|
|
if (!success) {
|
|
Logger.error('开始录音失败');
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 停止聆听
|
|
void stopListening() {
|
|
if (!isListening.value) return;
|
|
|
|
_realtimeService.stopRecording().then((success) {
|
|
if (!success) {
|
|
Logger.error('停止录音失败');
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 切换聆听状态
|
|
void toggleListening() {
|
|
if (!isConnected.value) return;
|
|
|
|
if (isListening.value) {
|
|
stopListening();
|
|
} else {
|
|
startListening();
|
|
}
|
|
}
|
|
|
|
/// 关闭并返回上一页
|
|
Future<void> closeAndGoBack() async {
|
|
Get.back();
|
|
|
|
// 异步清理资源
|
|
Future.delayed(Duration.zero, () async {
|
|
try {
|
|
if (isListening.value) {
|
|
await _realtimeService.stopRecording();
|
|
}
|
|
await _realtimeService.stopPlaying();
|
|
await _realtimeService.disconnect();
|
|
await _realtimeService.dispose();
|
|
_resetState();
|
|
} catch (e) {
|
|
Logger.error('关闭连接失败: $e');
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 重置状态
|
|
void _resetState() {
|
|
isConnected.value = false;
|
|
isConnecting.value = false;
|
|
isListening.value = false;
|
|
isSpeaking.value = false;
|
|
userSpeechText.value = '';
|
|
isUserSpeechFinal.value = true;
|
|
aiResponseMessages.clear();
|
|
latestUserMessage.value = '';
|
|
latestAiMessage.value = '';
|
|
partialUserMessage.value = '';
|
|
streamingAiMessage.value = '';
|
|
audioAmplitude.value = 0.0;
|
|
}
|
|
|
|
/// 清理资源
|
|
void _cleanup() async {
|
|
_eventSubscription?.cancel();
|
|
|
|
try {
|
|
await _realtimeService.dispose();
|
|
} catch (e) {
|
|
Logger.error('清理Realtime服务失败: $e');
|
|
}
|
|
}
|
|
}
|