import 'dart:io'; import 'dart:async'; import 'package:dio/dio.dart' show CancelToken; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; import 'package:intl/intl.dart'; import 'package:floating_ui_plugin/native_plugin.dart'; import 'package:path_provider/path_provider.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:device_info_plus/device_info_plus.dart'; import 'package:pip/pip.dart'; import 'package:eaimar/data/services/qq_music.dart'; import '../../../data/services/ast_service.dart'; import '../../../data/services/server_translation_service.dart'; import '../../../data/services/bluetooth_manager.dart'; import '../../../data/services/music_manager.dart'; import '../../../data/services/translation_usage_service.dart'; import '../../../data/services/tts_service.dart'; import '../../../data/services/language_manager.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/permission_util.dart'; import '../../../core/services/floating_window_manager.dart'; import '../../../data/services/asr_service.dart'; import '../../../data/services/usage_stats_service.dart'; import '../../../data/services/ble_manager.dart'; import '../../../data/services/bes_bluetooth_service.dart'; import '../../../data/services/recording_archive.dart'; import '../../../core/utils/device_connection.dart'; import '../../../core/utils/background_session_notifier.dart'; import 'record_timer_controller.dart'; import 'translation_history_controller.dart'; import '../models/translation_models.dart'; import '../views/dialogs/translation_mode_selector.dart'; import '../../../data/models/appconfig.dart'; import '../../../data/models/appconfig_model.dart'; import '../../../data/models/user_Info.dart'; import '../../common/widgets/recharge_dialog.dart'; /// 通话翻译"后台继续"系统通知的 id const int kCallTranslationNotificationId = 91013; /// 音频输出设备枚举 enum AudioOutputDevice { defaultDevice, // 默认(如有耳机优先耳机,否则扬声器) headphones, // 强制耳机 speaker, // 强制扬声器 } /// 面对面翻译的触发来源——决定收音走哪一路。 /// /// - [client]:App 界面上的开始按钮触发,走**手机麦克风**连续识别, /// 方向由 ASR 的语种检测自动判定([_determineLanguageCodes] 的 else 分支)。 /// - [device]:耳机左右耳按键触发,走**设备端收音**(BLE EXTERNAL 音频), /// 按住说话、松手即停,方向由按的是哪只耳朵决定。 /// /// 设备端那条链路([FaceToFaceBleService])目前仍在等固件的 UUID 与命令, /// 所以默认是 [client];等 [setActiveSpeaker] 被耳机按键事件调用时会自动切到 /// [device],业务逻辑无需再改。 enum FaceToFaceTrigger { client, device } /// 翻译主控制器 - 负责管理语音识别、翻译和TTS功能(主控制器) class TranslationController extends GetxController with WidgetsBindingObserver { static const String TAG = 'TranslationController'; // ==================== 服务依赖 ==================== final AsrService _asrService = Get.find(); // 同声翻译 / 面对面翻译 / 音视频翻译走火山翻译(REST) // 通话翻译(call 模式)不使用该字段,改走 _astService(AST 端到端) // 文本翻译走服务端(后台第三方服务配置里的 MT 服务,当前是阿里云)。 // 以前直连火山,AK/SK 要明文下发给每个客户端,换服务商还得发版。 final ServerTranslationService _translationService = Get.find(); final TtsService _ttsService = Get.find(); final AstService _astService = Get.find(); final LanguageManager _languageManager = Get.find(); /// 最近一次通话模式初始化所选中的 AST provider,仅用于日志追踪 String _lastCallAstProvider = ''; /// _initializeCallModeTranslationService 里的 _astService.initialize() 原生实现 /// 会自动调一次 startContinuousTranslation(见该函数注释)。这个连接握手是 /// 异步的,握手完成前原生的 isStarted 还是 false,如果这时候用户点了开始、 /// startRecognition 又显式调一次 startContinuousTranslation,会在旧连接握手 /// 成功之前抢先把 connectionGeneration 往前推一格,旧连接的 onOpen/onFailure /// 回调因此被判定为「stale」直接丢弃——WebSocket 永远建不起来,识别看着启动了 /// 实际收不到任何结果。这个标记就是用来跳过那次多余的显式调用:置 true 表示 /// "AST 已经被 initialize() 自动拉起,不用再调一次",startRecognition 消费一次 /// 后清掉;真正停止过之后(stopRecognition/_terminateCallOnError)也要清掉, /// 否则下次点开始会以为不需要再调,实际原生那边已经停了。 bool _astAutoStartPending = false; final GetStorage _storage = GetStorage(); final BluetoothManager bluetoothManager = Get.find(); final BleManager bleManager = Get.find(); final _musiceManager = Get.find(); final _qqMusicManager = Get.find(); late final UsageStatsService _usageService; // 安卓悬浮窗管理器 late final FloatingWindowManager _floatingWindowManager; // 苹果悬浮窗管理器 late final Pip _pip; late final NativePlugin _nativePlugin; int _pipContentView = 0; static const int _pipPreferredWidth = 375; static const int _pipCompactPreferredHeight = 72; static const int _pipExpandedPreferredHeight = 220; bool _isPipExpanded = false; // ==================== 悬浮窗相关 ==================== final isFloatingWindowEnabled = false.obs; // 悬浮窗状态 // ==================== ASR统计相关 ==================== DateTime? _asrSessionStartTime; // ASR会话开始时间 DateTime? _asrActiveStartTime; // ASR活跃开始时间(用户实际开始语音识别的时间) Duration _asrActiveDuration = Duration.zero; // ASR累计活跃时长 bool _isAsrActive = false; // ASR是否处于活跃状态 // ==================== 配置相关 ==================== bool _audioSourceType = false; // 音频输入源类型 bool isPlayback = false; // 是否开启播放 bool isContinuePlaying = false; // 是否继续播放 bool _isContinueQqMusicPlaying = false; // 是否继续播放QQ音乐 // 存储相关配置 static const String _sourceLanguageKey = 'translation_source_language'; static const String _targetLanguageKey = 'translation_target_language'; static const String _sourceLanguageUserSetKey = 'translation_source_language_user_set'; // 添加悬浮按钮位置存储键 static const String _floatingButtonPositionKey = 'floating_button_position'; // 分页加载相关状态 final pageSize = 20; // 每页记录数量 bool _scrollListenerSetup = false; // 滚动监听器设置标志 // ==================== 状态变量 ==================== RxInt activeSpeaker = 0.obs; // 0=无激活, 1=按钮1, 2=按钮2 RxInt lastActiveSpeaker = 0.obs; // 上一次状态 /// 面对面翻译当前的触发来源,见 [FaceToFaceTrigger] final faceToFaceTrigger = FaceToFaceTrigger.client.obs; final isRecognizing = false.obs; final isPreparing = false.obs; final isTranslating = false.obs; final isTtsEnabled = true.obs; final isRecording = false.obs; final isCreateRecord = false.obs; final hasRecordPermission = false.obs; // 本次录音文件路径,供 stopRecording 归档时用;null 表示本轮没开过录音 String? _currentRecordingPath; // 添加悬浮按钮位置状态 final floatingButtonPosition = Rx(Offset(2.0, 100.0)); // 默认位置 // ==================== 语言相关 ==================== final sourceLanguage = '中文(简体)'.tr.obs; final targetLanguage = '英语'.tr.obs; final sourceLanguageCode = 'zh-CN'.obs; final targetLanguageCode = 'en-US'.obs; var detectedLanguageCode = ''; final detectedLanguageCode1 = ''.obs; final latestRecognitionText = ''.obs; final faceToFaceRecognitionText = ''.obs; // ==================== 翻译相关 ==================== var currsessionid = ''; final currentModeTitle = 'simultaneousTranslation'.obs; final currentMode = 'simultaneous'.obs; //String? fTFTranslationResult = ''; String? currentSessionId; final appDocDir = getApplicationDocumentsDirectory(); var appDir; var dir; late Future _initServicesFuture; // ==================== 管理器 ==================== late final RecordTimerController _timerManager; late final TranslationHistoryManager _historyManager; // ==================== 访问器 ==================== RxString get recordDurationText => _timerManager.recordDurationText; RxList get translationHistory => _historyManager.translationHistory; ScrollController get scrollController => _historyManager.scrollController; RxBool get hasMoreHistory => _historyManager.hasMoreHistory; RxBool get isLoadingMore => _historyManager.isLoadingMore; RxBool get isLoadingMoreError => _historyManager.isLoadingMoreError; Map get supportedLanguages => _languageManager.getChineseNameToAsrCodeMap(); // ==================== 流订阅 ==================== StreamSubscription? _recognitionSubscription; StreamSubscription? _astEventSubscription; StreamSubscription? _voiceInteractionSubscription; StreamSubscription? _deviceCallTranslationSubscription; // 通话翻译 ↔ 杰理桥接: // inUplink/inDownlink → AstService.pushExternalAudio // AstService.astTtsFrames → BleManager.feedAstTtsPcm StreamSubscription? _jieliCallInUplinkSub; StreamSubscription? _jieliCallInDownlinkSub; StreamSubscription? _jieliCallAstTtsSub; // 通话翻译 ↔ 恒玄桥接(与上面杰理那套二选一,见 _configureCallMode): // micPcmStream(本端) / spkPcmStream(对端) → AstService.pushExternalAudio // AstService.astTtsFrames → BesBluetoothService.pushTranslationTtsPcm StreamSubscription? _besCallMicSub; StreamSubscription? _besCallSpkSub; StreamSubscription? _besCallAstTtsSub; // 本次通话翻译会话用的是恒玄链路还是杰理链路——停止/出错收尾时按这个分流, // 不要临停时再去查 DeviceConnection.besConnected(这时耳机可能已经断开了) bool _callModeUsingBes = false; // 一次通话模式会话的设备侧收尾(AA52/AA58 或 bleManager.closeCodec)只应该 // 真正执行一次——stopAll() 和 AST 流异步报错触发的 _terminateCallOnError() // 都可能收尾同一个会话,不加锁耳机会收到两遍关闭指令。见 _teardownCallModeAudio。 bool _callAudioTornDown = true; // 监听恒玄耳机的通话状态:挂断(0xD1/0xD2/0x02/0x03 → isCallOngoing=false) // 时如果正在通话翻译,自动停止识别,不用等用户手动点停止 Worker? _besCallOngoingWorker; // Android 悬浮窗"被用户叉掉"的事件订阅 — 用于在 audioVideo 翻译中 // 把"关掉浮窗"语义升级为"结束翻译",避免后台还在跑识别 + 录屏 StreamSubscription? _floatingWindowClosedSubscription; Timer? _translationDebounceTimer; // ==================== 重新初始化守卫 ==================== bool _isReinitializing = false; // 语言切换重新初始化期间为 true,抑制旧会话的错误事件 // 初始化/切换 provider 完成后的宽限期,用于过滤底层异步 dispose 旧 session 时产生的残留错误事件(如 1012/1011) DateTime? _astErrorGraceUntil; // 语言切换互斥:连续切换语言时串行执行,避免并发的 _reinitializeAsrService 导致 // 原生 AST 已启动但 isRecognizing 仍为 false 的状态不一致。 Future? _languageSwitchChain; // 播放防重复机制 String? _lastPlayedItemKey; // 最后播放的项目标识 int _lastPlayTime = 0; // 最后播放时间 static const int _playDebounceInterval = 500; // 防抖间隔:500毫秒 // 底部控制栏高度 double _bottomBarHeight = 0.0; /// 初始化安卓悬浮窗管理器 void _initFloatingWindowManager() { try { if (!Get.isRegistered()) { Get.put(FloatingWindowManager(), permanent: true); } _floatingWindowManager = Get.find(); // 监听用户主动关闭浮窗:在 Android 音视频翻译里浮窗就是用户唯一能看 // 翻译的入口,叉掉它意味着"结束翻译",顺势停掉识别 + 录屏前台服务, // 避免后台空跑。其它模式(call 等)保持原行为,仅同步本地标志位。 _floatingWindowClosedSubscription?.cancel(); _floatingWindowClosedSubscription = _floatingWindowManager.onClosedByUser.listen((_) { Logger.info('悬浮窗被用户关闭,同步翻译会话状态'); isFloatingWindowEnabled.value = false; if (currentMode.value == 'audioVideo' && (isRecognizing.value || isPreparing.value)) { stopAll(); } }); Logger.info('悬浮窗管理器初始化完成'); } catch (e) { Logger.error('悬浮窗管理器初始化失败: $e'); } } /// 初始化苹果悬浮窗管理器 Future initPlatformState() async { _pip = Pip(); _nativePlugin = NativePlugin(); try { isFloatingWindowEnabled.value = await _pip.isActived(); await _pip.registerStateChangedObserver( PipStateChangedObserver( onPipStateChanged: (state, error) { isFloatingWindowEnabled.value = state == PipState.pipStateStarted; if (state == PipState.pipStateFailed) { _isPipExpanded = false; _pip.dispose(); } else if (state == PipState.pipStateStopped) { _isPipExpanded = false; } }, ), ); } on PlatformException { isFloatingWindowEnabled.value = false; } } /// 切换悬浮窗功能 Future toggleFloatingWindow() async { if (isFloatingWindowEnabled.value) { if (Platform.isAndroid) { await _disableFloatingWindow(); } else if (Platform.isIOS) { _isPipExpanded = false; await _pip.dispose(); } } else { if (Platform.isAndroid) { await _enableFloatingWindow(); } else if (Platform.isIOS) { await _startPip(); } } } PipOptions _buildIosPipOptions({required int preferredHeight}) { return PipOptions( autoEnterEnabled: true, aspectRatioX: _pipPreferredWidth, aspectRatioY: preferredHeight, sourceRectHintLeft: 0, sourceRectHintTop: 0, sourceRectHintRight: 0, sourceRectHintBottom: 0, seamlessResizeEnabled: true, useExternalStateMonitor: true, externalStateMonitorInterval: 100, contentView: _pipContentView, preferredContentWidth: _pipPreferredWidth, preferredContentHeight: preferredHeight, controlStyle: 2, ); } Future _startPip() async { if (_pipContentView == 0) { _pipContentView = await _nativePlugin.createPipContentView(); } _isPipExpanded = false; await _pip.setup( _buildIosPipOptions(preferredHeight: _pipCompactPreferredHeight)); await _pip.start(); } /// 启用悬浮窗功能 Future _enableFloatingWindow() async { // 检查是否在支持悬浮窗的模式 if (!_isFloatingWindowSupportedMode()) { // Get.snackbar( // 'unsupportedMode'.tr, // '悬浮窗仅支持通话和音视频翻译模式', // snackPosition: SnackPosition.TOP, // ); return; } final bool success = await _floatingWindowManager.enableFloatingWindow(); if (success) { isFloatingWindowEnabled.value = true; await _floatingWindowManager.showFloatingWindow(); // 如果当前有翻译内容,立即更新到悬浮窗 if (translationHistory.isNotEmpty) { final latestItem = translationHistory.last; await _updateFloatingWindowWithTranslation(latestItem); } // Get.snackbar( // '悬浮窗已启用', // '翻译结果将在悬浮窗中显示', // snackPosition: SnackPosition.TOP, // ); } } /// 禁用悬浮窗功能 Future _disableFloatingWindow() async { await _floatingWindowManager.disableFloatingWindow(); isFloatingWindowEnabled.value = false; // Get.snackbar( // '悬浮窗已禁用', // '翻译结果将在应用内显示', // snackPosition: SnackPosition.TOP, // ); } /// 检查当前模式是否支持悬浮窗 bool _isFloatingWindowSupportedMode() { return currentMode.value == 'call' || currentMode.value == 'audioVideo'; } /// 更新悬浮窗翻译内容 Future _updateFloatingWindowWithTranslation( TranslationItem item) async { if (!isFloatingWindowEnabled.value) return; final sourceLanguageName = _languageManager.getChineseNameByAsrCode(item.sourceLanguageCode) ?? 'unknown'; final targetLanguageName = _languageManager.getChineseNameByAsrCode(item.targetLanguageCode) ?? 'unknown'; if (Platform.isAndroid) { final data = FloatingWindowData( sourceText: item.sourceText, translatedText: item.translatedText, sourceLanguage: sourceLanguageName, targetLanguage: targetLanguageName, timestamp: item.timestamp, isIntermediate: item.isIntermediate, ); await _floatingWindowManager.updateFloatingWindowContent(data); } else if (Platform.isIOS) { final hasContent = item.sourceText.trim().isNotEmpty || item.translatedText.trim().isNotEmpty; if (hasContent && !_isPipExpanded) { final ok = await _pip.setup( _buildIosPipOptions(preferredHeight: _pipExpandedPreferredHeight)); if (ok) _isPipExpanded = true; } else if (!hasContent && _isPipExpanded) { final ok = await _pip.setup( _buildIosPipOptions(preferredHeight: _pipCompactPreferredHeight)); if (ok) _isPipExpanded = false; } await _nativePlugin.updatePipContentView({ 'viewId': _pipContentView, 'cn': item.sourceText, 'en': item.translatedText, 'lang': '$sourceLanguageName -> $targetLanguageName', }); } } /// 更改翻译模式 /// 参数: /// - [mode] 翻译模式:simultaneous(同声传译)、audioVideo(音视频)、call(通话)、faceToFace(面对面) /// 返回值:无 /// 异常:可能抛出底层服务调用异常 Future changeTranslationMode(String mode) async { currentMode.value = mode; // 每次进/出面对面翻译都回到客户端触发;设备端触发由耳机按键在 // [setActiveSpeaker] 里重新标记,避免上一轮的设备状态残留下来 // 导致这一轮点开始按钮又不响应。 faceToFaceTrigger.value = FaceToFaceTrigger.client; activeSpeaker.value = 0; lastActiveSpeaker.value = 0; currentModeTitle.value = _getModeTitle(mode); _historyManager.setMode(mode); loadTranslationHistory(); //stopAll(); if (mode == 'faceToFace' || mode == 'call' || mode == 'simultaneous') { await suspendOtherServices(); } if (Platform.isAndroid && (mode == 'call' || mode == 'audioVideo')) { try { _floatingWindowManager.setLastTranslationMode(mode); } catch (_) {} } //设置播报的模式 if (mode == 'simultaneous') { await _ttsService.setTtsMode('phone_call'); } else if (mode == 'faceToFace') { await _ttsService.setTtsMode('push_to_talk'); } else { await _ttsService.setTtsMode('normal'); } // 检查悬浮窗支持情况 if (isFloatingWindowEnabled.value && !_isFloatingWindowSupportedMode()) { // 当前模式不支持悬浮窗,自动禁用 await _disableFloatingWindow(); // Get.snackbar( // '模式已切换', // '悬浮窗仅支持通话和音视频翻译模式', // snackPosition: SnackPosition.TOP, // ); } else if (!isFloatingWindowEnabled.value && _isFloatingWindowSupportedMode()) { // 切换到支持悬浮窗的模式,提示用户可以启用 // Get.snackbar( // '模式已切换', // '可以启用悬浮窗显示翻译结果', // snackPosition: SnackPosition.TOP, // duration: const Duration(seconds: 4), // ); } if (mode == 'call') { await _initializeCallModeTranslationService(); _astAutoStartPending = true; } else { await _reinitializeAsrService(); } } /// 根据模式获取标题 /// [mode] 翻译模式 /// 返回对应的标题字符串 String _getModeTitle(String mode) { switch (mode) { case 'audioVideo': return 'audioVideoTranslation'; case 'call': return 'callTranslation'; case 'faceToFace': return 'faceToFaceTranslation'; default: return 'simultaneousTranslation'; } } // ==================== 计时器相关方法 ==================== /// 暂停录音计时器 void pauseRecordTimer() => _timerManager.pauseTimer(); /// 恢复录音计时器 void resumeRecordTimer() => _timerManager.resumeTimer(); /// 重置录音计时器 void resetRecordTimer() => _timerManager.resetTimer(); /// 设置激活的说话者(设备端「按住说话」入口) /// /// [id] 说话者ID:0=松开, 1=左耳, 2=右耳 /// /// 这是**耳机按键事件的接入点**:一旦被以 1/2 调用,就说明本轮面对面翻译由 /// 设备端驱动,触发来源随之切到 [FaceToFaceTrigger.device],收音也跟着走 /// 设备端那一路(见 [_configureFaceToFaceMode])。固件就绪后把左右耳按键事件 /// 接到这里即可,其余业务逻辑不用动。 Future setActiveSpeaker(int id) async { if (id != 0) { faceToFaceTrigger.value = FaceToFaceTrigger.device; } await _handleFaceToFaceHoldToTalk(id); } /// 处理面对面翻译的按住说话逻辑 /// [id] 说话者ID Future _handleFaceToFaceHoldToTalk(int id) async { if (id == 0) { // 松开按钮,停止当前激活的说话者 if (activeSpeaker.value != 0) { lastActiveSpeaker.value = activeSpeaker.value; // 记录上一次激活的说话者 activeSpeaker.value = id; await _stopFaceToFaceRecognition(); } } else { // 按下按钮,只有在没有其他按钮激活时才能激活 if (activeSpeaker.value == 0) { activeSpeaker.value = id; await _startFaceToFaceRecognition(id); } } } /// 开始面对面翻译的语音识别 /// [speakerId] 说话者ID Future _startFaceToFaceRecognition(int speakerId) async { try { if (User.isLoggedIn() && User.instance.tradeintegral <= 0) { showRechargeDialog('translation'); activeSpeaker.value = 0; return; } if (_asrSessionStartTime == null) { await _initializeSession(); } // 按触发来源定收音路径(设备端触发 → 设备端收音) await _configureAudioMode(); await _asrService.startContinuousRecognition( _audioSourceType, isRemoveFirstPunctuation: true, mode: 'push_to_talk', ); //开启识别 _startAsrActiveTracking(); //开启识别活动跟踪 if (isRecording.value) { if (isCreateRecord.value) { //已经创建文件 await continueRecording(); } else { await startRecording(); } } Logger.info('面对面翻译:激活说话者 $speakerId'); } catch (e) { Logger.error('启动面对面翻译失败: ${e.toString()}'); activeSpeaker.value = 0; } } /// 停止面对面翻译的语音识别 Future _stopFaceToFaceRecognition() async { try { if (isRecording.value) { await pauseRecording(); //暂停 } if (faceToFaceRecognitionText.value.isNotEmpty) { handleFinalResult(faceToFaceRecognitionText.value); faceToFaceRecognitionText.value = ''; } await _asrService.stopContinuousRecognition(); _stopAsrActiveTracking(); //停止识别活动跟踪 _recordAsrUsageStats(sourceLanguage.value); isRecognizing.value = false; currentSessionId = null; Logger.info('面对面翻译:停止语音识别'); } catch (e) { Logger.error('停止面对面翻译失败: ${e.toString()}'); } } /// 获取当前翻译项(兼容旧版视图) Rx get currentItem { if (translationHistory.isNotEmpty && translationHistory.last.isIntermediate) { return Rx(translationHistory.last); } return Rx(null); } @override void onInit() { super.onInit(); // 初始化管理器 _timerManager = RecordTimerController(); _historyManager = TranslationHistoryManager(); if (Platform.isAndroid) { // 初始化安卓悬浮窗管理器 _initFloatingWindowManager(); } else if (Platform.isIOS) { // 初始化苹果悬浮窗管理器 initPlatformState(); } // 预先检查权限,避免在用户交互时阻塞 _preCheckPermissions(); // 初始化统计服务 _initUsageService(); // 加载上次保存的语言设置 _loadSavedLanguages(); // 初始化语言代码 final sourceAsrCode = _languageManager.getAsrCodeByChineseName(sourceLanguage.value); if (sourceAsrCode != null) { sourceLanguageCode.value = sourceAsrCode; } final targetAsrCode = _languageManager.getAsrCodeByChineseName(targetLanguage.value); if (targetAsrCode != null) { targetLanguageCode.value = targetAsrCode; } // 初始化翻译模式(用于历史隔离的初始加载) final initMode = Get.arguments?['translationId'] ?? 'simultaneous'; _historyManager.setMode(initMode); // 按初始模式加载历史 loadTranslationHistory(); // 初始化服务 _initServicesFuture = _initServices(); // 初始化历史管理器 _historyManager.initialize(); // 应用模式逻辑(含ASR/TTS配置等) // ⚠️ 这是个 async 方法,里面才会按当前语言对去 initialize ASR。 // 不接住这个 Future 的话,用户进页面立刻点「开始」会抢在它前面: // AzureAsrService.startContinuousRecognition 那条 `if (!_isInitialized)` // 兜底分支**不带语言**,会落到默认的 ['zh-CN','en-US']—— // 选了别的语种就等于拿错的候选语言在做识别。startRecognition 会先 await 它。 _modeInitFuture = changeTranslationMode(initMode); // 监听设备上报的通话翻译开关 (0x16 / 0x17) _setupDeviceCallTranslationListener(initMode); // 监听恒玄耳机通话状态,挂断时自动停止通话翻译 _setupBesCallHangupListener(); // 监听应用生命周期:当应用切到后台/失焦时兜底重置 activeSpeaker WidgetsBinding.instance.addObserver(this); } /// 监听 BleManager 下发的设备通话翻译开关信号 (0x16 / 0x17) /// - 收到 true: 若当前非 call 模式则切换到 call 模式,并自动启动识别; /// - 收到 false: 停止当前识别(stopAll)。 /// 首次进入时若 autoStart=true,也立即触发一次开启。 void _setupDeviceCallTranslationListener(String initMode) { _deviceCallTranslationSubscription?.cancel(); _deviceCallTranslationSubscription = bleManager.onCallTranslationRequest.listen((enable) async { Logger.info( '[CALL_TRANS_CMD] TranslationController 收到信号: ${enable ? "开启" : "关闭"}'); try { if (enable) { if (currentMode.value != 'call') { await changeTranslationMode('call'); } if (!isRecognizing.value) { await startRecognition(); } } else { await stopAll(); } } catch (e, s) { Logger.e('TRANSL', '[CALL_TRANS_CMD] 处理设备通话翻译信号失败: $e', e, s); } }); // 支持从 BleManager 导航进入时携带 autoStart 参数自动开启 final autoStart = Get.arguments?['autoStart'] == true; if (autoStart && initMode == 'call') { Logger.info('[CALL_TRANS_CMD] 检测到 autoStart=true, 进入通话翻译并自启动'); Future.microtask(() async { try { // 等待服务初始化完成(含 AST 端到端服务),再启动完整识别流程 await _initServicesFuture; if (!isRecognizing.value) { await startRecognition(); } } catch (e, s) { Logger.e('TRANSL', '[CALL_TRANS_CMD] autoStart 启动失败: $e', e, s); } }); } } /// 监听恒玄耳机的通话状态(0xD1/0xD2/0x02/0x03 → BesBluetoothService.isCallOngoing)。 /// 挂断(isCallOngoing 变 false)时,如果正在通话翻译,自动停止识别—— /// 不然识别会一直空跑,AST 也拿不到新音频,只能等用户自己发现再手动停。 void _setupBesCallHangupListener() { _besCallOngoingWorker?.dispose(); if (!Get.isRegistered()) return; _besCallOngoingWorker = ever( BesBluetoothService.to.isCallOngoing, (calling) async { if (calling) return; if (currentMode.value != 'call') return; if (!isRecognizing.value && !isPreparing.value) return; Logger.info('[CALL-BRIDGE-BES] 通话已挂断,自动停止通话翻译识别'); try { await stopAll(); } catch (e, s) { Logger.e('TRANSL', '[CALL-BRIDGE-BES] 挂断自动停止失败: $e', e, s); } }, ); } // @override // void onReady() { // super.onReady(); // // 初始化翻译模式,根据跳转参数 // final mode = Get.arguments?['translationId'] ?? 'simultaneous'; // changeTranslationMode(mode); // } /// 预先检查权限 Future _preCheckPermissions() async { try { await requestRecordPermission(); } catch (e) { Logger.e("Permission", "预检查权限失败: $e"); } } /// 初始化用量统计服务 void _initUsageService() { try { // 确保统计服务已注册 if (!Get.isRegistered()) { Get.put(UsageStatsService(), permanent: true); } _usageService = Get.find(); Logger.info('使用量统计服务初始化完成'); } catch (e) { Logger.error('使用量统计服务初始化失败: $e'); } } /// 加载保存的语言设置 void _loadSavedLanguages() { final appDefaultSourceLanguage = _getSourceLanguageByAppLocale(); final savedSourceLanguage = _storage.read(_sourceLanguageKey); final savedTargetLanguage = _storage.read(_targetLanguageKey); final bool isSourceLanguageUserSet = (_storage.read(_sourceLanguageUserSetKey) ?? false) == true; final bool isSavedSourceValid = savedSourceLanguage != null && supportedLanguages.containsKey(savedSourceLanguage); final bool isSavedTargetValid = savedTargetLanguage != null && supportedLanguages.containsKey(savedTargetLanguage); if (isSourceLanguageUserSet && isSavedSourceValid) { sourceLanguage.value = savedSourceLanguage; } else if (appDefaultSourceLanguage != null && supportedLanguages.containsKey(appDefaultSourceLanguage)) { sourceLanguage.value = appDefaultSourceLanguage; } else if (isSavedSourceValid) { sourceLanguage.value = savedSourceLanguage; } if (isSavedTargetValid) { targetLanguage.value = savedTargetLanguage; } // 加载悬浮按钮位置 _loadFloatingButtonPosition(); } /// 加载悬浮按钮位置 void _loadFloatingButtonPosition() { final savedPosition = _storage.read(_floatingButtonPositionKey); if (savedPosition != null && savedPosition is Map) { final dx = savedPosition['dx']?.toDouble() ?? 2.0; final dy = savedPosition['dy']?.toDouble() ?? 100.0; floatingButtonPosition.value = Offset(dx, dy); } } /// 保存悬浮按钮位置 void saveFloatingButtonPosition(Offset position) { floatingButtonPosition.value = position; _storage.write(_floatingButtonPositionKey, { 'dx': position.dx, 'dy': position.dy, }); } void _saveSourceLanguageSetting({required bool isUserSet}) { _storage.write(_sourceLanguageKey, sourceLanguage.value); if (isUserSet) { _storage.write(_sourceLanguageUserSetKey, true); } } void _saveTargetLanguageSetting() { _storage.write(_targetLanguageKey, targetLanguage.value); } ///根据当前系统语言获取默认源语言 String? _getSourceLanguageByAppLocale() { final Locale? locale = Get.locale ?? Get.deviceLocale; if (locale == null) return null; final languageCode = locale.languageCode.toLowerCase(); final countryCode = locale.countryCode?.toUpperCase(); final scriptCode = locale.scriptCode; if (languageCode == 'zh') { if (countryCode == 'HK' || countryCode == 'MO') { return _languageManager.getChineseNameByAsrCode('zh-HK'); } if (countryCode == 'TW') { return _languageManager.getChineseNameByAsrCode('zh-TW'); } if (scriptCode == 'Hant') { return _languageManager.getChineseNameByAsrCode('zh-TW'); } return _languageManager.getChineseNameByAsrCode('zh-CN'); } final normalizedShortCode = switch (languageCode) { 'nb' => 'no', _ => languageCode, }; return _languageManager.getChineseNameByShortCode(normalizedShortCode); } /// 请求录音权限 /// 返回是否授权成功 Future requestRecordPermission() async { try { hasRecordPermission.value = await PermissionUtil.instance.requestPermission( permissionType: Permission.microphone, permissionName: 'microphonePermission'.tr, explanationText: 'microphonePermissionExplanation'.tr, permanentDenialText: 'microphonePermissionPermanentDenial'.tr, ); if (hasRecordPermission.value) { Logger.d("Permission", "用户授予了录音权限"); return true; } else { Logger.d("Permission", "用户未授予录音权限"); return false; } } catch (e) { Logger.e("Permission", "请求录音权限时出错: $e"); return false; } } /// 请求存储权限 /// 返回是否授权成功 Future _requestStoragePermission() async { // 只在Android 10及以下版本请求存储权限 if (Platform.isAndroid) { try { final deviceInfo = DeviceInfoPlugin(); final androidInfo = await deviceInfo.androidInfo; final sdkVersion = androidInfo.version.sdkInt; // Android 11+ (API 30+) 不需要存储权限 if (sdkVersion >= 30) { Logger.d("Permission", "Android 11+版本,无需请求存储权限"); return true; // 直接返回true,表示可以使用存储 } } catch (e) { Logger.e("Permission", "获取Android版本信息失败: $e"); // 如果获取版本失败,为了兼容性还是请求权限 } } try { final bool granted = await PermissionUtil.instance.requestPermission( permissionType: Permission.storage, permissionName: 'storagePermission'.tr, explanationText: 'storagePermissionExplanation'.tr, permanentDenialText: 'storagePermissionPermanentDenial'.tr, ); if (granted) { Logger.d("Permission", "用户授予了存储权限"); return true; } else { Logger.d("Permission", "用户未授予存储权限"); return true; } } catch (e) { Logger.e("Permission", "请求存储权限时出错: $e"); return false; } } /// 暂停其他服务(如音乐播放) /// 参数:无 /// 返回值:无 /// 异常:内部捕获并记录日志,不向外抛出 Future suspendOtherServices() async { try { if (!isContinuePlaying) { final status = await _musiceManager.getStatus(); final bool isPlaying = status['isPlaying'] ?? false; if (isPlaying == true) { isContinuePlaying = true; await _musiceManager.pause(); } } if (!_isContinueQqMusicPlaying) { if (await _qqMusicManager.autopauseMusic()) { _isContinueQqMusicPlaying = true; } } } catch (e) { Logger.error('暂停其他服务失败: ${e.toString()}'); } } /// 恢复其他服务(如音乐播放) /// 参数:无 /// 返回值:无 /// 异常:内部捕获并记录日志,不向外抛出 Future restoreOtherServices() async { try { if (isContinuePlaying) await _musiceManager.resume(); if (_isContinueQqMusicPlaying) await _qqMusicManager.resumeMusic(); } catch (e) { Logger.error('恢复其他服务失败: ${e.toString()}'); } finally { isContinuePlaying = false; _isContinueQqMusicPlaying = false; } } /// 初始化所有服务 Future _initServices() async { try { await _translationService.initialize(); // 初始化 ASR 服务时,明确指定需要支持的语言 // final List asrSupportedLanguages = [ // sourceLanguageCode.value, // targetLanguageCode.value // ]; //Logger.info('2初始化ASR服务,支持语言: $asrSupportedLanguages'); //await _asrService.initialize(supportedLanguages: asrSupportedLanguages); // 获取识别事件流 // var recognitionStream = await _asrService.recognizeCallback(); // _recognitionSubscription?.cancel(); // _recognitionSubscription = // recognitionStream.listen(_handleRecognitionEvent); final bool storage = await _requestStoragePermission(); if (!storage) { Logger.d("Permission", "未授予存储权限,无法提供录音功能"); return; } appDir = await appDocDir; dir = Directory("${appDir.path}/TranslatAudio"); if (!await dir.exists()) { await dir.create(recursive: true); } Logger.info('所有服务初始化完成'); } catch (e) { Logger.error('初始化服务失败: ${e.toString()}'); } } /// 初始化通话模式的语音翻译服务 Future _initializeCallModeTranslationService() async { try { Logger.info('开始初始化通话模式语音翻译服务'); final sourceShort = _languageManager.getShortCodeByAsrCode(sourceLanguageCode.value) ?? 'zh'; final targetShort = _languageManager.getShortCodeByAsrCode(targetLanguageCode.value) ?? 'en'; final sourceName = _languageManager.getChineseNameByAsrCode(sourceLanguageCode.value) ?? '?'; final targetName = _languageManager.getChineseNameByAsrCode(targetLanguageCode.value) ?? '?'; Logger.info( '[TRANS-INIT] 通话翻译语言对: $sourceName($sourceShort/${sourceLanguageCode.value}) -> $targetName($targetShort/${targetLanguageCode.value})'); final bestProvider = _languageManager.findBestMatchingProvider(sourceShort, targetShort); final astProvider = bestProvider?.provider ?? 'azure'; _lastCallAstProvider = astProvider; if (bestProvider == null) { Logger.warning( '[TRANS-INIT] 未找到匹配的提供商,回退到默认 ${LanguageManager.providerDisplayName('azure')}[azure](asrCode=${sourceLanguageCode.value} -> ${targetLanguageCode.value})'); } // 构建完整的语言参数:[asrCode0, asrCode1, transCode0, transCode1, ttsVoice0, ttsVoice1] final transCode0 = bestProvider?.sourceSpec['translationCode'] ?? sourceLanguageCode.value; final transCode1 = bestProvider?.targetSpec['translationCode'] ?? targetLanguageCode.value; final ttsVoice0 = bestProvider?.sourceSpec['ttsCode'] ?? (_languageManager.getTtsVoiceNameByAsrCode(sourceLanguageCode.value) ?? 'zh-CN-XiaoxiaoNeural'); final ttsVoice1 = bestProvider?.targetSpec['ttsCode'] ?? (_languageManager.getTtsVoiceNameByAsrCode(targetLanguageCode.value) ?? 'en-US-AriaNeural'); final List callModeLanguages = [ sourceLanguageCode.value, // [0] ASR source targetLanguageCode.value, // [1] ASR target transCode0, // [2] translation source transCode1, // [3] translation target ttsVoice0, // [4] TTS voice source ttsVoice1, // [5] TTS voice target ]; Logger.info( '[TRANS-INIT] 最终启动服务: ${LanguageManager.providerDisplayName(astProvider)}[$astProvider], asrSrc=${callModeLanguages[0]}, asrTgt=${callModeLanguages[1]}, transSrc=${callModeLanguages[2]}, transTgt=${callModeLanguages[3]}, ttsSrc=${callModeLanguages[4]}, ttsTgt=${callModeLanguages[5]}'); await _astService.initialize(supportedLanguages: callModeLanguages, provider: astProvider); // 获取识别事件流(端到端服务已包含 ASR+翻译+TTS,不需要单独启动 ASR) var astStream = await _astService.recognizeCallback(); _astEventSubscription?.cancel(); _astEventSubscription = astStream.listen(_handleAstEvent, onError: (e) { Logger.error('[STS] astStream 错误: $e'); }, onDone: () { Logger.info('[STS] astStream 已关闭'); }); Logger.info('[STS] 已订阅 astStream, subscription=${_astEventSubscription.hashCode}'); // 初始化完成后给 2s 宽限期,过滤掉底层异步 dispose 旧 provider session 时产生的 // cancelled(1012) / Socket not connected(1011) 残留事件,避免被误判为新 session 错误 _astErrorGraceUntil = DateTime.now().add(const Duration(milliseconds: 2000)); Logger.info('通话模式语音翻译服务初始化完成'); } catch (e) { Logger.error('通话模式语音翻译服务初始化失败: ${e.toString()}'); } } @override void onClose() { stopAll(); _recognitionSubscription?.cancel(); _recognitionSubscription = null; _astEventSubscription?.cancel(); _astEventSubscription = null; _voiceInteractionSubscription?.cancel(); _deviceCallTranslationSubscription?.cancel(); _deviceCallTranslationSubscription = null; _besCallOngoingWorker?.dispose(); _besCallOngoingWorker = null; _floatingWindowClosedSubscription?.cancel(); _floatingWindowClosedSubscription = null; _detachJieliCallTranslationBridge(); _detachBesCallTranslationBridge(); _translationDebounceTimer?.cancel(); restoreOtherServices(); // 移除对服务的 dispose,避免频繁销毁/重建造成卡顿 // _asrService.dispose(); // _astService.dispose(); if (Platform.isAndroid) { _disableFloatingWindow(); } else if (Platform.isIOS) { _pip.dispose(); if (_pipContentView != 0) { _nativePlugin.disposePipContentView(_pipContentView); } } // 释放管理器资源 _timerManager.dispose(); _historyManager.dispose(); // 取消应用生命周期监听 WidgetsBinding.instance.removeObserver(this); super.onClose(); } /// 应用生命周期变化回调 /// 当应用进入后台、失去焦点或分离时,兜底将 activeSpeaker 重置为 0 @override void didChangeAppLifecycleState(AppLifecycleState state) { if (state == AppLifecycleState.inactive || state == AppLifecycleState.paused || state == AppLifecycleState.detached) { if (activeSpeaker.value != 0) { Future.microtask(() { setActiveSpeaker(0); }); } } } /// 切换TTS功能开关 void toggleTts() => isTtsEnabled.toggle(); /// 切换录音功能开关 Future toggleisRecord() async { isRecording.toggle(); // 设备端触发的面对面翻译是逐句按住说话,录音由 _startFaceToFaceRecognition // 自己按轮次开/续,这里不插手;客户端触发是连续识别,和其它模式一样立即开录。 if (isRecording.value && isRecognizing.value && !_isFaceToFaceDeviceMode) { startRecording(); } else if (!isRecording.value) { stopRecording(); } } /// 开始录音 /// /// - call 模式:走 [_astService],原生将录制立体声 WAV /// (左=本端麦克风原音,右=对方翻译后的合成音)。 /// - 其他模式:保持原有 [_asrService] 录制逻辑。 Future startRecording() async { // 等待服务初始化完成,确保 dir 已赋值 await _initServicesFuture; _timerManager.startTimer(); final formattedTime = DateFormat('yyyyMMdd_HHmmss').format(DateTime.now()); // 文件名即语音记录助手里的标题,前缀按 RecordingArchive 的约定走: // trans_call_* 通话翻译 // trans_holder_* 同声翻译 // 其它模式(音视频/面对面)暂不归档,沿用本地化标题命名。 // ⚠️ 别把这里的前缀和下面 stopRecording 的归档条件改岔了—— // 命名对不上约定,归档进去的标题就成了「同声传译_2026...」这种, // 列表里跟别的来源混在一起认不出。 final String filePath = _shouldArchiveRecording ? "${dir.path}/${_archivePrefix}_$formattedTime.wav" : "${dir.path}/${currentModeTitle.value.tr}_$formattedTime.wav"; _currentRecordingPath = filePath; if (currentMode.value == 'call') { await _astService.enableRecord(filePath); } else { AudioSourceType audioSourceType; if (currentMode.value == 'audioVideo') { audioSourceType = AudioSourceType.systemAudio; } else { audioSourceType = AudioSourceType.microphone; } await _asrService.enableRecord(audioSourceType, filePath, false); } isCreateRecord.value = true; } /// 停止录音 /// /// 通话翻译的录音停止后归档进「语音记录助手」,跟通话录音 /// ([BesCallRecordingService])看齐——之前只是写进了 App 自己的 /// TranslatAudio 目录,主列表里看不到。录音本身仍然是用户手动开的 /// (录音按钮),不是通话一开始就默认录,合规要求不变。 Future stopRecording() async { // stopTimer() 会把 _seconds 清零,归档要用的时长必须在它之前取 final int seconds = _timerManager.currentSeconds; final String? path = _currentRecordingPath; _currentRecordingPath = null; _timerManager.stopTimer(); if (currentMode.value == 'call') { await _astService.stopRecord(true); } else { await _asrService.stopRecord(true); } isCreateRecord.value = false; Logger.info('录音已停止,计时器已重置'); if (_shouldArchiveRecording && path != null && seconds > 0) { // ⚠️ 不 await:归档里有一次 echomeet_addrecord 网络请求(Dio 30s // connect + 30s receive),await 会把「点结束」一路堵到超时。 // 归档本身不依赖 controller(RecordingArchive 是静态的), // 页面退了也能跑完。 unawaited(RecordingArchive.archive( path: path, seconds: seconds, tag: 'TranslationController', )); } } /// 这个模式的录音要不要进「语音记录助手」。 /// /// 通话翻译和同声翻译要;音视频翻译录的是系统声音、面对面翻译是逐句 /// 按轮次开停的(见 _startFaceToFaceRecognition),两者的归档语义还没定, /// 先不动。 bool get _shouldArchiveRecording => currentMode.value == 'call' || currentMode.value == 'simultaneous'; /// 归档文件名前缀,取值见 RecordingArchive 的约定。 String get _archivePrefix => currentMode.value == 'call' ? 'trans_call' : 'trans_holder'; /// 暂停录音 Future pauseRecording() async { _timerManager.pauseTimer(); if (currentMode.value == 'call') { await _astService.pauseRecord(); } else { await _asrService.pauseRecord(); } Logger.info(' 录音已暂停'); } /// 继续录音 Future continueRecording() async { _timerManager.resumeTimer(); if (currentMode.value == 'call') { await _astService.resumeRecord(); } else { await _asrService.resumeRecord(); } Logger.info(' 录音已继续'); } /// 开始语音识别 /// 同声翻译、通话翻译、多媒体翻译的语音识别调用入口 Future startRecognition() async { Logger.info('开始语音识别'); final srcName = _languageManager.getChineseNameByAsrCode(sourceLanguageCode.value) ?? '?'; final tgtName = _languageManager.getChineseNameByAsrCode(targetLanguageCode.value) ?? '?'; if (currentMode.value == 'call') { final p = _lastCallAstProvider.isEmpty ? 'azure(未初始化)' : _lastCallAstProvider; Logger.info( '[TRANS-INIT] startRecognition: mode=call, 语言对=$srcName(${sourceLanguageCode.value}) -> $tgtName(${targetLanguageCode.value}), 服务=${LanguageManager.providerDisplayName(p)}[$p]'); } else { Logger.info( '[TRANS-INIT] startRecognition: mode=${currentMode.value}, 语言对=$srcName(${sourceLanguageCode.value}) -> $tgtName(${targetLanguageCode.value})'); } //检查用户是否登录且积分是否足够,不足则提示充值 if (User.isLoggedIn() && User.instance.tradeintegral <= 0) { showRechargeDialog('translation'); return; } if (!await _checkPermissions()) return; if (isRecognizing.value) return; // 等 onInit 里那次按当前语言对做的 ASR 初始化落地,别让它被兜底的 // 默认 zh-CN/en-US 初始化抢先(见 onInit 中 _modeInitFuture 的说明)。 try { await _modeInitFuture; } catch (e) { Logger.error('等待模式初始化失败(忽略): $e'); } try { await _initializeSession(); // Android 音视频翻译:先把悬浮窗起来,让用户切到其他 App 后还能看到翻译。 // 浮窗权限被拒绝则直接放弃启动 — 不开浮窗就没意义(用户切走就看不见结果)。 final isAndroidAudioVideo = Platform.isAndroid && currentMode.value == 'audioVideo'; if (isAndroidAudioVideo && !isFloatingWindowEnabled.value) { await _enableFloatingWindow(); if (!isFloatingWindowEnabled.value) { return; } } await _configureAudioMode(); // 设备端触发的面对面翻译不从这里启动——那是「按住说话」,由耳机左右耳 // 按键经 [setActiveSpeaker] 逐句拉起识别。客户端触发(App 上的开始按钮) // 则和其它模式一样在这里启动连续识别。 // // 这里原本写的是 `currentMode.value != 'faceToFace'`,把面对面整个挡在外面, // 导致下面那条 `else if (currentMode.value == 'faceToFace')` 永远进不去: // 点开始按钮只跑了 _configureAudioMode 就结束,isRecognizing 一直是 false, // 按钮图标不变——就是「面对面翻译点不动」。 if (!_isFaceToFaceDeviceMode) { // Android 上音视频翻译改走 MediaProjection,PCM 由系统播放流提供, // 不再需要 BLE codec 处于激活状态。其它平台/模式仍按旧逻辑校验: // 拿不到 BLE 编解码就放弃启动识别。 // 通话模式走恒玄链路时不检查 isCodecActive——那是杰理专属的解码器 // 就绪信号,恒玄没有对应概念,_configureCallModeBes 已经乐观下发过 // 指令了,是否有音频交给桥接的流订阅自己判断。 if ((currentMode.value == 'audioVideo' || currentMode.value == 'call') && !isAndroidAudioVideo && !(currentMode.value == 'call' && _callModeUsingBes) && !bleManager.isCodecActive) { return; } if (currentMode.value == 'simultaneous') { Logger.i('Translation', '开始语音识别,模式:同声传译,ASR phone_call'); await _startAsrService('phone_call'); } else if (currentMode.value == 'faceToFace') { // 走到这里必然是客户端触发(设备端触发已被外层挡掉),收音走手机麦克风, // 连续识别,方向由 ASR 语种检测自动判定。 Logger.i('Translation', '开始语音识别,模式:面对面(客户端触发/手机收音),ASR normal'); await _startAsrService('normal'); } else if (currentMode.value == 'call') { if (_astAutoStartPending) { // initialize() 已经自动拉起过一次了,见 _astAutoStartPending 注释—— // 这里不能再显式调用,否则会跟那次握手中的连接抢 generation。 Logger.i('Translation', '通话模式:AST 已由 initialize() 自动启动,跳过重复调用'); _astAutoStartPending = false; } else { Logger.i('Translation', '通话模式:启动 AST 端到端服务'); await _astService.startContinuousTranslation(); } } else { Logger.i('Translation', '开始语音识别,模式:${currentMode.value},ASR 模式:normal'); await _startAsrService('normal'); } await _finalizeRecognitionStart(); // Android 音视频翻译:识别就绪后才弹"录屏"授权 — 用户同意的瞬间 // 系统音频开始流入已经准备好的 ASR 通路,可以立即切到目标 App。 if (isAndroidAudioVideo) { final captured = await _asrService.requestScreenCapture(); if (!captured) { // 拒绝则回滚已经启动的识别 + 关掉浮窗,避免空浮窗悬空在桌面 await stopRecognition(); if (isFloatingWindowEnabled.value) { await _disableFloatingWindow(); } Get.snackbar( 'tip'.tr, 'meetingRecordScreenCapturePermissionDenied'.tr, ); return; } } } } catch (e) { isPreparing.value = false; isRecognizing.value = false; Logger.error('启动语音识别失败: ${e.toString()}'); } } /// 检查权限 Future _checkPermissions() async { final bool record = await requestRecordPermission(); if (!record) { Logger.d("Permission", "录音权限被拒绝,无法开始录音"); return false; } return true; } /// 初始化会话 Future _initializeSession() async { currentSessionId = DateTime.now().millisecondsSinceEpoch.toString(); try { _usageService.startNewSession(); _asrSessionStartTime = DateTime.now(); _asrActiveDuration = Duration.zero; _isAsrActive = false; } catch (e) { Logger.error('开始新会话统计失败: $e'); } } /// 配置音频模式 Future _configureAudioMode() async { switch (currentMode.value) { case "audioVideo": await _configureAudioVideoMode(); break; case "call": await _configureCallMode(); break; case "faceToFace": await _configureFaceToFaceMode(); break; default: await _configureDefaultMode(); } } /// 配置面对面翻译的音频链路:**收音走哪一路完全跟随触发来源**。 /// /// - [FaceToFaceTrigger.device]:耳机按键触发 → 设备端收音, /// PCM 由 BLE 喂给 ASR(原生侧的 EXTERNAL 音频源)。 /// - [FaceToFaceTrigger.client]:App 界面触发 → 手机麦克风(原生侧 MICROPHONE)。 /// /// 设备端那条链路还在等固件(见 [FaceToFaceBleService] 的 TODO), /// 所以当前实际生效的是手机麦克风这一路。 Future _configureFaceToFaceMode() async { _audioSourceType = _isFaceToFaceDeviceMode; isTtsEnabled.value = true; Logger.info( '面对面翻译收音来源:${_audioSourceType ? "设备端(BLE)" : "手机麦克风"}' '(触发来源=${faceToFaceTrigger.value.name})'); } /// 配置音视频模式 Future _configureAudioVideoMode() async { isPreparing.value = true; _callAudioTornDown = false; if (Platform.isAndroid) { // Android 改走 MediaProjection。这里只设置音频源标志,真正的录屏授权 // 推迟到 startRecognition 末尾申请:先把浮窗、识别都准备好,最后弹授权 // 让用户立刻就能切到 YouTube 等 App 看翻译结果。 isPreparing.value = false; } else { await bleManager.openDecoder(); int attempts = 0; while (!bleManager.isCodecActive && attempts < 10) { await Future.delayed(const Duration(milliseconds: 150)); attempts++; } isPreparing.value = false; if (!bleManager.isCodecActive) { Get.snackbar('tip'.tr, 'operationFailedPleaseRetry'.tr); throw Exception('lxm--BLE编解码打开失败'); } } _audioSourceType = true; isTtsEnabled.value = false; Logger.info('发送ble音乐或者通话远端声音'); } /// 配置通话模式(只做设备音频链路配置,AST 已在 /// _initializeCallModeTranslationService 中初始化)。 /// 恒玄和杰理耳机的通话音频通路完全不同,分两套桥接,互不影响; /// 用哪套由当前连着的是哪条链路决定,记进 [_callModeUsingBes] 供停止时分流。 Future _configureCallMode() async { _callModeUsingBes = DeviceConnection.besConnected; _callAudioTornDown = false; if (_callModeUsingBes) { await _configureCallModeBes(); } else { await _configureCallModeJieli(); } } /// 配置通话模式(恒玄):进通话模式拿双路上行(AA 51)+ 开译文下行通道(AA 56)+ /// 通知耳机 ASR 已开启(AA 57),再把 mic/spk PCM 接上 AST。 /// /// 恒玄这套私有协议是纯事件推送,没有类似杰理 isCodecActive 那种「解码器 /// 已就绪」的同步信号,所以这里不像杰理那样轮询等待/失败即抛异常—— /// 直接乐观下发指令,真正有没有收到音频帧由 [_attachBesCallTranslationBridge] /// 里的流订阅决定。 Future _configureCallModeBes() async { isPreparing.value = true; final bes = BesBluetoothService.to; try { Logger.info('[CALL-CFG-BES] 进通话模式(AA 51) + 开译文下行通道(AA 56) + 通知ASR已开启(AA 57)'); await bes.startCallMode(); await bes.startTranslationDownlink(); await bes.notifyAsrStarted(); } catch (e) { Logger.error('[CALL-CFG-BES] 恒玄通话模式配置失败: $e'); } isPreparing.value = false; _audioSourceType = true; isTtsEnabled.value = false; // 根据设备产品配置决定是否由原生层本地播报对方(B)翻译音频, // 逻辑与杰理那份一致,只是设备名的来源换成恒玄 bool broadcastPeer = false; try { final deviceName = bes.connectedDeviceName.value; if (deviceName.isNotEmpty) { final DBProduct? product = AppConfig.getproduct(deviceName); broadcastPeer = product?.broadcastpeertranslate == true; } } catch (e) { Logger.error('读取 broadcastpeertranslate 失败,回退默认 false: $e'); } await _astService.setBroadcastPeerTranslate(broadcastPeer); Logger.info('[CALL-BES] broadcastPeerTranslate=$broadcastPeer'); _attachBesCallTranslationBridge(); Logger.info('发送恒玄通话双路(mic本端/spk对端)声音'); } /// 通话翻译 ↔ 恒玄桥接: /// micPcmStream(本端) → AST.A(己方→对端语种) /// spkPcmStream(对端) → AST.B(对端→己方语种) /// AST.A TTS → pushTranslationTtsPcm('A', pcm)(对端听) /// AST.B TTS → pushTranslationTtsPcm('B', pcm)(己方听) void _attachBesCallTranslationBridge() { _detachBesCallTranslationBridge(); final bes = BesBluetoothService.to; _besCallMicSub = bes.micPcmStream.listen( (pcm) { _astService.pushExternalAudio(leg: 'A', pcm: pcm); }, onError: (e) => Logger.error('[CALL-BRIDGE-BES] mic stream error: $e'), ); _besCallSpkSub = bes.spkPcmStream.listen( (pcm) { _astService.pushExternalAudio(leg: 'B', pcm: pcm); }, onError: (e) => Logger.error('[CALL-BRIDGE-BES] spk stream error: $e'), ); // 诊断:计数 + isFinal 必打,用于定位 "TTS 到底有没有被转发到 native"。 int forwardCnt = 0; _besCallAstTtsSub = _astService.astTtsFrames.listen( (frame) { forwardCnt++; if (frame.isFinal || forwardCnt == 1 || forwardCnt % 50 == 0) { Logger.info( '[CALL-BRIDGE-BES] forward TTS leg=${frame.leg} pcm=${frame.pcm.length}B isFinal=${frame.isFinal} (#$forwardCnt)', ); } bes.pushTranslationTtsPcm(frame.leg, frame.pcm); }, onError: (e) => Logger.error('[CALL-BRIDGE-BES] ast tts stream error: $e'), ); Logger.info('[CALL-BRIDGE-BES] 已连通 恒玄双路 ↔ AST'); } void _detachBesCallTranslationBridge() { _besCallMicSub?.cancel(); _besCallMicSub = null; _besCallSpkSub?.cancel(); _besCallSpkSub = null; _besCallAstTtsSub?.cancel(); _besCallAstTtsSub = null; } /// 配置通话模式(杰理,原有实现,只是改了名字) Future _configureCallModeJieli() async { isPreparing.value = true; // 最多重试 3 轮 A2DP 解码器打开,每轮等待 2 秒(约 13 次 150ms 轮询) // 部分耳机首次收到 CMD_CONTROL_CODEC 时需要重放命令才能真正切到 A2DP 流式模式 const int maxRounds = 3; const int attemptsPerRound = 13; for (int round = 0; round < maxRounds; round++) { Logger.info('[CALL-CFG] 打开 A2DP 解码器 (round=${round + 1}/$maxRounds)'); await bleManager.openA2DPDecoder(); int attempts = 0; while (!bleManager.isCodecActive && attempts < attemptsPerRound) { await Future.delayed(const Duration(milliseconds: 150)); attempts++; } if (bleManager.isCodecActive) { Logger.info('[CALL-CFG] A2DP 解码器就绪 (round=${round + 1}, attempts=$attempts)'); break; } Logger.warning('[CALL-CFG] A2DP 解码器未激活,重试 (round=${round + 1})'); } isPreparing.value = false; if (!bleManager.isCodecActive) { Get.snackbar('tip'.tr, 'bluetoothCallStateError'.tr); throw Exception('lxm--BLE编解码打开失败'); } _audioSourceType = true; isTtsEnabled.value = false; // 根据设备产品配置决定是否由原生层本地播报对方(B)翻译音频 // 只有 product.broadcastpeertranslate == true 才播报;null / false / 读取异常 → 不播报 bool broadcastPeer = false; try { final deviceName = bleManager.getDeviceName(); if (deviceName.isNotEmpty) { final DBProduct? product = AppConfig.getproduct(deviceName); broadcastPeer = product?.broadcastpeertranslate == true; } } catch (e) { Logger.error('读取 broadcastpeertranslate 失败,回退默认 false: $e'); } await _astService.setBroadcastPeerTranslate(broadcastPeer); Logger.info('[CALL] broadcastPeerTranslate=$broadcastPeer'); // 把杰理 SCO 双向 PCM ↔ AST 流水线接上: // in.uplink → AST.A(己方→对端语种) // in.downlink → AST.B(对端→己方语种) // AST.A TTS → out.uplink(对端听) // AST.B TTS → out.downlink(己方听) _attachJieliCallTranslationBridge(); Logger.info('发送ble系统mic和dac(音乐或者通话远端)声音'); } void _attachJieliCallTranslationBridge() { _detachJieliCallTranslationBridge(); _jieliCallInUplinkSub = bleManager.inUplinkPcmStream.listen( (pcm) { _astService.pushExternalAudio(leg: 'A', pcm: pcm); }, onError: (e) => Logger.error('[CALL-BRIDGE] in.uplink stream error: $e'), ); _jieliCallInDownlinkSub = bleManager.inDownlinkPcmStream.listen( (pcm) { _astService.pushExternalAudio(leg: 'B', pcm: pcm); }, onError: (e) => Logger.error('[CALL-BRIDGE] in.downlink stream error: $e'), ); // 诊断:计数 + isFinal 必打,用于定位 “TTS 到底有没有被转发到 native”。 int forwardCnt = 0; _jieliCallAstTtsSub = _astService.astTtsFrames.listen( (frame) { forwardCnt++; if (frame.isFinal || forwardCnt == 1 || forwardCnt % 50 == 0) { Logger.info( '[CALL-BRIDGE] forward TTS leg=${frame.leg} pcm=${frame.pcm.length}B isFinal=${frame.isFinal} (#$forwardCnt)', ); } bleManager.feedAstTtsPcm( leg: frame.leg, pcm: frame.pcm, isFinal: frame.isFinal, ); }, onError: (e) => Logger.error('[CALL-BRIDGE] ast tts stream error: $e'), ); Logger.info('[CALL-BRIDGE] 已连通 杰理 SCO ↔ AST'); } void _detachJieliCallTranslationBridge() { _jieliCallInUplinkSub?.cancel(); _jieliCallInUplinkSub = null; _jieliCallInDownlinkSub?.cancel(); _jieliCallInDownlinkSub = null; _jieliCallAstTtsSub?.cancel(); _jieliCallAstTtsSub = null; } /// 配置默认模式 Future _configureDefaultMode() async { _audioSourceType = false; isTtsEnabled.value = true; } /// 启动语音识别 Future _startAsrService(String mode) async { if (mode == 'push_to_talk') { await _asrService.startContinuousRecognition( _audioSourceType, isRemoveFirstPunctuation: false, mode: mode, ); } else { await _asrService.startContinuousRecognition( _audioSourceType, isRemoveFirstPunctuation: true, mode: mode, ); } } /// 完成识别启动 Future _finalizeRecognitionStart() async { _startAsrActiveTracking(); isRecognizing.value = true; // 同声翻译**默认开录音**:不录就没有文件,也就没有东西归档进「语音记录助手」, // 而用户的预期是translate完能在助手里找到这段音频。 // 录音按钮仍然在,用户本轮可以随时关掉;下一轮开始时回到默认开。 // // ⚠️ 只对同声翻译这么做,**通话翻译保持手动**:那录的是通话双方的声音, // 默认开启属于「未经告知录音」,合规风险完全不是一个量级。 // 音视频翻译录的是系统播放声,面对面是逐句按轮次开停,两者归档语义都还没定, // 一并保持原样。 if (currentMode.value == 'simultaneous' && !isRecording.value) { isRecording.value = true; } if (isRecording.value && isRecognizing.value) { startRecording(); } } /// 处理语音识别事件 /// [event] 识别事件 void _handleRecognitionEvent(RecognitionEvent event) { final String asrProvider = event.provider; if (asrProvider.isNotEmpty) { Logger.d('Translation', 'ASR provider: $asrProvider'); } switch (event.type) { case RecognitionEventType.finalResult: if (event.text.isEmpty) break; // 通话模式下:目标语言即为识别语言,无需判断 detectedLanguageCode = currentMode.value == 'call' ? targetLanguageCode.value : event.detectedLanguage; Logger.d('Translation', '处理识别完成: ${event.text}'); if (currentMode.value != 'faceToFace') { handleFinalResult(event.text); } else { faceToFaceRecognitionText.value += event.text; } // activeSpeaker==0 有两种情形,都该把攒下的整句吐出去: // 设备端触发时是「按键已松手」,客户端触发时恒为 0(连续识别,每句即时出)。 if (currentMode.value == 'faceToFace' && activeSpeaker.value == 0) { handleFinalResult(faceToFaceRecognitionText.value); faceToFaceRecognitionText.value = ''; } break; case RecognitionEventType.finalResult1: // 通话模式下:己方(麦克风)识别完成事件(对应ast服务) if (event.text.isEmpty) break; //通话模式下:己方语言即为源语言,无需判断 detectedLanguageCode = sourceLanguageCode.value; Logger.d('Translation', '处理己方识别完成: ${event.text}'); handleFinalResult(event.text); break; case RecognitionEventType.intermediateResult: if (event.text.isEmpty) break; // 通话模式下:目标语言即为识别语言,无需判断 detectedLanguageCode = currentMode.value == 'call' ? targetLanguageCode.value : event.detectedLanguage; Logger.d('Translation', '处理识别中: ${event.text}'); handleIntermediateResult(event.text); break; case RecognitionEventType.error: final errorMsg = event.error; var displayMsg = errorMsg; if (errorMsg.contains('Xunfei connection failed') || errorMsg.contains('Socket is not connected')) { displayMsg = 'asrConnectionFailed'.tr; // 语音识别服务连接失败,请检查网络或稍后重试 } Get.snackbar('error'.tr, displayMsg); isRecognizing.value = false; Logger.error('语音识别错误: $errorMsg'); break; case RecognitionEventType.canceled: isRecognizing.value = false; Logger.error('语音识别取消: ${event.error}'); break; case RecognitionEventType.sessionStarted: // 可按需处理会话开始事件 break; case RecognitionEventType.sessionStopped: // 可按需处理会话结束事件 break; } } /// 处理 AST(语音识别+翻译一体)事件 /// 按 serviceId 区分双路(A=己方, B=对方),用 utteranceId 匹配同一句话的事件 void _handleAstEvent(ASTEvent event) { // 仅对终态事件打 info 日志,避免中间事件高频写 I/O 阻塞 event loop(会导致 AudioSendSlow 1011) if (event.type == ASTEventType.finalResult || event.type == ASTEventType.translationResult || event.type == ASTEventType.error || event.type == ASTEventType.canceled) { Logger.info( '[STS] _handleAstEvent 收到: type=${event.type}, serviceId=${event.serviceId}, text=${event.text}'); } // 根据 serviceId 确定语言方向 final String srcCode = event.serviceId == "A" ? sourceLanguageCode.value : targetLanguageCode.value; final String tgtCode = event.serviceId == "A" ? targetLanguageCode.value : sourceLanguageCode.value; switch (event.type) { case ASTEventType.intermediateResult: if (event.text.isEmpty) break; currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString(); // 按 utteranceId 查找已有项 TranslationItem? target; int targetIndex = -1; if (event.utteranceId.isNotEmpty) { for (int i = translationHistory.length - 1; i >= 0; i--) { if (translationHistory[i].isIntermediate && translationHistory[i].utteranceId == event.utteranceId) { target = translationHistory[i]; targetIndex = i; break; } } } if (target != null) { target.sourceText = event.text; target.sourceLanguageCode = srcCode; target.targetLanguageCode = tgtCode; target.serviceId = event.serviceId; translationHistory.refresh(); } else { _historyManager.addTranslationItem(TranslationItem( sourceText: event.text, translatedText: '', sourceLanguageCode: srcCode, targetLanguageCode: tgtCode, timestamp: DateTime.now(), sessionId: currentSessionId!, isFirstInSession: translationHistory.isEmpty || translationHistory.last.sessionId != currentSessionId, isIntermediate: true, utteranceId: event.utteranceId, serviceId: event.serviceId, )); } break; case ASTEventType.finalResult: if (event.text.isEmpty) break; Logger.i('Translation', 'AST 识别完成 [${event.serviceId}]: ${event.text}'); currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString(); TranslationItem? target; if (event.utteranceId.isNotEmpty) { for (int i = translationHistory.length - 1; i >= 0; i--) { if (translationHistory[i].isIntermediate && translationHistory[i].utteranceId == event.utteranceId) { target = translationHistory[i]; break; } } } if (target == null) { target = TranslationItem( sourceText: event.text, translatedText: '', sourceLanguageCode: srcCode, targetLanguageCode: tgtCode, timestamp: DateTime.now(), sessionId: currentSessionId!, isFirstInSession: translationHistory.isEmpty || translationHistory.last.sessionId != currentSessionId, isIntermediate: true, utteranceId: event.utteranceId, serviceId: event.serviceId, isFinalResultReceived: true, ); _historyManager.addTranslationItem(target); } else { target.sourceText = event.text; target.sourceLanguageCode = srcCode; target.targetLanguageCode = tgtCode; target.serviceId = event.serviceId; target.isFinalResultReceived = true; } // 识别+翻译都到齐才标记完成 if (target.isFinalResultReceived && target.isTranslationResultReceived) { target.isIntermediate = false; translationHistory.refresh(); _scrollToBottom(); saveTranslationHistory(); } else { translationHistory.refresh(); } break; case ASTEventType.translationInterim: if (event.text.isEmpty) break; currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString(); TranslationItem? target; if (event.utteranceId.isNotEmpty) { for (int i = translationHistory.length - 1; i >= 0; i--) { if (translationHistory[i].isIntermediate && translationHistory[i].utteranceId == event.utteranceId) { target = translationHistory[i]; break; } } } if (target != null) { target.translatedText = event.text; target.sourceLanguageCode = srcCode; target.targetLanguageCode = tgtCode; translationHistory.refresh(); } else { _historyManager.addTranslationItem(TranslationItem( sourceText: '', translatedText: event.text, sourceLanguageCode: srcCode, targetLanguageCode: tgtCode, timestamp: DateTime.now(), sessionId: currentSessionId!, isFirstInSession: translationHistory.isEmpty || translationHistory.last.sessionId != currentSessionId, isIntermediate: true, utteranceId: event.utteranceId, serviceId: event.serviceId, )); } break; case ASTEventType.translationResult: if (event.text.isEmpty) break; Logger.i('Translation', 'AST 翻译完成 [${event.serviceId}]: ${event.text}'); currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString(); TranslationItem? target; if (event.utteranceId.isNotEmpty) { for (int i = translationHistory.length - 1; i >= 0; i--) { if (translationHistory[i].isIntermediate && translationHistory[i].utteranceId == event.utteranceId) { target = translationHistory[i]; break; } } } if (target == null) { target = TranslationItem( sourceText: '', translatedText: event.text, sourceLanguageCode: srcCode, targetLanguageCode: tgtCode, timestamp: DateTime.now(), sessionId: currentSessionId!, isFirstInSession: translationHistory.isEmpty || translationHistory.last.sessionId != currentSessionId, isIntermediate: true, utteranceId: event.utteranceId, serviceId: event.serviceId, isTranslationResultReceived: true, ); translationHistory.add(target); translationHistory.refresh(); _scrollToBottom(); } else { target.translatedText = event.text; target.sourceLanguageCode = srcCode; target.targetLanguageCode = tgtCode; target.isTranslationResultReceived = true; } if (target.isFinalResultReceived && target.isTranslationResultReceived) { target.isIntermediate = false; translationHistory.refresh(); _scrollToBottom(); saveTranslationHistory(); } else { translationHistory.refresh(); } break; case ASTEventType.error: Logger.error('AST 错误: ${event.error}'); // 重新初始化期间的错误来自旧会话,忽略 if (_isReinitializing) { Logger.info('[STS] 正在重新初始化,忽略旧会话错误'); break; } // 初始化刚完成的宽限期内,底层仍在异步回调旧 session 的 1011/1012,忽略 if (_astErrorGraceUntil != null && DateTime.now().isBefore(_astErrorGraceUntil!)) { Logger.info('[STS] 初始化宽限期内,忽略旧会话错误: ${event.error}'); break; } // 非重新初始化期间的错误:终止通话服务并还原状态 _terminateCallOnError('AST 错误: ${event.error}'); break; case ASTEventType.canceled: Logger.error('AST 取消: ${event.error}'); if (_isReinitializing) { Logger.info('[STS] 正在重新初始化,忽略旧会话取消'); break; } if (_astErrorGraceUntil != null && DateTime.now().isBefore(_astErrorGraceUntil!)) { Logger.info('[STS] 初始化宽限期内,忽略旧会话取消: ${event.error}'); break; } _terminateCallOnError('AST 取消: ${event.error}'); break; default: break; } } /// 停止语音识别 Future stopRecognition() async { if (!isRecognizing.value) return; // ⚠️ 先翻状态再清理,别放到最后。 // 底下这串(停录音、关 ASR 的 WebSocket、归档)随时可能要好几秒,而 // 「开始/结束」按钮是 Obx 盯着 isRecognizing 的——放在最末尾翻,用户点完 // 「结束」会盯着一个毫无反应的按钮,以为没点上又点一次。 // 提前翻不影响防重入:开头那行 `if (!isRecognizing.value) return` 正是靠 // 它挡住重复调用,提前翻只会更严。 isRecognizing.value = false; try { await stopRecording(); await _asrService.stopContinuousRecognition(); if (currentMode.value == "call") { await _astService.stopContinuousTranslation(); _astAutoStartPending = false; } _stopAsrActiveTracking(); final langName = _languageManager.getChineseNameByAsrCode( (detectedLanguageCode.isNotEmpty ? detectedLanguageCode : sourceLanguageCode.value)) ?? '未知'; _recordAsrUsageStats(langName); currentSessionId = null; } catch (e) { Logger.error('停止语音识别失败: ${e.toString()}'); } } /// 处理中间识别结果 /// [text] 识别的文本 void handleIntermediateResult(String text) { if (text.isEmpty) return; Logger.info( "handleIntermediateResult:${translationHistory.isEmpty}${translationHistory.isNotEmpty ? translationHistory.last.isIntermediate : 'N/A'}"); if (translationHistory.isEmpty || (translationHistory.isNotEmpty && !translationHistory.last.isIntermediate)) { currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString(); // 关键调整:使用与最终结果一致的语言方向,避免左右“跳变” final languageCodes = _determineLanguageCodes(); final newItem = TranslationItem( sourceText: text, translatedText: '', sourceLanguageCode: languageCodes['source']!, targetLanguageCode: languageCodes['target']!, timestamp: DateTime.now(), sessionId: currentSessionId!, isFirstInSession: translationHistory.isEmpty || translationHistory.last.sessionId != currentSessionId, isIntermediate: true, ); if (_isFaceToFacePushToTalkReleased) { _asrService.stopContinuousRecognition(); } else { _historyManager.addTranslationItem(newItem); } } else { // 更新当前项 if (currentMode.value != 'faceToFace') { translationHistory.last.sourceText = text; } else { translationHistory.last.sourceText = faceToFaceRecognitionText.value + text; } translationHistory.refresh(); _historyManager.scrollToBottom(); } // 中间结果的翻译只是屏幕上的预览:它不播 TTS,也不写历史、不记统计。 // 原来是「每 300ms 无条件发一次」,一句 5 秒的话就是十几次真实的服务商调用; // 这些在途请求还会跟说完之后那一次 final 翻译抢同一条链路和同一份配额, // 弱网时直接表现为「话说完了译文还要等很久」。 // // 现在:间隔拉到 [_interimTranslateDebounce],且要求文本比上次真正发出去的 // 多出 [_interimMinGrowth] 个字才发(改写过的文本不受这条限制,照发)。 // 真正的取消发生在 [translateText] 里。 _translationDebounceTimer?.cancel(); _translationDebounceTimer = Timer(_interimTranslateDebounce, () { if (translationHistory.isEmpty) return; final last = translationHistory.last; if (!last.isIntermediate || last.sourceText.isEmpty) return; // 只是在上次发出去的文本后面又多了一两个字:不值得再翻一次。 // 文本被 ASR 改写过(不再以上次那段开头)则不受此限,照发。 final notGrownEnough = last.sourceText.startsWith(_lastInterimSentSource) && last.sourceText.length - _lastInterimSentSource.length < _interimMinGrowth; if (notGrownEnough) return; _lastInterimSentSource = last.sourceText; translateText(last.sourceText, last.timestamp, isFinal: false); }); } /// 处理最终识别结果 /// [text] 识别的文本 Future handleFinalResult(String text) async { if (text.isEmpty) return; // 更新当前项 if (translationHistory.isNotEmpty && translationHistory.last.isIntermediate) { translationHistory.last.sourceText = text; translationHistory.last.isIntermediate = false; translationHistory.refresh(); _scrollToBottom(); saveTranslationHistory(); //如果识别中,且是设备端按住说话已松手的面对面翻译,则停止识别 if (_isFaceToFacePushToTalkReleased) { _asrService.stopContinuousRecognition(); } translateText(text, translationHistory.last.timestamp, isFinal: true); } else { // 创建新项 final newItem = TranslationItem( sourceText: text, translatedText: '', sourceLanguageCode: sourceLanguageCode.value, targetLanguageCode: targetLanguageCode.value, timestamp: DateTime.now(), sessionId: currentSessionId ?? DateTime.now().millisecondsSinceEpoch.toString(), isFirstInSession: translationHistory.isEmpty || translationHistory.last.sessionId != currentSessionId, isIntermediate: false, ); if (_isFaceToFacePushToTalkReleased) { _asrService.stopContinuousRecognition(); } translationHistory.add(newItem); translationHistory.refresh(); _scrollToBottom(); saveTranslationHistory(); translateText(text, newItem.timestamp, isFinal: true); } } /// 翻译文本 /// [sourceText] 源文本 /// [timestamp] 时间戳 /// [isFinal] 是否为最终结果 /// 返回翻译结果 Future translateText(String sourceText, DateTime timestamp, {bool isFinal = false}) async { if (sourceText.isEmpty) return null; isTranslating.value = true; String? translationResult; // 中间结果(预览)可以被后来者取消;final 不行——它要写历史、要播报。 CancelToken? cancelToken; final int seq; if (isFinal) { // 说完了,屏幕上的预览已经没有意义。把在途的中间请求全部撤掉, // 免得它们晚一步回来,把 final 译文盖成半句。 _interimTranslateToken?.cancel('final result arrived'); _interimTranslateToken = null; _lastInterimSentSource = ''; seq = ++_interimTranslateSeq; } else { _interimTranslateToken?.cancel('superseded by newer interim'); cancelToken = _interimTranslateToken = CancelToken(); seq = ++_interimTranslateSeq; } try { final languageCodes = _determineLanguageCodes(); translationResult = await _performTranslation(sourceText, languageCodes['source']!, languageCodes['target']!, cancelToken); // 过期的中间结果直接丢:这期间已经有更新的中间请求、或者 final 发出去了, // 再写回去只会让译文闪回上一句的半截。 if (!isFinal && seq != _interimTranslateSeq) return null; if (translationResult != null) { await _updateTranslationHistory( sourceText, translationResult, timestamp, languageCodes, isFinal); } } catch (e, st) { Logger.e('Translation', '翻译失败: $e', e, st); } finally { // 只有最新那一发才有资格熄掉 loading,否则被取消的旧请求会把 // 正在跑的 final 提前标记成"翻译完了"。 if (seq == _interimTranslateSeq) isTranslating.value = false; } return translationResult; } /// 确定语言代码 /// 返回源语言和目标语言代码的映射 Map _determineLanguageCodes() { String detectedSourceLanguageCode; String detectedTargetLanguageCode; if (_isFaceToFaceSpeaker1()) { detectedLanguageCode = sourceLanguageCode.value; detectedSourceLanguageCode = sourceLanguageCode.value; detectedTargetLanguageCode = targetLanguageCode.value; } else if (_isFaceToFaceSpeaker2()) { detectedLanguageCode = targetLanguageCode.value; detectedSourceLanguageCode = targetLanguageCode.value; detectedTargetLanguageCode = sourceLanguageCode.value; } else { final shouldSwap = detectedLanguageCode == sourceLanguageCode.value; detectedSourceLanguageCode = shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value; detectedTargetLanguageCode = shouldSwap ? targetLanguageCode.value : sourceLanguageCode.value; } return { 'source': detectedSourceLanguageCode, 'target': detectedTargetLanguageCode, }; } /// 面对面翻译,且由设备端(耳机左右耳)驱动——「按住说话」语义只在此成立 bool get _isFaceToFaceDeviceMode => currentMode.value == 'faceToFace' && faceToFaceTrigger.value == FaceToFaceTrigger.device; /// 设备端「按住说话」已松手:这一句说完了,该收尾停识别。 /// /// 客户端触发走的是连续识别,activeSpeaker 恒为 0,**绝不能**套这条规则—— /// 否则第一句话刚识别完就把识别停掉,表现就是按钮点一下没反应。 bool get _isFaceToFacePushToTalkReleased => _isFaceToFaceDeviceMode && activeSpeaker.value == 0; /// 判断是否为面对面说话者1 bool _isFaceToFaceSpeaker1() { return (currentMode.value == 'faceToFace' && activeSpeaker.value == 1) || (currentMode.value == 'faceToFace' && activeSpeaker.value == 0 && lastActiveSpeaker.value == 1); } /// 判断是否为面对面说话者2 bool _isFaceToFaceSpeaker2() { return (currentMode.value == 'faceToFace' && activeSpeaker.value == 2) || (currentMode.value == 'faceToFace' && activeSpeaker.value == 0 && lastActiveSpeaker.value == 2); } /// 执行翻译 /// [sourceText] 源文本 /// [sourceCode] 源语言代码 /// [targetCode] 目标语言代码 /// 返回翻译结果 Future _performTranslation(String sourceText, String sourceCode, String targetCode, CancelToken? cancelToken) async { return await _translationService.translateText( text: sourceText, sourceLanguageCode: sourceCode, targetLanguageCode: targetCode, cancelToken: cancelToken, ); } /// 更新翻译历史 /// [sourceText] 源文本 /// [translationResult] 翻译结果 /// [timestamp] 时间戳 /// [languageCodes] 语言代码映射 /// [isFinal] 是否为最终结果 Future _updateTranslationHistory( String sourceText, String translationResult, DateTime timestamp, Map languageCodes, bool isFinal, ) async { final index = translationHistory .indexWhere((item) => item.timestamp.isAtSameMomentAs(timestamp)); if (index != -1) { await _updateExistingHistoryItem( index, translationResult, languageCodes, sourceText, isFinal); } else { Logger.info('未找到匹配的历史项 $sourceText ${timestamp.toIso8601String()}'); } } /// 更新现有历史项 /// [index] 历史项索引 /// [translationResult] 翻译结果 /// [languageCodes] 语言代码映射 /// [sourceText] 源文本 /// [isFinal] 是否为最终结果 Future _updateExistingHistoryItem( int index, String translationResult, Map languageCodes, String sourceText, bool isFinal, ) async { // 更新历史项 translationHistory[index].translatedText = translationResult; translationHistory[index].sourceLanguageCode = languageCodes['source']!; translationHistory[index].targetLanguageCode = languageCodes['target']!; translationHistory.refresh(); // ⚠️ 播报必须排在最前面。 // 它是用户唯一「听得见」的一环,而下面的浮窗更新、写历史、记统计 // 全是 await 的平台通道 / 存储操作。原来 TTS 排在它们之后, // 等于白白把播报推迟了这一整串的耗时。 if (isFinal) { await _handleTtsPlayback(translationResult); } // 更新悬浮窗内容 if (isFloatingWindowEnabled.value) { await _updateFloatingWindowWithTranslation(translationHistory[index]); } if (isFinal) { final item = translationHistory[index]; Logger.i('Translation', '翻译完成: ${item.sourceLanguageCode}→${item.targetLanguageCode} ${item.sourceText.length}字符'); } if (isFinal) { saveTranslationHistory(); await _recordTranslationStats( sourceText, translationResult, languageCodes); } _scrollToBottom(); } /// 记录翻译统计 /// [sourceText] 源文本 /// [translationResult] 翻译结果 /// [languageCodes] 语言代码映射 Future _recordTranslationStats( String sourceText, String translationResult, Map languageCodes, ) async { try { final sourceLanguageName = _languageManager.getChineseNameByAsrCode(languageCodes['source']!) ?? '未知'; final targetLanguageName = _languageManager.getChineseNameByAsrCode(languageCodes['target']!) ?? '未知'; _usageService.recordTranslationApiCall( sourceText: sourceText, targetText: translationResult, sourceLanguage: sourceLanguageName, targetLanguage: targetLanguageName, mode: currentMode.value, ); Logger.info( '翻译统计: 源文本${sourceText.length}字符, 模式${currentMode.value}, 语言对$sourceLanguageName→$targetLanguageName'); } catch (e) { Logger.error('记录翻译统计失败: $e'); } } /// 处理TTS播放 /// [translationResult] 翻译结果 Future _handleTtsPlayback(String translationResult) async { if (isTtsEnabled.value) { await playTranslatedText(translationResult); } } // /// 处理面对面TTS // /// [translationResult] 翻译结果 // Future _handleFaceToFaceTts(String translationResult) async { // fTFTranslationResult = (fTFTranslationResult ?? '') + translationResult; // print("translationResult=$translationResult"); // if (activeSpeaker.value == 0 && !isPlayback) { // isPlayback = true; // await playTranslatedText(fTFTranslationResult!); // fTFTranslationResult = ''; // } // } /// 播放翻译文本 /// [text] 要播放的文本 Future playTranslatedText(String text) async { if (text.isEmpty) return; try { final bool shouldSwap = detectedLanguageCode == targetLanguageCode.value; final detectedTargetLanguageCode = shouldSwap ? sourceLanguageCode.value : targetLanguageCode.value; final voiceName = _languageManager .getTtsVoiceNameByAsrCode(detectedTargetLanguageCode) ?? 'en-US-AriaNeural'; Logger.info("voiceName=$voiceName,text=$text"); final startTime = DateTime.now(); await _ttsService.setVoice(voiceName); await _ttsService.speakOnce(currsessionid, text); final endTime = DateTime.now(); // 记录TTS API调用统计 try { final durationSeconds = endTime.difference(startTime).inSeconds; final languageName = _languageManager .getChineseNameByAsrCode(detectedTargetLanguageCode) ?? '未知'; _usageService.recordTtsApiCall( text: text, durationSeconds: durationSeconds, language: languageName, ); Logger.info( 'TTS统计: 文本${text.length}字符, 播放${durationSeconds}秒, 语言$languageName'); } catch (e) { Logger.error('记录TTS统计失败: $e'); } } catch (e) { Logger.error('播放翻译失败: ${e.toString()}'); } } /// 停止所有操作 Future stopAll() async { try { isPreparing.value = false; if (currentMode.value == "audioVideo" || currentMode.value == "call") { await _teardownCallModeAudio(); } if (isRecognizing.value) { await stopRecognition(); } _stopAsrActiveTracking(); _translationDebounceTimer?.cancel(); await _ttsService.stop(); } catch (e) { Logger.error('停止操作失败: ${e.toString()}'); } } /// 通话模式发生错误时终止服务并还原状态 Future _terminateCallOnError(String reason) async { Logger.error('[STS] 通话错误,终止服务: $reason'); try { _astEventSubscription?.cancel(); _astEventSubscription = null; try { await _astService.stopContinuousTranslation(); } catch (_) {} _astAutoStartPending = false; isRecognizing.value = false; isPreparing.value = false; _stopAsrActiveTracking(); _translationDebounceTimer?.cancel(); await _ttsService.stop(); if (currentMode.value == "call" || currentMode.value == "audioVideo") { await _teardownCallModeAudio(); } } catch (e) { Logger.error('[STS] 终止通话服务失败: $e'); } } /// 通话/音视频模式的设备侧音频链路收尾,一次会话只做一次(见 /// [_callAudioTornDown] 注释)——stopAll() 正常停止、AST 流异步报错触发 /// 的 _terminateCallOnError() 都会走到这里,第二次进来直接短路。 Future _teardownCallModeAudio() async { if (_callAudioTornDown) return; _callAudioTornDown = true; if (currentMode.value == "call" && _callModeUsingBes) { await _teardownBesCallMode(); } else { _detachJieliCallTranslationBridge(); if (currentMode.value == "audioVideo" && Platform.isAndroid) { // Android 音视频翻译走 MediaProjection,关掉对应前台服务即可。 await _asrService.stopScreenCapture(); } else { bleManager.closeCodec(); } Logger.info('关闭ble编解码器'); } if (currentMode.value == "call") { // 通话翻译真的结束了(不管是用户点结束、还是挂断自动停止), // 之前挂着的"后台继续翻译"提示就没意义了,撤掉。 unawaited(BackgroundSessionNotifier.cancel(kCallTranslationNotificationId)); } } /// 恒玄通话翻译收尾:断开桥接 + 关译文下行(AA 58/56的反向) + 退通话模式(AA 52)。 /// 耳机可能已经断开了,下发指令前先判断连接状态,别让收尾本身再抛异常。 Future _teardownBesCallMode() async { _detachBesCallTranslationBridge(); if (!Get.isRegistered()) return; final bes = BesBluetoothService.to; if (!bes.isConnected) return; try { await bes.notifyAsrStopped(); // AA 58 await bes.stopTranslationDownlink(); await bes.stopCallMode(); // AA 52 Logger.info('[CALL-BRIDGE-BES] 已退出恒玄通话模式'); } catch (e) { Logger.error('[CALL-BRIDGE-BES] 退出恒玄通话模式失败: $e'); } } /// 播放特定翻译项 /// [item] 翻译项 Future playTranslationItem(TranslationItem item) async { if (item.translatedText.isEmpty) return; // 防重复点击:如果短时间内重复点击同一项,忽略后续点击 final currentTime = DateTime.now().millisecondsSinceEpoch; final itemKey = '${item.sessionId}_${item.timestamp.millisecondsSinceEpoch}'; // 检查是否是重复点击同一项 if (_lastPlayedItemKey == itemKey && currentTime - _lastPlayTime < _playDebounceInterval) { Logger.info('防重复播放:忽略短时间内的重复点击'); return; } // 如果正在播放,先停止当前播放 if (_ttsService.isSpeaking) { await _ttsService.stop(); // 短暂延迟确保停止完成 await Future.delayed(const Duration(milliseconds: 100)); } try { // 更新播放记录 _lastPlayedItemKey = itemKey; _lastPlayTime = currentTime; final voiceName = _languageManager.getTtsVoiceNameByAsrCode(item.targetLanguageCode) ?? 'en-US-AriaNeural'; await _ttsService.setVoice(voiceName); await _ttsService.speakOnce(currsessionid, item.translatedText); } catch (e) { Logger.error('播放翻译项失败: ${e.toString()}'); } } /// 播放文本 /// [text] 要播放的文本 /// [languageCode] 语言代码(可选) Future playText(String text, {String? languageCode}) async { if (text.isEmpty) return; // 防重复点击:基于文本内容和语言代码生成唯一标识 final currentTime = DateTime.now().millisecondsSinceEpoch; final textKey = '${text}_${languageCode ?? targetLanguageCode.value}'; // 检查是否是重复点击同一文本 if (_lastPlayedItemKey == textKey && currentTime - _lastPlayTime < _playDebounceInterval) { Logger.info('防重复播放:忽略短时间内的重复文本播放请求'); return; } // 如果正在播放,先停止当前播放 if (_ttsService.isSpeaking) { await _ttsService.stop(); // 短暂延迟确保停止完成 await Future.delayed(const Duration(milliseconds: 100)); } try { // 更新播放记录 _lastPlayedItemKey = textKey; _lastPlayTime = currentTime; final voiceName = languageCode != null ? _languageManager.getTtsVoiceNameByAsrCode(languageCode) ?? 'en-US-AriaNeural' : _languageManager .getTtsVoiceNameByAsrCode(targetLanguageCode.value) ?? 'en-US-AriaNeural'; await _ttsService.setVoice(voiceName); await _ttsService.speakOnce(currsessionid, text); } catch (e) { Logger.error('播放文本失败: ${e.toString()}'); } } /// 滚动到底部(适配reverse模式) /// [animate] 是否使用动画 void _scrollToBottom({bool animate = true}) { if (scrollController.hasClients && (translationHistory.isNotEmpty || currentSessionId != null)) { try { const targetPosition = 0.0; // reverse模式下最新消息在顶部 if (animate) { scrollController.animateTo( targetPosition, duration: const Duration(milliseconds: 300), curve: Curves.easeOut, ); } else { scrollController.jumpTo(targetPosition); } } catch (e) { Logger.error('滚动到顶部失败(reverse模式): ${e.toString()}'); } } } /// 设置滚动监听器(简化版,保留接口兼容性) void setupScrollListener() { // 简化后的空方法,保留接口兼容性 } /// 设置底部控制栏高度 /// [height] 高度值 void setBottomBarHeight(double height) { _bottomBarHeight = height; } /// 获取底部控制栏高度 double get bottomBarHeight => _bottomBarHeight; /// 串行化语言切换,避免并发 reinit 造成 UI 与原生 AST 状态不一致 Future _runLanguageSwitch(Future Function() action) { final previous = _languageSwitchChain; final completer = Completer(); final gate = Completer(); Future run() async { if (previous != null) { try { await previous; } catch (_) {} } try { completer.complete(await action()); } catch (e, st) { completer.completeError(e, st); } finally { gate.complete(); } } _languageSwitchChain = gate.future; run(); return completer.future; } /// 切换源语言和目标语言 Future swapLanguages() => _runLanguageSwitch(() async { final wasRecognizing = isRecognizing.value; await stopAll(); final tempName = sourceLanguage.value; sourceLanguage.value = targetLanguage.value; targetLanguage.value = tempName; final tempCode = sourceLanguageCode.value; sourceLanguageCode.value = targetLanguageCode.value; targetLanguageCode.value = tempCode; _saveSourceLanguageSetting(isUserSet: true); _saveTargetLanguageSetting(); await _reinitializeAsrService(restartRecognition: wasRecognizing); }); /// 设置源语言 /// [language] 语言名称 Future setSourceLanguage(String language) => _runLanguageSwitch(() async { if (sourceLanguage.value == language) return; final wasRecognizing = isRecognizing.value; await stopAll(); sourceLanguage.value = language; final asrCode = _languageManager.getAsrCodeByChineseName(language); if (asrCode != null) { sourceLanguageCode.value = asrCode; } _saveSourceLanguageSetting(isUserSet: true); await _reinitializeAsrService(restartRecognition: wasRecognizing); }); /// 设置目标语言 /// [language] 语言名称 Future setTargetLanguage(String language) => _runLanguageSwitch(() async { if (targetLanguage.value == language) return; final wasRecognizing = isRecognizing.value; await stopAll(); targetLanguage.value = language; final asrCode = _languageManager.getAsrCodeByChineseName(language); if (asrCode != null) { targetLanguageCode.value = asrCode; } _saveTargetLanguageSetting(); await _reinitializeAsrService(restartRecognition: wasRecognizing); }); /// 当前模式该给 ASR 传哪几种语言。 /// /// 传 1 种 = 固定语种识别;传 2 种 = 原生打开 Azure 的 **连续语种识别** /// (`LanguageIdMode=Continuous` + AutoDetectSourceLanguageConfig)。 /// 连续语种识别要先缓冲够音频、对候选语言逐个打分才敢出 final, /// 这段等待直接加在「说完到出译文」上,是面对面/同传最大的一块固定延迟。 /// /// 所以只有真正需要判断「现在是谁在说」的模式才开: /// - faceToFace:两个人轮流说,方向靠检测结果定,**必须**双语; /// - simultaneous / audioVideo:单向,方向固定,开了纯亏延迟; /// 固定语种时原生上报的 detectedLanguage 就是 supportedLanguages[0] /// (见 AzureAsrHelper 的 onResult),[_determineLanguageCodes] 据此 /// 算出 shouldSwap=true,方向仍然正确。 /// /// ⚠️ 代价:同传下用户若改说目标语种,不会再被自动识别成反向。 /// 同传本来就是单向场景,需要反向请切面对面。 List _asrLanguagesForCurrentMode() { if (currentMode.value == 'faceToFace') { return [sourceLanguageCode.value, targetLanguageCode.value]; } return [sourceLanguageCode.value]; } /// onInit 里那次 [changeTranslationMode] 的 Future,见 onInit 的说明。 Future? _modeInitFuture; // ---- 中间结果(说话过程中的预览译文)翻译的节流与取消,见 handleIntermediateResult ---- /// 中间结果翻译的防抖间隔。中间译文只上屏、不播报,值得为它省下请求。 static const Duration _interimTranslateDebounce = Duration(milliseconds: 700); /// 相比上次真正发出去的文本,至少要多这么多个字才值得再翻一次。 static const int _interimMinGrowth = 4; /// 在途的中间结果翻译请求,发新的之前撤掉它。 CancelToken? _interimTranslateToken; /// 上一次真正发出去翻译的中间文本,用于增量门槛判断。 String _lastInterimSentSource = ''; /// 翻译请求序号:回来时不是最新的一发就丢弃,避免旧结果盖掉新译文。 int _interimTranslateSeq = 0; /// 重新初始化ASR服务 /// [restartRecognition] 初始化完成后是否自动重新开始识别 Future _reinitializeAsrService({bool restartRecognition = false}) async { _isReinitializing = true; try { // 通话模式:无论 isRecognizing 状态如何,都强制停止 AST 服务 // 防止旧会话未完全关闭就创建新会话(Socket 1011 / Session 1012) if (currentMode.value == "call") { _astEventSubscription?.cancel(); _astEventSubscription = null; try { await _astService.stopContinuousTranslation(); } catch (e) { Logger.info('[STS] 停止旧AST服务(忽略): $e'); } _astAutoStartPending = false; } //1.统一先停止旧识别 if (isRecognizing.value) { await stopRecognition(); } isRecognizing.value = false; final List asrSupportedLanguages = _asrLanguagesForCurrentMode(); if (currentMode.value == "call") { await _initializeCallModeTranslationService(); // _astService.initialize 的原生实现会自动启动 AST(AzureAsrToAsr / DoubaoE2E // 等在 initialize 中都调用了 startContinuousTranslation)。若切换语言前未处于 // 识别状态,这里需要立即停止原生 AST,避免 UI 显示未启动但原生仍在识别。 _astAutoStartPending = true; if (!restartRecognition) { try { await _astService.stopContinuousTranslation(); Logger.info('[STS] 语言切换:非识别状态,已停止新AST保持状态一致'); } catch (e) { Logger.info('[STS] 停止新AST(忽略): $e'); } _astAutoStartPending = false; } // restartRecognition=true 时下面会调 startRecognition(),它会消费 // _astAutoStartPending,跳过重复的 startContinuousTranslation 调用。 } else { Logger.info('重新初始化ASR服务,支持语言: $asrSupportedLanguages'); await _asrService.initialize(supportedLanguages: asrSupportedLanguages); var recognitionStream = await _asrService.recognizeCallback(); _recognitionSubscription?.cancel(); _recognitionSubscription = recognitionStream.listen(_handleRecognitionEvent); } Logger.info('ASR服务重新初始化成功'); // 切换语言前正在识别,自动恢复识别状态 if (restartRecognition) { Logger.info('[STS] 语言切换完成,自动恢复识别'); await startRecognition(); } } catch (e) { Logger.error('重新初始化ASR服务失败: ${e.toString()}'); } finally { // 初始化完成后设置一个宽限期,过滤底层异步 dispose 旧 session 时产生的 // cancelled(1012)/Socket not connected(1011) 等残留错误事件,避免它们把新会话 // 误终止掉。2 秒对 urlSession didCompleteWithError 的回调窗口足够。 _astErrorGraceUntil = DateTime.now().add(const Duration(milliseconds: 2000)); _isReinitializing = false; } } // ==================== 历史记录相关方法 ==================== /// 加载翻译历史 void loadTranslationHistory() => _historyManager.loadTranslationHistory(); /// 保存翻译历史 void saveTranslationHistory() => _historyManager.saveTranslationHistory(); /// 设置滚动监听器(适配reverse模式) void _setupScrollListener() { if (_scrollListenerSetup) return; scrollController.addListener(() { if (scrollController.position.pixels >= scrollController.position.maxScrollExtent - 100) { if (hasMoreHistory.value && !isLoadingMore.value) { Logger.d(TAG, '触发加载更多翻译历史记录(reverse模式)'); loadMoreHistory(); } } }); _scrollListenerSetup = true; } /// 清空翻译历史 void clearTranslationHistory() => _historyManager.clearTranslationHistory(); /// 删除会话历史 /// [sessionId] 会话ID void deleteSessionHistory(String sessionId) => _historyManager.deleteSessionHistory(sessionId); /// 删除翻译项 /// [sessionId] 会话ID /// [timestamp] 时间戳 void deleteTranslationItem(String sessionId, DateTime timestamp) => _historyManager.deleteTranslationItem(sessionId, timestamp); /// 获取所有历史记录 /// 返回所有翻译项列表 List getAllHistory() => _historyManager.getAllHistory(); /// 加载更多历史记录 Future loadMoreHistory() => _historyManager.loadMoreHistory(); /// 滚动到底部 /// [animate] 是否使用动画 void scrollToBottom({bool animate = true}) => _historyManager.scrollToBottom(animate: animate); // ==================== ASR活跃时长跟踪 ==================== /// 开始ASR活跃时长跟踪 void _startAsrActiveTracking() { if (!_isAsrActive) { _asrActiveStartTime = DateTime.now(); _isAsrActive = true; Logger.info('开始ASR活跃时长跟踪'); } } /// 停止ASR活跃时长跟踪 void _stopAsrActiveTracking() { if (_isAsrActive && _asrActiveStartTime != null) { final activeDuration = DateTime.now().difference(_asrActiveStartTime!); _asrActiveDuration += activeDuration; _isAsrActive = false; _asrActiveStartTime = null; Logger.info('停止ASR活跃时长跟踪,本次活跃时长: ${activeDuration.inSeconds}秒'); } } /// 记录ASR使用统计 - 基于精确时长跟踪 /// [language] 语言名称 void _recordAsrUsageStats(String language) { try { if (_asrSessionStartTime == null) return; _stopAsrActiveTracking(); final now = DateTime.now(); final totalDurationSeconds = now.difference(_asrSessionStartTime!).inSeconds; final effectiveDurationSeconds = _asrActiveDuration.inSeconds; _usageService.recordAsrApiCall( effectiveDurationSeconds: effectiveDurationSeconds, totalDurationSeconds: totalDurationSeconds, language: language, mode: currentMode.value, audioFormat: 'pcm', sampleRate: 16000, ); Logger.info( 'ASR会话统计: 总时长${totalDurationSeconds}秒, 实际活跃时长${effectiveDurationSeconds}秒, 语言$language'); _asrSessionStartTime = null; _asrActiveDuration = Duration.zero; _isAsrActive = false; _asrActiveStartTime = null; } catch (e) { Logger.error('记录ASR统计失败: $e'); } } }