Files
vrsub/nodes/subtitle_cleanup.py
T
cat-shark 8f6083f8cf feat: 全系统统一 Whisper V2 权重并移除 demo 工作流
确认所有运行时引用均使用 V2(V3 已停用),并修复一处真实不一致:

- 工作流数据文件:learn-translate 用 faster-whisper-large-v2、
  zh-direct 用 whisper-large-v2-translate-zh-v0.2-st-ct2(原本即 V2);
- 本地库的 demo 最新版本仍指向 large-v3:workflows/demo.json 早已改为 V2,
  但 seed 对已存在工作流刻意跳过,导致旧库停留在历史上用 V3 保存的定义,
  即本机跑 demo 实际加载 V3 权重。按用户决定移除 demo 工作流及其关联的
  6 个 run、1 个批量任务与 431 条明细(媒体库中已放置的 6 个字幕成品保留);
- nodes/whisper.py 候选与远端兜底本就是 large-v2;
- V3 权重目录保留在盘上仅作对照实验,文档标注为废弃;
  scripts/compare_whisper_v2_vs_v3.py 保留用于对照。

顺带修复与清理:
- src/wov_app/scheduler.py:_file_size 补捕 ValueError(见上一条提交说明
  的真实缺陷,此处为同一批改动);
- .gitignore:data/ 改为 /data/,避免连带忽略 tests/**/data/;
- scripts/*:评测集路径改到 scripts/data/translate_eval/;
- 代码注释与文档同步移除 demo 引用(历史调研文档保留说明性引用)。

验证:全量 477 passed;新库 seed 只创建 3 个 V2 工作流。
2026-09-13 15:41:58 +08:00

230 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Subtitle cleanup: remove long-duration closing/greeting hallucinations.
Background (real run 20260905115050): after fixing the timing alignment,
subtitles still contain "closing/greeting hallucination words" - fixed
phrases like 'wan an / gan xie guan kan / gan xie nin de guan kan'
(good night / thanks for watching) that the ASR/LLM repeatedly emits on
empty segments, filling a full 30s block, unrelated to video content.
Some 2s 'good night' might be real dialogue, so it must be kept.
处理策略(2026-09 用户确认改为"整条剔除"):
幻觉识别出后(展示时长 ≥ 阈值 且 文本命中套话词表),应**连带时间戳把整条
字幕 cue 删除**(剩余条目重新编号),而不是把文本替换成 '-' 占位留给下游——
占位会一路流到 ASS 渲染成可见的"减号"、在翻译/过滤阶段都要额外特殊处理,
处理位置绕且不彻底。因此清洗统一为:**在幻觉产生处(whisper 转录后 / LLM
翻译后)整条删除**,时间轴随之消失,字幕序号重新连续编号。
两类词表:
- HALLUCINATION_TOKENS:中文(LLM 翻译产物中的寒暄,如"晚安/感谢观看");
- JAPANESE_HALLUCINATION_TOKENS:日文(whisper decode_full 无 VAD 解码在
无语音段直接输出的套话,如"おやすみなさい/ご視聴ありがとうございました")。
阈值从真实运行实测数据判定:30s 幻觉占位 vs 2s 真实词,分界明显,
默认 threshold=15s(≥15s 才删除;<15s 的相同词可能是剧情真实道晚安,保留)。
Pure functions, unit-testable (tests/test_hallucination_mask.py).
"""
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
# 日文 ASR 直出(whisper 节点 decode_full 无 VAD 整段解码)的收尾/寒暄幻觉词。
# 无 VAD 解码会把无语音/音乐/呻吟段当语音,whisper 常在这些段重复输出套话
# (实测 savr-1054 全片 119-149s/600-630s 等出现 30s 长"おやすみなさい"
# "ご視聴ありがとうございました")。短时(≤阈值)的相同词可能是剧情里真实
# 互道晚安,须保留;仅删除展示时长 ≥ 阈值的条目(与中文表同一机制)。
JAPANESE_HALLUCINATION_TOKENS = (
"おやすみなさい", # 晚安
"ご視聴ありがとうございました", # 感谢观看
"ありがとうございました", # 感谢
"ご視聴ありがとうございます", # 感谢观看(现在时)
"また見てね", # 下次再见
"お楽しみに", # 敬请期待
"チャンネル登録よろしくお願いします", # 求订阅
"さようなら", # 再见
"音楽", # 音乐(自述)
"Goodbye",
"Thank you for watching",
)
# 纯呻吟/喘息字符集合(decode_full 救回弱语音后的去噪,2026-09 用户决策)。
#
# 背景(实测 savr-1054-2 前 600s):decode_full 无 VAD 解码会把呻吟/BGM 混叠
# 的弱语音也整段救回,但其中混有大量**纯语气词碎片**(あ…/ん?/はぁ…/あ!あ!
# /んふふ 等),这类内容放进字幕是噪声。判据:文本(去空白/标点)**全部由本
# 集合字符组成**且有效假名数 ≤ 阈值才删除。集合**刻意排除** そ/こ/ね/や/ば/だ
# /く/へ 等假名——真实短对话(そこ/やばい/ねえ/やだ/えへへ)都含这些字符,
# 含任意非集合字符的条目天然不命中,从根上避免误删真实短句。
MOAN_CHARS = frozenset(
# 平假名元音与ん/ふ/は(呻吟与喘息气流音的主干)
"あいうえおんふはっ"
# 小写假名(ぁぃぅぇぉ)与片假名对应(アィゥェォ、ン)
"ぁぃぅぇぉアィゥェォン"
# 长音符/省略号/半浊音(ー〜…、…)与空白、标点(呻吟常带这些装饰)
"ー〜…\u2026。、!??!、"
" \t"
)
# 短呻吟过滤的默认有效假名上限:真实呻吟/喘息碎片(あ/ん/ん?/はぁ…/あ!あ!
# /んふふ)有效假名 ≤3;>3(如ああああ)或含非呻吟字符的一律保留。
DEFAULT_MOAN_MAX_CHARS = 3
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 去噪)。
decode_full 无 VAD 解码会把呻吟也整段救回,字幕混入大量纯语气词碎片
(あ…/ん?/はぁ…)。判据:文本全部由 MOAN_CHARS 组成且有效假名数 ≤
max_chars(默认 3)才删除(见 _is_pure_moan),真实短对话(そこ/やばい/
ねえ/やだ/えへへ/行く行く行く)天然不命中。max_chars=0 时关闭过滤(原
样返回)。仅在 whisper 节点 decode_full=true 时调用(用户 2026-09 决策,
不作用于 learn-translate 等 VAD 链路)。纯函数,不修改输入。
"""
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_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)