|
|
@ -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 时回填实际值。 |
|
|
|