diff --git a/docs/workflows.md b/docs/workflows.md index 81533a0..a3583d2 100644 --- a/docs/workflows.md +++ b/docs/workflows.md @@ -45,6 +45,18 @@ Expecting value: line 1 column 1 (char 0)`),也会直接渲染成超长字 展示时长 ≥15s 且同一 1–6 字单元连续重复 ≥6 次且覆盖正文 ≥70%;真实短促呻吟 (`ぇ`×15、`ああああああ`)靠时长区分,零误删。VAD 开关都会生效。 +**静音段幻觉抑制**(2026-09):decode_full 的固有副作用是"无语音段照样写字幕"—— +whisper 会在静音/音乐段输出 `こんにちは`、`おはようございます`、`東京都交通局8800形電車` +这类短幻觉。实测这类幻觉与"呻吟间隙里的真实短台词"在 `no_speech_prob`、`avg_logprob`、 +silero VAD 与音频能量四个维度上**都不可分**(数据见 `data/experiments/regen_plan/REPORT.md`), +所以走 faster-whisper 自带的 `hallucination_silence_threshold`(HST,需 +`word_timestamps=True`):怀疑该段是幻觉时,跳过超过阈值的静音部分。learn-translate +默认 `hallucination_silence_threshold=2.0` + `word_timestamps=true`:同一片头 120 秒 +无对话段由 15 条字幕降到 4 条(无套话幻觉残留),呻吟段基本保留;代价是转写约慢 1.8×。 +参数由工作流直接传给节点(`_guard_transcribe_params` 透传 `word_timestamps` / +`hallucination_silence_threshold` / `no_speech_threshold` / `log_prob_threshold` / +`compression_ratio_threshold`,未配置的键保持 faster-whisper 默认值)。 + 调研过程与结论见 [调研-whisper漏句与decode_full验证.md](./调研-whisper漏句与decode_full验证.md)。 ## 切换模型不改代码 diff --git a/nodes/whisper.py b/nodes/whisper.py index 63be17b..808c1cb 100755 --- a/nodes/whisper.py +++ b/nodes/whisper.py @@ -170,6 +170,24 @@ def _wav_duration_seconds(path: Path, fallback: float) -> float: return fallback +# 静音段幻觉抑制参数:无 VAD 的整段解码会在无语音处"编"出字幕,这些参数直接 +# 交给 faster-whisper(不传时保持其内置默认值,行为与改造前一致)。其中 +# `hallucination_silence_threshold` 需要 `word_timestamps=True` 才生效:它按词级 +# 时间戳跳过幻觉段里的静音部分,是"无语音段别写字幕"的主要开关。 +_GUARD_PARAM_KEYS = ( + "word_timestamps", + "hallucination_silence_threshold", + "no_speech_threshold", + "log_prob_threshold", + "compression_ratio_threshold", +) + + +def _guard_transcribe_params(params: dict) -> dict: + """挑出工作流显式传入的幻觉抑制参数,未传的键交给 faster-whisper 默认值。""" + return {key: params[key] for key in _GUARD_PARAM_KEYS if params.get(key) is not None} + + def _append_srt_lines(lines: list[str], segments, offset: float, start_index: int) -> int: """把一段转写结果按 SRT 格式追加到 lines,时间加上 offset 偏移。 @@ -287,6 +305,8 @@ def invoke(request: InvokeRequest) -> InvokeResponse: condition_on_previous_text=bool( request.params.get("condition_on_previous_text", False) ), + # 静音段幻觉抑制(未配置时不传,保持默认行为)。 + **_guard_transcribe_params(request.params), ) # 进度日志:块序号/总数、单块耗时、实时倍率(块音频时长/墙钟耗时) # 与转写累计耗时,直观反映数据处理速度。 diff --git a/tests/nodes/test_whisper/test_transcribe.py b/tests/nodes/test_whisper/test_transcribe.py index e1f12c5..29c870a 100644 --- a/tests/nodes/test_whisper/test_transcribe.py +++ b/tests/nodes/test_whisper/test_transcribe.py @@ -565,3 +565,53 @@ def test_invoke_drops_repetition_artifact_in_decode_full(tmp_path: Path, monkeyp content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8") assert artifact not in content assert "そこ、だめ" in content + + +def test_invoke_passes_hallucination_guard_params(tmp_path: Path, monkeypatch) -> None: + """数据:工作流传入幻觉抑制参数(HST + word_timestamps + 两个阈值)。 + + 过程:调用 invoke。 + + 验证:参数原样传给 faster-whisper——静音段幻觉只能靠这些参数抑制: + `hallucination_silence_threshold` 需要 word_timestamps 才生效。 + """ + # 数据:无语音段也能"编"出字幕的假模型 + 显式传入的抑制参数。 + model = FakeModel([FakeSegment(0.0, 2.0, "こんにちは")]) + _inject_model(monkeypatch, model) + + # 测试过程 + invoke(_request( + tmp_path, SPEECH_WAV, chunk_seconds=0, language="ja", + word_timestamps=True, hallucination_silence_threshold=2.0, + no_speech_threshold=0.3, log_prob_threshold=-1.2, + compression_ratio_threshold=2.4, + )) + + # 验证结果 + call = model.calls[0] + assert call["word_timestamps"] is True + assert call["hallucination_silence_threshold"] == 2.0 + assert call["no_speech_threshold"] == 0.3 + assert call["log_prob_threshold"] == -1.2 + assert call["compression_ratio_threshold"] == 2.4 + + +def test_invoke_omits_guard_params_by_default(tmp_path: Path, monkeypatch) -> None: + """数据:不传抑制参数(默认工作流)。 + + 过程:调用 invoke。 + + 验证:不透传这些键,保持 faster-whisper 自身默认值,行为与改造前一致。 + """ + # 数据:普通假模型。 + model = FakeModel([FakeSegment(0.0, 1.0, "x")]) + _inject_model(monkeypatch, model) + + # 测试过程 + invoke(_request(tmp_path, SPEECH_WAV, chunk_seconds=0)) + + # 验证结果 + call = model.calls[0] + assert "hallucination_silence_threshold" not in call + assert "no_speech_threshold" not in call + assert "word_timestamps" not in call diff --git a/workflows/learn-translate.json b/workflows/learn-translate.json index 21dce7c..c0236b7 100644 --- a/workflows/learn-translate.json +++ b/workflows/learn-translate.json @@ -31,6 +31,10 @@ "condition_on_previous_text": false, "chunk_seconds": 60, "vad_filter": false, + "word_timestamps": true, + "hallucination_silence_threshold": 2.0, + "_note_word_timestamps": "开启词级时间戳:hallucination_silence_threshold 只在该模式下生效(它按词级时间戳跳过幻觉段里的静音部分)。代价是解码更慢(实测同段音频约 1.8×),换来的是无语音段不再产生\"こんにちは/おはようございます\"这类套话幻觉。", + "_note_hallucination_silence_threshold": "静音段幻觉抑制:怀疑该段是幻觉时,跳过超过 2 秒的静音。实测片头无对话段的字幕条数由 15 降到 4、且不再出现套话幻觉,呻吟段基本保留。设 0 或删除该键即关闭(回到改造前行为)。", "beam_size": 1, "_note_decode_full": "【本次修复核心】decode_full=true 强制无 VAD 整段解码,绕过 silero VAD 对呻吟/轻语/快速讲解/BGM 混叠人声的切段误杀。实测 savr-1054 全片:生产 VAD 仅召回 115 条,decode_full 召回 340 条(弱语音全找回)。正例:教师快速带过一句'つまりね'(弱语音),decode_full 能捕捉;反例(decode_full=false 默认):该句被 silero 概率<阈值当静音剔除,字幕整句消失。代价是无语音段会产生长时'おやすみなさい/ご視聴ありがとうございました'幻觉——处理方式:whisper 转录后立即**连带时间戳把整条 cue 删除**(不留下 '-' 占位污染下游,占位会渲染进 ASS 成减号),短时(≤15s)相同词可能是剧情真实道晚安则保留。实测 speech_60s:前 30s 无语音幻觉被整条剔除,字幕直接从 30s 真实内容开始、序号连续。", "_note_vad_filter": "本工作流 decode_full=true 时 vad_filter 被强制置 false(两者互斥,decode_full 优先)。保留 vad_filter=false 仅为显式声明'不启用 VAD 切段'。正例:学习视频讲解者偶有停顿、翻页声,无 VAD 不误删。反例:vad_filter=true + 讲解者语速快/带气声,弱音节被整段吞掉(见 decode_full 反例)。",