import 'dart:async'; import 'dart:io'; import 'dart:math' as math; import 'dart:typed_data'; import '../../core/utils/logger.dart'; /// 串行写入的 WAV 文件(16k / 16bit)。 /// /// ⚠️ **必须串行写**:Stream.listen 的回调即使标了 async 也不会被等待, /// 音频每秒上百帧,多次 writeFrom 必然重叠;Dart 的 RandomAccessFile 同一时刻 /// 只允许一个异步操作,重叠调用会抛 "An async operation is currently pending", /// 那一帧就丢了(实测等效采样率只剩 1587~5331Hz,播放又快又糊)。 /// 用 Future 链把写入排成队列:既不丢帧,也保证顺序。 class WavWriter { WavWriter._(this._raf, this.path, this.channels, this.sampleRate); static const String _tag = 'WavWriter'; final RandomAccessFile _raf; final String path; final int channels; final int sampleRate; int dataBytes = 0; int writtenFrames = 0; int droppedFrames = 0; Future _chain = Future.value(); bool _closed = false; static Future open(String path, {int channels = 1, int sampleRate = 16000}) async { final raf = await File(path).open(mode: FileMode.write); final w = WavWriter._(raf, path, channels, sampleRate); await raf.writeFrom(w._header(0)); return w; } /// 入队写一块 PCM。 /// /// ⚠️ 这里刻意**没有**"写完回调"。它原本是给电平/波形用的,等于把 UI 挂在 /// 文件写入队列的尾巴上:队列一堵,界面跟着停;写失败时波形还会整段消失。 /// 显示所需的电平在调用方直接从这块 PCM 上算,与落盘无关。 void write(Uint8List pcm) { if (_closed) return; _chain = _chain.then((_) async { if (_closed) return; try { await _raf.writeFrom(pcm); dataBytes += pcm.length; writtenFrames++; } catch (e) { droppedFrames++; Logger.e(_tag, '写入 PCM 失败(已丢帧 $droppedFrames): $e'); } }); } /// 等队列里的帧全部落盘。 Future drain() async { try { await _chain; } catch (_) {} } /// 关闭;[save] = true 时回填 WAV 头的长度。 Future close({required bool save}) async { if (_closed) return; await drain(); _closed = true; try { if (save) { await _raf.setPosition(0); await _raf.writeFrom(_header(dataBytes)); } await _raf.close(); } catch (e) { Logger.e(_tag, '关闭录音文件失败: $e'); } } Uint8List _header(int dataLength) { const bits = 16; final byteRate = sampleRate * channels * (bits ~/ 8); final h = ByteData(44); void ascii(int off, String s) { for (var i = 0; i < s.length; i++) { h.setUint8(off + i, s.codeUnitAt(i)); } } ascii(0, 'RIFF'); h.setUint32(4, 36 + dataLength, Endian.little); ascii(8, 'WAVE'); ascii(12, 'fmt '); h.setUint32(16, 16, Endian.little); h.setUint16(20, 1, Endian.little); h.setUint16(22, channels, Endian.little); h.setUint32(24, sampleRate, Endian.little); h.setUint32(28, byteRate, Endian.little); h.setUint16(32, channels * (bits ~/ 8), Endian.little); h.setUint16(34, bits, Endian.little); ascii(36, 'data'); h.setUint32(40, dataLength, Endian.little); return h.buffer.asUint8List(); } /// PCM16 小端的 RMS 电平(0~1,开过根让波形更有跳动感)。 /// /// ⚠️ 不能用 `buffer.asInt16List(offsetInBytes, …)`:原生过来的 Uint8List 是 /// 带 offset 的视图,偏移是奇数时直接抛 RangeError。按字节手工拼。 static double rmsLevel(Uint8List pcm) { final count = pcm.lengthInBytes ~/ 2; if (count == 0) return 0; var sum = 0.0; for (var i = 0; i < count; i++) { var v = (pcm[i * 2 + 1] << 8) | pcm[i * 2]; if (v >= 0x8000) v -= 0x10000; final n = v / 32768.0; sum += n * n; } final rms = math.sqrt(sum / count); return math.sqrt(rms).clamp(0.0, 1.0); } /// 把两路单声道按字节交织成双声道(左=[left],右=[right])。 static Uint8List interleave(Uint8List left, Uint8List right) { final samples = (left.length < right.length ? left.length : right.length) ~/ 2; final out = Uint8List(samples * 4); for (var i = 0; i < samples; i++) { final si = i * 2, di = i * 4; out[di] = left[si]; out[di + 1] = left[si + 1]; out[di + 2] = right[si]; out[di + 3] = right[si + 1]; } return out; } } /// 一对已对齐的左右声道 PCM(等长、偶数字节)。 class StereoPair { StereoPair(this.left, this.right); final Uint8List left; final Uint8List right; } /// 把两路各自到达的 PCM 按字节对齐成对:两路都排队,谁都不丢, /// 每次取出两边都已有的那一段(长度取较短者、对齐到整样本)。 /// /// 两路只在**开头**可能不齐(先到的一路先攒着),之后按各自到达速率自然对齐; /// 一路长时间缺席时另一路会一直攒着(上限 [maxPending],超过就丢最旧的, /// 避免对端根本没数据时本端无限堆积)。 class StereoPairer { StereoPairer({this.maxPending = 16000 * 2 * 10}); // 10 秒 @16k/16bit final int maxPending; final BytesBuilder _left = BytesBuilder(copy: false); final BytesBuilder _right = BytesBuilder(copy: false); void pushLeft(Uint8List pcm) => _push(_left, pcm); void pushRight(Uint8List pcm) => _push(_right, pcm); void _push(BytesBuilder b, Uint8List pcm) { b.add(pcm); if (b.length > maxPending) { final all = b.takeBytes(); b.add(Uint8List.sublistView(all, all.length - maxPending)); } } /// 两边都有数据才返回;否则 null。 StereoPair? take() { final n = (_left.length < _right.length ? _left.length : _right.length) & ~1; if (n <= 0) return null; final l = _left.takeBytes(); final r = _right.takeBytes(); final pair = StereoPair( Uint8List.sublistView(l, 0, n), Uint8List.sublistView(r, 0, n)); if (l.length > n) _left.add(Uint8List.sublistView(l, n)); if (r.length > n) _right.add(Uint8List.sublistView(r, n)); return pair; } void clear() { _left.clear(); _right.clear(); } }