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

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 后静音不该直通');
});
});
}