feat: whisper 新增 decode_full 无VAD整段解码参数并改为整条删除式幻觉清洗

解决转写漏句(有人说话但没识别出来)问题:silero VAD 对呻吟/轻语/BGM
混叠声学切段能力天然不足,把真话当非语音剔除(实测 savr-1054 全片仅
召回 115 条)。新增 decode_full 参数(默认 false 保持 VAD 现状):

- decode_full=true 时强制无 VAD 整段解码 + 跳过自动 VAD 分析,救回被
  剔除的弱语音(savr-1054 全片 115 条 → 340 条)
- 副作用是长时寒暄套话幻觉(おやすみなさい/ご視聴ありがとうございま
  した 等),whisper 转录后连带时间戳整条删除(clean_japanese_ha
  lllucinations),不留下 '-' 占位污染下游(占位会渲染进 ASS 成减号)
- llm-translate 翻译后同样整条删除中文长时寒暄幻觉(clean_srt_text)
- 短时(≤15s)相同词可能是剧情真实道晚安,保留(15s 阈值实测校准)
- subtitle_cleanup 由 '-' 占位式改为整条删除式 + 剩余重编号,新增
  JAPANESE_HALLUCINATION_TOKENS 词表

新增工作流 learn-translate(学习资料转译+翻译字幕)示范 decode_full
用法,并确立参数标注约定:params._note_<参数名> 存放设定理由与正反例、
_node_help 放节点参数手册(_ 前缀说明键,节点执行时忽略,零运行影响)。

调研记录见 docs/调研-whisper漏句与decode_full验证.md(A/B 实验、结论
修正与 5 个待决问题)。
This commit is contained in:
2026-09-07 17:21:04 +08:00
parent c063aed1f1
commit a8fe133aa4
10 changed files with 695 additions and 106 deletions
+130 -52
View File
@@ -1,4 +1,4 @@
"""长时寒暄幻觉清洗测试(先红后绿)。
"""字幕幻觉清洗测试(先红后绿)。
背景(实测 run 20260905115050):修复时间对齐后,字幕仍残留四类问题,
其中"寒暄/收尾幻觉词"最具确定性、可规则化:
@@ -9,12 +9,18 @@
- 仅 2 条时长 ~2s(如 720.00-722.00 '晚安')可能是剧情里真的说了"晚安"
属于真实内容,不应误删。
方案(用户确认):日文转译完成后,对**展示时长过长**(≥阈值)且文本匹配
寒暄词表的条目,把文本替换为 '-' 占位,由后续处理(SRT/过滤流程)移除。
这样既清掉幻觉占位,又用"时长阈值"保住可能为真实对话的短时寒暄词。
方案(用户 2026-09 确认改为**整条剔除**):幻觉识别出后(展示时长 ≥ 阈值
且文本命中寒暄词表),应**连带时间戳把整条字幕 cue 删除**(剩余重新编号),
而不是替换成 '-' 占位——占位会一路流到 ASS 渲染成可见减号,处理位置绕且
不彻底。因此清洗统一为在幻觉产生处(whisper decode_full 转录后 / LLM 翻译
后)直接删除整条。
两类词表:
- HALLUCINATION_TOKENS:中文(LLM 翻译产物);
- JAPANESE_HALLUCINATION_TOKENS:日文(whisper decode_full 直出)。
阈值从本次真实运行实测数据判定:30s 幻觉占位 vs 2s 真实词,分界明显,
本测试选 threshold=15s>15s 才视为幻觉;≤15s 保留)。
本测试选 threshold=15s15s 才删除;≤15s 保留)。
"""
from __future__ import annotations
@@ -25,63 +31,135 @@ import pytest
from nodes.subtitle_cleanup import (
DEFAULT_THRESHOLD_SECONDS,
HALLUCINATION_TOKENS,
mask_hallucination_text,
clean_japanese_hallucinations,
clean_srt_text,
)
def _mk(start: float, end: float, text: str) -> dict:
"""构造一个字幕条目(测试辅助)"""
return {"start": start, "end": end, "text": text}
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_long_hallucination_masked() -> None:
"""30s 的'晚安/感谢观看'幻觉占位)必须被替换为 '-'"""
entries = [
_mk(60.0, 90.0, "晚安"),
_mk(90.0, 120.0, "感谢您的观看"),
_mk(1260.0, 1289.98, "感谢您的观看"),
]
out = mask_hallucination_text(entries)
assert all(e["text"] == "-" for e 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_short_hallucination_preserved() -> None:
"""2s 的'晚安'(可能为剧情真实对话)必须保留,不误删。"""
entries = [
_mk(720.0, 722.0, "晚安"),
_mk(238.0, 240.0, "非常感谢您的观看。"),
]
out = mask_hallucination_text(entries)
assert out[0]["text"] == "晚安"
assert out[1]["text"] == "非常感谢您的观看。"
def test_non_hallucination_always_preserved() -> None:
def test_remove_non_hallucination_always_preserved() -> None:
"""普通内容(即使很长)绝不能被当成寒暄幻觉处理。"""
entries = [
_mk(0.0, 30.0, "今天我将为您提供精神调适服务"),
_mk(10.0, 40.0, "请尽量放松,无论多少次都能感到舒适愉悦"),
]
out = mask_hallucination_text(entries)
assert out[0]["text"] == "今天我将为您提供精神调适服务"
assert out[1]["text"] == "请尽量放松,无论多少次都能感到舒适愉悦"
srt = (
"1\n00:00:00,000 --> 00:00:25,000\n"
"今天我将为您提供精神调适服务\n\n"
)
out = clean_srt_text(srt)
assert "精神调适" in out
def test_threshold_boundary() -> None:
"""阈值边界:好 ≥ 阈值才清洗< 阈值保留。"""
entries = [
_mk(0.0, 15.0, "晚安"), # 恰好 15s → 清洗(≥ threshold
_mk(0.0, 14.99, "晚安"), # 14.99s → 保留
]
out = mask_hallucination_text(entries, threshold_seconds=15.0)
assert out[0]["text"] == "-"
assert out[1]["text"] == "晚安"
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_mask_does_not_mutate_input() -> None:
"""清洗不得修改原始条目对象(纯函数约束)"""
entries = [_mk(60.0, 90.0, "晚安")]
original_text = entries[0]["text"]
mask_hallucination_text(entries)
assert entries[0]["text"] == original_text
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