You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

3033 lines
117 KiB

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<AsrService>();
// 同声翻译 / 面对面翻译 / 音视频翻译走火山翻译(REST)
// 通话翻译(call 模式)不使用该字段,改走 _astService(AST 端到端)
// 文本翻译走服务端(后台第三方服务配置里的 MT 服务,当前是阿里云)。
// 以前直连火山,AK/SK 要明文下发给每个客户端,换服务商还得发版。
final ServerTranslationService _translationService =
Get.find<ServerTranslationService>();
final TtsService _ttsService = Get.find<TtsService>();
final AstService _astService = Get.find<AstService>();
final LanguageManager _languageManager = Get.find<LanguageManager>();
/// 最近一次通话模式初始化所选中的 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<BluetoothManager>();
final BleManager bleManager = Get.find<BleManager>();
final _musiceManager = Get.find<MusicManager>();
final _qqMusicManager = Get.find<QqMusicService>();
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>(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<void> _initServicesFuture;
// ==================== 管理器 ====================
late final RecordTimerController _timerManager;
late final TranslationHistoryManager _historyManager;
// ==================== 访问器 ====================
RxString get recordDurationText => _timerManager.recordDurationText;
RxList<TranslationItem> 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<String, String> get supportedLanguages =>
_languageManager.getChineseNameToAsrCodeMap();
// ==================== 流订阅 ====================
StreamSubscription? _recognitionSubscription;
StreamSubscription? _astEventSubscription;
StreamSubscription? _voiceInteractionSubscription;
StreamSubscription<bool>? _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<void>? _floatingWindowClosedSubscription;
Timer? _translationDebounceTimer;
// ==================== 重新初始化守卫 ====================
bool _isReinitializing = false; // 语言切换重新初始化期间为 true,抑制旧会话的错误事件
// 初始化/切换 provider 完成后的宽限期,用于过滤底层异步 dispose 旧 session 时产生的残留错误事件(如 1012/1011)
DateTime? _astErrorGraceUntil;
// 语言切换互斥:连续切换语言时串行执行,避免并发的 _reinitializeAsrService 导致
// 原生 AST 已启动但 isRecognizing 仍为 false 的状态不一致。
Future<void>? _languageSwitchChain;
// 播放防重复机制
String? _lastPlayedItemKey; // 最后播放的项目标识
int _lastPlayTime = 0; // 最后播放时间
static const int _playDebounceInterval = 500; // 防抖间隔:500毫秒
// 底部控制栏高度
double _bottomBarHeight = 0.0;
/// 初始化安卓悬浮窗管理器
void _initFloatingWindowManager() {
try {
if (!Get.isRegistered<FloatingWindowManager>()) {
Get.put(FloatingWindowManager(), permanent: true);
}
_floatingWindowManager = Get.find<FloatingWindowManager>();
// 监听用户主动关闭浮窗:在 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<void> 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<void> 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<void> _startPip() async {
if (_pipContentView == 0) {
_pipContentView = await _nativePlugin.createPipContentView();
}
_isPipExpanded = false;
await _pip.setup(
_buildIosPipOptions(preferredHeight: _pipCompactPreferredHeight));
await _pip.start();
}
/// 启用悬浮窗功能
Future<void> _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<void> _disableFloatingWindow() async {
await _floatingWindowManager.disableFloatingWindow();
isFloatingWindowEnabled.value = false;
// Get.snackbar(
// '悬浮窗已禁用',
// '翻译结果将在应用内显示',
// snackPosition: SnackPosition.TOP,
// );
}
/// 检查当前模式是否支持悬浮窗
bool _isFloatingWindowSupportedMode() {
return currentMode.value == 'call' || currentMode.value == 'audioVideo';
}
/// 更新悬浮窗翻译内容
Future<void> _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<void> 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<void> setActiveSpeaker(int id) async {
if (id != 0) {
faceToFaceTrigger.value = FaceToFaceTrigger.device;
}
await _handleFaceToFaceHoldToTalk(id);
}
/// 处理面对面翻译的按住说话逻辑
/// [id] 说话者ID
Future<void> _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<void> _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<void> _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<TranslationItem?> get currentItem {
if (translationHistory.isNotEmpty &&
translationHistory.last.isIntermediate) {
return Rx<TranslationItem?>(translationHistory.last);
}
return Rx<TranslationItem?>(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<BesBluetoothService>()) return;
_besCallOngoingWorker = ever<bool>(
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<void> _preCheckPermissions() async {
try {
await requestRecordPermission();
} catch (e) {
Logger.e("Permission", "预检查权限失败: $e");
}
}
/// 初始化用量统计服务
void _initUsageService() {
try {
// 确保统计服务已注册
if (!Get.isRegistered<UsageStatsService>()) {
Get.put(UsageStatsService(), permanent: true);
}
_usageService = Get.find<UsageStatsService>();
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<bool> 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<bool> _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<void> 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<void> 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<void> _initServices() async {
try {
await _translationService.initialize();
// 初始化 ASR 服务时,明确指定需要支持的语言
// final List<String> 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<void> _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<String> 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<void> toggleisRecord() async {
isRecording.toggle();
// 设备端触发的面对面翻译是逐句按住说话,录音由 _startFaceToFaceRecognition
// 自己按轮次开/续,这里不插手;客户端触发是连续识别,和其它模式一样立即开录。
if (isRecording.value &&
isRecognizing.value &&
!_isFaceToFaceDeviceMode) {
startRecording();
} else if (!isRecording.value) {
stopRecording();
}
}
/// 开始录音
///
/// - call 模式:走 [_astService],原生将录制立体声 WAV
/// (左=本端麦克风原音,右=对方翻译后的合成音)。
/// - 其他模式:保持原有 [_asrService] 录制逻辑。
Future<void> 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<void> 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;
// ⚠️ 标记必须在这里清:`isRecording` 之前全文件**只有一处 `= true`**
// (_finalizeRecognitionStart 里「同传默认开录音」),从来没有被置回 false。
// 于是识别停了、原生录音也停了,右上角却一直显示在录,
// 用户以为翻译还活着。收口到这里,所有停止路径都覆盖得到。
isRecording.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<void> pauseRecording() async {
_timerManager.pauseTimer();
if (currentMode.value == 'call') {
await _astService.pauseRecord();
} else {
await _asrService.pauseRecord();
}
Logger.info(' 录音已暂停');
}
/// 继续录音
Future<void> continueRecording() async {
_timerManager.resumeTimer();
if (currentMode.value == 'call') {
await _astService.resumeRecord();
} else {
await _asrService.resumeRecord();
}
Logger.info(' 录音已继续');
}
/// 开始语音识别
/// 同声翻译、通话翻译、多媒体翻译的语音识别调用入口
Future<void> 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;
// ⚠️ 必须在这里点亮「准备中」:view 早就绑了 isPreparing 做按钮的缩放/阴影
// 反馈,但一直没人把它设成 true——点下去到真正开始识别之间毫无视觉变化,
// 用户看到的就是「点了没反应」。ASR 首次初始化要连 Azure,本来就有秒级耗时,
// 没有反馈时这段等待格外难受。下面的 finally 负责清,所有 return 路径都覆盖。
isPreparing.value = true;
final startedAt = DateTime.now();
try {
// 等 onInit 里那次按当前语言对做的 ASR 初始化落地,别让它被兜底的
// 默认 zh-CN/en-US 初始化抢先(见 onInit 中 _modeInitFuture 的说明)。
// 这一步是「首次点开始要等几秒」的主要来源:进页面就已经在跑了,
// 进页面后立刻点才会真的等到它。异常单独吞掉——初始化没跑成也不该
// 直接放弃启动,下面还有兜底路径。
try {
// ⚠️ **必须带超时**。这里等的是 onInit 那次按当前语言对做的 ASR 初始化,
// 而它在 iOS 首次进入时要激活音频会话、连着蓝牙耳机时还要等音频路由
// 建立,实测可能迟迟不返回。没有超时的话点击就一直卡在这一行——
// 表现是「第一次进同传点了半天没反应,退出重进就好了」(重进时系统
// 音频组件已经热了)。
//
// 超时后继续往下走:ASR 会走 startContinuousRecognition 里那条兜底的
// initialize(不带语言、落到默认 zh-CN/en-US)。语言可能不对,
// 但**至少起得来**——比永久无响应强。加这个 await 的初衷是消除竞态,
// 不该把它升级成一个卡死点。
await _modeInitFuture?.timeout(_modeInitMaxWait);
final waited = DateTime.now().difference(startedAt).inMilliseconds;
if (waited > 300) {
Logger.w('Translation', '等待模式初始化耗时 ${waited}ms(进页面后立刻点开始会等到它)');
}
} on TimeoutException {
// 提到 warning:release 包的日志级别是 warning,用 info 打的话
// 真机上一条都留不下来,出问题时无据可查。
Logger.w('Translation',
'等待模式初始化超过 ${_modeInitMaxWait.inSeconds}s,放弃等待继续启动识别'
'(ASR 可能落到默认语言,见上方说明)');
} catch (e) {
Logger.w('Translation', '等待模式初始化失败(忽略): $e');
}
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) {
await _abortRecognition('启动语音识别失败: ${e.toString()}');
} finally {
isPreparing.value = false;
final totalMs = DateTime.now().difference(startedAt).inMilliseconds;
// 只在明显偏慢时用 warning 记一笔:release 的日志级别是 warning,
// 正常情况不该刷屏,慢的时候必须留下证据。
if (totalMs > 1500 || !isRecognizing.value) {
Logger.w('Translation',
'startRecognition 总耗时 ${totalMs}ms, 识别中=${isRecognizing.value}');
}
}
}
/// 识别异常终止时的统一回滚。
///
/// ⚠️ **必须把录音一起停掉**:同传的录音是 [_finalizeRecognitionStart] 跟着识别
/// 一起拉起来的(「同声翻译默认开录音」),识别挂了而录音还在跑,表现就是
/// 右上角还在计时、用户以为翻译活着,实际早停了。
/// [stopRecording] 内部会把已录的部分正常落盘并归档,不会丢内容。
Future<void> _abortRecognition(String reason) async {
Logger.error('识别异常终止: $reason');
isPreparing.value = false;
isRecognizing.value = false;
if (isRecording.value) {
try {
await stopRecording();
} catch (e) {
Logger.error('异常终止时停止录音失败(忽略): $e');
}
}
}
/// 检查权限
Future<bool> _checkPermissions() async {
final bool record = await requestRecordPermission();
if (!record) {
Logger.d("Permission", "录音权限被拒绝,无法开始录音");
return false;
}
return true;
}
/// 初始化会话
Future<void> _initializeSession() async {
currentSessionId = DateTime.now().millisecondsSinceEpoch.toString();
try {
_usageService.startNewSession();
_asrSessionStartTime = DateTime.now();
_asrActiveDuration = Duration.zero;
_isAsrActive = false;
} catch (e) {
Logger.error('开始新会话统计失败: $e');
}
}
/// 配置音频模式
Future<void> _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<void> _configureFaceToFaceMode() async {
_audioSourceType = _isFaceToFaceDeviceMode;
isTtsEnabled.value = true;
Logger.info(
'面对面翻译收音来源:${_audioSourceType ? "设备端(BLE)" : "手机麦克风"}'
'(触发来源=${faceToFaceTrigger.value.name})');
}
/// 配置音视频模式
Future<void> _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<void> _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<void> _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<void> _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<void> _configureDefaultMode() async {
_audioSourceType = false;
isTtsEnabled.value = true;
}
/// 启动语音识别
///
/// 这一段是「首次进同传拉不起识别」的另一半嫌疑:原生
/// startContinuousRecognition 要连 Azure(DNS + TLS + WebSocket 握手),
/// 首次通常最慢,而它**不在** startRecognition 开头那个 _modeInitFuture 的
/// 等待范围内,超时兜底管不到它。所以单独打点,好把耗时归到正确的一段。
Future<void> _startAsrService(String mode) async {
final t0 = DateTime.now();
try {
await _startAsrServiceInner(mode);
} finally {
final ms = DateTime.now().difference(t0).inMilliseconds;
if (ms > 1000) {
Logger.w('Translation', 'ASR 启动(startContinuousRecognition)耗时 ${ms}ms, mode=$mode');
}
}
}
Future<void> _startAsrServiceInner(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<void> _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);
// ⚠️ 走 _abortRecognition 而不是只清 isRecognizing:
// 这条分支正是「接口没通、功能自己关了」的路径,录音必须跟着停,
// 否则右上角还在计时(见 _abortRecognition 的说明)。
unawaited(_abortRecognition('语音识别错误: $errorMsg'));
break;
case RecognitionEventType.canceled:
unawaited(_abortRecognition('语音识别取消: ${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<void> 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<void> 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<String?> 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<String, String> _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<String?> _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<void> _updateTranslationHistory(
String sourceText,
String translationResult,
DateTime timestamp,
Map<String, String> 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<void> _updateExistingHistoryItem(
int index,
String translationResult,
Map<String, String> 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<void> _recordTranslationStats(
String sourceText,
String translationResult,
Map<String, String> 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<void> _handleTtsPlayback(String translationResult) async {
if (isTtsEnabled.value) {
await playTranslatedText(translationResult);
}
}
// /// 处理面对面TTS
// /// [translationResult] 翻译结果
// Future<void> _handleFaceToFaceTts(String translationResult) async {
// fTFTranslationResult = (fTFTranslationResult ?? '') + translationResult;
// print("translationResult=$translationResult");
// if (activeSpeaker.value == 0 && !isPlayback) {
// isPlayback = true;
// await playTranslatedText(fTFTranslationResult!);
// fTFTranslationResult = '';
// }
// }
/// 播放翻译文本
/// [text] 要播放的文本
Future<void> 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<void> 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<void> _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<void> _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<void> _teardownBesCallMode() async {
_detachBesCallTranslationBridge();
if (!Get.isRegistered<BesBluetoothService>()) 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<void> 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<void> 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<T> _runLanguageSwitch<T>(Future<T> Function() action) {
final previous = _languageSwitchChain;
final completer = Completer<T>();
final gate = Completer<void>();
Future<void> 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<void> 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<void> 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<void> 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<String> _asrLanguagesForCurrentMode() {
if (currentMode.value == 'faceToFace') {
return [sourceLanguageCode.value, targetLanguageCode.value];
}
return [sourceLanguageCode.value];
}
/// onInit 里那次 [changeTranslationMode] 的 Future,见 onInit 的说明。
Future<void>? _modeInitFuture;
/// 等待上面那次初始化的上限。超了就不等,宁可语言落到默认值也不要卡住启动。
static const Duration _modeInitMaxWait = Duration(seconds: 3);
// ---- 中间结果(说话过程中的预览译文)翻译的节流与取消,见 handleIntermediateResult ----
/// 中间结果翻译的防抖间隔。中间译文只上屏、不播报,值得为它省下请求。
static const Duration _interimTranslateDebounce = Duration(milliseconds: 700);
/// 相比上次真正发出去的文本,至少要多这么多个字才值得再翻一次。
static const int _interimMinGrowth = 4;
/// 在途的中间结果翻译请求,发新的之前撤掉它。
CancelToken? _interimTranslateToken;
/// 上一次真正发出去翻译的中间文本,用于增量门槛判断。
String _lastInterimSentSource = '';
/// 翻译请求序号:回来时不是最新的一发就丢弃,避免旧结果盖掉新译文。
int _interimTranslateSeq = 0;
/// 重新初始化ASR服务
/// [restartRecognition] 初始化完成后是否自动重新开始识别
Future<void> _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<String> 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<TranslationItem> getAllHistory() => _historyManager.getAllHistory();
/// 加载更多历史记录
Future<void> 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');
}
}
}