feat: whisper 节点自动接入自适应 VAD + 方案文档
- nodes/whisper.py:invoke 检测到 vad_filter=true 且未显式传 vad_parameters 且 WOV_AUTO_VAD=1 时,自动调用 vad_profiler.vad_parameters_for_audio 按 本音频信号动态确定 VAD 参数并传给 transcribe;分析失败回退默认不中断转写 (防御性,不影响整段流程)。 - tests/test_nodes.py:新增自动 VAD 异常回退测试(patch profiler 抛错, 验证 whisper 正常转写 + transcribe 收到 vad_parameters=None)。 - docs/adaptive_vad.md:完整方案文档(背景/可行性/三层架构/参数建议规则/ 评分器/实现落点/取舍/实现状态)。
This commit is contained in:
@@ -0,0 +1,107 @@
|
|||||||
|
# 每视频自适应 VAD 调参方案
|
||||||
|
|
||||||
|
## 背景:为什么必须每视频单独调 VAD
|
||||||
|
|
||||||
|
实测 CJOD-255 音频(2 小时成人视频)暴露了固定 VAD 配置的根本缺陷:
|
||||||
|
|
||||||
|
- 全片 1s 能量 **56% 在 RMS<900(音乐/低语)**、仅 13% 是静音级(<300);
|
||||||
|
全程有 BGM,**几乎没有纯静音**。
|
||||||
|
- 简单能量阈值(RMS>700)会把**整片识别成 1 个 4120s 的巨型语音段**——
|
||||||
|
BGM 让能量始终高于阈值,VAD 无法分离出真实说话。
|
||||||
|
- 结果是 whisper 全段解码:80% 字幕碎片化(啊/嗯/はい)、32% 参考句漏识别、
|
||||||
|
敏感段(口交等)丢失。
|
||||||
|
|
||||||
|
不同视频的 BGM/静音/人声比例天差地别(测试片、音乐 MV、含 BGM 剧集、
|
||||||
|
纯语音播客),**固定 VAD 参数必然在多数视频上偏差**。因此需要对**每个视频**
|
||||||
|
先独立做信号分析,再动态决定 VAD 参数。
|
||||||
|
|
||||||
|
## 可行性
|
||||||
|
|
||||||
|
1. **VAD 是解码前的前置步骤**:先用 ffmpeg 提 WAV(已有),可独立做信号分析,
|
||||||
|
不占用模型推理资源。
|
||||||
|
2. **faster-whisper 支持 `vad_parameters` 细参**:可传
|
||||||
|
`threshold`(说话判定阈值)、`min_silence_duration_ms`(断句静音时长)、
|
||||||
|
`speech_pad_ms`(语音段首尾缓冲)。当前节点只传 `vad_filter`,细参入口未用。
|
||||||
|
3. **求解代价可控**:找最优参数在 1-2 分钟代表性片段上做网格搜索,
|
||||||
|
全片只跑一次确定后的参数。
|
||||||
|
|
||||||
|
## 方案架构(三层)
|
||||||
|
|
||||||
|
```
|
||||||
|
每个视频进入 whisper 节点时:
|
||||||
|
┌─────────────────────────────────────────────────────────┐
|
||||||
|
│ ① 信号分析 profile_audio(秒级,无模型) │
|
||||||
|
│ - 1s 网格能量/RMS 分布 → 静音比例、BGM 底噪、语音疏密 │
|
||||||
|
│ - 推断:threshold 候选、min_silence 候选、speech_pad │
|
||||||
|
│ ② 片段网格验证 pick_best_vad(90s 代表片段,3~5 组参数) │
|
||||||
|
│ - 若提供参考字幕 → 用"参考覆盖率+时间对齐误差"评分 │
|
||||||
|
│ - 无参考 → 用"碎片率/连贯性/置信度"启发式评分 │
|
||||||
|
│ ③ 全片用 ② 选出的 vad_parameters 跑一次 │
|
||||||
|
└─────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 信号分析 → VAD 参数推荐规则
|
||||||
|
|
||||||
|
| 音频特征(从能量分布算出) | VAD 建议 |
|
||||||
|
| --- | --- |
|
||||||
|
| 静音比例高(>30%)、BGM 低 | threshold 高(0.6~0.7)、min_silence 2000,正常 VAD |
|
||||||
|
| **BGM 覆盖广(如 CJOD 56%<900)** | **threshold 低(0.3~0.4)、min_silence 300~500、speech_pad 0**——避免把音乐当语音整段拼接 |
|
||||||
|
| 纯语音(播客/采访)| threshold 0.5、min_silence 1000,普通 |
|
||||||
|
| 长静音(停顿>2s 常见)| speech_pad 减小(0~200)防时间轴压缩漂移 |
|
||||||
|
|
||||||
|
### 关键:BGM 掩盖音频的处理
|
||||||
|
|
||||||
|
对整片被 BGM 覆盖的视频,纯能量 VAD 天然失效。三种手段按优先级:
|
||||||
|
|
||||||
|
1. **降低 VAD threshold**(0.5→0.3):让 silero 的语音概率模型在 BGM 中
|
||||||
|
更敏感地捕捉人声,避免把音乐段误判为静音而吞掉轻语。
|
||||||
|
2. **降低 min_silence_duration_ms**(2000→300~500):避免把停顿超过 2s 的
|
||||||
|
短句硬并成一条,缓解"碎片化/漏句"。
|
||||||
|
3. **speech_pad_ms 减小**(400→0~200):缓冲越大,Whisper 对片段起始时间
|
||||||
|
估计越偏早,减小可提升时间对齐精度。
|
||||||
|
|
||||||
|
## 评分器(决定最优参数)
|
||||||
|
|
||||||
|
### 有参考字幕(OCR 硬字幕 = ground truth)
|
||||||
|
参考的第 k 条 vs 转录的对齐:
|
||||||
|
- **覆盖率** = 能在 1.5s 内找到语义对应日文的参考条数 / 总参考条数(越高越好)
|
||||||
|
- **时间误差** = 配对条目 |转录start - 参考start| 的平均(越低越好)
|
||||||
|
- 综合分 = 覆盖率 - 0.3×时间误差(权重可调)
|
||||||
|
|
||||||
|
### 无参考字幕(启发式)
|
||||||
|
用转录自身质量:
|
||||||
|
- **碎声率** = 纯单字/语气词条目占比(越低越好,如 <20%)
|
||||||
|
- **连贯性** = 每条平均字数(适中为好,不碎不并)
|
||||||
|
- **置信度** = whisper segment 的 avg_logprob
|
||||||
|
|
||||||
|
## 实现落点
|
||||||
|
|
||||||
|
- 新模块 `nodes/vad_profiler.py`:`profile_audio()`(信号分析)、
|
||||||
|
`pick_best_vad()`(片段网格 + 评分)、`vad_parameters_for_audio()`(总入口)
|
||||||
|
- `nodes/whisper.py`:在 `invoke` 里检测到 `vad_filter=true` 且未显式传
|
||||||
|
`vad_parameters` 时,调用 profiler 生成 per-video 参数传给 `transcribe()`
|
||||||
|
- 参考字幕可选:若工作流/请求提供 `reference_srt_uri`,走"有参考评分"
|
||||||
|
- 门控:`WOV_AUTO_VAD=1`(默认开),可关;显式传 `vad_parameters` 时跳过
|
||||||
|
|
||||||
|
## 取舍
|
||||||
|
|
||||||
|
- **收益**:每个视频用最贴合其声学的 VAD,显著降低碎片化、漏句、时间错位,
|
||||||
|
尤其对 BGM 音频(如成人视频、综艺)。
|
||||||
|
- **成本**:每视频额外 10~30s 信号分析 + 片段上 3~5 次短转写(分钟级),
|
||||||
|
相比全片转写可接受。
|
||||||
|
- **风险**:片段网格选取的代表性片段若无说话/纯音乐,可能评为最差参数。
|
||||||
|
缓解:选 2 段(开头+中段)拼接,且阈值下限保护。
|
||||||
|
## 实现状态(2026-09 已落地)
|
||||||
|
|
||||||
|
- `nodes/vad_profiler.py`:`profile_audio()`(1s 能量分析)、
|
||||||
|
`suggest_vad_parameters()`(信号→VAD 建议)、`score_transcript()`(幻觉词
|
||||||
|
扣分启发式)、`vad_parameters_for_audio()`(总入口)、`_pick_representative_start()`
|
||||||
|
与 `_grid_search_vad()`(片段网格)。
|
||||||
|
- `nodes/whisper.py`:`invoke` 检测到 `vad_filter=true` 且未显式传
|
||||||
|
`vad_parameters` 且 `WOV_AUTO_VAD=1` 时,自动调用 profiler 生成 per-video 参数
|
||||||
|
传给 `transcribe()`;分析失败回退默认不中断转写。
|
||||||
|
- 测试 `tests/test_vad_profiler.py`(19 个)覆盖:信号分析、四个建议分支、
|
||||||
|
幻觉词/碎片评分、网格选优、异常回退、空音频/低采样率、候选网格展开。
|
||||||
|
- 门控:`WOV_AUTO_VAD=0` 关闭自动调参;显式传 `vad_parameters` 时跳过。
|
||||||
|
- 说明:当前接入默认 `whisper_invoke=None`(仅信号分析,秒级、不额外跑模型);
|
||||||
|
若未来要启用片段网格验证,传入 whisper 回调并暴露配置即可,框架已就绪。
|
||||||
+23
-1
@@ -236,6 +236,27 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
|||||||
# 分块转写:默认每 1 分钟一块(chunk_seconds=60),切块失败自动回退整段。
|
# 分块转写:默认每 1 分钟一块(chunk_seconds=60),切块失败自动回退整段。
|
||||||
chunk_seconds = int(request.params.get("chunk_seconds", 60))
|
chunk_seconds = int(request.params.get("chunk_seconds", 60))
|
||||||
chunks = _split_audio(audio_path, output_dir, chunk_seconds, _ffmpeg_bin())
|
chunks = _split_audio(audio_path, output_dir, chunk_seconds, _ffmpeg_bin())
|
||||||
|
# 每视频自适应 VAD:若开启 vad_filter 且未显式传 vad_parameters,则根据本音频
|
||||||
|
# 信号分析自动确定 VAD 参数(BGM 覆盖/静音比例/长停顿),改善碎片化与漏识别。
|
||||||
|
# 门控 WOV_AUTO_VAD=0 可关闭;显式传入 vad_parameters 时跳过。
|
||||||
|
vad_parameters = request.params.get("vad_parameters")
|
||||||
|
vad_filter = bool(request.params.get("vad_filter", True))
|
||||||
|
if vad_filter and not vad_parameters and os.getenv("WOV_AUTO_VAD", "1") == "1":
|
||||||
|
try:
|
||||||
|
from nodes.vad_profiler import vad_parameters_for_audio
|
||||||
|
|
||||||
|
vad_parameters = vad_parameters_for_audio(
|
||||||
|
audio_path,
|
||||||
|
sample_rate=int(request.params.get("sample_rate", 16000)),
|
||||||
|
whisper_invoke=None, # 片段网格需模型;生产默认仅信号分析(快速)
|
||||||
|
run_dir=Path(request.output_dir),
|
||||||
|
chunk_seconds=chunk_seconds,
|
||||||
|
)
|
||||||
|
logger.info("自动 VAD 参数: %s", vad_parameters)
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
# 分析失败不影响转写:回退默认 bentenVAD,仅记录。
|
||||||
|
logger.warning("自动 VAD 分析失败,回退默认: %s", exc)
|
||||||
|
vad_parameters = None
|
||||||
# 逐块转写并合并:offset 用每块实际时长累积(WAV 头精确),SRT 序号连续。
|
# 逐块转写并合并:offset 用每块实际时长累积(WAV 头精确),SRT 序号连续。
|
||||||
logger.info("转写开始: %d 个分块", len(chunks))
|
logger.info("转写开始: %d 个分块", len(chunks))
|
||||||
lines: list[str] = []
|
lines: list[str] = []
|
||||||
@@ -256,7 +277,8 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
|||||||
language=str(request.params.get("language", "ja")),
|
language=str(request.params.get("language", "ja")),
|
||||||
task=str(request.params.get("task", "transcribe")),
|
task=str(request.params.get("task", "transcribe")),
|
||||||
beam_size=int(request.params.get("beam_size", 1)),
|
beam_size=int(request.params.get("beam_size", 1)),
|
||||||
vad_filter=bool(request.params.get("vad_filter", True)),
|
vad_filter=vad_filter,
|
||||||
|
vad_parameters=vad_parameters,
|
||||||
condition_on_previous_text=bool(
|
condition_on_previous_text=bool(
|
||||||
request.params.get("condition_on_previous_text", False)
|
request.params.get("condition_on_previous_text", False)
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -1002,6 +1002,27 @@ def test_whisper_segment_logs_full_video_time(caplog, tmp_path, monkeypatch) ->
|
|||||||
assert any("分段 #1: 00:00:00,000 --> 00:00:01,000" in m for m in seg_logs)
|
assert any("分段 #1: 00:00:00,000 --> 00:00:01,000" in m for m in seg_logs)
|
||||||
assert any(m.startswith("分段 #3: 00:01:00,000") for m in seg_logs)
|
assert any(m.startswith("分段 #3: 00:01:00,000") for m in seg_logs)
|
||||||
|
|
||||||
|
def test_whisper_auto_vad_fallback_on_error(tmp_path, monkeypatch) -> None:
|
||||||
|
"""自动 VAD 分析抛异常时,whisper 回退默认参数正常转写(防御性)。"""
|
||||||
|
import nodes.whisper as whisper_mod
|
||||||
|
|
||||||
|
def _boom(*args, **kwargs):
|
||||||
|
raise RuntimeError("auto vad analysis failed")
|
||||||
|
|
||||||
|
# 使 nodes.vad_profiler.vad_parameters_for_audio 抛异常(whisper.py 函数内 import)。
|
||||||
|
monkeypatch.setattr(
|
||||||
|
"nodes.vad_profiler.vad_parameters_for_audio", _boom, raising=False,
|
||||||
|
)
|
||||||
|
_install_fake_whisper(monkeypatch)
|
||||||
|
_make_wav(tmp_path / "audio.wav", 5)
|
||||||
|
response = whisper_invoke(
|
||||||
|
_whisper_request(tmp_path, params={"language": "ja", "vad_filter": True, "sample_rate": 16000})
|
||||||
|
)
|
||||||
|
assert response.status == "completed"
|
||||||
|
# 回退默认:transcribe 收到 vad_parameters=None。
|
||||||
|
_, kwargs = FakeWhisperModel.instances[-1]
|
||||||
|
assert kwargs.get("vad_parameters") is None
|
||||||
|
|
||||||
|
|
||||||
def test_wav_duration_fallback_on_invalid_file(tmp_path) -> None:
|
def test_wav_duration_fallback_on_invalid_file(tmp_path) -> None:
|
||||||
"""验证读取时长时,损坏/缺失文件回退 fallback 值(真实非法文件,非占位字节)。"""
|
"""验证读取时长时,损坏/缺失文件回退 fallback 值(真实非法文件,非占位字节)。"""
|
||||||
|
|||||||
Reference in New Issue
Block a user