fix(transcriber): закрыты находки ревью по module выполнения
- неявный compute_type при fallback переразрешается по умолчанию CPU (float16 CUDA не переносится на CTranslate2 CPU); - prepare() помечает module готовым только после проверки пословного контракта; - движок сообщает сам adapter (атрибут engine) вместо параллельной таблицы; - сбой runtime_info() не роняет запуск; - каскад конфига оставляет model/compute_type пустыми для module, CLI не дублирует CLI > TOML; - удалены недостижимые из CLI сообщения о mid-stream fallback; - в --verbose потоки помечены как запрошенные, adapters сообщают применённые; OpenVINO прямо говорит, что --threads не применяется; - тесты: умолчания по устройствам, TOML compute_type, не-strict сценарий обычной ошибки; CONTRIBUTING.md и AGENTS.md без устаревших имён. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
f7c0ed9a8c
commit
6f1064ccc4
15 files changed
+236
-104
No files matched your search
@@ -16,6 +16,9 @@ class Backend(Protocol):
|
||||
наследование не требуется.
|
||||
"""
|
||||
|
||||
engine: str
|
||||
"""Имя движка для сведений о выполнении."""
|
||||
|
||||
@property
|
||||
def word_timestamps_available(self) -> bool:
|
||||
"""Гарантирует ли выбранный backend/model пословные таймкоды."""
|
||||
|
||||
@@ -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 "по умолчанию",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user