feat(cli): реализован шаг 5 — CLI-связка всех модулей с исправлениями из ревью
- Зачем:
- шаг 5 плана: нужен рабочий CLI-happy path, связывающий utils / transcriber / formatter.
- ревью этапов 4–5 выявило два medium-бага в formatter и отсутствие тестов для CLI.
- Что:
- cli.py: все опции по PRD 3.2 (--model, --language, --output, --device, --compute-type, --verbose),
rich Status + stderr-консоль, предупреждение на пустую речь, статистика времени.
- transcriber.py: добавлена ensure_model_available() с проверкой кэша HF и валидацией
локальной директории; on_status callback для передачи прогресса в CLI; обработка
ImportError при отсутствии socksio через SOCKS proxy.
- formatter.py: исправлен overflow в format_timestamp (0.995 → 00:01.00 вместо 00:00.100);
сегменты теперь пишутся с явным пробелом и strip() независимо от whisper-формата текста.
- deps: добавлен socksio>=1.0.0 для поддержки SOCKS proxy при загрузке модели.
- tests: test_cli.py (8 тестов на CLI-контракт), расширены test_formatter.py и test_transcriber.py.
- Проверка:
- uv run pytest — 42 passed.
- uv run transcribe --help показывает все опции.
This commit is contained in:
@@ -1,13 +1,77 @@
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
from rich.console import Console
|
||||
from rich.status import Status
|
||||
|
||||
from .formatter import format_transcript, write_transcript
|
||||
from .transcriber import Segment, ensure_model_available, transcribe
|
||||
from .utils import build_output_path, check_ffmpeg, detect_device, get_gpu_name, validate_input_file
|
||||
|
||||
app = typer.Typer()
|
||||
console = Console(stderr=True)
|
||||
|
||||
|
||||
@app.command()
|
||||
def main(file: Path) -> None:
|
||||
typer.echo("TODO: not implemented")
|
||||
def main(
|
||||
file: Path = typer.Argument(..., help="Путь к аудио- или видеофайлу"),
|
||||
model: str = typer.Option("large-v3", "--model", "-m", help="Модель Whisper"),
|
||||
language: str = typer.Option("auto", "--language", "-l", help="Язык (ru|en|auto)"),
|
||||
output: Path | None = typer.Option(None, "--output", "-o", help="Путь к выходному файлу"),
|
||||
device: str = typer.Option("auto", "--device", "-d", help="Устройство (auto|cpu|cuda)"),
|
||||
compute_type: str = typer.Option("int8", "--compute-type", help="Тип вычислений"),
|
||||
verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"),
|
||||
) -> None:
|
||||
start = time.monotonic()
|
||||
|
||||
check_ffmpeg()
|
||||
validated_file = validate_input_file(file)
|
||||
resolved_device = detect_device(device)
|
||||
output_path = build_output_path(validated_file, output)
|
||||
|
||||
console.print(f"Файл: [bold]{validated_file.name}[/bold]")
|
||||
console.print(f"Модель: [bold]{model}[/bold] Устройство: [bold]{resolved_device}[/bold] Compute: [bold]{compute_type}[/bold]")
|
||||
|
||||
model_path = ensure_model_available(model, on_status=lambda message: console.print(message))
|
||||
|
||||
def on_segment(seg: Segment) -> None:
|
||||
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
|
||||
|
||||
with Status("Подготавливаю запуск...", console=console) as status:
|
||||
result = transcribe(
|
||||
file_path=validated_file,
|
||||
model_name=model_path,
|
||||
device=resolved_device,
|
||||
compute_type=compute_type,
|
||||
language=language if language != "auto" else None,
|
||||
on_segment=on_segment if verbose else None,
|
||||
on_status=status.update,
|
||||
)
|
||||
|
||||
if len(result.segments) == 0:
|
||||
console.print(f"⚠ Речь не обнаружена в файле {validated_file.name}", style="yellow")
|
||||
|
||||
if result.device_used == "cuda":
|
||||
gpu_name = get_gpu_name()
|
||||
device_info = f"CUDA ({gpu_name or 'Unknown GPU'})"
|
||||
else:
|
||||
device_info = "CPU"
|
||||
|
||||
language_mode = "detected" if language == "auto" else "forced"
|
||||
|
||||
content = format_transcript(
|
||||
result=result,
|
||||
source_filename=validated_file.name,
|
||||
model_name=model,
|
||||
device_info=device_info,
|
||||
language_mode=language_mode,
|
||||
)
|
||||
write_transcript(content, output_path)
|
||||
|
||||
elapsed = time.monotonic() - start
|
||||
console.print(f"✓ Транскрипт сохранён: [bold]{output_path}[/bold]", style="green")
|
||||
console.print(f" Сегментов: {len(result.segments)} Время: {elapsed:.1f}с")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -5,8 +5,9 @@ from .transcriber import TranscribeResult
|
||||
|
||||
|
||||
def format_timestamp(seconds: float, use_hours: bool = False) -> str:
|
||||
total_seconds = int(seconds)
|
||||
centiseconds = int(round((seconds - total_seconds) * 100))
|
||||
total_cs = round(seconds * 100)
|
||||
centiseconds = total_cs % 100
|
||||
total_seconds = total_cs // 100
|
||||
|
||||
if use_hours:
|
||||
hours = total_seconds // 3600
|
||||
@@ -59,7 +60,7 @@ def format_transcript(
|
||||
start = format_timestamp(seg.start, use_hours=use_hours)
|
||||
end = format_timestamp(seg.end, use_hours=use_hours)
|
||||
lines.append("")
|
||||
lines.append(f"[{start} - {end}]{seg.text}")
|
||||
lines.append(f"[{start} - {end}] {seg.text.strip()}")
|
||||
|
||||
lines.append("")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -4,6 +4,31 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from faster_whisper import WhisperModel
|
||||
from huggingface_hub import snapshot_download
|
||||
from huggingface_hub.errors import LocalEntryNotFoundError
|
||||
|
||||
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",
|
||||
"preprocessor_config.json",
|
||||
"model.bin",
|
||||
"tokenizer.json",
|
||||
]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -29,12 +54,14 @@ def transcribe(
|
||||
compute_type: str = "int8",
|
||||
language: str | None = None,
|
||||
on_segment: Callable[[Segment], None] | None = None,
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
) -> TranscribeResult:
|
||||
actual_device = device
|
||||
lang_arg = language if language and language != "auto" else None
|
||||
|
||||
try:
|
||||
model = WhisperModel(model_name, device=device, compute_type=compute_type)
|
||||
_notify_status(on_status, f"Загружаю модель на {device}...")
|
||||
model = _create_model(model_name, device, compute_type)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
if device != "cpu" and _is_cuda_error(exc):
|
||||
warnings.warn(
|
||||
@@ -43,11 +70,13 @@ def transcribe(
|
||||
stacklevel=2,
|
||||
)
|
||||
actual_device = "cpu"
|
||||
model = WhisperModel(model_name, device="cpu", compute_type=compute_type)
|
||||
_notify_status(on_status, "Загружаю модель на cpu...")
|
||||
model = _create_model(model_name, "cpu", compute_type)
|
||||
else:
|
||||
raise
|
||||
|
||||
try:
|
||||
_notify_status(on_status, "Транскрибирую...")
|
||||
segments, info = _run_transcription(model, file_path, lang_arg, on_segment)
|
||||
except (RuntimeError, ValueError) as exc:
|
||||
if actual_device != "cpu" and _is_cuda_error(exc):
|
||||
@@ -57,7 +86,9 @@ def transcribe(
|
||||
stacklevel=2,
|
||||
)
|
||||
actual_device = "cpu"
|
||||
model = WhisperModel(model_name, device="cpu", compute_type=compute_type)
|
||||
_notify_status(on_status, "Загружаю модель на cpu...")
|
||||
model = _create_model(model_name, "cpu", compute_type)
|
||||
_notify_status(on_status, "Транскрибирую...")
|
||||
segments, info = _run_transcription(model, file_path, lang_arg, on_segment)
|
||||
else:
|
||||
raise
|
||||
@@ -71,6 +102,33 @@ def transcribe(
|
||||
)
|
||||
|
||||
|
||||
def ensure_model_available(
|
||||
model_name: str,
|
||||
on_status: Callable[[str], None] | None = None,
|
||||
) -> str:
|
||||
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_status(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_status(on_status, f"Кэш модели {model_name} неполный, докачиваю...")
|
||||
|
||||
_notify_status(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 _run_transcription(model, file_path, lang_arg, on_segment):
|
||||
"""Run model.transcribe and iterate segments. Returns (segments, info)."""
|
||||
segment_generator, info = model.transcribe(str(file_path), language=lang_arg)
|
||||
@@ -83,6 +141,70 @@ def _run_transcription(model, file_path, lang_arg, on_segment):
|
||||
return segments, info
|
||||
|
||||
|
||||
def _create_model(model_name: str, device: str, compute_type: str):
|
||||
try:
|
||||
return WhisperModel(model_name, device=device, compute_type=compute_type)
|
||||
except ImportError as exc:
|
||||
if _is_missing_socksio_error(exc):
|
||||
raise RuntimeError(
|
||||
"Обнаружен SOCKS proxy, но не установлена зависимость `socksio`, "
|
||||
"нужная для загрузки модели из Hugging Face через proxy. "
|
||||
"Обновите окружение: `uv sync`."
|
||||
) from exc
|
||||
raise
|
||||
|
||||
|
||||
def _is_cuda_error(exc: BaseException) -> bool:
|
||||
msg = str(exc).lower()
|
||||
return "cuda" in msg or "out of memory" in msg
|
||||
|
||||
|
||||
def _is_missing_socksio_error(exc: BaseException) -> bool:
|
||||
msg = str(exc).lower()
|
||||
return "socks proxy" in msg and "socksio" in msg
|
||||
|
||||
|
||||
def _notify_status(on_status: Callable[[str], None] | None, message: str) -> None:
|
||||
if on_status is not None:
|
||||
on_status(message)
|
||||
|
||||
|
||||
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}")
|
||||
|
||||
Reference in New Issue
Block a user