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 '../../../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<AgentView> with WidgetsBindingObserver {
if (controller.isDeviceCallMode.value) {
return const SizedBox.shrink();
}
final isConnected = Get.find<BleManager>().isConnected;
return Padding(
padding: EdgeInsets.only(bottom: 16.h),
child: Column(
@ -542,16 +544,20 @@ class _AgentViewState extends State<AgentView> 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: isConnected
? [
BoxShadow(
color: const Color(0xFF10B981)
.withValues(alpha: 0.35),
blurRadius: 16.w,
offset: Offset(0, 6.h),
),
],
]
: null,
),
child: Icon(
Icons.call,

10
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;
}

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 帧
// 配对成 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<JieliEvent>? _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<bool> 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 时回填实际值。

Loading…
Cancel
Save