fix: 短时重复伪影也整条删除(判据补重复次数上限)

真实产物里出现 3.7 秒的 cue 被同一个假名填满 111 次(`は`×111),此前判据要求
"时长 ≥15 秒"才删,于是它漏到翻译层,最终变成 56 个"哈"进中文成品字幕。

- 判据改为:重复段占正文 ≥70% 且重复 ≥6 次,并且**时长 ≥15 秒或重复 ≥20 次**。
- 短条走"极端重复"档:真实呻吟的重复次数实测 ≤15(ぇ×15 占 3.2 秒),不会误删。
- 测试数据取自真实产物(A 的日语转写里那条 112 字短伪影)。
This commit is contained in:
2026-09-18 23:41:32 +08:00
parent 171b088e7c
commit 4d2823c005
3 changed files with 45 additions and 11 deletions
+18 -10
View File
@@ -64,11 +64,13 @@ MOAN_CHARS = frozenset(
# /んふふ)有效假名 ≤3;>3(如ああああ)或含非呻吟字符的一律保留。
DEFAULT_MOAN_MAX_CHARS = 3
# 重复伪影判据(whisper 窗口内重复循环会输出整条重复到 30 秒的 cue):
# 单元最长 6 字(更长的重复单元在真实数据里未见),至少重复 6 次,
# 且重复段占正文 ≥70%。真实呻吟重复次数也在 6–15 之间,靠时长区分。
# 重复伪影判据(whisper 循环解码会把一个单元写满整条 cue):
# 单元最长 6 字(更长的重复单元在真实数据里未见),重复段占正文 ≥70%。
# 长度维度两档:长条(≥15s)重复 ≥6 次即删;短条则要求极端重复(≥20 次)——
# 真实呻吟重复次数实测 ≤15(ぇ×15 / しゅ×7),而短时伪影实测 3.7 秒填了 111 次。
REPETITION_UNIT_MAX_CHARS = 6
REPETITION_MIN_REPEATS = 6
REPETITION_HARD_REPEATS = 20
REPETITION_MIN_COVERAGE = 0.7
@@ -92,20 +94,25 @@ def _is_repetition_artifact(text: str, duration: float, threshold_seconds: float
min_repeats: int, min_coverage: float) -> bool:
"""判断一条 cue 是否为 whisper 重复循环伪影(整条删除判据)。
判据(同时满足):展示时长 ≥ 阈值、同一 1–6 字单元连续重复 ≥ min_repeats 次、
重复段占正文 ≥ min_coverage。短促重复(“ぇ”×15 只占 3 秒)是真实发声,靠
时长区分,与寒暄幻觉同一套“长条才删”思路。
同时满足同一 1–6 字单元连续重复 ≥ min_repeats 次、重复段占正文
≥ min_coverage,且**时长 ≥ 阈值或重复次数 ≥ REPETITION_HARD_REPEATS**。
长度维度分两档是必要的:只按"长条才删"会漏掉短时循环(实测 3.7 秒的 cue
被填了 111 个假名,翻译后变成 56 个""进成品);而真实呻吟的重复次数实测
≤15(ぇ×15 占 3.2 秒),所以短条用"极端重复"判据即可区分,不会误删。
threshold_seconds ≤ 0 关闭过滤。
"""
if threshold_seconds <= 0 or duration < threshold_seconds:
if threshold_seconds <= 0:
return False
stripped = text.replace("\n", "")
found = _longest_repeated_run(stripped, REPETITION_UNIT_MAX_CHARS)
if found is None:
return False
run, repeats = found
if repeats < min_repeats or not stripped:
if not stripped or repeats < min_repeats:
return False
return len(run) / len(stripped) >= min_coverage
if len(run) / len(stripped) < min_coverage:
return False
return duration >= threshold_seconds or repeats >= REPETITION_HARD_REPEATS
def _moan_chars(text: str) -> int:
"""返回 text 中'有效假名字符'数量(呻吟判据的一部分)。
@@ -196,7 +203,8 @@ def remove_repetition_entries(
模型在窗口内卡住重复时会把同一单元写满整条 cue(实测 30 秒、重复 74–446
次):这种正文会让下游 LLM 跟着重复、把预算耗在思考上而报结构错误,也会
直接渲染成超长字幕行,因此在产生处整条删除。判据见 _is_repetition_artifact
长条(时长 ≥ 阈值)+ 同一短单元高频重复且占满正文,真实短促呻吟不会命中。
长条(时长 ≥ 阈值)或极端重复(≥ REPETITION_HARD_REPEATS 次)+ 同一短单元
占满正文;真实短促呻吟(重复 ≤15 次)不会命中。
threshold_seconds ≤ 0 时关闭过滤。纯函数,不修改输入。
"""
@@ -49,5 +49,14 @@
"start": "00:00:12,000",
"end": "00:00:15,000",
"text": "そんなにご褒美欲しかったの?"
},
"short_artifacts": [
{
"start": "00:22:56,363",
"end": "00:23:00,093",
"text": "ははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははははは",
"repeats": 111,
"unit": "は"
}
]
}
@@ -330,3 +330,20 @@ def test_remove_repetition_entries_can_be_disabled() -> None:
srt = _srt((artifact["start"], artifact["end"], artifact["text"]))
assert remove_repetition_entries(srt, threshold_seconds=0) == srt
def test_remove_repetition_entries_deletes_short_but_extreme_repetition() -> None:
"""数据:真实产物里的短时伪影——3.7 秒的 cue 被同一个假名填了 111 次。
过程:调用 remove_repetition_entries。
验证:整条删除。判据不能只看时长:这种短条会一路翻成 56 个""进成品字幕,
但真实呻吟的重复次数实测 ≤15,用重复次数上限即可区分。
"""
artifact = _REPETITION_CUES["short_artifacts"][0]
srt = _srt((artifact["start"], artifact["end"], artifact["text"]))
cleaned = remove_repetition_entries(srt)
assert artifact["text"] not in cleaned
assert parse_srt_entries(cleaned) == []