Browse Source

client: 修通话翻译「德语/意大利语一开就卡死、只有中英正常」——并发初始化+默认参数兜底

不是语种问题,是竞态。原生阿里初始化天然 >=1.2s(A 路 init → delay(1200) →
B 路 init),期间只要再来一次 initialize/dispose,「代数」就变了,前一次返回
false;Dart 拿到 false 把 _isInitialized 清掉;接着 recognizeCallback() 看到
「未初始化」就调**无参** initialize()——默认值 provider=azure + [zh-CN,en-US],
把刚握手成功的阿里 zh→de 会话整个换成 Azure 中英三段拼装。此后原生 provider
停在 azure、Azure 识别器一直在跑,每次 dispose 都慢几秒,下一轮更容易撞进
1.2s 窗口,**退出重进也不会好**。中英「正常」只是因为默认值恰好是中英。

修在三层,缺一层都会复发:

- azure_ast_service.dart:initialize() 串行化(_initInFlight);记住最近一次的
  provider/语言表;recognizeCallback / startContinuousTranslation 走
  _ensureInitialized——先等在途初始化,仍没成就**按上次参数**补一次,
  永远不用默认值,没有可复用参数就直接报错。
- translation_controller.dart:_initializeCallModeTranslationService 串行链;
  isPreparing 期间忽略重复点开始;_attachBesCallTranslationBridge 开桥前先等
  初始化真正落地(10s 上限)——原来靠 _modeInitFuture 3s 超时硬往下走,
  桥开在原生初始化之前。
- AzureSpeechPlugin.kt/.swift:被新一代取代时打出阶段与代数、A/B 任一失败打
  结果,排查先找这两行。iOS 另修一处独有差异:原先 A/B 无论成败都回 true,
  Dart 以为就绪、桥照开而实际一路是空的,现在按真实结果回。

Redmi K90 Pro Max 验证:连开 9 场(it/de/en/ja/fr/ko×2/en,含切语言、关闭重开、
退出重进)全部阿里 3.5,初始化各 1.2s,87 句译文,0 错误,断粮 0 次。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
main
Rodger-Wang 3 weeks ago
parent
commit
5036ab4c3f
  1. 67
      apps/client/lib/data/services/speech_impl/azure_ast_service.dart
  2. 7
      apps/client/lib/data/utils/background_keepalive.dart
  3. 38
      apps/client/lib/modules/translation/controllers/translation_controller.dart
  4. 9
      apps/client/local_plugins/azure_speech/android/src/main/kotlin/com/yunqiinnovation/azure_speech/AzureSpeechPlugin.kt
  5. 13
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureSpeechPlugin.swift

67
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; late final String _alibabaAppURL;
final List<String> _defaultSupportedLanguages = ['zh-CN', 'en-US']; final List<String> _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<String>? _lastLanguages;
String _lastProvider = 'azure';
/// 正在进行中的 initialize:后来的调用先等它结束再开始,避免 dispose 撞上在途的原生初始化。
Future<bool>? _initInFlight;
@override @override
List<String> get supportedLanguages => _defaultSupportedLanguages; List<String> get supportedLanguages => _defaultSupportedLanguages;
@ -290,11 +303,34 @@ class AzureAstService extends GetxService implements AstService {
} }
@override @override
Future<Stream<ASTEvent>> recognizeCallback() async { /// 确保原生 AST 已按**当前语言对**初始化:先等在途的初始化,仍没成就按上次参数补一次。
///
/// ⚠️ 这里绝不能退回无参 initialize():它的默认值是 azure + zh-CN/en-US,会把当前语言对的
/// 阿里会话整个换掉(见 _lastLanguages 的说明)。没有可复用的参数就直接报错,让调用方知道。
Future<void> _ensureInitialized(String who) async {
if (!_isInitialized) { if (!_isInitialized) {
Logger.info('[STS] recognizeCallback: 未初始化,先执行initialize'); final inflight = _initInFlight;
await initialize(); 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<Stream<ASTEvent>> recognizeCallback() async {
await _ensureInitialized('recognizeCallback');
try { try {
_eventStreamController ??= StreamController<ASTEvent>.broadcast(); _eventStreamController ??= StreamController<ASTEvent>.broadcast();
Logger.info('[STS] recognizeCallback: streamController=${_eventStreamController.hashCode}, hasListener=${_eventStreamController!.hasListener}'); Logger.info('[STS] recognizeCallback: streamController=${_eventStreamController.hashCode}, hasListener=${_eventStreamController!.hasListener}');
@ -388,6 +424,8 @@ class AzureAstService extends GetxService implements AstService {
@override @override
Future<bool> startContinuousTranslation() async { Future<bool> startContinuousTranslation() async {
try { try {
// 原生初始化没落地就 start,等于对着空会话开录;快速开关/切语言时真机复现过。
await _ensureInitialized('startContinuousTranslation');
final bool result = final bool result =
await _channel.invokeMethod('startContinuousTranslation'); await _channel.invokeMethod('startContinuousTranslation');
@ -434,6 +472,29 @@ class AzureAstService extends GetxService implements AstService {
List<String>? supportedLanguages, List<String>? supportedLanguages,
String provider = 'azure', String provider = 'azure',
}) async { }) 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<bool> _initializeInner({
List<String>? supportedLanguages,
String provider = 'azure',
}) async {
_lastProvider = provider;
if (supportedLanguages != null) _lastLanguages = List<String>.from(supportedLanguages);
try { try {
final List<String> languages = final List<String> languages =
supportedLanguages ?? _defaultSupportedLanguages; supportedLanguages ?? _defaultSupportedLanguages;

7
apps/client/lib/data/utils/background_keepalive.dart

@ -31,6 +31,10 @@ class BackgroundKeepalive {
static bool get _apply => !kIsWeb && Platform.isIOS; static bool get _apply => !kIsWeb && Platform.isIOS;
/// 静音循环此刻是否真的在播(取样日志用;被电话打断会变 false)
static bool get isPlaying => _player?.playing ?? false;
static int get refs => _refs;
static Future<void> acquire(String owner) async { static Future<void> acquire(String owner) async {
if (!_apply) return; if (!_apply) return;
_refs++; _refs++;
@ -65,7 +69,8 @@ class BackgroundKeepalive {
await p.setVolume(0); await p.setVolume(0);
unawaited(p.play()); unawaited(p.play());
_player = p; _player = p;
Logger.i(_tag, '后台保活已开启($owner)'); // warning 级别:release 包的文件日志只收 warning 以上,切后台排查要看得到它
Logger.w(_tag, '后台保活已开启($owner)');
} catch (e) { } catch (e) {
// 起不来不阻断会话本身:只是锁屏后可能被挂起,前台使用不受影响 // 起不来不阻断会话本身:只是锁屏后可能被挂起,前台使用不受影响
Logger.w(_tag, '后台保活开启失败($owner): $e'); Logger.w(_tag, '后台保活开启失败($owner): $e');

38
apps/client/lib/modules/translation/controllers/translation_controller.dart

@ -62,7 +62,7 @@ enum AudioOutputDevice {
/// - [device]:耳机左右耳按键触发,走**设备端收音**(BLE EXTERNAL 音频), /// - [device]:耳机左右耳按键触发,走**设备端收音**(BLE EXTERNAL 音频),
/// 按住说话、松手即停,方向由按的是哪只耳朵决定。 /// 按住说话、松手即停,方向由按的是哪只耳朵决定。
/// ///
/// 设备端那条链路([FaceToFaceBleService])目前仍在等固件的 UUID 与命令, /// 设备端那条链路(双耳独立 BLE,见 docs/face_to_face_ble_protocol.md v0.2)仍在等固件确认,
/// 所以默认是 [client];等 [setActiveSpeaker] 被耳机按键事件调用时会自动切到 /// 所以默认是 [client];等 [setActiveSpeaker] 被耳机按键事件调用时会自动切到
/// [device],业务逻辑无需再改。 /// [device],业务逻辑无需再改。
enum FaceToFaceTrigger { client, device } enum FaceToFaceTrigger { client, device }
@ -1064,7 +1064,20 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
} }
/// 初始化通话模式的语音翻译服务 /// 初始化通话模式的语音翻译服务
Future<void> _initializeCallModeTranslationService({bool isRecovery = false}) async { /// 通话模式初始化的串行链:快速开关 + 切语言会让两条初始化并发(2026-09-23 Redmi K90
/// 真机复现:后一条的 dispose 撞上前一条在途的原生 initialize,前一条返回 false,
/// 接着被默认参数的补初始化换成 azure/中英,德语、意大利语一开就"卡死")。
/// 这里让后来的等前一条整个跑完再开始;服务层 AzureAstService 还有一道同样的闸。
Future<void> _callInitChain = Future<void>.value();
Future<void> _initializeCallModeTranslationService({bool isRecovery = false}) {
final next = _callInitChain.then(
(_) => _initializeCallModeTranslationServiceInner(isRecovery: isRecovery));
_callInitChain = next.catchError((_) {});
return next;
}
Future<void> _initializeCallModeTranslationServiceInner({bool isRecovery = false}) async {
try { try {
Logger.info('开始初始化通话模式语音翻译服务'); Logger.info('开始初始化通话模式语音翻译服务');
@ -1218,7 +1231,10 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
// lib/modules 不允许依赖厂商插件,device_hub_boundary_test 会 fail。 // lib/modules 不允许依赖厂商插件,device_hub_boundary_test 会 fail。
final dl = await _callSession?.diagnostics() ?? const {}; final dl = await _callSession?.diagnostics() ?? const {};
if (dl.isNotEmpty) { 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) { } catch (e) {
Logger.w('Translation', '[CALL-BRIDGE] 取样($why) 失败: $e'); Logger.w('Translation', '[CALL-BRIDGE] 取样($why) 失败: $e');
@ -1428,6 +1444,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
if (!await _checkPermissions()) return; if (!await _checkPermissions()) return;
if (isRecognizing.value) return; if (isRecognizing.value) return;
// 上一次点击还在准备中(等初始化 / 开桥),再点一次会起第二条初始化与第一条并发。
if (isPreparing.value) {
Logger.w('Translation', '识别正在准备中,忽略重复的开始请求');
return;
}
// ⚠️ 必须在这里点亮「准备中」:view 早就绑了 isPreparing 做按钮的缩放/阴影 // ⚠️ 必须在这里点亮「准备中」:view 早就绑了 isPreparing 做按钮的缩放/阴影
// 反馈,但一直没人把它设成 true——点下去到真正开始识别之间毫无视觉变化, // 反馈,但一直没人把它设成 true——点下去到真正开始识别之间毫无视觉变化,
@ -1678,7 +1699,7 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// PCM 由 BLE 喂给 ASR(原生侧的 EXTERNAL 音频源)。 /// PCM 由 BLE 喂给 ASR(原生侧的 EXTERNAL 音频源)。
/// - [FaceToFaceTrigger.client]:App 界面触发 → 手机麦克风(原生侧 MICROPHONE)。 /// - [FaceToFaceTrigger.client]:App 界面触发 → 手机麦克风(原生侧 MICROPHONE)。
/// ///
/// 设备端那条链路还在等固件(见 [FaceToFaceBleService] 的 TODO), /// 设备端那条链路还在等固件(见 docs/face_to_face_ble_protocol.md §14),
/// 所以当前实际生效的是手机麦克风这一路。 /// 所以当前实际生效的是手机麦克风这一路。
Future<void> _configureFaceToFaceMode() async { Future<void> _configureFaceToFaceMode() async {
_audioSourceType = _isFaceToFaceDeviceMode; _audioSourceType = _isFaceToFaceDeviceMode;
@ -1820,6 +1841,15 @@ class TranslationController extends GetxController with WidgetsBindingObserver {
/// AST.A TTS → callLegA(对端听) /// AST.A TTS → callLegA(对端听)
/// AST.B TTS → callLegB(己方听) /// AST.B TTS → callLegB(己方听)
Future<void> _attachBesCallTranslationBridge() async { Future<void> _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); await _detachBesCallTranslationBridge(closeSinks: false);
final session = _callSession; final session = _callSession;
if (session == null) return; if (session == null) return;

9
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) { GlobalScope.launch(Dispatchers.Main) {
// 被更新的 initialize/dispose 取代时只放弃自己这一代,并把原因写进日志:
// 2026-09-23 K90 真机上这条 false 回到 Dart 后曾被当成「未初始化」,
// 触发按默认参数(azure/中英)的兜底初始化,把阿里德语会话整个换掉。
// Dart 侧现已不再用默认参数兜底,这里的 false 只表示「本次作废」。
if (!isAstInitGenerationCurrent(initGeneration)) { if (!isAstInitGenerationCurrent(initGeneration)) {
FileLogger.w(tag, "AST 初始化 gen=$initGeneration 在 A 路之前被新一代取代,本次作废")
result.success(false); return@launch result.success(false); return@launch
} }
val initA = bailianAstHelperA?.initialize(bailianConfigA, callbackA) ?: false val initA = bailianAstHelperA?.initialize(bailianConfigA, callbackA) ?: false
delay(1200L) delay(1200L)
if (!isAstInitGenerationCurrent(initGeneration)) { if (!isAstInitGenerationCurrent(initGeneration)) {
FileLogger.w(tag, "AST 初始化 gen=$initGeneration 在 A/B 间隔期被新一代取代,本次作废(A 路已建,交由新一代重建)")
result.success(false); return@launch result.success(false); return@launch
} }
val initB = bailianAstHelperB?.initialize(bailianConfigB, callbackB) ?: false val initB = bailianAstHelperB?.initialize(bailianConfigB, callbackB) ?: false
if (!(initA && initB)) {
FileLogger.w(tag, "AST 初始化 gen=$initGeneration 结果 A=$initA B=$initB")
}
markAstBridgeReady() markAstBridgeReady()
result.success(initA && initB) result.success(initA && initB)
} }

13
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 bailianCallbackB = cbB
Task { Task {
// 被更新的 initialize/dispose 取代时只放弃自己这一代,并把原因写进日志(与 Android 同口径):
// 2026-09-23 Redmi K90 上这条 false 回到 Dart 后曾被当成「未初始化」,触发按默认参数
// (azure/中英)的兜底初始化,把阿里德语会话整个换掉。Dart 侧现已不再用默认参数兜底,
// 这里的 false 只表示「本次作废」。release 包只收得到 os_log,级别用 .default 才看得见。
if !self.isAstInitGenerationCurrent(initGeneration) { if !self.isAstInitGenerationCurrent(initGeneration) {
os_log("[AST-INIT] Alibaba gen=%d 在 A 路之前被新一代取代,本次作废", log: self.ctLog, type: .default, initGeneration)
DispatchQueue.main.async { result(false) } DispatchQueue.main.async { result(false) }
return return
} }
@ -1539,13 +1544,19 @@ private func sendAudioDataEvent(_ event: [String: Any]) {
let rA = await bailianAstHelperA.initialize(config: cfgA, cb: cbA) 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) os_log("[AST-INIT] Alibaba: A service result=%d, initializing B service...", log: self.ctLog, type: .info, rA ? 1 : 0)
if !self.isAstInitGenerationCurrent(initGeneration) { 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) } DispatchQueue.main.async { result(false) }
return return
} }
let rB = await bailianAstHelperB.initialize(config: cfgB, cb: cbB) 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) 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() self.markAstBridgeReady()
DispatchQueue.main.async { result(true) } DispatchQueue.main.async { result(rA && rB) }
} }
} else { } else {
markAstBridgeReady() markAstBridgeReady()

Loading…
Cancel
Save