确认所有运行时引用均使用 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 工作流。
230 lines
11 KiB
Python
230 lines
11 KiB
Python
"""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)
|