diff --git a/apps/client/lib/modules/translation/controllers/translation_controller.dart b/apps/client/lib/modules/translation/controllers/translation_controller.dart index 9cb77ba6..ed30c44b 100644 --- a/apps/client/lib/modules/translation/controllers/translation_controller.dart +++ b/apps/client/lib/modules/translation/controllers/translation_controller.dart @@ -1197,9 +1197,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver { await _astService.enableRecord(filePath); } else { AudioSourceType audioSourceType; - if (currentMode.value == 'audioVideo') { + if (currentMode.value == 'audioVideo' && Platform.isAndroid) { audioSourceType = AudioSourceType.systemAudio; } else { + // ⚠️ iOS 的音视频翻译已经改成手机麦克风收音(见 _configureAudioVideoMode), + // 录音源必须跟着改。留 systemAudio 的话,原生把 index>0 一律当成 + // "external",会建好文件然后等一份没人推的 PCM —— 存下来是个空录音。 audioSourceType = AudioSourceType.microphone; } await _asrService.enableRecord(audioSourceType, filePath, false); @@ -1372,9 +1375,16 @@ class TranslationController extends GetxController with WidgetsBindingObserver { // 通话模式走恒玄链路时不检查 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) && !bleManager.isCodecActive) { return; @@ -1397,6 +1407,12 @@ class TranslationController extends GetxController with WidgetsBindingObserver { Logger.i('Translation', '通话模式:启动 AST 端到端服务'); 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'); @@ -1522,19 +1538,24 @@ class TranslationController extends GetxController with WidgetsBindingObserver { // 让用户立刻就能切到 YouTube 等 App 看翻译结果。 isPreparing.value = false; } else { - await bleManager.openDecoder(); - int attempts = 0; - while (!bleManager.isCodecActive && attempts < 10) { - await Future.delayed(const Duration(milliseconds: 150)); - attempts++; - } + // ⚠️ iOS 上**没有**「抓取其他 App 音频」这条路。 + // + // 这里原来的写法是 openDecoder() + 等 isCodecActive + 音频源置 external, + // 那是杰理时代的假设:音视频翻译 = 从耳机的 A2DP 解码器拿手机正在放的音乐。 + // 本项目只有恒玄耳机(没有这个能力),而 openDecoder() 现在是个乐观桩, + // 它只是把 _isCodecActive 置 true 就返回。于是 iOS 上的实际行为是: + // ASR 以 external 源启动,然后**等一份永远不会有人推的 PCM**—— + // 不报错、不提示,就是一个字都翻不出来。 + // + // 退化成手机麦克风:用户外放视频,我们从空气里收。这不是等价替代, + // 音质和抗噪都差一截,但至少功能是通的。要真正拿到系统音频, + // iOS 只有 ReplayKit Broadcast Upload Extension 一条路(需要新建 extension + // target + App Group,且要用户手动发起录屏广播),那是产品决策。 isPreparing.value = false; - if (!bleManager.isCodecActive) { - Get.snackbar('tip'.tr, 'operationFailedPleaseRetry'.tr); - throw Exception('lxm--BLE编解码打开失败'); - } } - _audioSourceType = true; + // Android: true = external,PCM 由 MediaProjection 推上来。 + // iOS: false = 麦克风。 + _audioSourceType = Platform.isAndroid; isTtsEnabled.value = false; Logger.info('发送ble音乐或者通话远端声音'); } diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift index c9e43525..d8ded533 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift +++ b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift @@ -189,7 +189,21 @@ public class SimpleAudioReceiver: NSObject { if audioSourceType == .microphone { // --- 麦克风输入逻辑 (包括混合模式) --- - if currentRecognitionMode == "phone_call" { + if currentRecognitionMode == "media" { + // 多媒体翻译:用户在别的 App 里放视频,我们用麦克风把外放的声音收进来。 + // + // ⚠️ `.mixWithOthers` 是这条路能成立的前提。不加的话,我们一 + // `setActive(true)` 就会打断/停掉正在播放的那个 App —— 用户点开 + // 翻译,视频就哑了,等于功能自相矛盾。 + // + // ⚠️ mode 用 `.default` 不用 `.voiceChat`:`.voiceChat` 会让 + // MicrophoneCapture 打开 setVoiceProcessingEnabled(回声消除), + // 而这里要收的**恰恰就是本机扬声器放出来的声音**,AEC 会把它当回声 + // 精确地消掉,结果就是一片安静、一个字都识别不出来。 + desiredCategory = .playAndRecord + desiredMode = .default + desiredOptions = [.mixWithOthers, .defaultToSpeaker, .allowBluetoothA2DP] + } else if currentRecognitionMode == "phone_call" { if isHeadphones { desiredCategory = .playAndRecord desiredMode = .default