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.
3498 lines
148 KiB
3498 lines
148 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 '../../../core/utils/audio_foreground_service.dart';
|
|
import '../../../data/utils/peer_speech_gate.dart';
|
|
import '../../../data/utils/background_keepalive.dart';
|
|
import '../../../core/utils/recent_languages.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:eaimar/data/services/qq_music.dart';
|
|
import '../../../data/services/ast_service.dart';
|
|
import '../../../data/services/server_translation_service.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/utils/toast_throttle.dart';
|
|
import '../../../core/services/floating_window_manager.dart';
|
|
|
|
import '../../../data/services/asr_service.dart';
|
|
import '../../../data/services/usage_stats_service.dart';
|
|
import 'package:device_plugin_interface/device_plugin_interface.dart';
|
|
import '../../../devices/device_hub.dart';
|
|
import '../../../data/services/recording_archive.dart';
|
|
import '../models/translation_export.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 '../models/translation_direction.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;
|
|
|
|
// AST 掉线自动重连:切后台被 ROM 冻结、弱网抖动都会让 WebSocket 断开,
|
|
// 那不是「功能该结束了」,用户还在通话里。见 _handleAstDrop。
|
|
int _astReconnectAttempts = 0;
|
|
bool _astReconnecting = false;
|
|
|
|
/// 重连预算改成**时间窗**而不是次数(2026-09-20):原来固定 3 次、退避 1/2/4s,
|
|
/// 蜂窝网抖动频繁时 7 秒就烧光,然后整个通话翻译被终止——用户还在电话里。
|
|
/// 现在:从第一次掉线起 [_astReconnectWindow] 内不限次数重连(退避封顶
|
|
/// [_astReconnectMaxDelaySec]),收到终态结果就把窗口清零;超过窗口才真的终止。
|
|
DateTime? _astDropWindowStart;
|
|
|
|
/// 给界面看的链路状态(通话翻译页顶栏那颗小灯)。
|
|
/// 原来用户只能靠「有没有字幕」猜链路是死是活,重连中也没有任何提示。
|
|
final Rx<AstLinkState> astLinkState = AstLinkState.idle.obs;
|
|
|
|
/// 通话翻译 PCM 是否走了原生直连桥(见 [_attachBesCallTranslationBridge])。
|
|
/// true 时 Dart 不再搬任何一帧音频,[_peerGate] 也不参与(门限在原生)。
|
|
bool _callBridgeNative = false;
|
|
/// 对端(B 路)说话人门限,保证阿里刻到的第一段音频来自对端本人。
|
|
/// 见 [PeerSpeechGate];**每次 AST 会话重建都要 reset**,否则重连后照样刻到串音。
|
|
final PeerSpeechGate _peerGate = PeerSpeechGate();
|
|
|
|
/// AST 链路当前是否可用。收到任何一条 AST 事件即为 true,掉线置 false。
|
|
/// [didChangeAppLifecycleState] 靠它决定回到前台要不要补一次重连。
|
|
bool _astHealthy = false;
|
|
Timer? _astReconnectTimer;
|
|
static const Duration _astReconnectWindow = Duration(seconds: 60);
|
|
static const int _astReconnectMaxDelaySec = 8;
|
|
final GetStorage _storage = GetStorage();
|
|
final _musiceManager = Get.find<MusicManager>();
|
|
final _qqMusicManager = Get.find<QqMusicService>();
|
|
late final UsageStatsService _usageService;
|
|
// 安卓悬浮窗管理器
|
|
late final FloatingWindowManager _floatingWindowManager;
|
|
// 苹果悬浮窗管理器
|
|
late final NativePlugin _nativePlugin;
|
|
|
|
// ==================== 悬浮窗相关 ====================
|
|
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 表示本轮没开过录音
|
|
// 录音起点与暂停区间:归档时把翻译条目的时间戳换算成「音频里的毫秒偏移」要用。
|
|
// 面对面翻译逐句按住说话、松开就暂停录音,不记暂停的话偏移越往后越大。
|
|
DateTime? _recordingStartedAt;
|
|
final List<(DateTime, DateTime)> _recordingPauses = [];
|
|
DateTime? _pauseStartedAt;
|
|
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;
|
|
// 通话翻译 ↔ 设备桥接(厂商无关,走 DeviceSession 的音频通路):
|
|
// openMic(callLocal)(本端) / openMic(callPeer)(对端) → AstService.pushExternalAudio
|
|
// AstService.astTtsFrames → openSpeaker(callLegA / callLegB)
|
|
DeviceSession? _callSession;
|
|
DeviceAudioSource? _callLocalSrc;
|
|
DeviceAudioSource? _callPeerSrc;
|
|
DeviceAudioSink? _callSinkA;
|
|
DeviceAudioSink? _callSinkB;
|
|
StreamSubscription? _besCallMicSub;
|
|
StreamSubscription? _besCallSpkSub;
|
|
StreamSubscription? _besCallAstTtsSub;
|
|
// 本次通话翻译会话是否走了设备双路通路——停止/出错收尾时按这个分流,
|
|
// 不要临停时再去查连接状态(这时耳机可能已经断开了)
|
|
bool _callModeUsingBes = false;
|
|
// 一次通话模式会话的设备侧收尾(call.stop/asr.stopped)只应该
|
|
// 真正执行一次——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 {
|
|
_nativePlugin = NativePlugin();
|
|
if (!Platform.isIOS) return;
|
|
// ⚠️ 不再走 pub 包 `pip`。它给小窗喂的是一整块纯黑像素,真正的字幕卡片
|
|
// 靠把 contentView 搬进「keyWindow」来显示,而 PiP 窗口由系统进程托管、
|
|
// 根本不在 App 的窗口列表里 —— 于是字幕落在 App 自己身上(还盖住整屏),
|
|
// 小窗里只有一块黑的。现在改成自绘:把字幕画进 CVPixelBuffer 逐帧喂给
|
|
// AVSampleBufferDisplayLayer,小窗显示的就是它(见原生 SubtitlePipController)。
|
|
_restorePipStyle();
|
|
SubtitlePip.onStateChanged = _onSubtitlePipStateChanged;
|
|
isFloatingWindowEnabled.value = await SubtitlePip.isActive();
|
|
}
|
|
|
|
/// iOS 字幕小窗的真实状态(原生回传)。
|
|
///
|
|
/// iOS 全系统同时只允许一个画中画:用户在视频 App 里开了它自己的小窗,我们的就被
|
|
/// 系统停掉。原来这里没有任何回调,标志位停在 stale-true——用户点开关走的是"关",
|
|
/// 要点两下才回来;而且没人再把小窗拉起来,"弹幕框就丢了"(2026-09-20 苹果测试)。
|
|
/// 原生现在会自愈重试、并在下次退后台时再拉一次;这里只负责把标志校准、并在被
|
|
/// 顶掉时告诉用户怎么恢复。
|
|
void _onSubtitlePipStateChanged(String state) {
|
|
switch (state) {
|
|
case 'started':
|
|
isFloatingWindowEnabled.value = true;
|
|
break;
|
|
case 'stopped':
|
|
// 只有这一种是"我们自己关的"——原生只在 stop() 里发它。
|
|
// 其余两种 wanted 仍为 true,开关不能跟着灭(见下面的说明)。
|
|
isFloatingWindowEnabled.value = false;
|
|
break;
|
|
case 'failed':
|
|
// ⚠️ **不要把开关置成关**。前台调 start 本来就不会真正进小窗
|
|
// (possible=true / active=false,并回调一次 failed,sample-buffer
|
|
// 画中画的正常行为),真正呈现是在 App 退到后台那一刻由系统拉起。
|
|
// 置成关的后果是:默认开启之后用户立刻看到开关是灭的,以为没生效,
|
|
// 去点一下反而把原生的 wanted 关掉,自愈也跟着没了。
|
|
Logger.i('Translation', '字幕小窗这次没拉起来(前台或被占用),保持"已开启"等待系统拉起');
|
|
break;
|
|
case 'stoppedBySystem':
|
|
// 被别的 App 的画中画顶掉。原生的 wanted 仍是 true、还在退避重试,
|
|
// 并且会在本 App 下次退到后台时再拉一次——**用户什么都不用点**。
|
|
// 所以开关保持"开",只给一句提示说明它为什么暂时不见了。
|
|
if ((isRecognizing.value || isPreparing.value) &&
|
|
_isFloatingWindowSupportedMode()) {
|
|
Get.snackbar('tip'.tr, 'pipTakenOverByOtherApp'.tr,
|
|
snackPosition: SnackPosition.TOP,
|
|
duration: const Duration(seconds: 5));
|
|
}
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// 切换悬浮窗功能
|
|
Future<void> toggleFloatingWindow() async {
|
|
if (isFloatingWindowEnabled.value) {
|
|
await _disableFloatingWindow();
|
|
} else {
|
|
if (Platform.isAndroid) {
|
|
await _enableFloatingWindow();
|
|
} else if (Platform.isIOS) {
|
|
await _startPip();
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _startPip() async {
|
|
final ok = await SubtitlePip.start();
|
|
isFloatingWindowEnabled.value = ok;
|
|
if (!ok) {
|
|
Logger.w('Translation', '字幕小窗启动失败(系统拒绝或本机不支持画中画)');
|
|
return;
|
|
}
|
|
// 起来之后把当前样式补一次,否则用户上次调的透明度/宽度这一轮不生效
|
|
await SubtitlePip.setStyle(
|
|
opacity: pipOpacity.value, widthScale: pipWidthScale.value);
|
|
}
|
|
|
|
/// 字幕小窗的样式。**都会持久化**,下次进来沿用。
|
|
///
|
|
/// ⚠️ [pipOpacity] 改的是卡片自己的底色,**不是**「透过小窗看见下面的 App」。
|
|
/// iOS 的 PiP 窗口本身不透明,系统没有开放任何让它半透明的接口。
|
|
/// 真要少挡内容只能把小窗做小(widthScale)或用系统手势把它拖到别处。
|
|
///
|
|
/// ⚠️ [pipWidthScale] 改的是**画布宽高比**。小窗的实际大小由用户捏合手势
|
|
/// 决定,App 无权直接设定;换比例会让系统按新形状重新布局。
|
|
final RxDouble pipOpacity = 0.9.obs;
|
|
final RxDouble pipWidthScale = 1.0.obs;
|
|
|
|
static const String _kPipOpacityKey = 'pip_opacity';
|
|
static const String _kPipWidthScaleKey = 'pip_width_scale';
|
|
|
|
void _restorePipStyle() {
|
|
final o = _storage.read<double>(_kPipOpacityKey);
|
|
final w = _storage.read<double>(_kPipWidthScaleKey);
|
|
if (o != null) pipOpacity.value = o.clamp(0.2, 1.0);
|
|
if (w != null) pipWidthScale.value = w.clamp(0.5, 2.0);
|
|
}
|
|
|
|
Future<void> setPipOpacity(double v) async {
|
|
pipOpacity.value = v.clamp(0.2, 1.0);
|
|
await _storage.write(_kPipOpacityKey, pipOpacity.value);
|
|
await SubtitlePip.setStyle(opacity: pipOpacity.value);
|
|
}
|
|
|
|
Future<void> setPipWidthScale(double v) async {
|
|
pipWidthScale.value = v.clamp(0.5, 2.0);
|
|
await _storage.write(_kPipWidthScaleKey, pipWidthScale.value);
|
|
await SubtitlePip.setStyle(widthScale: pipWidthScale.value);
|
|
}
|
|
|
|
/// 启用悬浮窗功能
|
|
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,
|
|
// );
|
|
}
|
|
}
|
|
|
|
/// 关掉悬浮字幕窗。
|
|
///
|
|
/// ⚠️ **必须分平台**:[_floatingWindowManager] 是 `late` 且只在
|
|
/// [onInit] 的 `Platform.isAndroid` 分支里赋值(iOS 走
|
|
/// [initPlatformState] 那套 SubtitlePip),在 iOS 上碰它就是
|
|
/// LateInitializationError。而本方法会被 [changeTranslationMode] 调到
|
|
/// ——从多媒体/通话翻译切到同传或面对面时,小窗不受支持要自动关掉。
|
|
/// 少了这层判断,iOS 上的后果是连锁的、且没有一条指向真因:
|
|
/// 异常从 [changeTranslationMode] 中途抛出,后面的
|
|
/// [_reinitializeAsrService] 再也走不到;小窗又没真的停掉,
|
|
/// 继续浮在同传/面对面页上盖住顶部的标题与按钮;偏偏小窗开关按钮
|
|
/// 只在 call/audioVideo 两个模式才渲染,用户连关都关不掉。
|
|
Future<void> _disableFloatingWindow() async {
|
|
if (Platform.isIOS) {
|
|
await SubtitlePip.stop();
|
|
} else if (Get.isRegistered<FloatingWindowManager>()) {
|
|
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) {
|
|
// 收起/展开不再需要重新 setup:画布高度跟着卡片内容走,
|
|
// 宽高比一变系统就会把小窗重新布形状。
|
|
await SubtitlePip.update(
|
|
lang: '$sourceLanguageName -> $targetLanguageName',
|
|
cn: item.sourceText,
|
|
en: item.translatedText,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 更改翻译模式
|
|
/// 参数:
|
|
/// - [mode] 翻译模式:simultaneous(同声传译)、audioVideo(音视频)、call(通话)、faceToFace(面对面)
|
|
/// 返回值:无
|
|
/// 异常:可能抛出底层服务调用异常
|
|
Future<void> changeTranslationMode(String mode) async {
|
|
currentMode.value = mode;
|
|
astLinkState.value = AstLinkState.idle;
|
|
// 每次进/出面对面翻译都回到客户端触发;设备端触发由耳机按键在
|
|
// [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');
|
|
}
|
|
|
|
// 小窗只支持 call / audioVideo,切到别的模式一律关掉。
|
|
// ⚠️ **不要再加 `isFloatingWindowEnabled.value &&` 这个前置条件**:
|
|
// iOS 上这个标志位是 [initPlatformState] 里那次异步的
|
|
// `SubtitlePip.isActive()` 回填的,而 onInit 紧接着就调本方法,
|
|
// 两者是竞态——回填晚一步,这里就当成「没开」直接跳过,小窗继续浮在
|
|
// 同传/面对面页上盖住顶部按钮,而小窗开关只在 call/audioVideo 渲染,
|
|
// 用户连关都关不掉。没开着时多关一次是无害的。
|
|
if (!_isFloatingWindowSupportedMode()) {
|
|
await _disableFloatingWindow();
|
|
}
|
|
|
|
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 {
|
|
// 同上:旧额度桶已停用,不能再当闸门(见 startRecognition 的说明)
|
|
if (!_hasTranslationQuota()) {
|
|
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);
|
|
}
|
|
|
|
/// 首次进入时若 autoStart=true,立即触发一次开启。
|
|
/// (设备端「通话翻译开关」按键的信号目前没有厂商上报,所以这里只剩 autoStart。)
|
|
void _setupDeviceCallTranslationListener(String initMode) {
|
|
_deviceCallTranslationSubscription?.cancel();
|
|
// 支持导航进入时携带 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);
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
/// 监听设备上报的通话状态(DeviceHub.inCall)。
|
|
/// 挂断(isCallOngoing 变 false)时,如果正在通话翻译,自动停止识别——
|
|
/// 不然识别会一直空跑,AST 也拿不到新音频,只能等用户自己发现再手动停。
|
|
void _setupBesCallHangupListener() {
|
|
_besCallOngoingWorker?.dispose();
|
|
if (!Get.isRegistered<DeviceHub>()) return;
|
|
_besCallOngoingWorker = ever<bool>(
|
|
DeviceHub.to.inCall,
|
|
(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({bool isRecovery = false}) 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);
|
|
|
|
// ⚠️ 新会话 = 重新刻一次音色,门限必须跟着重新关上,否则重连后照样刻到串音。
|
|
_peerGate.reset();
|
|
|
|
// 获取识别事件流(端到端服务已包含 ASR+翻译+TTS,不需要单独启动 ASR)
|
|
await _subscribeAstEvents();
|
|
|
|
// 初始化完成后给一段宽限期,过滤掉底层异步 dispose 旧 provider session 时产生的
|
|
// cancelled(1012) / Socket not connected(1011) 残留事件,避免被误判为新 session 错误。
|
|
//
|
|
// ⚠️ **重连时必须把这个窗口收窄**。2026-09-19 真机:进程被 ROM 冻结 →
|
|
// 重连起来的新连接在 1.9s 后又被冻死,而那条致命错误正好落在 2s 宽限期里被
|
|
// 当成「旧会话残留」吞掉 → 第 2 次重连压根没排上,翻译就此停在那里、
|
|
// 回到前台也不会自己好。恢复场景下旧 session 早在上一轮就收干净了,
|
|
// 800ms 足够盖住 dispose 的尾巴。
|
|
_astErrorGraceUntil = DateTime.now().add(
|
|
isRecovery ? const Duration(milliseconds: 800) : 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;
|
|
_detachBesCallTranslationBridge();
|
|
_translationDebounceTimer?.cancel();
|
|
restoreOtherServices();
|
|
// 移除对服务的 dispose,避免频繁销毁/重建造成卡顿
|
|
// _asrService.dispose();
|
|
// _astService.dispose();
|
|
|
|
unawaited(_disableFloatingWindow());
|
|
if (SubtitlePip.onStateChanged == _onSubtitlePipStateChanged) {
|
|
SubtitlePip.onStateChanged = null;
|
|
}
|
|
|
|
// 释放管理器资源
|
|
_timerManager.dispose();
|
|
_historyManager.dispose();
|
|
|
|
// 取消应用生命周期监听
|
|
WidgetsBinding.instance.removeObserver(this);
|
|
|
|
super.onClose();
|
|
}
|
|
|
|
/// 应用生命周期变化回调
|
|
/// 当应用进入后台、失去焦点或分离时,兜底将 activeSpeaker 重置为 0
|
|
@override
|
|
void didChangeAppLifecycleState(AppLifecycleState state) {
|
|
// 观测点:切后台会不会让通话翻译卡顿,光看识别/翻译日志分不出来——
|
|
// 那两条在后台照样按时出结果,真正被 iOS 节流的是下行那条 20ms 定时器。
|
|
// 所以每次切换都把当时的桥统计打一份,前后一对比就知道帧是在哪一段丢的。
|
|
if (currentMode.value == 'call' && isRecognizing.value) {
|
|
Logger.w('Translation', '[生命周期] → $state(通话翻译进行中)');
|
|
unawaited(_dumpCallBridgeStats('lifecycle:$state'));
|
|
}
|
|
if (state == AppLifecycleState.inactive ||
|
|
state == AppLifecycleState.paused ||
|
|
state == AppLifecycleState.detached) {
|
|
if (activeSpeaker.value != 0) {
|
|
Future.microtask(() {
|
|
setActiveSpeaker(0);
|
|
});
|
|
}
|
|
return;
|
|
}
|
|
if (state != AppLifecycleState.resumed) return;
|
|
// ⚠️ 回到前台是通话翻译的**最后一道兜底**。
|
|
// 国内 ROM(鸿蒙的 Pged-Freezer 尤其激进)会在切后台几秒后直接冻结进程,
|
|
// 前台服务也挡不住(2026-09-19 真机:服务已按 serviceType:128 登记、通知也挂着,
|
|
// 照样 `Freeze process`)。进程被冻住时连重连定时器都不会跑,所以必须在解冻回来
|
|
// 的这一刻主动补一次——否则用户看到的就是「切出去一趟,回来翻译已经死了,
|
|
// 还得手动结束再重开」。
|
|
if (currentMode.value != 'call' || !isRecognizing.value) return;
|
|
if (_astHealthy) return;
|
|
Logger.w('Translation', '[STS] 回到前台且 AST 已掉线,立即重连');
|
|
_astReconnectTimer?.cancel();
|
|
_astReconnectTimer = null;
|
|
_astReconnecting = false;
|
|
_astReconnectAttempts = 0; // 用户回来了,重连预算给满
|
|
_astDropWindowStart = null;
|
|
unawaited(_handleAstDrop('回到前台后恢复'));
|
|
}
|
|
|
|
/// 拉一次原生直连桥的实时统计并落日志。
|
|
///
|
|
/// 与会话结束时那条 `[CALL-BRIDGE] 原生直连桥统计` 同源,区别是可以**在会话中途**
|
|
/// 随时取样。framesA/framesB 的增量除以两次取样的间隔,就是上行的实际帧率
|
|
/// (正常 50 帧/秒),掉下来就说明进程被挂起或节流了。
|
|
Future<void> _dumpCallBridgeStats(String why) async {
|
|
try {
|
|
final st = await _astService.nativeCallBridgeStats();
|
|
if (st.isNotEmpty) {
|
|
Logger.w('Translation', '[CALL-BRIDGE] 上行取样($why): $st');
|
|
}
|
|
// 下行(译文回灌耳机)单独取一份:远端听到的卡顿看的是这一组。
|
|
// gapAvgMs 明显大于 intervalMs = iOS 把 20ms 定时器节流了;
|
|
// queueA 一路上涨 = 发得比产出慢。两者都只在这里看得到。
|
|
// ⚠️ 走 DeviceSession.diagnostics() 而不是直接 import bluetooth_manager:
|
|
// lib/modules 不允许依赖厂商插件,device_hub_boundary_test 会 fail。
|
|
final dl = await _callSession?.diagnostics() ?? const {};
|
|
if (dl.isNotEmpty) {
|
|
Logger.w('Translation', '[DOWNLINK] 取样($why): $dl');
|
|
}
|
|
} catch (e) {
|
|
Logger.w('Translation', '[CALL-BRIDGE] 取样($why) 失败: $e');
|
|
}
|
|
}
|
|
|
|
/// 切换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;
|
|
_recordingStartedAt = DateTime.now();
|
|
_recordingPauses.clear();
|
|
_pauseStartedAt = null;
|
|
}
|
|
|
|
/// 停止录音
|
|
///
|
|
/// 通话 / 同声 / 面对面翻译的录音停止后归档进「语音纪要」,**连同这段
|
|
/// 时间里翻译出的整句一起送过去**:纪要那边不再重新识别,直接用智能模板总结
|
|
/// (2026-09-18 产品口径,见 [RecordingArchive.archive] 的 `transcript`)。
|
|
/// 录音在这三个模式下默认开(见 [_initializeSession]),用户本轮可随时关。
|
|
Future<void> stopRecording() async {
|
|
// stopTimer() 会把 _seconds 清零,归档要用的时长必须在它之前取
|
|
final int seconds = _timerManager.currentSeconds;
|
|
final String? path = _currentRecordingPath;
|
|
_currentRecordingPath = null;
|
|
// 面对面翻译的会话是在「暂停」状态下结束的(最后一句松手就暂停了),
|
|
// 把这段收尾进暂停区间,最后一句之后的空档不算进偏移。
|
|
if (_pauseStartedAt != null) {
|
|
_recordingPauses.add((_pauseStartedAt!, DateTime.now()));
|
|
_pauseStartedAt = null;
|
|
}
|
|
final DateTime? startedAt = _recordingStartedAt;
|
|
_recordingStartedAt = 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) {
|
|
// 转写在归档**之前**就地算好:列表和暂停区间都是 controller 的状态,
|
|
// 归档是异步的,等它跑到的时候页面可能已经退了、下一轮也可能已经开始。
|
|
final transcript = startedAt == null
|
|
? const <Map<String, Object?>>[]
|
|
: buildMeetingTranscript(
|
|
items: translationHistory.toList(),
|
|
recordingStartedAt: startedAt,
|
|
mode: currentMode.value,
|
|
ownLanguageCode: sourceLanguageCode.value,
|
|
pauses: List.of(_recordingPauses),
|
|
);
|
|
Logger.i('Translation',
|
|
'归档翻译录音:${seconds}s 转写 ${transcript.length} 段 模式=${currentMode.value}');
|
|
// ⚠️ 不 await:归档里有一次 echomeet_addrecord 网络请求(Dio 30s
|
|
// connect + 30s receive),await 会把「点结束」一路堵到超时。
|
|
// 归档本身不依赖 controller(RecordingArchive 是静态的),
|
|
// 页面退了也能跑完。
|
|
unawaited(RecordingArchive.archive(
|
|
path: path,
|
|
seconds: seconds,
|
|
tag: 'TranslationController',
|
|
transcript: transcript,
|
|
// 总结用用户自己的语言(源语言);对方说的外语那几句原文+译文都在转写里
|
|
summaryLanguage: sourceLanguageCode.value,
|
|
));
|
|
}
|
|
_recordingPauses.clear();
|
|
}
|
|
|
|
/// 这个模式的录音要不要进「语音纪要」。
|
|
///
|
|
/// 通话 / 同声 / 面对面翻译都要(2026-09-18 起面对面也归档:它逐句按轮次
|
|
/// 暂停/继续,偏移由 [_recordingPauses] 扣掉)。音视频翻译录的是系统播放声,
|
|
/// 归档语义还没定,先不动。
|
|
bool get _shouldArchiveRecording =>
|
|
currentMode.value == 'call' ||
|
|
currentMode.value == 'simultaneous' ||
|
|
currentMode.value == 'faceToFace';
|
|
|
|
/// 这三个模式默认开录音:不录就没有文件,也就没有东西进语音纪要,
|
|
/// 而产品口径是「翻译完在纪要里能看到音频 + 转写 + 总结」。
|
|
bool get _recordsByDefault => _shouldArchiveRecording;
|
|
|
|
/// 归档文件名前缀,取值见 RecordingArchive 的约定。
|
|
String get _archivePrefix =>
|
|
currentMode.value == 'call' ? 'trans_call' : 'trans_holder';
|
|
|
|
/// 暂停录音
|
|
Future<void> pauseRecording() async {
|
|
_pauseStartedAt ??= DateTime.now();
|
|
_timerManager.pauseTimer();
|
|
if (currentMode.value == 'call') {
|
|
await _astService.pauseRecord();
|
|
} else {
|
|
await _asrService.pauseRecord();
|
|
}
|
|
|
|
Logger.info(' 录音已暂停');
|
|
}
|
|
|
|
/// 继续录音
|
|
Future<void> continueRecording() async {
|
|
if (_pauseStartedAt != null) {
|
|
_recordingPauses.add((_pauseStartedAt!, DateTime.now()));
|
|
_pauseStartedAt = null;
|
|
}
|
|
_timerManager.resumeTimer();
|
|
if (currentMode.value == 'call') {
|
|
await _astService.resumeRecord();
|
|
} else {
|
|
await _asrService.resumeRecord();
|
|
}
|
|
|
|
Logger.info(' 录音已继续');
|
|
}
|
|
|
|
/// 本地还允不允许开翻译。
|
|
///
|
|
/// 旧的「翻译额度」桶(tradeintegral)已停用、不再发放,拿它当闸门会把所有
|
|
/// 新账号挡在门外。真正的准入在服务端:无 VIP 时 user_translate 直接返回
|
|
/// VipRequired,算力不足按后台开关决定放不放行。客户端这层只保留一个
|
|
/// 「老账号还有存量额度」的兼容判断:**有存量就放行,没有也放行**,
|
|
/// 即恒为 true —— 保留这个函数是为了把「为什么不再判额度」写在一个地方,
|
|
/// 将来要恢复本地闸门也只改这里。
|
|
bool _hasTranslationQuota() => true;
|
|
|
|
/// 开始语音识别
|
|
/// 同声翻译、通话翻译、多媒体翻译的语音识别调用入口
|
|
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})');
|
|
}
|
|
// ⚠️ 这里原来判的是 `User.instance.tradeintegral <= 0`(旧「翻译额度」桶)。
|
|
// 那三个旧桶(tradeintegral/meetintegral/aichatintegral)已于会员与算力改造时
|
|
// **停用**:余额一次性折进算力,之后再也不发放,新账号恒为 0。于是新用户一点
|
|
// 「开始」就弹充值、识别根本不启动——表现就是「没语音识别、也没翻译」。
|
|
// 准入现在由服务端管(VIP 判 user_translate,算力记流水),客户端不再自己拦。
|
|
if (!_hasTranslationQuota()) {
|
|
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;
|
|
}
|
|
}
|
|
|
|
// iOS 音视频翻译:同样默认把字幕小窗开起来,不用用户再点一次开关。
|
|
// 这个模式的使用场景本来就是"切到别的 App 看视频",小窗是唯一能看到
|
|
// 译文的地方,默认关着等于默认不可用。
|
|
//
|
|
// ⚠️ 与 Android 那条不同,**起不来不中断翻译**:
|
|
// 1) 前台调 start 本来就不会真正进小窗(见 _onSubtitlePipStateChanged
|
|
// 的 failed 分支),拿到 false 不代表功能坏了;
|
|
// 2) 别的 App 的画中画正占着时也会失败,而那时翻译本身完全正常。
|
|
// Android 之所以 return,是因为没有系统悬浮窗就真的什么都看不见。
|
|
if (Platform.isIOS &&
|
|
currentMode.value == 'audioVideo' &&
|
|
!isFloatingWindowEnabled.value) {
|
|
await _startPip();
|
|
}
|
|
|
|
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 已经乐观下发过
|
|
// 指令了,是否有音频交给桥接的流订阅自己判断。
|
|
// ⚠️ iOS 的音视频翻译同样要排除在外。这个守卫要求杰理解码器就绪
|
|
// (isCodecActive 只由 openEncoder/openDecoder 这两个杰理血统的入口置位),
|
|
// 而 iOS 已改走手机麦克风、不再碰那条链路——不排除的话这里直接 return,
|
|
// 识别根本不会启动,表现就是「点了开始,什么都没发生」。
|
|
final bool isIosAudioVideo =
|
|
Platform.isIOS && currentMode.value == 'audioVideo';
|
|
if ((currentMode.value == 'audioVideo' ||
|
|
currentMode.value == 'call') &&
|
|
!isAndroidAudioVideo &&
|
|
!isIosAudioVideo &&
|
|
!(currentMode.value == 'call' && _callModeUsingBes)) {
|
|
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 端到端服务');
|
|
// 出错终止过的页面订阅已经没了,不补上这一轮就是「聋」的
|
|
if (_astEventSubscription == null) {
|
|
await _subscribeAstEvents();
|
|
}
|
|
await _astService.startContinuousTranslation();
|
|
}
|
|
} else if (isIosAudioVideo) {
|
|
// 专用的 media 模式:原生据此把 AVAudioSession 配成
|
|
// .playAndRecord + .mixWithOthers + .default —— 既不打断正在播放的
|
|
// 那个 App,又不开回声消除(AEC 会把本机扬声器的声音当回声消掉)。
|
|
Logger.i('Translation', '开始语音识别,模式:音视频(iOS/手机麦克风外放收音),ASR media');
|
|
await _startAsrService('media');
|
|
} else {
|
|
Logger.i('Translation',
|
|
'开始语音识别,模式:${currentMode.value},ASR 模式:normal');
|
|
await _startAsrService('normal');
|
|
}
|
|
await _finalizeRecognitionStart();
|
|
|
|
// Android 音视频翻译:识别就绪后才弹"录屏"授权 — 用户同意的瞬间
|
|
// 系统音频开始流入已经准备好的 ASR 通路,可以立即切到目标 App。
|
|
if (isAndroidAudioVideo || isIosAudioVideo) {
|
|
// iOS:起 socket 接收端 + 弹系统「开始广播」面板。
|
|
// ⚠️ 返回 true 只代表面板弹出来了,**不代表用户真的开始了广播**——
|
|
// 系统没有可查询的回调。所以下面的回滚分支在 iOS 上只会在
|
|
// 「接收端都没起起来」(App Group 没配好)时触发。
|
|
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');
|
|
}
|
|
}
|
|
// 音视频/通话模式:识别挂了,设备侧/录屏广播那条链路也得收掉,
|
|
// 否则 socket 接收端还开着、没人消费(正常 stopAll 走的是同一个收尾,幂等)。
|
|
if (currentMode.value == "call" || currentMode.value == "audioVideo") {
|
|
try {
|
|
await _teardownCallModeAudio();
|
|
} 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();
|
|
// 通话 / 同声 / 面对面默认开录音,每轮开始回到默认开(本轮关掉只管本轮)。
|
|
// 放在这里而不是 _finalizeRecognitionStart:设备端触发的面对面(按住说话)
|
|
// 不经过后者,但第一句一定经过这里。
|
|
// ⚠️ 通话翻译录的是通话双方的声音。默认录是 2026-09-18 的产品决定
|
|
// (翻译完要能在纪要里回听),上线前请确认录音告知/合规口径。
|
|
if (_recordsByDefault && !isRecording.value) {
|
|
isRecording.value = true;
|
|
}
|
|
|
|
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 {
|
|
// iOS 走 ReplayKit 录屏广播扩展拿系统音频(唯一合规途径,iOS 不允许
|
|
// 一个 App 读另一个 App 的音频)。真正的启动排在 startRecognition 末尾,
|
|
// 与 Android 弹录屏授权的时机一致:先把识别通路准备好,最后才请用户
|
|
// 开始广播,同意的瞬间音频就有地方去。
|
|
//
|
|
// ⚠️ 这里**不能**再调 openDecoder()。那是杰理时代的写法(音视频翻译 =
|
|
// 从耳机 A2DP 解码器拿手机正在放的音乐),本项目只有恒玄耳机、没这个能力;
|
|
// 而 openDecoder() 如今是个乐观桩,只把 _isCodecActive 置 true 就返回,
|
|
// 于是 ASR 以 external 源起来之后**等一份永远没人推的 PCM**——
|
|
// 不报错、不提示,一个字都翻不出来。这正是 iOS 多媒体翻译此前全哑的原因。
|
|
isPreparing.value = false;
|
|
}
|
|
// 两端都是 external:PCM 由系统音频捕获推上来
|
|
// (Android = MediaProjection,iOS = 录屏广播扩展)。
|
|
_audioSourceType = true;
|
|
isTtsEnabled.value = false;
|
|
Logger.info('发送ble音乐或者通话远端声音');
|
|
}
|
|
|
|
/// 配置通话模式(只做设备音频链路配置,AST 已在
|
|
/// _initializeCallModeTranslationService 中初始化)。
|
|
/// 需要一台具备 [DeviceCapability.callAudioTap] 的设备;没有就直接报错。
|
|
Future<void> _configureCallMode() async {
|
|
// ⚠️ **前台服务必须在这里拉起,不能指望 startContinuousTranslation**。
|
|
// 那个方法里确实起了前台服务,但通话翻译常常**根本不走它**:AST 已由
|
|
// initialize() 自动拉起时,startRecognition 会走「跳过重复调用」的分支
|
|
// (见 _astAutoStartPending),于是通话翻译成了唯一一个没有前台服务的模式。
|
|
// 后果是 2026-09-19 真机实录的这一串:按 Home 4.5 秒后
|
|
// `Pged-Freezer: Freeze process` 冻结进程 → 两条 WebSocket 当场
|
|
// `Software caused connection abort` → Dart 当成 AST 故障 →
|
|
// _terminateCallOnError 把整个通话翻译关掉。用户看到的是「切个后台功能就没了」。
|
|
await AudioForegroundService.start();
|
|
_callSession = await _awaitCallAudioSession();
|
|
_callModeUsingBes = _callSession != null;
|
|
_callAudioTornDown = false;
|
|
if (_callModeUsingBes) {
|
|
await _configureCallModeBes();
|
|
} else {
|
|
Get.snackbar('tip'.tr, 'bluetoothCallStateError'.tr);
|
|
throw Exception('没有具备通话双路抓音能力的设备');
|
|
}
|
|
}
|
|
|
|
/// 等一台能抓通话双路的设备上线,最多 [timeout]。
|
|
///
|
|
/// ⚠️ 原来是「当场问一次 DeviceHub,拿不到就弹错抛异常」,而**设备会话不是立刻就绪的**:
|
|
/// 真机实测 App 启动到 `设备上线: bes Echo-one` 有 ~2.7s(蓝牙连上 → 服务发现 →
|
|
/// 握手),退出通话翻译时给耳机发过 `call.stop` 之后也有一小段空窗。用户在这个窗口里
|
|
/// 点进来,看到的就是「蓝牙通话状态异常」,而**过一会儿再点就好了**——这正是
|
|
/// 2026-09-19 真机报的「退出再进提示未在通话中,等一下又好了」。
|
|
///
|
|
/// 等待上限取 6s:比实测的 2.7s 留一倍余量,又不至于让真的没连耳机的人干等太久
|
|
/// (那种情况下多等这几秒,换来的是弱网/慢启动时不再误报)。
|
|
Future<DeviceSession?> _awaitCallAudioSession({
|
|
Duration timeout = const Duration(seconds: 6),
|
|
}) async {
|
|
final immediate = DeviceHub.to.sessionWith(DeviceCapability.callAudioTap);
|
|
if (immediate != null) return immediate;
|
|
|
|
Logger.w('Translation', '通话翻译:设备会话尚未就绪,等待最多 ${timeout.inSeconds}s');
|
|
final completer = Completer<DeviceSession?>();
|
|
// sessions 是 RxList,设备上线/下线都会触发
|
|
final worker = ever<List<DeviceSession>>(DeviceHub.to.sessions, (_) {
|
|
final s = DeviceHub.to.sessionWith(DeviceCapability.callAudioTap);
|
|
if (s != null && !completer.isCompleted) completer.complete(s);
|
|
});
|
|
final timer = Timer(timeout, () {
|
|
if (!completer.isCompleted) completer.complete(null);
|
|
});
|
|
final result = await completer.future;
|
|
timer.cancel();
|
|
worker.dispose();
|
|
Logger.i('Translation',
|
|
result != null ? '通话翻译:设备会话已就绪' : '通话翻译:等待 ${timeout.inSeconds}s 仍无可用设备');
|
|
return result;
|
|
}
|
|
|
|
/// 配置通话模式(设备双路通路):进通话模式拿双路上行 + 开译文下行通道 +
|
|
/// 通知设备 ASR 已开启,再把本端/对端 PCM 接上 AST。
|
|
///
|
|
/// 恒玄这套私有协议是纯事件推送,没有「解码器已就绪」的同步信号,
|
|
/// 所以这里直接乐观下发指令,真正有没有收到音频帧由桥接里的流订阅决定。
|
|
Future<void> _configureCallModeBes() async {
|
|
isPreparing.value = true;
|
|
final session = _callSession!;
|
|
try {
|
|
Logger.info('[CALL-CFG] call.start + 开译文下行通道 + asr.started');
|
|
await session.invokeFeature(DeviceFeatures.callStart);
|
|
_callSinkA = await session.openSpeaker(route: AudioRoute.callLegA);
|
|
_callSinkB = await session.openSpeaker(route: AudioRoute.callLegB);
|
|
await session.invokeFeature(DeviceFeatures.asrStarted);
|
|
} catch (e) {
|
|
Logger.error('[CALL-CFG] 设备通话模式配置失败: $e');
|
|
}
|
|
isPreparing.value = false;
|
|
_audioSourceType = true;
|
|
isTtsEnabled.value = false;
|
|
|
|
// 根据设备产品配置决定是否由原生层本地播报对方(B)翻译音频
|
|
bool broadcastPeer = false;
|
|
try {
|
|
final deviceName = session.info.name;
|
|
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');
|
|
|
|
await _attachBesCallTranslationBridge();
|
|
Logger.info('发送设备通话双路(本端/对端)声音');
|
|
}
|
|
|
|
/// 通话翻译 ↔ 设备桥接:
|
|
/// callLocal(本端) → AST.A(己方→对端语种)
|
|
/// callPeer(对端) → AST.B(对端→己方语种)
|
|
/// AST.A TTS → callLegA(对端听)
|
|
/// AST.B TTS → callLegB(己方听)
|
|
Future<void> _attachBesCallTranslationBridge() async {
|
|
await _detachBesCallTranslationBridge(closeSinks: false);
|
|
final session = _callSession;
|
|
if (session == null) return;
|
|
|
|
// ---- 首选:原生直连桥(两个原生插件之间直接传 PCM,Dart 一帧都不碰)----
|
|
//
|
|
// 原来这条链路是 `恒玄插件 → EventChannel → Dart → MethodChannel → azure_speech`,
|
|
// 20ms 一帧、双路,每秒约 200 次跨界。长句一多 event loop 就被拖住,原生那边
|
|
// 报 AudioSendSlow 1011——2026-09-20 苹果测试「WiFi 下断断续续、长句基本不行」
|
|
// 就是它。桥开着时门限(PeerSpeechGate)也在原生做,重连期间的帧由原生缓存补推。
|
|
// 任一端不支持(老包 / 别的厂商)就退回下面的 Dart 流路径,功能不受影响。
|
|
if (await _tryEnableNativeCallBridge(session)) {
|
|
_callBridgeNative = true;
|
|
// iOS 后台保活。通话翻译的两路音频**全走耳机 GATT**,手机侧没有任何
|
|
// AVAudioSession 的实际 I/O(译文也是发给耳机、不经扬声器),于是
|
|
// UIBackgroundModes=audio 没有成立的理由,切后台几十秒后上行就被系统掐掉:
|
|
// WebSocket 还连着(所以译文照出),但服务端收不到可识别的语音,只能靠
|
|
// 默认 VAD 每 30 秒强切一句,吐出空译文或幻觉 —— 真机表现就是
|
|
// 「切后台一会儿,只剩译文、原文全没了」(2026-09-21 实测,23:02:33 起
|
|
// 连流式的「识别中」都消失,之后每 30.0/29.5/30.4/31.0 秒一条空翻译)。
|
|
// ⚠️ acquire 内部只在当前类别撑不住后台音频时才改类别,不会踩掉 .playAndRecord。
|
|
unawaited(BackgroundKeepalive.acquire('callTranslation'));
|
|
int finals = 0;
|
|
_besCallAstTtsSub = _astService.astTtsFrames.listen(
|
|
(frame) {
|
|
// 桥模式下原生只回 final 标记(PCM 已直接下发耳机),这里只记账
|
|
if (frame.isFinal) {
|
|
finals++;
|
|
Logger.info('[CALL-BRIDGE] TTS 段尾 leg=${frame.leg} (#$finals, 原生直发)');
|
|
}
|
|
},
|
|
onError: (e) => Logger.error('[CALL-BRIDGE] ast tts stream error: $e'),
|
|
);
|
|
Logger.w('Translation', '[CALL-BRIDGE] 已连通 设备双路 ↔ AST(原生直连,不经 Dart)');
|
|
return;
|
|
}
|
|
|
|
// ---- 兜底:Dart 流路径(原样保留)----
|
|
Logger.w('Translation', '[CALL-BRIDGE] 原生直连桥不可用,退回 Dart 流路径');
|
|
_callLocalSrc = await session.openMic(route: AudioRoute.callLocal);
|
|
_callPeerSrc = await session.openMic(route: AudioRoute.callPeer);
|
|
_besCallMicSub = _callLocalSrc!.pcm.listen(
|
|
(pcm) => _astService.pushExternalAudio(leg: 'A', pcm: pcm),
|
|
onError: (e) => Logger.error('[CALL-BRIDGE] local stream error: $e'),
|
|
);
|
|
// ⚠️ B 路**不能拿到帧就推**:阿里的声音复刻是 frequency=once,只在会话开头刻一次
|
|
// 音色,而那时对端往往还没开口,先进来的是机主声音的串音(2026-09-19 真机实锤:
|
|
// B 路配的是 language:en,却识别出机主刚说的中文)。刻错的结果是用户听到的对端
|
|
// 译文用的是自己的声音——两个人音色一模一样。门限保证第一段来自对端本人,
|
|
// 未开门期间的音频进环形缓冲、开门时整段补发,所以对端先开口也不会被吃掉开头。
|
|
_peerGate.reset();
|
|
_besCallSpkSub = _callPeerSrc!.pcm.listen(
|
|
(pcm) {
|
|
final wasOpen = _peerGate.isOpen;
|
|
for (final f in _peerGate.accept(pcm)) {
|
|
_astService.pushExternalAudio(leg: 'B', pcm: f);
|
|
}
|
|
if (!wasOpen && _peerGate.isOpen) {
|
|
// 真机调 rmsThreshold 的唯一依据,必须留在正式包(release 级别是 warning)
|
|
Logger.w('Translation',
|
|
'[CALL-BRIDGE] B 路门限开启:${_peerGate.openedByFallback ? "兜底强开(阈值可能偏高/全程只有串音)" : "检测到对端语音"} rms=${_peerGate.openRms}');
|
|
}
|
|
},
|
|
onError: (e) => Logger.error('[CALL-BRIDGE] peer 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] forward TTS leg=${frame.leg} pcm=${frame.pcm.length}B isFinal=${frame.isFinal} (#$forwardCnt)',
|
|
);
|
|
}
|
|
final sink = frame.leg == 'A' ? _callSinkA : _callSinkB;
|
|
sink?.write(frame.pcm);
|
|
},
|
|
onError: (e) => Logger.error('[CALL-BRIDGE] ast tts stream error: $e'),
|
|
);
|
|
Logger.info('[CALL-BRIDGE] 已连通 设备双路 ↔ AST');
|
|
}
|
|
|
|
/// 试着把两端的原生直连桥都打开;任一端失败就把已开的那端关回去,返回 false。
|
|
///
|
|
/// 顺序:先开翻译插件那端(它要先挂上收帧的槽/观察者),再开设备那端
|
|
/// (它一开就开始往外推帧)。反过来会有帧无人接收。
|
|
Future<bool> _tryEnableNativeCallBridge(DeviceSession session) async {
|
|
bool astOn = false;
|
|
try {
|
|
astOn = await _astService.setNativeCallBridge(true);
|
|
if (!astOn) return false;
|
|
final r = await session
|
|
.invokeFeature(DeviceFeatures.callPcmNativeBridge, {'enabled': true});
|
|
if (r['enabled'] == true) return true;
|
|
} on DeviceException catch (e) {
|
|
Logger.w('Translation', '[CALL-BRIDGE] 设备端不支持原生直连桥: ${e.code}');
|
|
} catch (e) {
|
|
Logger.w('Translation', '[CALL-BRIDGE] 开启原生直连桥失败: $e');
|
|
}
|
|
if (astOn) {
|
|
try {
|
|
await _astService.setNativeCallBridge(false);
|
|
} catch (_) {}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/// 收到终态结果:链路健康,重连预算与时间窗都归零。
|
|
void _markAstLinkHealthy() {
|
|
_astReconnectAttempts = 0;
|
|
_astDropWindowStart = null;
|
|
if (currentMode.value == 'call' && isRecognizing.value) {
|
|
astLinkState.value = AstLinkState.connected;
|
|
}
|
|
}
|
|
|
|
/// 把原生/底层的错误原文分成用户能看懂的两类:网络断了 vs 服务出错。
|
|
/// 别把 `[NSPOSIXErrorDomain:53]`、`Software caused connection abort` 这种原样甩给用户。
|
|
String _classifyAstFailure(String reason) {
|
|
final r = reason.toLowerCase();
|
|
const netMarkers = [
|
|
'1011', '1012', 'nsposixerrordomain', 'connection abort', 'econnaborted',
|
|
'socket', 'network', 'timed out', 'timeout', 'host lookup', 'offline',
|
|
'connection failed', 'urlsession', 'unreachable',
|
|
];
|
|
if (netMarkers.any(r.contains)) {
|
|
return 'networkConnectionFailed'.tr; // 网络连接失败,请检查网络设置后重试
|
|
}
|
|
return 'asrConnectionFailed'.tr; // 语音识别服务连接失败,请检查网络或稍后重试
|
|
}
|
|
|
|
Future<void> _detachBesCallTranslationBridge({bool closeSinks = true}) async {
|
|
if (_callBridgeNative) {
|
|
_callBridgeNative = false;
|
|
// 先关翻译插件那端(停止收 tap/通知),再关耳机插件那端(恢复 Dart 路径);
|
|
// 反过来会有一小段「耳机在推、没人收」的空窗。统计打 warn:release 能看到。
|
|
try {
|
|
final stats = await _astService.nativeCallBridgeStats();
|
|
Logger.w('Translation', '[CALL-BRIDGE] 原生直连桥统计: $stats');
|
|
} catch (_) {}
|
|
try {
|
|
await _astService.setNativeCallBridge(false);
|
|
} catch (e) {
|
|
Logger.error('[CALL-BRIDGE] 关闭翻译端原生桥失败(忽略): $e');
|
|
}
|
|
final session = _callSession;
|
|
if (session != null && session.state == DeviceConnectionState.ready) {
|
|
try {
|
|
await session.invokeFeature(
|
|
DeviceFeatures.callPcmNativeBridge, {'enabled': false});
|
|
} catch (e) {
|
|
Logger.error('[CALL-BRIDGE] 关闭设备端原生桥失败(忽略): $e');
|
|
}
|
|
}
|
|
}
|
|
_besCallMicSub?.cancel();
|
|
_besCallMicSub = null;
|
|
_besCallSpkSub?.cancel();
|
|
_besCallSpkSub = null;
|
|
_besCallAstTtsSub?.cancel();
|
|
_besCallAstTtsSub = null;
|
|
await _callLocalSrc?.close();
|
|
_callLocalSrc = null;
|
|
await _callPeerSrc?.close();
|
|
_callPeerSrc = null;
|
|
if (closeSinks) {
|
|
await _callSinkA?.close();
|
|
_callSinkA = null;
|
|
await _callSinkB?.close();
|
|
_callSinkB = 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;
|
|
|
|
// 默认录音的开关在 _initializeSession 里定,这里只按开关开文件。
|
|
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;
|
|
// 原生报上来的两种写法都要认:receive 失败是「connection failed」,
|
|
// URLSession 整个任务被系统掐断(退后台被挂起、ECONNABORTED)是「URLSession failed」。
|
|
// 只匹配前者时后者会把 `[NSPOSIXErrorDomain:53] …` 原样甩给用户。
|
|
if (errorMsg.contains('Xunfei connection failed') ||
|
|
errorMsg.contains('Xunfei URLSession failed') ||
|
|
errorMsg.contains('NSPOSIXErrorDomain') ||
|
|
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 事件属于哪条**还没闭合**的字幕(识别与翻译分四个事件到齐才闭合)。
|
|
///
|
|
/// 匹配键是 **(utteranceId, serviceId)** 两项:
|
|
/// - `utteranceId` 由原生按**句**生成(`<sessionId>#<序号>`)。它曾经等于会话 id ——
|
|
/// 一整通电话一条腿只有一个值,于是这里必然匹配到上一句那条还开着的记录:
|
|
/// 端到端有 ~2.8s 语义延迟,第 N+1 句的中间结果总是赶在第 N 句译文之前到,
|
|
/// 原文被后一句覆盖、译文却是前一句的,字幕越说越乱(2026-09-19 修);
|
|
/// - `serviceId` 是 A/B 两条腿(己方 / 对方)。两腿 id 本就不同,带上它只是
|
|
/// 多一道保险:万一哪天 id 撞了,宁可多起一条,也不能把两个人的话并成一句。
|
|
TranslationItem? _findOpenAstItem(ASTEvent event) {
|
|
if (event.utteranceId.isEmpty) return null;
|
|
for (int i = translationHistory.length - 1; i >= 0; i--) {
|
|
final item = translationHistory[i];
|
|
if (item.isIntermediate &&
|
|
item.utteranceId == event.utteranceId &&
|
|
item.serviceId == event.serviceId) {
|
|
return item;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/// 处理 AST(语音识别+翻译一体)事件
|
|
/// 按 serviceId 区分双路(A=己方, B=对方),用 utteranceId 匹配同一句话的事件
|
|
void _handleAstEvent(ASTEvent event) {
|
|
// 有事件进来就说明链路是活的(错误/取消除外,它们在下面各自置回 false)
|
|
if (event.type != ASTEventType.error && event.type != ASTEventType.canceled) {
|
|
_astHealthy = true;
|
|
if (currentMode.value == 'call' && isRecognizing.value &&
|
|
astLinkState.value != AstLinkState.connected) {
|
|
astLinkState.value = AstLinkState.connected;
|
|
}
|
|
}
|
|
// 仅对终态事件打 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, serviceId) 查找已有项,见 _findOpenAstItem
|
|
TranslationItem? target = _findOpenAstItem(event);
|
|
|
|
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;
|
|
// 有终态结果回来 = 链路是通的,把重连预算还回去(否则一通长电话里
|
|
// 攒够几次零星抖动就再也不重连了)
|
|
_markAstLinkHealthy();
|
|
Logger.i('Translation', 'AST 识别完成 [${event.serviceId}]: ${event.text}');
|
|
currentSessionId ??= DateTime.now().millisecondsSinceEpoch.toString();
|
|
|
|
TranslationItem? target = _findOpenAstItem(event);
|
|
|
|
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 = _findOpenAstItem(event);
|
|
|
|
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 = _findOpenAstItem(event);
|
|
|
|
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;
|
|
}
|
|
// 非重新初始化期间的错误:先尝试重连,连不上才真的关掉
|
|
unawaited(_handleAstDrop('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;
|
|
}
|
|
unawaited(_handleAstDrop('AST 取消: ${event.error}'));
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
/// 停止语音识别
|
|
Future<void> stopRecognition() async {
|
|
if (!isRecognizing.value) return;
|
|
// 正常结束:链路灯归位(错误终止那条路在 _terminateCallOnError 里已置成 disconnected)
|
|
if (currentMode.value == 'call' &&
|
|
astLinkState.value != AstLinkState.disconnected) {
|
|
astLinkState.value = AstLinkState.idle;
|
|
}
|
|
|
|
// ⚠️ 先翻状态再清理,别放到最后。
|
|
// 底下这串(停录音、关 ASR 的 WebSocket、归档)随时可能要好几秒,而
|
|
// 「开始/结束」按钮是 Obx 盯着 isRecognizing 的——放在最末尾翻,用户点完
|
|
// 「结束」会盯着一个毫无反应的按钮,以为没点上又点一次。
|
|
// 提前翻不影响防重入:开头那行 `if (!isRecognizing.value) return` 正是靠
|
|
// 它挡住重复调用,提前翻只会更严。
|
|
isRecognizing.value = false;
|
|
|
|
try {
|
|
await stopRecording();
|
|
await _asrService.stopContinuousRecognition();
|
|
if (currentMode.value == "call") {
|
|
_astReconnectTimer?.cancel();
|
|
_astReconnectTimer = null;
|
|
_astReconnecting = false;
|
|
_markAstLinkHealthy();
|
|
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;
|
|
|
|
// ⚠️ 同声翻译 / 多媒体翻译是**单向**的:收到的音频一律按「源语言 → 目标语言」翻,
|
|
// 不再拿 ASR 报上来的语种去反推方向。
|
|
//
|
|
// 原来走下面那条 `shouldSwap = detectedLanguageCode == sourceLanguageCode` 的分支,
|
|
// 而这两个模式给 ASR 的本来就只有一种语言(见 _asrLanguagesForCurrentMode),
|
|
// 原生固定语种时上报的 detectedLanguage 形式并不保证与 sourceLanguageCode 逐字相等
|
|
// (空串 / 大小写 / 带不带地区都出现过)——一旦不相等就判成 shouldSwap=false,
|
|
// 于是把源语言的句子当成目标语言送去翻译,方向整个反过来:
|
|
// 表现是「识别出字了,译文却不出来或者是原文」。单向之后这个竞态不存在。
|
|
if (_isOneWayMode) {
|
|
detectedLanguageCode = sourceLanguageCode.value;
|
|
final d = resolveTranslationDirection(
|
|
mode: currentMode.value,
|
|
sourceCode: sourceLanguageCode.value,
|
|
targetCode: targetLanguageCode.value,
|
|
);
|
|
detectedSourceLanguageCode = d.source;
|
|
detectedTargetLanguageCode = d.target;
|
|
} else 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 get _isOneWayMode => isOneWayTranslationMode(currentMode.value);
|
|
|
|
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 {
|
|
// 单向模式播报的永远是目标语言,不做方向推断(见 _isOneWayMode)
|
|
final bool shouldSwap =
|
|
!_isOneWayMode && 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();
|
|
} else if (isCreateRecord.value) {
|
|
// 面对面翻译每句松手就把识别停了(isRecognizing=false),录音只是暂停、
|
|
// 文件还开着——原来 stopAll 一看识别没在跑就什么都不做,文件永远收不了尾,
|
|
// 自然也从来没进过纪要。
|
|
await stopRecording();
|
|
}
|
|
|
|
_stopAsrActiveTracking();
|
|
_translationDebounceTimer?.cancel();
|
|
await _ttsService.stop();
|
|
} catch (e) {
|
|
Logger.error('停止操作失败: ${e.toString()}');
|
|
}
|
|
}
|
|
|
|
/// 订阅 AST 事件流(幂等:已有订阅先取消再订)。
|
|
///
|
|
/// ⚠️ 除了初始化,**每次启动通话翻译前都要确保订阅还在**:
|
|
/// [_terminateCallOnError] 会把订阅取消置空,页面还在、用户再点一次开始,
|
|
/// AST 照常握手、识别/翻译日志(service 层打的)一行不少,但 controller
|
|
/// 一条事件都收不到——不上屏、不进历史、归档时「转写 0 段」。
|
|
/// 2026-09-18 真机:第一轮 socket 1011 终止后重开,六句话全丢。
|
|
Future<void> _subscribeAstEvents() async {
|
|
final 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}');
|
|
}
|
|
|
|
/// AST 链路掉了:**先当成掉线重连,不要当成「功能结束」**。
|
|
///
|
|
/// 2026-09-19 真机:按 Home 之后 4.5 秒,鸿蒙 `Pged-Freezer` 冻结进程,两条
|
|
/// WebSocket 同时 `Software caused connection abort (1011)`。原来这里直接
|
|
/// [_terminateCallOnError] 把整个通话翻译收掉——用户还在通话中,回到前台
|
|
/// 发现翻译没了。前台服务(见 _configureCallMode)能挡住大部分冻结,但弱网、
|
|
/// 服务端主动断开仍会发生,所以这一层重连是必须的。
|
|
///
|
|
/// 只在「用户确实还在通话翻译里」时重连;连 [_astMaxReconnects] 次仍不行,
|
|
/// 才按原来的方式终止——那时确实不是抖动了。
|
|
Future<void> _handleAstDrop(String reason) async {
|
|
_astHealthy = false;
|
|
if (currentMode.value != 'call' || !isRecognizing.value) {
|
|
await _terminateCallOnError(reason);
|
|
return;
|
|
}
|
|
// A/B 两条腿会各报一次,同一轮只处理一次
|
|
if (_astReconnecting) {
|
|
Logger.i('Translation', '[STS] 已在重连中,忽略重复上报: $reason');
|
|
return;
|
|
}
|
|
final now = DateTime.now();
|
|
_astDropWindowStart ??= now;
|
|
final sinceFirstDrop = now.difference(_astDropWindowStart!);
|
|
if (sinceFirstDrop > _astReconnectWindow) {
|
|
Logger.e('Translation',
|
|
'[STS] 掉线后 ${sinceFirstDrop.inSeconds}s 内重连 $_astReconnectAttempts 次仍未恢复,终止通话翻译: $reason');
|
|
await _terminateCallOnError(reason);
|
|
return;
|
|
}
|
|
_astReconnecting = true;
|
|
_astReconnectAttempts++;
|
|
astLinkState.value = AstLinkState.reconnecting;
|
|
// 1s / 2s / 4s / 8s / 8s …:窗口内不限次数,退避封顶
|
|
final delay = Duration(
|
|
seconds: (1 << (_astReconnectAttempts - 1).clamp(0, 3))
|
|
.clamp(1, _astReconnectMaxDelaySec));
|
|
Logger.w('Translation',
|
|
'[STS] AST 掉线($reason),第 $_astReconnectAttempts 次重连将在 ${delay.inSeconds}s 后开始');
|
|
_astReconnectTimer?.cancel();
|
|
_astReconnectTimer = Timer(delay, () async {
|
|
try {
|
|
// 这段时间里用户可能已经自己结束了,别把它又拉起来
|
|
if (currentMode.value != 'call' || !isRecognizing.value) {
|
|
Logger.i('Translation', '[STS] 重连前发现已不在通话翻译中,放弃重连');
|
|
return;
|
|
}
|
|
await _initializeCallModeTranslationService(isRecovery: true);
|
|
Logger.i('Translation', '[STS] 第 $_astReconnectAttempts 次重连已发起');
|
|
} catch (e) {
|
|
Logger.e('Translation', '[STS] 重连失败: $e');
|
|
} finally {
|
|
_astReconnecting = false;
|
|
}
|
|
});
|
|
}
|
|
|
|
/// 通话模式发生错误时终止服务并还原状态
|
|
Future<void> _terminateCallOnError(String reason) async {
|
|
Logger.error('[STS] 通话错误,终止服务: $reason');
|
|
try {
|
|
_astReconnectTimer?.cancel();
|
|
_astReconnectTimer = null;
|
|
_astReconnecting = false;
|
|
_astReconnectAttempts = 0;
|
|
_astDropWindowStart = null;
|
|
astLinkState.value = AstLinkState.disconnected;
|
|
_astEventSubscription?.cancel();
|
|
_astEventSubscription = null;
|
|
// 终止是用户可感知的:给一句**分得清是网断了还是服务挂了**的提示。
|
|
// 原来不弹任何东西,用户只看到字幕不再更新;再往前是弹 Dio 的英文原文。
|
|
if (reason.isNotEmpty && isRecognizing.value) {
|
|
Get.snackbar('error'.tr, _classifyAstFailure(reason),
|
|
snackPosition: SnackPosition.TOP,
|
|
backgroundColor: Colors.red.withOpacity(0.1),
|
|
colorText: Colors.red,
|
|
duration: const Duration(seconds: 4));
|
|
}
|
|
try {
|
|
await _astService.stopContinuousTranslation();
|
|
} catch (_) {}
|
|
_astAutoStartPending = false;
|
|
isRecognizing.value = false;
|
|
isPreparing.value = false;
|
|
// 录音跟着识别一起收:原来这条路径不管录音,AST 挂了以后识别停了、
|
|
// 原生录音还在往文件里写,右上角一直显示在录,直到用户退页面才被 onClose
|
|
// 的 stopAll 收掉——而且那时 isRecognizing 已经是 false,老的 stopAll 根本
|
|
// 不会去停录音。已录到的部分照常归档(转写有多少算多少)。
|
|
if (isCreateRecord.value) {
|
|
try {
|
|
await stopRecording();
|
|
} catch (e) {
|
|
Logger.error('[STS] 终止时停止录音失败(忽略): $e');
|
|
}
|
|
}
|
|
_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 if (currentMode.value == "audioVideo" &&
|
|
(Platform.isAndroid || Platform.isIOS)) {
|
|
// Android:关掉 MediaProjection 的前台服务。
|
|
// iOS:只关我们这一端的 socket 接收;广播本身得用户从系统面板/灵动岛
|
|
// 自己停,App 无权强停(扩展会收到 broadcastFinished 自行退出)。
|
|
await _asrService.stopScreenCapture();
|
|
}
|
|
if (currentMode.value == "call") {
|
|
// 通话翻译真的结束了(不管是用户点结束、还是挂断自动停止),
|
|
// 之前挂着的"后台继续翻译"提示就没意义了,撤掉。
|
|
unawaited(BackgroundSessionNotifier.cancel(kCallTranslationNotificationId));
|
|
}
|
|
|
|
// 前台服务跟着这一轮通话翻译一起收(见 _configureCallMode 的说明)
|
|
await AudioForegroundService.stop();
|
|
|
|
// ⚠️ 悬浮字幕窗必须跟着一起收。它原来只在「用户手动 toggle / 切到不支持的模式 /
|
|
// controller 销毁」时才关,于是点完「结束」小窗还浮在页面上:安卓那个是
|
|
// TYPE_APPLICATION_OVERLAY 系统级覆盖层,压在它身上的点击**不会穿透**
|
|
// (FLAG_NOT_TOUCH_MODAL 只放行窗外的),盖住哪儿哪儿就点不动——用户报的
|
|
// 「返回按钮像被遮挡、退不出去」就是它;而退不出去 → controller 不销毁 →
|
|
// 小窗更关不掉,闭环。iOS 的 PiP 同理(系统窗口浮在 App 之上)。
|
|
//
|
|
// 放在这里是因为本方法正好覆盖「支持小窗的那两个模式」的全部收尾路径
|
|
// (stopAll / _abortRecognition / _terminateCallOnError),且自带一次性短路。
|
|
// ⚠️ 「后台继续翻译」那条路径不经过这里(它压根不停翻译),小窗照常留着——
|
|
// 那正是要的:人切到通话界面了,字幕只能靠小窗看。
|
|
try {
|
|
if (isFloatingWindowEnabled.value) {
|
|
await _disableFloatingWindow();
|
|
}
|
|
} catch (e) {
|
|
Logger.error('停止翻译时关闭悬浮字幕窗失败(忽略): $e');
|
|
}
|
|
}
|
|
|
|
/// 设备通话翻译收尾:断开桥接 + 通知 ASR 关闭 + 关译文下行 + 退通话模式。
|
|
/// 耳机可能已经断开了,下发指令前先判断连接状态,别让收尾本身再抛异常。
|
|
Future<void> _teardownBesCallMode() async {
|
|
final session = _callSession;
|
|
_callSession = null;
|
|
final alive = session != null && session.state == DeviceConnectionState.ready;
|
|
try {
|
|
if (alive) await session.invokeFeature(DeviceFeatures.asrStopped);
|
|
} catch (_) {}
|
|
unawaited(BackgroundKeepalive.release('callTranslation'));
|
|
await _detachBesCallTranslationBridge(); // 关掉两路 sink = 停译文下行
|
|
if (!alive) return;
|
|
try {
|
|
await session.invokeFeature(DeviceFeatures.callStop);
|
|
Logger.info('[CALL-BRIDGE] 已退出设备通话模式');
|
|
} catch (e) {
|
|
Logger.error('[CALL-BRIDGE] 退出设备通话模式失败: $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;
|
|
}
|
|
|
|
/// 翻译进行中能不能换语言 —— **不能**。
|
|
///
|
|
/// 原先允许:换一次 = stopAll(顺带把这段录音归档)→ 重建 ASR → 自动恢复识别。
|
|
/// 这条链上每一步都是异步的原生往返(关 ASR 连接、收音频引擎、归档上传、再开连接),
|
|
/// 用户连着切几次就把几条链叠在一起跑,真机上表现为 App 卡死。
|
|
/// 现在翻译中一律拦住、提示先停止;切换只在停止状态下发生,链路只跑一条。
|
|
///
|
|
/// 三个入口(选源语言 / 选目标语言 / 互换)和两处 UI(通用语言栏、面对面布局)都过这一道,
|
|
/// UI 那层是为了连弹窗都不打开,controller 这层是兜底。
|
|
bool guardLanguageChange() {
|
|
if (!isRecognizing.value && !isPreparing.value) return true;
|
|
ToastThrottle.show(
|
|
'translation.language_locked',
|
|
title: 'tip'.tr,
|
|
message: 'stopTranslationBeforeLanguageChange'.tr,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
/// 切换源语言和目标语言
|
|
Future<void> swapLanguages() => _runLanguageSwitch(_swapLanguagesInner);
|
|
|
|
/// 对调的实现体。**不包 [_runLanguageSwitch]** —— 选语言撞上对侧时要在
|
|
/// 已经排队进来的那一次切换里直接调它,再包一层会等自己前面那条链,死锁。
|
|
Future<void> _swapLanguagesInner() async {
|
|
if (!guardLanguageChange()) return;
|
|
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;
|
|
|
|
RecentLanguages.touch(sourceLanguage.value, isSource: true);
|
|
RecentLanguages.touch(targetLanguage.value, isSource: false);
|
|
|
|
_saveSourceLanguageSetting(isUserSet: true);
|
|
_saveTargetLanguageSetting();
|
|
await _reinitializeAsrService(restartRecognition: wasRecognizing);
|
|
}
|
|
|
|
/// 设置源语言
|
|
/// [language] 语言名称
|
|
Future<void> setSourceLanguage(String language) => _runLanguageSwitch(() async {
|
|
if (sourceLanguage.value == language) return;
|
|
// 选中的正是目标语言 → 两边对调,而不是让源=目标。
|
|
// (语言弹窗左右两侧给的是同一张完整表,撞上是正常操作,见 LanguageSelectionDialog)
|
|
if (targetLanguage.value == language) return _swapLanguagesInner();
|
|
if (!guardLanguageChange()) 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;
|
|
// 选中的正是源语言 → 两边对调(同上)
|
|
if (sourceLanguage.value == language) return _swapLanguagesInner();
|
|
if (!guardLanguageChange()) 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:单向,方向固定,开了纯亏延迟;
|
|
/// 方向由 [_determineLanguageCodes] 的 [_isOneWayMode] 分支直接钉成
|
|
/// 「源 → 目标」,**不再看原生上报的 detectedLanguage**(它在固定语种下
|
|
/// 本应等于 supportedLanguages[0],但形式不保证逐字相等,靠它推方向会翻车)。
|
|
///
|
|
/// ⚠️ 代价:同传下用户若改说目标语种,不会再被自动识别成反向。
|
|
/// 同传本来就是单向场景,需要反向请切面对面。
|
|
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');
|
|
}
|
|
}
|
|
}
|
|
|
|
/// 通话翻译(AST)链路状态,给顶栏指示灯用。
|
|
enum AstLinkState { idle, connected, reconnecting, disconnected }
|
|
|