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 _preroll = []; /// 门是否已开(开了之后本次会话一直直通)。 bool get isOpen => _open; /// 开门时那一帧的 RMS;兜底开门时为最后一帧的 RMS。用于真机调阈值。 int get openRms => _openRms; /// 是否是被 [fallbackFrames] 兜底强开的(说明阈值可能偏高,或者全程只有串音)。 bool get openedByFallback => _openedByFallback; /// 喂一帧,返回**应当推给翻译服务**的帧(可能是 0 帧、1 帧,或开门瞬间的一整串)。 List accept(Uint8List frame) { if (_open) return [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 []; _open = true; _openRms = rms; _openedByFallback = !hitThreshold; final out = List.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(); }