"""字幕清洗:删除寒暄幻觉与纯呻吟碎片。 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)