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.
130 lines
4.9 KiB
130 lines
4.9 KiB
import 'dart:typed_data';
|
|
|
|
/// 对端(B 路)说话人门限:**在对端真的开口之前,不要把音频推给端到端翻译**。
|
|
///
|
|
/// ## 为什么需要它
|
|
/// 通话翻译的 B 路会话在功能一启动就建立,而阿里的实时声音复刻配的是
|
|
/// `voice_clone_options.frequency = "once"` —— **只在会话开头刻一次音色,之后整通
|
|
/// 电话不再更新**。此时对端通常还没开口,B 路先收到的往往是**机主声音的串音**
|
|
/// (2026-09-19 真机实锤:B 路 ASR 配的是 `language: en`,却识别出了机主刚说的
|
|
/// 中文「你好,亮亮,听得到吗?」)。结果是 B 路把机主的音色刻成了「对端音色」,
|
|
/// 用户听到的对端译文用的是自己的声音——两个人听起来一模一样。
|
|
///
|
|
/// 这个门限保证「喂给 B 路的第一段音频来自对端本人」,从而刻对人。
|
|
///
|
|
/// ## ⚠️ 必须缓存补发,不能丢弃
|
|
/// 门限没过就把音频扔掉的话,**对端先开口时他第一句的前 150ms 会被吃掉**
|
|
/// (「你好,听得到吗」变成「好,听得到吗」,甚至整句识别失败)。所以未开门期间
|
|
/// 音频进环形缓冲,开门瞬间把缓冲整段补推上去——既刻对人,又一个字不丢。
|
|
/// 谁先说话都不影响:对端先说就是立刻开门,机主先说就被门限挡住等对端。
|
|
///
|
|
/// ## ⚠️ 失败方向是刻意选的
|
|
/// [fallbackFrames] 到时仍未开门就**强行开门**。因为两种失败的代价不对等:
|
|
/// 门限太严 → 对端整通电话都不被翻译(功能性失效)
|
|
/// 门限太松 → 音色可能刻错(体验问题)
|
|
/// 后者轻得多,所以宁可放宽。强行开门时会打日志,真机上据此调 [rmsThreshold]。
|
|
///
|
|
/// ## ⚠️ 每次会话重建都要 [reset]
|
|
/// 断线重连会新建 session、**重新刻一次音色**,门限不复位就等于白修。
|
|
class PeerSpeechGate {
|
|
PeerSpeechGate({
|
|
this.rmsThreshold = 600,
|
|
this.openFrames = 8,
|
|
this.prerollFrames = 25,
|
|
this.fallbackFrames = 500,
|
|
});
|
|
|
|
/// 判定为「有人在说话」的 RMS 阈值(PCM16)。
|
|
/// 经验值:静音 0~50,串音漏音通常 <300,正常说话 1000~8000。
|
|
/// **需要真机调**——开门时会把实测 RMS 打进日志。
|
|
final int rmsThreshold;
|
|
|
|
/// 连续多少帧超过阈值才开门。20ms/帧,8 帧 = 160ms。
|
|
/// 用「连续」而不是「单帧」是为了滤掉串音:串音是断续的小尖峰,真声是连续的。
|
|
final int openFrames;
|
|
|
|
/// 未开门时最多缓存多少帧(20ms/帧,25 帧 = 500ms)。开门时整段补发。
|
|
final int prerollFrames;
|
|
|
|
/// 兜底:收到这么多帧仍未开门就强行开门(500 帧 = 10s)。见上方「失败方向」。
|
|
final int fallbackFrames;
|
|
|
|
bool _open = false;
|
|
int _run = 0;
|
|
int _seen = 0;
|
|
int _openRms = 0;
|
|
bool _openedByFallback = false;
|
|
final List<Uint8List> _preroll = <Uint8List>[];
|
|
|
|
/// 门是否已开(开了之后本次会话一直直通)。
|
|
bool get isOpen => _open;
|
|
|
|
/// 开门时那一帧的 RMS;兜底开门时为最后一帧的 RMS。用于真机调阈值。
|
|
int get openRms => _openRms;
|
|
|
|
/// 是否是被 [fallbackFrames] 兜底强开的(说明阈值可能偏高,或者全程只有串音)。
|
|
bool get openedByFallback => _openedByFallback;
|
|
|
|
/// 喂一帧,返回**应当推给翻译服务**的帧(可能是 0 帧、1 帧,或开门瞬间的一整串)。
|
|
List<Uint8List> accept(Uint8List frame) {
|
|
if (_open) return <Uint8List>[frame];
|
|
|
|
_seen++;
|
|
final rms = rmsOfPcm16(frame);
|
|
if (rms >= rmsThreshold) {
|
|
_run++;
|
|
} else {
|
|
_run = 0;
|
|
}
|
|
|
|
_preroll.add(frame);
|
|
while (_preroll.length > prerollFrames) {
|
|
_preroll.removeAt(0);
|
|
}
|
|
|
|
final hitThreshold = _run >= openFrames;
|
|
final hitFallback = _seen >= fallbackFrames;
|
|
if (!hitThreshold && !hitFallback) return const <Uint8List>[];
|
|
|
|
_open = true;
|
|
_openRms = rms;
|
|
_openedByFallback = !hitThreshold;
|
|
final out = List<Uint8List>.from(_preroll);
|
|
_preroll.clear();
|
|
return out;
|
|
}
|
|
|
|
/// 会话重建(含断线重连)时必须调用,否则新会话照样可能刻到串音。
|
|
void reset() {
|
|
_open = false;
|
|
_run = 0;
|
|
_seen = 0;
|
|
_openRms = 0;
|
|
_openedByFallback = false;
|
|
_preroll.clear();
|
|
}
|
|
}
|
|
|
|
/// PCM16 小端单声道的 RMS。字节数为奇数时忽略末尾半个样本。
|
|
int rmsOfPcm16(Uint8List frame) {
|
|
final n = frame.length ~/ 2;
|
|
if (n == 0) return 0;
|
|
final view = ByteData.sublistView(frame, 0, n * 2);
|
|
var sum = 0.0;
|
|
for (var i = 0; i < n; i++) {
|
|
final s = view.getInt16(i * 2, Endian.little);
|
|
sum += s * s;
|
|
}
|
|
return (sum / n).isFinite ? _sqrtInt(sum / n) : 0;
|
|
}
|
|
|
|
int _sqrtInt(double v) {
|
|
if (v <= 0) return 0;
|
|
var x = v;
|
|
var y = (x + 1) / 2;
|
|
while (y < x) {
|
|
x = y;
|
|
y = (x + v / x) / 2;
|
|
}
|
|
return x.round();
|
|
}
|
|
|