Browse Source

修 iOS 多媒体翻译一个字都翻不出来:ASR 在等一份永远没人推的 PCM

根因是 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 <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
5e2b4f5431
  1. 45
      apps/client/lib/modules/translation/controllers/translation_controller.dart
  2. 16
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/tools/SimpleAudioReceiver.swift

45
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音乐或者通话远端声音');
}

16
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

Loading…
Cancel
Save