263 lines
12 KiB
Python
263 lines
12 KiB
Python
"""字幕幻觉清洗测试(先红后绿)。
|
|
|
|
背景(实测 run 20260905115050):修复时间对齐后,字幕仍残留四类问题,
|
|
其中"寒暄/收尾幻觉词"最具确定性、可规则化:
|
|
|
|
- 产物里 '晚安 / 感谢观看 / 感谢收看 / 感谢您的观看' 等固定套话出现 36 次;
|
|
- 其中 **33 条展示时长 = 30s(整块占满)**,明显是 ASR/LLM 对无内容段
|
|
的音量幻觉占位,与视频内容毫无关系;
|
|
- 仅 2 条时长 ~2s(如 720.00-722.00 '晚安')可能是剧情里真的说了"晚安",
|
|
属于真实内容,不应误删。
|
|
|
|
方案(用户 2026-09 确认改为**整条剔除**):幻觉识别出后(展示时长 ≥ 阈值
|
|
且文本命中寒暄词表),应**连带时间戳把整条字幕 cue 删除**(剩余重新编号),
|
|
而不是替换成 '-' 占位——占位会一路流到 ASS 渲染成可见减号,处理位置绕且
|
|
不彻底。因此清洗统一为在幻觉产生处(whisper decode_full 转录后 / LLM 翻译
|
|
后)直接删除整条。
|
|
|
|
两类词表:
|
|
- HALLUCINATION_TOKENS:中文(LLM 翻译产物);
|
|
- JAPANESE_HALLUCINATION_TOKENS:日文(whisper decode_full 直出)。
|
|
|
|
阈值从本次真实运行实测数据判定:30s 幻觉占位 vs 2s 真实词,分界明显,
|
|
本测试选 threshold=15s(≥15s 才删除;≤15s 保留)。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
# 清洗逻辑来自生产模块 nodes/subtitle_cleanup.py(纯函数),测试只 import。
|
|
from nodes.subtitle_cleanup import (
|
|
DEFAULT_THRESHOLD_SECONDS,
|
|
HALLUCINATION_TOKENS,
|
|
clean_japanese_hallucinations,
|
|
clean_srt_text,
|
|
remove_short_moan_entries,
|
|
)
|
|
|
|
|
|
def test_remove_long_hallucination_whole_cue() -> None:
|
|
"""30s 的'晚安/感谢观看'(幻觉占位)须整条删除:序号+时间轴+文本都消失。"""
|
|
srt = (
|
|
"1\n00:00:00,000 --> 00:00:02,000\n真实内容\n\n"
|
|
"2\n00:01:00,000 --> 00:01:30,000\n晚安\n\n"
|
|
"3\n00:02:00,000 --> 00:02:30,000\n感谢您的观看\n\n"
|
|
"4\n00:03:00,000 --> 00:03:02,000\n继续真实\n\n"
|
|
)
|
|
out = clean_srt_text(srt)
|
|
# 幻觉条目连带时间戳整条消失。
|
|
assert "00:01:00,000 --> 00:01:30,000" not in out
|
|
assert "晚安" not in out
|
|
assert "00:02:00,000 --> 00:02:30,000" not in out
|
|
assert "感谢您的观看" not in out
|
|
# 真实条目保留且序号重新连续编号(原 1、4 -> 新 1、2)。
|
|
assert out.startswith("1\n00:00:00,000 --> 00:00:02,000\n真实内容")
|
|
assert "2\n00:03:00,000 --> 00:03:02,000\n继续真实" in out
|
|
|
|
|
|
def test_remove_short_hallucination_preserved() -> None:
|
|
"""2s 的'晚安'(剧情真实道晚安)必须保留,不误删。"""
|
|
srt = (
|
|
"1\n00:12:00,000 --> 00:12:02,000\n晚安\n\n"
|
|
"2\n00:12:03,000 --> 00:12:06,000\n明天见\n\n"
|
|
)
|
|
out = clean_srt_text(srt)
|
|
assert "晚安" in out
|
|
assert "明天见" in out
|
|
assert out.count("-->") == 2
|
|
|
|
|
|
def test_remove_non_hallucination_always_preserved() -> None:
|
|
"""普通内容(即使很长)绝不能被当成寒暄幻觉处理。"""
|
|
srt = (
|
|
"1\n00:00:00,000 --> 00:00:25,000\n"
|
|
"今天我将为您提供精神调适服务\n\n"
|
|
)
|
|
out = clean_srt_text(srt)
|
|
assert "精神调适" in out
|
|
|
|
|
|
def test_remove_threshold_boundary() -> None:
|
|
"""阈值边界:恰好 ≥ 阈值才删除;< 阈值保留。"""
|
|
srt_ge = "1\n00:00:00,000 --> 00:00:15,000\n晚安\n\n"
|
|
assert "晚安" not in clean_srt_text(srt_ge)
|
|
srt_lt = "1\n00:00:00,000 --> 00:00:14,990\n晚安\n\n"
|
|
assert "晚安" in clean_srt_text(srt_lt)
|
|
|
|
|
|
def test_remove_resequences_numbers() -> None:
|
|
"""删除中间 cue 后,剩余条目序号从 1 连续递增(合法 SRT)。"""
|
|
srt = (
|
|
"1\n00:00:00,000 --> 00:00:01,000\n甲\n\n"
|
|
"2\n00:01:00,000 --> 00:01:30,000\n晚安\n\n" # 幻觉被删
|
|
"3\n00:02:00,000 --> 00:02:01,000\n乙\n\n"
|
|
"4\n00:03:00,000 --> 00:03:01,000\n丙\n\n"
|
|
)
|
|
out = clean_srt_text(srt)
|
|
lines = [l for l in out.splitlines() if l.strip()]
|
|
# 重新编号:序号应为 1,2,3 各一次。
|
|
import re
|
|
numbers = [int(l) for l in lines if re.fullmatch(r"\d+", l.strip())]
|
|
assert numbers == [1, 2, 3]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 日语(ASR 直出)幻觉清洗 —— whisper 节点 decode_full 兜底用
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def test_jp_long_hallucination_removed() -> None:
|
|
"""30s 的'おやすみなさい/ご視聴ありがとうございました'(无语音段幻觉)整条删除。"""
|
|
srt = (
|
|
"1\n00:00:00,000 --> 00:00:02,000\n気持ちいい\n\n"
|
|
"2\n00:00:10,000 --> 00:00:40,000\nおやすみなさい\n\n"
|
|
"3\n00:00:41,000 --> 00:01:11,000\nご視聴ありがとうございました\n\n"
|
|
"4\n00:01:12,000 --> 00:01:14,000\nまた明日ね\n\n"
|
|
)
|
|
out = clean_japanese_hallucinations(srt)
|
|
assert "おやすみなさい" not in out
|
|
assert "ご視聴ありがとうございました" not in out
|
|
assert "00:00:10,000 --> 00:00:40,000" not in out
|
|
assert "気持ちいい" in out
|
|
assert "また明日ね" in out
|
|
|
|
|
|
def test_jp_short_hallucination_preserved() -> None:
|
|
"""2s 的'おやすみなさい'(剧情真实道晚安)须保留,不误删。"""
|
|
srt = (
|
|
"1\n00:12:00,000 --> 00:12:02,000\nおやすみなさい\n\n"
|
|
"2\n00:12:03,000 --> 00:12:06,000\nまた明日ね\n\n"
|
|
)
|
|
out = clean_japanese_hallucinations(srt)
|
|
assert "おやすみなさい" in out
|
|
assert "また明日ね" in out
|
|
|
|
|
|
def test_jp_non_hallucination_always_preserved() -> None:
|
|
"""普通长句(即使很长)绝不能被当成日语幻觉处理。"""
|
|
srt = (
|
|
"1\n00:00:00,000 --> 00:00:25,000\n"
|
|
"今日はお客様のために精神整備を務めさせていただきます\n\n"
|
|
)
|
|
out = clean_japanese_hallucinations(srt)
|
|
assert "精神整備" in out
|
|
|
|
|
|
def test_jp_threshold_15s() -> None:
|
|
"""日语清洗同样遵守 15s 时长阈值:15s 恰好删除,14.99s 保留。"""
|
|
srt = "1\n00:00:00,000 --> 00:00:15,000\nおやすみなさい\n\n"
|
|
assert "おやすみなさい" not in clean_japanese_hallucinations(srt)
|
|
srt2 = "1\n00:00:00,000 --> 00:00:14,990\nおやすみなさい\n\n"
|
|
assert "おやすみなさい" in clean_japanese_hallucinations(srt2)
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_jp_middle_removal_resequences() -> None:
|
|
"""删除中间日语幻觉后剩余条目重编号且文本/时间正确对应。"""
|
|
srt = (
|
|
"1\n00:00:00,000 --> 00:00:02,000\nあ\n\n"
|
|
"2\n00:00:10,000 --> 00:00:40,000\nおやすみなさい\n\n" # 删
|
|
"3\n00:00:41,000 --> 00:00:43,000\nい\n\n"
|
|
)
|
|
out = clean_japanese_hallucinations(srt)
|
|
assert out.count("-->") == 2
|
|
assert out.startswith("1\n00:00:00,000 --> 00:00:02,000\nあ")
|
|
assert "2\n00:00:41,000 --> 00:00:43,000\nい" in out
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 短呻吟/喘息碎片过滤(decode_full 救回弱语音的去噪)—— user 2026-09 决策
|
|
# ---------------------------------------------------------------------------
|
|
#
|
|
# 背景实测(savr-1054-2 前 600s):decode_full 无 VAD 解码会把呻吟/BGM 混叠
|
|
# 的弱语音也救回来,但其中含大量**纯语气词碎片**(あ…/ん?/はぁ…/あ!あ!/んふふ
|
|
# 等,有效假名 ≤3 个),这类内容放进字幕是噪声,影响观看。真实数据对照:
|
|
# - 要删(纯呻吟/喘息):あ、ん、ん?、あ〜、あ…、はぁ…、あぁ…、あ!あ!、
|
|
# ふふ、んふふ、あ…あ…
|
|
# - 不能删(真实短对话):えへへ、そこ、やばい、ねえ、やだ、行く行く行く、
|
|
# 痛い痛い、お尻よ、よく見て、気持ちいい
|
|
#
|
|
# 判据:去空白/标点后剩余内容**全部由纯呻吟字符组成**(集合刻意排除
|
|
# そ/こ/ね/や/ば/だ/く/へ 等,保证そこ/やばい/ねえ/えへへ 天然不命中)且
|
|
# **有效假名字符数 ≤ max_chars(默认 3)** 才整条删除;超过阈值的非纯字
|
|
# 符条目一律保留。仅 decode_full=true 时由 whisper 节点调用(用户决策)。
|
|
|
|
|
|
def test_remove_short_moan_pure_moans_deleted() -> None:
|
|
"""纯呻吟/喘息碎片(あ/ん/ん?/あ〜/はぁ…/あ!あ!/んふふ)整条删除。"""
|
|
srt = (
|
|
"1\n00:00:00,000 --> 00:00:02,000\n気持ちいい\n\n"
|
|
"2\n00:00:10,000 --> 00:00:11,000\nあ\n\n" # 删
|
|
"3\n00:00:12,000 --> 00:00:13,000\nん?\n\n" # 删
|
|
"4\n00:00:14,000 --> 00:00:15,000\nあ〜\n\n" # 删
|
|
"5\n00:00:16,000 --> 00:00:17,000\nはぁ…\n\n" # 删
|
|
"6\n00:00:18,000 --> 00:00:19,000\nあ!あ!\n\n" # 删
|
|
"7\n00:00:20,000 --> 00:00:21,000\nんふふ\n\n" # 删
|
|
)
|
|
out = remove_short_moan_entries(srt)
|
|
for frag in ("あ\n", "ん?", "あ〜", "はぁ", "あ!あ!", "んふふ"):
|
|
assert frag not in out
|
|
# 真实内容保留、序号重编号为 1。
|
|
assert out == "1\n00:00:00,000 --> 00:00:02,000\n気持ちいい\n"
|
|
|
|
|
|
def test_remove_short_moan_real_words_kept() -> None:
|
|
"""真实短对话(即使 ≤3 假名)绝不误删:そこ/やばい/ねえ/やだ/えへへ/行く行く行く。"""
|
|
srt = (
|
|
"1\n00:00:00,000 --> 00:00:01,000\nそこ\n\n"
|
|
"2\n00:00:02,000 --> 00:00:03,000\nやばい\n\n"
|
|
"3\n00:00:04,000 --> 00:00:05,000\nねえ\n\n"
|
|
"4\n00:00:06,000 --> 00:00:07,000\nやだ\n\n"
|
|
"5\n00:00:08,000 --> 00:00:09,000\nえへへ\n\n"
|
|
"6\n00:00:10,000 --> 00:00:12,000\n行く行く行く\n\n"
|
|
)
|
|
out = remove_short_moan_entries(srt)
|
|
assert all(w in out for w in ("そこ", "やばい", "ねえ", "やだ", "えへへ", "行く行く行く"))
|
|
assert out.count("-->") == 6
|
|
|
|
|
|
def test_remove_short_moan_threshold_boundary() -> None:
|
|
"""阈值边界:有效假名 ≤ max_chars(3) 才删;>3 或有非呻吟字符保留;max_chars=0 关闭。"""
|
|
# 4 个あ(>3)超出阈值 → 保留;3 个あ(=3)→ 删。
|
|
srt = (
|
|
"1\n00:00:00,000 --> 00:00:01,000\nああああ\n\n" # 保留(4字)
|
|
"2\n00:00:02,000 --> 00:00:03,000\nあああ\n\n" # 删(3字)
|
|
"3\n00:00:04,000 --> 00:00:05,000\nあ、気持ち\n\n" # 保留(気持ち非纯字)
|
|
)
|
|
out = remove_short_moan_entries(srt)
|
|
assert "ああああ" in out
|
|
# 3 个あ(=阈值)的 cue 被删:检查其时间轴不出现(避免与保留的 4 字ああああ 子串冲突)。
|
|
assert "00:00:02,000 --> 00:00:03,000" not in out
|
|
assert "気持ち" in out
|
|
assert out.count("-->") == 2
|
|
# max_chars=0 关闭过滤:什么都不删。
|
|
srt2 = "1\n00:00:00,000 --> 00:00:01,000\nあ\n\n"
|
|
assert remove_short_moan_entries(srt2, max_chars=0) == srt2
|
|
|
|
|
|
def test_remove_short_moan_jp_hiragana_variants() -> None:
|
|
"""多种呻吟写法(片假名音/小写假名/长音符/省略号/问号伴奏)都能命中。"""
|
|
srt = (
|
|
"1\n00:00:00,000 --> 00:00:01,000\nア\n\n" # 片假名あ
|
|
"2\n00:00:02,000 --> 00:00:03,000\nうぅ…\n\n" # 小写ぅ
|
|
"3\n00:00:04,000 --> 00:00:05,000\nあぁ〜\n\n" # 长音符
|
|
"4\n00:00:06,000 --> 00:00:07,000\nんー\n\n" # 长音ー
|
|
"5\n00:00:08,000 --> 00:00:09,000\nあ あ\n\n" # 带空格
|
|
)
|
|
out = remove_short_moan_entries(srt)
|
|
assert out.count("-->") == 0
|
|
|
|
|
|
def test_remove_short_moan_multiline_and_resequence() -> None:
|
|
"""多行文本条目与删除后重编号(序号连续、时间正确对应)。"""
|
|
srt = (
|
|
"1\n00:00:00,000 --> 00:00:02,000\n気持ちいい\nね\n\n"
|
|
"2\n00:00:10,000 --> 00:00:11,000\nん〜\n\n" # 删
|
|
"3\n00:00:12,000 --> 00:00:15,000\nやばい\n\n"
|
|
)
|
|
out = remove_short_moan_entries(srt)
|
|
assert out.count("-->") == 2
|
|
assert out.startswith("1\n00:00:00,000 --> 00:00:02,000\n気持ちいい\nね")
|
|
assert "2\n00:00:12,000 --> 00:00:15,000\nやばい" in out
|