Browse Source

上传代码

weicu
liwei1dao 5 months ago
parent
commit
ac87185cde
  1. 12
      lib/modules/agent/views/agent_view.dart
  2. 10
      lib/modules/meeting/controllers/meeting_record_controller.dart
  3. 116
      lib/modules/meeting/utils/jieli_call_recorder.dart

12
lib/modules/agent/views/agent_view.dart

@ -4,6 +4,7 @@ import 'package:flutter_intro/flutter_intro.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import '../../../core/widgets/intro_overlay_builder.dart'; import '../../../core/widgets/intro_overlay_builder.dart';
import '../../../data/models/channel_app_model.dart'; import '../../../data/models/channel_app_model.dart';
import '../../../data/services/ble_manager.dart';
import '../../../data/services/version_update_service.dart'; import '../../../data/services/version_update_service.dart';
import '../controllers/agent_controller.dart'; import '../controllers/agent_controller.dart';
import '../controllers/message_action_controller.dart'; import '../controllers/message_action_controller.dart';
@ -531,6 +532,7 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
if (controller.isDeviceCallMode.value) { if (controller.isDeviceCallMode.value) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
final isConnected = Get.find<BleManager>().isConnected;
return Padding( return Padding(
padding: EdgeInsets.only(bottom: 16.h), padding: EdgeInsets.only(bottom: 16.h),
child: Column( child: Column(
@ -542,16 +544,20 @@ class _AgentViewState extends State<AgentView> with WidgetsBindingObserver {
width: 68.w, width: 68.w,
height: 68.w, height: 68.w,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF10B981), color: isConnected
? const Color(0xFF10B981)
: Colors.grey,
shape: BoxShape.circle, shape: BoxShape.circle,
boxShadow: [ boxShadow: isConnected
? [
BoxShadow( BoxShadow(
color: const Color(0xFF10B981) color: const Color(0xFF10B981)
.withValues(alpha: 0.35), .withValues(alpha: 0.35),
blurRadius: 16.w, blurRadius: 16.w,
offset: Offset(0, 6.h), offset: Offset(0, 6.h),
), ),
], ]
: null,
), ),
child: Icon( child: Icon(
Icons.call, Icons.call,

10
lib/modules/meeting/controllers/meeting_record_controller.dart

@ -611,9 +611,9 @@ class MeetingRecordController extends GetxController
} }
if (audioType.value == 2) { if (audioType.value == 2) {
// 通话录音:走 device_jieli 设备端录音,直接在 Dart 侧合成立体声 WAV, // 通话录音:走 device_jieli AI 助理通路(assistantStart →
// 不复用 Azure 插件的 RecordFile 路径(其 setAudioConfig 为 no-op, // MODE_RECORD + STRATEGY_DEVICE_ALWAYS_RECORDING),单声道
// 也没有干净的 Dart push 入口)。 // PCM 直写 WAV,不依赖手机真实通话状态。
_jieliCallRecorder = JieliCallRecorder( _jieliCallRecorder = JieliCallRecorder(
onWavePcm: _processAudioData, onWavePcm: _processAudioData,
); );
@ -670,8 +670,8 @@ class MeetingRecordController extends GetxController
_bleManager.openDecoder(); _bleManager.openDecoder();
break; break;
case 2: case 2:
// 通话录音已切换到 device_jieli JieliDeviceRecordPort 通道, // 通话录音走 device_jieli AI 助理通路(assistantStart),
// 由 JieliCallRecorder 在 Dart 层接管 PCM 合成与文件写入, // 由 JieliCallRecorder 在 Dart 层接管 PCM 写入与 WAV 文件生成,
// 不再依赖 BleManager.openCallRecordDecoder(当前为空壳实现)。 // 不再依赖 BleManager.openCallRecordDecoder(当前为空壳实现)。
break; break;
} }

116
lib/modules/meeting/utils/jieli_call_recorder.dart

@ -1,15 +1,18 @@
// 通话录音 Dart 端 PCM 合成器 // 通话录音 Dart 端 PCM 写入器(AI 助理通路)
// //
// 用途:把 device_jieli 上行(in.uplink)/下行(in.downlink)两路异步 PCM 帧 // 之前的实现走 device_jieli 的 deviceRecordStart(MODE_CALL_TRANSLATION
// 配对成 16k/16bit/双声道(左=本端、右=对端)的标准 WAV 文件,独立于 // + STRATEGY_DEVICE_ALWAYS_RECORDING),监听 uplink + downlink 双流配对
// Azure 插件的 RecordFile 路径(避开其 setAudioConfig no-op 与 push 通道的 // 写立体声 WAV。问题:那条路是为真实 SCO 电话通话准备的,没有通话期间
// 参数 key 不一致 bug)。 // 设备不会推任何 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 帧实现。 // 在 Dart 层丢弃接收到的 PCM 帧实现。
import 'dart:async'; import 'dart:async';
import 'dart:io'; import 'dart:io';
import 'dart:math';
import 'dart:typed_data'; import 'dart:typed_data';
import 'package:device_jieli/device_jieli.dart'; import 'package:device_jieli/device_jieli.dart';
@ -23,17 +26,17 @@ class JieliCallRecorder {
this.onWavePcm, this.onWavePcm,
}); });
/// 采样率(Hz)。device_jieli DeviceRecord 默认 16000。 /// 采样率(Hz)。device_jieli AI 助理通路固定 16000。
final int sampleRate; final int sampleRate;
/// 位深,固定 16bit。 /// 位深,固定 16bit。
final int bitsPerSample; final int bitsPerSample;
/// 写入文件后的立体声 PCM 通知,用于驱动上层波形/振幅。 /// 写入文件后的 mono PCM 通知,用于驱动上层波形/振幅。
final void Function(Uint8List stereoPcm)? onWavePcm; final void Function(Uint8List pcm)? onWavePcm;
static const String _tag = 'JieliCallRecorder'; static const String _tag = 'JieliCallRecorder';
static const int _channels = 2; static const int _channels = 1; // AI 助理通路是 mono
StreamSubscription<JieliEvent>? _eventSub; StreamSubscription<JieliEvent>? _eventSub;
RandomAccessFile? _raf; RandomAccessFile? _raf;
@ -42,14 +45,10 @@ class JieliCallRecorder {
bool _paused = false; bool _paused = false;
bool _running = false; bool _running = false;
// 上下行 PCM 字节缓冲(s16le)
final BytesBuilder _uplink = BytesBuilder(copy: false);
final BytesBuilder _downlink = BytesBuilder(copy: false);
bool get isRunning => _running; bool get isRunning => _running;
String? get filePath => _filePath; String? get filePath => _filePath;
/// 启动录音:写 WAV 头占位、订阅事件、调起设备录音上行。 /// 启动录音:写 WAV 头占位、订阅事件、调起 AI 助理通路。
Future<bool> start(String filePath) async { Future<bool> start(String filePath) async {
if (_running) { if (_running) {
Logger.w(_tag, '重复 start 调用,已忽略'); Logger.w(_tag, '重复 start 调用,已忽略');
@ -70,21 +69,29 @@ class JieliCallRecorder {
_dataBytes = 0; _dataBytes = 0;
_filePath = filePath; _filePath = filePath;
_paused = false; _paused = false;
_uplink.clear();
_downlink.clear();
_eventSub = Jielihome.instance.events.listen(_onEvent); _eventSub = Jielihome.instance.events.listen(_onEvent);
try { 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) { } catch (e) {
Logger.error('$_tag deviceRecordStart 失败: $e'); Logger.error('$_tag assistantStart 失败: $e');
await _shutdown(saveFile: false); await _shutdown(saveFile: false);
return false; return false;
} }
_running = true; _running = true;
Logger.info('$_tag 启动通话录音 path=$filePath sr=$sampleRate'); Logger.info(
'$_tag 启动通话录音 path=$filePath sr=$sampleRate (AI 助理通路)');
return true; return true;
} catch (e) { } catch (e) {
Logger.error('$_tag 启动失败: $e'); Logger.error('$_tag 启动失败: $e');
@ -115,9 +122,9 @@ class JieliCallRecorder {
_running = false; _running = false;
try { try {
try { try {
await Jielihome.instance.deviceRecordStop(); await Jielihome.instance.assistantStop();
} catch (e) { } catch (e) {
Logger.w(_tag, 'deviceRecordStop 失败(忽略): $e'); Logger.w(_tag, 'assistantStop 失败(忽略): $e');
} }
await _shutdown(saveFile: save); await _shutdown(saveFile: save);
return true; return true;
@ -134,8 +141,6 @@ class JieliCallRecorder {
final path = _filePath; final path = _filePath;
_raf = null; _raf = null;
_filePath = null; _filePath = null;
_uplink.clear();
_downlink.clear();
_paused = false; _paused = false;
if (raf != null) { if (raf != null) {
@ -170,69 +175,28 @@ class JieliCallRecorder {
} }
void _onEvent(JieliEvent event) { void _onEvent(JieliEvent event) {
if (event is DeviceRecordAudioEvent) { if (event is AssistantAudioEvent) {
if (_paused) return; if (_paused) return;
if (event.streamId == DeviceRecordStreams.inUplink) { if (event.encoding != 'pcm16') return;
_uplink.add(event.pcm); if (event.pcm.isEmpty) return;
} else if (event.streamId == DeviceRecordStreams.inDownlink) { _writePcm(event.pcm);
_downlink.add(event.pcm); } else if (event is AssistantErrorEvent) {
} else {
return;
}
_drain();
} else if (event is DeviceRecordErrorEvent) {
Logger.error( Logger.error(
'$_tag 设备录音错误: code=${event.code} msg=${event.message ?? ''}'); '$_tag AI 助理通路错误: code=${event.code} msg=${event.message ?? ''}');
} }
} }
/// 按 sample 对齐配对 uplink/downlink,写入立体声 WAV 数据段。 void _writePcm(Uint8List pcm) {
void _drain() {
final raf = _raf; final raf = _raf;
if (raf == null) return; 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 { try {
raf.writeFromSync(stereo); raf.writeFromSync(pcm);
_dataBytes += stereo.length; _dataBytes += pcm.length;
} catch (e) { } catch (e) {
Logger.error('$_tag 写 PCM 失败: $e'); Logger.error('$_tag 写 PCM 失败: $e');
// 写失败时,把字节放回缓冲,避免丢数据无可恢复
_uplink.add(upBytes);
_downlink.add(downBytes);
return; return;
} }
onWavePcm?.call(pcm);
onWavePcm?.call(stereo);
if (pairableBytes < upBytes.length) {
_uplink.add(Uint8List.sublistView(upBytes, pairableBytes));
}
if (pairableBytes < downBytes.length) {
_downlink.add(Uint8List.sublistView(downBytes, pairableBytes));
}
} }
/// 标准 PCM WAV 头(44 字节)。dataLength=0 用于占位,stop 时回填实际值。 /// 标准 PCM WAV 头(44 字节)。dataLength=0 用于占位,stop 时回填实际值。

Loading…
Cancel
Save