Files
vrsub/tests/nodes/test_subtitle_cleanup/test_cleanup.py
T
cat-shark 4d2823c005 fix: 短时重复伪影也整条删除(判据补重复次数上限)
真实产物里出现 3.7 秒的 cue 被同一个假名填满 111 次(`は`×111),此前判据要求
"时长 ≥15 秒"才删,于是它漏到翻译层,最终变成 56 个"哈"进中文成品字幕。

- 判据改为:重复段占正文 ≥70% 且重复 ≥6 次,并且**时长 ≥15 秒或重复 ≥20 次**。
- 短条走"极端重复"档:真实呻吟的重复次数实测 ≤15(ぇ×15 占 3.2 秒),不会误删。
- 测试数据取自真实产物(A 的日语转写里那条 112 字短伪影)。
2026-09-18 23:41:32 +08:00

350 lines
13 KiB
Python

"""nodes/subtitle_cleanup.py 的模块级测试(数据 → 测试过程 → 验证结果)。
被测模块:`nodes/subtitle_cleanup.py`(幻觉整条删除 + 短呻吟过滤),被
whisper(日语链路)与 llm-translate(中文链路)复用,纯函数可独立调用。
每个用例构造真实 SRT 文本,调用真实清理函数,并解析输出验证时间轴与序号。
"""
from __future__ import annotations
import json
from pathlib import Path
from nodes.subtitle_cleanup import (
DEFAULT_MOAN_MAX_CHARS,
HALLUCINATION_TOKENS,
JAPANESE_HALLUCINATION_TOKENS,
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
def _srt(*cues: tuple[str, str, str]) -> str:
"""把 (起始, 结束, 文本) 列表拼成标准 SRT 文本,供各用例作为输入数据。"""
blocks = [
f"{i}\n{start} --> {end}\n{text}\n"
for i, (start, end, text) in enumerate(cues, 1)
]
return "\n".join(blocks)
# ---------------------------------------------------------------------------
# 幻觉整条删除(中文词表 / 日语词表)
# ---------------------------------------------------------------------------
def test_removes_long_hallucination_cue_entirely() -> None:
"""展示时长达到阈值的寒暄幻觉整条删除(时间轴不残留空 cue)。"""
# 数据:一条 20s 的"谢谢观看"(超过默认 15s 阈值)。
text = _srt(
("00:00:01,000", "00:00:02,000", "真实的对话"),
("00:00:03,000", "00:00:23,000", "谢谢观看"),
)
# 测试过程
cleaned = clean_srt_text(text)
# 验证结果:只剩真实对话,序号重排为 1,幻想的行完全消失。
entries = parse_srt_entries(cleaned)
assert [e["text"] for e in entries] == ["真实的对话"]
assert cleaned.startswith("1\n")
assert "谢谢观看" not in cleaned
def test_keeps_short_hallucination_when_inside_threshold() -> None:
"""展示时长低于阈值的相同词可能是剧情真实内容,必须保留。"""
# 数据:一条 2s 的"晚安"(剧情中真实互道晚安)。
text = _srt(("00:00:01,000", "00:00:03,000", "晚安"))
# 测试过程
cleaned = clean_srt_text(text)
# 验证结果:保留。
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["晚安"]
def test_keeps_non_hallucination_long_cue() -> None:
"""长时但不是幻觉词的内容必须保留(只按词表删除)。"""
# 数据:一条 30s 的正常长台词。
text = _srt(("00:00:01,000", "00:00:31,000", "这是一段很长的真实独白内容"))
# 测试过程
cleaned = clean_srt_text(text)
# 验证结果
assert "这是一段很长的真实独白内容" in cleaned
def test_threshold_boundary_is_inclusive() -> None:
"""阈值边界按"≥ 阈值"删除(严格等于阈值即删除)。"""
# 数据:恰好 15s 的幻觉条目与 14.999s 的同类条目。
text = _srt(
("00:00:00,000", "00:00:15,000", "谢谢观看"),
("00:00:16,000", "00:00:30,999", "感谢观看"),
)
# 测试过程
cleaned = clean_srt_text(text, threshold_seconds=15.0)
# 验证结果:15s 的被删除,14.999s 的保留。
kept = [e["text"] for e in parse_srt_entries(cleaned)]
assert kept == ["感谢观看"]
def test_resequences_after_middle_removal() -> None:
"""删除中间条目后剩余条目从 1 连续编号,保持合法 SRT。"""
# 数据:三条,中间一条是长时幻觉。
text = _srt(
("00:00:01,000", "00:00:02,000", "第一条"),
("00:00:03,000", "00:00:25,000", "谢谢观看"),
("00:00:26,000", "00:00:27,000", "第三条"),
)
# 测试过程
cleaned = clean_srt_text(text)
# 验证结果:序号连续且内容为第一、三条。
assert cleaned.splitlines()[0] == "1"
assert "\n2\n" in cleaned
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["第一条", "第三条"]
def test_japanese_hallucination_removed_and_short_kept() -> None:
"""日语词表:长时"おやすみなさい"删除,短时保留。"""
# 数据:一条 30s 日语幻觉 + 一条 3s 同词。
text = _srt(
("00:01:00,000", "00:01:30,000", "おやすみなさい"),
("00:02:00,000", "00:02:03,000", "おやすみなさい"),
)
# 测试过程
cleaned = clean_japanese_hallucinations(text)
# 验证结果:只保留短的那条。
entries = parse_srt_entries(cleaned)
assert len(entries) == 1
assert entries[0]["start"] == 120.0
def test_custom_token_list_is_honored() -> None:
"""自定义词表生效:只删除传入词命中的条目。"""
# 数据:两个不同的长时条目。
text = _srt(
("00:00:01,000", "00:00:20,000", "自定义幻觉词"),
("00:00:21,000", "00:00:40,000", "谢谢观看"),
)
# 测试过程:只传"自定义幻觉词"。
cleaned = remove_hallucination_entries(text, ("自定义幻觉词",))
# 验证结果:只删除自定义词,"谢谢观看"保留。
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["谢谢观看"]
def test_default_tables_are_not_empty() -> None:
"""两张默认词表都非空(防止重构时误清空导致清理失效)。"""
# 数据:模块导出的词表常量。
# 测试过程与验证结果
assert len(HALLUCINATION_TOKENS) > 0
assert len(JAPANESE_HALLUCINATION_TOKENS) > 0
# ---------------------------------------------------------------------------
# 短呻吟过滤(decode_full 去噪)
# ---------------------------------------------------------------------------
def test_removes_pure_moan_fragments() -> None:
"""纯呻吟碎片(あ…/ん?/はぁ…)整条删除。"""
# 数据:三条纯呻吟与一条真实短对话。
text = _srt(
("00:00:01,000", "00:00:02,000", "あ…"),
("00:00:03,000", "00:00:04,000", "ん?"),
("00:00:05,000", "00:00:06,000", "はぁ…"),
("00:00:07,000", "00:00:09,000", "そこ、だめ"),
)
# 测试过程
cleaned = remove_short_moan_entries(text)
# 验证结果:只剩真实短对话。
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["そこ、だめ"]
def test_keeps_real_short_dialogue() -> None:
"""含真实假名(そ/や/ね)的短对话不命中判据,必须保留。"""
# 数据:四条真实短对话。
dialog = ["そこ", "やばい", "ねえ", "やだ"]
# 测试过程
cleaned = remove_short_moan_entries(_srt(*[
(f"00:00:0{i},000", f"00:00:0{i + 1},000", text)
for i, text in enumerate(dialog, 1)
]))
# 验证结果:全部保留。
assert [e["text"] for e in parse_srt_entries(cleaned)] == dialog
def test_moan_threshold_boundary() -> None:
"""有效假名数超过阈值(默认 3)的纯呻吟串保留,等于阈值的删除。"""
# 数据:3 个假名(删除)与 4 个假名(保留)。
text = _srt(
("00:00:01,000", "00:00:02,000", "あんあ"),
("00:00:03,000", "00:00:04,000", "あんあん"),
)
# 测试过程
cleaned = remove_short_moan_entries(text, max_chars=3)
# 验证结果
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["あんあん"]
def test_moan_default_max_chars_constant() -> None:
"""默认阈值常量为 3(与文档约定一致,改动需同步文档)。"""
# 数据:模块常量。
# 测试过程与验证结果
assert DEFAULT_MOAN_MAX_CHARS == 3
def test_moan_filter_can_be_disabled() -> None:
"""max_chars=0 时关闭过滤,输入原样返回。"""
# 数据:一条纯呻吟。
text = _srt(("00:00:01,000", "00:00:02,000", "あ…"))
# 测试过程与验证结果
assert remove_short_moan_entries(text, max_chars=0) == text
def test_moan_removal_in_middle_resequences() -> None:
"""删除中间呻吟后剩余条目序号连续。"""
# 数据:真实对话、呻吟、真实对话。
text = _srt(
("00:00:01,000", "00:00:02,000", "行くよ"),
("00:00:03,000", "00:00:04,000", "ん…"),
("00:00:05,000", "00:00:06,000", "だめ"),
)
# 测试过程
cleaned = remove_short_moan_entries(text)
# 验证结果
assert [e["text"] for e in parse_srt_entries(cleaned)] == ["行くよ", "だめ"]
assert "\n2\n" in cleaned
def test_multiline_moan_entry_removed_as_one_cue() -> None:
"""多行呻吟条目整体作为一条 cue 删除(不残留半条)。"""
# 数据:两行纯呻吟组成一条 cue。
text = "1\n00:00:01,000 --> 00:00:03,000\nあ…\nん…\n\n2\n00:00:04,000 --> 00:00:05,000\nそこ\n"
# 测试过程
cleaned = remove_short_moan_entries(text)
# 验证结果:只剩第二条并重编号。
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
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) == []