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。
278 lines
13 KiB
Python
278 lines
13 KiB
Python
"""字幕清洗:删除寒暄幻觉与纯呻吟碎片。
|
||
|
||
ASR(whisper 无 VAD 解码)与 LLM 翻译在无语音段会输出固定套话(如“晚安/
|
||
感谢观看”/“おやすみなさい”),且在翻译产物里反复出现。处理方式为**连带
|
||
时间戳整条删除** cue 并重新编号:不能改成 '-' 占位,否则会一路渲染成可见
|
||
减号,并在翻译/过滤阶段需要额外特判。
|
||
|
||
两类词表:
|
||
- HALLUCINATION_TOKENS:中文(LLM 翻译产物中的寒暄);
|
||
- JAPANESE_HALLUCINATION_TOKENS:日文(whisper 无 VAD 解码直接输出的套话)。
|
||
|
||
删除只针对展示时长 ≥ 阈值的长条目:短时相同词可能是剧情真实台词(如真实
|
||
互道晚安),必须保留。另外删除纯呻吟/喘息碎片(判据见 _is_pure_moan)与
|
||
whisper 窗口内重复循环造成的重复伪影(判据见 _is_repetition_artifact)。
|
||
|
||
纯函数,不修改输入。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
# Greeting/closing hallucination tokens that LLM repeats on empty/end segments.
|
||
HALLUCINATION_TOKENS = (
|
||
"谢谢观看", "感谢观看", "感谢收看", "谢谢收看", "感谢您的观看", "感谢您的收看",
|
||
"晚安", "下次再见", "再会", "敬请期待", "感谢您的光临", "欢迎光临",
|
||
"再见", "多谢观看", "观看愉快",
|
||
)
|
||
|
||
# Display-duration threshold (seconds): only remove entries longer than this.
|
||
DEFAULT_THRESHOLD_SECONDS = 15.0
|
||
|
||
# 日文套话词表:whisper 无 VAD 解码会把无语音/音乐段当语音,从而重复输出
|
||
# 这些寒暄;短时相同词可能是剧情真实台词,靠时长阈值区分(同中文表机制)。
|
||
JAPANESE_HALLUCINATION_TOKENS = (
|
||
"おやすみなさい", # 晚安
|
||
"ご視聴ありがとうございました", # 感谢观看
|
||
"ありがとうございました", # 感谢
|
||
"ご視聴ありがとうございます", # 感谢观看(现在时)
|
||
"また見てね", # 下次再见
|
||
"お楽しみに", # 敬请期待
|
||
"チャンネル登録よろしくお願いします", # 求订阅
|
||
"さようなら", # 再见
|
||
"音楽", # 音乐(自述)
|
||
"Goodbye",
|
||
"Thank you for watching",
|
||
)
|
||
|
||
# 纯呻吟/喘息字符集合:文本(去空白/标点)全部由本集合字符组成、且有效假名数
|
||
# ≤ 阈值时才判为噪声删除。集合**刻意排除** そ/こ/ね/や/ば/だ/く/へ 等假名——
|
||
# 真实短对话(そこ/やばい/ねえ/やだ/えへへ)都含这些字符,因此天然不命中,从
|
||
# 判据根源上避免误删真实短句。
|
||
MOAN_CHARS = frozenset(
|
||
# 平假名元音与ん/ふ/は(呻吟与喘息气流音的主干)
|
||
"あいうえおんふはっ"
|
||
# 小写假名(ぁぃぅぇぉ)与片假名对应(アィゥェォ、ン)
|
||
"ぁぃぅぇぉアィゥェォン"
|
||
# 长音符/省略号/半浊音(ー〜…、…)与空白、标点(呻吟常带这些装饰)
|
||
"ー〜…\u2026。、!??!、"
|
||
" \t"
|
||
)
|
||
|
||
# 短呻吟过滤的默认有效假名上限:真实呻吟/喘息碎片(あ/ん/ん?/はぁ…/あ!あ!
|
||
# /んふふ)有效假名 ≤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 中'有效假名字符'数量(呻吟判据的一部分)。
|
||
|
||
只统计假名(片/平)与发音健全字符,空白/标点/长音符/省略号不计入,
|
||
这样'あ…'/'ん?'/'あ〜' 的有效字符都是 1 个。
|
||
"""
|
||
return sum(ch in "あいうえおんふはっぁぃぅぇぉアィゥェォン" for ch in text)
|
||
|
||
|
||
def _is_pure_moan(text: str, max_chars: int) -> bool:
|
||
"""判断一条字幕文本是否为'纯呻吟/喘息碎片'(整条删除判据)。
|
||
|
||
两个条件同时满足才返回 True:
|
||
1. 去除空白/标点后剩余字符**全部** ∈ MOAN_CHARS(即整个文本只能由呻吟
|
||
字符、标点、空白组成,不允许出现そ/こ/ね/や/ば 等真实词假名);
|
||
2. 有效假名字符数 ≤ max_chars(超过阈值即使是纯呻吟长串也不删)。
|
||
"""
|
||
chars = [c for c in text if not c.isspace()]
|
||
# 全部字符必须都在呻吟字符集合中(含标点/长音符)。
|
||
if not chars or any(c not in MOAN_CHARS for c in chars):
|
||
return False
|
||
return _moan_chars(text) <= max_chars
|
||
|
||
# 解析 SRT:每个 cue 由 序号行 + 时间轴行 + 文本行(可能多行) + 空行 组成。
|
||
# 采用逐行解析(不依赖可能粘连的跨 cue 正则),兼容文本多行。
|
||
_TS_RE = re.compile(r"^(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*$")
|
||
|
||
|
||
def _ts_to_seconds(ts: str) -> float:
|
||
"""Convert an SRT timestamp HH:MM:SS,mmm to seconds (float)."""
|
||
hours, minutes, rest = ts.split(":")
|
||
seconds, millis = rest.split(",")
|
||
return int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(millis) / 1000
|
||
|
||
|
||
def remove_hallucination_entries(
|
||
srt_text: str,
|
||
tokens: tuple[str, ...],
|
||
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
|
||
) -> str:
|
||
"""删除 SRT 中展示时长 ≥ 阈值且文本命中 tokens 的**整条 cue**(连带时间戳)。
|
||
|
||
被删除的 cue 不再输出(序号、时间轴、文本全部消失),剩余 cue 按原顺序
|
||
重新从 1 编号,保证产物是合法连续的 SRT。输入不被修改(纯函数)。
|
||
|
||
用途:幻觉在产生处直接剔除——whisper decode_full(日语词表)与 LLM 翻译后
|
||
(中文词表)均调用本函数,避免 '-' 占位一路流到 ASS 渲染成可见减号。
|
||
内部委托 _remove_cues_by_predicate,与短呻吟过滤共用同一套 SRT 解析/重建。
|
||
"""
|
||
|
||
def _keep(start_sec: float, end_sec: float, text: str) -> bool:
|
||
"""保留判据:不命中寒暄幻觉才保留。"""
|
||
duration = end_sec - start_sec
|
||
return not (duration >= threshold_seconds and any(t in text for t in tokens))
|
||
|
||
return _remove_cues_by_predicate(srt_text, _keep)
|
||
|
||
|
||
def remove_short_moan_entries(
|
||
srt_text: str,
|
||
max_chars: int = DEFAULT_MOAN_MAX_CHARS,
|
||
) -> str:
|
||
"""删除 SRT 中'纯呻吟/喘息碎片'的**整条 cue**(whisper decode_full 去噪)。
|
||
|
||
无 VAD 解码会把呻吟段也整段救回,字幕因此混入纯语气词碎片(あ…/ん?)。
|
||
判据见 _is_pure_moan:文本全部由 MOAN_CHARS 组成且有效假名数 ≤ max_chars
|
||
才删除;max_chars=0 时关闭过滤。纯函数,不修改输入。
|
||
"""
|
||
if max_chars <= 0:
|
||
return srt_text
|
||
|
||
def _keep(start_sec: float, end_sec: float, text: str) -> bool:
|
||
"""保留判据:非纯呻吟碎片才保留。"""
|
||
return not _is_pure_moan(text, max_chars)
|
||
|
||
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,
|
||
) -> str:
|
||
"""通用 SRT 逐条过滤:keep(起始秒, 结束秒, 文本) 为 False 的 cue 整条删除。
|
||
|
||
删除 cue 时序号/时间轴/文本全部消失,剩余 cue 重新从 1 连续编号(合法
|
||
SRT)。用逐行解析(不依赖跨 cue 正则,兼容多行文本)。纯函数不修改输入。
|
||
"""
|
||
lines = srt_text.splitlines()
|
||
kept: list[str] = []
|
||
number = 1
|
||
index = 0
|
||
while index < len(lines):
|
||
line = lines[index]
|
||
if line.strip() and line.strip().isdigit() and index + 1 < len(lines):
|
||
ts_match = _TS_RE.match(lines[index + 1].strip())
|
||
if ts_match:
|
||
# 收集本 cue 文本:时间轴后直到空行前的所有非空行(可多行)。
|
||
text_lines: list[str] = []
|
||
cursor = index + 2
|
||
while cursor < len(lines) and lines[cursor].strip():
|
||
text_lines.append(lines[cursor].strip())
|
||
cursor += 1
|
||
text = "\n".join(text_lines)
|
||
start_sec = _ts_to_seconds(ts_match.group(1))
|
||
end_sec = _ts_to_seconds(ts_match.group(2))
|
||
if keep(start_sec, end_sec, text):
|
||
# 保留:输出 新序号+时间轴+文本+空行(重建标准 SRT)。
|
||
kept.append(
|
||
f"{number}\n{lines[index + 1].strip()}\n{text}\n"
|
||
)
|
||
number += 1
|
||
# 删除:整条跳过(序号/时间轴/文本都不输出)。
|
||
index = cursor
|
||
continue
|
||
# 非 cue 行(文件头/尾部噪声)跳过,避免序号/空行残留。
|
||
index += 1
|
||
return "\n".join(kept).rstrip() + "\n"
|
||
|
||
|
||
def clean_japanese_hallucinations(
|
||
srt_text: str,
|
||
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
|
||
) -> str:
|
||
"""从 SRT 中整条删除日文收尾/寒暄长时幻觉(ASR 无 VAD 解码兜底)。
|
||
|
||
whisper 节点 decode_full=true(无 VAD 整段解码)在无语音段会输出长时
|
||
套话占位;本函数把展示时长 ≥ 阈值且文本含 JAPANESE_HALLUCINATION_TOKENS
|
||
的**整条 cue 连带时间戳删除**、剩余重编号。短时相同词(剧情真实道晚安)
|
||
保留。纯函数,不修改输入。
|
||
"""
|
||
return remove_hallucination_entries(srt_text, JAPANESE_HALLUCINATION_TOKENS, threshold_seconds)
|
||
|
||
|
||
def clean_srt_text(
|
||
srt_text: str,
|
||
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
|
||
) -> str:
|
||
"""从 SRT 中整条删除中文长时寒暄幻觉(LLM 翻译产物清洗)。
|
||
|
||
对展示时长 ≥ 阈值且文本含 HALLUCINATION_TOKENS 的 cue 连带时间戳整条
|
||
删除、剩余重编号;短时相同词(剧情真实互道晚安)保留。与
|
||
clean_japanese_hallucinations 同机制,词表不同。纯函数,不修改输入。
|
||
"""
|
||
return remove_hallucination_entries(srt_text, HALLUCINATION_TOKENS, threshold_seconds)
|