Files
audio-scribe/tests/test_backends_openvino.py
JMR-devandClaude Sonnet 5 b23995f218 refactor: rename the package from ccn-transcribe to audio-scribe
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>
2026-09-19 15:52:42 -05:00

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