You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
cloudsolutions-atoms/lib/modules/copilot/utils/wav_merge.dart

96 lines
3.0 KiB
Dart

import 'dart:convert';
import 'dart:typed_data';
/// Parsed RIFF/WAVE chunks we care about from a single clip.
class _WavChunks {
final Uint8List? fmt;
final Uint8List data;
const _WavChunks({required this.fmt, required this.data});
}
_WavChunks _parseWav(Uint8List clip) {
Uint8List? fmtChunk;
final dataChunks = BytesBuilder();
if (clip.length < 12) return _WavChunks(fmt: fmtChunk, data: dataChunks.toBytes());
final data = ByteData.sublistView(clip);
int offset = 12; // skip "RIFF" size "WAVE"
while (offset + 8 <= clip.length) {
final chunkId = ascii.decode(clip.sublist(offset, offset + 4), allowInvalid: true);
final chunkSize = data.getUint32(offset + 4, Endian.little);
final chunkStart = offset + 8;
final chunkEnd = (chunkStart + chunkSize).clamp(0, clip.length);
if (chunkId == 'fmt ') {
fmtChunk ??= clip.sublist(chunkStart, chunkEnd);
} else if (chunkId == 'data') {
dataChunks.add(clip.sublist(chunkStart, chunkEnd));
}
offset = chunkStart + chunkSize + (chunkSize.isOdd ? 1 : 0);
}
return _WavChunks(fmt: fmtChunk, data: dataChunks.toBytes());
}
/// Merges multiple small WAV clips (as received one-per-sentence from the
/// Push-to-Talk hub's `AudioReceived` event) into a single playable WAV byte
/// buffer.
///
/// Assumes all clips share the same PCM format (sample rate/channels/bit
/// depth), which holds true since they all come from the same TTS voice
/// within a single response.
Uint8List mergeWavClips(List<Uint8List> clips) {
if (clips.isEmpty) return Uint8List(0);
if (clips.length == 1) return clips.first;
Uint8List? fmtChunk;
final pcmData = BytesBuilder();
for (final clip in clips) {
final parsed = _parseWav(clip);
fmtChunk ??= parsed.fmt;
pcmData.add(parsed.data);
}
fmtChunk ??= Uint8List(16);
final pcmBytes = pcmData.toBytes();
final fmtSize = fmtChunk.length;
final dataSize = pcmBytes.length;
final riffSize = 4 + (8 + fmtSize) + (8 + dataSize);
final header = BytesBuilder();
header.add(ascii.encode('RIFF'));
header.add(_uint32le(riffSize));
header.add(ascii.encode('WAVE'));
header.add(ascii.encode('fmt '));
header.add(_uint32le(fmtSize));
header.add(fmtChunk);
header.add(ascii.encode('data'));
header.add(_uint32le(dataSize));
return Uint8List.fromList([...header.toBytes(), ...pcmBytes]);
}
/// Computes a WAV clip's playback duration directly from its header
/// (dataSize / byteRate), without needing to load it into an audio engine.
Duration wavDuration(Uint8List clip) {
final parsed = _parseWav(clip);
final fmt = parsed.fmt;
if (fmt == null || fmt.length < 12 || parsed.data.isEmpty) return Duration.zero;
final byteRate = ByteData.sublistView(fmt).getUint32(8, Endian.little);
if (byteRate == 0) return Duration.zero;
final seconds = parsed.data.length / byteRate;
return Duration(milliseconds: (seconds * 1000).round());
}
Uint8List _uint32le(int value) {
final b = ByteData(4);
b.setUint32(0, value, Endian.little);
return b.buffer.asUint8List();
}