You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
125 lines
3.7 KiB
125 lines
3.7 KiB
import 'dart:async';
|
|
import 'dart:typed_data';
|
|
|
|
import 'package:record/record.dart';
|
|
|
|
import '../../core/utils/logger.dart';
|
|
import 'pcm_stream_player.dart';
|
|
|
|
/// 手机麦克风的 PCM 流采集,专供百炼上行使用。
|
|
///
|
|
/// `record` 的 `startStream` 回调块大小由平台决定(Android 通常几百字节到 2KB),
|
|
/// 百炼建议每包 ~100ms(16k/16bit/单声道 = 3200 字节),所以这里做一层重新分包:
|
|
/// 攒够 [_packetBytes] 再发一包,尾巴不足一包的在 [stop] 时补发。
|
|
class MicPcmStreamer {
|
|
MicPcmStreamer({this.sampleRate = 16000});
|
|
|
|
static const String _tag = 'MicPcmStreamer';
|
|
|
|
final int sampleRate;
|
|
|
|
/// 100ms 一包
|
|
int get _packetBytes => sampleRate * 2 ~/ 10;
|
|
|
|
final AudioRecorder _recorder = AudioRecorder();
|
|
StreamSubscription<Uint8List>? _sub;
|
|
final _buffer = BytesBuilder(copy: false);
|
|
|
|
final _packets = StreamController<Uint8List>.broadcast();
|
|
|
|
/// 重新分包后的上行 PCM
|
|
Stream<Uint8List> get packets => _packets.stream;
|
|
|
|
final _levelCtl = StreamController<double>.broadcast();
|
|
|
|
/// 麦克风实时音量(0~1),UI 音浪用
|
|
Stream<double> get level => _levelCtl.stream;
|
|
|
|
bool _running = false;
|
|
bool get isRunning => _running;
|
|
|
|
Future<bool> hasPermission() => _recorder.hasPermission();
|
|
|
|
Future<bool> start() async {
|
|
if (_running) return true;
|
|
try {
|
|
if (!await _recorder.hasPermission()) {
|
|
Logger.w(_tag, '没有麦克风权限');
|
|
return false;
|
|
}
|
|
final stream = await _recorder.startStream(
|
|
RecordConfig(
|
|
encoder: AudioEncoder.pcm16bits,
|
|
sampleRate: sampleRate,
|
|
numChannels: 1,
|
|
// 语音助手场景:开回声消除和降噪,否则外放播报会被自己的麦克风收回去,
|
|
// duplex 模式下会直接把服务端 VAD 打乱
|
|
echoCancel: true,
|
|
noiseSuppress: true,
|
|
),
|
|
);
|
|
_running = true;
|
|
_buffer.clear();
|
|
_sub = stream.listen(
|
|
_onChunk,
|
|
onError: (e) => Logger.e(_tag, '录音流错误: $e'),
|
|
);
|
|
Logger.i(_tag, '麦克风采集启动 ${sampleRate}Hz');
|
|
return true;
|
|
} catch (e) {
|
|
Logger.e(_tag, '麦克风采集启动失败: $e');
|
|
_running = false;
|
|
return false;
|
|
}
|
|
}
|
|
|
|
void _onChunk(Uint8List chunk) {
|
|
if (chunk.isEmpty) return;
|
|
if (!_levelCtl.isClosed) _levelCtl.add(PcmStreamPlayer.rmsLevel(chunk));
|
|
|
|
_buffer.add(chunk);
|
|
while (_buffer.length >= _packetBytes) {
|
|
// BytesBuilder 只能整体取出,取出后把余量放回去
|
|
final all = _buffer.takeBytes();
|
|
var offset = 0;
|
|
while (all.length - offset >= _packetBytes) {
|
|
_emit(Uint8List.sublistView(all, offset, offset + _packetBytes));
|
|
offset += _packetBytes;
|
|
}
|
|
if (offset < all.length) {
|
|
_buffer.add(Uint8List.sublistView(all, offset));
|
|
}
|
|
}
|
|
}
|
|
|
|
void _emit(Uint8List packet) {
|
|
if (_packets.isClosed) return;
|
|
// sublistView 是原缓冲的视图,下游可能异步持有,复制一份避免被后续写入覆盖
|
|
_packets.add(Uint8List.fromList(packet));
|
|
}
|
|
|
|
Future<void> stop() async {
|
|
if (!_running) return;
|
|
_running = false;
|
|
await _sub?.cancel();
|
|
_sub = null;
|
|
try {
|
|
await _recorder.stop();
|
|
} catch (e) {
|
|
Logger.w(_tag, '停止录音异常: $e');
|
|
}
|
|
// 尾包补发,否则最后不足 100ms 的语音会被丢掉
|
|
if (_buffer.length > 0) {
|
|
_emit(_buffer.takeBytes());
|
|
}
|
|
_buffer.clear();
|
|
if (!_levelCtl.isClosed) _levelCtl.add(0);
|
|
}
|
|
|
|
Future<void> dispose() async {
|
|
await stop();
|
|
await _packets.close();
|
|
await _levelCtl.close();
|
|
await _recorder.dispose();
|
|
}
|
|
}
|
|
|