feat: 优化学习视频转写与字幕清洗
This commit is contained in:
@@ -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
@@ -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
@@ -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:使用中文直出模型并开启翻译任务。
|
||||
|
||||
Reference in New Issue
Block a user