test: 按模块重写测试代码,删除旧平铺结构
按"测试规则"重写 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。
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,545 @@
|
||||
"""nodes/whisper.py 的模块级测试(数据 → 测试过程 → 验证结果)。
|
||||
|
||||
被测模块:`nodes/whisper.py`(转写:模型解析 + 分块 + 时间轴合并 + 幻觉清洗
|
||||
入口),可独立调用。模型推理属允许替身的 I/O 边界:单元用例注入结构真实的
|
||||
假模型/假 ffmpeg;集成用例使用真实 faster-whisper 模型与真实语音。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import wave
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from nodes.ffmpeg import _ffmpeg_bin
|
||||
from nodes.whisper import (
|
||||
_append_srt_lines,
|
||||
_is_windows,
|
||||
_load_cuda_libraries,
|
||||
_local_model_candidates,
|
||||
_wav_duration_seconds,
|
||||
format_timestamp,
|
||||
invoke,
|
||||
resolve_model_path,
|
||||
)
|
||||
from wov_sdk.models import InvokeRequest
|
||||
|
||||
# 模块专用真实素材:60 秒真实语音(16kHz 单声道 WAV)。
|
||||
DATA_DIR = Path(__file__).resolve().parent / "data"
|
||||
SPEECH_WAV = DATA_DIR / "speech_60s.wav"
|
||||
|
||||
|
||||
class FakeSegment:
|
||||
"""结构真实的 whisper 分段替身(start/end/text 与真实段一致)。"""
|
||||
|
||||
def __init__(self, start: float, end: float, text: str) -> None:
|
||||
self.start = start
|
||||
self.end = end
|
||||
self.text = text
|
||||
|
||||
|
||||
class FakeInfo:
|
||||
"""结构真实的转写信息替身(含 language 字段)。"""
|
||||
|
||||
def __init__(self, language: str = "ja") -> None:
|
||||
self.language = language
|
||||
|
||||
|
||||
class FakeModel:
|
||||
"""结构真实的假模型:按预置分段返回,并记录每次调用的参数。"""
|
||||
|
||||
def __init__(self, segments: list[FakeSegment]) -> None:
|
||||
self._segments = segments
|
||||
self.calls: list[dict] = []
|
||||
|
||||
def transcribe(self, audio, **kwargs):
|
||||
self.calls.append({"audio": str(audio), **kwargs})
|
||||
return iter(self._segments), FakeInfo()
|
||||
|
||||
|
||||
def _request(tmp_path: Path, audio: Path | None, **params) -> InvokeRequest:
|
||||
"""构造真实请求;audio 为 None 时表示不传 audio_uri。"""
|
||||
inputs = {} if audio is None else {"audio_uri": str(audio)}
|
||||
return InvokeRequest(
|
||||
run_id="run-test",
|
||||
node_instance_id="whisper-1",
|
||||
params=params,
|
||||
inputs=inputs,
|
||||
output_dir=str(tmp_path / "out"),
|
||||
)
|
||||
|
||||
|
||||
def _inject_model(monkeypatch, model: FakeModel) -> None:
|
||||
"""把假模型注入到 faster_whisper.WhisperModel(I/O 边界替身)。
|
||||
|
||||
节点在 invoke 内部 `from faster_whisper import WhisperModel` 延迟导入,
|
||||
因此必须 patch 库模块上的名字,才能让真实调用路径拿到假模型。
|
||||
"""
|
||||
import faster_whisper
|
||||
|
||||
monkeypatch.setattr(faster_whisper, "WhisperModel", lambda *a, **k: model)
|
||||
# 假模型不需要真实权重,屏蔽 CUDA 库预加载以避免无 GPU 环境的副作用。
|
||||
monkeypatch.setattr("nodes.whisper._load_cuda_libraries", lambda: None)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 模型路径解析(本地优先)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_resolve_model_path_explicit_param_wins(tmp_path: Path) -> None:
|
||||
"""请求参数 model_path 优先级最高。"""
|
||||
# 数据:显式路径(含分隔符,按原样返回)。
|
||||
explicit = str(tmp_path / "custom-model")
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert resolve_model_path({"model_path": explicit}, env={}) == explicit
|
||||
|
||||
|
||||
def test_resolve_model_path_bare_name_resolves_locally(tmp_path: Path) -> None:
|
||||
"""裸模型名在本地 model/ 目录下解析(存在 model.bin 时)。"""
|
||||
# 数据:构造 <模型目录>/<名称>/model.bin。
|
||||
models_root = tmp_path / "model"
|
||||
target = models_root / "my-model"
|
||||
target.mkdir(parents=True)
|
||||
(target / "model.bin").write_bytes(b"weights")
|
||||
candidates = [models_root / "faster-whisper-large-v2"]
|
||||
|
||||
# 测试过程
|
||||
resolved = resolve_model_path({"model_path": "my-model"}, env={}, candidates=candidates)
|
||||
|
||||
# 验证结果:解析到本地目录。
|
||||
assert resolved == str(target)
|
||||
|
||||
|
||||
def test_resolve_model_path_env_used_when_no_param(tmp_path: Path) -> None:
|
||||
"""无参数时使用 WHISPER_MODEL_PATH 环境变量。"""
|
||||
# 数据:环境变量指向真实存在的模型目录。
|
||||
model_dir = tmp_path / "env-model"
|
||||
model_dir.mkdir()
|
||||
(model_dir / "model.bin").write_bytes(b"w")
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert resolve_model_path({}, env={"WHISPER_MODEL_PATH": str(model_dir)}) == str(model_dir)
|
||||
|
||||
|
||||
def test_resolve_model_path_prefers_complete_local_candidate(tmp_path: Path) -> None:
|
||||
"""无参数/环境变量时使用本地候选目录(含 model.bin 才算完整)。"""
|
||||
# 数据:第一个候选缺失 model.bin,第二个完整。
|
||||
broken = tmp_path / "broken"
|
||||
broken.mkdir()
|
||||
good = tmp_path / "good"
|
||||
good.mkdir()
|
||||
(good / "model.bin").write_bytes(b"w")
|
||||
|
||||
# 测试过程
|
||||
resolved = resolve_model_path({}, env={}, candidates=[broken, good])
|
||||
|
||||
# 验证结果:跳过不完整候选,选中完整目录。
|
||||
assert resolved == str(good)
|
||||
|
||||
|
||||
def test_resolve_model_path_falls_back_to_remote_name(tmp_path: Path) -> None:
|
||||
"""全部本地候选缺失时回退到可下载的模型名。"""
|
||||
# 数据:空候选目录。
|
||||
empty = tmp_path / "empty"
|
||||
empty.mkdir()
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert resolve_model_path({}, env={}, candidates=[empty]) == "large-v2"
|
||||
|
||||
|
||||
def test_local_model_candidates_are_platform_paths() -> None:
|
||||
"""本地候选包含单体内置模型目录(跨平台用 pathlib 表达)。"""
|
||||
# 数据:无。
|
||||
# 测试过程
|
||||
candidates = _local_model_candidates()
|
||||
|
||||
# 验证结果:非空且都是 Path。
|
||||
assert candidates
|
||||
assert all(isinstance(p, Path) for p in candidates)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 时间戳与 WAV 时长
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_format_timestamp_pads_and_handles_hours() -> None:
|
||||
"""时间戳格式化为 HH:MM:SS,mmm,毫秒与小时均正确。"""
|
||||
# 数据:0、1.5、3661.004 秒。
|
||||
# 测试过程与验证结果
|
||||
assert format_timestamp(0) == "00:00:00,000"
|
||||
assert format_timestamp(1.5) == "00:00:01,500"
|
||||
assert format_timestamp(3661.004) == "01:01:01,004"
|
||||
|
||||
|
||||
def test_wav_duration_from_real_header(tmp_path: Path) -> None:
|
||||
"""WAV 时长按文件头精确计算(分块偏移依赖它,不能用假设块长)。"""
|
||||
# 数据:3 秒合法 WAV。
|
||||
path = tmp_path / "3s.wav"
|
||||
with wave.open(str(path), "wb") as wav:
|
||||
wav.setnchannels(1)
|
||||
wav.setsampwidth(2)
|
||||
wav.setframerate(16000)
|
||||
wav.writeframes(b"\x00\x00" * 16000 * 3)
|
||||
|
||||
# 测试过程
|
||||
duration = _wav_duration_seconds(path, fallback=99.0)
|
||||
|
||||
# 验证结果
|
||||
assert duration == pytest.approx(3.0, abs=0.01)
|
||||
|
||||
|
||||
def test_wav_duration_falls_back_on_invalid_file(tmp_path: Path) -> None:
|
||||
"""非法 WAV 时回退到给定默认值(不抛异常中断整片转写)。"""
|
||||
# 数据:非 WAV 内容。
|
||||
path = tmp_path / "broken.wav"
|
||||
path.write_bytes(b"not a wav")
|
||||
|
||||
# 测试过程与验证结果
|
||||
assert _wav_duration_seconds(path, fallback=42.0) == 42.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SRT 行追加与时间轴偏移
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_append_srt_lines_applies_offset_and_index() -> None:
|
||||
"""分块转写按块偏移平移时间轴,并延续 SRT 序号。"""
|
||||
# 数据:一段本地时间 0~2 秒的分段,偏移 60 秒,起始序号 5。
|
||||
segments = [FakeSegment(0.0, 2.0, "你好")]
|
||||
|
||||
# 测试过程
|
||||
lines: list[str] = []
|
||||
added = _append_srt_lines(lines, segments, offset=60.0, start_index=5)
|
||||
|
||||
# 验证结果:序号为 5,时间轴为 60~62 秒,返回本段新增条数 1。
|
||||
assert added == 1
|
||||
assert lines[0] == "5"
|
||||
assert lines[1] == "00:01:00,000 --> 00:01:02,000"
|
||||
assert lines[2] == "你好"
|
||||
|
||||
|
||||
def test_append_srt_lines_strips_segment_text() -> None:
|
||||
"""分段文本两端空白被去除(避免 SRT 正文带多余空格)。"""
|
||||
# 数据:一条文本带首尾空格。
|
||||
segments = [FakeSegment(1.0, 2.0, " 正常 ")]
|
||||
|
||||
# 测试过程
|
||||
lines: list[str] = []
|
||||
_append_srt_lines(lines, segments, offset=0.0, start_index=1)
|
||||
|
||||
# 验证结果:正文为去空白后的文本,序号为 1。
|
||||
assert lines[0] == "1"
|
||||
assert lines[2] == "正常"
|
||||
|
||||
|
||||
def test_append_srt_lines_continues_numbering_across_chunks() -> None:
|
||||
"""跨块调用时序号连续(调用方按上一块返回的条数累加)。"""
|
||||
# 数据:两块各一段,第二块起始序号 = 1 + 第一块条数。
|
||||
first: list[str] = []
|
||||
added = _append_srt_lines(first, [FakeSegment(0, 1, "甲")], 0.0, 1)
|
||||
second: list[str] = []
|
||||
_append_srt_lines(second, [FakeSegment(0, 1, "乙")], 60.0, 1 + added)
|
||||
|
||||
# 验证结果:第二块序号为 2,时间轴带 60 秒偏移。
|
||||
assert second[0] == "2"
|
||||
assert second[1].startswith("00:01:00,000")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 平台分支
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_is_windows_flag_matches_platform() -> None:
|
||||
"""_is_windows 反映当前平台(测试需同时可在 Windows 与 Linux 运行)。"""
|
||||
# 数据:当前运行平台。
|
||||
# 测试过程与验证结果
|
||||
assert _is_windows() == (os.name == "nt")
|
||||
|
||||
|
||||
def test_load_cuda_libraries_is_noop_off_windows(tmp_path: Path, monkeypatch) -> None:
|
||||
"""非 Windows 平台加载 CUDA 库为无操作(Linux 由系统/venv 提供)。"""
|
||||
# 数据:强制 _is_windows 为 False。
|
||||
monkeypatch.setattr("nodes.whisper._is_windows", lambda: False)
|
||||
|
||||
# 测试过程与验证结果:不抛异常。
|
||||
_load_cuda_libraries()
|
||||
|
||||
|
||||
def test_load_cuda_libraries_scans_site_packages_on_windows(tmp_path: Path, monkeypatch) -> None:
|
||||
"""Windows 下扫描 site-packages/nvidia/*/bin 并注册 DLL 搜索目录。"""
|
||||
# 数据:伪造含 cublas/cudnn/cuda_nvrtc 三个厂商 bin 目录的 site-packages。
|
||||
site = tmp_path / "site-packages"
|
||||
vendors = ("cublas", "cudnn", "cuda_nvrtc")
|
||||
for package in vendors:
|
||||
bin_dir = site / "nvidia" / package / "bin"
|
||||
bin_dir.mkdir(parents=True)
|
||||
(bin_dir / f"{package}.dll").write_bytes(b"dll")
|
||||
added: list[str] = []
|
||||
monkeypatch.setattr("nodes.whisper._is_windows", lambda: True)
|
||||
monkeypatch.setattr("nodes.whisper.sysconfig.get_paths", lambda: {"purelib": str(site)})
|
||||
monkeypatch.setattr("os.add_dll_directory", added.append, raising=False)
|
||||
|
||||
# 测试过程
|
||||
_load_cuda_libraries()
|
||||
|
||||
# 验证结果:三个厂商的 bin 目录都被加入 DLL 搜索路径。
|
||||
assert len(added) == len(vendors)
|
||||
assert all("nvidia" in path for path in added)
|
||||
|
||||
|
||||
def test_load_cuda_libraries_skips_missing_vendor_dirs(tmp_path: Path, monkeypatch) -> None:
|
||||
"""厂商目录不存在时跳过,不报错(部分轮子未安装)。"""
|
||||
# 数据:只有 cublas 一个厂商目录。
|
||||
site = tmp_path / "site-packages"
|
||||
(site / "nvidia" / "cublas" / "bin").mkdir(parents=True)
|
||||
added: list[str] = []
|
||||
monkeypatch.setattr("nodes.whisper._is_windows", lambda: True)
|
||||
monkeypatch.setattr("nodes.whisper.sysconfig.get_paths", lambda: {"purelib": str(site)})
|
||||
monkeypatch.setattr("os.add_dll_directory", added.append, raising=False)
|
||||
|
||||
# 测试过程
|
||||
_load_cuda_libraries()
|
||||
|
||||
# 验证结果:只注册存在的那一个。
|
||||
assert len(added) == 1
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# invoke:分块转写与合并(假模型)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_invoke_transcribes_with_fake_model_and_writes_srt(tmp_path: Path, monkeypatch) -> None:
|
||||
"""invoke 调用模型转写并写出 SRT(结构真实的分段替身)。"""
|
||||
# 数据:真实 WAV 输入 + 假模型返回两段。
|
||||
assert SPEECH_WAV.is_file(), f"缺少测试素材 {SPEECH_WAV}"
|
||||
model = FakeModel([FakeSegment(0.0, 2.0, "第一句"), FakeSegment(2.5, 4.0, "第二句")])
|
||||
_inject_model(monkeypatch, model)
|
||||
|
||||
# 测试过程
|
||||
response = invoke(_request(tmp_path, SPEECH_WAV, chunk_seconds=0))
|
||||
|
||||
# 验证结果:产物存在且含两段文本与时间轴。
|
||||
assert response.status == "completed", response.error
|
||||
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
|
||||
assert "第一句" in content and "第二句" in content
|
||||
assert "00:00:00,000 --> 00:00:02,000" in content
|
||||
assert content.index("第一句") < content.index("第二句")
|
||||
|
||||
|
||||
def test_invoke_passes_params_to_model(tmp_path: Path, monkeypatch) -> None:
|
||||
"""节点参数透传到模型调用(language/task/beam_size 等)。"""
|
||||
# 数据:指定语言与任务。
|
||||
model = FakeModel([FakeSegment(0.0, 1.0, "x")])
|
||||
_inject_model(monkeypatch, model)
|
||||
|
||||
# 测试过程
|
||||
invoke(_request(
|
||||
tmp_path, SPEECH_WAV, chunk_seconds=0,
|
||||
language="ja", task="translate", beam_size=3, vad_filter=False,
|
||||
condition_on_previous_text=False,
|
||||
))
|
||||
|
||||
# 验证结果:模型收到对应参数。
|
||||
call = model.calls[0]
|
||||
assert call["language"] == "ja"
|
||||
assert call["task"] == "translate"
|
||||
assert call["beam_size"] == 3
|
||||
assert call["vad_filter"] is False
|
||||
|
||||
|
||||
def test_invoke_default_vad_and_condition_flags(tmp_path: Path, monkeypatch) -> None:
|
||||
"""默认 vad_filter=True 且 condition_on_previous_text=False(防重复)。"""
|
||||
# 数据:不传相关参数。
|
||||
model = FakeModel([FakeSegment(0.0, 1.0, "x")])
|
||||
_inject_model(monkeypatch, model)
|
||||
|
||||
# 测试过程
|
||||
invoke(_request(tmp_path, SPEECH_WAV, chunk_seconds=0))
|
||||
|
||||
# 验证结果
|
||||
assert model.calls[0]["vad_filter"] is True
|
||||
assert model.calls[0]["condition_on_previous_text"] is False
|
||||
|
||||
|
||||
def test_invoke_fails_without_audio_uri(tmp_path: Path) -> None:
|
||||
"""缺少 audio_uri 时失败。"""
|
||||
# 数据:空输入。
|
||||
# 测试过程
|
||||
response = invoke(_request(tmp_path, None))
|
||||
|
||||
# 验证结果
|
||||
assert response.status == "failed"
|
||||
assert "audio_uri" in (response.error or "")
|
||||
|
||||
|
||||
def test_invoke_fails_when_audio_missing(tmp_path: Path) -> None:
|
||||
"""音频文件不存在时失败。"""
|
||||
# 数据:不存在的路径。
|
||||
# 测试过程
|
||||
response = invoke(_request(tmp_path, tmp_path / "nope.wav"))
|
||||
|
||||
# 验证结果
|
||||
assert response.status == "failed"
|
||||
assert "not found" in (response.error or "")
|
||||
|
||||
|
||||
def test_invoke_cleans_japanese_hallucination_in_decode_full(tmp_path: Path, monkeypatch) -> None:
|
||||
"""decode_full 模式下,长时日语寒暄幻觉整条删除(不留下 '-' 占位)。"""
|
||||
# 数据:假模型返回一段 30 秒的"おやすみなさい"。
|
||||
model = FakeModel([FakeSegment(0.0, 30.0, "おやすみなさい")])
|
||||
_inject_model(monkeypatch, model)
|
||||
|
||||
# 测试过程
|
||||
response = invoke(_request(tmp_path, SPEECH_WAV, chunk_seconds=0, decode_full=True))
|
||||
|
||||
# 验证结果:幻觉被删除,产物无该文本。
|
||||
assert response.status == "completed", response.error
|
||||
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
|
||||
assert "おやすみなさい" not in content
|
||||
|
||||
|
||||
def test_invoke_filters_short_moan_in_decode_full(tmp_path: Path, monkeypatch) -> None:
|
||||
"""decode_full 模式下短呻吟碎片被过滤,真实短对话保留。"""
|
||||
# 数据:呻吟碎片 + 真实短对话。
|
||||
model = FakeModel([
|
||||
FakeSegment(0.0, 1.0, "あ…"),
|
||||
FakeSegment(1.5, 3.0, "そこ、だめ"),
|
||||
])
|
||||
_inject_model(monkeypatch, model)
|
||||
|
||||
# 测试过程
|
||||
response = invoke(_request(tmp_path, SPEECH_WAV, chunk_seconds=0, decode_full=True))
|
||||
|
||||
# 验证结果:呻吟被删,真实对话保留。
|
||||
assert response.status == "completed", response.error
|
||||
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
|
||||
assert "そこ、だめ" in content
|
||||
assert "あ…" not in content
|
||||
|
||||
|
||||
def test_invoke_keeps_moan_when_filter_disabled(tmp_path: Path, monkeypatch) -> None:
|
||||
"""short_moan_max_chars=0 时关闭呻吟过滤。"""
|
||||
# 数据:一条呻吟。
|
||||
model = FakeModel([FakeSegment(0.0, 1.0, "あ…")])
|
||||
_inject_model(monkeypatch, model)
|
||||
|
||||
# 测试过程
|
||||
response = invoke(_request(
|
||||
tmp_path, SPEECH_WAV, chunk_seconds=0, decode_full=True, short_moan_max_chars=0,
|
||||
))
|
||||
|
||||
# 验证结果:呻吟保留。
|
||||
assert response.status == "completed", response.error
|
||||
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
|
||||
assert "あ…" in content
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 真实模型集成
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# 已废弃的模型目录(不得用于测试):V3 已全面停用,全部改用 V2。
|
||||
_DEPRECATED_MODEL_DIRS = ("faster-whisper-large-v3",)
|
||||
|
||||
|
||||
def _v2_model_candidates() -> list[Path]:
|
||||
"""返回可用的 V2 权重目录(排除已废弃的 V3)。
|
||||
|
||||
解析顺序:
|
||||
1. `nodes.whisper` 文档化的默认候选(通用 V2 转写模型);
|
||||
2. `model/` 下其它已下载的 V2 权重(例如中文直出模型)。
|
||||
V3 权重已全面停用(用户 2026-09 决定:均改用 V2),即使留在盘上也不得
|
||||
被测试使用,否则测的不是线上实际运行的模型。
|
||||
"""
|
||||
candidates = [p for p in _local_model_candidates() if p.name not in _DEPRECATED_MODEL_DIRS]
|
||||
model_root = Path(__file__).resolve().parents[3] / "model"
|
||||
if model_root.is_dir():
|
||||
for path in sorted(model_root.iterdir()):
|
||||
if not path.is_dir() or path.name in _DEPRECATED_MODEL_DIRS:
|
||||
continue
|
||||
# V3 的判据:preprocessor_config.json 的 feature_size == 128。
|
||||
if _is_v3_weights(path):
|
||||
continue
|
||||
candidates.append(path)
|
||||
return candidates
|
||||
|
||||
|
||||
def _is_v3_weights(model_dir: Path) -> bool:
|
||||
"""按 preprocessor_config.json 的 feature_size 判断是否为 V3 权重。
|
||||
|
||||
Whisper V2 的 mel 特征维度是 80,V3 是 128;这是区分两代权重的稳定判据
|
||||
(目录名可能被人工改名,不能只靠名字判断)。
|
||||
"""
|
||||
import json
|
||||
|
||||
config = model_dir / "preprocessor_config.json"
|
||||
if not config.is_file():
|
||||
return False
|
||||
try:
|
||||
return int(json.loads(config.read_text(encoding="utf-8")).get("feature_size", 80)) == 128
|
||||
except (ValueError, TypeError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _real_model_available() -> Path | None:
|
||||
"""返回一个可用的 V2 权重目录(缺失则返回 None 供跳过)。"""
|
||||
for candidate in _v2_model_candidates():
|
||||
if candidate.is_dir() and (candidate / "model.bin").is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
def test_real_whisper_transcribes_real_speech(tmp_path: Path) -> None:
|
||||
"""真实 faster-whisper 模型 + 真实语音:端到端转写产出可用 SRT。
|
||||
|
||||
本地无模型或素材时跳过;有则必须执行,作为假模型单测的校准。
|
||||
"""
|
||||
# 数据:模块 data/ 下的真实 60 秒语音。
|
||||
if not SPEECH_WAV.is_file():
|
||||
pytest.skip(f"缺少测试素材 {SPEECH_WAV}")
|
||||
model_dir = _real_model_available()
|
||||
if model_dir is None:
|
||||
pytest.skip("本地没有完整 whisper 权重,跳过真实模型集成测试")
|
||||
# 显存不足时跳过(真实模型推理需要显存,属外部环境状态)。
|
||||
from tests.shared.gpu_memory import (
|
||||
fits_with_margin,
|
||||
require_gpu_memory,
|
||||
require_node_result,
|
||||
)
|
||||
|
||||
require_gpu_memory(model_dir)
|
||||
# 分块路径(生产默认)会产生更多分配峰值,在临界显存卡上易触发 CUDA OOM;
|
||||
# 显存充裕时走分块覆盖该路径,否则退化为整段单次推理。
|
||||
chunk_seconds = 20 if fits_with_margin(model_dir) else 0
|
||||
|
||||
# 测试过程:真实模型转写真实语音。
|
||||
response = invoke(_request(
|
||||
tmp_path, SPEECH_WAV, chunk_seconds=chunk_seconds, language="ja",
|
||||
model_path=str(model_dir),
|
||||
))
|
||||
|
||||
# 验证结果:成功、产物为合法 SRT、时间轴递增且不超音频时长。
|
||||
require_node_result(response, model_dir)
|
||||
assert response.status == "completed", response.error
|
||||
content = Path(response.outputs["srt_uri"]).read_text(encoding="utf-8")
|
||||
timelines = [line for line in content.splitlines() if "-->" in line]
|
||||
assert timelines, "真实转写应产出至少一条字幕"
|
||||
from tests.shared.srt_entries import parse_srt_entries
|
||||
|
||||
entries = parse_srt_entries(content)
|
||||
starts = [e["start"] for e in entries]
|
||||
assert starts == sorted(starts)
|
||||
assert max(starts) <= 62.0
|
||||
Reference in New Issue
Block a user