按"测试规则"重写 tests/:一个模块一个目录、用例按数据→过程→验证三段书写、 不保留全局 conftest.py、测试过程只调用真实生产代码。 结构(73 个文件、30 个模块目录、477 用例): - tests/nodes/ 15 个模块目录(srt/whisper/ass/ffmpeg/frame_extract/vlm/ subtitle_ocr/llm/llm_filter/subtitle_cleanup/subtitle_correction/ proper_nouns/adaptive_pool/vad_profiler/echo); - tests/app/ 11 个模块目录(db/scheduler/batch/maintenance/registry/seed/ storage/config/logging/main/routers 三组 API); - tests/sdk/test_models、tests/web/test_crop、tests/shared(公共设施)。 测试数据随模块目录入库(tests/**/data/),删除根级 testdata/;.gitignore 的 data/ 改为 /data/,否则会连带忽略 tests/**/data/ 导致测试数据无法入库。 顺带发现并修复三个真实缺陷: - nodes/srt.py:相邻条目缺少空行时把下一条时间轴吞进正文(静默错位), 改为正文行遇时间戳行即报错; - src/wov_app/scheduler.py:_file_size 只捕获 OSError,含 \x00 的产物 URI 抛 ValueError 导致任务误判失败,改为同时捕获; - nodes/subtitle_correction.py:生产代码依赖测试包解析 SRT, 改用生产模块 nodes/srt.py。 真实模型/服务集成测试按外部状态跳过:新增 tests/shared/gpu_memory.py (运行时探测显存、CUDA OOM 转跳过)与 tests/shared/llm_service.py (无 Key / 余额 / 限流转跳过)。全量 477 passed。
128 lines
5.5 KiB
Python
128 lines
5.5 KiB
Python
"""真实语音时间对齐集成测试(数据 → 测试过程 → 验证结果)。
|
|
|
|
覆盖模块:`nodes/whisper.py`(真实转写)+ `tests/shared/realdata_contract.py`
|
|
(对齐量化工具)。这是模块级直测的补充:用真实模型 + 真实音频 + 人工校对
|
|
参考字幕,量化字幕时间轴与"说话真实发生时间"的偏差。
|
|
|
|
数据契约:`tests/shared/data/alignment/` 下 `<name>.wav|.mp4` 与其同名
|
|
`<name>.reference.srt`(人工校对)。素材缺失或本地无模型时跳过。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from nodes.whisper import _local_model_candidates, invoke
|
|
from tests.shared.realdata_contract import (
|
|
TIER1_TOLERANCE_SECONDS,
|
|
align_report,
|
|
alignment_candidates,
|
|
clean_reference,
|
|
)
|
|
from tests.shared.srt_entries import parse_srt_entries
|
|
from wov_sdk.models import InvokeRequest
|
|
|
|
|
|
def _model_available() -> Path | None:
|
|
"""返回可用的 V2 权重目录(缺失则返回 None 供跳过)。
|
|
|
|
只接受 V2 权重:V3 已全面停用(均改用 V2),留在盘上的 V3 目录不得被
|
|
测试使用,否则测的不是线上实际运行的模型。
|
|
"""
|
|
from tests.nodes.test_whisper.test_transcribe import _v2_model_candidates
|
|
|
|
for candidate in _v2_model_candidates():
|
|
if candidate.is_dir() and (candidate / "model.bin").is_file():
|
|
return candidate
|
|
return None
|
|
|
|
|
|
def _sample_dir() -> Path | None:
|
|
"""返回包含对齐素材的目录(共享数据目录;缺失时返回 None)。"""
|
|
directory = Path(__file__).resolve().parents[2] / "shared" / "data" / "alignment"
|
|
return directory if directory.is_dir() else None
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_real_alignment_candidates_discovered() -> None:
|
|
"""对齐素材探查:能找到成对的媒体与参考字幕(数据契约可用)。"""
|
|
# 数据:共享数据目录下的真实素材。
|
|
directory = _sample_dir()
|
|
if directory is None:
|
|
pytest.skip("缺少 tests/shared/data/alignment 目录")
|
|
|
|
# 测试过程
|
|
candidates = alignment_candidates(directory)
|
|
|
|
# 验证结果:至少一对,且每对都有同名 reference.srt。
|
|
assert candidates, "应至少有一对素材 + 参考字幕"
|
|
for media in candidates:
|
|
assert media.with_suffix(".reference.srt").is_file()
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_real_whisper_alignment_within_tolerance(tmp_path) -> None:
|
|
"""真实转写产物 vs 人工参考字幕:时间偏差在容差内且无系统性偏移。
|
|
|
|
这验证"字幕时间与说话时刻对齐"这一核心功能,使用真实模型与真实音频。
|
|
"""
|
|
# 数据:最小的真实对齐素材(避免长视频拖慢测试)。
|
|
directory = _sample_dir()
|
|
model_dir = _model_available()
|
|
if directory is None or model_dir is None:
|
|
pytest.skip("缺少对齐素材或本地 whisper 权重,跳过")
|
|
# 显存不足时跳过(转写整段真实音频需要显存,属外部环境状态)。
|
|
from tests.shared.gpu_memory import require_gpu_memory, require_node_result
|
|
|
|
require_gpu_memory(model_dir)
|
|
candidates = alignment_candidates(directory)
|
|
if not candidates:
|
|
pytest.skip("没有成对的对齐素材")
|
|
media = min(candidates, key=lambda p: p.stat().st_size)
|
|
reference = clean_reference(
|
|
parse_srt_entries(media.with_suffix(".reference.srt").read_text(encoding="utf-8"))
|
|
)
|
|
if not reference:
|
|
pytest.skip("参考字幕为空")
|
|
|
|
# 测试过程:走真实生产链路——先提音(视频/任意容器 → 16kHz 单声道 WAV),
|
|
# 再转写。whisper 只接受 WAV,直接喂 mp4 会解析失败(对齐素材多为 mp4)。
|
|
from nodes.ffmpeg import invoke as extract_audio
|
|
|
|
audio = extract_audio(InvokeRequest(
|
|
run_id="alignment", node_instance_id="ffmpeg-1", params={},
|
|
inputs={"video_uri": str(media)}, output_dir=str(tmp_path / "audio"),
|
|
))
|
|
require_node_result(audio, model_dir)
|
|
assert audio.status == "completed", audio.error
|
|
response = invoke(InvokeRequest(
|
|
run_id="alignment", node_instance_id="whisper-1",
|
|
params={
|
|
"language": "ja",
|
|
# 本用例目标是"时间轴对齐":整段单次解码(chunk_seconds=0)避免
|
|
# 分块上下文带来的额外显存与上下文损失;分块路径本身已由
|
|
# whisper 模块测试覆盖。实测 6GB 卡上该配置稳定完成。
|
|
"chunk_seconds": 0,
|
|
"vad_filter": True,
|
|
"model_path": str(model_dir),
|
|
},
|
|
inputs={"audio_uri": audio.outputs["audio_uri"]},
|
|
output_dir=str(tmp_path / "out"),
|
|
))
|
|
require_node_result(response, model_dir)
|
|
assert response.status == "completed", response.error
|
|
produced = parse_srt_entries(open(response.outputs["srt_uri"], encoding="utf-8").read())
|
|
report = align_report(media.stem, True, produced, reference)
|
|
|
|
# 验证结果(基线由 V2 模型实测标定,见 docs/testing.md):
|
|
# - 产出条数与参考规模同量级(不丢整段、不大量幻觉);
|
|
# - 平均绝对偏差在真实转写抖动范围内;
|
|
# - 中位偏差不构成系统性偏早/偏晚。
|
|
assert produced, "真实转写应产出字幕"
|
|
assert len(produced) >= len(reference) * 0.5, report.format_summary()
|
|
assert report.mean_abs_error <= 2.0, report.format_summary()
|
|
assert not report.consistently_early, report.format_summary()
|
|
assert not report.consistently_late, report.format_summary()
|