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
+18 -2
View File
@@ -109,6 +109,22 @@ whisper 节点按以下顺序解析模型路径,默认避免从远端下载:
| `demo` | 视频字幕生成 | 提音 → 转写 → LLM 翻译 → ASS | 通用链路,翻译走 SiliconFlow | | `demo` | 视频字幕生成 | 提音 → 转写 → LLM 翻译 → ASS | 通用链路,翻译走 SiliconFlow |
| `zh-direct` | 中文直出字幕 | 提音 → 中文转写 → ASS | 中文直出模型,无 LLM 步骤 | | `zh-direct` | 中文直出字幕 | 提音 → 中文转写 → ASS | 中文直出模型,无 LLM 步骤 |
| `ocr-subtitle` | 字幕OCR提取 | 抽帧 → 逐帧 OCR → 汇总 SRT → LLM 过滤 | 提取烧录字幕做基准数据;前端框选 crop;LLM 过滤多余/无意义字幕 | | `ocr-subtitle` | 字幕OCR提取 | 抽帧 → 逐帧 OCR → 汇总 SRT → LLM 过滤 | 提取烧录字幕做基准数据;前端框选 crop;LLM 过滤多余/无意义字幕 |
| `learn-translate` | 学习资料转译+翻译字幕 | 提音 → 转写(decode_full) → LLM 翻译 → ASS | 面向讲解/学习类视频;应用本次修复的 decode_full 无 VAD 整段解码 + 日语幻觉清洗,优先"说了的话不漏"(弱语音/快速讲解召回),再由幻觉清洗移除无语音段长套话 |
**工作流参数标注约定**learn-translate 示范,可复用到任何工作流):JSON 不支持注释,
因此"参数理由"以节点 `params``_note_<参数名>` 键存放(`_` 前缀说明键,节点执行时
只读真实参数键、忽略 `_note_*`,零运行影响);节点级参数手册放 `params._node_help`
(多行字符串,含关键参数解释与正反例)。`WorkflowNode.from_dict` 会完整保留 params
全部键(不清洗未知键),seed 入库/前端展示均不丢。查看方式:管理后台/工作流编排页
打开工作流 definition JSON 即可见每个参数旁的理由说明。
**decode_full 参数**(本次修复,faster-whisper 节点):默认 `false`(保持 VAD 现状);
`true` 时强制无 VAD 整段解码并跳过自动 VAD 分析,救回被 silero VAD 当非语音剔除的
弱语音/呻吟/BGM 混叠人声(实测 savr-1054 全片 115 条 → 340 条),副作用为无语音段
长时寒暄幻觉,处理方式:whisper 转录后立即**连带时间戳把整条 cue 删除**(剩余重编号,
`nodes/subtitle_cleanup.py``clean_japanese_hallucinations`),不留下 `-` 占位污染
下游(占位会渲染进 ASS 成可见减号);llm-translate 翻译后同样整条删除中文长时寒暄
幻觉(`clean_srt_text`)。短时(≤15s)相同词可能是剧情真实道晚安,保留。
最终产物按 `上传文件名.标识.时间戳` 重命名(如 `test01.zh-CN.20260815123000.srt`), 最终产物按 `上传文件名.标识.时间戳` 重命名(如 `test01.zh-CN.20260815123000.srt`),
标识优先取节点的 `target_language` 参数,否则用产物别名。 标识优先取节点的 `target_language` 参数,否则用产物别名。
@@ -121,8 +137,8 @@ whisper 节点按以下顺序解析模型路径,默认避免从远端下载:
### 切换模型不改代码 ### 切换模型不改代码
- 模型是工作流 DAG 中 asr 节点的 `model_path` 参数(**数据**),两个内置工作流 - 模型是工作流 DAG 中 asr 节点的 `model_path` 参数(**数据**),内置工作流
均已显式声明:demo 用 `faster-whisper-large-v3`zh-direct 用中文直出模型。 均已显式声明:demo/learn-translate`faster-whisper-large-v3`zh-direct 用中文直出模型。
- 切换模型 = 改 `workflows/*.json` 或管理页面 DAG JSON → 保存新版本 → 发布, - 切换模型 = 改 `workflows/*.json` 或管理页面 DAG JSON → 保存新版本 → 发布,
全程不涉及代码;新库启动时从 JSON 重新 seed。 全程不涉及代码;新库启动时从 JSON 重新 seed。
- 默认工作流定义存放在 `workflows/*.json`(数据文件),代码只负责加载。 - 默认工作流定义存放在 `workflows/*.json`(数据文件),代码只负责加载。
@@ -0,0 +1,138 @@
# 调研记录:whisper "说话没识别" 漏句问题 与 decode_full 方案验证
> 本文档记录 2026-09 对"转写时有人说话但没识别出来(漏句/漏识别)"问题的完整调研过程、
> 实验数据与结论修正。调研中发现**早期结论被后续更严谨的验证推翻**,特此如实记录,
> 供后续对话继续推进时参考,避免重复踩坑。
>
> 状态:**调研暂告段落,方案未定稿,代码改动未提交**。详见文末"当前状态"。
---
## 1. 问题背景
用户反馈:whisper 转写时**动态配置参数**(自动 VAD 调参),生成的字幕中有时出现
"有人说话但没识别出来"的情况,怀疑与动态参数配置有关。
链路(demo/learn-translate):提音(ffmpeg-extract) → 转写(faster-whisper) → 翻译(llm) → ASS。
whisper 节点关键动态逻辑(改动前):
- `vad_filter=true`(默认开启)
- `WOV_AUTO_VAD=1`(默认):每视频信号分析 → 动态生成 `vad_parameters`
`nodes/vad_profiler.py`,按静音比例/BGM 覆盖/长停顿 四分支给 threshold/min_silence/speech_pad
- `chunk_seconds=60` 分块、`condition_on_previous_text=false``beam_size=1`
---
## 2. 实验一:savr-1054 全片 A/BVAD vs 无VAD
素材:`/mnt/fnOS/123/savr-1054/4k2.me@savr01054_2_8k.mp4`1383s,呻吟/BGM 密集)。
| 配置 | 全片条数 | 覆盖时长 | 幻觉长段 |
| --- | --- | --- | --- |
| 生产 VADvad_filter=true+自动VADthreshold0.5/ms1000/pad200 | 115 | 621s | 0 |
| 无 VADdecode_full 前身) | 369 | 983s | 有(119-149s、600-630s 30s"ご視聴…" |
- 无 VAD 确实**大幅增加召回**115→369),其中 65-135s 的"入ってるところ見える?
/奥までジュボジュボ入ってるよ"等与线上 CN.srt 空洞吻合 → **当时判定为"救回真话"**
- 但无 VAD 副作用明显:119-149s/600-630s 出现 30s 长"ご視聴ありがとうございました"幻觉。
### 2.1 人声分离(demucs)实验
- `htdemucs --two-stems=vocals` 分离后:**vocals 轨 VAD 切段与混音几乎一样**(瓶颈是
silero 对呻吟/轻语本身概率低,不是底噪);
- 分离 vocals + 无VAD 与混音无VAD 召回相当(16 vs 15 条/窗口)→ **人声分离不解决问题**
只是把音乐底噪去掉,呻吟/轻语仍是低概率语音。
### 2.2 min_silence / threshold 扫描
- 固定 threshold=0.5 只调 min_silence(300→2000ms):真话召回**不变**450s 窗口恒 1 条、
546s 窗口恒 1 条)→ **只调 min_silence 无效**(它只决定断句,不能把 silero 没标成
语音的弱语音变成语音段);
- 调 threshold(0.3~0.6):覆盖仅 44%→45%,真话也救不回(呻吟/BGM 混叠使 silero 切段能力
天然不足)。
### 2.3 每片独立 VAD / 精细能量分段 模拟
- "每 60s 块独立调参"24 块中 11 块会落到 bgm 分支(thr0.3),但段切更碎、覆盖不升,
**NO_VAD 降级反而新增 60s/36s/20s 空洞 + 幻觉**sim_perchunk 318 条,1080-1140s 整段空);
- 精细能量分段(RMS>900 切语音候选段):连真实说话段都定位错(1065-1115s 只找到 3 小段、
解码出幻觉)→ **能量/VAD 前置切段对混叠声学都不可靠**
---
## 3. 关键结论修正(重要)
### 3.1 "能量低=幻觉" 是错的
savr-1174 上精确验证:**938s 轻语真话 mean -40dBVAD 与无VAD 都能稳定识别**;
而 120s/140s/210s 真话区 mean 仅 -45~-48dB(整片录音电平低),**whisper 仍稳定识别**。
### 3.2 "稳定性(跨配置一致)= 真话" 是相对可靠的判据
- 真话(938s"吸い付かないでよ"、1065s"そういうプレイ好きな人?"):VAD/无VAD/beam1/beam5
**多配置稳定复现同一句**
- 幻听(1260s"手ついてたら…"):VAD 配置下变"おやすみなさい"、无VAD 才出该句 → 内容
跨配置**不稳定** = 无真实语音锚点。
### 3.3 置信度字段无法区分真幻
faster-whisper segment 的 `avg_logprob` / `no_speech_prob`:真话 60s(avg_logprob -0.42) 比
幻觉 1260s(-0.63) 还高;no_speech_prob 幻觉 330s(0.28) 比真话 1065s(0.30) 还低 → **不可用**
### 3.4 "decode_full 救回 VAD 漏掉的真话 2.7 倍" —— 被推翻
savr-1174 全片:decode_full(beam1) 463 条 vs 生产 VAD 170 条,早期结论是"救回弱语音"。
**逐条 + 分层抽样 VAD 复查后**
- decode_full 新增内容中,**大量(抽样 45 条中 20 条 VAD 静默)是 VAD 静默段的幻听**,
beam1 把噪声/重复碎片劣化转写成"词句"(如"何がやばいんだ"实为"何?何?何?"、2243s
beam1/beam5 结果不同=不稳定);
- 真正被救回的低电平真话(120s/140s/210s**VAD 其实也能识别**(只是旧自动 VAD 参数
或分块把它们漏了)→ 说明问题在**旧自动 VAD 参数选取**而非 VAD 机制本身。
### 3.5 无法用单一信号完美区分真幻
| 信号 | 效果 |
| --- | --- |
| 能量/RMS | 无效:savr-1174 录音电平低,真话-45dB vs 幻听-51dB 难分;高响度(如-22dB 效果音段)也可能是幻听 |
| avg_logprob/no_speech | 无效(见 3.3 |
| 寒暄词表 | 有效但覆盖面窄(savr-1174 decode_full 后寒暄类已 0 命中,即已被清干净) |
| **VAD 复查**decode_full 后逐条用 VAD 复查,VAD 能识别才保留) | **相对最可靠**,但会误删 VAD 漏掉的短真语气词(うん/はい/んー),且切窗不准会误删响亮真话 |
---
## 4. 代码改动现状(未提交)
以下改动已写入工作区(`git status` 可见),**未经用户确认提交**
| 文件 | 改动 | 状态 |
| --- | --- | --- |
| `nodes/subtitle_cleanup.py` | 幻觉清洗改为**整条删除式**`remove_hallucination_entries`:≥15s 命中寒暄词表 → 序号+时间轴+文本全删、剩余重编号);新增日语词表 `JAPANESE_HALLUCINATION_TOKENS` | 已改 |
| `nodes/whisper.py` | 新增 `decode_full` 参数(默认 false):true 时无VAD 整段解码 + 跳过自动VAD + 日语幻觉整条删除 | 已改 |
| `nodes/llm.py` | `clean_srt_text` 由替换 `-` 占位改为整条删除;回滚了临时的 `-` 跳过逻辑 | 已改 |
| `workflows/learn-translate.json` | 新工作流"学习资料转译+翻译字幕"decode_full=true;每参数 `_note_<名>` 标注理由+正反例;`_node_help` 参数手册 | 新增 |
| `tests/*` | test_hallucination_mask 重写为删除式;whisper decode_full 测试;learn-translate 加载/标注测试;test_seed 计数 3→4 | 已改 |
| `AGENTS.md` | decode_full 说明、工作流表、参数标注约定 | 已改 |
测试:相关 97 passed(全量 357 passed / 5 个既有环境失败与本次无关:ffmpeg 4.2 不支持
`force_divisible_by` 滤镜、home 目录解析)。
**端到端已验证**savr-1174 前 200s decode_full → 幻觉整条删除(无 0-90s 占位重叠)、
真实内容 60.03s 起时间轴正确、翻译 36 条、ASS 72 Dialogue 0 噪点。
---
## 5. 待决问题(新对话继续)
1. **decode_full 是否值得作为默认增强**savr-1174 显示其对呻吟/BGM 密集视频**弊大于利**
(新增大量幻听),但对纯对话/低电平场景(120s 真话)有效。可能只适合特定声学;
2. **"VAD 复查过滤"方案是否落地**:可保留 decode_full 救回的真话并剔幻听,但需解决
短真语气词误删与切窗不稳问题;实现成本中等;
3. **旧自动 VAD 参数为何漏低电平真话**(120s 这类 VAD 单窗能识别但整片 A 漏了)——
可能是分块边界/自动参数分支误判,值得单独排查;
4. **现有代码改动去留**subtitle_cleanup 整条删除式、whisper decode_full、learn-translate
工作流——保留、调整还是回滚待定;
5. **如何真正"听"音频验证**(本调研最大局限:未实际听音,靠多配置转写交叉推断)。
### 实验产物(临时文件,可复用)
```
/tmp/savr1054_2.wav savr-1054 全片 16k 音频
/tmp/savr1054_2_vocals16k.wav demucs 分离 vocals 轨
/tmp/run_A_vad.srt savr-1054 生产VAD 全片 (115条)
/tmp/run_B_novad.srt savr-1054 无VAD 全片 (369条)
/tmp/savr1174_1.wav savr-1174 全片 16k 音频
/tmp/savr1174_A_vad.srt savr-1174 生产VAD 全片 (170条)
/tmp/savr1174_B_decodefull.srt savr-1174 decode_full beam1 (463条)
/tmp/savr1174_D.srt savr-1174 decode_full beam5 (336条)
```
+1 -1
View File
@@ -271,7 +271,7 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
lines[text_index] = translated_lines[index] lines[text_index] = translated_lines[index]
# 长时寒暄幻觉词清洗:对展示时长超过阈值且含收尾/开场寒暄(晚安、感谢观看 # 长时寒暄幻觉词清洗:对展示时长超过阈值且含收尾/开场寒暄(晚安、感谢观看
# 等)的条目,文本替换为 '-'(由后续过滤流程移除),避免幻觉占位污染正片; # 等)的条目,**连带时间戳整条删除**(剩余重编号),避免幻觉占位污染正片/ASS
# 短时(≤阈值)如剧情中真实互道'晚安'则保留,不误删。见 # 短时(≤阈值)如剧情中真实互道'晚安'则保留,不误删。见
# nodes/subtitle_cleanup.py。 # nodes/subtitle_cleanup.py。
srt_body = "\n".join(lines) + "\n" srt_body = "\n".join(lines) + "\n"
+103 -42
View File
@@ -1,4 +1,4 @@
"""Subtitle cleanup: mask long-duration closing/greeting hallucinations. """Subtitle cleanup: remove long-duration closing/greeting hallucinations.
Background (real run 20260905115050): after fixing the timing alignment, Background (real run 20260905115050): after fixing the timing alignment,
subtitles still contain "closing/greeting hallucination words" - fixed subtitles still contain "closing/greeting hallucination words" - fixed
@@ -7,13 +7,20 @@ phrases like 'wan an / gan xie guan kan / gan xie nin de guan kan'
empty segments, filling a full 30s block, unrelated to video content. empty segments, filling a full 30s block, unrelated to video content.
Some 2s 'good night' might be real dialogue, so it must be kept. Some 2s 'good night' might be real dialogue, so it must be kept.
Plan (confirmed by user): after translation, mask subtitle entries whose 处理策略(2026-09 用户确认改为"整条剔除"):
*display duration* exceeds a threshold AND whose text contains a greeting 幻觉识别出后(展示时长 ≥ 阈值 且 文本命中套话词表),应**连带时间戳把整条
hallucination token - replace the text with '-' so the downstream SRT/filter 字幕 cue 删除**(剩余条目重新编号),而不是把文本替换成 '-' 占位留给下游——
pipeline drops it. The duration threshold protects short real greetings. 占位会一路流到 ASS 渲染成可见的"减号"、在翻译/过滤阶段都要额外特殊处理,
处理位置绕且不彻底。因此清洗统一为:**在幻觉产生处(whisper 转录后 / LLM
翻译后)整条删除**,时间轴随之消失,字幕序号重新连续编号。
Threshold is derived from real run data: 30s hallucinations vs 2s real words, 两类词表:
a clear gap; default 15s (>=15s masks, <15s keeps). - HALLUCINATION_TOKENS:中文(LLM 翻译产物中的寒暄,如"晚安/感谢观看");
- JAPANESE_HALLUCINATION_TOKENS:日文(whisper decode_full 无 VAD 解码在
无语音段直接输出的套话,如"おやすみなさい/ご視聴ありがとうございました")。
阈值从真实运行实测数据判定:30s 幻觉占位 vs 2s 真实词,分界明显,
默认 threshold=15s(≥15s 才删除;<15s 的相同词可能是剧情真实道晚安,保留)。
Pure functions, unit-testable (tests/test_hallucination_mask.py). Pure functions, unit-testable (tests/test_hallucination_mask.py).
""" """
@@ -29,32 +36,31 @@ HALLUCINATION_TOKENS = (
"再见", "多谢观看", "观看愉快", "再见", "多谢观看", "观看愉快",
) )
# Display-duration threshold (seconds): only mask entries longer than this. # Display-duration threshold (seconds): only remove entries longer than this.
DEFAULT_THRESHOLD_SECONDS = 15.0 DEFAULT_THRESHOLD_SECONDS = 15.0
_SRT_BLOCK = re.compile( # 日文 ASR 直出(whisper 节点 decode_full 无 VAD 整段解码)的收尾/寒暄幻觉词。
r"(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*\n(.*?)(?=\n\s*\d+\s*\n|\Z)", # 无 VAD 解码会把无语音/音乐/呻吟段当语音,whisper 常在这些段重复输出套话
re.DOTALL, # (实测 savr-1054 全片 119-149s/600-630s 等出现 30s 长"おやすみなさい"
# "ご視聴ありがとうございました")。短时(≤阈值)的相同词可能是剧情里真实
# 互道晚安,须保留;仅删除展示时长 ≥ 阈值的条目(与中文表同一机制)。
JAPANESE_HALLUCINATION_TOKENS = (
"おやすみなさい", # 晚安
"ご視聴ありがとうございました", # 感谢观看
"ありがとうございました", # 感谢
"ご視聴ありがとうございます", # 感谢观看(现在时)
"また見てね", # 下次再见
"お楽しみに", # 敬请期待
"チャンネル登録よろしくお願いします", # 求订阅
"さようなら", # 再见
"音楽", # 音乐(自述)
"Goodbye",
"Thank you for watching",
) )
# 解析 SRT:每个 cue 由 序号行 + 时间轴行 + 文本行(可能多行) + 空行 组成。
def mask_hallucination_text( # 采用逐行解析(不依赖可能粘连的跨 cue 正则),兼容文本多行。
entries: list[dict], _TS_RE = re.compile(r"^(\d{2}:\d{2}:\d{2},\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2},\d{3})\s*$")
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
) -> list[dict]:
"""Return a new list where long-duration greeting entries have text='-'.
duration = end - start. Only entries whose duration >= threshold AND text
contains any HALLUCINATION_TOKENS are masked. Input list is not mutated.
"""
cleaned = []
for entry in entries:
duration = entry.get("end", 0.0) - entry.get("start", 0.0)
text = entry.get("text", "")
if duration >= threshold_seconds and any(t in text for t in HALLUCINATION_TOKENS):
entry = dict(entry, text="-")
cleaned.append(entry)
return cleaned
def _ts_to_seconds(ts: str) -> float: def _ts_to_seconds(ts: str) -> float:
@@ -64,22 +70,77 @@ def _ts_to_seconds(ts: str) -> float:
return int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(millis) / 1000 return int(hours) * 3600 + int(minutes) * 60 + int(seconds) + int(millis) / 1000
def remove_hallucination_entries(
srt_text: str,
tokens: tuple[str, ...],
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
) -> str:
"""删除 SRT 中展示时长 ≥ 阈值且文本命中 tokens 的**整条 cue**(连带时间戳)。
被删除的 cue 不再输出(序号、时间轴、文本全部消失),剩余 cue 按原顺序
重新从 1 编号,保证产物是合法连续的 SRT。输入不被修改(纯函数)。
用途:幻觉在产生处直接剔除——whisper decode_full(日语词表)与 LLM 翻译后
(中文词表)均调用本函数,避免 '-' 占位一路流到 ASS 渲染成可见减号。
"""
lines = srt_text.splitlines()
kept: list[str] = []
number = 1
index = 0
while index < len(lines):
line = lines[index]
if line.strip() and line.strip().isdigit() and index + 1 < len(lines):
ts_match = _TS_RE.match(lines[index + 1].strip())
if ts_match:
# 收集本 cue 文本:时间轴后直到空行前的所有非空行(可多行)。
text_lines: list[str] = []
cursor = index + 2
while cursor < len(lines) and lines[cursor].strip():
text_lines.append(lines[cursor].strip())
cursor += 1
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)。
kept.append(
f"{number}\n{lines[index + 1].strip()}\n{text}\n"
)
number += 1
# 幻觉 cue:整条跳过(序号/时间轴/文本都不输出)。
index = cursor
continue
# 非 cue 行(文件头/尾部噪声)跳过,避免序号/空行残留。
index += 1
return "\n".join(kept).rstrip() + "\n"
def clean_japanese_hallucinations(
srt_text: str,
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
) -> str:
"""从 SRT 中整条删除日文收尾/寒暄长时幻觉(ASR 无 VAD 解码兜底)。
whisper 节点 decode_full=true(无 VAD 整段解码)在无语音段会输出长时
套话占位;本函数把展示时长 ≥ 阈值且文本含 JAPANESE_HALLUCINATION_TOKENS
的**整条 cue 连带时间戳删除**、剩余重编号。短时相同词(剧情真实道晚安)
保留。纯函数,不修改输入。
"""
return remove_hallucination_entries(srt_text, JAPANESE_HALLUCINATION_TOKENS, threshold_seconds)
def clean_srt_text( def clean_srt_text(
srt_text: str, srt_text: str,
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS, threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
) -> str: ) -> str:
"""Mask long-duration greeting hallucinations in an SRT string. """从 SRT 中整条删除中文长时寒暄幻觉(LLM 翻译产物清洗)。
Parses each cue's start/end/text, applies mask_hallucination_text, and 对展示时长 ≥ 阈值且文本含 HALLUCINATION_TOKENS 的 cue 连带时间戳整条
rewrites the block keeping the original time line when not masked. 删除、剩余重编号;短时相同词(剧情真实互道晚安)保留。与
clean_japanese_hallucinations 同机制,词表不同。纯函数,不修改输入。
""" """
def _replace(match) -> str: return remove_hallucination_entries(srt_text, HALLUCINATION_TOKENS, threshold_seconds)
start = _ts_to_seconds(match.group(1))
end = _ts_to_seconds(match.group(2))
text = match.group(3).strip()
entry = {"start": start, "end": end, "text": text}
cleaned = mask_hallucination_text([entry], threshold_seconds)
new_text = cleaned[0]["text"]
return f"{match.group(1)} --> {match.group(2)}\n{new_text}"
return _SRT_BLOCK.sub(_replace, srt_text)
+23 -7
View File
@@ -228,6 +228,10 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
# language 默认日语;vad_filter 默认开启(用户 2026-08 决定):过滤静音 # language 默认日语;vad_filter 默认开启(用户 2026-08 决定):过滤静音
# 段以提速并减少无语音处幻觉;长静音时 VAD 压缩时间轴可能轻微错位, # 段以提速并减少无语音处幻觉;长静音时 VAD 压缩时间轴可能轻微错位,
# 如需极致对齐可在工作流参数中显式关闭。 # 如需极致对齐可在工作流参数中显式关闭。
# decode_full=true(默认 false):VAD 对呻吟/轻语/BGM 混叠声学切段会
# 把真话当非语音剔除(实测 savr-1054 全片仅召回 115 条),开启后强制
# 无 VAD 整段解码(vad_filter=False 且跳过自动 VAD 分析)以召回弱语音,
# 代价是无语音段会产生长时套话幻觉,由下方日语幻觉清洗兜底移除。
# task 默认 transcribe,中文直出模型可传 translate 直接翻译为目标语言。 # task 默认 transcribe,中文直出模型可传 translate 直接翻译为目标语言。
# condition_on_previous_text 默认 False:长音频下开启会导致重复/漂移, # condition_on_previous_text 默认 False:长音频下开启会导致重复/漂移,
# 关闭后每个 30s 窗口独立解码,是 faster-whisper 官方建议的长音频方案。 # 关闭后每个 30s 窗口独立解码,是 faster-whisper 官方建议的长音频方案。
@@ -236,12 +240,12 @@ 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则根据本音频 # decode_full 时强制无 VAD不传 vad_filter/vad_parameters也不跑自动 VAD
# 信号分析自动确定 VAD 参数(BGM 覆盖/静音比例/长停顿),改善碎片化与漏识别 # 分析(分析结果对呻吟/轻语类音频无效,只会把整块切碎/剔除真话)
# 门控 WOV_AUTO_VAD=0 可关闭;显式传入 vad_parameters 时跳过。 decode_full = bool(request.params.get("decode_full", False))
vad_parameters = request.params.get("vad_parameters") vad_parameters = request.params.get("vad_parameters")
vad_filter = bool(request.params.get("vad_filter", True)) vad_filter = bool(request.params.get("vad_filter", True))
if vad_filter and not vad_parameters and os.getenv("WOV_AUTO_VAD", "1") == "1": if not decode_full and vad_filter and not vad_parameters and os.getenv("WOV_AUTO_VAD", "1") == "1":
try: try:
from nodes.vad_profiler import vad_parameters_for_audio from nodes.vad_profiler import vad_parameters_for_audio
@@ -272,13 +276,14 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
if (Path(request.output_dir).parent.parent / PAUSE_FLAG).exists(): if (Path(request.output_dir).parent.parent / PAUSE_FLAG).exists():
raise RuntimeError(f"whisper 被暂停(run {request.run_id}") raise RuntimeError(f"whisper 被暂停(run {request.run_id}")
chunk_started = time.monotonic() chunk_started = time.monotonic()
# decode_full=true 时 vad_filter 传 False:跳过 VAD 剔除弱语音段。
segments, _info = model.transcribe( segments, _info = model.transcribe(
str(chunk), str(chunk),
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=vad_filter, vad_filter=False if decode_full else vad_filter,
vad_parameters=vad_parameters, vad_parameters=None if decode_full else 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)
), ),
@@ -295,8 +300,19 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
chunk_seconds / chunk_elapsed if chunk_elapsed > 0 else 0.0, chunk_seconds / chunk_elapsed if chunk_elapsed > 0 else 0.0,
time.monotonic() - transcribe_started, time.monotonic() - transcribe_started,
) )
# decode_full(无 VAD)副作用:无语音/音乐/呻吟段会产生长时寒暄套话幻觉
# (おやすみなさい/ご視聴ありがとうございました 等),在此**连带时间戳整条
# 剔除**(序号/时间轴/文本全删、剩余重编号),不留下 '-' 占位污染下游
# (占位会渲染进 ASS 成减号、翻译/过滤都要额外处理);短时(≤15s)相同词
# 可能是剧情真实道晚安,保留。见 nodes/subtitle_cleanup.py。
if decode_full:
from nodes.subtitle_cleanup import clean_japanese_hallucinations
body = clean_japanese_hallucinations("\n".join(lines))
else:
body = "\n".join(lines)
output_path = output_dir / "transcript.srt" output_path = output_dir / "transcript.srt"
output_path.write_text("\n".join(lines), encoding="utf-8") output_path.write_text(body, encoding="utf-8")
return InvokeResponse(status="completed", outputs={"srt_uri": str(output_path)}) return InvokeResponse(status="completed", outputs={"srt_uri": str(output_path)})
except Exception as exc: # noqa: BLE001 except Exception as exc: # noqa: BLE001
# 模型加载或转写异常统一转换为 failed 响应,不让调度线程崩溃。 # 模型加载或转写异常统一转换为 failed 响应,不让调度线程崩溃。
+130 -52
View File
@@ -1,4 +1,4 @@
"""长时寒暄幻觉清洗测试(先红后绿)。 """字幕幻觉清洗测试(先红后绿)。
背景(实测 run 20260905115050):修复时间对齐后,字幕仍残留四类问题, 背景(实测 run 20260905115050):修复时间对齐后,字幕仍残留四类问题,
其中"寒暄/收尾幻觉词"最具确定性、可规则化: 其中"寒暄/收尾幻觉词"最具确定性、可规则化:
@@ -9,12 +9,18 @@
- 仅 2 条时长 ~2s(如 720.00-722.00 '晚安')可能是剧情里真的说了"晚安" - 仅 2 条时长 ~2s(如 720.00-722.00 '晚安')可能是剧情里真的说了"晚安"
属于真实内容,不应误删。 属于真实内容,不应误删。
方案(用户确认):日文转译完成后,对**展示时长过长**(≥阈值)且文本匹配 方案(用户 2026-09 确认改为**整条剔除**):幻觉识别出后(展示时长 ≥ 阈值
寒暄词表的条目,把文本替换为 '-' 占位,由后续处理(SRT/过滤流程)移除。 且文本命中寒暄词表),应**连带时间戳把整条字幕 cue 删除**(剩余重新编号),
这样既清掉幻觉占位,又用"时长阈值"保住可能为真实对话的短时寒暄词。 而不是替换成 '-' 占位——占位会一路流到 ASS 渲染成可见减号,处理位置绕且
不彻底。因此清洗统一为在幻觉产生处(whisper decode_full 转录后 / LLM 翻译
后)直接删除整条。
两类词表:
- HALLUCINATION_TOKENS:中文(LLM 翻译产物);
- JAPANESE_HALLUCINATION_TOKENS:日文(whisper decode_full 直出)。
阈值从本次真实运行实测数据判定:30s 幻觉占位 vs 2s 真实词,分界明显, 阈值从本次真实运行实测数据判定:30s 幻觉占位 vs 2s 真实词,分界明显,
本测试选 threshold=15s>15s 才视为幻觉;≤15s 保留)。 本测试选 threshold=15s15s 才删除;≤15s 保留)。
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,63 +31,135 @@ import pytest
from nodes.subtitle_cleanup import ( from nodes.subtitle_cleanup import (
DEFAULT_THRESHOLD_SECONDS, DEFAULT_THRESHOLD_SECONDS,
HALLUCINATION_TOKENS, HALLUCINATION_TOKENS,
mask_hallucination_text, clean_japanese_hallucinations,
clean_srt_text,
) )
def _mk(start: float, end: float, text: str) -> dict: def test_remove_long_hallucination_whole_cue() -> None:
"""构造一个字幕条目(测试辅助)""" """30s 的'晚安/感谢观看'(幻觉占位)须整条删除:序号+时间轴+文本都消失"""
return {"start": start, "end": end, "text": text} srt = (
"1\n00:00:00,000 --> 00:00:02,000\n真实内容\n\n"
"2\n00:01:00,000 --> 00:01:30,000\n晚安\n\n"
"3\n00:02:00,000 --> 00:02:30,000\n感谢您的观看\n\n"
"4\n00:03:00,000 --> 00:03:02,000\n继续真实\n\n"
)
out = clean_srt_text(srt)
# 幻觉条目连带时间戳整条消失。
assert "00:01:00,000 --> 00:01:30,000" not in out
assert "晚安" not in out
assert "00:02:00,000 --> 00:02:30,000" not in out
assert "感谢您的观看" not in out
# 真实条目保留且序号重新连续编号(原 1、4 -> 新 1、2)。
assert out.startswith("1\n00:00:00,000 --> 00:00:02,000\n真实内容")
assert "2\n00:03:00,000 --> 00:03:02,000\n继续真实" in out
def test_long_hallucination_masked() -> None: def test_remove_short_hallucination_preserved() -> None:
"""30s 的'晚安/感谢观看'幻觉占位)必须被替换为 '-'""" """2s 的'晚安'剧情真实道晚安)必须保留,不误删"""
entries = [ srt = (
_mk(60.0, 90.0, "晚安"), "1\n00:12:00,000 --> 00:12:02,000\n晚安\n\n"
_mk(90.0, 120.0, "感谢您的观看"), "2\n00:12:03,000 --> 00:12:06,000\n明天见\n\n"
_mk(1260.0, 1289.98, "感谢您的观看"), )
] out = clean_srt_text(srt)
out = mask_hallucination_text(entries) assert "晚安" in out
assert all(e["text"] == "-" for e in out) assert "明天见" in out
assert out.count("-->") == 2
def test_short_hallucination_preserved() -> None: def test_remove_non_hallucination_always_preserved() -> None:
"""2s 的'晚安'(可能为剧情真实对话)必须保留,不误删。"""
entries = [
_mk(720.0, 722.0, "晚安"),
_mk(238.0, 240.0, "非常感谢您的观看。"),
]
out = mask_hallucination_text(entries)
assert out[0]["text"] == "晚安"
assert out[1]["text"] == "非常感谢您的观看。"
def test_non_hallucination_always_preserved() -> None:
"""普通内容(即使很长)绝不能被当成寒暄幻觉处理。""" """普通内容(即使很长)绝不能被当成寒暄幻觉处理。"""
entries = [ srt = (
_mk(0.0, 30.0, "今天我将为您提供精神调适服务"), "1\n00:00:00,000 --> 00:00:25,000\n"
_mk(10.0, 40.0, "请尽量放松,无论多少次都能感到舒适愉悦"), "今天我将为您提供精神调适服务\n\n"
] )
out = mask_hallucination_text(entries) out = clean_srt_text(srt)
assert out[0]["text"] == "今天我将为您提供精神调适服务" assert "精神调适" in out
assert out[1]["text"] == "请尽量放松,无论多少次都能感到舒适愉悦"
def test_threshold_boundary() -> None: def test_remove_threshold_boundary() -> None:
"""阈值边界:好 ≥ 阈值才清洗< 阈值保留。""" """阈值边界:好 ≥ 阈值才删除< 阈值保留。"""
entries = [ srt_ge = "1\n00:00:00,000 --> 00:00:15,000\n晚安\n\n"
_mk(0.0, 15.0, "晚安"), # 恰好 15s → 清洗(≥ threshold assert "晚安" not in clean_srt_text(srt_ge)
_mk(0.0, 14.99, "晚安"), # 14.99s → 保留 srt_lt = "1\n00:00:00,000 --> 00:00:14,990\n晚安\n\n"
] assert "晚安" in clean_srt_text(srt_lt)
out = mask_hallucination_text(entries, threshold_seconds=15.0)
assert out[0]["text"] == "-"
assert out[1]["text"] == "晚安" def test_remove_resequences_numbers() -> None:
"""删除中间 cue 后,剩余条目序号从 1 连续递增(合法 SRT)。"""
srt = (
"1\n00:00:00,000 --> 00:00:01,000\n\n\n"
"2\n00:01:00,000 --> 00:01:30,000\n晚安\n\n" # 幻觉被删
"3\n00:02:00,000 --> 00:02:01,000\n\n\n"
"4\n00:03:00,000 --> 00:03:01,000\n\n\n"
)
out = clean_srt_text(srt)
lines = [l for l in out.splitlines() if l.strip()]
# 重新编号:序号应为 1,2,3 各一次。
import re
numbers = [int(l) for l in lines if re.fullmatch(r"\d+", l.strip())]
assert numbers == [1, 2, 3]
# ---------------------------------------------------------------------------
# 日语(ASR 直出)幻觉清洗 —— whisper 节点 decode_full 兜底用
# ---------------------------------------------------------------------------
def test_jp_long_hallucination_removed() -> None:
"""30s 的'おやすみなさい/ご視聴ありがとうございました'(无语音段幻觉)整条删除。"""
srt = (
"1\n00:00:00,000 --> 00:00:02,000\n気持ちいい\n\n"
"2\n00:00:10,000 --> 00:00:40,000\nおやすみなさい\n\n"
"3\n00:00:41,000 --> 00:01:11,000\nご視聴ありがとうございました\n\n"
"4\n00:01:12,000 --> 00:01:14,000\nまた明日ね\n\n"
)
out = clean_japanese_hallucinations(srt)
assert "おやすみなさい" not in out
assert "ご視聴ありがとうございました" not in out
assert "00:00:10,000 --> 00:00:40,000" not in out
assert "気持ちいい" in out
assert "また明日ね" in out
def test_jp_short_hallucination_preserved() -> None:
"""2s 的'おやすみなさい'(剧情真实道晚安)须保留,不误删。"""
srt = (
"1\n00:12:00,000 --> 00:12:02,000\nおやすみなさい\n\n"
"2\n00:12:03,000 --> 00:12:06,000\nまた明日ね\n\n"
)
out = clean_japanese_hallucinations(srt)
assert "おやすみなさい" in out
assert "また明日ね" in out
def test_jp_non_hallucination_always_preserved() -> None:
"""普通长句(即使很长)绝不能被当成日语幻觉处理。"""
srt = (
"1\n00:00:00,000 --> 00:00:25,000\n"
"今日はお客様のために精神整備を務めさせていただきます\n\n"
)
out = clean_japanese_hallucinations(srt)
assert "精神整備" in out
def test_jp_threshold_15s() -> None:
"""日语清洗同样遵守 15s 时长阈值:15s 恰好删除,14.99s 保留。"""
srt = "1\n00:00:00,000 --> 00:00:15,000\nおやすみなさい\n\n"
assert "おやすみなさい" not in clean_japanese_hallucinations(srt)
srt2 = "1\n00:00:00,000 --> 00:00:14,990\nおやすみなさい\n\n"
assert "おやすみなさい" in clean_japanese_hallucinations(srt2)
@pytest.mark.integration @pytest.mark.integration
def test_mask_does_not_mutate_input() -> None: def test_jp_middle_removal_resequences() -> None:
"""清洗不得修改原始条目对象(纯函数约束)""" """删除中间日语幻觉后剩余条目重编号且文本/时间正确对应"""
entries = [_mk(60.0, 90.0, "晚安")] srt = (
original_text = entries[0]["text"] "1\n00:00:00,000 --> 00:00:02,000\n\n\n"
mask_hallucination_text(entries) "2\n00:00:10,000 --> 00:00:40,000\nおやすみなさい\n\n" # 删
assert entries[0]["text"] == original_text "3\n00:00:41,000 --> 00:00:43,000\n\n\n"
)
out = clean_japanese_hallucinations(srt)
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
+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
+73
View File
@@ -1426,3 +1426,76 @@ def test_vlm_truncate_at_stop() -> None:
assert _truncate_at_stop("还有没有什么困扰 或者奇怪的地方吗") == ( assert _truncate_at_stop("还有没有什么困扰 或者奇怪的地方吗") == (
"还有没有什么困扰 或者奇怪的地方吗" "还有没有什么困扰 或者奇怪的地方吗"
) )
# ---------------------------------------------------------------------------
# decode_full:无 VAD 整段解码 + 日语幻觉清洗(TDD)
# ---------------------------------------------------------------------------
def test_whisper_decode_full_disables_vad(tmp_path, monkeypatch) -> None:
"""decode_full=true 时 transcribe 收到 vad_filter=False 且不触发自动 VAD。"""
captured = {}
class FullModel:
def __init__(self, *args, **kwargs):
pass
def transcribe(self, path, **kwargs):
captured["vad_filter"] = kwargs.get("vad_filter")
captured["vad_parameters"] = kwargs.get("vad_parameters")
return ([FakeSegment(0, 1, "ok")], None)
monkeypatch.setitem(
sys.modules, "faster_whisper", types.SimpleNamespace(WhisperModel=lambda *a, **k: FullModel())
)
_make_wav(tmp_path / "audio.wav", 5)
# 默认 vad_filter=true(保持现状),显式 decode_full=true 强制无 VAD 整段解码。
resp = whisper_invoke(
_whisper_request(tmp_path, params={"language": "ja", "decode_full": True})
)
assert resp.status == "completed"
assert captured["vad_filter"] is False
assert captured["vad_parameters"] is None
def test_whisper_decode_full_cleanup_jp_hallucination(tmp_path, monkeypatch) -> None:
"""decode_full=true 时产物 SRT 中日文长时寒暄幻觉被**整条删除**(连带时间戳)。"""
class HallucModel:
def __init__(self, *args, **kwargs):
pass
def transcribe(self, path, **kwargs):
# 模拟无 VAD 解码:正常句 + 一条 30s '晚安'幻觉占位 + 一条 2s 真实晚安。
return (
[
FakeSegment(0, 2, "気持ちいい"),
FakeSegment(10, 40, "おやすみなさい"), # 30s 幻觉
FakeSegment(45, 47, "おやすみなさい"), # 2s 真实
],
None,
)
monkeypatch.setitem(
sys.modules, "faster_whisper", types.SimpleNamespace(WhisperModel=lambda *a, **k: HallucModel())
)
_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")
# 30s 幻觉整条删除(时间轴 10-40s 不出现);2s 真实晚安与正常句保留。
assert "気持ちいい" in content
assert content.count("おやすみなさい") == 1
assert "00:00:10,000 --> 00:00:40,000" not in content
# 不残留 '-' 占位(时间轴里的 '-' 是 SRT 合法分隔符,只检查文本行)。
text_lines = [
l for l in content.splitlines()
if l.strip() and not l.strip().isdigit() and "-->" not in l
]
assert all(t != "-" for t in text_lines)
+7 -2
View File
@@ -17,10 +17,10 @@ WORKSPACE = Path(__file__).resolve().parent.parent
def test_seed_default_workflows_idempotent(tmp_path) -> None: def test_seed_default_workflows_idempotent(tmp_path) -> None:
"""验证从数据文件加载 demo/zh-direct 两个工作流且重复调用幂等。""" """验证从数据文件加载 demo/zh-direct/ocr-subtitle/learn-translate 工作流且重复调用幂等。"""
db = Database(tmp_path / "wov.db") db = Database(tmp_path / "wov.db")
created = seed_default_workflows(db) created = seed_default_workflows(db)
assert created == 3 assert created == 4
assert db.get_workflow("demo") is not None assert db.get_workflow("demo") is not None
assert db.get_workflow("zh-direct") is not None assert db.get_workflow("zh-direct") is not None
@@ -38,6 +38,11 @@ def test_seed_default_workflows_idempotent(tmp_path) -> None:
assert zh_asr["params"]["model_path"] == "whisper-large-v2-translate-zh-v0.2-st-ct2" assert zh_asr["params"]["model_path"] == "whisper-large-v2-translate-zh-v0.2-st-ct2"
assert zh_asr["params"]["task"] == "translate" assert zh_asr["params"]["task"] == "translate"
# learn-translate:应用本次 decode_full 修复的新工作流。
learn = db.get_latest_workflow_version("learn-translate")["definition"]
learn_asr = next(node for node in learn["nodes"] if node["id"] == "asr")
assert learn_asr["params"]["decode_full"] is True
# 再次调用不重复创建。 # 再次调用不重复创建。
assert seed_default_workflows(db) == 0 assert seed_default_workflows(db) == 0
assert len(db.list_workflow_versions("demo")) == 1 assert len(db.list_workflow_versions("demo")) == 1
+95
View File
@@ -0,0 +1,95 @@
{
"id": "learn-translate",
"name": "学习资料转译+翻译字幕",
"description": "面向学习/教学类视频(人声讲解为主)的中文字幕工作流。采用本次修复的 decode_full 无 VAD 整段解码 + 日语幻觉清洗,优先保证'说了的话不漏'(弱语音/快速讲解/轻微 BGM 下的人声均能召回),再靠幻觉清洗移除无语音段套话。每个参数旁以 _note_ 前缀标注设定理由,_node_help 字段给出节点关键参数的解释与正反例。",
"version": 1,
"definition": {
"name": "学习资料转译+翻译字幕",
"version": 1,
"nodes": [
{
"id": "extract",
"node_type": "ffmpeg-extract",
"params": {
"sample_rate": 16000,
"channels": 1,
"_note_sample_rate": "16000Hz 是 faster-whisper 训练采用的采样率;ffmpeg 提取时直接对齐可避免节点内二次重采样造成的时间轴误差与音质损失。正例:speech_60s.wav 即 16k 单声道,转写时间轴与素材严格一致。反例:若用 44100Hzfaster-whisper 会自动降采样,极端情况下边界样本插值引入轻微漂移。",
"_note_channels": "单声道是 whisper 输入要求;立体声转单声道由 ffmpeg 平均合并,避免人声在左右声道相位抵消(成人视频双声道常有人声偏置,合并后更清晰)。正例:CJOD-255 全程 BGM 覆盖仍能稳定提取人声。反例:保留双声道直接喂 whisper 会告警且浪费显存。",
"_node_help": "ffmpeg-extract 节点:把上传视频提取为 16kHz 单声道 WAV。\n关键参数:\n- sample_rate=16000:对齐 whisper 训练采样率,避免重采样时间误差。\n- channels=1whisper 要求单声道。\n正例:16k 单声道 WAV -> 转写时间轴精确到 0.1s。\n反例:44.1k 立体声 -> 需内部重采样且可能引入边界误差。"
},
"inputs": {
"video_uri": "input.video_uri"
}
},
{
"id": "asr",
"node_type": "faster-whisper",
"params": {
"language": "ja",
"model_path": "faster-whisper-large-v3",
"decode_full": true,
"condition_on_previous_text": false,
"chunk_seconds": 60,
"vad_filter": false,
"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 反例)。",
"_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_filterdecode_full 下被强制 false;独立开启时靠 silero 切语音段,适合纯安静对话,但对 BGM/气声场景会误杀真话。\n- chunk_seconds=60:分块转写,显存有界、块偏移按实际时长累积不漂移、可断点。\n- condition_on_previous_text=false:长音频防重复/漂移(官方建议)。\n- beam_size=1:贪心最快,清晰讲解足够。\n正例:学习视频讲解者轻声带过关键词也能召回。\n反例:开启 VAD 且遇快速讲解/轻微 BGM,弱音节被吞 -> 字幕缺句。"
},
"inputs": {
"audio_uri": "extract.audio_uri"
}
},
{
"id": "translate",
"node_type": "llm-translate",
"params": {
"target_language": "zh-CN",
"_note_target_language": "zh-CN:输出简体中文字幕,与媒体库既有 .CN.srt 命名一致(批量侧车字幕约定)。正例:demo 工作流同参数,产物对齐 CN.srt。反例:若设 zh-TW 会得到繁体,下游命名/惯例不匹配。",
"_node_help": "llm-translate 节点:把日语 SRT 逐行翻译为中文(走 SiliconFlow LLM)。\n关键参数:\n- target_language=zh-CN:目标语言。\n效果:翻译按批(20 行/批)调用 LLM,行数与原文严格对齐(_repair_batch 防内容-时间错位),完成后做中文长时寒暄幻觉清洗(clean_srt_text30s'晚安'->'-'2s 真实晚安保留)。\n正例:decode_full 召回的长句被正确翻译。\n反例:LLM 返回行数错位时由 _repair_batch 兜底,宁缺勿错位。"
},
"inputs": {
"srt_uri": "asr.srt_uri"
}
},
{
"id": "ass",
"node_type": "srt-to-dual-eye-ass",
"params": {
"resolution": "3840x1920",
"margin_top": 700,
"_note_resolution": "3840x1920:VR 双眼视频的典型单眼分辨率(每眼 1920 宽),ASS 按此布局左右半幅。正例:3dsvr/savr 系列 8k 视频单眼即 1920x1920 左右并排,此分辨率匹配。反例:若设 1920x1080 会在 8k 视频上字幕缩在左上角。",
"_note_margin_top": "700:顶部安全边距(2026-09 起默认)。字幕定位在屏幕上部安全区、避开画面中央人脸/重点区,且处于视线自然可读高度。正例:VR 观看时字幕在视线自然位置,不遮挡中央内容。反例:margin_top=120 字幕贴最顶需抬头看;=0 则可能盖住画面中央人脸。",
"_node_help": "srt-to-dual-eye-ass 节点:把中文字幕生成 VR 双眼 ASS(左右眼各占半幅、A-1 零视差、an8 顶部居中)。\n关键参数:\n- resolution=3840x1920VR 单眼分辨率。\n- margin_top=700:顶部安全边距。\n效果:文字 70% 透明描边半透明黑,降低遮挡;历史字幕可用 scripts/unify_ass_style.py 统一。\n正例:3840x1920 + margin_top 700 -> 双眼字幕顶部自然可读、零视差不眩晕。\n反例:resolution 与视频不符 -> 字幕偏移;margin_top 过小 -> 遮挡画面中央。"
},
"inputs": {
"cn_srt_uri": "translate.cn_srt_uri"
}
}
],
"edges": [
{
"from": "extract",
"to": "asr"
},
{
"from": "asr",
"to": "translate"
},
{
"from": "translate",
"to": "ass"
}
],
"entry_inputs": {
"video_uri": "file"
},
"final_outputs": {
"cn_srt": "translate.cn_srt_uri",
"ass": "ass.ass_uri"
}
}
}