diff --git a/docs/workflows.md b/docs/workflows.md index cee5b9a..7c311a2 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -37,6 +37,14 @@ JSON 不支持注释,因此"参数理由"以节点 `params` 内 `_note_<参数 等假名,真实短对话(そこ/やばい/ねえ/やだ/えへへ)天然不命中。仅 decode_full 生效,learn-translate 等 VAD 链路不受影响。 +**重复伪影过滤**(2026-09):whisper 在单个窗口内卡住重复时,会把一个单元写满整条 +cue(实测 30 秒、重复 74–446 次,如 `チン`×111);这种正文会让下游 LLM 跟着重复、 +把输出预算耗在思考上而报结构错误(表现为 `translation alignment failed … +Expecting value: line 1 column 1 (char 0)`),也会直接渲染成超长字幕行。whisper +转录后按**模式判据**(无字符词表)**整条删除**(`remove_repetition_entries`): +展示时长 ≥15s 且同一 1–6 字单元连续重复 ≥6 次且覆盖正文 ≥70%;真实短促呻吟 +(`ぇ`×15、`ああああああ`)靠时长区分,零误删。VAD 开关都会生效。 + 调研过程与结论见 [调研-whisper漏句与decode_full验证.md](./调研-whisper漏句与decode_full验证.md)。 ## 切换模型不改代码 diff --git a/nodes/subtitle_cleanup.py b/nodes/subtitle_cleanup.py index 894691d..f769c2e 100644 --- a/nodes/subtitle_cleanup.py +++ b/nodes/subtitle_cleanup.py @@ -10,7 +10,8 @@ ASR(whisper 无 VAD 解码)与 LLM 翻译在无语音段会输出固定套 - JAPANESE_HALLUCINATION_TOKENS:日文(whisper 无 VAD 解码直接输出的套话)。 删除只针对展示时长 ≥ 阈值的长条目:短时相同词可能是剧情真实台词(如真实 -互道晚安),必须保留。另外删除纯呻吟/喘息碎片(判据见 _is_pure_moan)。 +互道晚安),必须保留。另外删除纯呻吟/喘息碎片(判据见 _is_pure_moan)与 + whisper 窗口内重复循环造成的重复伪影(判据见 _is_repetition_artifact)。 纯函数,不修改输入。 """ @@ -63,6 +64,49 @@ MOAN_CHARS = frozenset( # /んふふ)有效假名 ≤3;>3(如ああああ)或含非呻吟字符的一律保留。 DEFAULT_MOAN_MAX_CHARS = 3 +# 重复伪影判据(whisper 窗口内重复循环会输出整条重复到 30 秒的 cue): +# 单元最长 6 字(更长的重复单元在真实数据里未见),至少重复 6 次, +# 且重复段占正文 ≥70%。真实呻吟重复次数也在 6–15 之间,靠时长区分。 +REPETITION_UNIT_MAX_CHARS = 6 +REPETITION_MIN_REPEATS = 6 +REPETITION_MIN_COVERAGE = 0.7 + + +def _longest_repeated_run(text: str, unit_max: int) -> tuple[str, int] | None: + """返回正文中最长的'同一单元连续重复'片段(原文, 重复次数)。 + + 单元长度 1..unit_max 由短到长尝试,取片段最长的一次;无重复返回 None。 + 注意用 finditer 而不是 re.search(..., pos):模块级 re.search 的第三个位置 + 参数是 flags,拿它当偏移会导致原地重搜、死循环。 + """ + best: tuple[str, int] | None = None + for size in range(1, unit_max + 1): + for match in re.finditer(rf"(.{{1,{size}}})\1+", text): + run = match.group(0) + if best is None or len(run) > len(best[0]): + best = (run, len(run) // len(match.group(1))) + return best + + +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 秒)是真实发声,靠 + 时长区分,与寒暄幻觉同一套“长条才删”思路。 + """ + if threshold_seconds <= 0 or duration < threshold_seconds: + 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: + return False + return len(run) / len(stripped) >= min_coverage + def _moan_chars(text: str) -> int: """返回 text 中'有效假名字符'数量(呻吟判据的一部分)。 @@ -141,6 +185,30 @@ def remove_short_moan_entries( return _remove_cues_by_predicate(srt_text, _keep) +def remove_repetition_entries( + srt_text: str, + threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS, + min_repeats: int = REPETITION_MIN_REPEATS, + min_coverage: float = REPETITION_MIN_COVERAGE, +) -> str: + """删除 SRT 中 whisper 重复循环造成的**整条重复伪影**(ASR 产物去噪)。 + + 模型在窗口内卡住重复时会把同一单元写满整条 cue(实测 30 秒、重复 74–446 + 次):这种正文会让下游 LLM 跟着重复、把预算耗在思考上而报结构错误,也会 + 直接渲染成超长字幕行,因此在产生处整条删除。判据见 _is_repetition_artifact: + 长条(时长 ≥ 阈值)+ 同一短单元高频重复且占满正文,真实短促呻吟不会命中。 + threshold_seconds ≤ 0 时关闭过滤。纯函数,不修改输入。 + """ + + def _keep(start_sec: float, end_sec: float, text: str) -> bool: + """保留判据:非重复伪影才保留。""" + return not _is_repetition_artifact( + text, end_sec - start_sec, threshold_seconds, min_repeats, min_coverage, + ) + + return _remove_cues_by_predicate(srt_text, _keep) + + def _remove_cues_by_predicate( srt_text: str, keep: callable, diff --git a/nodes/whisper.py b/nodes/whisper.py index bc61281..63be17b 100755 --- a/nodes/whisper.py +++ b/nodes/whisper.py @@ -300,24 +300,24 @@ def invoke(request: InvokeRequest) -> InvokeResponse: chunk_seconds / chunk_elapsed if chunk_elapsed > 0 else 0.0, time.monotonic() - transcribe_started, ) - # decode_full(无 VAD)的副作用清理:无语音段的长时寒暄幻觉与纯语气词 - # 碎片都是噪声,整条删除(序列号重排,不留 '-' 占位污染下游);判据与 - # 细节见 nodes/subtitle_cleanup.py。仅 decode_full 时启用。 - if decode_full: - from nodes.subtitle_cleanup import ( - clean_japanese_hallucinations, - remove_short_moan_entries, - ) + # 噪声清理:重复伪影(窗口内卡住重复,VAD 开关都会有)整条删除; + # decode_full 另加无语音段的长时寒暄幻觉与纯语气词碎片。都是整条删除、 + # 序号重排,不留 '-' 占位污染下游;判据见 nodes/subtitle_cleanup.py。 + from nodes.subtitle_cleanup import ( + clean_japanese_hallucinations, + remove_repetition_entries, + remove_short_moan_entries, + ) - body = clean_japanese_hallucinations("\n".join(lines)) + body = remove_repetition_entries("\n".join(lines)) + if decode_full: + body = clean_japanese_hallucinations(body) # short_moan_max_chars:有效假名 ≤ 该值的纯呻吟碎片整条删除, # 设 0 关闭(真实短对话不会命中,判据见 subtitle_cleanup)。 body = remove_short_moan_entries( body, max_chars=int(request.params.get("short_moan_max_chars", 3)), ) - else: - body = "\n".join(lines) output_path = output_dir / "transcript.srt" output_path.write_text(body, encoding="utf-8") return InvokeResponse(status="completed", outputs={"srt_uri": str(output_path)}) diff --git a/tests/nodes/test_subtitle_cleanup/data/repetition_cues.json b/tests/nodes/test_subtitle_cleanup/data/repetition_cues.json new file mode 100644 index 0000000..4f1eb0a --- /dev/null +++ b/tests/nodes/test_subtitle_cleanup/data/repetition_cues.json @@ -0,0 +1,53 @@ +{ + "artifacts": [ + { + "start": "00:10:00,064", + "end": "00:10:30,064", + "text": "チンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチンチ", + "repeats": 111, + "unit": "チン" + }, + { + "start": "00:36:29,999", + "end": "00:36:59,999", + "text": "ああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああああ", + "repeats": 446, + "unit": "あ" + }, + { + "start": "00:40:20,199", + "end": "00:40:50,199", + "text": "ハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハッハ", + "repeats": 111, + "unit": "ハッ" + } + ], + "moans": [ + { + "start": "00:01:28,832", + "end": "00:01:31,252", + "text": "しゅしゅしゅしゅしゅしゅしゅ", + "repeats": 7, + "unit": "しゅ" + }, + { + "start": "00:01:31,991", + "end": "00:01:33,991", + "text": "しゅしゅしゅしゅしゅしゅ", + "repeats": 6, + "unit": "しゅ" + }, + { + "start": "00:03:00,096", + "end": "00:03:02,276", + "text": "ウウウウウウウウウウ", + "repeats": 10, + "unit": "ウ" + } + ], + "normal": { + "start": "00:00:12,000", + "end": "00:00:15,000", + "text": "そんなにご褒美欲しかったの?" + } +} \ 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 e9e5e28..9b4365a 100644 --- a/tests/nodes/test_subtitle_cleanup/test_cleanup.py +++ b/tests/nodes/test_subtitle_cleanup/test_cleanup.py @@ -8,6 +8,9 @@ whisper(日语链路)与 llm-translate(中文链路)复用,纯函数 from __future__ import annotations +import json +from pathlib import Path + from nodes.subtitle_cleanup import ( DEFAULT_MOAN_MAX_CHARS, HALLUCINATION_TOKENS, @@ -15,6 +18,7 @@ from nodes.subtitle_cleanup import ( clean_japanese_hallucinations, clean_srt_text, remove_hallucination_entries, + remove_repetition_entries, remove_short_moan_entries, ) from tests.shared.srt_entries import parse_srt_entries @@ -246,3 +250,83 @@ def test_multiline_moan_entry_removed_as_one_cue() -> None: # 验证结果:只剩第二条并重编号。 assert [e["text"] for e in parse_srt_entries(cleaned)] == ["そこ"] assert cleaned.startswith("1\n") + + +# 真实转写抽样(data/repetition_cues.json):30 秒窗口被同一单元填满的 whisper +# 重复伪影 + 真实短呻吟 + 真实台词,用于"重复伪影"判据的正反例。 +_REPETITION_CUES = json.loads( + (Path(__file__).parent / "data" / "repetition_cues.json").read_text(encoding="utf-8") +) + + +def test_remove_repetition_entries_deletes_whisper_loops_only() -> None: + """数据:真实 ASR 产物——3 条 30 秒重复伪影(重复 74/111/446 次)、 + 3 条 2–3 秒真实呻吟(重复 6–10 次)、1 条正常台词。 + + 过程:调用 remove_repetition_entries 清理整份 SRT。 + + 验证:只删 30 秒伪影,真实呻吟与台词原样保留,剩余 cue 序号连续。 + """ + artifacts = _REPETITION_CUES["artifacts"] + moans = _REPETITION_CUES["moans"] + normal = _REPETITION_CUES["normal"] + srt = _srt( + *[(c["start"], c["end"], c["text"]) for c in artifacts], + *[(c["start"], c["end"], c["text"]) for c in moans], + (normal["start"], normal["end"], normal["text"]), + ) + + cleaned = remove_repetition_entries(srt) + + for cue in artifacts: + assert cue["text"] not in cleaned + for cue in moans: + assert cue["text"] in cleaned + assert normal["text"] in cleaned + entries = parse_srt_entries(cleaned) + assert [e["text"] for e in entries] == [c["text"] for c in moans] + [normal["text"]] + # 序号/时间轴重建后从 1 连续编号,不留空号(合法 SRT)。 + numbers = [line for line in cleaned.splitlines() if line.strip().isdigit()] + assert numbers == [str(i) for i in range(1, len(entries) + 1)] + + +def test_remove_repetition_entries_keeps_short_repeated_moan() -> None: + """数据:3 秒内重复 15 次的真实呻吟(时长不足阈值)。 + + 过程:调用 remove_repetition_entries。 + + 验证:保留——时长阈值是"窗口被填满"的判据,短促重复属真实发声。 + """ + srt = _srt(("00:00:01,000", "00:00:04,200", "ぇ" * 15)) + + cleaned = remove_repetition_entries(srt) + + assert "ぇ" * 15 in cleaned + + +def test_remove_repetition_entries_keeps_mixed_long_line() -> None: + """数据:30 秒长条但正文以正常台词为主,只有少量重复。 + + 过程:调用 remove_repetition_entries。 + + 验证:保留——重复片段未占正文 70% 以上,不构成重复伪影。 + """ + text = "そうですね、それでいいと思いますよ" * 3 + "ああ" + srt = _srt(("00:00:01,000", "00:00:31,000", text)) + + cleaned = remove_repetition_entries(srt) + + assert text in cleaned + + +def test_remove_repetition_entries_can_be_disabled() -> None: + """数据:一条 30 秒重复伪影,阈值设为 0(关闭)。 + + 过程:调用 remove_repetition_entries(threshold_seconds=0)。 + + 验证:原样返回,便于按需走旧行为。 + """ + artifact = _REPETITION_CUES["artifacts"][0] + srt = _srt((artifact["start"], artifact["end"], artifact["text"])) + + assert remove_repetition_entries(srt, threshold_seconds=0) == srt diff --git a/tests/nodes/test_whisper/test_transcribe.py b/tests/nodes/test_whisper/test_transcribe.py index a1efbed..e1f12c5 100644 --- a/tests/nodes/test_whisper/test_transcribe.py +++ b/tests/nodes/test_whisper/test_transcribe.py @@ -543,3 +543,25 @@ def test_real_whisper_transcribes_real_speech(tmp_path: Path) -> None: starts = [e["start"] for e in entries] assert starts == sorted(starts) assert max(starts) <= 62.0 + + +def test_invoke_drops_repetition_artifact_in_decode_full(tmp_path: Path, monkeypatch) -> None: + """decode_full 下删除 30 秒重复伪影:它会带着翻译层一起进重复循环。 + + 数据:假模型返回一段 30 秒窗口被同一单元填满的伪影 + 一条真实台词。 + 过程:调用 invoke(decode_full=True)。 + 验证:伪影整条删除、真实台词保留,产物里不再出现超长重复正文。 + """ + artifact = "チン" * 111 + model = FakeModel([ + FakeSegment(0.0, 30.0, artifact), + FakeSegment(30.0, 33.0, "そこ、だめ"), + ]) + _inject_model(monkeypatch, model) + + response = invoke(_request(tmp_path, SPEECH_WAV, chunk_seconds=0, decode_full=True)) + + assert response.status == "completed", response.error + content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8") + assert artifact not in content + assert "そこ、だめ" in content