feat: 优化学习视频转写与字幕清洗
This commit is contained in:
@@ -89,13 +89,16 @@ whisper 节点按以下顺序解析模型路径,默认避免从远端下载:
|
||||
1. 请求参数 `model_path`;裸模型名(不含路径分隔符)会在 `model/<名称>` 下解析。
|
||||
2. 环境变量 `WHISPER_MODEL_PATH`。
|
||||
3. 本地候选目录(存在且含 `model.bin` 即使用):
|
||||
- 单体根目录 `model/faster-whisper-large-v3`。
|
||||
- `nodes/model/faster-whisper-large-v3`。
|
||||
4. 兜底:`large-v3`(需要联网从 Hugging Face 下载)。
|
||||
- 单体根目录 `model/faster-whisper-large-v2`。
|
||||
- `nodes/model/faster-whisper-large-v2`。
|
||||
4. 兜底:`large-v2`(需要联网从 Hugging Face 下载)。
|
||||
|
||||
把权重放在 `model/` 目录即可完全离线运行。当前已下载模型:
|
||||
|
||||
- `model/faster-whisper-large-v3`:通用转写模型(demo 工作流)。
|
||||
- `model/faster-whisper-large-v2`:通用转写模型(demo/learn-translate 工作流,
|
||||
2026-09 从 large-v3 切换:savr-1054 全片 A/B 实测无VAD 幻觉长段归零、开头漏句
|
||||
救回,VAD 链路条数与覆盖小幅领先,见 `data/experiments/whisper_v2_vs_v3/`)。
|
||||
- `model/faster-whisper-large-v3`:旧通用转写模型,已无工作流引用,保留在盘上。
|
||||
- `model/whisper-large-v2-translate-zh-v0.2-st-ct2`:中文直出模型
|
||||
(`chickenrice0721/whisper-large-v2-translate-zh-v0.2-st-ct2`),配合
|
||||
`task=translate` 直接生成中文,无需 LLM 翻译(zh-direct 工作流)。
|
||||
@@ -125,6 +128,12 @@ whisper 节点按以下顺序解析模型路径,默认避免从远端下载:
|
||||
见 `nodes/subtitle_cleanup.py` 的 `clean_japanese_hallucinations`),不留下 `-` 占位污染
|
||||
下游(占位会渲染进 ASS 成可见减号);llm-translate 翻译后同样整条删除中文长时寒暄
|
||||
幻觉(`clean_srt_text`)。短时(≤15s)相同词可能是剧情真实道晚安,保留。
|
||||
**短呻吟过滤**(2026-09):decode_full 救回的弱语音中混有大量**纯语气词碎片**
|
||||
(あ…/ん?/はぁ…/あ!あ!/んふふ 等 ≤3 假名),影响字幕观感;whisper 转录后按
|
||||
'全部字符 ∈ 纯呻吟字符集合(`MOAN_CHARS`)且有效假名数 ≤ `short_moan_max_chars`(默认 3,设 0 关闭)'
|
||||
判据**整条删除**(`remove_short_moan_entries`)。集合刻意排除 そ/こ/ね/や/ば/だ
|
||||
等假名,真实短对话(そこ/やばい/ねえ/やだ/えへへ)天然不命中。仅 decode_full
|
||||
生效,demo 等 VAD 链路不受影响。
|
||||
|
||||
最终产物按 `上传文件名.标识.时间戳` 重命名(如 `test01.zh-CN.20260815123000.srt`),
|
||||
标识优先取节点的 `target_language` 参数,否则用产物别名。
|
||||
@@ -138,7 +147,7 @@ whisper 节点按以下顺序解析模型路径,默认避免从远端下载:
|
||||
### 切换模型不改代码
|
||||
|
||||
- 模型是工作流 DAG 中 asr 节点的 `model_path` 参数(**数据**),内置工作流
|
||||
均已显式声明:demo/learn-translate 用 `faster-whisper-large-v3`,zh-direct 用中文直出模型。
|
||||
均已显式声明:demo/learn-translate 用 `faster-whisper-large-v2`,zh-direct 用中文直出模型。
|
||||
- 切换模型 = 改 `workflows/*.json` 或管理页面 DAG JSON → 保存新版本 → 发布,
|
||||
全程不涉及代码;新库启动时从 JSON 重新 seed。
|
||||
- 默认工作流定义存放在 `workflows/*.json`(数据文件),代码只负责加载。
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
"""whisper large-v3 vs large-v2 同配置对比实验脚本(savr-1054 全片)。
|
||||
|
||||
目的:验证 large-v2 是否比当前生产用的 large-v3 更适合本项目(日语成人内容,
|
||||
呻吟/BGM 混叠、弱语音)。方法:对同一视频,用相同参数分别跑 large-v3 与
|
||||
large-v2 的「生产 VAD」与「无 VAD」两条链路,统计条数/覆盖时长/幻觉长段等
|
||||
指标,与 docs/调研-whisper漏句与decode_full验证.md 中记录的 v3 历史结果对照。
|
||||
|
||||
复现路径:直接调用 nodes/whisper.py 的 invoke(真实节点代码),不绕开注册表
|
||||
或重写业务逻辑。显式传 vad_parameters 复现文档实验一的"生产 VAD
|
||||
(threshold0.5/ms1000/pad200)",并关闭自动 VAD(WOV_AUTO_VAD=0),确保
|
||||
v3/v2 差异纯粹来自模型,而非信号分析分支抖动。
|
||||
|
||||
用法:
|
||||
uv run python scripts/compare_whisper_v2_vs_v3.py \
|
||||
--video /mnt/fnOS/123/savr-1054/4k2.me@savr01054_2_8k.mp4 \
|
||||
--out data/experiments/whisper_v2_vs_v3
|
||||
|
||||
产物:
|
||||
<out>/audio.wav 16k 单声道(ffmpeg 提取一次,复用)
|
||||
<out>/<tag>/transcript.srt 各组合的 SRT 产物
|
||||
<out>/summary.csv 指标汇总
|
||||
控制台打印对比表
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
# 保证脚本可从仓库根目录直接 import nodes/wov_sdk。
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
os.environ.setdefault("WOV_AUTO_VAD", "0") # 关闭自动 VAD,用显式参数
|
||||
|
||||
from nodes.ffmpeg import _ffmpeg_bin # noqa: E402
|
||||
from nodes.subtitle_cleanup import JAPANESE_HALLUCINATION_TOKENS # noqa: E402
|
||||
from wov_sdk.models import InvokeRequest # noqa: E402
|
||||
|
||||
# 实验一的生产 VAD 配置(文档记录):threshold0.5 / min_silence1000 / pad200。
|
||||
PROD_VAD_PARAMS = {
|
||||
"threshold": 0.5,
|
||||
"min_silence_duration_ms": 1000,
|
||||
"speech_pad_ms": 200,
|
||||
}
|
||||
|
||||
# 幻觉长段判定:展示时长 ≥ 阈值 且文本命中日文寒暄词表(与 subtitle_cleanup
|
||||
# 的 decode_full 清洗同一判据,这里不删除只统计)。
|
||||
HALLUC_THRESHOLD_SECONDS = 15.0
|
||||
|
||||
VIDEO_PATH = "/mnt/fnOS/123/savr-1054/4k2.me@savr01054_2_8k.mp4"
|
||||
OUT_DIR = ROOT / "data" / "experiments" / "whisper_v2_vs_v3"
|
||||
|
||||
|
||||
def extract_audio(video: Path, out_dir: Path) -> Path:
|
||||
"""用 ffmpeg 提取 16k 单声道 wav(与 ffmpeg-extract 节点相同规格)。"""
|
||||
wav = out_dir / "audio.wav"
|
||||
if wav.is_file() and wav.stat().st_size > 0:
|
||||
return wav
|
||||
subprocess.run(
|
||||
[
|
||||
_ffmpeg_bin(),
|
||||
"-y", "-i", str(video),
|
||||
"-vn", "-ac", "1", "-ar", "16000",
|
||||
str(wav),
|
||||
],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
return wav
|
||||
|
||||
|
||||
def parse_srt(srt_text: str) -> list[dict]:
|
||||
"""解析 SRT 文本为 cue 列表(序号/起止/文本/时长),文档统计口径。"""
|
||||
cues = []
|
||||
# 标准 SRT 块:序号 / 时间轴 / 文本行(可多行)/ 空行。
|
||||
blocks = re.split(r"\n\s*\n", srt_text.strip())
|
||||
for block in blocks:
|
||||
lines = [ln for ln in block.splitlines() if ln.strip()]
|
||||
if len(lines) < 2:
|
||||
continue
|
||||
if not lines[0].strip().isdigit():
|
||||
continue
|
||||
m = re.match(
|
||||
r"(\d+):(\d+):(\d+)[,.](\d+)\s*-->\s*(\d+):(\d+):(\d+)[,.](\d+)",
|
||||
lines[1],
|
||||
)
|
||||
if not m:
|
||||
continue
|
||||
start = (int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3])) + int(m[4]) / 1000
|
||||
end = (int(m[5]) * 3600 + int(m[6]) * 60 + int(m[7])) + int(m[8]) / 1000
|
||||
text = " ".join(lines[2:]).strip()
|
||||
cues.append({
|
||||
"start": start,
|
||||
"end": end,
|
||||
"duration": end - start,
|
||||
"text": text,
|
||||
})
|
||||
return cues
|
||||
|
||||
|
||||
def stats(cues: list[dict], total_seconds: float) -> dict:
|
||||
"""汇总指标:条数/展示时长和/覆盖时长(并集)/幻觉长段。"""
|
||||
count = len(cues)
|
||||
display_sum = sum(c["duration"] for c in cues)
|
||||
# 覆盖并集:按开始时间排序后合并重叠区间。
|
||||
ordered = sorted(cues, key=lambda c: c["start"])
|
||||
union = 0.0
|
||||
cur_s, cur_e = None, None
|
||||
for c in ordered:
|
||||
if cur_s is None:
|
||||
cur_s, cur_e = c["start"], c["end"]
|
||||
elif c["start"] <= cur_e:
|
||||
cur_e = max(cur_e, c["end"])
|
||||
else:
|
||||
union += cur_e - cur_s
|
||||
cur_s, cur_e = c["start"], c["end"]
|
||||
if cur_s is not None:
|
||||
union += cur_e - cur_s
|
||||
# 幻觉长段:时长≥阈值 且 文本含任一寒暄词。
|
||||
halluc = [
|
||||
c for c in cues
|
||||
if c["duration"] >= HALLUC_THRESHOLD_SECONDS
|
||||
and any(tok in c["text"] for tok in JAPANESE_HALLUCINATION_TOKENS)
|
||||
]
|
||||
halluc_seconds = sum(c["duration"] for c in halluc)
|
||||
return {
|
||||
"count": count,
|
||||
"display_sum": round(display_sum, 1),
|
||||
"union": round(union, 1),
|
||||
"coverage_pct": round(union / total_seconds * 100, 1) if total_seconds else 0.0,
|
||||
"halluc_count": len(halluc),
|
||||
"halluc_seconds": round(halluc_seconds, 1),
|
||||
}
|
||||
|
||||
|
||||
def run_combo(tag: str, model_path: str, wav: Path, out_dir: Path,
|
||||
vad_filter: bool, vad_parameters: dict | None) -> dict:
|
||||
"""跑一趟 whisper.invoke(真实节点路径),返回统计与产物路径。"""
|
||||
combo_dir = out_dir / tag
|
||||
combo_dir.mkdir(parents=True, exist_ok=True)
|
||||
params: dict = {
|
||||
"language": "ja",
|
||||
"model_path": model_path,
|
||||
"condition_on_previous_text": False,
|
||||
"chunk_seconds": 60,
|
||||
"vad_filter": vad_filter,
|
||||
"beam_size": 1,
|
||||
}
|
||||
if vad_parameters is not None:
|
||||
params["vad_parameters"] = vad_parameters
|
||||
request = InvokeRequest(
|
||||
run_id=f"exp-{tag}",
|
||||
node_instance_id="whisper",
|
||||
inputs={"audio_uri": str(wav)},
|
||||
params=params,
|
||||
output_dir=str(combo_dir),
|
||||
)
|
||||
started = time.monotonic()
|
||||
from nodes.whisper import invoke
|
||||
response = invoke(request)
|
||||
elapsed = time.monotonic() - started
|
||||
if response.status != "completed":
|
||||
raise RuntimeError(f"[{tag}] invoke failed: {response.error}")
|
||||
srt_path = combo_dir / "transcript.srt"
|
||||
cues = parse_srt(srt_path.read_text(encoding="utf-8"))
|
||||
return {"tag": tag, "model": model_path, "elapsed": round(elapsed, 1), "cues": cues}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--video", default=VIDEO_PATH, help="待测视频路径")
|
||||
parser.add_argument("--out", default=str(OUT_DIR), help="实验输出目录")
|
||||
args = parser.parse_args()
|
||||
|
||||
video = Path(args.video)
|
||||
out_dir = Path(args.out)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
if not video.is_file():
|
||||
sys.exit(f"视频不存在: {video}")
|
||||
# 用 ffmpeg -i 的 stderr 探测视频时长(ffprobe 可能不在 PATH)。
|
||||
probe = subprocess.run(
|
||||
[_ffmpeg_bin(), "-i", str(video)],
|
||||
capture_output=True, text=True,
|
||||
).stderr
|
||||
m = re.search(r"Duration:\s*(\d+):(\d+):(\d+)\.(\d+)", probe)
|
||||
if m:
|
||||
total_seconds = int(m[1]) * 3600 + int(m[2]) * 60 + int(m[3]) + int(m[4]) / 100
|
||||
else:
|
||||
total_seconds = 0.0
|
||||
|
||||
print(f"视频: {video} 时长 {total_seconds:.1f}s")
|
||||
print("提取 16k 单声道音频 ...")
|
||||
wav = extract_audio(video, out_dir)
|
||||
|
||||
# 4 个组合:v3/v2 × 生产VAD/无VAD,串行执行(模型加载复用由 faster-whisper
|
||||
# 内部承担,这里每次 invoke 新载一次,串行避免显存竞争)。
|
||||
combos = [
|
||||
("v3_vad", "faster-whisper-large-v3", True, PROD_VAD_PARAMS),
|
||||
("v3_novad", "faster-whisper-large-v3", False, None),
|
||||
("v2_vad", "faster-whisper-large-v2", True, PROD_VAD_PARAMS),
|
||||
("v2_novad", "faster-whisper-large-v2", False, None),
|
||||
]
|
||||
results = []
|
||||
for tag, model, vad_filter, vp in combos:
|
||||
print(f"\n=== {tag} (model={model}, vad_filter={vad_filter}) ===")
|
||||
r = run_combo(tag, model, wav, out_dir, vad_filter, vp)
|
||||
s = stats(r["cues"], total_seconds)
|
||||
r.update(s)
|
||||
results.append(r)
|
||||
# 简要打印每条产物前若干行验证非空。
|
||||
print(f" {s['count']} 条 / 展示和 {s['display_sum']}s / "
|
||||
f"覆盖 {s['union']}s ({s['coverage_pct']}%) / "
|
||||
f"幻觉长段 {s['halluc_count']} 条 {s['halluc_seconds']}s "
|
||||
f"/ 耗时 {r['elapsed']}s")
|
||||
|
||||
# 汇总表:与控制台对齐输出 CSV + 终端 markdown 表格。
|
||||
csv_path = out_dir / "summary.csv"
|
||||
with csv_path.open("w", newline="", encoding="utf-8") as f:
|
||||
writer = csv.writer(f)
|
||||
writer.writerow(["tag", "model", "count", "display_sum_s", "union_s",
|
||||
"coverage_pct", "halluc_count", "halluc_seconds_s",
|
||||
"elapsed_s"])
|
||||
for r in results:
|
||||
writer.writerow([r["tag"], r["model"], r["count"], r["display_sum"],
|
||||
r["union"], r["coverage_pct"], r["halluc_count"],
|
||||
r["halluc_seconds"], r["elapsed"]])
|
||||
|
||||
print("\n===== 汇总(大模型实物对比,同代码同配置) =====")
|
||||
print(f"{'tag':9s} {'条数':>5s} {'展示和s':>8s} {'覆盖s':>7s} "
|
||||
f"{'覆盖%':>6s} {'幻觉段数':>7s} {'幻觉s':>7s} {'耗时s':>7s}")
|
||||
for r in results:
|
||||
print(f"{r['tag']:9s} {r['count']:5d} {r['display_sum']:8.1f} "
|
||||
f"{r['union']:7.1f} {r['coverage_pct']:6.1f} "
|
||||
f"{r['halluc_count']:7d} {r['halluc_seconds']:7.1f} "
|
||||
f"{r['elapsed']:7.1f}")
|
||||
print(f"\n历史 large-v3 文档值(调研文档 2026-09):"
|
||||
f"生产VAD 115 条/621s 覆盖;无VAD 369 条/983s 覆盖(有 119-149s、"
|
||||
f"600-630s 30s 幻觉长段)。")
|
||||
print(f"汇总已存: {csv_path}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -218,7 +218,10 @@ def list_batch_roots() -> list[dict]:
|
||||
roots.append({"path": str(drive), "name": f"{letter}:\\"})
|
||||
else:
|
||||
roots.append({"path": "/", "name": "/"})
|
||||
home = Path.home()
|
||||
# 用环境变量解析家目录,避免测试模拟 Windows 分支时在 POSIX 进程中
|
||||
# 调用 Path.home() 触发 WindowsPath 的平台不匹配异常。
|
||||
home_value = os.environ.get("USERPROFILE") or os.environ.get("HOME")
|
||||
home = Path(home_value).expanduser() if home_value else Path.home()
|
||||
roots.append({"path": str(home), "name": f"家目录({home.name})"})
|
||||
return roots
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ from nodes.subtitle_cleanup import (
|
||||
HALLUCINATION_TOKENS,
|
||||
clean_japanese_hallucinations,
|
||||
clean_srt_text,
|
||||
remove_short_moan_entries,
|
||||
)
|
||||
|
||||
|
||||
@@ -163,3 +164,99 @@ def test_jp_middle_removal_resequences() -> None:
|
||||
assert out.count("-->") == 2
|
||||
assert out.startswith("1\n00:00:00,000 --> 00:00:02,000\nあ")
|
||||
assert "2\n00:00:41,000 --> 00:00:43,000\nい" in out
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 短呻吟/喘息碎片过滤(decode_full 救回弱语音的去噪)—— user 2026-09 决策
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# 背景实测(savr-1054-2 前 600s):decode_full 无 VAD 解码会把呻吟/BGM 混叠
|
||||
# 的弱语音也救回来,但其中含大量**纯语气词碎片**(あ…/ん?/はぁ…/あ!あ!/んふふ
|
||||
# 等,有效假名 ≤3 个),这类内容放进字幕是噪声,影响观看。真实数据对照:
|
||||
# - 要删(纯呻吟/喘息):あ、ん、ん?、あ〜、あ…、はぁ…、あぁ…、あ!あ!、
|
||||
# ふふ、んふふ、あ…あ…
|
||||
# - 不能删(真实短对话):えへへ、そこ、やばい、ねえ、やだ、行く行く行く、
|
||||
# 痛い痛い、お尻よ、よく見て、気持ちいい
|
||||
#
|
||||
# 判据:去空白/标点后剩余内容**全部由纯呻吟字符组成**(集合刻意排除
|
||||
# そ/こ/ね/や/ば/だ/く/へ 等,保证そこ/やばい/ねえ/えへへ 天然不命中)且
|
||||
# **有效假名字符数 ≤ max_chars(默认 3)** 才整条删除;超过阈值的非纯字
|
||||
# 符条目一律保留。仅 decode_full=true 时由 whisper 节点调用(用户决策)。
|
||||
|
||||
|
||||
def test_remove_short_moan_pure_moans_deleted() -> None:
|
||||
"""纯呻吟/喘息碎片(あ/ん/ん?/あ〜/はぁ…/あ!あ!/んふふ)整条删除。"""
|
||||
srt = (
|
||||
"1\n00:00:00,000 --> 00:00:02,000\n気持ちいい\n\n"
|
||||
"2\n00:00:10,000 --> 00:00:11,000\nあ\n\n" # 删
|
||||
"3\n00:00:12,000 --> 00:00:13,000\nん?\n\n" # 删
|
||||
"4\n00:00:14,000 --> 00:00:15,000\nあ〜\n\n" # 删
|
||||
"5\n00:00:16,000 --> 00:00:17,000\nはぁ…\n\n" # 删
|
||||
"6\n00:00:18,000 --> 00:00:19,000\nあ!あ!\n\n" # 删
|
||||
"7\n00:00:20,000 --> 00:00:21,000\nんふふ\n\n" # 删
|
||||
)
|
||||
out = remove_short_moan_entries(srt)
|
||||
for frag in ("あ\n", "ん?", "あ〜", "はぁ", "あ!あ!", "んふふ"):
|
||||
assert frag not in out
|
||||
# 真实内容保留、序号重编号为 1。
|
||||
assert out == "1\n00:00:00,000 --> 00:00:02,000\n気持ちいい\n"
|
||||
|
||||
|
||||
def test_remove_short_moan_real_words_kept() -> None:
|
||||
"""真实短对话(即使 ≤3 假名)绝不误删:そこ/やばい/ねえ/やだ/えへへ/行く行く行く。"""
|
||||
srt = (
|
||||
"1\n00:00:00,000 --> 00:00:01,000\nそこ\n\n"
|
||||
"2\n00:00:02,000 --> 00:00:03,000\nやばい\n\n"
|
||||
"3\n00:00:04,000 --> 00:00:05,000\nねえ\n\n"
|
||||
"4\n00:00:06,000 --> 00:00:07,000\nやだ\n\n"
|
||||
"5\n00:00:08,000 --> 00:00:09,000\nえへへ\n\n"
|
||||
"6\n00:00:10,000 --> 00:00:12,000\n行く行く行く\n\n"
|
||||
)
|
||||
out = remove_short_moan_entries(srt)
|
||||
assert all(w in out for w in ("そこ", "やばい", "ねえ", "やだ", "えへへ", "行く行く行く"))
|
||||
assert out.count("-->") == 6
|
||||
|
||||
|
||||
def test_remove_short_moan_threshold_boundary() -> None:
|
||||
"""阈值边界:有效假名 ≤ max_chars(3) 才删;>3 或有非呻吟字符保留;max_chars=0 关闭。"""
|
||||
# 4 个あ(>3)超出阈值 → 保留;3 个あ(=3)→ 删。
|
||||
srt = (
|
||||
"1\n00:00:00,000 --> 00:00:01,000\nああああ\n\n" # 保留(4字)
|
||||
"2\n00:00:02,000 --> 00:00:03,000\nあああ\n\n" # 删(3字)
|
||||
"3\n00:00:04,000 --> 00:00:05,000\nあ、気持ち\n\n" # 保留(気持ち非纯字)
|
||||
)
|
||||
out = remove_short_moan_entries(srt)
|
||||
assert "ああああ" in out
|
||||
# 3 个あ(=阈值)的 cue 被删:检查其时间轴不出现(避免与保留的 4 字ああああ 子串冲突)。
|
||||
assert "00:00:02,000 --> 00:00:03,000" not in out
|
||||
assert "気持ち" in out
|
||||
assert out.count("-->") == 2
|
||||
# max_chars=0 关闭过滤:什么都不删。
|
||||
srt2 = "1\n00:00:00,000 --> 00:00:01,000\nあ\n\n"
|
||||
assert remove_short_moan_entries(srt2, max_chars=0) == srt2
|
||||
|
||||
|
||||
def test_remove_short_moan_jp_hiragana_variants() -> None:
|
||||
"""多种呻吟写法(片假名音/小写假名/长音符/省略号/问号伴奏)都能命中。"""
|
||||
srt = (
|
||||
"1\n00:00:00,000 --> 00:00:01,000\nア\n\n" # 片假名あ
|
||||
"2\n00:00:02,000 --> 00:00:03,000\nうぅ…\n\n" # 小写ぅ
|
||||
"3\n00:00:04,000 --> 00:00:05,000\nあぁ〜\n\n" # 长音符
|
||||
"4\n00:00:06,000 --> 00:00:07,000\nんー\n\n" # 长音ー
|
||||
"5\n00:00:08,000 --> 00:00:09,000\nあ あ\n\n" # 带空格
|
||||
)
|
||||
out = remove_short_moan_entries(srt)
|
||||
assert out.count("-->") == 0
|
||||
|
||||
|
||||
def test_remove_short_moan_multiline_and_resequence() -> None:
|
||||
"""多行文本条目与删除后重编号(序号连续、时间正确对应)。"""
|
||||
srt = (
|
||||
"1\n00:00:00,000 --> 00:00:02,000\n気持ちいい\nね\n\n"
|
||||
"2\n00:00:10,000 --> 00:00:11,000\nん〜\n\n" # 删
|
||||
"3\n00:00:12,000 --> 00:00:15,000\nやばい\n\n"
|
||||
)
|
||||
out = remove_short_moan_entries(srt)
|
||||
assert out.count("-->") == 2
|
||||
assert out.startswith("1\n00:00:00,000 --> 00:00:02,000\n気持ちいい\nね")
|
||||
assert "2\n00:00:12,000 --> 00:00:15,000\nやばい" in out
|
||||
|
||||
+66
-3
@@ -292,13 +292,13 @@ def test_resolve_incomplete_candidate_skipped(tmp_path) -> None:
|
||||
empty = tmp_path / "empty"
|
||||
empty.mkdir()
|
||||
resolved = resolve_model_path({}, env={}, candidates=[empty])
|
||||
assert resolved == "large-v3"
|
||||
assert resolved == "large-v2"
|
||||
|
||||
|
||||
def test_resolve_fallback_remote() -> None:
|
||||
"""验证全部本地候选缺失时回退到远端 large-v3 作为最后兜底。"""
|
||||
"""验证全部本地候选缺失时回退到远端 large-v2 作为最后兜底。"""
|
||||
resolved = resolve_model_path({}, env={}, candidates=[])
|
||||
assert resolved == "large-v3"
|
||||
assert resolved == "large-v2"
|
||||
|
||||
|
||||
def test_format_timestamp() -> None:
|
||||
@@ -1497,5 +1497,68 @@ def test_whisper_decode_full_cleanup_jp_hallucination(tmp_path, monkeypatch) ->
|
||||
assert all(t != "-" for t in text_lines)
|
||||
|
||||
|
||||
def test_whisper_decode_full_filters_short_moan(tmp_path, monkeypatch) -> None:
|
||||
"""decode_full=true 时纯呻吟碎片整条删除,真实短词保留(2026-09 决策)。"""
|
||||
class MoanModel:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def transcribe(self, path, **kwargs):
|
||||
# 混合:正常句 + 纯呻吟碎片 + 真实短词(都应保留)。
|
||||
return (
|
||||
[
|
||||
FakeSegment(0, 2, "気持ちいい"), # 正常
|
||||
FakeSegment(2, 3, "あ〜"), # 纯呻吟 → 删
|
||||
FakeSegment(3, 4, "ん?"), # 纯呻吟 → 删
|
||||
FakeSegment(4, 5, "そこ"), # 真实短词 → 保留
|
||||
FakeSegment(5, 6, "やばい"), # 真实短词 → 保留
|
||||
],
|
||||
None,
|
||||
)
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "faster_whisper", types.SimpleNamespace(WhisperModel=lambda *a, **k: MoanModel())
|
||||
)
|
||||
_make_wav(tmp_path / "audio.wav", 5)
|
||||
resp = whisper_invoke(
|
||||
_whisper_request(tmp_path, params={"language": "ja", "decode_full": True})
|
||||
)
|
||||
assert resp.status == "completed"
|
||||
content = Path(resp.outputs["srt_uri"]).read_text(encoding="utf-8")
|
||||
# 纯呻吟碎片被整条删除;真实短词与正常句保留。
|
||||
assert "あ〜" not in content
|
||||
assert "ん?" not in content
|
||||
assert "気持ちいい" in content
|
||||
assert "そこ" in content
|
||||
assert "やばい" in content
|
||||
assert content.count("-->") == 3 # 5 段 - 2 段呻吟 = 3 条
|
||||
|
||||
|
||||
def test_whisper_decode_full_short_moan_can_disable(tmp_path, monkeypatch) -> None:
|
||||
"""short_moan_max_chars=0 时关闭短呻吟过滤,所有内容原样保留。"""
|
||||
class MoanModel2:
|
||||
def __init__(self, *args, **kwargs):
|
||||
pass
|
||||
|
||||
def transcribe(self, path, **kwargs):
|
||||
return ([FakeSegment(0, 1, "あ〜"), FakeSegment(1, 2, "そこ")], None)
|
||||
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "faster_whisper", types.SimpleNamespace(WhisperModel=lambda *a, **k: MoanModel2())
|
||||
)
|
||||
_make_wav(tmp_path / "audio.wav", 5)
|
||||
resp = whisper_invoke(
|
||||
_whisper_request(
|
||||
tmp_path,
|
||||
params={"language": "ja", "decode_full": True, "short_moan_max_chars": 0},
|
||||
)
|
||||
)
|
||||
assert resp.status == "completed"
|
||||
content = Path(resp.outputs["srt_uri"]).read_text(encoding="utf-8")
|
||||
assert "あ〜" in content
|
||||
assert "そこ" in content
|
||||
assert content.count("-->") == 2
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ def test_seed_default_workflows_idempotent(tmp_path) -> None:
|
||||
demo = db.get_latest_workflow_version("demo")["definition"]
|
||||
assert demo["name"] == "视频字幕生成"
|
||||
demo_asr = next(node for node in demo["nodes"] if node["id"] == "asr")
|
||||
assert demo_asr["params"]["model_path"] == "faster-whisper-large-v3"
|
||||
assert demo_asr["params"]["model_path"] == "faster-whisper-large-v2"
|
||||
assert demo_asr["params"]["condition_on_previous_text"] is False
|
||||
|
||||
# zh-direct:使用中文直出模型并开启翻译任务。
|
||||
|
||||
@@ -18,6 +18,9 @@ async function loadBatchWorkflowOptions() {
|
||||
.join("")
|
||||
: '<option value="">暂无已发布工作流</option>';
|
||||
apps.forEach((app) => WORKFLOW_NAMES.set(app.id, app.name));
|
||||
// 批量处理默认使用 learn-translate 工作流(面向学习/讲解类视频,优先召回弱语音)。
|
||||
const learnOption = select.querySelector('option[value="learn-translate"]');
|
||||
if (learnOption) learnOption.selected = true;
|
||||
}
|
||||
|
||||
// 创建批量任务:POST 文件夹路径与所选工作流,成功后清空输入并刷新列表。
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@
|
||||
"node_type": "faster-whisper",
|
||||
"params": {
|
||||
"language": "ja",
|
||||
"model_path": "faster-whisper-large-v3",
|
||||
"model_path": "faster-whisper-large-v2",
|
||||
"condition_on_previous_text": false,
|
||||
"chunk_seconds": 60,
|
||||
"vad_filter": true
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
"node_type": "faster-whisper",
|
||||
"params": {
|
||||
"language": "ja",
|
||||
"model_path": "faster-whisper-large-v3",
|
||||
"model_path": "faster-whisper-large-v2",
|
||||
"decode_full": true,
|
||||
"condition_on_previous_text": false,
|
||||
"chunk_seconds": 60,
|
||||
@@ -37,7 +37,8 @@
|
||||
"_note_chunk_seconds": "60s 分块:内存/显存有界、失败粒度小,块偏移按 WAV 实际时长累积无漂移。正例:2 小时学习视频切成 120 块逐块转写,每块独立,某块失败只重跑该块。反例:chunk_seconds=0(不分块)在长视频上显存吃紧,且一旦中途异常整段重来。decode_full 模式下分块同样生效,幻觉清洗在合并后的整条 SRT 上执行。",
|
||||
"_note_condition_on_previous_text": "false(默认):每个 30s 窗口独立解码,避免长音频下 whisper 把前文错误延续成重复/漂移(官方建议长音频方案)。正例:讲解视频 1 小时,各窗口互不污染,无重复句。反例:设为 true 时,若某窗口误识别,会沿上文把错误放大成整段重复。",
|
||||
"_note_beam_size": "1(贪心解码):速度最快且对清晰讲解足够;学习视频人声清晰,不需要 beam search 的多候选。正例:清晰人声 + beam=1 已能稳定出句。反例:若遇多说话人/嘈杂环境想提升,可调 beam_size=5,但速度明显下降(RTX3090 上约慢 3-5 倍),对本场景收益低。",
|
||||
"_node_help": "faster-whisper 节点(asr):把 WAV 转写为日语 SRT。\n【关键参数与效果】\n- decode_full=true(默认 false):无 VAD 整段解码,救回被 silero 当非语音剔除的弱语音/快速讲解/呻吟/混叠人声。\n 正例:savr-1054 全片 115 条 -> 340 条,'说了的话不漏'。\n 反例(false):弱语音整句消失,字幕出现无端空洞。副作用=无语音段长套话幻觉,已由节点日语幻觉清洗自动移除(连带时间戳整条删除,非 '-' 占位)。\n- vad_filter:decode_full 下被强制 false;独立开启时靠 silero 切语音段,适合纯安静对话,但对 BGM/气声场景会误杀真话。\n- chunk_seconds=60:分块转写,显存有界、块偏移按实际时长累积不漂移、可断点。\n- condition_on_previous_text=false:长音频防重复/漂移(官方建议)。\n- beam_size=1:贪心最快,清晰讲解足够。\n正例:学习视频讲解者轻声带过关键词也能召回。\n反例:开启 VAD 且遇快速讲解/轻微 BGM,弱音节被吞 -> 字幕缺句。"
|
||||
"_note_short_moan_max_chars": "3(默认):短呻吟/喘息碎片过滤的'有效假名上限'——decode_full 救回的弱语音中混有大量纯语气词碎片(あ…/ん?/はぁ…/んふふ 等),全部字符 ∈ 纯呻吟集合且有效假名数 ≤ 此值即整条删除,字幕不再被呻吟噪声刷屏。正例:呻吟片段 あ…/ん? 被整条删除、字幕清爽;真实短对话 そこ/やばい/ねえ/やだ/えへへ 含非呻吟字符天然命中不了、完整保留(实测 savr-1054 600s:133 条中仅删 14 条纯呻吟,真实长句零误删)。反例:设为 0 则关闭过滤,呻吟碎片重新混入字幕。",
|
||||
"_node_help": "faster-whisper 节点(asr):把 WAV 转写为日语 SRT。\n【关键参数与效果】\n- decode_full=true(默认 false):无 VAD 整段解码,救回被 silero 当非语音剔除的弱语音/快速讲解/呻吟/混叠人声。\n 正例:savr-1054 全片 115 条 -> 340 条,'说了的话不漏'。\n 反例(false):弱语音整句消失,字幕出现无端空洞。副作用=无语音段长套话幻觉,已由节点日语幻觉清洗自动移除(连带时间戳整条删除,非 '-' 占位)。\n- vad_filter:decode_full 下被强制 false;独立开启时靠 silero 切语音段,适合纯安静对话,但对 BGM/气声场景会误杀真话。\n- chunk_seconds=60:分块转写,显存有界、块偏移按实际时长累积不漂移、可断点。\n- condition_on_previous_text=false:长音频防重复/漂移(官方建议)。\n- beam_size=1:贪心最快,清晰讲解足够。\n- short_moan_max_chars=3:短呻吟/喘息碎片过滤阈值(纯呻吟全字符命中且≤N假名才删,真实短对话不命中;0=关闭)。\n正例:学习视频讲解者轻声带过关键词也能召回。\n反例:开启 VAD 且遇快速讲解/轻微 BGM,弱音节被吞 -> 字幕缺句。"
|
||||
},
|
||||
"inputs": {
|
||||
"audio_uri": "extract.audio_uri"
|
||||
|
||||
Reference in New Issue
Block a user