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.
117 lines
4.2 KiB
117 lines
4.2 KiB
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<Uint8List> 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 后静音不该直通');
|
|
});
|
|
});
|
|
}
|
|
|