Skip to content

Commit 174509a

Browse files
committed
OPT: 收紧复述录音自动停止算法,避免思考时被早停
- 通道 1 注释禁用 D 规则(剩余词数估算过激进),仅保留 A/B/E - A/B 规则参数化:复述传 minConsecutive=3、strictPerfectOnly=true、1s 阈值;跟读默认值不变 - 新增 E 规则 detectNearCompletion:matchRate ≥ 90% + 末尾 5 词命中 ≥4 → 1s,覆盖"差 1-2 词的真完成" - 转录停滞计时器改用动态兜底(不再被 A/B/D 加速到 1-3s) - computeRetellDynamicFallback:cap 20s→30s、各档翻倍、下限 1s→5s - 旧算法均以注释保留方便回滚
1 parent fae5be3 commit 174509a

3 files changed

Lines changed: 667 additions & 118 deletions

File tree

lib/providers/retell_recording_controller_provider.dart

Lines changed: 65 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -704,14 +704,27 @@ class RetellRecordingController extends Notifier<RetellRecordingState> {
704704
partialTranscript: liveTranscript,
705705
);
706706
if (ctx.hasMatch) {
707-
final ruleD = detectRemainingByPosition(
707+
// [旧算法-2026-05-18] D 规则(剩余词数估算)在复述场景过于激进,
708+
// 用户说几个词碰巧匹配末尾就被快速停止。已在调用方禁用,函数本身保留。
709+
// final ruleD = detectRemainingByPosition(
710+
// ctx,
711+
// secondsPerWord: 3,
712+
// baseSeconds: 2,
713+
// );
714+
// 复述场景:A 要求尾部连续 ≥3 词唯一匹配,B 仅 100% 匹配时触发,
715+
// E 覆盖"接近完成但 ASR 漏识别 1-2 词"的场景。三者均高置信,阈值 1s。
716+
final ruleA = detectTailMatch(
708717
ctx,
709-
secondsPerWord: 3,
710-
baseSeconds: 2,
718+
minConsecutive: 3,
719+
triggerDuration: const Duration(seconds: 1),
711720
);
712-
final ruleA = detectTailMatch(ctx);
713-
final ruleB = detectOverallMatchRate(ctx);
714-
final rules = [ruleD, ruleA, ruleB];
721+
final ruleB = detectOverallMatchRate(
722+
ctx,
723+
strictPerfectOnly: true,
724+
perfectDuration: const Duration(seconds: 1),
725+
);
726+
final ruleE = detectNearCompletion(ctx);
727+
final rules = [ruleA, ruleB, ruleE];
715728

716729
// 找最短触发阈值用于日志
717730
Duration? shortest;
@@ -835,71 +848,67 @@ class RetellRecordingController extends Notifier<RetellRecordingState> {
835848
);
836849
}
837850

838-
/// 转录停滞定时器:只在规则 A/B 触发时才设置。
851+
/// 转录停滞定时器(收紧版)。
852+
///
853+
/// 收紧后只用动态兜底阈值,不再被 A/B/D 内容匹配规则加速。
854+
/// 语义:长时间没有新转录文字 → 等同于"声学静音兜底"。
855+
/// A/B 规则的"已说完即停"职责由静音通道(`_checkAutoStopOnSilence`)独立承担。
856+
///
857+
/// 旧逻辑保留在下方注释,方便回滚。
839858
void _resetTranscriptStaleTimer({
840859
required String promptId,
841860
required String referenceText,
842861
required String transcript,
843862
}) {
844863
_transcriptStaleTimer?.cancel();
845864

846-
final ctx = buildMatchContext(
847-
referenceText: referenceText,
848-
partialTranscript: transcript,
849-
);
850-
if (!ctx.hasMatch) return;
851-
852-
// 取规则 D/A/B 中最短的触发阈值
853-
final ruleD = detectRemainingByPosition(
854-
ctx,
855-
secondsPerWord: 3,
856-
baseSeconds: 2,
857-
);
858-
Duration? shortest;
859-
String? desc;
860-
for (final rule in [
861-
ruleD,
862-
detectTailMatch(ctx),
863-
detectOverallMatchRate(ctx),
864-
]) {
865-
if (rule.triggered) {
866-
if (shortest == null || rule.threshold! < shortest) {
867-
shortest = rule.threshold;
868-
desc = rule.description;
869-
}
870-
}
871-
}
872-
// 无规则触发 → 用动态兜底阈值
873-
if (shortest == null) {
874-
final refDur = _referenceDuration;
875-
final fallback = (refDur != null)
876-
? computeRetellDynamicFallback(
877-
voicedDuration: _voicedDuration,
878-
referenceDuration: refDur,
879-
)
880-
: _silenceTimeout;
881-
shortest = fallback;
882-
desc = '转录停滞兜底${fallback.inSeconds}s';
883-
AppLogger.log(
884-
'RetellRec',
885-
'转录停滞: 无规则触发, 靠兜底 ${fallback.inSeconds}s',
886-
);
887-
}
865+
final refDur = _referenceDuration;
866+
final fallback = (refDur != null)
867+
? computeRetellDynamicFallback(
868+
voicedDuration: _voicedDuration,
869+
referenceDuration: refDur,
870+
)
871+
: _silenceTimeout;
872+
final desc = '转录停滞动态兜底${fallback.inSeconds}s';
888873

889-
AppLogger.log('RetellRec', '转录停滞阈值 ${shortest.inMilliseconds}ms | $desc');
890-
_transcriptStaleTimer = Timer(shortest, () {
874+
AppLogger.log('RetellRec', '转录停滞阈值 ${fallback.inMilliseconds}ms | $desc');
875+
_transcriptStaleTimer = Timer(fallback, () {
891876
if (state.promptId != promptId || _isStopping) return;
892877
if (state.phase != RetellRecordingPhase.recording) return;
893-
final pct = (ctx.matchRate * 100).toInt();
894878
AppLogger.log(
895879
'RetellRec',
896-
'⏹ 转录停滞停止: '
897-
'${shortest!.inMilliseconds}ms | '
898-
'匹配${ctx.lcsPairs.length}/${ctx.referenceTokens.length}词'
899-
'($pct%), $desc',
880+
'⏹ 转录停滞停止: ${fallback.inMilliseconds}ms | $desc',
900881
);
901882
_stopForEvaluation(promptId: promptId, reason: '转录停滞($desc)');
902883
});
884+
885+
// [旧算法-2026-05-18] 旧逻辑:取规则 D/A/B 最短触发阈值;都未触发再用兜底。
886+
// 已下线:复述思考时 transcript 不更新,A/B/D 命中后阈值很短(1-5s)会误停。
887+
// final ctx = buildMatchContext(
888+
// referenceText: referenceText,
889+
// partialTranscript: transcript,
890+
// );
891+
// if (!ctx.hasMatch) return;
892+
// final ruleD = detectRemainingByPosition(
893+
// ctx,
894+
// secondsPerWord: 3,
895+
// baseSeconds: 2,
896+
// );
897+
// Duration? shortest;
898+
// String? desc;
899+
// for (final rule in [
900+
// ruleD,
901+
// detectTailMatch(ctx),
902+
// detectOverallMatchRate(ctx),
903+
// ]) {
904+
// if (rule.triggered) {
905+
// if (shortest == null || rule.threshold! < shortest) {
906+
// shortest = rule.threshold;
907+
// desc = rule.description;
908+
// }
909+
// }
910+
// }
911+
// if (shortest == null) { ... 旧兜底分支 ... }
903912
}
904913

905914
// ── 最大录音时长 ──

lib/services/speech_completion_detector.dart

Lines changed: 127 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,16 @@ SpeechMatchContext buildMatchContext({
7171

7272
/// 检测 A:连续尾部匹配。
7373
///
74-
/// 原文末尾有 ≥1 个连续词被匹配,且该尾部子序列在原文中唯一出现。
74+
/// 原文末尾有 ≥[minConsecutive] 个连续词被匹配,且该尾部子序列在原文中唯一出现。
7575
/// 触发条件说明:用户说出了原文结尾的独特片段,大概率已读完。
76-
DetectionResult detectTailMatch(SpeechMatchContext ctx) {
76+
///
77+
/// 默认参数与历史跟读行为一致([minConsecutive] = 1,阈值 1s)。
78+
/// 复述场景调用方应传 `minConsecutive: 3, triggerDuration: Duration(seconds: 3)` 收紧。
79+
DetectionResult detectTailMatch(
80+
SpeechMatchContext ctx, {
81+
int minConsecutive = 1,
82+
Duration triggerDuration = const Duration(seconds: 1),
83+
}) {
7784
if (!ctx.hasMatch) {
7885
return const DetectionResult(description: 'A:无匹配');
7986
}
@@ -88,8 +95,13 @@ DetectionResult detectTailMatch(SpeechMatchContext ctx) {
8895
}
8996
}
9097

91-
if (consecutiveTail < 1) {
92-
return const DetectionResult(description: 'A:末尾未匹配');
98+
if (consecutiveTail < minConsecutive) {
99+
if (consecutiveTail < 1) {
100+
return const DetectionResult(description: 'A:末尾未匹配');
101+
}
102+
return DetectionResult(
103+
description: 'A:尾部连续${consecutiveTail}词<$minConsecutive,未触发',
104+
);
93105
}
94106

95107
final uniqueStart = tokens.length - consecutiveTail;
@@ -100,36 +112,51 @@ DetectionResult detectTailMatch(SpeechMatchContext ctx) {
100112
}
101113

102114
return DetectionResult(
103-
threshold: const Duration(seconds: 1),
104-
description: 'A:尾部连续${consecutiveTail}词且唯一→1s',
115+
threshold: triggerDuration,
116+
description:
117+
'A:尾部连续${consecutiveTail}词且唯一→${triggerDuration.inMilliseconds}ms',
105118
);
106119
}
107120

108121
/// 检测 B:全句匹配率。
109122
///
110-
/// 100% → 1s, ≥95% → 2s, ≥90% → 3s,低于 90% 不触发。
111-
DetectionResult detectOverallMatchRate(SpeechMatchContext ctx) {
123+
/// 默认(跟读模式):100% → 1s, ≥95% → 2s, ≥90% → 3s,低于 90% 不触发。
124+
///
125+
/// 收紧模式:通过 [strictPerfectOnly] = true 启用,仅 100% 匹配才触发,阈值 [perfectDuration]
126+
/// 复述场景用此模式,避免 90-99% 匹配触发短阈值早停。
127+
DetectionResult detectOverallMatchRate(
128+
SpeechMatchContext ctx, {
129+
bool strictPerfectOnly = false,
130+
Duration perfectDuration = const Duration(seconds: 1),
131+
Duration nearPerfectDuration = const Duration(seconds: 2),
132+
Duration highMatchDuration = const Duration(seconds: 3),
133+
}) {
112134
if (!ctx.hasMatch) {
113135
return const DetectionResult(description: 'B:无匹配');
114136
}
115137

116138
final pct = (ctx.matchRate * 100).toInt();
117139
if (ctx.matchRate >= 1.0) {
118140
return DetectionResult(
119-
threshold: const Duration(seconds: 1),
120-
description: 'B:匹配率${pct}%→1s',
141+
threshold: perfectDuration,
142+
description: 'B:匹配率${pct}%→${perfectDuration.inMilliseconds}ms',
143+
);
144+
}
145+
if (strictPerfectOnly) {
146+
return DetectionResult(
147+
description: 'B:匹配率${pct}%<100%,严格模式不触发',
121148
);
122149
}
123150
if (ctx.matchRate >= 0.95) {
124151
return DetectionResult(
125-
threshold: const Duration(seconds: 2),
126-
description: 'B:匹配率${pct}%→2s',
152+
threshold: nearPerfectDuration,
153+
description: 'B:匹配率${pct}%→${nearPerfectDuration.inMilliseconds}ms',
127154
);
128155
}
129156
if (ctx.matchRate >= 0.90) {
130157
return DetectionResult(
131-
threshold: const Duration(seconds: 3),
132-
description: 'B:匹配率${pct}%→3s',
158+
threshold: highMatchDuration,
159+
description: 'B:匹配率${pct}%→${highMatchDuration.inMilliseconds}ms',
133160
);
134161
}
135162

@@ -172,6 +199,66 @@ DetectionResult detectTailHitCount(SpeechMatchContext ctx, {int tailSize = 5}) {
172199
);
173200
}
174201

202+
/// 检测 E:近完成(全句匹配率高 + 末尾覆盖到位)。
203+
///
204+
/// 复述场景专用:当用户已讲完原文 9 成以上内容,且原文末尾 5 词命中 ≥4 时,
205+
/// 判定为"基本已完成复述",高置信快速收尾。
206+
///
207+
/// 设计动机:覆盖 B 规则(要求 100% 全句匹配)漏掉的"差 1-2 个词没命中"场景,
208+
/// 又比 A 规则(要求末尾连续匹配)宽容,允许 ASR 漏识别 1 个末尾词。
209+
///
210+
/// 触发条件:
211+
/// - `matchRate >= minMatchRate`(默认 0.90)
212+
/// - 原文末尾 [tailSize] 词中至少 [minTailHits] 词被匹配(默认 5 词中 ≥4 词)
213+
///
214+
/// 默认参数对应复述模式收紧值。函数本身保持参数化,方便其他场景调用。
215+
DetectionResult detectNearCompletion(
216+
SpeechMatchContext ctx, {
217+
double minMatchRate = 0.90,
218+
int tailSize = 5,
219+
int minTailHits = 4,
220+
Duration triggerDuration = const Duration(seconds: 1),
221+
}) {
222+
if (!ctx.hasMatch) {
223+
return const DetectionResult(description: 'E:无匹配');
224+
}
225+
226+
final pct = (ctx.matchRate * 100).toInt();
227+
if (ctx.matchRate < minMatchRate) {
228+
final minPct = (minMatchRate * 100).toInt();
229+
return DetectionResult(
230+
description: 'E:匹配率${pct}%<$minPct%,未触发',
231+
);
232+
}
233+
234+
final tokens = ctx.referenceTokens;
235+
final effectiveTailSize = tokens.length < tailSize ? tokens.length : tailSize;
236+
final tailStart = tokens.length - effectiveTailSize;
237+
var tailMatchCount = 0;
238+
for (var i = tailStart; i < tokens.length; i++) {
239+
if (ctx.matchedRefIndexes.contains(i)) {
240+
tailMatchCount++;
241+
}
242+
}
243+
244+
// 短句兜底:末尾词数不足 [minTailHits] 时,要求全部命中。
245+
final requiredHits =
246+
effectiveTailSize < minTailHits ? effectiveTailSize : minTailHits;
247+
if (tailMatchCount < requiredHits) {
248+
return DetectionResult(
249+
description:
250+
'E:末尾${effectiveTailSize}词命中$tailMatchCount<$requiredHits,未触发',
251+
);
252+
}
253+
254+
return DetectionResult(
255+
threshold: triggerDuration,
256+
description:
257+
'E:匹配率${pct}% + 末尾$tailMatchCount/${effectiveTailSize}'
258+
'→${triggerDuration.inMilliseconds}ms',
259+
);
260+
}
261+
175262
/// 检测 D:剩余词数估算阈值。
176263
///
177264
/// 从 transcript 末尾取 1-[maxSubstringLength] 个词,枚举所有连续子串,
@@ -343,16 +430,20 @@ Duration computeDynamicFallback({
343430
/// - ≤20s → 1.2
344431
/// - >20s → 1.3(长段落需要更多回忆时间)
345432
///
346-
/// - [matchRate]:文本匹配率(0-1),低于 0.8 时不缩短兜底。
347-
/// 传 null 表示无转录(ASR 关闭),此时仅凭有声比例计算。
433+
/// [matchRate]:文本匹配率(0-1),低于 0.8 时不缩短兜底。
434+
/// 传 null 表示无转录(ASR 关闭),此时仅凭有声比例计算。
435+
///
436+
/// 收紧版(2026-05-18):上限 20s→30s,各档阈值翻倍,下限 1s→5s。
437+
/// 旧算法以注释保留在函数尾部,方便对比/回滚。
348438
Duration computeRetellDynamicFallback({
349439
required Duration voicedDuration,
350440
required Duration referenceDuration,
351441
double? matchRate,
352442
}) {
353-
// 动态上限:min(20s, max(5s, referenceDuration))
354-
final capMs = referenceDuration.inMilliseconds.clamp(5000, 20000);
355-
final scale = capMs / 20000; // 缩放因子,各阈值等比缩放
443+
// 动态上限:clamp(referenceDuration, 5s, 30s)
444+
// [旧算法-2026-05-18] 旧上限:clamp(refDur, 5s, 20s)
445+
final capMs = referenceDuration.inMilliseconds.clamp(5000, 30000);
446+
final scale = capMs / 30000; // 缩放因子,各阈值按 cap/30s 等比缩放
356447

357448
if (referenceDuration <= Duration.zero) return Duration(milliseconds: capMs);
358449
if (matchRate != null && matchRate < 0.8) return Duration(milliseconds: capMs);
@@ -365,24 +456,34 @@ Duration computeRetellDynamicFallback({
365456
final adjustedMs = referenceDuration.inMilliseconds * speedFactor;
366457
final ratio = voicedDuration.inMilliseconds / adjustedMs;
367458

368-
// 基准阈值(cap=20s 时)按比例缩放:3/6/10/15/20 × scale
459+
// 基准阈值(scale=1.0 即 ref≥30s 时):6/12/cap/cap/cap,下限 5s
369460
final int ms;
370461
if (ratio >= 0.95) {
371-
ms = (3000 * scale).round();
372-
} else if (ratio >= 0.90) {
373462
ms = (6000 * scale).round();
463+
} else if (ratio >= 0.90) {
464+
ms = (12000 * scale).round();
374465
} else if (ratio >= 0.85) {
375-
ms = (10000 * scale).round();
466+
ms = capMs;
376467
} else if (ratio >= 0.80) {
377-
ms = (15000 * scale).round();
468+
ms = capMs;
378469
} else if (ratio >= 0.75) {
379470
ms = capMs;
380471
} else {
381472
return Duration(milliseconds: capMs);
382473
}
383474

384-
// 最低 1s,避免过短误触
385-
return Duration(milliseconds: ms < 1000 ? 1000 : ms);
475+
// 收紧后下限:5s
476+
// [旧算法-2026-05-18] 原下限:1s
477+
return Duration(milliseconds: ms < 5000 ? 5000 : ms);
478+
479+
// [旧算法-2026-05-18] 原档位(cap=20s, scale=capMs/20000):
480+
// ratio >= 0.95 → 3000 * scale
481+
// ratio >= 0.90 → 6000 * scale
482+
// ratio >= 0.85 → 10000 * scale
483+
// ratio >= 0.80 → 15000 * scale
484+
// ratio >= 0.75 → cap
485+
// 其他 → cap
486+
// 下限 1000ms
386487
}
387488

388489
// ========== 内部工具函数 ==========

0 commit comments

Comments
 (0)