import 'dart:math'; import 'dart:typed_data'; import 'package:flutter_test/flutter_test.dart'; import 'package:eaimar/data/utils/peer_speech_gate.dart'; /// 造一帧 20ms/16k/单声道 PCM16(320 样本 = 640 字节),幅度固定。 Uint8List frame(int amplitude) { const samples = 320; final b = ByteData(samples * 2); for (var i = 0; i < samples; i++) { // 用正弦而不是常数:常数信号的 RMS 等于幅值,掩盖不了算错的情况 final v = (amplitude * sin(2 * pi * 8 * i / samples)).round(); b.setInt16(i * 2, v.clamp(-32768, 32767), Endian.little); } return b.buffer.asUint8List(); } void main() { group('rmsOfPcm16', () { test('静音为 0', () => expect(rmsOfPcm16(frame(0)), 0)); test('正弦的 RMS 约为幅值的 0.707 倍', () { // 3000 * 0.707 ≈ 2121 expect(rmsOfPcm16(frame(3000)), closeTo(2121, 40)); }); test('空帧与半个样本不崩', () { expect(rmsOfPcm16(Uint8List(0)), 0); expect(rmsOfPcm16(Uint8List.fromList([0x11])), 0); }); }); group('PeerSpeechGate 开门条件', () { test('静音不开门,一帧都不放行', () { final g = PeerSpeechGate(); for (var i = 0; i < 50; i++) { expect(g.accept(frame(0)), isEmpty); } expect(g.isOpen, isFalse); }); test('断续的串音尖峰不开门——这是本门限存在的理由', () { final g = PeerSpeechGate(openFrames: 8); // 每 3 帧来一个响帧,永远凑不满 8 帧连续 for (var i = 0; i < 90; i++) { g.accept(frame(i % 3 == 0 ? 5000 : 0)); } expect(g.isOpen, isFalse, reason: '断续尖峰不该被当成有人在说话'); }); test('连续超阈值 openFrames 帧即开门', () { final g = PeerSpeechGate(openFrames: 8); for (var i = 0; i < 7; i++) { expect(g.accept(frame(5000)), isEmpty, reason: '第 ${i + 1} 帧还不该开'); } final out = g.accept(frame(5000)); expect(g.isOpen, isTrue); expect(out, isNotEmpty); expect(g.openedByFallback, isFalse); }); }); group('PeerSpeechGate 缓存补发(对端先说话时不能吃掉开头)', () { test('开门时把此前缓存的帧整段补发,一帧不丢', () { final g = PeerSpeechGate(openFrames: 8, prerollFrames: 25); var emitted = 0; for (var i = 0; i < 8; i++) { emitted += g.accept(frame(5000)).length; } expect(emitted, 8, reason: '开门前的 8 帧必须在开门瞬间补发出来'); }); test('缓存上限生效:更早的帧被丢弃,不会无限涨', () { final g = PeerSpeechGate(openFrames: 2, prerollFrames: 5); for (var i = 0; i < 30; i++) { g.accept(frame(0)); // 静音,只进缓存不开门 } final out = [g.accept(frame(5000)), g.accept(frame(5000))] .expand((e) => e) .toList(); expect(out.length, 5, reason: '只应补发最近 prerollFrames 帧'); }); test('开门之后直通,每帧进一出一', () { final g = PeerSpeechGate(openFrames: 1); g.accept(frame(5000)); expect(g.accept(frame(0)).length, 1, reason: '开门后静音帧也要照推'); expect(g.accept(frame(9000)).length, 1); }); }); group('PeerSpeechGate 兜底与复位', () { test('阈值一直不过时兜底强开——宁可音色刻错也不能整路不翻译', () { final g = PeerSpeechGate(rmsThreshold: 30000, fallbackFrames: 20); List out = const []; for (var i = 0; i < 20; i++) { out = g.accept(frame(100)); } expect(g.isOpen, isTrue); expect(g.openedByFallback, isTrue, reason: '要能区分是正常开门还是兜底强开'); expect(out, isNotEmpty, reason: '兜底开门同样要把缓存补发出去'); }); test('reset 之后回到初始态(断线重连会重新刻音色,必须重新把门关上)', () { final g = PeerSpeechGate(openFrames: 2); g.accept(frame(5000)); g.accept(frame(5000)); expect(g.isOpen, isTrue); g.reset(); expect(g.isOpen, isFalse); expect(g.openedByFallback, isFalse); expect(g.accept(frame(0)), isEmpty, reason: 'reset 后静音不该直通'); }); }); }