Browse Source
真机实测(Android,恒玄耳机)逐条定位后修,原生能改的都在原生层改。 字幕乱(AliyunBailianE2EHelper,Android/iOS 同改) - 原文流与译文流各自按到达顺序自己发号,两条流的切句本来就不一致,off-by-one 一旦发生就一路错到底。改成认服务端的 item_id:译文帧的 item_id 经 itemParent (response item → input item)映射回原文那条,取不到才退回本地发号。 - response.audio_transcript 的 text 是累计值、delta 才是增量,此前一律当增量 append,于是出现「Hello,Hello,Hello,」。改成按字段名分支:有 delta 就追加, 只有 text 就整段替换。 - connectionGeneration 守住 onOpen/onMessage/onFailure/onClosed(iOS 用 activeTask 弱引用按任务身份判),旧 socket 的迟到回调不再打进新会话;stopContinuousConversation 同步清 webSocket/isStarted。 翻译结束后「总结内容」还留在屏幕上 - AliyunAstCallback.onSessionFinished 会把整场累计文本当作一条 translated 再发 一次,表现就是一大坨总结。不再发;原文缓存改为按 utteranceId 存,上限 32 条。 结束后无法再翻译、返回按钮像被遮挡 - 退出时那颗延时 Get.delete 会误杀刚重新进来的页面。按 tag 记活跃页面数 (_PageAliveMarker + _aliveCount),还有人用就跳过销毁。 重进页面提示「未在通话中」 - 耳机对 0xD1/0xD2(进/出通话模式)的应答被当成通话状态上报,把 isCallOngoing 改掉了。这两个 type 改为只记日志,不动通话状态。 切后台功能被关掉 - 前台服务类型加 phoneCall:microphone|phoneCall (132),ROM 的进程冻结不再掐断 通话翻译。startForeground 带类型失败时回退到仅 microphone。stopService 改用 context.stopService,后台也能调。 - AST 掉线自动重连(1s/2s/4s,最多 3 次),回到前台按 _astHealthy 补一次; 重连后的新会话用 800ms 宽限期,否则会被原来的 2s 窗口吞掉重试。 B 路说话人门限(PeerSpeechGate,新增 + 11 个单测) - 声音复刻配的是 frequency=once,只在会话开头刻一次音色。B 路会话一启动就建立, 此时对端多半还没开口,先收到的是机主的串音(实测 B 路 ASR 配 language=en 却 识别出机主刚说的中文),于是把机主音色刻成了「对端音色」,两个人听起来一样。 - 未开门期间音频进环形缓冲,开门瞬间整段补发——对端先开口时第一句的开头一个字 都不能丢。fallbackFrames 到时强行开门:门限太严会让对端整通电话不被翻译, 太松只是音色可能刻错,后者轻得多。 - 每次会话重建(含断线重连)都 reset,否则重连后照样刻到串音。 已知未尽:Android 14+ 会拒绝 phoneCall 这个前台服务类型并回退成仅 microphone, 那里的冻结保护不生效;rmsThreshold=600 尚未在真机上标定(上一轮跑到了兜底强开)。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>main
11 changed files with 1063 additions and 161 deletions
@ -0,0 +1,130 @@ |
|||||
|
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(); |
||||
|
} |
||||
@ -0,0 +1,117 @@ |
|||||
|
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 后静音不该直通'); |
||||
|
}); |
||||
|
}); |
||||
|
} |
||||
Loading…
Reference in new issue