25 changed files with 976 additions and 695 deletions
@ -0,0 +1,9 @@ |
|||||
|
1. 不要主动运行程序, 如flutter run |
||||
|
2. 不要改动android, iOS项目的配置信息,包括SDK, NDK版本等 |
||||
|
3. 不要使用print, 而是使用logger |
||||
|
4. 尽可能使用GetX提供的各种功能, 而不是使用原生的StatefulWidget |
||||
|
5. 不要增加重试逻辑,出现失败直接返回错误 |
||||
|
6. 尽可能避免使用try catch |
||||
|
7. 尽可能避免打印日志 |
||||
|
8. 请使用较小的代码量改动,最安全的方法进行代码编写和优化 |
||||
|
|
||||
@ -0,0 +1,250 @@ |
|||||
|
import 'dart:convert'; |
||||
|
import 'dart:math'; |
||||
|
import 'package:http/http.dart' as http; |
||||
|
import 'package:crypto/crypto.dart'; |
||||
|
import 'package:flutter_dotenv/flutter_dotenv.dart'; |
||||
|
import 'package:get/get.dart'; |
||||
|
import '../../core/utils/logger.dart'; |
||||
|
|
||||
|
class VolcanoTranslationService extends GetxService { |
||||
|
// API 配置 |
||||
|
late String _baseUrl; |
||||
|
final String _endpoint = '/'; // 修改为根路径 |
||||
|
late final String _accessKey; |
||||
|
late final String _secretKey; |
||||
|
late final String _region; |
||||
|
late final String _service; |
||||
|
|
||||
|
// 初始化标志 |
||||
|
bool _isInitialized = false; |
||||
|
|
||||
|
// 语言代码映射 |
||||
|
final Map<String, String> _languageCodeMap = { |
||||
|
"中文 (Chinese)": "zh", |
||||
|
"英语 (English)": "en", |
||||
|
"日语 (Japanese)": "ja", |
||||
|
"韩语 (Korean)": "ko", |
||||
|
"法语 (French)": "fr", |
||||
|
"西班牙语 (Spanish)": "es", |
||||
|
"葡萄牙语 (Portuguese)": "pt", |
||||
|
"意大利语 (Italian)": "it", |
||||
|
"德语 (German)": "de", |
||||
|
"俄语 (Russian)": "ru", |
||||
|
"阿拉伯语 (Arabic)": "ar", |
||||
|
"泰语 (Thai)": "th", |
||||
|
"越南语 (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", |
||||
|
}; |
||||
|
|
||||
|
// 初始化服务 |
||||
|
Future<bool> initialize({ |
||||
|
String? accessKey, |
||||
|
String? secretKey, |
||||
|
String? region, |
||||
|
}) async { |
||||
|
try { |
||||
|
_accessKey = accessKey ?? dotenv.env['VOLCANO_TRANSLATION_ACCESS_KEY'] ?? ''; |
||||
|
_secretKey = secretKey ?? dotenv.env['VOLCANO_TRANSLATION_SECRET_KEY'] ?? ''; |
||||
|
_region = region ?? dotenv.env['VOLCANO_TRANSLATION_REGION'] ?? 'cn-north-1'; |
||||
|
_service = 'translate'; |
||||
|
_baseUrl = 'https://translate.volcengineapi.com'; |
||||
|
|
||||
|
if (_accessKey.isEmpty || _secretKey.isEmpty) { |
||||
|
return false; |
||||
|
} |
||||
|
|
||||
|
_isInitialized = true; |
||||
|
return true; |
||||
|
} catch (e) { |
||||
|
return false; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 获取语言代码 |
||||
|
String _getLanguageCode(String language) { |
||||
|
return _languageCodeMap[language] ?? 'zh'; |
||||
|
} |
||||
|
|
||||
|
// 生成签名 |
||||
|
Map<String, String> _generateSignature(String method, Map<String, dynamic> requestBody, Map<String, String> queryParams) { |
||||
|
// 1. 准备时间相关参数 |
||||
|
final now = DateTime.now().toUtc(); |
||||
|
final date = now.toIso8601String().split('T')[0].replaceAll('-', ''); |
||||
|
final timestamp = now.toIso8601String().replaceAll(':', '').replaceAll('-', '').split('.')[0] + 'Z'; |
||||
|
|
||||
|
// 2. 构建规范查询字符串 - 按照参数名ASCII排序 |
||||
|
final sortedParams = Map.fromEntries(queryParams.entries.toList()..sort((a, b) => a.key.compareTo(b.key))); |
||||
|
final List<String> queryParts = []; |
||||
|
sortedParams.forEach((key, value) { |
||||
|
// 使用RFC 3986编码,确保特殊字符被正确处理 |
||||
|
final encodedKey = Uri.encodeComponent(key).replaceAll('+', '%20'); |
||||
|
final encodedValue = Uri.encodeComponent(value).replaceAll('+', '%20'); |
||||
|
queryParts.add('$encodedKey=$encodedValue'); |
||||
|
}); |
||||
|
final canonicalQueryString = queryParts.join('&'); |
||||
|
|
||||
|
// 3. 创建规范请求 |
||||
|
final contentType = 'application/json'; |
||||
|
final payloadHash = sha256.convert(utf8.encode(json.encode(requestBody))).toString(); |
||||
|
|
||||
|
final host = 'translate.volcengineapi.com'; |
||||
|
|
||||
|
// 确保头部按照字典序排序,且键名小写 |
||||
|
// 注意:只包含host和x-date头部,与示例保持一致 |
||||
|
final canonicalHeaders = 'host:$host\nx-date:$timestamp\n'; |
||||
|
final signedHeaders = 'host;x-date'; |
||||
|
|
||||
|
// 构建规范请求,格式必须与示例完全一致 |
||||
|
final canonicalRequest = '$method\n$_endpoint\n$canonicalQueryString\n$canonicalHeaders\n$signedHeaders\n$payloadHash'; |
||||
|
|
||||
|
// 4. 创建待签字符串 |
||||
|
final algorithm = 'HMAC-SHA256'; |
||||
|
final credentialScope = '$date/$_region/$_service/request'; |
||||
|
final stringToSign = '$algorithm\n$timestamp\n$credentialScope\n${sha256.convert(utf8.encode(canonicalRequest)).toString()}'; |
||||
|
|
||||
|
// 5. 计算签名 - 按照文档正确派生密钥 |
||||
|
// 注意:这里是关键修复点,确保密钥派生过程完全符合文档 |
||||
|
final kSecret = utf8.encode(_secretKey); // 直接使用原始密钥,不添加前缀 |
||||
|
|
||||
|
// kDate = HMAC(kSecret, Date) |
||||
|
var kDate = Hmac(sha256, kSecret).convert(utf8.encode(date)).bytes; |
||||
|
|
||||
|
// kRegion = HMAC(kDate, Region) |
||||
|
var kRegion = Hmac(sha256, kDate).convert(utf8.encode(_region)).bytes; |
||||
|
|
||||
|
// kService = HMAC(kRegion, Service) |
||||
|
var kService = Hmac(sha256, kRegion).convert(utf8.encode(_service)).bytes; |
||||
|
|
||||
|
// kSigning = HMAC(kService, "request") |
||||
|
var kSigning = Hmac(sha256, kService).convert(utf8.encode('request')).bytes; |
||||
|
|
||||
|
// 使用派生的签名密钥计算最终签名 |
||||
|
final signature = Hmac(sha256, kSigning).convert(utf8.encode(stringToSign)).toString(); |
||||
|
|
||||
|
// 6. 添加签名到请求头 - 格式必须与示例完全一致 |
||||
|
final authorization = '$algorithm Credential=$_accessKey/$credentialScope, SignedHeaders=$signedHeaders, Signature=$signature'; |
||||
|
|
||||
|
// 只返回必要的头部,与示例保持一致 |
||||
|
return { |
||||
|
'Content-Type': contentType, |
||||
|
'X-Date': timestamp, |
||||
|
'Authorization': authorization, |
||||
|
'Host': host, |
||||
|
}; |
||||
|
} |
||||
|
|
||||
|
// 翻译文本 |
||||
|
Future<String?> translateText({ |
||||
|
required String text, |
||||
|
required String sourceLanguage, |
||||
|
required String targetLanguage, |
||||
|
}) async { |
||||
|
if (!_isInitialized) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
try { |
||||
|
final sourceCode = _getLanguageCode(sourceLanguage); |
||||
|
final targetCode = _getLanguageCode(targetLanguage); |
||||
|
|
||||
|
// 构建请求体 |
||||
|
final requestBody = { |
||||
|
'SourceLanguage': sourceCode, |
||||
|
'TargetLanguage': targetCode, |
||||
|
'TextList': [text], |
||||
|
}; |
||||
|
|
||||
|
// 添加必要的查询参数 - 确保包含所有必需参数 |
||||
|
final queryParams = { |
||||
|
'Action': 'TranslateText', |
||||
|
'Version': '2020-06-01', |
||||
|
'Region': _region, |
||||
|
'Service': _service, |
||||
|
}; |
||||
|
|
||||
|
// 生成包含查询参数的签名 |
||||
|
final headers = _generateSignature('POST', requestBody, queryParams); |
||||
|
|
||||
|
// 构建带有查询参数的URL |
||||
|
final queryString = Uri(queryParameters: queryParams).query; |
||||
|
final url = '$_baseUrl/?$queryString'; |
||||
|
|
||||
|
final response = await http.post( |
||||
|
Uri.parse(url), |
||||
|
headers: headers, |
||||
|
body: json.encode(requestBody), |
||||
|
); |
||||
|
|
||||
|
if (response.statusCode == 200) { |
||||
|
final responseData = json.decode(response.body); |
||||
|
|
||||
|
// 尝试从不同的响应结构中提取翻译结果 |
||||
|
String? translation; |
||||
|
|
||||
|
// 1. 标准响应结构 |
||||
|
if (responseData['TranslationList'] != null && |
||||
|
responseData['TranslationList'] is List && |
||||
|
responseData['TranslationList'].isNotEmpty) { |
||||
|
final translationItem = responseData['TranslationList'][0]; |
||||
|
if (translationItem is Map && translationItem.containsKey('Translation')) { |
||||
|
translation = translationItem['Translation']; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 2. 检查是否有错误响应 |
||||
|
if (translation == null && |
||||
|
responseData['ResponseMetadata'] != null && |
||||
|
responseData['ResponseMetadata'] is Map && |
||||
|
responseData['ResponseMetadata']['Error'] != null) { |
||||
|
return null; |
||||
|
} |
||||
|
|
||||
|
// 3. 尝试其他可能的响应结构 |
||||
|
if (translation == null) { |
||||
|
if (responseData.containsKey('Translation')) { |
||||
|
translation = responseData['Translation']; |
||||
|
} else if (responseData.containsKey('Result') && |
||||
|
responseData['Result'] is Map && |
||||
|
responseData['Result'].containsKey('Translation')) { |
||||
|
translation = responseData['Result']['Translation']; |
||||
|
} else if (responseData.containsKey('Data') && |
||||
|
responseData['Data'] is Map) { |
||||
|
final data = responseData['Data']; |
||||
|
if (data.containsKey('Translation')) { |
||||
|
translation = data['Translation']; |
||||
|
} else if (data.containsKey('TranslationList') && |
||||
|
data['TranslationList'] is List && |
||||
|
data['TranslationList'].isNotEmpty) { |
||||
|
final item = data['TranslationList'][0]; |
||||
|
if (item is Map && item.containsKey('Translation')) { |
||||
|
translation = item['Translation']; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
// 返回提取到的翻译结果 |
||||
|
return translation; |
||||
|
} |
||||
|
|
||||
|
return null; |
||||
|
} catch (e) { |
||||
|
return null; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,9 +1,26 @@ |
|||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
import '../controllers/translation_controller.dart'; |
import '../controllers/translation_controller.dart'; |
||||
|
import '../../../data/services/azure_asr_service.dart'; |
||||
|
import '../../../data/services/volcano_translation_service.dart'; |
||||
|
import '../../../data/services/volcano_tts_service.dart'; |
||||
|
|
||||
class TranslationBinding extends Bindings { |
class TranslationBinding implements Bindings { |
||||
@override |
@override |
||||
void dependencies() { |
void dependencies() { |
||||
Get.lazyPut<TranslationController>(() => TranslationController()); |
// Ensure services are registered |
||||
|
if (!Get.isRegistered<AzureAsrService>()) { |
||||
|
Get.put(AzureAsrService()); |
||||
|
} |
||||
|
|
||||
|
if (!Get.isRegistered<VolcanoTranslationService>()) { |
||||
|
Get.put(VolcanoTranslationService()); |
||||
|
} |
||||
|
|
||||
|
if (!Get.isRegistered<VolcanoTtsService>()) { |
||||
|
Get.put(VolcanoTtsService()); |
||||
|
} |
||||
|
|
||||
|
// Register the controller |
||||
|
Get.put(TranslationController()); |
||||
} |
} |
||||
} |
} |
||||
@ -1,334 +1,340 @@ |
|||||
import 'dart:async'; |
import 'package:flutter/material.dart'; |
||||
import 'package:get/get.dart'; |
import 'package:get/get.dart'; |
||||
import '../../../data/services/volcano_asr_service.dart'; |
import 'dart:async'; |
||||
|
import '../../../data/services/azure_asr_service.dart'; |
||||
|
import '../../../data/services/volcano_translation_service.dart'; |
||||
import '../../../data/services/volcano_tts_api_service.dart'; |
import '../../../data/services/volcano_tts_api_service.dart'; |
||||
import '../../../core/utils/logger.dart'; |
// import '../../../core/utils/logger.dart'; |
||||
import '../models/translation_message_model.dart'; |
|
||||
|
|
||||
class TranslationController extends GetxController { |
class TranslationController extends GetxController { |
||||
// Observable states |
// Services |
||||
final isListening = false.obs; |
final AzureAsrService _asrService = Get.find<AzureAsrService>(); |
||||
|
final VolcanoTranslationService _translationService = Get.find<VolcanoTranslationService>(); |
||||
|
final VolcanoTtsApiService _ttsService = Get.find<VolcanoTtsApiService>(); |
||||
|
|
||||
|
// State variables |
||||
|
final isRecognizing = false.obs; |
||||
final isTranslating = false.obs; |
final isTranslating = false.obs; |
||||
final sourceLanguage = "Chinese".obs; |
|
||||
final targetLanguage = "English".obs; |
|
||||
final originalText = "".obs; |
|
||||
final translatedText = "".obs; |
|
||||
final isUserSpeaking = false.obs; |
|
||||
final messages = <TranslationMessage>[].obs; |
|
||||
|
|
||||
// TTS启用状态 |
|
||||
final isTtsEnabled = true.obs; |
final isTtsEnabled = true.obs; |
||||
|
final recognizedText = ''.obs; |
||||
|
final translatedText = ''.obs; |
||||
|
final sourceLanguage = '中文 (Chinese)'.obs; |
||||
|
final targetLanguage = '英语 (English)'.obs; |
||||
|
final translationHistory = <TranslationItem>[].obs; |
||||
|
|
||||
// Services |
// Current translation item |
||||
late final VolcanoAsrService _voiceService; |
TranslationItem? currentItem; |
||||
late final VolcanoTtsApiService _ttsService; |
|
||||
|
|
||||
// Stream subscriptions |
// Subscription for recognition stream |
||||
StreamSubscription? _recognitionSubscription; |
StreamSubscription? _recognitionSubscription; |
||||
|
|
||||
// Timers |
// Scroll controller for ListView |
||||
Timer? _silenceTimer; |
final ScrollController scrollController = ScrollController(); |
||||
Timer? _speakingCheckTimer; |
|
||||
|
|
||||
// State tracking |
|
||||
bool _hasRecognizedSpeech = false; |
|
||||
bool _hasFinalResult = false; |
|
||||
DateTime? _lastSpeechTime; |
|
||||
Completer<String>? _recognitionCompleter; |
|
||||
|
|
||||
// TTS事件订阅 |
|
||||
StreamSubscription? _ttsEventSubscription; |
|
||||
bool _isTtsSpeaking = false; |
|
||||
|
|
||||
|
// Timer for debouncing translation requests |
||||
|
Timer? _translationDebounceTimer; |
||||
|
|
||||
@override |
@override |
||||
void onInit() { |
void onInit() { |
||||
super.onInit(); |
super.onInit(); |
||||
_initializeServices(); |
|
||||
_startSpeakingCheckTimer(); |
|
||||
|
|
||||
_ttsService = Get.find<VolcanoTtsApiService>(); |
// Initialize the translation service |
||||
|
_initTranslationService(); |
||||
|
|
||||
// 设置TTS事件监听 |
// Start voice recognition automatically when the page is loaded |
||||
_setupTtsEventListener(); |
startRecognition(); |
||||
|
} |
||||
|
|
||||
|
@override |
||||
|
void onClose() { |
||||
|
stopRecognition(); |
||||
|
_recognitionSubscription?.cancel(); |
||||
|
_translationDebounceTimer?.cancel(); |
||||
|
scrollController.dispose(); |
||||
|
super.onClose(); |
||||
} |
} |
||||
|
|
||||
Future<void> _initializeServices() async { |
// Initialize the translation service |
||||
|
Future<void> _initTranslationService() async { |
||||
try { |
try { |
||||
_voiceService = Get.find<VolcanoAsrService>(); |
await _translationService.initialize(); |
||||
_ttsService = Get.find<VolcanoTtsApiService>(); |
|
||||
|
|
||||
await _voiceService.initialize(); |
|
||||
await startListening(); |
|
||||
} catch (e) { |
} catch (e) { |
||||
Logger.error('Failed to initialize translation services', e); |
|
||||
Get.snackbar( |
Get.snackbar( |
||||
'Error', |
'Error', |
||||
'Failed to initialize translation services: $e', |
'Failed to initialize translation service: ${e.toString()}', |
||||
snackPosition: SnackPosition.BOTTOM, |
snackPosition: SnackPosition.BOTTOM, |
||||
); |
); |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
Future<void> startListening() async { |
// Toggle TTS feature |
||||
if (isListening.value) return; |
void toggleTts() { |
||||
|
isTtsEnabled.toggle(); |
||||
|
} |
||||
|
|
||||
|
// Start voice recognition |
||||
|
Future<void> startRecognition() async { |
||||
|
if (isRecognizing.value) return; |
||||
|
|
||||
try { |
try { |
||||
isListening.value = true; |
// Initialize a new translation item |
||||
originalText.value = ''; |
currentItem = TranslationItem( |
||||
translatedText.value = ''; |
sourceText: '', |
||||
_hasRecognizedSpeech = false; |
translatedText: '', |
||||
_hasFinalResult = false; |
sourceLanguage: sourceLanguage.value, |
||||
|
targetLanguage: targetLanguage.value, |
||||
|
timestamp: DateTime.now(), |
||||
|
); |
||||
|
|
||||
_recognitionCompleter = Completer<String>(); |
// Start the voice recognition service |
||||
|
final success = await _asrService.startContinuousRecognition(); |
||||
|
|
||||
final success = await _voiceService.startContinuousRecognition(); |
if (success) { |
||||
if (!success) { |
isRecognizing.value = true; |
||||
isListening.value = false; |
|
||||
throw Exception('Failed to start voice recognition'); |
// Reset the recognized text |
||||
} |
recognizedText.value = ''; |
||||
|
translatedText.value = ''; |
||||
_recognitionSubscription = _voiceService.recognitionStream?.listen( |
|
||||
(event) { |
// Listen for recognition events |
||||
|
_recognitionSubscription = _asrService.recognitionStream?.listen((event) { |
||||
switch (event.type) { |
switch (event.type) { |
||||
case RecognitionEventType.recognizing: |
case RecognitionEventType.finalResult: |
||||
if (event.text.isNotEmpty) { |
if (event.text.isNotEmpty) { |
||||
originalText.value = event.text; |
handleFinalResult(event.text); |
||||
_hasRecognizedSpeech = true; |
|
||||
_lastSpeechTime = DateTime.now(); |
|
||||
isUserSpeaking.value = true; |
|
||||
|
|
||||
// Cancel previous silence timer |
|
||||
_silenceTimer?.cancel(); |
|
||||
|
|
||||
// Set new silence timer - if no new recognition results in 2 seconds, |
|
||||
// consider the user has stopped speaking |
|
||||
_silenceTimer = Timer(const Duration(seconds: 2), () { |
|
||||
if (_hasRecognizedSpeech && !_hasFinalResult) { |
|
||||
_processTranslation(event.text); |
|
||||
} |
|
||||
}); |
|
||||
|
|
||||
// If TTS is playing and user starts speaking, stop TTS |
|
||||
if (_isTtsSpeaking) { |
|
||||
_ttsService.endSession(); |
|
||||
} |
|
||||
} |
} |
||||
break; |
break; |
||||
|
case RecognitionEventType.recognizing: |
||||
case RecognitionEventType.finalResult: |
// Update the UI with intermediate results |
||||
_hasFinalResult = true; |
|
||||
isUserSpeaking.value = false; |
|
||||
|
|
||||
if (event.text.isNotEmpty) { |
if (event.text.isNotEmpty) { |
||||
originalText.value = event.text; |
handleIntermediateResult(event.text); |
||||
_hasRecognizedSpeech = true; |
|
||||
_lastSpeechTime = DateTime.now(); |
|
||||
|
|
||||
_processTranslation(event.text); |
|
||||
} else if (_hasRecognizedSpeech) { |
|
||||
// If final result is empty but we had intermediate results |
|
||||
_processTranslation(originalText.value); |
|
||||
} |
} |
||||
|
|
||||
// Reset for next utterance |
|
||||
_hasRecognizedSpeech = false; |
|
||||
_hasFinalResult = false; |
|
||||
break; |
break; |
||||
|
|
||||
case RecognitionEventType.error: |
case RecognitionEventType.error: |
||||
Logger.error('Voice recognition error', event.error); |
Get.snackbar( |
||||
|
'Error', |
||||
|
'Recognition error: ${event.error}', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
); |
||||
break; |
break; |
||||
|
|
||||
default: |
default: |
||||
break; |
break; |
||||
} |
} |
||||
}, |
}); |
||||
onError: (error) { |
} else { |
||||
Logger.error('Voice recognition stream error', error); |
throw Exception('Failed to start continuous recognition'); |
||||
isListening.value = false; |
} |
||||
restartListening(); |
|
||||
} |
|
||||
); |
|
||||
|
|
||||
} catch (e) { |
} catch (e) { |
||||
Logger.error('Failed to start listening', e); |
isRecognizing.value = false; |
||||
isListening.value = false; |
Get.snackbar( |
||||
|
'Error', |
||||
|
'Failed to start voice recognition: ${e.toString()}', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
); |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
Future<void> _processTranslation(String text) async { |
// Stop voice recognition |
||||
if (text.isEmpty) return; |
Future<void> stopRecognition() async { |
||||
|
if (!isRecognizing.value) return; |
||||
|
|
||||
try { |
try { |
||||
isTranslating.value = true; |
await _asrService.stopContinuousRecognition(); |
||||
|
_recognitionSubscription?.cancel(); |
||||
// In a real implementation, you would call your translation service here |
_recognitionSubscription = null; |
||||
// For now, we'll simulate translation with a delay |
isRecognizing.value = false; |
||||
await Future.delayed(const Duration(milliseconds: 300)); |
|
||||
|
|
||||
// Simulate translation (in a real app, call your translation API here) |
|
||||
final translated = await _simulateTranslation(text); |
|
||||
translatedText.value = translated; |
|
||||
|
|
||||
// Add to messages |
|
||||
messages.add(TranslationMessage( |
|
||||
original: text, |
|
||||
translated: translated, |
|
||||
timestamp: DateTime.now(), |
|
||||
isFromUser: true, |
|
||||
)); |
|
||||
|
|
||||
// Speak the translation if TTS is enabled |
// 添加当前项到历史(如果有内容) |
||||
if (isTtsEnabled.value) { |
finalizeCurrentItem(); |
||||
await _synthesizeText(translated); |
|
||||
} |
|
||||
|
|
||||
|
// 重置当前显示的文本 |
||||
|
recognizedText.value = ''; |
||||
|
translatedText.value = ''; |
||||
} catch (e) { |
} catch (e) { |
||||
Logger.error('Translation error', e); |
Get.snackbar( |
||||
} finally { |
'Error', |
||||
isTranslating.value = false; |
'Failed to stop voice recognition: ${e.toString()}', |
||||
|
snackPosition: SnackPosition.BOTTOM, |
||||
|
); |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
// This is a placeholder - in a real app, you would call your translation API |
// Handle intermediate recognition result |
||||
Future<String> _simulateTranslation(String text) async { |
void handleIntermediateResult(String text) { |
||||
// This is just a simulation - replace with actual translation API call |
if (text.isEmpty) return; |
||||
if (sourceLanguage.value == "Chinese" && targetLanguage.value == "English") { |
|
||||
// Simulate Chinese to English translation |
// 如果当前没有活跃的翻译项,创建一个新的 |
||||
if (text.contains('测试')) return 'I am now starting to test the function of translating spoken Chinese into English.'; |
if (currentItem == null) { |
||||
return 'Translated from Chinese: $text'; |
currentItem = TranslationItem( |
||||
|
sourceText: text, |
||||
|
translatedText: '', |
||||
|
sourceLanguage: sourceLanguage.value, |
||||
|
targetLanguage: targetLanguage.value, |
||||
|
timestamp: DateTime.now(), |
||||
|
); |
||||
} else { |
} else { |
||||
// Simulate English to Chinese translation |
// 更新当前翻译项 |
||||
return '翻译自英语: $text'; |
currentItem!.sourceText = text; |
||||
} |
} |
||||
} |
|
||||
|
|
||||
void switchLanguages() { |
|
||||
final temp = sourceLanguage.value; |
|
||||
sourceLanguage.value = targetLanguage.value; |
|
||||
targetLanguage.value = temp; |
|
||||
|
|
||||
// Clear current messages when switching languages |
// 实时更新识别文本 |
||||
messages.clear(); |
recognizedText.value = text; |
||||
} |
|
||||
|
|
||||
Future<void> restartListening() async { |
|
||||
await stopListening(); |
|
||||
await Future.delayed(const Duration(milliseconds: 500)); |
|
||||
await startListening(); |
|
||||
} |
|
||||
|
|
||||
Future<void> stopListening() async { |
|
||||
if (!isListening.value) return; |
|
||||
|
|
||||
try { |
// Debounce translation requests to avoid too many API calls |
||||
await _recognitionSubscription?.cancel(); |
_translationDebounceTimer?.cancel(); |
||||
_recognitionSubscription = null; |
_translationDebounceTimer = Timer(const Duration(milliseconds: 300), () { |
||||
|
translateText(text, isFinal: false); |
||||
_silenceTimer?.cancel(); |
|
||||
_silenceTimer = null; |
|
||||
|
|
||||
if (_voiceService.isContinuousRecognitionActive()) { |
|
||||
await _voiceService.stopRecognition(); |
|
||||
} |
|
||||
|
|
||||
isListening.value = false; |
|
||||
isUserSpeaking.value = false; |
|
||||
_hasRecognizedSpeech = false; |
|
||||
_hasFinalResult = false; |
|
||||
} catch (e) { |
|
||||
Logger.error('Failed to stop listening', e); |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
void _startSpeakingCheckTimer() { |
|
||||
_speakingCheckTimer?.cancel(); |
|
||||
_speakingCheckTimer = Timer.periodic(const Duration(milliseconds: 200), (_) { |
|
||||
_checkIfUserIsSpeaking(); |
|
||||
}); |
}); |
||||
} |
|
||||
|
|
||||
void _checkIfUserIsSpeaking() { |
|
||||
if (_hasRecognizedSpeech && !_hasFinalResult && _lastSpeechTime != null) { |
|
||||
final timeSinceLastSpeech = DateTime.now().difference(_lastSpeechTime!); |
|
||||
if (timeSinceLastSpeech.inMilliseconds < 1000) { |
|
||||
isUserSpeaking.value = true; |
|
||||
return; |
|
||||
} |
|
||||
} |
|
||||
|
|
||||
isUserSpeaking.value = false; |
// Scroll to the bottom to show the latest content |
||||
|
_scrollToBottom(); |
||||
} |
} |
||||
|
|
||||
void _setupTtsEventListener() { |
// Handle final recognition result |
||||
_ttsEventSubscription = _ttsService.eventStream.listen(_handleTtsEvent); |
void handleFinalResult(String text) { |
||||
} |
if (text.isEmpty) return; |
||||
|
|
||||
void _handleTtsEvent(Map<String, dynamic> event) { |
|
||||
final String eventType = event['type'] as String? ?? 'unknown'; |
|
||||
|
|
||||
switch (eventType) { |
// 更新当前翻译项(如果不存在则创建一个) |
||||
case 'ttsSentenceStart': |
if (currentItem == null) { |
||||
_isTtsSpeaking = true; |
currentItem = TranslationItem( |
||||
break; |
sourceText: text, |
||||
case 'ttsSentenceEnd': |
translatedText: '', |
||||
case 'sessionFinished': |
sourceLanguage: sourceLanguage.value, |
||||
case 'connectionFinished': |
targetLanguage: targetLanguage.value, |
||||
_isTtsSpeaking = false; |
timestamp: DateTime.now(), |
||||
break; |
); |
||||
case 'connectionFailed': |
} else { |
||||
case 'sessionFailed': |
currentItem!.sourceText = text; |
||||
_isTtsSpeaking = false; |
|
||||
print('TTS错误: ${event['message']}'); |
|
||||
break; |
|
||||
} |
} |
||||
} |
|
||||
|
|
||||
@override |
|
||||
void onClose() { |
|
||||
stopListening(); |
|
||||
_speakingCheckTimer?.cancel(); |
|
||||
|
|
||||
// 取消TTS事件订阅 |
// 更新识别文本 |
||||
_ttsEventSubscription?.cancel(); |
recognizedText.value = text; |
||||
|
|
||||
// 结束TTS会话 |
// 翻译最终结果 (添加到历史的逻辑已移至translateText方法) |
||||
_ttsService.endSession(); |
translateText(text, isFinal: true).then((_) { |
||||
|
// 作为安全措施,确保当前项被添加到历史中 |
||||
|
// 这是为了处理translateText中可能因为条件不满足而没有添加到历史的情况 |
||||
|
finalizeCurrentItem(); |
||||
|
}); |
||||
|
|
||||
super.onClose(); |
// Scroll to the bottom to show the latest content |
||||
|
_scrollToBottom(); |
||||
} |
} |
||||
|
|
||||
// Add a method to synthesize text using the new TTS service |
// Finalize the current translation item and add it to history |
||||
Future<void> _synthesizeText(String text) async { |
void finalizeCurrentItem() { |
||||
if (text.isEmpty || !isTtsEnabled.value) return; |
if (currentItem != null && |
||||
|
currentItem!.sourceText.isNotEmpty && |
||||
|
currentItem!.translatedText.isNotEmpty && |
||||
|
!translationHistory.contains(currentItem)) { |
||||
|
translationHistory.add(currentItem!); |
||||
|
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 { |
try { |
||||
// 如果当前有正在进行的TTS会话,先结束它 |
final result = await _translationService.translateText( |
||||
if (_isTtsSpeaking) { |
text: text, |
||||
await _ttsService.endSession(); |
sourceLanguage: sourceLanguage.value, |
||||
} |
targetLanguage: targetLanguage.value, |
||||
|
); |
||||
|
|
||||
// 确保连接已建立 |
if (result != null) { |
||||
if (!_ttsService.isConnected.value) { |
// 实时更新翻译文本 |
||||
final connected = await _ttsService.connect(); |
translatedText.value = result; |
||||
if (!connected) { |
|
||||
print('无法连接到TTS服务'); |
// 确保更新的是发起翻译请求时的翻译项,而不是当前可能已经变化的翻译项 |
||||
return; |
if (itemToUpdate != null && itemToUpdate.sourceText == text) { |
||||
|
itemToUpdate.translatedText = result; |
||||
|
|
||||
|
// 如果是最终结果,并且当前项仍然是发起请求时的项,则添加到历史 |
||||
|
if (isFinal && itemToUpdate == currentItem) { |
||||
|
translationHistory.add(itemToUpdate); |
||||
|
currentItem = null; // 重要:将当前项设为null,表示已完成 |
||||
|
|
||||
|
// 重置当前显示的文本,避免与历史项重复 |
||||
|
recognizedText.value = ''; |
||||
|
translatedText.value = ''; |
||||
|
} |
||||
|
|
||||
|
// Play TTS if enabled and this is a final result |
||||
|
if (isTtsEnabled.value && isFinal) { |
||||
|
playTranslatedText(result); |
||||
|
} |
||||
} |
} |
||||
} |
} |
||||
|
|
||||
// 开始合成 |
|
||||
await _ttsService.synthesize(text, 'zh_female_1'); // 使用默认说话人 |
|
||||
} catch (e) { |
} catch (e) { |
||||
print('TTS播放出错: $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); |
||||
|
} |
||||
|
|
||||
// Add a method to toggle TTS |
// Scroll to the bottom of the list |
||||
void toggleTTS() { |
void _scrollToBottom() { |
||||
isTtsEnabled.toggle(); |
if (scrollController.hasClients) { |
||||
if (!isTtsEnabled.value) { |
Future.delayed(const Duration(milliseconds: 100), () { |
||||
_ttsService.endSession(); |
scrollController.animateTo( |
||||
|
scrollController.position.maxScrollExtent, |
||||
|
duration: const Duration(milliseconds: 300), |
||||
|
curve: Curves.easeOut, |
||||
|
); |
||||
|
}); |
||||
} |
} |
||||
} |
} |
||||
|
} |
||||
|
|
||||
|
// Model class for translation items |
||||
|
class TranslationItem { |
||||
|
String sourceText; |
||||
|
String translatedText; |
||||
|
final String sourceLanguage; |
||||
|
final String targetLanguage; |
||||
|
final DateTime timestamp; |
||||
|
|
||||
|
TranslationItem({ |
||||
|
required this.sourceText, |
||||
|
required this.translatedText, |
||||
|
required this.sourceLanguage, |
||||
|
required this.targetLanguage, |
||||
|
required this.timestamp, |
||||
|
}); |
||||
} |
} |
||||
@ -1,13 +0,0 @@ |
|||||
class TranslationMessage { |
|
||||
final String original; |
|
||||
final String translated; |
|
||||
final DateTime timestamp; |
|
||||
final bool isFromUser; |
|
||||
|
|
||||
TranslationMessage({ |
|
||||
required this.original, |
|
||||
required this.translated, |
|
||||
required this.timestamp, |
|
||||
required this.isFromUser, |
|
||||
}); |
|
||||
} |
|
||||
@ -1,82 +0,0 @@ |
|||||
import 'package:flutter/material.dart'; |
|
||||
import '../models/translation_message_model.dart'; |
|
||||
|
|
||||
class TranslationMessageItem extends StatelessWidget { |
|
||||
final TranslationMessage message; |
|
||||
|
|
||||
const TranslationMessageItem({ |
|
||||
Key? key, |
|
||||
required this.message, |
|
||||
}) : super(key: key); |
|
||||
|
|
||||
@override |
|
||||
Widget build(BuildContext context) { |
|
||||
return Container( |
|
||||
margin: const EdgeInsets.symmetric(vertical: 8), |
|
||||
child: Column( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
// Original text |
|
||||
Container( |
|
||||
width: double.infinity, |
|
||||
padding: const EdgeInsets.all(16), |
|
||||
decoration: BoxDecoration( |
|
||||
color: const Color(0xFFF5F5F5), |
|
||||
borderRadius: BorderRadius.circular(12), |
|
||||
border: Border.all( |
|
||||
color: Colors.grey[300]!, |
|
||||
width: 0.5, |
|
||||
), |
|
||||
), |
|
||||
child: Text( |
|
||||
message.original, |
|
||||
style: TextStyle( |
|
||||
color: Colors.grey[800], |
|
||||
fontSize: 14, |
|
||||
height: 1.5, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
|
|
||||
const SizedBox(height: 8), |
|
||||
|
|
||||
// Translated text |
|
||||
Container( |
|
||||
width: double.infinity, |
|
||||
padding: const EdgeInsets.all(16), |
|
||||
decoration: BoxDecoration( |
|
||||
color: const Color(0xFFEEF4FF), |
|
||||
borderRadius: BorderRadius.circular(12), |
|
||||
border: Border.all( |
|
||||
color: const Color(0xFFD0DFFF), |
|
||||
width: 0.5, |
|
||||
), |
|
||||
), |
|
||||
child: Row( |
|
||||
crossAxisAlignment: CrossAxisAlignment.start, |
|
||||
children: [ |
|
||||
Expanded( |
|
||||
child: Text( |
|
||||
message.translated, |
|
||||
style: TextStyle( |
|
||||
color: Colors.black87, |
|
||||
fontSize: 14, |
|
||||
fontWeight: FontWeight.w500, |
|
||||
height: 1.5, |
|
||||
), |
|
||||
), |
|
||||
), |
|
||||
const SizedBox(width: 8), |
|
||||
Icon( |
|
||||
Icons.volume_up, |
|
||||
color: Colors.grey[600], |
|
||||
size: 20, |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
), |
|
||||
], |
|
||||
), |
|
||||
); |
|
||||
} |
|
||||
} |
|
||||
Loading…
Reference in new issue