From 80c6ea1be3dc612036d82544f2a0f6267af6f333 Mon Sep 17 00:00:00 2001 From: Rodger-Wang <1367893453@qq.com> Date: Fri, 4 Sep 2026 23:37:29 +0800 Subject: [PATCH] =?UTF-8?q?iOS=20=E7=9C=9F=E6=9C=BA=E6=8E=92=E6=9F=A5?= =?UTF-8?q?=EF=BC=9A=E4=BF=AE=E5=90=8C=E4=BC=A0=E6=92=AD=E6=8A=A5=E8=B7=AF?= =?UTF-8?q?=E7=94=B1=E3=80=81=E5=BD=95=E9=9F=B3=E6=97=B6=E9=95=BF=E4=B8=8E?= =?UTF-8?q?=E7=94=B5=E5=B9=B3=E5=B4=A9=E6=BA=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一轮 iPhone 真机联调里发现并修掉的一组问题,多数是既有缺陷,两处是我在 本轮前期改出来的回归(已在注释里标明,避免以后再踩)。 同传播报路由(AzureTtsHelper.swift,phone_call 分支) 这一支被改坏过三次,结论固定在注释里: ① 最早 `.defaultToSpeaker` + `overrideOutputAudioPort(.speaker)` → 连了 A2DP 也被强拽到外放;元凶是那次 override(已删)。 ② 修 ① 时把 `.defaultToSpeaker` 一并删了 → 删过头:`.playAndRecord` 的 默认输出是**听筒**,没耳机时译文几乎听不见(「翻译出来了但不播报」)。 ③ 补回 `.defaultToSpeaker` 且 mode 设成 `.videoChat`(为了 AEC) → `.videoChat` 本身偏好内置扬声器,连了耳机又被拽到外放,绕回 ①。 现在按**有没有耳机**分两支:有耳机走 `.default` + 仅 A2DP;没耳机走 `.videoChat` + `.defaultToSpeaker`(外放且开回声消除)。分支口径与采集侧 MicrophoneCapture 启用 voice processing 的条件完全一致,两边不再打架。 没有这个 AEC 时,外放的译文会被自己的麦克风收回去,ASR 当成新一句再翻一遍。 现场录音(bes_recording_service.dart) - 时长改为按**已写入的数据量**算(32 字节 = 1ms),与 WAV 头一致, 因此界面时长与文件实际播放长度永远相等。墙钟算不准:耳机收到 AA 59 后 要 1.8~6.5 秒才上行首帧,还会把等待期缓存的音频一次性补推,实测数据量 比「首帧到停止」的墙钟窗口还多 21~45%,从哪头计时都对不齐。 真机验证:音频19.15s/墙钟18.88s、音频18.72s/墙钟18.69s, 等效采样率均为 16000Hz 整、0 丢帧。 - 修 `_updateLevel` 的 `asInt16List(offsetInBytes, …)`:原生来的 Uint8List 是带 offset 的视图,偏移为奇数时必抛 RangeError。原来藏在 try 里被吞掉, 表现是波形/电平一直不动(数据本身没丢)。改成按小端手工拼 int16, 与 BesCallRecordingService._writeStereo 同一修法。 ⚠️ 本轮我一度把它挪到写入之前且未包 try,一抛异常整个 _onPcm 中断、 音频一帧都写不进去(实测 6990 次未捕获异常)——现在它排在写入成功之后 并单独包 try,波形挂了绝不能影响录音。 - 音频写入排成 Future 队列。Stream.listen 的 async 回调不会被等待, 每秒上百帧必然重叠,而 RandomAccessFile 同一时刻只允许一个异步操作。 (事后核对通话录音同样写法却没丢数据,所以这条是防御性的,不是主因。) 识别异常与状态收口(translation_controller.dart) - 新增 _abortRecognition:catch / error / canceled 三条异常路径统一回滚, **把录音一起停掉**。此前 `isRecording` 全文件只有一处 `= true`、从来没被 置回 false,识别挂了右上角还在计时;异常路径更是连 stopRecording 都不调。 标记同时收口进 stopRecording,所有停止路径都覆盖得到。 - startRecognition 点击即点亮 isPreparing(view 早已绑好按钮反馈,只是从来 没人设过它,点下去毫无视觉变化,就是「点了没反应」)。 - `await _modeInitFuture` 补 3 秒超时。它是本轮前期为消除语言竞态加的, 但没有超时——iOS 首次要激活音频会话、连着蓝牙还要等音频路由,一旦迟迟 不返回点击就永久卡住。把「可能用错语言」换成「可能完全卡住」是坏交易, 超时后走兜底 initialize,至少起得来。 - 三段耗时打点提到 warning 级:release 的日志级别就是 warning,用 info 打 在真机上一条都留不下。 其它 - Logger 在非 release 下同时 print 一份:`developer.log` 只进 VM service 的 logging stream,`flutter run` 控制台看不到、调试态下 idevicesyslog 也抓不到, 真机排查时加了一堆打点却一条都看不见。 - 连接握手不再自动跑 probeCommands:每条间隔 400ms、一轮十几秒,期间 SPP 通道被占满,用户点录音/翻译的指令只能排队。需要重扫时手动调。 本次在固件 0.0.3 上的扫描结果记进注释(0x0B/0C/0D/0F/10/19 有回包, 但取值都不像电量百分比,电量命令仍未定位到)。 - 通话状态变化打出触发它的原始字节,便于排查「刚连上就被判成通话中」。 Co-Authored-By: Claude Opus 5 --- apps/client/lib/core/utils/logger.dart | 16 ++- .../data/services/bes_bluetooth_service.dart | 44 ++++-- .../data/services/bes_recording_service.dart | 132 ++++++++++++++++-- .../controllers/translation_controller.dart | 107 ++++++++++++-- .../Sources/azure_speech/AzureTtsHelper.swift | 42 ++++-- 5 files changed, 283 insertions(+), 58 deletions(-) diff --git a/apps/client/lib/core/utils/logger.dart b/apps/client/lib/core/utils/logger.dart index 05d276c7..b001f285 100644 --- a/apps/client/lib/core/utils/logger.dart +++ b/apps/client/lib/core/utils/logger.dart @@ -341,12 +341,16 @@ class Logger { /// 保证崩溃前关键错误一定落盘。 static void _log(String level, String tag, String message, bool forceFlush) { if (_consoleEnabled) { - if (message.length <= _maxLineLength) { - developer.log('[$tag]: $message'); - } else { - developer.log( - '[$tag]: ${message.substring(0, _maxLineLength)}… (+${message.length - _maxLineLength}字符)'); - } + final line = message.length <= _maxLineLength + ? '[$tag]: $message' + : '[$tag]: ${message.substring(0, _maxLineLength)}… (+${message.length - _maxLineLength}字符)'; + developer.log(line); + // ⚠️ 同时 print 一份:`developer.log` 只进 VM service 的 logging stream, + // **`flutter run` 的控制台看不到它**,idevicesyslog 在调试态下也抓不到—— + // 结果就是真机排查时加了一堆 Logger 打点却一条都看不见(2026-09-04 踩到)。 + // _consoleEnabled = !kReleaseMode,所以 release 包不受影响。 + // ignore: avoid_print + print(line); } _enqueue(level, tag, message); if (forceFlush) { diff --git a/apps/client/lib/data/services/bes_bluetooth_service.dart b/apps/client/lib/data/services/bes_bluetooth_service.dart index 588394c8..b6ae8a6d 100644 --- a/apps/client/lib/data/services/bes_bluetooth_service.dart +++ b/apps/client/lib/data/services/bes_bluetooth_service.dart @@ -4,7 +4,6 @@ import 'dart:typed_data'; import 'package:bluetooth_manager/bluetooth_manager.dart' as bes; -import 'package:flutter/foundation.dart' show kDebugMode; import 'package:get/get.dart'; import 'package:get_storage/get_storage.dart'; @@ -233,17 +232,24 @@ class BesBluetoothService extends GetxService { // App 可能是在耳机已经通话中时才连上/重启回连的——0xD1/0xD2/0x02/0x03 // 都是推送事件,错过了就再也等不到,必须连上后主动查一次。 await queryCallState(); - // 命令探测改成手动触发(BesBluetoothService.to.probeCommands()), - // 不再每次连上都扫——扫一轮要几十秒,日志也吵 + // ⚠️ **不要在这里自动跑 probeCommands**。 // - // TODO(临时): 固件 0.0.3 上 `AA 09` 不再回 `BB 0A`(电量帧), - // 之前那张命令表是在 0.0.2 上扫出来的,升级后失效了。 - // 重扫 0x01~0x20 这一段——当初就是在这段里扫出 AA 09 的。 - // 0x21~0x7F 已确认整段无回包(2026-09-01 扫过 69 个命令),不用再扫。 - // **重新定位到电量命令后必须删掉这段**。 - if (kDebugMode) { - unawaited(probeCommands(from: 0x01, to: 0x20)); - } + // 它每条命令间隔 400ms,扫 0x01~0x20 一轮要十几秒,期间 SPP 通道被 + // 探测命令占满,用户点录音/开始翻译的指令只能排队——表现就是 + // 「第一次进去点了半天没响应,过一会儿就好了」(2026-09-04 真机复现, + // 当时正是为了查别的问题打的 debug 包,结果被这段探测干扰)。 + // + // 需要重扫时手动调 `BesBluetoothService.to.probeCommands()`。 + // 2026-09-04 在固件 0.0.3 上扫 0x01~0x20 的结果(供定位电量命令参考): + // AA 0B → BB 8B 04 00 + // AA 0C → BB 8C 1C 00 02 01 01 02 02 01 02 03 00 02 04 05 + // 02 05 00 02 06 00 02 07 00 02 08 00 + // AA 0D → BB 8D 07 00 02 01 02 + // AA 0F → BB 8F 04 00 + // AA 10 → BB 90 07 00 02 01 00 + // AA 19 → BB 99 04 00 + // 都是 [len, attr, value] 的 TLV,但取值(0/1/2/5)都不像电量百分比, + // 电量命令在 0.0.3 上仍未定位到。 } } catch (e) { Logger.e(_tag, '连接后握手失败(不致命): $e'); @@ -264,7 +270,13 @@ class BesBluetoothService extends GetxService { }); _cmdEventSubscription = _manager.onDeviceCmdEvent().listen((event) { - Logger.d(_tag, '恒玄设备命令事件: $event'); + final raw = event['data']; + final hex = raw is List + ? raw + .map((b) => (b as int).toRadixString(16).padLeft(2, '0')) + .join(' ') + : '$raw'; + Logger.d(_tag, '恒玄设备命令事件: type=${event['type']} raw=[$hex]'); final type = event['type']; if (type is String && type.isNotEmpty) lastCmdType.value = type; switch (type) { @@ -272,10 +284,16 @@ class BesBluetoothService extends GetxService { // 两类都算「通话中」——通话录音和通话翻译都以此为准 case 'startMakeCall': case 'startCall': + // ⚠️ 用 info 而不是 debug:这是排查「刚连上耳机就被判成通话中、 + // 同传点不动」的关键线索——原生侧 `case 0x02` 对任何第二字节为 0x02 + // 的帧都会上报 startMakeCall,且不做任何长度/上下文校验, + // 握手期间的推送帧有可能被误判。要看清是哪一帧干的就得有原始字节。 + Logger.i(_tag, 'isCallOngoing → true (type=$type, raw=[$hex])'); isCallOngoing.value = true; break; case 'stopMakeCall': case 'stopCall': + Logger.i(_tag, 'isCallOngoing → false (type=$type, raw=[$hex])'); isCallOngoing.value = false; break; case 'unknown': @@ -485,7 +503,7 @@ class BesBluetoothService extends GetxService { // `[187, 134, 34, ...]` 对着协议文档看要一个个换算,很容易看错。 Logger.i( _tag, - '[探测] 未识别回包 CMD=0x${data[1].toRadixString(16).padLeft(2, '0')} ' + '未识别回包(设备主动上报) CMD=0x${data[1].toRadixString(16).padLeft(2, '0')} ' '原始=${data.map((e) => e.toRadixString(16).padLeft(2, '0').toUpperCase()).join(' ')}'); } } diff --git a/apps/client/lib/data/services/bes_recording_service.dart b/apps/client/lib/data/services/bes_recording_service.dart index ff06f521..1babcf64 100644 --- a/apps/client/lib/data/services/bes_recording_service.dart +++ b/apps/client/lib/data/services/bes_recording_service.dart @@ -53,6 +53,16 @@ class BesRecordingService extends GetxService { Timer? _sampleTimer; final Stopwatch _stopwatch = Stopwatch(); + /// 写入队列:把并发到达的音频帧排成串行,见 _onPcm 的说明。 + Future _writeChain = Future.value(); + int _writtenFrames = 0; + int _droppedFrames = 0; + + /// 下发 AA 59(开主麦)的时刻,用来量「发指令 → 耳机真正上行首帧」的间隔。 + /// 这段间隔目前是被计进录音时长里的,所以时长会比实际音频长一截。 + DateTime? _micCmdSentAt; + bool _firstPcmLogged = false; + @override void onInit() { super.onInit(); @@ -125,20 +135,46 @@ class BesRecordingService extends GetxService { _audioSub?.cancel(); _audioSub = bes.audioStream.listen(_onPcm); + _firstPcmLogged = false; + _micCmdSentAt = null; + _writeChain = Future.value(); + _writtenFrames = 0; + _droppedFrames = 0; if (sendCommand) { // AA 59:通知耳机开主麦,之后 mSBC 帧才会上行 + final t0 = DateTime.now(); await bes.startMicRecording(); + _micCmdSentAt = DateTime.now(); + // 这一行量的是「我们把指令写进 SPP 花了多久」——如果它就很大, + // 说明卡在 app / 蓝牙写入侧;如果它很小而下面「首帧」很大, + // 那就是耳机响应慢。两者的优化方向完全不同。 + Logger.i(_tag, + '已下发 AA 59(开主麦),写入耗时 ${_micCmdSentAt!.difference(t0).inMilliseconds}ms'); } isRecording.value = true; elapsedMs.value = 0; level.value = 0; waveform.clear(); - _stopwatch - ..reset() - ..start(); + // ⚠️ **计时不在这里启动**,而是等首帧音频真正到达(见 _onPcm)。 + // 实测耳机从收到 AA 59 到开始上行音频要 1.8~6.2 秒(首次尤其慢), + // 而 app 侧下发指令本身是 0ms。以前从下发就开始计时,这段空白全被 + // 算进录音时长——9 秒的录音里只有 2.8 秒是真音频, + // 等效采样率被稀释到 7160Hz(应为 16000)。 + // 现在时长与实际音频严格对齐;首帧到达前 UI 停在 00:00, + // 正好如实反映「还在等设备开麦」。 + _stopwatch.reset(); + // ⚠️ 时长按**已写入的数据量**算,不用墙钟。 + // + // 16kHz / 16bit / 单声道 → 32 字节 = 1 毫秒音频,这也正是播放器读 WAV + // 头算出来的时长,所以显示值与文件实际播放长度**永远一致**。 + // + // 为什么墙钟不行:耳机收到 AA 59 后要 1.8~6.2 秒才开始上行,而且会把 + // 这段等待期缓存的音频**一次性补推**上来——实测数据量比「首帧到停止」 + // 的墙钟窗口还多 45%(128880B=4.03s 音频 vs 2.77s 窗口)。 + // 无论从下发计时还是从首帧计时,都对不齐。 _tick = Timer.periodic(const Duration(milliseconds: 200), (_) { - elapsedMs.value = _stopwatch.elapsedMilliseconds; + elapsedMs.value = _dataBytes ~/ 32; }); _sampleTimer = Timer.periodic( const Duration(milliseconds: sampleIntervalMs), @@ -169,9 +205,27 @@ class BesRecordingService extends GetxService { _sampleTimer?.cancel(); _stopwatch.stop(); + // 先停订阅再排空写入队列:不停订阅的话新帧会一直往队列里追加,等不完。 + await _audioSub?.cancel(); + _audioSub = null; + // ⚠️ 必须等队列写完再关文件:队列里还压着最后几帧,直接关会丢掉它们, + // 而且 _dataBytes 还没加完,回填的 WAV 头长度会和实际数据对不上。 + try { + await _writeChain; + } catch (_) {} + final int seconds = elapsedMs.value ~/ 1000; final String? path = _path; + // 音频时长(= 显示时长)与墙钟分开打:两者的差就是设备端「延迟开麦 + 补推 + // 缓存」的行为,丢帧数为 0 时差值全部来自设备侧,与 app 无关。 + final wallMs = _stopwatch.elapsedMilliseconds; + Logger.i( + _tag, + '录音统计: 音频${(_dataBytes / 32000).toStringAsFixed(2)}s 数据${_dataBytes}B ' + '墙钟${(wallMs / 1000).toStringAsFixed(2)}s ' + '写入${_writtenFrames}帧 丢${_droppedFrames}帧'); + if (sendCommand && BesBluetoothService.to.isConnected) { try { await BesBluetoothService.to.stopMicRecording(); // AA 5A @@ -207,20 +261,56 @@ class BesRecordingService extends GetxService { _path = null; } - void _onPcm(Uint8List pcm) async { + void _onPcm(Uint8List pcm) { final raf = _raf; if (raf == null || pcm.isEmpty) return; + if (!_firstPcmLogged) { + _firstPcmLogged = true; + // 首帧到了才开始计时,理由见 start() 里的说明。 + if (!_stopwatch.isRunning) _stopwatch.start(); + final sent = _micCmdSentAt; + // ⚠️ 计时器是在下发指令后就 start 的,而耳机真正开麦上行还要等这段。 + // 这段差值就是「录音显示时长 > 实际音频时长」的来源。 + Logger.i( + _tag, + '首帧音频到达' + '${sent != null ? ",距 AA 59 下发 ${DateTime.now().difference(sent).inMilliseconds}ms" : ""}' + ',计时从此刻开始'); + } // 隔帧取主麦,前馈麦那一帧丢掉 final take = _toMainMic; _toMainMic = !_toMainMic; if (!take) return; - try { - await raf.writeFrom(pcm); - _dataBytes += pcm.length; - _updateLevel(pcm); - } catch (e) { - Logger.e(_tag, '写入 PCM 失败: $e'); - } + // ⚠️ **必须串行写**,不能在这个回调里直接 `await raf.writeFrom(...)`。 + // + // Stream.listen 的回调即使标了 async 也不会被等待,而音频每秒上百帧, + // 多次 writeFrom 必然重叠;Dart 的 RandomAccessFile 同一时刻只允许一个 + // 异步操作,重叠调用会抛 "An async operation is currently pending", + // 原来那个 catch 把异常吞成一行日志——**那一帧就丢了**。 + // 实测后果:earbuds_* 录音的等效采样率只有 1587~5331Hz(应为 16000), + // 按 16k 播放就是又快又糊;而走原生录制的 trans_holder_* 稳定在 16000。 + // + // 用 Future 链把写入排成队列:既不丢帧,也保证写入顺序与到达顺序一致 + // (顺序乱了音频同样是糊的)。 + // ⚠️ 电平只是波形显示,**绝不能挡住写入**:上一版把它放在入队之前且没包 + // try,它一抛异常整个 _onPcm 就中断,音频一帧都写不进去。 + _writeChain = _writeChain.then((_) async { + final f = _raf; + if (f == null) return; + try { + await f.writeFrom(pcm); + _dataBytes += pcm.length; + _writtenFrames++; + try { + _updateLevel(pcm); + } catch (e) { + // 波形挂了不影响录音本身,吞掉即可 + } + } catch (e) { + _droppedFrames++; + Logger.e(_tag, '写入 PCM 失败(已丢帧 $_droppedFrames): $e'); + } + }); } void _sample() { @@ -234,13 +324,25 @@ class BesRecordingService extends GetxService { void _updateLevel(Uint8List pcm) { if (pcm.lengthInBytes < 2) return; - final s = pcm.buffer.asInt16List(pcm.offsetInBytes, pcm.lengthInBytes ~/ 2); + // ⚠️ **不能用 `buffer.asInt16List(offsetInBytes, …)`**:原生过来的 Uint8List + // 是带 offset 的视图,偏移是奇数时这行直接抛 + // `RangeError: Offset must be a multiple of BYTES_PER_ELEMENT`。 + // 实测这条异常在一次会话里抛了 6990 次——通话录音那边早就踩过并改成了 + // 按字节搬(见 BesCallRecordingService._writeStereo 的说明), + // 现场录音这条链路一直没同步修。 + // 直接按小端手工拼 int16,没有对齐要求。 + final count = pcm.lengthInBytes ~/ 2; + if (count == 0) return; var sum = 0.0; - for (final v in s) { + for (var i = 0; i < count; i++) { + final lo = pcm[i * 2]; + final hi = pcm[i * 2 + 1]; + var v = (hi << 8) | lo; + if (v >= 0x8000) v -= 0x10000; // 转有符号 final n = v / 32768.0; sum += n * n; } - final rms = math.sqrt(sum / s.length); + final rms = math.sqrt(sum / count); level.value = math.sqrt(rms).clamp(0.0, 1.0); } diff --git a/apps/client/lib/modules/translation/controllers/translation_controller.dart b/apps/client/lib/modules/translation/controllers/translation_controller.dart index edabe376..5f26eddc 100644 --- a/apps/client/lib/modules/translation/controllers/translation_controller.dart +++ b/apps/client/lib/modules/translation/controllers/translation_controller.dart @@ -1206,6 +1206,11 @@ class TranslationController extends GetxController with WidgetsBindingObserver { await _asrService.stopRecord(true); } isCreateRecord.value = false; + // ⚠️ 标记必须在这里清:`isRecording` 之前全文件**只有一处 `= true`** + // (_finalizeRecognitionStart 里「同传默认开录音」),从来没有被置回 false。 + // 于是识别停了、原生录音也停了,右上角却一直显示在录, + // 用户以为翻译还活着。收口到这里,所有停止路径都覆盖得到。 + isRecording.value = false; Logger.info('录音已停止,计时器已重置'); if (_shouldArchiveRecording && path != null && seconds > 0) { @@ -1280,15 +1285,44 @@ class TranslationController extends GetxController with WidgetsBindingObserver { if (!await _checkPermissions()) return; if (isRecognizing.value) return; - // 等 onInit 里那次按当前语言对做的 ASR 初始化落地,别让它被兜底的 - // 默认 zh-CN/en-US 初始化抢先(见 onInit 中 _modeInitFuture 的说明)。 + // ⚠️ 必须在这里点亮「准备中」:view 早就绑了 isPreparing 做按钮的缩放/阴影 + // 反馈,但一直没人把它设成 true——点下去到真正开始识别之间毫无视觉变化, + // 用户看到的就是「点了没反应」。ASR 首次初始化要连 Azure,本来就有秒级耗时, + // 没有反馈时这段等待格外难受。下面的 finally 负责清,所有 return 路径都覆盖。 + isPreparing.value = true; + final startedAt = DateTime.now(); try { - await _modeInitFuture; - } catch (e) { - Logger.error('等待模式初始化失败(忽略): $e'); - } + // 等 onInit 里那次按当前语言对做的 ASR 初始化落地,别让它被兜底的 + // 默认 zh-CN/en-US 初始化抢先(见 onInit 中 _modeInitFuture 的说明)。 + // 这一步是「首次点开始要等几秒」的主要来源:进页面就已经在跑了, + // 进页面后立刻点才会真的等到它。异常单独吞掉——初始化没跑成也不该 + // 直接放弃启动,下面还有兜底路径。 + try { + // ⚠️ **必须带超时**。这里等的是 onInit 那次按当前语言对做的 ASR 初始化, + // 而它在 iOS 首次进入时要激活音频会话、连着蓝牙耳机时还要等音频路由 + // 建立,实测可能迟迟不返回。没有超时的话点击就一直卡在这一行—— + // 表现是「第一次进同传点了半天没反应,退出重进就好了」(重进时系统 + // 音频组件已经热了)。 + // + // 超时后继续往下走:ASR 会走 startContinuousRecognition 里那条兜底的 + // initialize(不带语言、落到默认 zh-CN/en-US)。语言可能不对, + // 但**至少起得来**——比永久无响应强。加这个 await 的初衷是消除竞态, + // 不该把它升级成一个卡死点。 + await _modeInitFuture?.timeout(_modeInitMaxWait); + final waited = DateTime.now().difference(startedAt).inMilliseconds; + if (waited > 300) { + Logger.w('Translation', '等待模式初始化耗时 ${waited}ms(进页面后立刻点开始会等到它)'); + } + } on TimeoutException { + // 提到 warning:release 包的日志级别是 warning,用 info 打的话 + // 真机上一条都留不下来,出问题时无据可查。 + Logger.w('Translation', + '等待模式初始化超过 ${_modeInitMaxWait.inSeconds}s,放弃等待继续启动识别' + '(ASR 可能落到默认语言,见上方说明)'); + } catch (e) { + Logger.w('Translation', '等待模式初始化失败(忽略): $e'); + } - try { await _initializeSession(); // Android 音视频翻译:先把悬浮窗起来,让用户切到其他 App 后还能看到翻译。 @@ -1369,9 +1403,35 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } } } catch (e) { + await _abortRecognition('启动语音识别失败: ${e.toString()}'); + } finally { isPreparing.value = false; - isRecognizing.value = false; - Logger.error('启动语音识别失败: ${e.toString()}'); + final totalMs = DateTime.now().difference(startedAt).inMilliseconds; + // 只在明显偏慢时用 warning 记一笔:release 的日志级别是 warning, + // 正常情况不该刷屏,慢的时候必须留下证据。 + if (totalMs > 1500 || !isRecognizing.value) { + Logger.w('Translation', + 'startRecognition 总耗时 ${totalMs}ms, 识别中=${isRecognizing.value}'); + } + } + } + + /// 识别异常终止时的统一回滚。 + /// + /// ⚠️ **必须把录音一起停掉**:同传的录音是 [_finalizeRecognitionStart] 跟着识别 + /// 一起拉起来的(「同声翻译默认开录音」),识别挂了而录音还在跑,表现就是 + /// 右上角还在计时、用户以为翻译活着,实际早停了。 + /// [stopRecording] 内部会把已录的部分正常落盘并归档,不会丢内容。 + Future _abortRecognition(String reason) async { + Logger.error('识别异常终止: $reason'); + isPreparing.value = false; + isRecognizing.value = false; + if (isRecording.value) { + try { + await stopRecording(); + } catch (e) { + Logger.error('异常终止时停止录音失败(忽略): $e'); + } } } @@ -1665,7 +1725,24 @@ class TranslationController extends GetxController with WidgetsBindingObserver { } /// 启动语音识别 + /// + /// 这一段是「首次进同传拉不起识别」的另一半嫌疑:原生 + /// startContinuousRecognition 要连 Azure(DNS + TLS + WebSocket 握手), + /// 首次通常最慢,而它**不在** startRecognition 开头那个 _modeInitFuture 的 + /// 等待范围内,超时兜底管不到它。所以单独打点,好把耗时归到正确的一段。 Future _startAsrService(String mode) async { + final t0 = DateTime.now(); + try { + await _startAsrServiceInner(mode); + } finally { + final ms = DateTime.now().difference(t0).inMilliseconds; + if (ms > 1000) { + Logger.w('Translation', 'ASR 启动(startContinuousRecognition)耗时 ${ms}ms, mode=$mode'); + } + } + } + + Future _startAsrServiceInner(String mode) async { if (mode == 'push_to_talk') { await _asrService.startContinuousRecognition( _audioSourceType, @@ -1758,13 +1835,14 @@ class TranslationController extends GetxController with WidgetsBindingObserver { displayMsg = 'asrConnectionFailed'.tr; // 语音识别服务连接失败,请检查网络或稍后重试 } Get.snackbar('error'.tr, displayMsg); - isRecognizing.value = false; - Logger.error('语音识别错误: $errorMsg'); + // ⚠️ 走 _abortRecognition 而不是只清 isRecognizing: + // 这条分支正是「接口没通、功能自己关了」的路径,录音必须跟着停, + // 否则右上角还在计时(见 _abortRecognition 的说明)。 + unawaited(_abortRecognition('语音识别错误: $errorMsg')); break; case RecognitionEventType.canceled: - isRecognizing.value = false; - Logger.error('语音识别取消: ${event.error}'); + unawaited(_abortRecognition('语音识别取消: ${event.error}')); break; case RecognitionEventType.sessionStarted: @@ -2757,6 +2835,9 @@ class TranslationController extends GetxController with WidgetsBindingObserver { /// onInit 里那次 [changeTranslationMode] 的 Future,见 onInit 的说明。 Future? _modeInitFuture; + /// 等待上面那次初始化的上限。超了就不等,宁可语言落到默认值也不要卡住启动。 + static const Duration _modeInitMaxWait = Duration(seconds: 3); + // ---- 中间结果(说话过程中的预览译文)翻译的节流与取消,见 handleIntermediateResult ---- /// 中间结果翻译的防抖间隔。中间译文只上屏、不播报,值得为它省下请求。 diff --git a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift index d767eeac..be99cf88 100644 --- a/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift +++ b/apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift @@ -1610,20 +1610,40 @@ private func applyAudioSessionForTTS() -> Bool { if self.currentRecognitionMode == "phone_call" { - // 设计:对齐 Android `BleAgent` 的"设备录音模式" —— 上行 BLE,下行 - // 让系统自由路由。Android 走 `AudioTrack` USAGE_MEDIA,系统连了 A2DP - // 自然走 A2DP,没连就走手机扬声器,不强制任何一边。 + // 同传/通话的播报路由。这一支被反复改坏过三次,把结论固定在这里: // - // 之前 iOS 在没耳机分支里塞 `.defaultToSpeaker` + 下面 1667 行还会再来 - // 一次 `overrideOutputAudioPort(.speaker)` —— 等于"无论如何都打到手机 - // 扬声器"。结果就是用户配对了 A2DP 也没用(被 override 抢回去), - // AI 对话播报永远从手机出。 + // ① 最早:`.defaultToSpeaker` + 后面一次 `overrideOutputAudioPort(.speaker)` + // → 配对了 A2DP 也被强行拽回手机扬声器。元凶是那次 override + // (强制抢路由,已删),不是 `.defaultToSpeaker`。 + // ② 修 ① 时把 `.defaultToSpeaker` 也一并删了,那是删过头: + // **`.playAndRecord` 的默认输出是听筒(receiver),不是扬声器**, + // 于是没耳机时译文从听筒出、几乎听不见 ——「翻译出来了但不播报」。 + // ③ 补回 `.defaultToSpeaker` 并把 mode 设成 `.videoChat`(为了 AEC,见下) + // → **`.videoChat` 这个 mode 本身就偏好内置扬声器**,叠加 + // `.defaultToSpeaker` 之后又变成「连了耳机也走外放」,绕回 ① 的症状。 // - // 改成 `.allowBluetoothA2DP` 两个分支都加;没配对 A2DP 的设备上 - // iOS 默认走手机扬声器(不需要我们 override),配对了就自动走耳机。 + // 所以必须**按有没有耳机分两支**,不能用一套参数通吃: + // + // - 有耳机(有线 / A2DP):走耳机。`.defaultToSpeaker` 和 `.videoChat` + // 都会把输出拽回内置扬声器,两个都不能要。耳机不会漏音回麦克风, + // 本来也不需要回声消除。 + // - 没耳机:外放(`.defaultToSpeaker`)+ 开回声消除(`.videoChat`)。 + // 同传是全双工的,扬声器放出来的译文会立刻被自己的麦克风收回去, + // ASR 当成新的一句再翻一遍;采集侧 MicrophoneCapture 启用 AEC 的 + // 条件正是 `category == .playAndRecord && mode ∈ {voiceChat, videoChat} + // && 没有蓝牙 A2DP 输出`(见 setVoiceProcessingEnabled 那段), + // 这里的分支口径与它**完全一致**。 + // + // 只动 phone_call 这一支:push_to_talk / normal 是「按住说话」, + // 播报时不采集,没有回声问题,保持原样。 desiredCategory = .playAndRecord - desiredMode = .default - desiredOptions = [.allowBluetoothA2DP] + if hasHeadphones { + desiredMode = .default + desiredOptions = [.allowBluetoothA2DP] + } else { + desiredMode = .videoChat + desiredOptions = [.defaultToSpeaker, .allowBluetoothA2DP] + } if hasHeadphones { // 用手机麦做兜底(设备录音模式下其实不用,但保留与之前一致) if let inputs = audioSession.availableInputs, let builtInMic = inputs.first(where: { $0.portType == .builtInMic }) {