Browse Source

iOS 真机排查:修同传播报路由、录音时长与电平崩溃

一轮 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 <noreply@anthropic.com>
main
Rodger-Wang 1 month ago
parent
commit
80c6ea1be3
  1. 16
      apps/client/lib/core/utils/logger.dart
  2. 44
      apps/client/lib/data/services/bes_bluetooth_service.dart
  3. 132
      apps/client/lib/data/services/bes_recording_service.dart
  4. 107
      apps/client/lib/modules/translation/controllers/translation_controller.dart
  5. 42
      apps/client/local_plugins/azure_speech/ios/azure_speech/Sources/azure_speech/AzureTtsHelper.swift

16
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) {

44
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(' ')}');
}
}

132
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<void> _writeChain = Future<void>.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<void>.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);
}

107
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<void> _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<void> _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<void> _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<void>? _modeInitFuture;
/// 等待上面那次初始化的上限。超了就不等,宁可语言落到默认值也不要卡住启动。
static const Duration _modeInitMaxWait = Duration(seconds: 3);
// ---- 中间结果(说话过程中的预览译文)翻译的节流与取消,见 handleIntermediateResult ----
/// 中间结果翻译的防抖间隔。中间译文只上屏、不播报,值得为它省下请求。

42
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 }) {

Loading…
Cancel
Save