From 5e2b4f54315829ef2cccdac19da15e82eafedb89 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Sat, 5 Sep 2026 20:57:32 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=20iOS=20=E5=A4=9A=E5=AA=92=E4=BD=93?= =?UTF-8?q?=E7=BF=BB=E8=AF=91=E4=B8=80=E4=B8=AA=E5=AD=97=E9=83=BD=E7=BF=BB?= =?UTF-8?q?=E4=B8=8D=E5=87=BA=E6=9D=A5=EF=BC=9AASR=20=E5=9C=A8=E7=AD=89?= =?UTF-8?q?=E4=B8=80=E4=BB=BD=E6=B0=B8=E8=BF=9C=E6=B2=A1=E4=BA=BA=E6=8E=A8?= =?UTF-8?q?=E7=9A=84=20PCM?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因是 iOS 分支还停在杰理时代的假设上。_configureAudioVideoMode 的非 Android 分支做的是 openDecoder() → 等 isCodecActive → 音频源置 external,语义是 「从耳机的 A2DP 解码器拿手机正在播放的音乐」。本项目只有恒玄耳机,没有这个 能力;而 openDecoder() 如今是个乐观桩,只把 _isCodecActive 置 true 就返回。 于是 iOS 的实际行为是:ASR 以 external 源启动,然后等一份永远不会有人推的 PCM——不报错、不提示,就是一个字都翻不出来。Android 早已整条换成 MediaProjection(isAndroidAudioVideo 分支),iOS 这条没跟着改。 iOS 平台层面也没有「抓其他 App 音频」的能力,唯一合规途径是 ReplayKit Broadcast Upload Extension(需新建 extension target + App Group,且要用户 手动发起录屏广播)——那是产品决策,本次没做。先退化成手机麦克风外放收音, 让功能是通的: - _configureAudioVideoMode 的 iOS 分支不再碰杰理解码器; _audioSourceType = Platform.isAndroid(Android=external/MediaProjection, iOS=麦克风)。 - 启动守卫里排除 iOS 音视频。那个守卫要求 isCodecActive,而它只由 openEncoder/openDecoder 这两个杰理血统的入口置位——不排除的话直接 return, 识别根本不会启动。 - iOS 音视频用新的 'media' ASR 模式,原生据此把 AVAudioSession 配成 .playAndRecord + .default + [.mixWithOthers, .defaultToSpeaker, .allowBluetoothA2DP]: · .mixWithOthers 是前提——不加的话我们一 setActive 就把正在播放的那个 App 掐了,点开翻译视频就哑了; · mode 用 .default 不用 .voiceChat——后者会让 MicrophoneCapture 打开 setVoiceProcessingEnabled,而要收的恰恰是本机扬声器的声音,AEC 会把它 当回声精确消掉,结果一片安静。 iOS 侧只有两处 currentRecognitionMode 比较(phone_call / push_to_talk), 新增的 media 落到 normal 的默认分支,不影响其它模式。 - enableRecord 的音频源跟着改成 microphone。留 systemAudio 的话原生把 index>0 一律当 external,会建好文件然后等没人推的 PCM,存下来是个空录音。 Co-Authored-By: Claude Opus 5 --- .../controllers/translation_controller.dart | 45 ++++++++++++++----- .../Sources/tools/SimpleAudioReceiver.swift | 16 ++++++- 2 files changed, 48 insertions(+), 13 deletions(-) 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