feat: whisper 新增 decode_full 无VAD整段解码参数并改为整条删除式幻觉清洗

解决转写漏句(有人说话但没识别出来)问题:silero VAD 对呻吟/轻语/BGM
混叠声学切段能力天然不足,把真话当非语音剔除(实测 savr-1054 全片仅
召回 115 条)。新增 decode_full 参数(默认 false 保持 VAD 现状):

- decode_full=true 时强制无 VAD 整段解码 + 跳过自动 VAD 分析,救回被
  剔除的弱语音(savr-1054 全片 115 条 → 340 条)
- 副作用是长时寒暄套话幻觉(おやすみなさい/ご視聴ありがとうございま
  した 等),whisper 转录后连带时间戳整条删除(clean_japanese_ha
  lllucinations),不留下 '-' 占位污染下游(占位会渲染进 ASS 成减号)
- llm-translate 翻译后同样整条删除中文长时寒暄幻觉(clean_srt_text)
- 短时(≤15s)相同词可能是剧情真实道晚安,保留(15s 阈值实测校准)
- subtitle_cleanup 由 '-' 占位式改为整条删除式 + 剩余重编号,新增
  JAPANESE_HALLUCINATION_TOKENS 词表

新增工作流 learn-translate(学习资料转译+翻译字幕)示范 decode_full
用法,并确立参数标注约定:params._note_<参数名> 存放设定理由与正反例、
_node_help 放节点参数手册(_ 前缀说明键,节点执行时忽略,零运行影响)。

调研记录见 docs/调研-whisper漏句与decode_full验证.md(A/B 实验、结论
修正与 5 个待决问题)。
This commit is contained in:
2026-09-07 17:21:04 +08:00
parent c063aed1f1
commit a8fe133aa4
10 changed files with 695 additions and 106 deletions
+107
View File
@@ -0,0 +1,107 @@
"""学习资料转译工作流(learn-translate)加载与标注测试。
验证新增工作流 learn-translate.json
1. 能被 seed 正常加载入库(definition 通过 WorkflowDefinition 校验);
2. 关键参数应用了本次修复(decode_full=true、vad_filter=false)且 params 内
_note_* 说明键、_node_help 手册完整保留(不被模型/入库丢弃);
3. 标注键(_note_*/_node_help)作为 params 传给节点时不影响节点执行——
节点只读取它认识的参数键,多余的说明键被忽略(真实节点行为)。
"""
from __future__ import annotations
import json
from pathlib import Path
from wov_app.db import Database
from wov_app.seed import seed_default_workflows
from wov_sdk.models import WorkflowDefinition
# 单体根目录:tests/ 的上一级。
WORKSPACE = Path(__file__).resolve().parent.parent
LEARN = WORKSPACE / "workflows" / "learn-translate.json"
def test_learn_translate_seed_loads_and_applies_fix() -> None:
"""验证 learn-translate 能被 seed 加载,asr 应用本次 decode_full 修复。"""
db = Database(WORKSPACE / "data" / "wov_test.db")
# 用临时目录避免污染真实 data
import tempfile
with tempfile.TemporaryDirectory() as tmp:
db2 = Database(Path(tmp) / "wov.db")
created = seed_default_workflows(db2)
assert created >= 4 # demo/zh-direct/ocr-subtitle/learn-translate
definition = db2.get_latest_workflow_version("learn-translate")["definition"]
asr = next(node for node in definition["nodes"] if node["id"] == "asr")
assert asr["params"]["decode_full"] is True
assert asr["params"]["vad_filter"] is False
assert asr["params"]["chunk_seconds"] == 60
assert asr["params"]["condition_on_previous_text"] is False
def test_learn_translate_notes_and_help_preserved() -> None:
"""验证 _note_* 理由与 _node_help 手册在入库后完整保留。"""
import tempfile
with tempfile.TemporaryDirectory() as tmp:
db = Database(Path(tmp) / "wov.db")
seed_default_workflows(db)
definition = db.get_latest_workflow_version("learn-translate")["definition"]
# 每个节点都应有 _node_helpasr 每个参数都有 _note_。
for node in definition["nodes"]:
assert "_node_help" in node["params"], f"{node['id']} 缺 _node_help"
assert node["params"]["_node_help"] # 非空
asr = next(node for node in definition["nodes"] if node["id"] == "asr")
for key in ("_note_decode_full", "_note_vad_filter", "_note_chunk_seconds",
"_note_condition_on_previous_text", "_note_beam_size"):
assert key in asr["params"], f"asr 缺 {key}"
# 理由需含正例/反例关键字(说明确实举例)。
assert "正例" in asr["params"]["_note_decode_full"]
assert "反例" in asr["params"]["_note_decode_full"]
def test_learn_translate_notes_do_not_break_execution(tmp_path, monkeypatch) -> None:
"""验证带 _note_*/_node_help 的 params 传给 whisper 节点不影响执行。
节点只读取它认识的键(chunk_seconds/vad_filter/decode_full 等),
额外的说明键被忽略;伪造模型确认 transcribe 收到的正是修复后参数。
"""
import sys
import types
from tests.test_nodes import FakeSegment, _install_fake_whisper, _whisper_request # 复用脚手架
captured = {}
class FullModel:
def __init__(self, *args, **kwargs):
pass
def transcribe(self, path, **kwargs):
captured["decode_full_effect"] = (
kwargs.get("vad_filter") is False and kwargs.get("vad_parameters") is None
)
return ([FakeSegment(0, 1, "ok")], None)
monkeypatch.setitem(
sys.modules, "faster_whisper", types.SimpleNamespace(WhisperModel=lambda *a, **k: FullModel())
)
from nodes.whisper import invoke as whisper_invoke
from wov_sdk.models import InvokeRequest
import wave
# 真实 WAV
wav = tmp_path / "audio.wav"
with wave.open(str(wav), "wb") as w:
w.setnchannels(1); w.setsampwidth(2); w.setframerate(16000)
w.writeframes(b"\x00\x00" * 16000)
# 带 learn-translate 工作流 asr 的完整 params(含 _note_*/_node_help
learn = json.loads(LEARN.read_text(encoding="utf-8"))
asr_params = next(n["params"] for n in learn["definition"]["nodes"] if n["id"] == "asr")
resp = whisper_invoke(InvokeRequest(
run_id="learn_test", node_instance_id="",
inputs={"audio_uri": str(wav)},
params=asr_params,
output_dir=str(tmp_path / "out"),
))
assert resp.status == "completed"
assert captured["decode_full_effect"] is True # decode_full 生效且说明键被忽略不报错
content = Path(resp.outputs["srt_uri"]).read_text(encoding="utf-8")
assert "ok" in content