feat: 优化学习视频转写与字幕清洗
This commit is contained in:
@@ -188,7 +188,9 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
(
|
||||
f"select='not(mod(n\\,{step}))',"
|
||||
f"crop={w_px}:{h_px}:{x_px}:{y_px},"
|
||||
"scale=1280:720:force_original_aspect_ratio=decrease:force_divisible_by=2"
|
||||
# force_divisible_by 仅在较新 ffmpeg 中可用;PNG 抽帧不要求偶数尺寸,
|
||||
# 保留等比缩小即可兼容系统版 ffmpeg 4.x。
|
||||
"scale=1280:720:force_original_aspect_ratio=decrease"
|
||||
),
|
||||
# 只写出被选中的帧,避免 CFR 补帧产生重复文件。
|
||||
"-vsync",
|
||||
|
||||
@@ -58,6 +58,51 @@ JAPANESE_HALLUCINATION_TOKENS = (
|
||||
"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*$")
|
||||
@@ -82,6 +127,48 @@ def remove_hallucination_entries(
|
||||
|
||||
用途:幻觉在产生处直接剔除——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 决策,
|
||||
不作用于 demo 等 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] = []
|
||||
@@ -101,17 +188,13 @@ def remove_hallucination_entries(
|
||||
text = "\n".join(text_lines)
|
||||
start_sec = _ts_to_seconds(ts_match.group(1))
|
||||
end_sec = _ts_to_seconds(ts_match.group(2))
|
||||
duration = end_sec - start_sec
|
||||
is_hallucination = duration >= threshold_seconds and any(
|
||||
t in text for t in tokens
|
||||
)
|
||||
if not is_hallucination:
|
||||
# 非幻觉:输出 新序号+时间轴+文本+空行(重建标准 SRT)。
|
||||
if keep(start_sec, end_sec, text):
|
||||
# 保留:输出 新序号+时间轴+文本+空行(重建标准 SRT)。
|
||||
kept.append(
|
||||
f"{number}\n{lines[index + 1].strip()}\n{text}\n"
|
||||
)
|
||||
number += 1
|
||||
# 幻觉 cue:整条跳过(序号/时间轴/文本都不输出)。
|
||||
# 删除:整条跳过(序号/时间轴/文本都不输出)。
|
||||
index = cursor
|
||||
continue
|
||||
# 非 cue 行(文件头/尾部噪声)跳过,避免序号/空行残留。
|
||||
|
||||
+22
-7
@@ -1,7 +1,7 @@
|
||||
"""faster-whisper ASR 节点。
|
||||
|
||||
单体版中作为进程内节点模块,由调度器直接调用。模型权重默认优先从本地
|
||||
目录加载,避免从远端下载,仅在本地找不到模型时才回退到远端 large-v3。
|
||||
目录加载,避免从远端下载,仅在本地找不到模型时才回退到远端 large-v2。
|
||||
CUDA 动态库通过 ctypes 在进程内预加载,替代分布式版的 LD_LIBRARY_PATH 注入。
|
||||
"""
|
||||
|
||||
@@ -60,13 +60,13 @@ def _load_cuda_libraries() -> None:
|
||||
def _local_model_candidates() -> list[Path]:
|
||||
"""返回本地模型候选目录:单体根目录 model/ 优先,其次 nodes/ 同级 model/。
|
||||
|
||||
单体根目录 model/ 对应仓库根下的 model/faster-whisper-large-v3,
|
||||
单体根目录 model/ 对应仓库根下的 model/faster-whisper-large-v2,
|
||||
nodes/ 同级 model/ 允许部署时把权重随代码目录一起携带。
|
||||
"""
|
||||
monolith_root = Path(__file__).resolve().parent.parent
|
||||
return [
|
||||
monolith_root / "model" / "faster-whisper-large-v3",
|
||||
monolith_root / "nodes" / "model" / "faster-whisper-large-v3",
|
||||
monolith_root / "model" / "faster-whisper-large-v2",
|
||||
monolith_root / "nodes" / "model" / "faster-whisper-large-v2",
|
||||
]
|
||||
|
||||
|
||||
@@ -75,7 +75,7 @@ def resolve_model_path(
|
||||
env: dict | None = None,
|
||||
candidates: list[Path] | None = None,
|
||||
) -> str:
|
||||
"""按 参数 > 环境变量 > 本地候选目录 > 远端 large-v3 的顺序解析模型路径。
|
||||
"""按 参数 > 环境变量 > 本地候选目录 > 远端 large-v2 的顺序解析模型路径。
|
||||
|
||||
本地优先是默认行为:只要候选目录存在且包含 model.bin 就使用本地权重,
|
||||
避免从 Hugging Face 下载;远端下载仅在全部本地候选缺失时作为兜底。
|
||||
@@ -99,7 +99,7 @@ def resolve_model_path(
|
||||
# model.bin 是 CTranslate2 权重的必需文件,存在才认为模型完整。
|
||||
if candidate.is_dir() and (candidate / "model.bin").is_file():
|
||||
return str(candidate)
|
||||
return "large-v3"
|
||||
return "large-v2"
|
||||
|
||||
def format_timestamp(seconds: float) -> str:
|
||||
"""把秒数格式化为 SRT 时间戳,例如 01:00:00,500。"""
|
||||
@@ -232,6 +232,8 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
# 把真话当非语音剔除(实测 savr-1054 全片仅召回 115 条),开启后强制
|
||||
# 无 VAD 整段解码(vad_filter=False 且跳过自动 VAD 分析)以召回弱语音,
|
||||
# 代价是无语音段会产生长时套话幻觉,由下方日语幻觉清洗兜底移除。
|
||||
# 另:decode_full 救回的弱语音中混有纯语气词碎片(あ/ん?等),由
|
||||
# short_moan_max_chars(默认 3,0=关闭)参数控制短呻吟整条删除。
|
||||
# task 默认 transcribe,中文直出模型可传 translate 直接翻译为目标语言。
|
||||
# condition_on_previous_text 默认 False:长音频下开启会导致重复/漂移,
|
||||
# 关闭后每个 30s 窗口独立解码,是 faster-whisper 官方建议的长音频方案。
|
||||
@@ -305,10 +307,23 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
# 剔除**(序号/时间轴/文本全删、剩余重编号),不留下 '-' 占位污染下游
|
||||
# (占位会渲染进 ASS 成减号、翻译/过滤都要额外处理);短时(≤15s)相同词
|
||||
# 可能是剧情真实道晚安,保留。见 nodes/subtitle_cleanup.py。
|
||||
# 其次(2026-09 用户决策):decode_full 也会把呻吟/BGM 混叠的弱语音整段
|
||||
# 救回,其中混有大量**纯语气词碎片**(あ…/ん?/はぁ…/あ!あ!/んふふ 等),
|
||||
# 这类噪声影响字幕观感;在此按'全部字符∈纯呻吟集合 且 有效假名≤max_chars'
|
||||
# 判据**整条删除**(remove_short_moan_entries),真实短对话(そこ/やばい/
|
||||
# ねえ/やだ)天然不命中。仅 decode_full 生效,demo 等 VAD 链路不受影响;
|
||||
# 参数 short_moan_max_chars 可调(默认 3,设 0 关闭)。
|
||||
if decode_full:
|
||||
from nodes.subtitle_cleanup import clean_japanese_hallucinations
|
||||
from nodes.subtitle_cleanup import (
|
||||
clean_japanese_hallucinations,
|
||||
remove_short_moan_entries,
|
||||
)
|
||||
|
||||
body = clean_japanese_hallucinations("\n".join(lines))
|
||||
body = remove_short_moan_entries(
|
||||
body,
|
||||
max_chars=int(request.params.get("short_moan_max_chars", 3)),
|
||||
)
|
||||
else:
|
||||
body = "\n".join(lines)
|
||||
output_path = output_dir / "transcript.srt"
|
||||
|
||||
Reference in New Issue
Block a user