fix: whisper 重复伪影整条删除,避免翻译层空响应失败
whisper 在单个窗口内卡住重复时会把一个单元写满整条 cue(实测 30 秒整、重复 74–446 次,如 `チン`×111)。这类正文会让下游 LLM 跟着循环、把输出预算耗在思考上, 最终 `content` 返回空串并报 `translation alignment failed … Expecting value: line 1 column 1 (char 0)`;它也可能直接渲染成超长字幕行。 - `nodes/subtitle_cleanup.py` 新增 `remove_repetition_entries`:**纯模式判据、无字符 词表**——展示时长 ≥15s 且同一 1–6 字单元连续重复 ≥6 次且覆盖正文 ≥70% 的 cue 整条 删除;真实短促呻吟(`ぇ`×15、`ああああああ`)靠时长区分,零误删。 - `nodes/whisper.py` 在转写产出处应用该清洗(VAD 开关都生效,它与套话幻觉无关)。 - 真实数据验证:本地全部真实转写 3605 条 cue 只删 5 条 30 秒伪影;对照实验同批次 伪影截短后 5/5 成功、原样 1/5。
This commit is contained in:
@@ -10,7 +10,8 @@ ASR(whisper 无 VAD 解码)与 LLM 翻译在无语音段会输出固定套
|
||||
- JAPANESE_HALLUCINATION_TOKENS:日文(whisper 无 VAD 解码直接输出的套话)。
|
||||
|
||||
删除只针对展示时长 ≥ 阈值的长条目:短时相同词可能是剧情真实台词(如真实
|
||||
互道晚安),必须保留。另外删除纯呻吟/喘息碎片(判据见 _is_pure_moan)。
|
||||
互道晚安),必须保留。另外删除纯呻吟/喘息碎片(判据见 _is_pure_moan)与
|
||||
whisper 窗口内重复循环造成的重复伪影(判据见 _is_repetition_artifact)。
|
||||
|
||||
纯函数,不修改输入。
|
||||
"""
|
||||
@@ -63,6 +64,49 @@ MOAN_CHARS = frozenset(
|
||||
# /んふふ)有效假名 ≤3;>3(如ああああ)或含非呻吟字符的一律保留。
|
||||
DEFAULT_MOAN_MAX_CHARS = 3
|
||||
|
||||
# 重复伪影判据(whisper 窗口内重复循环会输出整条重复到 30 秒的 cue):
|
||||
# 单元最长 6 字(更长的重复单元在真实数据里未见),至少重复 6 次,
|
||||
# 且重复段占正文 ≥70%。真实呻吟重复次数也在 6–15 之间,靠时长区分。
|
||||
REPETITION_UNIT_MAX_CHARS = 6
|
||||
REPETITION_MIN_REPEATS = 6
|
||||
REPETITION_MIN_COVERAGE = 0.7
|
||||
|
||||
|
||||
def _longest_repeated_run(text: str, unit_max: int) -> tuple[str, int] | None:
|
||||
"""返回正文中最长的'同一单元连续重复'片段(原文, 重复次数)。
|
||||
|
||||
单元长度 1..unit_max 由短到长尝试,取片段最长的一次;无重复返回 None。
|
||||
注意用 finditer 而不是 re.search(..., pos):模块级 re.search 的第三个位置
|
||||
参数是 flags,拿它当偏移会导致原地重搜、死循环。
|
||||
"""
|
||||
best: tuple[str, int] | None = None
|
||||
for size in range(1, unit_max + 1):
|
||||
for match in re.finditer(rf"(.{{1,{size}}})\1+", text):
|
||||
run = match.group(0)
|
||||
if best is None or len(run) > len(best[0]):
|
||||
best = (run, len(run) // len(match.group(1)))
|
||||
return best
|
||||
|
||||
|
||||
def _is_repetition_artifact(text: str, duration: float, threshold_seconds: float,
|
||||
min_repeats: int, min_coverage: float) -> bool:
|
||||
"""判断一条 cue 是否为 whisper 重复循环伪影(整条删除判据)。
|
||||
|
||||
判据(同时满足):展示时长 ≥ 阈值、同一 1–6 字单元连续重复 ≥ min_repeats 次、
|
||||
重复段占正文 ≥ min_coverage。短促重复(“ぇ”×15 只占 3 秒)是真实发声,靠
|
||||
时长区分,与寒暄幻觉同一套“长条才删”思路。
|
||||
"""
|
||||
if threshold_seconds <= 0 or duration < threshold_seconds:
|
||||
return False
|
||||
stripped = text.replace("\n", "")
|
||||
found = _longest_repeated_run(stripped, REPETITION_UNIT_MAX_CHARS)
|
||||
if found is None:
|
||||
return False
|
||||
run, repeats = found
|
||||
if repeats < min_repeats or not stripped:
|
||||
return False
|
||||
return len(run) / len(stripped) >= min_coverage
|
||||
|
||||
def _moan_chars(text: str) -> int:
|
||||
"""返回 text 中'有效假名字符'数量(呻吟判据的一部分)。
|
||||
|
||||
@@ -141,6 +185,30 @@ def remove_short_moan_entries(
|
||||
return _remove_cues_by_predicate(srt_text, _keep)
|
||||
|
||||
|
||||
def remove_repetition_entries(
|
||||
srt_text: str,
|
||||
threshold_seconds: float = DEFAULT_THRESHOLD_SECONDS,
|
||||
min_repeats: int = REPETITION_MIN_REPEATS,
|
||||
min_coverage: float = REPETITION_MIN_COVERAGE,
|
||||
) -> str:
|
||||
"""删除 SRT 中 whisper 重复循环造成的**整条重复伪影**(ASR 产物去噪)。
|
||||
|
||||
模型在窗口内卡住重复时会把同一单元写满整条 cue(实测 30 秒、重复 74–446
|
||||
次):这种正文会让下游 LLM 跟着重复、把预算耗在思考上而报结构错误,也会
|
||||
直接渲染成超长字幕行,因此在产生处整条删除。判据见 _is_repetition_artifact:
|
||||
长条(时长 ≥ 阈值)+ 同一短单元高频重复且占满正文,真实短促呻吟不会命中。
|
||||
threshold_seconds ≤ 0 时关闭过滤。纯函数,不修改输入。
|
||||
"""
|
||||
|
||||
def _keep(start_sec: float, end_sec: float, text: str) -> bool:
|
||||
"""保留判据:非重复伪影才保留。"""
|
||||
return not _is_repetition_artifact(
|
||||
text, end_sec - start_sec, threshold_seconds, min_repeats, min_coverage,
|
||||
)
|
||||
|
||||
return _remove_cues_by_predicate(srt_text, _keep)
|
||||
|
||||
|
||||
def _remove_cues_by_predicate(
|
||||
srt_text: str,
|
||||
keep: callable,
|
||||
|
||||
+11
-11
@@ -300,24 +300,24 @@ def invoke(request: InvokeRequest) -> InvokeResponse:
|
||||
chunk_seconds / chunk_elapsed if chunk_elapsed > 0 else 0.0,
|
||||
time.monotonic() - transcribe_started,
|
||||
)
|
||||
# decode_full(无 VAD)的副作用清理:无语音段的长时寒暄幻觉与纯语气词
|
||||
# 碎片都是噪声,整条删除(序列号重排,不留 '-' 占位污染下游);判据与
|
||||
# 细节见 nodes/subtitle_cleanup.py。仅 decode_full 时启用。
|
||||
if decode_full:
|
||||
from nodes.subtitle_cleanup import (
|
||||
clean_japanese_hallucinations,
|
||||
remove_short_moan_entries,
|
||||
)
|
||||
# 噪声清理:重复伪影(窗口内卡住重复,VAD 开关都会有)整条删除;
|
||||
# decode_full 另加无语音段的长时寒暄幻觉与纯语气词碎片。都是整条删除、
|
||||
# 序号重排,不留 '-' 占位污染下游;判据见 nodes/subtitle_cleanup.py。
|
||||
from nodes.subtitle_cleanup import (
|
||||
clean_japanese_hallucinations,
|
||||
remove_repetition_entries,
|
||||
remove_short_moan_entries,
|
||||
)
|
||||
|
||||
body = clean_japanese_hallucinations("\n".join(lines))
|
||||
body = remove_repetition_entries("\n".join(lines))
|
||||
if decode_full:
|
||||
body = clean_japanese_hallucinations(body)
|
||||
# short_moan_max_chars:有效假名 ≤ 该值的纯呻吟碎片整条删除,
|
||||
# 设 0 关闭(真实短对话不会命中,判据见 subtitle_cleanup)。
|
||||
body = remove_short_moan_entries(
|
||||
body,
|
||||
max_chars=int(request.params.get("short_moan_max_chars", 3)),
|
||||
)
|
||||
else:
|
||||
body = "\n".join(lines)
|
||||
output_path = output_dir / "transcript.srt"
|
||||
output_path.write_text(body, encoding="utf-8")
|
||||
return InvokeResponse(status="completed", outputs={"srt_uri": str(output_path)})
|
||||
|
||||
Reference in New Issue
Block a user