The distribution, console script and import package are now audio-scribe / audio_scribe (src/audio_scribe). Everything named for the old project follows: - CcnError -> AudioScribeError, and its code "ccn_error" -> "audio_scribe_error" (the code is never persisted, so existing job state still loads) - CCN_LIVE -> AUDIO_SCRIBE_LIVE for the live-GPU tests - OpenVINO kernel cache moves to <cache>/audio-scribe/ov_cache; the first run after upgrading recompiles kernels, and the old directory is left in place - README, build-binary.sh, hatch/coverage config and uv.lock updated to match Breaking: the command is now `audio-scribe`; reinstall any tool install of the old name with `uv tool uninstall ccn-transcribe && uv tool install .`. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
299 lines
12 KiB
Python
299 lines
12 KiB
Python
from __future__ import annotations
|
|
|
|
import builtins
|
|
import logging
|
|
import os
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
import numpy as np
|
|
import openvino_genai
|
|
import pytest
|
|
|
|
from audio_scribe import errors
|
|
from audio_scribe.backends import openvino_backend as ovb
|
|
from audio_scribe.backends import probe as probe_mod
|
|
from audio_scribe.backends.base import TranscribeRequest
|
|
from audio_scribe.media import ffmpeg
|
|
|
|
LIVE = pytest.mark.skipif(
|
|
not os.environ.get("AUDIO_SCRIBE_LIVE"),
|
|
reason="set AUDIO_SCRIBE_LIVE=1 to run against the real GPU",
|
|
)
|
|
|
|
|
|
class TestLanguageToken:
|
|
def test_bare_code_becomes_a_token(self) -> None:
|
|
assert ovb.language_token("en") == "<|en|>"
|
|
|
|
def test_an_existing_token_is_left_alone(self) -> None:
|
|
assert ovb.language_token("<|de|>") == "<|de|>"
|
|
|
|
def test_none_stays_none_for_autodetect(self) -> None:
|
|
assert ovb.language_token(None) is None
|
|
|
|
def test_an_unknown_code_is_rejected_with_suggestions(self) -> None:
|
|
with pytest.raises(errors.ConfigError, match="unknown language"):
|
|
ovb.language_token("zz", allowed={"<|en|>", "<|de|>"})
|
|
|
|
def test_a_known_code_passes_validation(self) -> None:
|
|
assert ovb.language_token("en", allowed={"<|en|>"}) == "<|en|>"
|
|
|
|
|
|
class TestSupportedLanguages:
|
|
def test_none_when_the_config_is_absent(self, tmp_path: Path) -> None:
|
|
assert ovb.supported_languages(tmp_path) is None
|
|
|
|
def test_none_when_the_config_is_unreadable(self, tmp_path: Path) -> None:
|
|
(tmp_path / "generation_config.json").write_text("{broken")
|
|
assert ovb.supported_languages(tmp_path) is None
|
|
|
|
def test_none_when_there_is_no_language_map(self, tmp_path: Path) -> None:
|
|
(tmp_path / "generation_config.json").write_text('{"max_length": 448}')
|
|
assert ovb.supported_languages(tmp_path) is None
|
|
|
|
def test_reads_the_language_map(self, tmp_path: Path) -> None:
|
|
(tmp_path / "generation_config.json").write_text('{"lang_to_id": {"<|en|>": 1}}')
|
|
assert ovb.supported_languages(tmp_path) == {"<|en|>"}
|
|
|
|
def test_reads_the_real_cached_model(self) -> None:
|
|
languages = ovb.supported_languages(ovb.resolve_model(ovb.DEFAULT_MODEL, None))
|
|
assert languages is not None
|
|
assert "<|en|>" in languages
|
|
assert len(languages) > 90
|
|
|
|
|
|
class TestCacheRoot:
|
|
def test_respects_xdg_cache_home(self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
|
|
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
|
|
assert ovb.cache_root() == tmp_path / "audio-scribe/ov_cache"
|
|
|
|
def test_falls_back_to_home_cache(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
monkeypatch.delenv("XDG_CACHE_HOME", raising=False)
|
|
assert ovb.cache_root() == Path.home() / ".cache/audio-scribe/ov_cache"
|
|
|
|
def test_is_not_relative_to_the_working_directory(self) -> None:
|
|
# The benchmark scripts use a relative .ov_cache_* and only work from the
|
|
# repo root; the pipeline must not inherit that.
|
|
assert ovb.cache_root().is_absolute()
|
|
|
|
|
|
class TestResolveModel:
|
|
def test_finds_the_cached_model_without_network(self) -> None:
|
|
path = ovb.resolve_model(ovb.DEFAULT_MODEL, ovb.DEFAULT_REVISION)
|
|
assert (path / "openvino_encoder_model.xml").exists()
|
|
|
|
def test_a_missing_model_raises_with_a_manual_command(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
def boom(*_a: object, **_k: object) -> None:
|
|
raise OSError("no such model")
|
|
|
|
monkeypatch.setattr(ovb, "snapshot", boom)
|
|
with pytest.raises(errors.ModelUnavailableError) as caught:
|
|
ovb.resolve_model("nope/nope", None)
|
|
assert "hf download" in (caught.value.hint or "")
|
|
|
|
|
|
class TestDeviceSelection:
|
|
def test_gpu_backend_probes_intel_hardware(self) -> None:
|
|
assert ovb.OpenvinoGpuBackend.probe_hardware() is True
|
|
|
|
def test_cpu_backend_always_has_hardware(self) -> None:
|
|
assert ovb.OpenvinoCpuBackend.probe_hardware() is True
|
|
|
|
def test_gpu_toolchain_is_available_here(self) -> None:
|
|
assert ovb.OpenvinoGpuBackend.probe_toolchain().ok is True
|
|
|
|
def test_cpu_toolchain_is_available_here(self) -> None:
|
|
assert ovb.OpenvinoCpuBackend.probe_toolchain().ok is True
|
|
|
|
def test_multi_gpu_enumeration_is_accepted(self) -> None:
|
|
assert ovb.OpenvinoGpuBackend.device_present(["CPU", "GPU.0", "GPU.1"]) is True
|
|
|
|
def test_a_cpu_only_enumeration_is_rejected_for_gpu(self) -> None:
|
|
assert ovb.OpenvinoGpuBackend.device_present(["CPU"]) is False
|
|
|
|
def test_a_missing_gpu_plugin_is_reported_as_a_plugin_problem(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
def absent(_cls: type[ovb.OpenvinoGpuBackend], _devices: list[str]) -> bool:
|
|
return False
|
|
|
|
monkeypatch.setattr(ovb.OpenvinoGpuBackend, "device_present", classmethod(absent))
|
|
status = ovb.OpenvinoGpuBackend.probe_toolchain()
|
|
assert status.ok is False
|
|
assert "plugin problem" in status.reason
|
|
|
|
def test_an_unimportable_openvino_is_reported(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
real = builtins.__import__
|
|
|
|
def no_openvino(name: str, *a: object, **k: object) -> Any:
|
|
if name == "openvino":
|
|
raise ImportError("gone")
|
|
return real(name, *a, **k) # type: ignore[arg-type]
|
|
|
|
monkeypatch.setattr(builtins, "__import__", no_openvino)
|
|
assert ovb.OpenvinoCpuBackend.probe_toolchain().ok is False
|
|
|
|
|
|
class TestTranscribeWiring:
|
|
class _Pipe:
|
|
def __init__(self, chunks: list[Any] | None = None, boom: bool = False) -> None:
|
|
self.chunks = chunks
|
|
self.boom = boom
|
|
self.seen: dict[str, Any] = {}
|
|
|
|
def generate(self, _pcm: object, **kwargs: object) -> Any:
|
|
if self.boom:
|
|
raise RuntimeError("kernel compile failed")
|
|
self.seen = dict(kwargs)
|
|
return type("R", (), {"chunks": self.chunks})()
|
|
|
|
def _chunk(self, start: float, end: float, text: str) -> Any:
|
|
return type("C", (), {"start_ts": start, "end_ts": end, "text": text})()
|
|
|
|
def _backend(self, pipe: object) -> ovb.OpenvinoCpuBackend:
|
|
backend = ovb.OpenvinoCpuBackend()
|
|
backend.pipe = pipe
|
|
backend.languages = {"<|en|>"}
|
|
return backend
|
|
|
|
def test_passes_timestamps_and_task(self) -> None:
|
|
pipe = self._Pipe(chunks=[])
|
|
self._backend(pipe).transcribe(np.zeros(16000, np.float32), TranscribeRequest())
|
|
assert pipe.seen["return_timestamps"] is True
|
|
assert pipe.seen["task"] == "transcribe"
|
|
|
|
def test_converts_the_language_to_token_form(self) -> None:
|
|
pipe = self._Pipe(chunks=[])
|
|
self._backend(pipe).transcribe(
|
|
np.zeros(16000, np.float32), TranscribeRequest(language="en")
|
|
)
|
|
assert pipe.seen["language"] == "<|en|>"
|
|
|
|
def test_omits_language_when_autodetecting(self) -> None:
|
|
pipe = self._Pipe(chunks=[])
|
|
self._backend(pipe).transcribe(np.zeros(16000, np.float32), TranscribeRequest())
|
|
assert "language" not in pipe.seen
|
|
|
|
def test_forwards_prompt_and_hotwords_only_when_set(self) -> None:
|
|
pipe = self._Pipe(chunks=[])
|
|
self._backend(pipe).transcribe(
|
|
np.zeros(16000, np.float32),
|
|
TranscribeRequest(initial_prompt="P", hotwords="H"),
|
|
)
|
|
assert pipe.seen["initial_prompt"] == "P"
|
|
assert pipe.seen["hotwords"] == "H"
|
|
|
|
def test_normalizes_chunks_into_segments(self) -> None:
|
|
pipe = self._Pipe(chunks=[self._chunk(0.0, -1.0, "hi")])
|
|
got = self._backend(pipe).transcribe(np.zeros(32000, np.float32), TranscribeRequest())
|
|
assert got.segments[0].end == pytest.approx(2.0)
|
|
|
|
def test_reports_audio_duration_and_device(self) -> None:
|
|
pipe = self._Pipe(chunks=[self._chunk(0.0, 1.0, "hi")])
|
|
got = self._backend(pipe).transcribe(np.zeros(32000, np.float32), TranscribeRequest())
|
|
assert got.audio_s == pytest.approx(2.0)
|
|
assert got.device == "CPU"
|
|
assert got.backend == "openvino"
|
|
|
|
def test_empty_chunks_give_no_segments(self) -> None:
|
|
pipe = self._Pipe(chunks=None)
|
|
assert (
|
|
self._backend(pipe)
|
|
.transcribe(np.zeros(16000, np.float32), TranscribeRequest())
|
|
.segments
|
|
== ()
|
|
)
|
|
|
|
def test_a_generate_failure_becomes_a_backend_runtime_error(self) -> None:
|
|
pipe = self._Pipe(boom=True)
|
|
with pytest.raises(errors.BackendRuntimeError, match="during generation"):
|
|
self._backend(pipe).transcribe(np.zeros(16000, np.float32), TranscribeRequest())
|
|
|
|
def test_transcribe_loads_lazily(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
|
backend = ovb.OpenvinoCpuBackend()
|
|
calls: list[int] = []
|
|
|
|
def fake_load(self: ovb.OpenvinoCpuBackend) -> None:
|
|
calls.append(1)
|
|
self.pipe = TestTranscribeWiring._Pipe(chunks=[])
|
|
|
|
monkeypatch.setattr(ovb.OpenvinoCpuBackend, "load", fake_load)
|
|
backend.transcribe(np.zeros(16000, np.float32), TranscribeRequest())
|
|
assert calls == [1]
|
|
|
|
def test_close_releases_the_pipeline(self) -> None:
|
|
backend = self._backend(self._Pipe())
|
|
backend.close()
|
|
assert backend.pipe is None
|
|
|
|
|
|
class TestLoadHints:
|
|
def test_cpu_hint_points_at_doctor(self) -> None:
|
|
assert "doctor" in ovb.OpenvinoCpuBackend().load_hint()
|
|
|
|
def test_gpu_hint_names_the_render_group_when_the_node_is_unwritable(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
node = probe_mod.RenderNode(
|
|
path=Path("/dev/dri/renderD128"),
|
|
vendor="intel",
|
|
driver="i915",
|
|
mode=0o660,
|
|
writable=False,
|
|
)
|
|
|
|
def one_node(*_a: object, **_k: object) -> list[probe_mod.RenderNode]:
|
|
return [node]
|
|
|
|
monkeypatch.setattr(probe_mod, "render_nodes", one_node)
|
|
assert "usermod -aG render" in ovb.OpenvinoGpuBackend().load_hint()
|
|
|
|
def test_gpu_hint_falls_back_to_doctor_when_permissions_are_fine(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
def no_nodes(*_a: object, **_k: object) -> list[probe_mod.RenderNode]:
|
|
return []
|
|
|
|
monkeypatch.setattr(probe_mod, "render_nodes", no_nodes)
|
|
assert "doctor" in ovb.OpenvinoGpuBackend().load_hint()
|
|
|
|
def test_a_pipeline_construction_failure_is_wrapped(
|
|
self, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
def boom(*_a: object, **_k: object) -> None:
|
|
raise RuntimeError("no device")
|
|
|
|
monkeypatch.setattr(openvino_genai, "WhisperPipeline", boom)
|
|
with pytest.raises(errors.BackendRuntimeError, match="could not build a pipeline"):
|
|
ovb.OpenvinoCpuBackend().load()
|
|
|
|
|
|
@LIVE
|
|
class TestLive:
|
|
def test_real_transcription_on_the_default_device(self, sine_wav: Path) -> None:
|
|
backend = ovb.OpenvinoGpuBackend()
|
|
backend.load()
|
|
result = backend.transcribe(ffmpeg.decode_16k_mono(sine_wav), TranscribeRequest())
|
|
assert result.device == "GPU"
|
|
|
|
|
|
def test_a_cache_miss_falls_through_to_the_network(
|
|
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
|
) -> None:
|
|
calls: list[bool] = []
|
|
|
|
def fake_snapshot(_model: str, _revision: str | None, *, local_only: bool) -> str:
|
|
calls.append(local_only)
|
|
if local_only:
|
|
raise OSError("not cached")
|
|
return str(tmp_path)
|
|
|
|
monkeypatch.setattr(ovb, "snapshot", fake_snapshot)
|
|
with caplog.at_level(logging.INFO):
|
|
assert ovb.resolve_model("some/model", None) == tmp_path
|
|
assert calls == [True, False], "the cache must be consulted before the network"
|
|
assert "not cached" in caplog.text
|