- неявный 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>
239 lines
8.2 KiB
Python
239 lines
8.2 KiB
Python
"""Бэкенд транскрипции на основе faster-whisper (CTranslate2)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from collections.abc import Callable
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from huggingface_hub import snapshot_download
|
|
from huggingface_hub.errors import LocalEntryNotFoundError
|
|
|
|
from local_transcriber._cuda_bootstrap import ensure_cublas_loadable
|
|
from local_transcriber.types import (
|
|
Segment,
|
|
TranscribeResult,
|
|
Word,
|
|
WordTimestampsUnavailableError,
|
|
)
|
|
from local_transcriber.utils import package_version
|
|
|
|
MODEL_REPOS = {
|
|
"tiny": "Systran/faster-whisper-tiny",
|
|
"base": "Systran/faster-whisper-base",
|
|
"small": "Systran/faster-whisper-small",
|
|
"medium": "Systran/faster-whisper-medium",
|
|
"large-v3": "Systran/faster-whisper-large-v3",
|
|
}
|
|
|
|
MODEL_ALLOW_PATTERNS = [
|
|
"config.json",
|
|
"preprocessor_config.json",
|
|
"model.bin",
|
|
"tokenizer.json",
|
|
"vocabulary.*",
|
|
]
|
|
|
|
MODEL_REQUIRED_FILES = [
|
|
"config.json",
|
|
"model.bin",
|
|
"tokenizer.json",
|
|
]
|
|
|
|
|
|
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,
|
|
model_name: str,
|
|
compute_type: str,
|
|
on_status: Callable[[str], None] | None = None,
|
|
) -> str:
|
|
"""Резолвит alias модели в repo_id и гарантирует наличие файлов."""
|
|
self.actual_compute_type = compute_type
|
|
local_path = Path(model_name).expanduser()
|
|
if local_path.is_dir():
|
|
_validate_model_dir(local_path)
|
|
return str(local_path)
|
|
|
|
repo_id = _resolve_model_repo(model_name)
|
|
|
|
try:
|
|
_notify(on_status, f"Проверяю кэш модели {model_name}...")
|
|
cached_path = Path(_snapshot_download(repo_id, local_files_only=True))
|
|
_validate_model_dir(cached_path)
|
|
return str(cached_path)
|
|
except LocalEntryNotFoundError:
|
|
pass
|
|
except ValueError:
|
|
_notify(on_status, f"Кэш модели {model_name} неполный, докачиваю...")
|
|
|
|
_notify(on_status, f"Скачиваю модель {model_name} из Hugging Face...")
|
|
downloaded_path = Path(_snapshot_download(repo_id, local_files_only=False))
|
|
_validate_model_dir(downloaded_path)
|
|
return str(downloaded_path)
|
|
|
|
def create_model(
|
|
self,
|
|
model_path: str,
|
|
device: str,
|
|
compute_type: str,
|
|
cpu_threads: int = 0,
|
|
) -> Any:
|
|
"""Создаёт WhisperModel.
|
|
|
|
cpu_threads: число потоков для CPU inference (0 = дефолт библиотеки, обычно 4).
|
|
"""
|
|
if device == "cuda":
|
|
ensure_cublas_loadable()
|
|
|
|
from faster_whisper import WhisperModel
|
|
|
|
self._cpu_threads = cpu_threads
|
|
try:
|
|
return WhisperModel(
|
|
model_path,
|
|
device=device,
|
|
compute_type=compute_type,
|
|
cpu_threads=cpu_threads,
|
|
)
|
|
except ImportError as exc:
|
|
if _is_missing_socksio_error(exc):
|
|
raise RuntimeError(
|
|
"Обнаружен SOCKS proxy, но не установлена зависимость `socksio`, "
|
|
"нужная для загрузки модели из Hugging Face через proxy. "
|
|
"Обновите окружение: `uv sync`."
|
|
) from exc
|
|
raise
|
|
|
|
def transcribe(
|
|
self,
|
|
model: Any,
|
|
file_path: Path,
|
|
language: str | None,
|
|
on_segment: Callable[[Segment], None] | None = None,
|
|
on_status: Callable[[str], None] | None = None,
|
|
) -> TranscribeResult:
|
|
"""Транскрибирует файл через faster-whisper."""
|
|
segment_generator, info = model.transcribe(
|
|
str(file_path),
|
|
language=language,
|
|
word_timestamps=True,
|
|
)
|
|
total_duration = info.duration
|
|
segments: list[Segment] = []
|
|
words: list[Word] = []
|
|
for raw_seg in segment_generator:
|
|
seg = Segment(start=raw_seg.start, end=raw_seg.end, text=raw_seg.text)
|
|
raw_words = raw_seg.words or []
|
|
if seg.text.strip() and not raw_words:
|
|
raise WordTimestampsUnavailableError(
|
|
"FasterWhisper не вернул пословные таймкоды "
|
|
"для распознанного сегмента"
|
|
)
|
|
words.extend(
|
|
Word(start=raw_word.start, end=raw_word.end, text=raw_word.word)
|
|
for raw_word in raw_words
|
|
)
|
|
if on_segment is not None:
|
|
on_segment(seg)
|
|
segments.append(seg)
|
|
_notify(
|
|
on_status,
|
|
f"Транскрибирую... {_fmt_time(seg.end)} / {_fmt_time(total_duration)}"
|
|
f" [{len(segments)} сегм.]",
|
|
)
|
|
|
|
return TranscribeResult(
|
|
segments=segments,
|
|
language=info.language,
|
|
language_probability=info.language_probability,
|
|
duration=info.duration,
|
|
device_used="", # оркестратор проставит actual_device
|
|
words=words,
|
|
)
|
|
|
|
def runtime_info(self) -> dict[str, str]:
|
|
"""Версии CTranslate2/faster-whisper и число видимых CUDA-устройств."""
|
|
import ctranslate2
|
|
|
|
try:
|
|
cuda_devices = str(ctranslate2.get_cuda_device_count())
|
|
except Exception as exc: # noqa: BLE001 — диагностика не должна ронять запуск
|
|
cuda_devices = f"недоступно ({exc})"
|
|
return {
|
|
"faster_whisper": package_version("faster-whisper"),
|
|
"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 "по умолчанию",
|
|
}
|
|
|
|
|
|
def _notify(on_status: Callable[[str], None] | None, message: str) -> None:
|
|
if on_status is not None:
|
|
on_status(message)
|
|
|
|
|
|
def _fmt_time(seconds: float) -> str:
|
|
m, s = divmod(int(seconds), 60)
|
|
h, m = divmod(m, 60)
|
|
return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
|
|
|
|
|
|
def _resolve_model_repo(model_name: str) -> str:
|
|
if "/" in model_name:
|
|
return model_name
|
|
repo_id = MODEL_REPOS.get(model_name)
|
|
if repo_id is None:
|
|
expected = ", ".join(MODEL_REPOS)
|
|
raise ValueError(
|
|
f"Неподдерживаемая модель '{model_name}'. Ожидалось одно из: {expected}"
|
|
)
|
|
return repo_id
|
|
|
|
|
|
def _snapshot_download(repo_id: str, local_files_only: bool) -> str:
|
|
try:
|
|
return snapshot_download(
|
|
repo_id,
|
|
local_files_only=local_files_only,
|
|
allow_patterns=MODEL_ALLOW_PATTERNS,
|
|
)
|
|
except ImportError as exc:
|
|
if _is_missing_socksio_error(exc):
|
|
raise RuntimeError(
|
|
"Обнаружен SOCKS proxy, но не установлена зависимость `socksio`, "
|
|
"нужная для загрузки модели из Hugging Face через proxy. "
|
|
"Обновите окружение: `uv sync`."
|
|
) from exc
|
|
raise
|
|
|
|
|
|
def _validate_model_dir(model_dir: Path) -> None:
|
|
missing = [
|
|
filename
|
|
for filename in MODEL_REQUIRED_FILES
|
|
if not (model_dir / filename).exists()
|
|
]
|
|
if not any(model_dir.glob("vocabulary.*")):
|
|
missing.append("vocabulary.*")
|
|
if missing:
|
|
missing_str = ", ".join(missing)
|
|
raise ValueError(
|
|
f"Неполная локальная модель в '{model_dir}': отсутствуют {missing_str}"
|
|
)
|
|
|
|
|
|
def _is_missing_socksio_error(exc: BaseException) -> bool:
|
|
msg = str(exc).lower()
|
|
return "socks proxy" in msg and "socksio" in msg
|