From ac87185cdec1cdfa47d65c48cb952aee2737bb56 Mon Sep 17 00:00:00 2001 From: liwei1dao Date: Fri, 8 May 2026 14:34:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=8A=E4=BC=A0=E4=BB=A3=E7=A0=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/modules/agent/views/agent_view.dart | 24 ++-- .../meeting_record_controller.dart | 10 +- .../meeting/utils/jieli_call_recorder.dart | 116 ++++++------------ 3 files changed, 60 insertions(+), 90 deletions(-) diff --git a/lib/modules/agent/views/agent_view.dart b/lib/modules/agent/views/agent_view.dart index 52d7a0b4a..7fef9f273 100644 --- a/lib/modules/agent/views/agent_view.dart +++ b/lib/modules/agent/views/agent_view.dart @@ -4,6 +4,7 @@ import 'package:flutter_intro/flutter_intro.dart'; import 'package:get/get.dart'; import '../../../core/widgets/intro_overlay_builder.dart'; import '../../../data/models/channel_app_model.dart'; +import '../../../data/services/ble_manager.dart'; import '../../../data/services/version_update_service.dart'; import '../controllers/agent_controller.dart'; import '../controllers/message_action_controller.dart'; @@ -531,6 +532,7 @@ class _AgentViewState extends State with WidgetsBindingObserver { if (controller.isDeviceCallMode.value) { return const SizedBox.shrink(); } + final isConnected = Get.find().isConnected; return Padding( padding: EdgeInsets.only(bottom: 16.h), child: Column( @@ -542,16 +544,20 @@ class _AgentViewState extends State with WidgetsBindingObserver { width: 68.w, height: 68.w, decoration: BoxDecoration( - color: const Color(0xFF10B981), + color: isConnected + ? const Color(0xFF10B981) + : Colors.grey, shape: BoxShape.circle, - boxShadow: [ - BoxShadow( - color: const Color(0xFF10B981) - .withValues(alpha: 0.35), - blurRadius: 16.w, - offset: Offset(0, 6.h), - ), - ], + boxShadow: isConnected + ? [ + BoxShadow( + color: const Color(0xFF10B981) + .withValues(alpha: 0.35), + blurRadius: 16.w, + offset: Offset(0, 6.h), + ), + ] + : null, ), child: Icon( Icons.call, diff --git a/lib/modules/meeting/controllers/meeting_record_controller.dart b/lib/modules/meeting/controllers/meeting_record_controller.dart index 05d6b6541..1c825046b 100644 --- a/lib/modules/meeting/controllers/meeting_record_controller.dart +++ b/lib/modules/meeting/controllers/meeting_record_controller.dart @@ -611,9 +611,9 @@ class MeetingRecordController extends GetxController } if (audioType.value == 2) { - // 通话录音:走 device_jieli 设备端录音,直接在 Dart 侧合成立体声 WAV, - // 不复用 Azure 插件的 RecordFile 路径(其 setAudioConfig 为 no-op, - // 也没有干净的 Dart push 入口)。 + // 通话录音:走 device_jieli AI 助理通路(assistantStart → + // MODE_RECORD + STRATEGY_DEVICE_ALWAYS_RECORDING),单声道 + // PCM 直写 WAV,不依赖手机真实通话状态。 _jieliCallRecorder = JieliCallRecorder( onWavePcm: _processAudioData, ); @@ -670,8 +670,8 @@ class MeetingRecordController extends GetxController _bleManager.openDecoder(); break; case 2: - // 通话录音已切换到 device_jieli JieliDeviceRecordPort 通道, - // 由 JieliCallRecorder 在 Dart 层接管 PCM 合成与文件写入, + // 通话录音走 device_jieli AI 助理通路(assistantStart), + // 由 JieliCallRecorder 在 Dart 层接管 PCM 写入与 WAV 文件生成, // 不再依赖 BleManager.openCallRecordDecoder(当前为空壳实现)。 break; } diff --git a/lib/modules/meeting/utils/jieli_call_recorder.dart b/lib/modules/meeting/utils/jieli_call_recorder.dart index f08dadd8b..a03a476dd 100644 --- a/lib/modules/meeting/utils/jieli_call_recorder.dart +++ b/lib/modules/meeting/utils/jieli_call_recorder.dart @@ -1,15 +1,18 @@ -// 通话录音 Dart 端 PCM 合成器 +// 通话录音 Dart 端 PCM 写入器(AI 助理通路) // -// 用途:把 device_jieli 上行(in.uplink)/下行(in.downlink)两路异步 PCM 帧 -// 配对成 16k/16bit/双声道(左=本端、右=对端)的标准 WAV 文件,独立于 -// Azure 插件的 RecordFile 路径(避开其 setAudioConfig no-op 与 push 通道的 -// 参数 key 不一致 bug)。 +// 之前的实现走 device_jieli 的 deviceRecordStart(MODE_CALL_TRANSLATION +// + STRATEGY_DEVICE_ALWAYS_RECORDING),监听 uplink + downlink 双流配对 +// 写立体声 WAV。问题:那条路是为真实 SCO 电话通话准备的,没有通话期间 +// 设备不会推任何 PCM,本地测试无法采样。 // -// 注意:device_jieli 的设备录音没有原生 pause API,本类的暂停语义通过 +// 现在改走 AI 助理通路(assistantStart → MODE_RECORD + STRATEGY_ +// DEVICE_ALWAYS_RECORDING),接收 [AssistantAudioEvent] 拿单路 +// 16k/16bit/mono/20ms PCM 帧,直接写成标准单声道 WAV,与通话状态解耦。 +// +// 注意:device_jieli AI 助理通路无原生 pause API,本类的暂停语义通过 // 在 Dart 层丢弃接收到的 PCM 帧实现。 import 'dart:async'; import 'dart:io'; -import 'dart:math'; import 'dart:typed_data'; import 'package:device_jieli/device_jieli.dart'; @@ -23,17 +26,17 @@ class JieliCallRecorder { this.onWavePcm, }); - /// 采样率(Hz)。device_jieli DeviceRecord 默认 16000。 + /// 采样率(Hz)。device_jieli AI 助理通路固定 16000。 final int sampleRate; /// 位深,固定 16bit。 final int bitsPerSample; - /// 写入文件后的立体声 PCM 通知,用于驱动上层波形/振幅。 - final void Function(Uint8List stereoPcm)? onWavePcm; + /// 写入文件后的 mono PCM 通知,用于驱动上层波形/振幅。 + final void Function(Uint8List pcm)? onWavePcm; static const String _tag = 'JieliCallRecorder'; - static const int _channels = 2; + static const int _channels = 1; // AI 助理通路是 mono StreamSubscription? _eventSub; RandomAccessFile? _raf; @@ -42,14 +45,10 @@ class JieliCallRecorder { bool _paused = false; bool _running = false; - // 上下行 PCM 字节缓冲(s16le) - final BytesBuilder _uplink = BytesBuilder(copy: false); - final BytesBuilder _downlink = BytesBuilder(copy: false); - bool get isRunning => _running; String? get filePath => _filePath; - /// 启动录音:写 WAV 头占位、订阅事件、调起设备录音上行。 + /// 启动录音:写 WAV 头占位、订阅事件、调起 AI 助理通路。 Future start(String filePath) async { if (_running) { Logger.w(_tag, '重复 start 调用,已忽略'); @@ -70,21 +69,29 @@ class JieliCallRecorder { _dataBytes = 0; _filePath = filePath; _paused = false; - _uplink.clear(); - _downlink.clear(); _eventSub = Jielihome.instance.events.listen(_onEvent); try { - await Jielihome.instance.deviceRecordStart(sampleRate: sampleRate); + // 防御性先 stop,避免外部已有 AI 助理通路在跑导致 start 行为不确定 + try { + await Jielihome.instance.assistantStop(); + } catch (_) {} + final ok = await Jielihome.instance.assistantStart(); + if (!ok) { + Logger.error('$_tag assistantStart 返回 false'); + await _shutdown(saveFile: false); + return false; + } } catch (e) { - Logger.error('$_tag deviceRecordStart 失败: $e'); + Logger.error('$_tag assistantStart 失败: $e'); await _shutdown(saveFile: false); return false; } _running = true; - Logger.info('$_tag 启动通话录音 path=$filePath sr=$sampleRate'); + Logger.info( + '$_tag 启动通话录音 path=$filePath sr=$sampleRate (AI 助理通路)'); return true; } catch (e) { Logger.error('$_tag 启动失败: $e'); @@ -115,9 +122,9 @@ class JieliCallRecorder { _running = false; try { try { - await Jielihome.instance.deviceRecordStop(); + await Jielihome.instance.assistantStop(); } catch (e) { - Logger.w(_tag, 'deviceRecordStop 失败(忽略): $e'); + Logger.w(_tag, 'assistantStop 失败(忽略): $e'); } await _shutdown(saveFile: save); return true; @@ -134,8 +141,6 @@ class JieliCallRecorder { final path = _filePath; _raf = null; _filePath = null; - _uplink.clear(); - _downlink.clear(); _paused = false; if (raf != null) { @@ -170,69 +175,28 @@ class JieliCallRecorder { } void _onEvent(JieliEvent event) { - if (event is DeviceRecordAudioEvent) { + if (event is AssistantAudioEvent) { if (_paused) return; - if (event.streamId == DeviceRecordStreams.inUplink) { - _uplink.add(event.pcm); - } else if (event.streamId == DeviceRecordStreams.inDownlink) { - _downlink.add(event.pcm); - } else { - return; - } - _drain(); - } else if (event is DeviceRecordErrorEvent) { + if (event.encoding != 'pcm16') return; + if (event.pcm.isEmpty) return; + _writePcm(event.pcm); + } else if (event is AssistantErrorEvent) { Logger.error( - '$_tag 设备录音错误: code=${event.code} msg=${event.message ?? ''}'); + '$_tag AI 助理通路错误: code=${event.code} msg=${event.message ?? ''}'); } } - /// 按 sample 对齐配对 uplink/downlink,写入立体声 WAV 数据段。 - void _drain() { + void _writePcm(Uint8List pcm) { final raf = _raf; if (raf == null) return; - - final upBytes = _uplink.takeBytes(); - final downBytes = _downlink.takeBytes(); - final pairableBytes = (min(upBytes.length, downBytes.length) ~/ 2) * 2; - if (pairableBytes <= 0) { - // 没有可配对样本,原样放回缓冲 - _uplink.add(upBytes); - _downlink.add(downBytes); - return; - } - - final samples = pairableBytes ~/ 2; - final stereo = Uint8List(samples * 4); - for (int i = 0; i < samples; i++) { - final si = i * 2; - final di = i * 4; - // L = uplink(本端) - stereo[di] = upBytes[si]; - stereo[di + 1] = upBytes[si + 1]; - // R = downlink(对端) - stereo[di + 2] = downBytes[si]; - stereo[di + 3] = downBytes[si + 1]; - } - try { - raf.writeFromSync(stereo); - _dataBytes += stereo.length; + raf.writeFromSync(pcm); + _dataBytes += pcm.length; } catch (e) { Logger.error('$_tag 写 PCM 失败: $e'); - // 写失败时,把字节放回缓冲,避免丢数据无可恢复 - _uplink.add(upBytes); - _downlink.add(downBytes); return; } - - onWavePcm?.call(stereo); - - if (pairableBytes < upBytes.length) { - _uplink.add(Uint8List.sublistView(upBytes, pairableBytes)); - } - if (pairableBytes < downBytes.length) { - _downlink.add(Uint8List.sublistView(downBytes, pairableBytes)); - } + onWavePcm?.call(pcm); } /// 标准 PCM WAV 头(44 字节)。dataLength=0 用于占位,stop 时回填实际值。