fix: whisper 重复伪影整条删除,避免翻译层空响应失败

whisper 在单个窗口内卡住重复时会把一个单元写满整条 cue(实测 30 秒整、重复
74–446 次,如 `チン`×111)。这类正文会让下游 LLM 跟着循环、把输出预算耗在思考上,
最终 `content` 返回空串并报 `translation alignment failed … Expecting value:
line 1 column 1 (char 0)`;它也可能直接渲染成超长字幕行。

- `nodes/subtitle_cleanup.py` 新增 `remove_repetition_entries`:**纯模式判据、无字符
  词表**——展示时长 ≥15s 且同一 1–6 字单元连续重复 ≥6 次且覆盖正文 ≥70% 的 cue 整条
  删除;真实短促呻吟(`ぇ`×15、`ああああああ`)靠时长区分,零误删。
- `nodes/whisper.py` 在转写产出处应用该清洗(VAD 开关都生效,它与套话幻觉无关)。
- 真实数据验证:本地全部真实转写 3605 条 cue 只删 5 条 30 秒伪影;对照实验同批次
  伪影截短后 5/5 成功、原样 1/5。
This commit is contained in:
2026-09-18 22:23:25 +08:00
parent f656ec98c5
commit 3f4478523e
6 changed files with 247 additions and 12 deletions
@@ -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": "そんなにご褒美欲しかったの?"
}
}
@@ -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
@@ -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 秒窗口被同一单元填满的伪影 + 一条真实台词。
过程:调用 invokedecode_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