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.
563 lines
18 KiB
563 lines
18 KiB
import 'package:flutter/material.dart';
|
|
import 'package:get/get.dart';
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'package:get_storage/get_storage.dart';
|
|
import '../../../data/services/azure_asr_service.dart';
|
|
import '../../../data/services/volcano_translation_service.dart';
|
|
import '../../../data/services/volcano_tts_api_service.dart';
|
|
// import '../../../core/utils/logger.dart';
|
|
|
|
class TranslationController extends GetxController {
|
|
// Services
|
|
final AzureAsrService _asrService = Get.find<AzureAsrService>();
|
|
final VolcanoTranslationService _translationService = Get.find<VolcanoTranslationService>();
|
|
final VolcanoTtsApiService _ttsService = Get.find<VolcanoTtsApiService>();
|
|
final GetStorage _storage = GetStorage();
|
|
|
|
// Storage key
|
|
static const String _historyKey = 'translation_history';
|
|
static const int _maxHistoryItems = 100; // 最多保存100条历史记录
|
|
|
|
// State variables
|
|
final isRecognizing = false.obs;
|
|
final isTranslating = false.obs;
|
|
final isTtsEnabled = true.obs;
|
|
final recognizedText = ''.obs;
|
|
final translatedText = ''.obs;
|
|
final sourceLanguage = '中文 (Chinese)'.obs;
|
|
final targetLanguage = '英语 (English)'.obs;
|
|
final translationHistory = <TranslationItem>[].obs;
|
|
|
|
// 支持的语言列表 - 改为Map结构,键为显示名称,值为语言代码
|
|
final Map<String, String> supportedLanguages = {
|
|
'中文 (Chinese)': 'zh',
|
|
'英语 (English)': 'en',
|
|
'日语 (Japanese)': 'ja',
|
|
'韩语 (Korean)': 'ko',
|
|
'法语 (French)': 'fr',
|
|
'德语 (German)': 'de',
|
|
'西班牙语 (Spanish)': 'es',
|
|
'意大利语 (Italian)': 'it',
|
|
'俄语 (Russian)': 'ru',
|
|
'泰语 (Thai)': 'th',
|
|
'葡萄牙语 (Portuguese)': 'pt',
|
|
'阿拉伯语 (Arabic)': 'ar',
|
|
'越南语 (Vietnamese)': 'vi',
|
|
'印尼语 (Indonesian)': 'id',
|
|
'印地语 (Hindi)': 'hi',
|
|
'土耳其语 (Turkish)': 'tr',
|
|
'波兰语 (Polish)': 'pl',
|
|
'荷兰语 (Dutch)': 'nl',
|
|
'瑞典语 (Swedish)': 'sv',
|
|
'捷克语 (Czech)': 'cs',
|
|
'希腊语 (Greek)': 'el',
|
|
'罗马尼亚语 (Romanian)': 'ro',
|
|
'匈牙利语 (Hungarian)': 'hu',
|
|
'乌克兰语 (Ukrainian)': 'uk',
|
|
'保加利亚语 (Bulgarian)': 'bg',
|
|
'丹麦语 (Danish)': 'da',
|
|
'芬兰语 (Finnish)': 'fi',
|
|
'挪威语 (Norwegian)': 'no',
|
|
'马来语 (Malay)': 'ms',
|
|
};
|
|
|
|
// Current translation item
|
|
TranslationItem? currentItem;
|
|
|
|
// Subscription for recognition stream
|
|
StreamSubscription? _recognitionSubscription;
|
|
|
|
// Scroll controller for ListView
|
|
final ScrollController scrollController = ScrollController();
|
|
|
|
// Timer for debouncing translation requests
|
|
Timer? _translationDebounceTimer;
|
|
|
|
// 当前会话ID
|
|
String? currentSessionId;
|
|
|
|
@override
|
|
void onInit() {
|
|
super.onInit();
|
|
|
|
// 加载历史记录
|
|
loadTranslationHistory();
|
|
|
|
// Initialize the translation service
|
|
_initTranslationService();
|
|
|
|
// 添加后处理,确保在历史记录加载后滚动到底部
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
// 延迟执行滚动操作,确保视图已完全渲染
|
|
Future.delayed(const Duration(milliseconds: 300), () {
|
|
_scrollToBottom();
|
|
});
|
|
});
|
|
|
|
// Start voice recognition automatically when the page is loaded
|
|
startRecognition();
|
|
}
|
|
|
|
@override
|
|
void onClose() {
|
|
stopRecognition();
|
|
_recognitionSubscription?.cancel();
|
|
_translationDebounceTimer?.cancel();
|
|
scrollController.dispose();
|
|
super.onClose();
|
|
}
|
|
|
|
// Initialize the translation service
|
|
Future<void> _initTranslationService() async {
|
|
try {
|
|
await _translationService.initialize();
|
|
} catch (e) {
|
|
Get.snackbar(
|
|
'Error',
|
|
'Failed to initialize translation service: ${e.toString()}',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Toggle TTS feature
|
|
void toggleTts() {
|
|
isTtsEnabled.toggle();
|
|
}
|
|
|
|
// Start voice recognition
|
|
Future<void> startRecognition() async {
|
|
if (isRecognizing.value) return;
|
|
|
|
try {
|
|
// 生成新的会话ID
|
|
currentSessionId = DateTime.now().millisecondsSinceEpoch.toString();
|
|
|
|
// Initialize a new translation item
|
|
currentItem = TranslationItem(
|
|
sourceText: '',
|
|
translatedText: '',
|
|
sourceLanguage: sourceLanguage.value,
|
|
targetLanguage: targetLanguage.value,
|
|
timestamp: DateTime.now(),
|
|
sessionId: currentSessionId!,
|
|
isFirstInSession: true, // 标记为会话中的第一条
|
|
);
|
|
|
|
// Start the voice recognition service
|
|
final success = await _asrService.startContinuousRecognition();
|
|
|
|
if (success) {
|
|
isRecognizing.value = true;
|
|
|
|
// Reset the recognized text
|
|
recognizedText.value = '';
|
|
translatedText.value = '';
|
|
|
|
// Listen for recognition events
|
|
_recognitionSubscription = _asrService.recognitionStream?.listen((event) {
|
|
switch (event.type) {
|
|
case RecognitionEventType.finalResult:
|
|
if (event.text.isNotEmpty) {
|
|
handleFinalResult(event.text);
|
|
}
|
|
break;
|
|
case RecognitionEventType.recognizing:
|
|
// Update the UI with intermediate results
|
|
if (event.text.isNotEmpty) {
|
|
handleIntermediateResult(event.text);
|
|
}
|
|
break;
|
|
case RecognitionEventType.error:
|
|
Get.snackbar(
|
|
'Error',
|
|
'Recognition error: ${event.error}',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
});
|
|
} else {
|
|
throw Exception('Failed to start continuous recognition');
|
|
}
|
|
} catch (e) {
|
|
isRecognizing.value = false;
|
|
Get.snackbar(
|
|
'Error',
|
|
'Failed to start voice recognition: ${e.toString()}',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Stop voice recognition
|
|
Future<void> stopRecognition() async {
|
|
if (!isRecognizing.value) return;
|
|
|
|
try {
|
|
await _asrService.stopContinuousRecognition();
|
|
_recognitionSubscription?.cancel();
|
|
_recognitionSubscription = null;
|
|
isRecognizing.value = false;
|
|
|
|
// 添加当前项到历史(如果有内容)
|
|
finalizeCurrentItem();
|
|
|
|
// 重置当前会话ID
|
|
currentSessionId = null;
|
|
|
|
// 重置当前显示的文本
|
|
recognizedText.value = '';
|
|
translatedText.value = '';
|
|
} catch (e) {
|
|
Get.snackbar(
|
|
'Error',
|
|
'Failed to stop voice recognition: ${e.toString()}',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Handle intermediate recognition result
|
|
void handleIntermediateResult(String text) {
|
|
if (text.isEmpty) return;
|
|
|
|
// 如果当前没有活跃的翻译项,创建一个新的
|
|
if (currentItem == null) {
|
|
// 如果没有当前会话ID,创建一个新的
|
|
if (currentSessionId == null) {
|
|
currentSessionId = DateTime.now().millisecondsSinceEpoch.toString();
|
|
}
|
|
|
|
currentItem = TranslationItem(
|
|
sourceText: text,
|
|
translatedText: '',
|
|
sourceLanguage: sourceLanguage.value,
|
|
targetLanguage: targetLanguage.value,
|
|
timestamp: DateTime.now(),
|
|
sessionId: currentSessionId!,
|
|
isFirstInSession: translationHistory.isEmpty ||
|
|
translationHistory.last.sessionId != currentSessionId,
|
|
);
|
|
} else {
|
|
// 更新当前翻译项
|
|
currentItem!.sourceText = text;
|
|
}
|
|
|
|
// 实时更新识别文本
|
|
recognizedText.value = text;
|
|
|
|
// Debounce translation requests to avoid too many API calls
|
|
_translationDebounceTimer?.cancel();
|
|
_translationDebounceTimer = Timer(const Duration(milliseconds: 300), () {
|
|
translateText(text, isFinal: false);
|
|
});
|
|
|
|
// Scroll to the bottom to show the latest content
|
|
_scrollToBottom();
|
|
}
|
|
|
|
// Handle final recognition result
|
|
void handleFinalResult(String text) {
|
|
if (text.isEmpty) return;
|
|
|
|
// 更新当前翻译项(如果不存在则创建一个)
|
|
if (currentItem == null) {
|
|
// 如果没有当前会话ID,创建一个新的
|
|
if (currentSessionId == null) {
|
|
currentSessionId = DateTime.now().millisecondsSinceEpoch.toString();
|
|
}
|
|
|
|
currentItem = TranslationItem(
|
|
sourceText: text,
|
|
translatedText: '',
|
|
sourceLanguage: sourceLanguage.value,
|
|
targetLanguage: targetLanguage.value,
|
|
timestamp: DateTime.now(),
|
|
sessionId: currentSessionId!,
|
|
isFirstInSession: translationHistory.isEmpty ||
|
|
translationHistory.last.sessionId != currentSessionId,
|
|
);
|
|
} else {
|
|
currentItem!.sourceText = text;
|
|
}
|
|
|
|
// 更新识别文本
|
|
recognizedText.value = text;
|
|
|
|
// 翻译最终结果 (添加到历史的逻辑已移至translateText方法)
|
|
translateText(text, isFinal: true).then((_) {
|
|
// 作为安全措施,确保当前项被添加到历史中
|
|
// 这是为了处理translateText中可能因为条件不满足而没有添加到历史的情况
|
|
finalizeCurrentItem();
|
|
});
|
|
|
|
// Scroll to the bottom to show the latest content
|
|
_scrollToBottom();
|
|
}
|
|
|
|
// Finalize the current translation item and add it to history
|
|
void finalizeCurrentItem() {
|
|
if (currentItem != null &&
|
|
currentItem!.sourceText.isNotEmpty &&
|
|
currentItem!.translatedText.isNotEmpty &&
|
|
!translationHistory.contains(currentItem)) {
|
|
translationHistory.add(currentItem!);
|
|
saveTranslationHistory();
|
|
currentItem = null;
|
|
}
|
|
}
|
|
|
|
// Handle voice recognition result (legacy method, kept for compatibility)
|
|
void handleVoiceResult(String text) {
|
|
handleFinalResult(text);
|
|
}
|
|
|
|
// Translate the recognized text
|
|
Future<void> translateText(String text, {bool isFinal = true}) async {
|
|
if (text.isEmpty) return;
|
|
|
|
isTranslating.value = true;
|
|
|
|
// 保存当前的翻译项引用,确保异步返回时更新正确的项
|
|
final TranslationItem? itemToUpdate = currentItem;
|
|
|
|
try {
|
|
// 获取源语言和目标语言的代码
|
|
final String sourceCode = supportedLanguages[sourceLanguage.value] ?? 'zh';
|
|
|
|
// 尝试检测输入文本的语言(这里简化处理,实际项目可能需要更复杂的语言检测)
|
|
bool shouldSwapLanguages = false;
|
|
|
|
// 简单的语言检测逻辑 - 检查是否包含中文字符
|
|
final bool containsChinese = RegExp(r'[\u4e00-\u9fa5]').hasMatch(text);
|
|
final bool containsLatin = RegExp(r'[a-zA-Z]').hasMatch(text);
|
|
|
|
// 如果源语言是中文但文本不包含中文字符,且包含拉丁字符,可能需要交换语言
|
|
if (sourceCode == 'zh' && !containsChinese && containsLatin) {
|
|
shouldSwapLanguages = true;
|
|
}
|
|
// 如果源语言不是中文但文本包含中文字符,可能需要交换语言
|
|
else if (sourceCode != 'zh' && containsChinese) {
|
|
shouldSwapLanguages = true;
|
|
}
|
|
|
|
// 调用翻译服务
|
|
final result = await _translationService.translateText(
|
|
text: text,
|
|
sourceLanguage: shouldSwapLanguages ? targetLanguage.value : sourceLanguage.value,
|
|
targetLanguage: shouldSwapLanguages ? sourceLanguage.value : targetLanguage.value,
|
|
);
|
|
|
|
if (result != null) {
|
|
// 实时更新翻译文本
|
|
translatedText.value = result;
|
|
|
|
// 确保更新的是发起翻译请求时的翻译项,而不是当前可能已经变化的翻译项
|
|
if (itemToUpdate != null && itemToUpdate.sourceText == text) {
|
|
itemToUpdate.translatedText = result;
|
|
|
|
// 如果是最终结果,并且当前项仍然是发起请求时的项,则添加到历史
|
|
if (isFinal && itemToUpdate == currentItem) {
|
|
// 检查是否已经存在相同会话ID的项
|
|
bool isFirstInSession = true;
|
|
for (var item in translationHistory) {
|
|
if (item.sessionId == itemToUpdate.sessionId) {
|
|
isFirstInSession = false;
|
|
break;
|
|
}
|
|
}
|
|
|
|
// 创建一个新的TranslationItem,包含正确的isFirstInSession值
|
|
final updatedItem = TranslationItem(
|
|
sourceText: itemToUpdate.sourceText,
|
|
translatedText: itemToUpdate.translatedText,
|
|
sourceLanguage: itemToUpdate.sourceLanguage,
|
|
targetLanguage: itemToUpdate.targetLanguage,
|
|
timestamp: itemToUpdate.timestamp,
|
|
sessionId: itemToUpdate.sessionId,
|
|
isFirstInSession: isFirstInSession,
|
|
);
|
|
|
|
translationHistory.add(updatedItem);
|
|
saveTranslationHistory();
|
|
currentItem = null; // 重要:将当前项设为null,表示已完成
|
|
|
|
// 重置当前显示的文本,避免与历史项重复
|
|
recognizedText.value = '';
|
|
translatedText.value = '';
|
|
}
|
|
|
|
// Play TTS if enabled and this is a final result
|
|
if (isTtsEnabled.value && isFinal) {
|
|
playTranslatedText(result);
|
|
}
|
|
}
|
|
}
|
|
} catch (e) {
|
|
Get.snackbar(
|
|
'Error',
|
|
'Translation failed: ${e.toString()}',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
} finally {
|
|
isTranslating.value = false;
|
|
}
|
|
}
|
|
|
|
// Play the translated text using TTS
|
|
Future<void> playTranslatedText(String text) async {
|
|
if (text.isEmpty) return;
|
|
|
|
try {
|
|
await _ttsService.speakSingle(text);
|
|
} catch (e) {
|
|
Get.snackbar(
|
|
'Error',
|
|
'Failed to play translation: ${e.toString()}',
|
|
snackPosition: SnackPosition.BOTTOM,
|
|
);
|
|
}
|
|
}
|
|
|
|
// Play a specific translation item
|
|
Future<void> playTranslationItem(TranslationItem item) async {
|
|
await playTranslatedText(item.translatedText);
|
|
}
|
|
|
|
// Scroll to the bottom of the list
|
|
void _scrollToBottom() {
|
|
if (scrollController.hasClients) {
|
|
try {
|
|
scrollController.animateTo(
|
|
scrollController.position.maxScrollExtent,
|
|
duration: const Duration(milliseconds: 300),
|
|
curve: Curves.easeOut,
|
|
);
|
|
} catch (e) {
|
|
// 如果滚动失败,尝试延迟后再次滚动
|
|
Future.delayed(const Duration(milliseconds: 200), () {
|
|
if (scrollController.hasClients) {
|
|
scrollController.jumpTo(scrollController.position.maxScrollExtent);
|
|
}
|
|
});
|
|
}
|
|
} else {
|
|
// 如果scrollController还没有客户端,延迟后再尝试
|
|
Future.delayed(const Duration(milliseconds: 200), _scrollToBottom);
|
|
}
|
|
}
|
|
|
|
// 切换源语言和目标语言
|
|
void swapLanguages() {
|
|
final temp = sourceLanguage.value;
|
|
sourceLanguage.value = targetLanguage.value;
|
|
targetLanguage.value = temp;
|
|
|
|
// 如果正在识别,需要停止并重新开始
|
|
if (isRecognizing.value) {
|
|
stopRecognition();
|
|
startRecognition();
|
|
}
|
|
}
|
|
|
|
// 设置源语言
|
|
void setSourceLanguage(String language) {
|
|
if (sourceLanguage.value != language) {
|
|
sourceLanguage.value = language;
|
|
|
|
// 如果正在识别,需要停止并重新开始
|
|
if (isRecognizing.value) {
|
|
stopRecognition();
|
|
startRecognition();
|
|
}
|
|
}
|
|
}
|
|
|
|
// 设置目标语言
|
|
void setTargetLanguage(String language) {
|
|
if (targetLanguage.value != language) {
|
|
targetLanguage.value = language;
|
|
}
|
|
}
|
|
|
|
// Load translation history from storage
|
|
void loadTranslationHistory() {
|
|
final historyJson = _storage.read(_historyKey);
|
|
if (historyJson != null) {
|
|
final List<dynamic> historyList = jsonDecode(historyJson);
|
|
translationHistory.value = historyList.map((item) => TranslationItem.fromJson(item)).toList();
|
|
|
|
// 确保在历史记录加载后滚动到底部
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
_scrollToBottom();
|
|
});
|
|
}
|
|
}
|
|
|
|
// Save translation history to storage
|
|
void saveTranslationHistory() {
|
|
// 限制历史记录数量,保留最新的记录
|
|
if (translationHistory.length > _maxHistoryItems) {
|
|
translationHistory.removeRange(0, translationHistory.length - _maxHistoryItems);
|
|
}
|
|
|
|
final List<Map<String, dynamic>> historyJson =
|
|
translationHistory.map((item) => item.toJson()).toList();
|
|
_storage.write(_historyKey, jsonEncode(historyJson));
|
|
}
|
|
|
|
// Clear translation history
|
|
void clearTranslationHistory() {
|
|
translationHistory.clear();
|
|
_storage.remove(_historyKey);
|
|
}
|
|
}
|
|
|
|
// Model class for translation items
|
|
class TranslationItem {
|
|
String sourceText;
|
|
String translatedText;
|
|
final String sourceLanguage;
|
|
final String targetLanguage;
|
|
final DateTime timestamp;
|
|
final String sessionId;
|
|
final bool isFirstInSession;
|
|
|
|
TranslationItem({
|
|
required this.sourceText,
|
|
required this.translatedText,
|
|
required this.sourceLanguage,
|
|
required this.targetLanguage,
|
|
required this.timestamp,
|
|
required this.sessionId,
|
|
this.isFirstInSession = false,
|
|
});
|
|
|
|
factory TranslationItem.fromJson(Map<String, dynamic> json) {
|
|
return TranslationItem(
|
|
sourceText: json['sourceText'],
|
|
translatedText: json['translatedText'],
|
|
sourceLanguage: json['sourceLanguage'],
|
|
targetLanguage: json['targetLanguage'],
|
|
timestamp: DateTime.parse(json['timestamp']),
|
|
sessionId: json['sessionId'] ?? DateTime.now().millisecondsSinceEpoch.toString(),
|
|
isFirstInSession: json['isFirstInSession'] ?? false,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() {
|
|
return {
|
|
'sourceText': sourceText,
|
|
'translatedText': translatedText,
|
|
'sourceLanguage': sourceLanguage,
|
|
'targetLanguage': targetLanguage,
|
|
'timestamp': timestamp.toIso8601String(),
|
|
'sessionId': sessionId,
|
|
'isFirstInSession': isFirstInSession,
|
|
};
|
|
}
|
|
}
|