diff --git a/apps/client/lib/data/services/speech_impl/azure_ast_service.dart b/apps/client/lib/data/services/speech_impl/azure_ast_service.dart index 306c8396..7c32f16e 100644 --- a/apps/client/lib/data/services/speech_impl/azure_ast_service.dart +++ b/apps/client/lib/data/services/speech_impl/azure_ast_service.dart @@ -56,6 +56,19 @@ class AzureAstService extends GetxService implements AstService { late final String _alibabaAppURL; final List _defaultSupportedLanguages = ['zh-CN', 'en-US']; + + /// 最近一次 initialize 用的参数:需要「补一次初始化」时用它,**绝不能退回默认值**。 + /// + /// 2026-09-23 Redmi K90 真机:快速开关通话翻译 + 切语言,两条初始化并发——后一条的 + /// dispose() 把 `_isInitialized` 清成 false、前一条的原生 initialize 因此返回 false; + /// 随后 recognizeCallback() 看到"未初始化"就调了无参 initialize(),落到默认的 + /// provider=azure + zh-CN/en-US,把刚握手成功的阿里 zh→de 会话整个拆掉。 + /// 表现是「德语/意大利语一开就卡死、只有中英正常」——因为默认值恰好就是中英。 + List? _lastLanguages; + String _lastProvider = 'azure'; + + /// 正在进行中的 initialize:后来的调用先等它结束再开始,避免 dispose 撞上在途的原生初始化。 + Future? _initInFlight; @override List get supportedLanguages => _defaultSupportedLanguages; @@ -290,11 +303,34 @@ class AzureAstService extends GetxService implements AstService { } @override - Future> recognizeCallback() async { + /// 确保原生 AST 已按**当前语言对**初始化:先等在途的初始化,仍没成就按上次参数补一次。 + /// + /// ⚠️ 这里绝不能退回无参 initialize():它的默认值是 azure + zh-CN/en-US,会把当前语言对的 + /// 阿里会话整个换掉(见 _lastLanguages 的说明)。没有可复用的参数就直接报错,让调用方知道。 + Future _ensureInitialized(String who) async { if (!_isInitialized) { - Logger.info('[STS] recognizeCallback: 未初始化,先执行initialize'); - await initialize(); + final inflight = _initInFlight; + if (inflight != null) { + Logger.warning('[STS] $who: 初始化进行中,等待其完成'); + try { + await inflight; + } catch (_) {} + } } + if (!_isInitialized) { + final langs = _lastLanguages; + if (langs == null) { + throw StateError('[STS] $who: AST 尚未初始化,且没有可复用的参数'); + } + Logger.warning('[STS] $who: 未初始化,按上次参数补初始化 provider=$_lastProvider langs=$langs'); + final ok = await initialize(supportedLanguages: langs, provider: _lastProvider); + if (!ok) throw StateError('[STS] $who: 按上次参数补初始化失败'); + } + } + + @override + Future> recognizeCallback() async { + await _ensureInitialized('recognizeCallback'); try { _eventStreamController ??= StreamController.broadcast(); Logger.info('[STS] recognizeCallback: streamController=${_eventStreamController.hashCode}, hasListener=${_eventStreamController!.hasListener}'); @@ -388,6 +424,8 @@ class AzureAstService extends GetxService implements AstService { @override Future startContinuousTranslation() async { try { + // 原生初始化没落地就 start,等于对着空会话开录;快速开关/切语言时真机复现过。 + await _ensureInitialized('startContinuousTranslation'); final bool result = await _channel.invokeMethod('startContinuousTranslation'); @@ -434,6 +472,29 @@ class AzureAstService extends GetxService implements AstService { List? supportedLanguages, String provider = 'azure', }) async { + // 串行化:前一条还在原生里跑时就 dispose 会让它返回 false,然后触发一连串误判。 + final prev = _initInFlight; + if (prev != null) { + Logger.warning('[STS] initialize: 上一条初始化仍在进行,先等它结束'); + try { + await prev; + } catch (_) {} + } + final task = _initializeInner(supportedLanguages: supportedLanguages, provider: provider); + _initInFlight = task; + try { + return await task; + } finally { + if (identical(_initInFlight, task)) _initInFlight = null; + } + } + + Future _initializeInner({ + List? supportedLanguages, + String provider = 'azure', + }) async { + _lastProvider = provider; + if (supportedLanguages != null) _lastLanguages = List.from(supportedLanguages); try { final List languages = supportedLanguages ?? _defaultSupportedLanguages; diff --git a/apps/client/lib/data/utils/background_keepalive.dart b/apps/client/lib/data/utils/background_keepalive.dart index 423365ad..6b9022b7 100644 --- a/apps/client/lib/data/utils/background_keepalive.dart +++ b/apps/client/lib/data/utils/background_keepalive.dart @@ -31,6 +31,10 @@ class BackgroundKeepalive { static bool get _apply => !kIsWeb && Platform.isIOS; + /// 静音循环此刻是否真的在播(取样日志用;被电话打断会变 false) + static bool get isPlaying => _player?.playing ?? false; + static int get refs => _refs; + static Future acquire(String owner) async { if (!_apply) return; _refs++; @@ -65,7 +69,8 @@ class BackgroundKeepalive { await p.setVolume(0); unawaited(p.play()); _player = p; - Logger.i(_tag, '后台保活已开启($owner)'); + // warning 级别:release 包的文件日志只收 warning 以上,切后台排查要看得到它 + Logger.w(_tag, '后台保活已开启($owner)'); } catch (e) { // 起不来不阻断会话本身:只是锁屏后可能被挂起,前台使用不受影响 Logger.w(_tag, '后台保活开启失败($owner): $e'); diff --git a/apps/client/lib/modules/translation/controllers/translation_controller.dart b/apps/client/lib/modules/translation/controllers/translation_controller.dart index 2a8e3b5c..e0580aa5 100644 --- a/apps/client/lib/modules/translation/controllers/translation_controller.dart +++ b/apps/client/lib/modules/translation/controllers/translation_controller.dart @@ -62,7 +62,7 @@ enum AudioOutputDevice { /// - [device]:耳机左右耳按键触发,走**设备端收音**(BLE EXTERNAL 音频), /// 按住说话、松手即停,方向由按的是哪只耳朵决定。 /// -/// 设备端那条链路([FaceToFaceBleService])目前仍在等固件的 UUID 与命令, +/// 设备端那条链路(双耳独立 BLE,见 docs/face_to_face_ble_protocol.md v0.2)仍在等固件确认, /// 所以默认是 [client];等 [setActiveSpeaker] 被耳机按键事件调用时会自动切到 /// [device],业务逻辑无需再改。 enum FaceToFaceTrigger { client, device } @@ -1064,7 +1064,20 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } /// 初始化通话模式的语音翻译服务 - Future _initializeCallModeTranslationService({bool isRecovery = false}) async { + /// 通话模式初始化的串行链:快速开关 + 切语言会让两条初始化并发(2026-09-23 Redmi K90 + /// 真机复现:后一条的 dispose 撞上前一条在途的原生 initialize,前一条返回 false, + /// 接着被默认参数的补初始化换成 azure/中英,德语、意大利语一开就"卡死")。 + /// 这里让后来的等前一条整个跑完再开始;服务层 AzureAstService 还有一道同样的闸。 + Future _callInitChain = Future.value(); + + Future _initializeCallModeTranslationService({bool isRecovery = false}) { + final next = _callInitChain.then( + (_) => _initializeCallModeTranslationServiceInner(isRecovery: isRecovery)); + _callInitChain = next.catchError((_) {}); + return next; + } + + Future _initializeCallModeTranslationServiceInner({bool isRecovery = false}) async { try { Logger.info('开始初始化通话模式语音翻译服务'); @@ -1218,7 +1231,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver { // lib/modules 不允许依赖厂商插件,device_hub_boundary_test 会 fail。 final dl = await _callSession?.diagnostics() ?? const {}; if (dl.isNotEmpty) { - Logger.w('Translation', '[DOWNLINK] 取样($why): $dl'); + // 顺带记后台保活的静音循环此刻在不在播:它一停(比如被电话打断), + // iOS 就把 20ms 定时器合并成 60~120ms 一次,gapAvgMs 会跟着涨 + Logger.w('Translation', + '[DOWNLINK] 取样($why): $dl keepalive=${BackgroundKeepalive.isPlaying}/${BackgroundKeepalive.refs}'); } } catch (e) { Logger.w('Translation', '[CALL-BRIDGE] 取样($why) 失败: $e'); @@ -1428,6 +1444,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver { if (!await _checkPermissions()) return; if (isRecognizing.value) return; + // 上一次点击还在准备中(等初始化 / 开桥),再点一次会起第二条初始化与第一条并发。 + if (isPreparing.value) { + Logger.w('Translation', '识别正在准备中,忽略重复的开始请求'); + return; + } // ⚠️ 必须在这里点亮「准备中」:view 早就绑了 isPreparing 做按钮的缩放/阴影 // 反馈,但一直没人把它设成 true——点下去到真正开始识别之间毫无视觉变化, @@ -1678,7 +1699,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// PCM 由 BLE 喂给 ASR(原生侧的 EXTERNAL 音频源)。 /// - [FaceToFaceTrigger.client]:App 界面触发 → 手机麦克风(原生侧 MICROPHONE)。 /// - /// 设备端那条链路还在等固件(见 [FaceToFaceBleService] 的 TODO), + /// 设备端那条链路还在等固件(见 docs/face_to_face_ble_protocol.md §14), /// 所以当前实际生效的是手机麦克风这一路。 Future _configureFaceToFaceMode() async { _audioSourceType = _isFaceToFaceDeviceMode; @@ -1820,6 +1841,15 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// AST.A TTS → callLegA(对端听) /// AST.B TTS → callLegB(己方听) Future _attachBesCallTranslationBridge() async { + // 开桥之前必须等通话模式初始化真正落地:startRecognition 等 _modeInitFuture 最多 3 秒就 + // 往下走,而阿里两路初始化本身要 1.2 秒以上、前面还可能排着一次 dispose——桥先开、 + // startContinuousTranslation 先发,原生那边会话还没建好(2026-09-23 K90 真机日志: + // 桥开在 17:23:05.71,原生初始化 17:23:06.23 才开始)。这里给 10 秒上限,超时只记日志。 + try { + await _callInitChain.timeout(const Duration(seconds: 10)); + } on TimeoutException { + Logger.w('Translation', '[CALL-BRIDGE] 等通话模式初始化超过 10s,继续开桥'); + } catch (_) {} await _detachBesCallTranslationBridge(closeSinks: false); final session = _callSession; if (session == null) return; diff --git a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt index 6af1c40b..1e100b57 100644 --- a/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt +++ b/apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt @@ -1912,15 +1912,24 @@ class AzureSpeechPlugin : BleService.Callback, FlutterPlugin, ActivityAware, ) GlobalScope.launch(Dispatchers.Main) { + // 被更新的 initialize/dispose 取代时只放弃自己这一代,并把原因写进日志: + // 2026-09-23 K90 真机上这条 false 回到 Dart 后曾被当成「未初始化」, + // 触发按默认参数(azure/中英)的兜底初始化,把阿里德语会话整个换掉。 + // Dart 侧现已不再用默认参数兜底,这里的 false 只表示「本次作废」。 if (!isAstInitGenerationCurrent(initGeneration)) { + FileLogger.w(tag, "AST 初始化 gen=$initGeneration 在 A 路之前被新一代取代,本次作废") result.success(false); return@launch } val initA = bailianAstHelperA?.initialize(bailianConfigA, callbackA) ?: false delay(1200L) if (!isAstInitGenerationCurrent(initGeneration)) { + FileLogger.w(tag, "AST 初始化 gen=$initGeneration 在 A/B 间隔期被新一代取代,本次作废(A 路已建,交由新一代重建)") result.success(false); return@launch } val initB = bailianAstHelperB?.initialize(bailianConfigB, callbackB) ?: false + if (!(initA && initB)) { + FileLogger.w(tag, "AST 初始化 gen=$initGeneration 结果 A=$initA B=$initB") + } markAstBridgeReady() result.success(initA && initB) } diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift index 645d2d4c..bed18b14 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift +++ b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift @@ -1531,7 +1531,12 @@ private func sendAudioDataEvent(_ event: [String: Any]) { bailianCallbackB = cbB Task { + // 被更新的 initialize/dispose 取代时只放弃自己这一代,并把原因写进日志(与 Android 同口径): + // 2026-09-23 Redmi K90 上这条 false 回到 Dart 后曾被当成「未初始化」,触发按默认参数 + // (azure/中英)的兜底初始化,把阿里德语会话整个换掉。Dart 侧现已不再用默认参数兜底, + // 这里的 false 只表示「本次作废」。release 包只收得到 os_log,级别用 .default 才看得见。 if !self.isAstInitGenerationCurrent(initGeneration) { + os_log("[AST-INIT] Alibaba gen=%d 在 A 路之前被新一代取代,本次作废", log: self.ctLog, type: .default, initGeneration) DispatchQueue.main.async { result(false) } return } @@ -1539,13 +1544,19 @@ private func sendAudioDataEvent(_ event: [String: Any]) { let rA = await bailianAstHelperA.initialize(config: cfgA, cb: cbA) os_log("[AST-INIT] Alibaba: A service result=%d, initializing B service...", log: self.ctLog, type: .info, rA ? 1 : 0) if !self.isAstInitGenerationCurrent(initGeneration) { + os_log("[AST-INIT] Alibaba gen=%d 在 A/B 间隔期被新一代取代,本次作废(A 路已建,交由新一代重建)", log: self.ctLog, type: .default, initGeneration) DispatchQueue.main.async { result(false) } return } let rB = await bailianAstHelperB.initialize(config: cfgB, cb: cbB) os_log("[AST-INIT] Alibaba: B service result=%d. Both services ready.", log: self.ctLog, type: .info, rB ? 1 : 0) + if !(rA && rB) { + // 原先无论 A/B 成败都回 true,Dart 以为已就绪、桥照开,实际一路是空的。 + // 现在按真实结果回;Dart 拿到 false 会按上次参数补初始化(不会退回默认值)。 + os_log("[AST-INIT] Alibaba gen=%d 结果 A=%d B=%d", log: self.ctLog, type: .error, initGeneration, rA ? 1 : 0, rB ? 1 : 0) + } self.markAstBridgeReady() - DispatchQueue.main.async { result(true) } + DispatchQueue.main.async { result(rA && rB) } } } else { markAstBridgeReady()