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:
2026-03-17 23:38:32 +03:00
parent 0d1a734479
commit 3d14ed7b86
11 changed files with 804 additions and 12 deletions
+66 -2
View File
@@ -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__":