feat(transcriber): deep module выполнения транскрипции (#56) #58
No files matched your search
@@ -25,7 +25,7 @@ Package manager is **uv** (not pip). Build backend is hatchling.
|
|||||||
|
|
||||||
```
|
```
|
||||||
CLI (cli.py)
|
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)
|
→ utils.py detect_device(), validate files, expand globs (Windows workaround)
|
||||||
→ context_menu.py Windows SendTo: Transcribe.cmd install/uninstall (--install-menu / --uninstall-menu)
|
→ context_menu.py Windows SendTo: Transcribe.cmd install/uninstall (--install-menu / --uninstall-menu)
|
||||||
→ transcriber.py Transcriber(ExecutionRequest): resolves auto + device defaults,
|
→ transcriber.py Transcriber(ExecutionRequest): resolves auto + device defaults,
|
||||||
|
|||||||
+8
-8
@@ -43,25 +43,25 @@ tests/
|
|||||||
CLI (cli.py)
|
CLI (cli.py)
|
||||||
→ config.py: загрузка .transcriber.toml, каскад дефолтов
|
→ config.py: загрузка .transcriber.toml, каскад дефолтов
|
||||||
→ utils.py: валидация файлов, определение устройства
|
→ utils.py: валидация файлов, определение устройства
|
||||||
→ transcriber.py: загрузка модели (с CUDA fallback), транскрипция
|
→ transcriber.py: Transcriber — разрешение устройства, загрузка модели один раз на запуск, транскрипция, fallback
|
||||||
→ formatter.py: сегменты → markdown с таймкодами
|
→ formatter.py: сегменты → markdown с таймкодами
|
||||||
→ запись результата
|
→ запись результата
|
||||||
```
|
```
|
||||||
|
|
||||||
## Ключевые архитектурные решения
|
## Ключевые архитектурные решения
|
||||||
|
|
||||||
- **CUDA fallback** — двухуровневый: при загрузке модели и при транскрипции (mid-stream). GPU может быть видна через nvidia-smi, но не иметь достаточно VRAM.
|
- **Module выполнения** (`Transcriber` в `transcriber.py`) — один владелец модели, adapter'а и фактического исполнения на запуск. Fallback GPU→CPU (при загрузке и mid-stream) живёт внутри и доступен только при `strict_device=False` из Python API: явный device в CLI strict, `auto` — ONNX CPU.
|
||||||
- **Device-aware дефолты** — `compute_type` зависит от устройства (`float16`/`float32`). `float16` не работает на CPU, `float32` расточителен на GPU.
|
- **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` нельзя изменить в рантайме.
|
- **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`, поэтому глобы обрабатываются явно.
|
- **Ручной glob в utils** — typer на Windows не раскрывает `*.mp4`, поэтому глобы обрабатываются явно.
|
||||||
|
|
||||||
## Тестирование
|
## Тестирование
|
||||||
|
|
||||||
- Все CLI-тесты через `typer.testing.CliRunner` + моки (faster-whisper не вызывается)
|
- Все CLI-тесты через `typer.testing.CliRunner` с настоящим `Transcriber` и fake adapter, подменённым в `local_transcriber.transcriber.get_backend` (библиотеки не вызываются)
|
||||||
- Моки: `load_config`, `validate_input_file`, `detect_device`, `ensure_model_available`, `load_model`, `_transcribe_file`, `write_transcript`
|
- Моки: `load_config`, `validate_input_file`, `write_transcript`
|
||||||
- Паттерн: `_single_patches()` — хелпер для стандартного happy-path набора моков
|
- Паттерн: `_cli_run()` — контекстный менеджер стандартного набора, отдаёт `(backend, write_transcript)`
|
||||||
- `_make_result()` / `_make_tfr()` — фабрики тестовых данных
|
- `_make_result()` / `_make_backend()` — фабрики тестовых данных
|
||||||
|
|
||||||
## Частые задачи
|
## Частые задачи
|
||||||
|
|
||||||
|
|||||||
@@ -312,8 +312,9 @@ transcribe --uninstall-menu
|
|||||||
|
|
||||||
Перед распознаванием программа печатает модель, устройство и тип вычислений,
|
Перед распознаванием программа печатает модель, устройство и тип вычислений,
|
||||||
которые выбраны фактически: после `auto` это `onnx`, после `openvino` —
|
которые выбраны фактически: после `auto` это `onnx`, после `openvino` —
|
||||||
`openvino-gpu` или `openvino-cpu`. Если фактическое устройство отличается
|
`openvino-gpu` или `openvino-cpu`. Если после загрузки устройство оказалось
|
||||||
от запрошенного, выводится строка `Запрошено ..., используется ...`.
|
не тем, что выбрано до неё (например, OpenVINO взял CPU вместо GPU),
|
||||||
|
выводится строка `Запрошено ..., используется ...`.
|
||||||
|
|
||||||
С `--verbose` добавляются движок, бюджет потоков и версии runtime: для ONNX —
|
С `--verbose` добавляются движок, бюджет потоков и версии runtime: для ONNX —
|
||||||
`onnxruntime`, `onnx-asr`, доступные providers и те, что заданы сессиям ASR
|
`onnxruntime`, `onnx-asr`, доступные providers и те, что заданы сессиям ASR
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ class Backend(Protocol):
|
|||||||
наследование не требуется.
|
наследование не требуется.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
engine: str
|
||||||
|
"""Имя движка для сведений о выполнении."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def word_timestamps_available(self) -> bool:
|
def word_timestamps_available(self) -> bool:
|
||||||
"""Гарантирует ли выбранный backend/model пословные таймкоды."""
|
"""Гарантирует ли выбранный backend/model пословные таймкоды."""
|
||||||
|
|||||||
@@ -44,10 +44,12 @@ MODEL_REQUIRED_FILES = [
|
|||||||
class FasterWhisperBackend:
|
class FasterWhisperBackend:
|
||||||
"""Бэкенд транскрипции через faster-whisper (CTranslate2)."""
|
"""Бэкенд транскрипции через faster-whisper (CTranslate2)."""
|
||||||
|
|
||||||
|
engine = "faster-whisper"
|
||||||
word_timestamps_available = True
|
word_timestamps_available = True
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self.actual_compute_type: str | None = None
|
self.actual_compute_type: str | None = None
|
||||||
|
self._cpu_threads = 0
|
||||||
|
|
||||||
def ensure_model_available(
|
def ensure_model_available(
|
||||||
self,
|
self,
|
||||||
@@ -95,6 +97,7 @@ class FasterWhisperBackend:
|
|||||||
|
|
||||||
from faster_whisper import WhisperModel
|
from faster_whisper import WhisperModel
|
||||||
|
|
||||||
|
self._cpu_threads = cpu_threads
|
||||||
try:
|
try:
|
||||||
return WhisperModel(
|
return WhisperModel(
|
||||||
model_path,
|
model_path,
|
||||||
@@ -171,6 +174,7 @@ class FasterWhisperBackend:
|
|||||||
"ctranslate2": package_version("ctranslate2"),
|
"ctranslate2": package_version("ctranslate2"),
|
||||||
"cuda_devices": cuda_devices,
|
"cuda_devices": cuda_devices,
|
||||||
"compute_type": self.actual_compute_type or "",
|
"compute_type": self.actual_compute_type or "",
|
||||||
|
"cpu_threads": str(self._cpu_threads) if self._cpu_threads else "по умолчанию",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -132,6 +132,8 @@ def _normalize_quantization(compute_type: str) -> str | None:
|
|||||||
class OnnxAsrBackend:
|
class OnnxAsrBackend:
|
||||||
"""Бэкенд транскрипции через onnx-asr (ONNX Runtime)."""
|
"""Бэкенд транскрипции через onnx-asr (ONNX Runtime)."""
|
||||||
|
|
||||||
|
engine = "onnx-asr"
|
||||||
|
|
||||||
def __init__(self, compute_type_explicit: bool = True):
|
def __init__(self, compute_type_explicit: bool = True):
|
||||||
self._compute_type_explicit = compute_type_explicit
|
self._compute_type_explicit = compute_type_explicit
|
||||||
self.actual_compute_type: str | None = None
|
self.actual_compute_type: str | None = None
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ MODEL_REQUIRED_FILES = [
|
|||||||
class OpenVINOBackend:
|
class OpenVINOBackend:
|
||||||
"""Бэкенд транскрипции через openvino-genai WhisperPipeline."""
|
"""Бэкенд транскрипции через openvino-genai WhisperPipeline."""
|
||||||
|
|
||||||
|
engine = "openvino"
|
||||||
word_timestamps_available = True
|
word_timestamps_available = True
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
@@ -213,6 +214,7 @@ class OpenVINOBackend:
|
|||||||
"available_devices": ", ".join(Core().available_devices),
|
"available_devices": ", ".join(Core().available_devices),
|
||||||
"device": self.actual_ov_device or "",
|
"device": self.actual_ov_device or "",
|
||||||
"compute_type": self.actual_compute_type or "",
|
"compute_type": self.actual_compute_type or "",
|
||||||
|
"threads": "управляет OpenVINO, --threads не применяется",
|
||||||
}
|
}
|
||||||
|
|
||||||
def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]:
|
def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]:
|
||||||
|
|||||||
@@ -343,13 +343,11 @@ def main(
|
|||||||
diarize_enabled = defaults["diarize"] is True or speakers is not None
|
diarize_enabled = defaults["diarize"] is True or speakers is not None
|
||||||
requested_device = defaults["device"]
|
requested_device = defaults["device"]
|
||||||
|
|
||||||
# Явные model/compute_type; умолчания исполнения разрешает module.
|
# Умолчания model/compute_type зависят от устройства — их разрешает module.
|
||||||
request = ExecutionRequest(
|
request = ExecutionRequest(
|
||||||
device=requested_device,
|
device=requested_device,
|
||||||
model=model if model is not None else config.get("model"),
|
model=defaults["model"],
|
||||||
compute_type=(
|
compute_type=defaults["compute_type"],
|
||||||
compute_type if compute_type is not None else config.get("compute_type")
|
|
||||||
),
|
|
||||||
language=defaults["language"],
|
language=defaults["language"],
|
||||||
cpu_threads=threads,
|
cpu_threads=threads,
|
||||||
require_word_timestamps=diarize_enabled,
|
require_word_timestamps=diarize_enabled,
|
||||||
@@ -427,7 +425,9 @@ def _print_execution_header(info: ExecutionInfo, verbose: bool) -> None:
|
|||||||
)
|
)
|
||||||
if verbose:
|
if verbose:
|
||||||
threads = info.cpu_threads or "по умолчанию библиотеки"
|
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():
|
for key, value in info.runtime.items():
|
||||||
console.print(f" {key}: {value}", style="dim", markup=False)
|
console.print(f" {key}: {value}", style="dim", markup=False)
|
||||||
|
|
||||||
@@ -502,13 +502,6 @@ def _run_single(
|
|||||||
if diarization_warning is not None:
|
if diarization_warning is not None:
|
||||||
console.print(f"Внимание: {diarization_warning}", style="yellow")
|
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:
|
if len(result.segments) == 0:
|
||||||
message = f"Речь не обнаружена в файле {validated_file.name}"
|
message = f"Речь не обнаружена в файле {validated_file.name}"
|
||||||
if speaker_diarizer is not None:
|
if speaker_diarizer is not None:
|
||||||
@@ -520,8 +513,8 @@ def _run_single(
|
|||||||
content = format_transcript(
|
content = format_transcript(
|
||||||
result=result,
|
result=result,
|
||||||
source_filename=validated_file.name,
|
source_filename=validated_file.name,
|
||||||
model_name=execution.model,
|
model_name=info.model,
|
||||||
device_info=execution.description,
|
device_info=info.description,
|
||||||
language_mode=language_mode,
|
language_mode=language_mode,
|
||||||
speaker_transcript=speaker_transcript,
|
speaker_transcript=speaker_transcript,
|
||||||
diarization_warning=diarization_warning,
|
diarization_warning=diarization_warning,
|
||||||
@@ -591,7 +584,6 @@ def _run_batch(
|
|||||||
def on_segment(seg: Segment) -> None:
|
def on_segment(seg: Segment) -> None:
|
||||||
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
|
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
|
||||||
|
|
||||||
device_before = transcriber.execution.device
|
|
||||||
with Status(f"{prefix}...", console=console) as status:
|
with Status(f"{prefix}...", console=console) as status:
|
||||||
result = transcriber.transcribe(
|
result = transcriber.transcribe(
|
||||||
file,
|
file,
|
||||||
@@ -601,13 +593,6 @@ def _run_batch(
|
|||||||
else lambda msg: console.print(msg),
|
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)
|
language_mode = _format_language_mode(request.language or "auto", result)
|
||||||
speaker_transcript = None
|
speaker_transcript = None
|
||||||
diarization_warning = None
|
diarization_warning = None
|
||||||
@@ -651,8 +636,8 @@ def _run_batch(
|
|||||||
content = format_transcript(
|
content = format_transcript(
|
||||||
result=result,
|
result=result,
|
||||||
source_filename=file.name,
|
source_filename=file.name,
|
||||||
model_name=execution.model,
|
model_name=info.model,
|
||||||
device_info=execution.description,
|
device_info=info.description,
|
||||||
language_mode=language_mode,
|
language_mode=language_mode,
|
||||||
speaker_transcript=speaker_transcript,
|
speaker_transcript=speaker_transcript,
|
||||||
diarization_warning=diarization_warning,
|
diarization_warning=diarization_warning,
|
||||||
|
|||||||
@@ -30,12 +30,16 @@ class CliValues(TypedDict, total=False):
|
|||||||
|
|
||||||
|
|
||||||
class ResolvedConfig(TypedDict):
|
class ResolvedConfig(TypedDict):
|
||||||
"""Полная конфигурация после разрешения каскада."""
|
"""Конфигурация после каскада CLI > TOML.
|
||||||
|
|
||||||
model: str
|
``model`` и ``compute_type`` остаются ``None``, если не заданы явно:
|
||||||
|
их умолчания зависят от устройства и разрешаются при загрузке модели.
|
||||||
|
"""
|
||||||
|
|
||||||
|
model: str | None
|
||||||
language: str
|
language: str
|
||||||
device: str
|
device: str
|
||||||
compute_type: str
|
compute_type: str | None
|
||||||
diarize: bool
|
diarize: bool
|
||||||
|
|
||||||
|
|
||||||
@@ -47,6 +51,8 @@ HARDCODED_DEFAULTS: ResolvedConfig = {
|
|||||||
"diarize": False,
|
"diarize": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Умолчания по устройству; HARDCODED_DEFAULTS — последний резерв для
|
||||||
|
# устройств вне таблицы.
|
||||||
DEVICE_DEFAULTS: dict[str, dict[str, str]] = {
|
DEVICE_DEFAULTS: dict[str, dict[str, str]] = {
|
||||||
"cuda": {"model": "medium", "compute_type": "float16"},
|
"cuda": {"model": "medium", "compute_type": "float16"},
|
||||||
"cpu": {"model": "medium", "compute_type": "float32"},
|
"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:
|
def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedConfig:
|
||||||
"""Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты."""
|
"""Каскад приоритетов: CLI > конфиг-файл > hardcoded-дефолты.
|
||||||
|
|
||||||
|
Device-aware ключи (model, compute_type) без явного значения остаются None.
|
||||||
|
"""
|
||||||
return {
|
return {
|
||||||
"model": _resolve_value(
|
"model": _resolve_value(cli_values.get("model"), config.get("model"), None),
|
||||||
cli_values.get("model"), config.get("model"), HARDCODED_DEFAULTS["model"]
|
|
||||||
),
|
|
||||||
"language": _resolve_value(
|
"language": _resolve_value(
|
||||||
cli_values.get("language"),
|
cli_values.get("language"),
|
||||||
config.get("language"),
|
config.get("language"),
|
||||||
@@ -152,9 +159,7 @@ def resolve_defaults(cli_values: CliValues, config: ConfigValues) -> ResolvedCon
|
|||||||
HARDCODED_DEFAULTS["device"],
|
HARDCODED_DEFAULTS["device"],
|
||||||
),
|
),
|
||||||
"compute_type": _resolve_value(
|
"compute_type": _resolve_value(
|
||||||
cli_values.get("compute_type"),
|
cli_values.get("compute_type"), config.get("compute_type"), None
|
||||||
config.get("compute_type"),
|
|
||||||
HARDCODED_DEFAULTS["compute_type"],
|
|
||||||
),
|
),
|
||||||
"diarize": _resolve_value(
|
"diarize": _resolve_value(
|
||||||
cli_values.get("diarize"),
|
cli_values.get("diarize"),
|
||||||
|
|||||||
@@ -10,24 +10,13 @@ from local_transcriber.backends import get_backend
|
|||||||
from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS
|
from local_transcriber.config import DEVICE_DEFAULTS, HARDCODED_DEFAULTS
|
||||||
|
|
||||||
# Re-export из types.py для обратной совместимости
|
# Re-export из types.py для обратной совместимости
|
||||||
from local_transcriber.types import ( # noqa: F401
|
from local_transcriber.types import (
|
||||||
Segment,
|
Segment,
|
||||||
TranscribeResult,
|
TranscribeResult,
|
||||||
WordTimestampsUnavailableError,
|
WordTimestampsUnavailableError,
|
||||||
)
|
)
|
||||||
from local_transcriber.utils import detect_device, get_gpu_name, get_intel_gpu_name
|
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)
|
@dataclass(frozen=True)
|
||||||
class ExecutionRequest:
|
class ExecutionRequest:
|
||||||
@@ -78,23 +67,15 @@ class Transcriber:
|
|||||||
else request.device != "auto"
|
else request.device != "auto"
|
||||||
)
|
)
|
||||||
self._compute_type_explicit = request.compute_type is not None
|
self._compute_type_explicit = request.compute_type is not None
|
||||||
resolved = detect_device(request.device)
|
self._resolved_device = detect_device(request.device)
|
||||||
defaults = DEVICE_DEFAULTS.get(resolved, {})
|
self._device = self._resolved_device
|
||||||
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._backend: Any = None
|
self._backend: Any = None
|
||||||
self._model: Any = None
|
self._model: Any = None
|
||||||
self._info: ExecutionInfo | None = None
|
self._info: ExecutionInfo | None = None
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def execution(self) -> ExecutionInfo:
|
def execution(self) -> ExecutionInfo:
|
||||||
"""Текущие сведения о выполнении; до prepare() — без загрузки."""
|
"""Текущие сведения о выполнении; доступны после prepare()."""
|
||||||
if self._info is None:
|
if self._info is None:
|
||||||
raise RuntimeError("Модель ещё не подготовлена: вызовите prepare()")
|
raise RuntimeError("Модель ещё не подготовлена: вызовите prepare()")
|
||||||
return self._info
|
return self._info
|
||||||
@@ -104,7 +85,7 @@ class Transcriber:
|
|||||||
if self._info is not None:
|
if self._info is not None:
|
||||||
return self._info
|
return self._info
|
||||||
try:
|
try:
|
||||||
self._load(self._device, on_status)
|
info = self._load(self._device, on_status)
|
||||||
except (RuntimeError, ValueError) as exc:
|
except (RuntimeError, ValueError) as exc:
|
||||||
if not self._may_fall_back(exc):
|
if not self._may_fall_back(exc):
|
||||||
raise
|
raise
|
||||||
@@ -113,15 +94,13 @@ class Transcriber:
|
|||||||
"Переключение на CPU.",
|
"Переключение на CPU.",
|
||||||
stacklevel=2,
|
stacklevel=2,
|
||||||
)
|
)
|
||||||
self._load("cpu", on_status)
|
info = self._load("cpu", on_status)
|
||||||
if (
|
if self._request.require_word_timestamps and not info.word_timestamps_available:
|
||||||
self._request.require_word_timestamps
|
|
||||||
and not self._info.word_timestamps_available
|
|
||||||
):
|
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
"Выбранный движок или модель не поддерживает пословные таймкоды"
|
"Выбранный движок или модель не поддерживает пословные таймкоды"
|
||||||
)
|
)
|
||||||
return self._info
|
self._info = info
|
||||||
|
return info
|
||||||
|
|
||||||
def transcribe(
|
def transcribe(
|
||||||
self,
|
self,
|
||||||
@@ -144,7 +123,7 @@ class Transcriber:
|
|||||||
"Переключение на CPU и повтор.",
|
"Переключение на CPU и повтор.",
|
||||||
stacklevel=2,
|
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)
|
result = self._recognize(file_path, lang_arg, on_segment, on_status)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
@@ -168,42 +147,51 @@ class Transcriber:
|
|||||||
return False
|
return False
|
||||||
return _is_backend_error(exc, self._device)
|
return _is_backend_error(exc, self._device)
|
||||||
|
|
||||||
def _load(self, device: str, on_status: Callable[[str], None] | None) -> None:
|
def _load(
|
||||||
backend = get_backend(device, compute_type_explicit=self._compute_type_explicit)
|
self, device: str, on_status: Callable[[str], None] | None
|
||||||
model_path = backend.ensure_model_available(
|
) -> ExecutionInfo:
|
||||||
self._model_name, self._compute_type, on_status
|
"""Загружает модель на 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}...")
|
_notify_status(on_status, f"Инициализирую модель на {device}...")
|
||||||
model = backend.create_model(
|
model = backend.create_model(
|
||||||
model_path,
|
model_path,
|
||||||
device,
|
device,
|
||||||
self._compute_type,
|
compute_type,
|
||||||
cpu_threads=self._request.cpu_threads,
|
cpu_threads=self._request.cpu_threads,
|
||||||
)
|
)
|
||||||
self._backend = backend
|
self._backend = backend
|
||||||
self._model = model
|
self._model = model
|
||||||
self._device = _refine_openvino_device(device, backend)
|
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(
|
return ExecutionInfo(
|
||||||
requested_device=self._request.device,
|
requested_device=self._request.device,
|
||||||
resolved_device=self._resolved_device,
|
resolved_device=self._resolved_device,
|
||||||
device=self._device,
|
device=self._device,
|
||||||
engine=_ENGINES.get(self._device, "faster-whisper"),
|
engine=backend.engine,
|
||||||
model=self._model_name,
|
model=model_name,
|
||||||
compute_type=compute_type,
|
compute_type=getattr(backend, "actual_compute_type", None) or compute_type,
|
||||||
cpu_threads=self._request.cpu_threads,
|
cpu_threads=self._request.cpu_threads,
|
||||||
word_timestamps_available=bool(backend.word_timestamps_available),
|
word_timestamps_available=bool(backend.word_timestamps_available),
|
||||||
description=_describe_device(self._device),
|
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:
|
def _refine_openvino_device(device: str, backend: Any) -> str:
|
||||||
"""Заменяет запрошенный openvino-* на устройство, которое реально выбрал OpenVINO."""
|
"""Заменяет запрошенный openvino-* на устройство, которое реально выбрал OpenVINO."""
|
||||||
ov_dev = getattr(backend, "actual_ov_device", None)
|
ov_dev = getattr(backend, "actual_ov_device", None)
|
||||||
|
|||||||
@@ -97,3 +97,4 @@ def test_runtime_info_reports_versions_and_cuda_devices(monkeypatch):
|
|||||||
assert info["faster_whisper"]
|
assert info["faster_whisper"]
|
||||||
assert info["cuda_devices"] == "0"
|
assert info["cuda_devices"] == "0"
|
||||||
assert info["compute_type"] == "float32"
|
assert info["compute_type"] == "float32"
|
||||||
|
assert info["cpu_threads"] == "по умолчанию"
|
||||||
@@ -480,3 +480,4 @@ def test_runtime_info_reports_available_and_actual_devices(monkeypatch):
|
|||||||
assert info["available_devices"] == "CPU, GPU"
|
assert info["available_devices"] == "CPU, GPU"
|
||||||
assert info["device"] == "GPU"
|
assert info["device"] == "GPU"
|
||||||
assert info["compute_type"] == "fp16"
|
assert info["compute_type"] == "fp16"
|
||||||
|
assert "--threads не применяется" in info["threads"]
|
||||||
+33
-2
@@ -45,6 +45,7 @@ def _make_backend(
|
|||||||
):
|
):
|
||||||
"""Fake adapter на internal seam Backend; module выполнения работает по-настоящему."""
|
"""Fake adapter на internal seam Backend; module выполнения работает по-настоящему."""
|
||||||
backend = MagicMock(name="Backend")
|
backend = MagicMock(name="Backend")
|
||||||
|
backend.engine = "onnx-asr"
|
||||||
backend.word_timestamps_available = word_timestamps
|
backend.word_timestamps_available = word_timestamps
|
||||||
backend.actual_compute_type = None
|
backend.actual_compute_type = None
|
||||||
backend.actual_ov_device = actual_ov_device
|
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"])
|
out = runner.invoke(app, [str(audio), "--verbose", "--threads", "4"])
|
||||||
|
|
||||||
assert out.exit_code == 0
|
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 "onnxruntime: 1.28.0" in out.output
|
||||||
assert "asr_providers: CPUExecutionProvider" 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
|
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."""
|
"""--threads передаётся в create_model как cpu_threads."""
|
||||||
audio = tmp_path / "test.mp3"
|
audio = tmp_path / "test.mp3"
|
||||||
audio.write_bytes(b"fake")
|
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 out.exit_code == 0
|
||||||
assert "потеря хвоста" not in out.output
|
assert "потеря хвоста" not in out.output
|
||||||
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)
|
||||||
@@ -106,15 +106,15 @@ def test_resolve_defaults_config_wins():
|
|||||||
assert result["model"] == "tiny"
|
assert result["model"] == "tiny"
|
||||||
|
|
||||||
|
|
||||||
def test_resolve_defaults_hardcoded_fallback():
|
def test_resolve_defaults_leaves_device_aware_keys_empty():
|
||||||
result = resolve_defaults(
|
result = resolve_defaults(
|
||||||
{"model": None, "language": None, "device": None, "compute_type": None}, {}
|
{"model": None, "language": None, "device": None, "compute_type": None}, {}
|
||||||
)
|
)
|
||||||
assert result == {
|
assert result == {
|
||||||
"model": "medium",
|
"model": None,
|
||||||
"language": "ru",
|
"language": "ru",
|
||||||
"device": "auto",
|
"device": "auto",
|
||||||
"compute_type": "float32",
|
"compute_type": None,
|
||||||
"diarize": False,
|
"diarize": False,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+115
-6
@@ -232,7 +232,7 @@ def test_transcribe_reports_status_transitions(mock_get_backend):
|
|||||||
on_status=statuses.append,
|
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)
|
||||||
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 возвращает словарь."""
|
"""Fake adapter для external interface: runtime_info возвращает словарь."""
|
||||||
backend = _make_backend(**kwargs)
|
backend = _make_backend(**kwargs)
|
||||||
backend.runtime_info.return_value = {}
|
backend.runtime_info.return_value = {}
|
||||||
|
backend.engine = "fake-engine"
|
||||||
backend.word_timestamps_available = True
|
backend.word_timestamps_available = True
|
||||||
backend.actual_compute_type = None
|
backend.actual_compute_type = None
|
||||||
backend.actual_ov_device = 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.requested_device == "auto"
|
||||||
assert info.device == "onnx"
|
assert info.device == "onnx"
|
||||||
assert info.engine == "onnx-asr"
|
|
||||||
assert info.model == "gigaam-v3-e2e-rnnt"
|
assert info.model == "gigaam-v3-e2e-rnnt"
|
||||||
assert info.compute_type == "int8"
|
assert info.compute_type == "int8"
|
||||||
assert info.description == "ONNX (CPU)"
|
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 cpu_backend.create_model.call_args.kwargs["cpu_threads"] == 3
|
||||||
assert run.execution.resolved_device == "cuda"
|
assert run.execution.resolved_device == "cuda"
|
||||||
assert run.execution.device == "cpu"
|
assert run.execution.device == "cpu"
|
||||||
assert run.execution.engine == "faster-whisper"
|
|
||||||
|
|
||||||
|
|
||||||
@patch("local_transcriber.transcriber.get_backend")
|
@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
|
mock_get_backend.return_value = backend
|
||||||
|
|
||||||
run = Transcriber(
|
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="декодировать"):
|
with pytest.raises(ValueError, match="декодировать"):
|
||||||
run.transcribe(Path("bad.mp3"))
|
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.requested_device == "openvino"
|
||||||
assert info.resolved_device == "openvino-gpu"
|
assert info.resolved_device == "openvino-gpu"
|
||||||
assert info.device == "openvino-cpu"
|
assert info.device == "openvino-cpu"
|
||||||
assert info.engine == "openvino"
|
|
||||||
assert info.compute_type == "int8"
|
assert info.compute_type == "int8"
|
||||||
assert info.description == "OpenVINO (CPU)"
|
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"))
|
result = run.transcribe(Path("a.mp3"))
|
||||||
|
|
||||||
assert result.device_used == "cpu"
|
assert result.device_used == "cpu"
|
||||||
assert run.execution.engine == "faster-whisper"
|
|
||||||
|
|
||||||
|
|
||||||
@patch("local_transcriber.transcriber.get_backend")
|
@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.resolved_device == "openvino-cpu"
|
||||||
assert info.device == "openvino-gpu"
|
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"]
|
||||||
Reference in new issue
Block a user