feat: 优化学习视频转写与字幕清洗

This commit is contained in:
2026-09-11 14:26:02 +08:00
parent a8fe133aa4
commit eba9163246
12 changed files with 552 additions and 28 deletions
+97
View File
@@ -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
View File
@@ -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
View File
@@ -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:使用中文直出模型并开启翻译任务。