diff --git a/nodes/subtitle_cleanup.py b/nodes/subtitle_cleanup.py index f769c2e..e4b19eb 100644 --- a/nodes/subtitle_cleanup.py +++ b/nodes/subtitle_cleanup.py @@ -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 时关闭过滤。纯函数,不修改输入。 """ diff --git a/tests/nodes/test_subtitle_cleanup/data/repetition_cues.json b/tests/nodes/test_subtitle_cleanup/data/repetition_cues.json index 4f1eb0a..fb631b1 100644 --- a/tests/nodes/test_subtitle_cleanup/data/repetition_cues.json +++ b/tests/nodes/test_subtitle_cleanup/data/repetition_cues.json @@ -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": "は" + } + ] } \ No newline at end of file diff --git a/tests/nodes/test_subtitle_cleanup/test_cleanup.py b/tests/nodes/test_subtitle_cleanup/test_cleanup.py index 9b4365a..51f388e 100644 --- a/tests/nodes/test_subtitle_cleanup/test_cleanup.py +++ b/tests/nodes/test_subtitle_cleanup/test_cleanup.py @@ -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) == []