diff --git a/AGENTS.md b/AGENTS.md index 7e19c64..73dba05 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -25,7 +25,7 @@ Package manager is **uv** (not pip). Build backend is hatchling. ``` CLI (cli.py) - → config.py cascade: CLI arg → .transcriber.toml → device-aware default → hardcoded + → config.py cascade: CLI arg → .transcriber.toml (model/compute_type stay None for the module to resolve) → utils.py detect_device(), validate files, expand globs (Windows workaround) → context_menu.py Windows SendTo: Transcribe.cmd install/uninstall (--install-menu / --uninstall-menu) → transcriber.py Transcriber(ExecutionRequest): resolves auto + device defaults, diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 05fce13..b88f7d8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,25 +43,25 @@ tests/ CLI (cli.py) → config.py: загрузка .transcriber.toml, каскад дефолтов → utils.py: валидация файлов, определение устройства - → transcriber.py: загрузка модели (с CUDA fallback), транскрипция + → transcriber.py: Transcriber — разрешение устройства, загрузка модели один раз на запуск, транскрипция, fallback → formatter.py: сегменты → markdown с таймкодами → запись результата ``` ## Ключевые архитектурные решения -- **CUDA fallback** — двухуровневый: при загрузке модели и при транскрипции (mid-stream). GPU может быть видна через nvidia-smi, но не иметь достаточно VRAM. -- **Device-aware дефолты** — `compute_type` зависит от устройства (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU. +- **Module выполнения** (`Transcriber` в `transcriber.py`) — один владелец модели, adapter'а и фактического исполнения на запуск. Fallback GPU→CPU (при загрузке и mid-stream) живёт внутри и доступен только при `strict_device=False` из Python API: явный device в CLI strict, `auto` — ONNX CPU. +- **Device-aware дефолты** — `model` и `compute_type` без явного значения разрешаются по устройству при загрузке (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU. - **cuBLAS bootstrap** (`_cuda_bootstrap.py`) — preload через ctypes до импорта ctranslate2. pip-пакет `nvidia-cublas-cu12` ставит `.so` в нестандартное место, а `LD_LIBRARY_PATH` нельзя изменить в рантайме. -- **Батч-режим** — 3 фазы (prescan → load model → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки. +- **Батч-режим** — 3 фазы (prescan → создание `Transcriber` → transcribe). Модель загружается один раз (~2-5 сек), невалидные файлы отсеиваются до загрузки; CLI не переносит состояние между файлами. - **Ручной glob в utils** — typer на Windows не раскрывает `*.mp4`, поэтому глобы обрабатываются явно. ## Тестирование -- Все CLI-тесты через `typer.testing.CliRunner` + моки (faster-whisper не вызывается) -- Моки: `load_config`, `validate_input_file`, `detect_device`, `ensure_model_available`, `load_model`, `_transcribe_file`, `write_transcript` -- Паттерн: `_single_patches()` — хелпер для стандартного happy-path набора моков -- `_make_result()` / `_make_tfr()` — фабрики тестовых данных +- Все CLI-тесты через `typer.testing.CliRunner` с настоящим `Transcriber` и fake adapter, подменённым в `local_transcriber.transcriber.get_backend` (библиотеки не вызываются) +- Моки: `load_config`, `validate_input_file`, `write_transcript` +- Паттерн: `_cli_run()` — контекстный менеджер стандартного набора, отдаёт `(backend, write_transcript)` +- `_make_result()` / `_make_backend()` — фабрики тестовых данных ## Частые задачи diff --git a/README.md b/README.md index ecec6d5..e989a23 100644 --- a/README.md +++ b/README.md @@ -312,8 +312,9 @@ transcribe --uninstall-menu Перед распознаванием программа печатает модель, устройство и тип вычислений, которые выбраны фактически: после `auto` это `onnx`, после `openvino` — -`openvino-gpu` или `openvino-cpu`. Если фактическое устройство отличается -от запрошенного, выводится строка `Запрошено ..., используется ...`. +`openvino-gpu` или `openvino-cpu`. Если после загрузки устройство оказалось +не тем, что выбрано до неё (например, OpenVINO взял CPU вместо GPU), +выводится строка `Запрошено ..., используется ...`. С `--verbose` добавляются движок, бюджет потоков и версии runtime: для ONNX — `onnxruntime`, `onnx-asr`, доступные providers и те, что заданы сессиям ASR diff --git a/src/local_transcriber/backends/base.py b/src/local_transcriber/backends/base.py index bc4ca1c..9d46d30 100644 --- a/src/local_transcriber/backends/base.py +++ b/src/local_transcriber/backends/base.py @@ -16,6 +16,9 @@ class Backend(Protocol): наследование не требуется. """ + engine: str + """Имя движка для сведений о выполнении.""" + @property def word_timestamps_available(self) -> bool: """Гарантирует ли выбранный backend/model пословные таймкоды.""" diff --git a/src/local_transcriber/backends/faster_whisper.py b/src/local_transcriber/backends/faster_whisper.py index c75fe87..d5767ee 100644 --- a/src/local_transcriber/backends/faster_whisper.py +++ b/src/local_transcriber/backends/faster_whisper.py @@ -44,10 +44,12 @@ MODEL_REQUIRED_FILES = [ class FasterWhisperBackend: """Бэкенд транскрипции через faster-whisper (CTranslate2).""" + engine = "faster-whisper" word_timestamps_available = True def __init__(self): self.actual_compute_type: str | None = None + self._cpu_threads = 0 def ensure_model_available( self, @@ -95,6 +97,7 @@ class FasterWhisperBackend: from faster_whisper import WhisperModel + self._cpu_threads = cpu_threads try: return WhisperModel( model_path, @@ -171,6 +174,7 @@ class FasterWhisperBackend: "ctranslate2": package_version("ctranslate2"), "cuda_devices": cuda_devices, "compute_type": self.actual_compute_type or "", + "cpu_threads": str(self._cpu_threads) if self._cpu_threads else "по умолчанию", } diff --git a/src/local_transcriber/backends/onnx_asr.py b/src/local_transcriber/backends/onnx_asr.py index 3c4eb13..0dc0e2f 100644 --- a/src/local_transcriber/backends/onnx_asr.py +++ b/src/local_transcriber/backends/onnx_asr.py @@ -132,6 +132,8 @@ def _normalize_quantization(compute_type: str) -> str | None: class OnnxAsrBackend: """Бэкенд транскрипции через onnx-asr (ONNX Runtime).""" + engine = "onnx-asr" + def __init__(self, compute_type_explicit: bool = True): self._compute_type_explicit = compute_type_explicit self.actual_compute_type: str | None = None diff --git a/src/local_transcriber/backends/openvino.py b/src/local_transcriber/backends/openvino.py index e5a1be7..1d359f4 100644 --- a/src/local_transcriber/backends/openvino.py +++ b/src/local_transcriber/backends/openvino.py @@ -57,6 +57,7 @@ MODEL_REQUIRED_FILES = [ class OpenVINOBackend: """Бэкенд транскрипции через openvino-genai WhisperPipeline.""" + engine = "openvino" word_timestamps_available = True def __init__( @@ -213,6 +214,7 @@ class OpenVINOBackend: "available_devices": ", ".join(Core().available_devices), "device": self.actual_ov_device or "", "compute_type": self.actual_compute_type or "", + "threads": "управляет OpenVINO, --threads не применяется", } def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]: diff --git a/src/local_transcriber/cli.py b/src/local_transcriber/cli.py index 554025a..2a56126 100644 --- a/src/local_transcriber/cli.py +++ b/src/local_transcriber/cli.py @@ -343,13 +343,11 @@ def main( diarize_enabled = defaults["diarize"] is True or speakers is not None requested_device = defaults["device"] - # Явные model/compute_type; умолчания исполнения разрешает module. + # Умолчания model/compute_type зависят от устройства — их разрешает module. request = ExecutionRequest( device=requested_device, - model=model if model is not None else config.get("model"), - compute_type=( - compute_type if compute_type is not None else config.get("compute_type") - ), + model=defaults["model"], + compute_type=defaults["compute_type"], language=defaults["language"], cpu_threads=threads, require_word_timestamps=diarize_enabled, @@ -427,7 +425,9 @@ def _print_execution_header(info: ExecutionInfo, verbose: bool) -> None: ) if verbose: threads = info.cpu_threads or "по умолчанию библиотеки" - console.print(f"Движок: {info.engine} Потоки: {threads}", style="dim") + console.print( + f"Движок: {info.engine} Потоки (запрошено): {threads}", style="dim" + ) for key, value in info.runtime.items(): console.print(f" {key}: {value}", style="dim", markup=False) @@ -502,13 +502,6 @@ def _run_single( if diarization_warning is not None: console.print(f"Внимание: {diarization_warning}", style="yellow") - execution = transcriber.execution - if execution.device != info.device: - console.print( - f"Запрошено {execution.requested_device}, использовано {execution.device}", - style="yellow", - ) - if len(result.segments) == 0: message = f"Речь не обнаружена в файле {validated_file.name}" if speaker_diarizer is not None: @@ -520,8 +513,8 @@ def _run_single( content = format_transcript( result=result, source_filename=validated_file.name, - model_name=execution.model, - device_info=execution.description, + model_name=info.model, + device_info=info.description, language_mode=language_mode, speaker_transcript=speaker_transcript, diarization_warning=diarization_warning, @@ -591,7 +584,6 @@ def _run_batch( def on_segment(seg: Segment) -> None: console.print(f" [{seg.start:.2f}s] {seg.text.strip()}") - device_before = transcriber.execution.device with Status(f"{prefix}...", console=console) as status: result = transcriber.transcribe( file, @@ -601,13 +593,6 @@ def _run_batch( else lambda msg: console.print(msg), ) - execution = transcriber.execution - if execution.device != device_before: - console.print( - f" {file.name}: fallback на {execution.device} при транскрипции", - style="yellow", - ) - language_mode = _format_language_mode(request.language or "auto", result) speaker_transcript = None diarization_warning = None @@ -651,8 +636,8 @@ def _run_batch( content = format_transcript( result=result, source_filename=file.name, - model_name=execution.model, - device_info=execution.description, + model_name=info.model, + device_info=info.description, language_mode=language_mode, speaker_transcript=speaker_transcript, diarization_warning=diarization_warning, diff --git a/src/local_transcriber/config.py b/src/local_transcriber/config.py index 66f0896..c27cd75 100644 --- a/src/local_transcriber/config.py +++ b/src/local_transcriber/config.py @@ -30,12 +30,16 @@ class CliValues(TypedDict, total=False): class ResolvedConfig(TypedDict): - """Полная конфигурация после разрешения каскада.""" + """Конфигурация после каскада CLI > TOML. - model: str + ``model`` и ``compute_type`` остаются ``None``, если не заданы явно: + их умолчания зависят от устройства и разрешаются при загрузке модели. + """ + + model: str | None language: str device: str - compute_type: str + compute_type: str | None diarize: bool @@ -47,6 +51,8 @@ HARDCODED_DEFAULTS: ResolvedConfig = { "diarize": False, } +# Умолчания по устройству; HARDCODED_DEFAULTS — последний резерв для +# устройств вне таблицы. DEVICE_DEFAULTS: dict[str, dict[str, str]] = { "cuda": {"model": "medium", "compute_type": "float16"}, "cpu": {"model": "medium", "compute_type": "float32"}, @@ -136,11 +142,12 @@ def load_config(path: Path | None = None) -> ConfigValues: def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedConfig: - """Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты.""" + """Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты. + + Device-aware ключи (model, compute_type) без явного значения остаются None. + """ return { - "model": _resolve_value( - cli_values.get("model"), config.get("model"), HARDCODED_DEFAULTS["model"] - ), + "model": _resolve_value(cli_values.get("model"), config.get("model"), None), "language": _resolve_value( cli_values.get("language"), config.get("language"), @@ -152,9 +159,7 @@ def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedCon HARDCODED_DEFAULTS["device"], ), "compute_type": _resolve_value( - cli_values.get("compute_type"), - config.get("compute_type"), - HARDCODED_DEFAULTS["compute_type"], + cli_values.get("compute_type"), config.get("compute_type"), None ), "diarize": _resolve_value( cli_values.get("diarize"), diff --git a/src/local_transcriber/transcriber.py b/src/local_transcriber/transcriber.py index 7485991..3313b80 100644 --- a/src/local_transcriber/transcriber.py +++ b/src/local_transcriber/transcriber.py @@ -10,24 +10,13 @@ from local_transcriber.backends import get_backend from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS # Re-export из types.py для обратной совместимости -from local_transcriber.types import ( # noqa: F401 +from local_transcriber.types import ( Segment, TranscribeResult, WordTimestampsUnavailableError, ) from local_transcriber.utils import detect_device, get_gpu_name, get_intel_gpu_name -# Движок распознавания для каждого значения device; остальное, как и в -# get_backend, уходит в faster-whisper. Способ исполнения (CPU/GPU) остаётся -# в самом значении device и трактуется adapter'ом. -_ENGINES: dict[str, str] = { - "cpu": "faster-whisper", - "cuda": "faster-whisper", - "openvino-cpu": "openvino", - "openvino-gpu": "openvino", - "onnx": "onnx-asr", -} - @dataclass(frozen=True) class ExecutionRequest: @@ -78,23 +67,15 @@ class Transcriber: else request.device != "auto" ) self._compute_type_explicit = request.compute_type is not None - resolved = detect_device(request.device) - defaults = DEVICE_DEFAULTS.get(resolved, {}) - self._model_name = request.model or defaults.get( - "model", HARDCODED_DEFAULTS["model"] - ) - self._compute_type = request.compute_type or defaults.get( - "compute_type", HARDCODED_DEFAULTS["compute_type"] - ) - self._resolved_device = resolved - self._device = resolved + self._resolved_device = detect_device(request.device) + self._device = self._resolved_device self._backend: Any = None self._model: Any = None self._info: ExecutionInfo | None = None @property def execution(self) -> ExecutionInfo: - """Текущие сведения о выполнении; до prepare() — без загрузки.""" + """Текущие сведения о выполнении; доступны после prepare().""" if self._info is None: raise RuntimeError("Модель ещё не подготовлена: вызовите prepare()") return self._info @@ -104,7 +85,7 @@ class Transcriber: if self._info is not None: return self._info try: - self._load(self._device, on_status) + info = self._load(self._device, on_status) except (RuntimeError, ValueError) as exc: if not self._may_fall_back(exc): raise @@ -113,15 +94,13 @@ class Transcriber: "Переключение на CPU.", stacklevel=2, ) - self._load("cpu", on_status) - if ( - self._request.require_word_timestamps - and not self._info.word_timestamps_available - ): + info = self._load("cpu", on_status) + if self._request.require_word_timestamps and not info.word_timestamps_available: raise ValueError( "Выбранный движок или модель не поддерживает пословные таймкоды" ) - return self._info + self._info = info + return info def transcribe( self, @@ -144,7 +123,7 @@ class Transcriber: "Переключение на CPU и повтор.", stacklevel=2, ) - self._load("cpu", on_status) + self._info = self._load("cpu", on_status) result = self._recognize(file_path, lang_arg, on_segment, on_status) return result @@ -168,42 +147,51 @@ class Transcriber: return False return _is_backend_error(exc, self._device) - def _load(self, device: str, on_status: Callable[[str], None] | None) -> None: - backend = get_backend(device, compute_type_explicit=self._compute_type_explicit) - model_path = backend.ensure_model_available( - self._model_name, self._compute_type, on_status + def _load( + self, device: str, on_status: Callable[[str], None] | None + ) -> ExecutionInfo: + """Загружает модель на device; неявные model/compute_type берутся из его умолчаний.""" + defaults = DEVICE_DEFAULTS.get(device, {}) + model_name = self._request.model or defaults.get( + "model", HARDCODED_DEFAULTS["model"] ) + compute_type = self._request.compute_type or defaults.get( + "compute_type", HARDCODED_DEFAULTS["compute_type"] + ) + backend = get_backend(device, compute_type_explicit=self._compute_type_explicit) + model_path = backend.ensure_model_available(model_name, compute_type, on_status) _notify_status(on_status, f"Инициализирую модель на {device}...") model = backend.create_model( model_path, device, - self._compute_type, + compute_type, cpu_threads=self._request.cpu_threads, ) self._backend = backend self._model = model self._device = _refine_openvino_device(device, backend) - self._info = self._describe() - - def _describe(self) -> ExecutionInfo: - backend = self._backend - compute_type = ( - getattr(backend, "actual_compute_type", None) or self._compute_type - ) return ExecutionInfo( requested_device=self._request.device, resolved_device=self._resolved_device, device=self._device, - engine=_ENGINES.get(self._device, "faster-whisper"), - model=self._model_name, - compute_type=compute_type, + engine=backend.engine, + model=model_name, + compute_type=getattr(backend, "actual_compute_type", None) or compute_type, cpu_threads=self._request.cpu_threads, word_timestamps_available=bool(backend.word_timestamps_available), description=_describe_device(self._device), - runtime=dict(backend.runtime_info()), + runtime=_collect_runtime_info(backend), ) +def _collect_runtime_info(backend: Any) -> dict[str, str]: + """Диагностика не должна ронять запуск: сбой попадает в сведения строкой.""" + try: + return dict(backend.runtime_info()) + except Exception as exc: # noqa: BLE001 + return {"diagnostics": f"недоступно ({exc})"} + + def _refine_openvino_device(device: str, backend: Any) -> str: """Заменяет запрошенный openvino-* на устройство, которое реально выбрал OpenVINO.""" ov_dev = getattr(backend, "actual_ov_device", None) diff --git a/tests/test_backend_faster_whisper.py b/tests/test_backend_faster_whisper.py index b4cd575..4cf4423 100644 --- a/tests/test_backend_faster_whisper.py +++ b/tests/test_backend_faster_whisper.py @@ -97,3 +97,4 @@ def test_runtime_info_reports_versions_and_cuda_devices(monkeypatch): assert info["faster_whisper"] assert info["cuda_devices"] == "0" assert info["compute_type"] == "float32" + assert info["cpu_threads"] == "по умолчанию" diff --git a/tests/test_backend_openvino.py b/tests/test_backend_openvino.py index 385ad8a..97ed20a 100644 --- a/tests/test_backend_openvino.py +++ b/tests/test_backend_openvino.py @@ -480,3 +480,4 @@ def test_runtime_info_reports_available_and_actual_devices(monkeypatch): assert info["available_devices"] == "CPU, GPU" assert info["device"] == "GPU" assert info["compute_type"] == "fp16" + assert "--threads не применяется" in info["threads"] diff --git a/tests/test_cli.py b/tests/test_cli.py index ce5745e..b9fcfb4 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -45,6 +45,7 @@ def _make_backend( ): """Fake adapter на internal seam Backend; module выполнения работает по-настоящему.""" backend = MagicMock(name="Backend") + backend.engine = "onnx-asr" backend.word_timestamps_available = word_timestamps backend.actual_compute_type = None backend.actual_ov_device = actual_ov_device @@ -126,7 +127,7 @@ def test_cli_verbose_prints_runtime_diagnostics(tmp_path): out = runner.invoke(app, [str(audio), "--verbose", "--threads", "4"]) assert out.exit_code == 0 - assert "Движок: onnx-asr Потоки: 4" in out.output + assert "Движок: onnx-asr Потоки (запрошено): 4" in out.output assert "onnxruntime: 1.28.0" in out.output assert "asr_providers: CPUExecutionProvider" in out.output @@ -1069,7 +1070,7 @@ def test_cli_reports_openvino_cpu_when_gpu_was_requested(tmp_path): assert "Запрошено openvino, используется openvino-cpu" in out.output -def test_cli_threads_passed_to_load_model(tmp_path): +def test_cli_threads_reach_adapter(tmp_path): """--threads передаётся в create_model как cpu_threads.""" audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") @@ -1325,3 +1326,33 @@ def test_cli_default_result_has_no_quality_warnings(tmp_path): assert out.exit_code == 0 assert "потеря хвоста" not in out.output assert "галлюцинации" not in out.output + + +def test_cli_toml_compute_type_overrides_device_default(tmp_path): + """compute_type из TOML важнее умолчания устройства и считается явным.""" + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + + with ( + _cli_run(tmp_file=audio, config={"device": "cuda", "compute_type": "int8"}) as ( + backend, + _write, + ), + patch("local_transcriber.transcriber.get_backend", return_value=backend) as get_backend, + ): + out = runner.invoke(app, [str(audio)]) + + assert out.exit_code == 0 + get_backend.assert_called_once_with("cuda", compute_type_explicit=True) + backend.ensure_model_available.assert_called_once_with("medium", "int8", ANY) + + +def test_cli_device_default_compute_type_when_not_configured(tmp_path): + audio = tmp_path / "test.mp3" + audio.write_bytes(b"fake") + + with _cli_run(tmp_file=audio, config={"device": "cuda"}) as (backend, _write): + out = runner.invoke(app, [str(audio)]) + + assert out.exit_code == 0 + backend.ensure_model_available.assert_called_once_with("medium", "float16", ANY) diff --git a/tests/test_config.py b/tests/test_config.py index fcecbcf..9e44eea 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -106,15 +106,15 @@ def test_resolve_defaults_config_wins(): assert result["model"] == "tiny" -def test_resolve_defaults_hardcoded_fallback(): +def test_resolve_defaults_leaves_device_aware_keys_empty(): result = resolve_defaults( {"model": None, "language": None, "device": None, "compute_type": None}, {} ) assert result == { - "model": "medium", + "model": None, "language": "ru", "device": "auto", - "compute_type": "float32", + "compute_type": None, "diarize": False, } diff --git a/tests/test_transcriber.py b/tests/test_transcriber.py index 6551e70..3f5e929 100644 --- a/tests/test_transcriber.py +++ b/tests/test_transcriber.py @@ -232,7 +232,7 @@ def test_transcribe_reports_status_transitions(mock_get_backend): on_status=statuses.append, ) - # load_model reports init status, _transcribe_file reports transcribe status + # Статусы подготовки и распознавания идут через один callback assert any("Инициализирую модель" in s for s in statuses) assert any("Транскрибирую" in s for s in statuses) @@ -415,6 +415,7 @@ def _make_run_backend(**kwargs): """Fake adapter для external interface: runtime_info возвращает словарь.""" backend = _make_backend(**kwargs) backend.runtime_info.return_value = {} + backend.engine = "fake-engine" backend.word_timestamps_available = True backend.actual_compute_type = None backend.actual_ov_device = None @@ -461,7 +462,6 @@ def test_auto_resolves_to_onnx_cpu_with_device_defaults(mock_get_backend): ) assert info.requested_device == "auto" assert info.device == "onnx" - assert info.engine == "onnx-asr" assert info.model == "gigaam-v3-e2e-rnnt" assert info.compute_type == "int8" assert info.description == "ONNX (CPU)" @@ -499,7 +499,6 @@ def test_run_falls_back_to_cpu_at_load_and_keeps_state_for_next_file( assert cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 3 assert run.execution.resolved_device == "cuda" assert run.execution.device == "cpu" - assert run.execution.engine == "faster-whisper" @patch("local_transcriber.transcriber.get_backend") @@ -555,7 +554,9 @@ def test_run_ordinary_file_error_does_not_change_state(mock_get_backend): mock_get_backend.return_value = backend run = Transcriber( - ExecutionRequest(device="cuda", model="tiny", compute_type="int8") + ExecutionRequest( + device="cuda", model="tiny", compute_type="int8", strict_device=False + ) ) with pytest.raises(ValueError, match="декодировать"): run.transcribe(Path("bad.mp3")) @@ -620,7 +621,6 @@ def test_run_reports_actual_openvino_device(mock_get_backend): assert info.requested_device == "openvino" assert info.resolved_device == "openvino-gpu" assert info.device == "openvino-cpu" - assert info.engine == "openvino" assert info.compute_type == "int8" assert info.description == "OpenVINO (CPU)" @@ -723,7 +723,6 @@ def test_run_openvino_runtime_error_falls_back_when_not_strict(mock_get_backend) result = run.transcribe(Path("a.mp3")) assert result.device_used == "cpu" - assert run.execution.engine == "faster-whisper" @patch("local_transcriber.transcriber.get_backend") @@ -749,3 +748,113 @@ def test_run_reports_openvino_gpu_chosen_for_cpu_request(mock_get_backend): assert info.resolved_device == "openvino-cpu" assert info.device == "openvino-gpu" + + +@patch("local_transcriber.transcriber.get_backend") +def test_run_fallback_rederives_implicit_compute_type_for_cpu(mock_get_backend): + """Неявный compute_type следует за фактическим устройством: CPU не получает float16.""" + cuda_backend = _make_run_backend(create_model_error=RuntimeError("CUDA error")) + cpu_backend = _make_run_backend() + mock_get_backend.side_effect = lambda device, **_: ( + cuda_backend if device == "cuda" else cpu_backend + ) + + run = Transcriber(ExecutionRequest(device="cuda", strict_device=False)) + with pytest.warns(UserWarning, match="Переключение на CPU"): + info = run.prepare() + + cuda_backend.ensure_model_available.assert_called_once_with("medium", "float16", None) + cpu_backend.ensure_model_available.assert_called_once_with("medium", "float32", None) + assert info.compute_type == "float32" + + +@patch("local_transcriber.transcriber.get_backend") +def test_run_fallback_keeps_explicit_compute_type(mock_get_backend): + cuda_backend = _make_run_backend(create_model_error=RuntimeError("CUDA error")) + cpu_backend = _make_run_backend() + mock_get_backend.side_effect = lambda device, **_: ( + cuda_backend if device == "cuda" else cpu_backend + ) + + run = Transcriber( + ExecutionRequest(device="cuda", compute_type="int8", strict_device=False) + ) + with pytest.warns(UserWarning): + run.prepare() + + cpu_backend.ensure_model_available.assert_called_once_with("medium", "int8", None) + + +@patch("local_transcriber.transcriber.get_backend") +def test_run_failed_capability_check_does_not_mark_module_prepared(mock_get_backend): + """После отказа по пословному контракту повторный вызов не запускает ASR.""" + backend = _make_run_backend() + backend.word_timestamps_available = False + mock_get_backend.return_value = backend + + run = Transcriber( + ExecutionRequest(device="onnx", model="some/raw-model", require_word_timestamps=True) + ) + with pytest.raises(ValueError, match="пословные таймкоды"): + run.prepare() + with pytest.raises(ValueError, match="пословные таймкоды"): + run.transcribe(Path("a.mp3")) + + backend.transcribe.assert_not_called() + + +@pytest.mark.parametrize( + ("device", "model", "compute_type"), + [ + ("cpu", "medium", "float32"), + ("cuda", "medium", "float16"), + ("openvino-cpu", "medium", "int8"), + ("openvino-gpu", "medium", "int8"), + ("onnx", "gigaam-v3-e2e-rnnt", "int8"), + ], +) +@patch("local_transcriber.transcriber.get_backend") +def test_run_applies_device_defaults_when_request_leaves_them_empty( + mock_get_backend, device, model, compute_type +): + backend = _make_run_backend() + mock_get_backend.return_value = backend + + Transcriber(ExecutionRequest(device=device)).prepare() + + mock_get_backend.assert_called_once_with(device, compute_type_explicit=False) + backend.ensure_model_available.assert_called_once_with(model, compute_type, None) + + +@patch("local_transcriber.transcriber.get_backend") +def test_run_explicit_values_override_device_defaults(mock_get_backend): + backend = _make_run_backend() + mock_get_backend.return_value = backend + + Transcriber(ExecutionRequest(device="cuda", model="large-v3", compute_type="int8")).prepare() + + mock_get_backend.assert_called_once_with("cuda", compute_type_explicit=True) + backend.ensure_model_available.assert_called_once_with("large-v3", "int8", None) + + +@patch("local_transcriber.transcriber.get_backend") +def test_run_engine_comes_from_adapter(mock_get_backend): + backend = _make_run_backend() + backend.engine = "openvino" + mock_get_backend.return_value = backend + + info = Transcriber(ExecutionRequest(device="openvino-cpu", model="medium")).prepare() + + assert info.engine == "openvino" + + +@patch("local_transcriber.transcriber.get_backend") +def test_run_diagnostics_failure_does_not_fail_the_run(mock_get_backend): + """Сбой сбора runtime_info не роняет распознавание, а попадает в сведения.""" + backend = _make_run_backend() + backend.runtime_info.side_effect = RuntimeError("plugin enumeration failed") + mock_get_backend.return_value = backend + + info = Transcriber(ExecutionRequest(device="cpu", model="tiny")).prepare() + + assert "plugin enumeration failed" in info.runtime["diagnostics"]