Files
local-transcriber/src/local_transcriber/cli.py
T
Dmitriy Dementiev 2e30d668ce refactor(config): уточнены типы каскада настроек
- Зачем:
  - общий union допускал несоответствие между ключом настройки и его типом.
- Что:
  - разделены типы TOML, CLI и полностью разрешённой конфигурации.
  - каскад значений сохранён в типобезопасной generic-функции.
- Проверка:
  - mypy src/local_transcriber/config.py.
  - pytest tests/test_config.py tests/test_cli.py: 109 passed.
2026-09-02 12:52:33 +03:00

780 lines
28 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""CLI-точка входа (typer). Single и batch режимы транскрипции."""
import sys
import time
import warnings
from collections.abc import Callable
from functools import wraps
from pathlib import Path
import typer
from rich.console import Console
from rich.status import Status
from .config import (
CliValues,
ResolvedConfig,
apply_device_defaults,
load_config,
resolve_defaults,
)
from .context_menu import install_menu as install_context_menu
from .context_menu import uninstall_menu as uninstall_context_menu
from .diarization import build_speaker_transcript
from .formatter import (
LANGUAGE_DETECTED,
LANGUAGE_FORCED,
LANGUAGE_FROM_MODEL,
LANGUAGE_UNKNOWN,
format_duration,
format_timestamp,
format_transcript,
write_transcript,
)
from .quality import (
TAIL_GAP_WARN_S,
RepetitionBlock,
find_repetition_blocks,
tail_gap,
)
from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer
from .transcriber import (
Segment,
TranscribeResult,
_is_cuda_error,
_transcribe_file,
load_model,
)
from .types import (
UNKNOWN_LANGUAGE,
DiarizationRun,
SpeakerTranscript,
StatusCallback,
)
from .utils import (
build_output_path,
detect_device,
expand_globs,
get_gpu_name,
get_intel_gpu_name,
has_existing_transcript,
validate_input_file,
)
app = typer.Typer()
console = Console(stderr=True)
def _show_cli_warning(
message: Warning | str,
*_args: object,
**_kwargs: object,
) -> None:
"""Печатает предупреждение в пользовательском формате CLI."""
console.print(f"Внимание: {message}", style="yellow", soft_wrap=True, markup=False)
def _with_cli_warning_renderer(
command: Callable[..., None],
) -> Callable[..., None]:
"""Устанавливает CLI-рендер предупреждений на время одного запуска."""
@wraps(command)
def wrapped(*args: object, **kwargs: object) -> None:
with warnings.catch_warnings():
warnings.showwarning = _show_cli_warning
command(*args, **kwargs)
return wrapped
def _format_device_info(device_used: str) -> str:
"""Формирует строку устройства для шапки транскрипта."""
if device_used == "cuda":
gpu_name = get_gpu_name()
return f"CUDA ({gpu_name or 'Unknown GPU'})"
if device_used == "openvino-gpu":
gpu_name = get_intel_gpu_name()
return f"OpenVINO ({gpu_name or 'Intel GPU'})"
if device_used in ("openvino", "openvino-cpu"):
return "OpenVINO (CPU)"
if device_used == "onnx":
return "ONNX (CPU)"
return "CPU"
def _format_language_mode(requested_language: str, result: TranscribeResult) -> str:
"""Описывает источник языка, не выдавая профиль модели за детектор."""
if requested_language != "auto":
return LANGUAGE_FORCED
if result.language_probability > 0:
return LANGUAGE_DETECTED
if result.language not in {"", UNKNOWN_LANGUAGE}:
return LANGUAGE_FROM_MODEL
return LANGUAGE_UNKNOWN
def _format_repetition_blocks(
blocks: list[RepetitionBlock],
use_hours: bool,
) -> str:
"""Формирует краткое описание блоков повторов для консоли."""
rendered = [
f"[{format_timestamp(block.start, use_hours=use_hours)} - "
f"{format_timestamp(block.end, use_hours=use_hours)}] ({block.count}×)"
for block in blocks[:3]
]
summary = "; ".join(rendered)
remaining = len(blocks) - 3
if remaining > 0:
summary = f"{summary} (+ ещё {remaining})"
return summary
def _print_quality_warnings(
result: TranscribeResult, file_name: str | None = None
) -> None:
"""Печатает предупреждения о возможной потере содержания."""
is_batch = file_name is not None
use_hours = result.duration > 3600
gap = tail_gap(result)
if gap > TAIL_GAP_WARN_S:
covered = format_duration(result.segments[-1].end)
total = format_duration(result.duration)
message = (
f"транскрипт покрывает {covered} из {total} — возможна потеря хвоста записи"
)
if is_batch:
console.print(f" {file_name}: {message}", style="yellow")
else:
console.print(
f"Внимание: {message}. Попробуйте другой --device.",
style="yellow",
)
blocks = find_repetition_blocks(result.segments)
if blocks:
message = (
f"блоки повторов: {_format_repetition_blocks(blocks, use_hours)} "
"— возможны галлюцинации модели"
)
if is_batch:
console.print(f" {file_name}: {message}", style="yellow")
else:
console.print(
f"Внимание: {message}. Попробуйте другой --device.",
style="yellow",
)
def _diarize_result(
file_path: Path,
result: TranscribeResult,
diarizer: SpeakerDiarizer,
on_status: StatusCallback,
) -> tuple[SpeakerTranscript | None, str | None, DiarizationRun | None]:
"""Запускает диаризацию и переводит ожидаемые сбои в деградацию вывода."""
try:
run = diarizer.process(file_path, on_status=on_status)
transcript = build_speaker_transcript(
result.words,
run.intervals,
result.duration,
)
if not run.intervals:
warning = "Диаризатор не нашёл интервалов при непустом распознавании"
elif transcript.cluster_count < 2:
warning = "Найден только один голосовой кластер"
else:
warning = None
return transcript, warning, run
except Exception as exc:
return None, f"Диаризация завершилась с ошибкой: {exc}", None
def _print_diarization_report(
transcript: SpeakerTranscript,
run: DiarizationRun,
verbose: bool,
file_name: str | None = None,
) -> None:
"""Печатает метрики verbose и обязательные предупреждения сведения."""
if verbose:
indent = " " if file_name is not None else ""
console.print(
f"{indent}Диаризация: {transcript.cluster_count} кластеров, "
f"{len(run.intervals)} интервалов, {run.elapsed_seconds:.1f} с"
)
warning_prefix = f" {file_name}: " if file_name is not None else "Внимание: "
if transcript.unassigned_word_count:
console.print(
f"{warning_prefix}{transcript.unassigned_word_count} слов "
"без назначенного говорящего",
style="yellow",
)
for cluster in transcript.small_clusters:
label = (
f"Speaker {cluster.speaker}"
if cluster.speaker is not None
else "кластер без номера"
)
console.print(
f"{warning_prefix}малый кластер {label}: {cluster.duration:.1f} с",
style="yellow",
)
@app.command()
@_with_cli_warning_renderer
def main(
files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"),
model: str | None = typer.Option(
None,
"--model",
"-m",
show_default=False,
help="Модель [по умолч.: medium (CUDA) / gigaam-v3-e2e-rnnt (ONNX)]",
),
language: str | None = typer.Option(
None, "--language", "-l", show_default=False, help="Язык [по умолч.: ru]"
),
output: Path | None = typer.Option(
None, "--output", "-o", help="Путь к выходному файлу"
),
device: str | None = typer.Option(
None,
"--device",
"-d",
show_default=False,
help="Устройство (auto|cpu|cuda|openvino|openvino-gpu|openvino-cpu|onnx) [по умолч.: auto]",
),
compute_type: str | None = typer.Option(
None,
"--compute-type",
show_default=False,
help=(
"Тип вычислений [по умолч.: float16 (CUDA) / "
"int8 (ONNX/OpenVINO) / float32 (CPU)]"
),
),
threads: int = typer.Option(
0,
"--threads",
"-t",
show_default=False,
min=0,
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)",
),
diarize: bool | None = typer.Option(
None,
"--diarize/--no-diarize",
help="Разделить транскрипт на реплики говорящих",
),
speakers: int | None = typer.Option(
None,
"--speakers",
min=1,
help="Известное число говорящих; автоматически включает --diarize",
),
verbose: bool = typer.Option(False, "--verbose", "-v", help="Подробный вывод"),
force: bool = typer.Option(
False, "--force", "-f", help="Перезаписать существующие транскрипты"
),
install_menu: bool = typer.Option(
False, "--install-menu", help="Установить пункт Transcribe в SendTo"
),
uninstall_menu: bool = typer.Option(
False, "--uninstall-menu", help="Удалить пункт Transcribe из SendTo"
),
) -> None:
"""Транскрибирует аудио/видеофайлы в markdown с таймкодами.
Каскад приоритетов параметров: CLI-флаги > .transcriber.toml > device-aware дефолты.
"""
files = [] if files is None else files
if install_menu or uninstall_menu:
if install_menu and uninstall_menu:
console.print(
"--install-menu и --uninstall-menu несовместимы.", style="red bold"
)
raise SystemExit(2)
if files:
console.print(
"Флаги меню нельзя использовать вместе с файлами.", style="red bold"
)
raise SystemExit(2)
if sys.platform != "win32":
console.print(
"Пункт меню SendTo доступен только на Windows.", style="red bold"
)
raise SystemExit(1)
try:
if install_menu:
cmd_path = install_context_menu()
console.print(f'Пункт меню установлен: "{cmd_path}"', style="green")
else:
cmd_path = uninstall_context_menu()
if cmd_path is None:
console.print("Пункт меню не был установлен.", style="yellow")
else:
console.print(f'Пункт меню удалён: "{cmd_path}"', style="green")
except RuntimeError as exc:
console.print(f"Ошибка: {exc}", style="red bold")
raise SystemExit(1)
return
if not files:
console.print(
"Укажите хотя бы один файл или используйте --install-menu/--uninstall-menu.",
style="red bold",
)
raise SystemExit(2)
if diarize is False and speakers is not None:
console.print(
"--no-diarize и --speakers несовместимы.",
style="red bold",
)
raise SystemExit(2)
try:
config = load_config()
cli_values: CliValues = {
"model": model,
"language": language,
"device": device,
"compute_type": compute_type,
"diarize": diarize,
}
defaults = resolve_defaults(cli_values, config)
diarize_enabled = defaults["diarize"] is True or speakers is not None
resolved_device = detect_device(defaults["device"])
defaults = apply_device_defaults(defaults, resolved_device, cli_values, config)
ct_explicit = compute_type is not None or "compute_type" in config
expanded = expand_globs(files)
if not expanded:
console.print("Файлы не найдены.", style="red bold")
raise SystemExit(1)
is_batch = len(expanded) > 1
if is_batch and output is not None:
console.print(
"--output несовместим с несколькими файлами.", style="red bold"
)
raise SystemExit(1)
if is_batch:
_run_batch(
expanded,
defaults,
verbose,
force,
ct_explicit,
cpu_threads=threads,
diarize=diarize_enabled,
speakers=speakers,
)
else:
_run_single(
expanded[0],
defaults,
output,
verbose,
ct_explicit,
cpu_threads=threads,
diarize=diarize_enabled,
speakers=speakers,
)
except KeyboardInterrupt:
console.print("\nПрервано пользователем.", style="yellow")
raise SystemExit(130)
except SystemExit:
raise
except ValueError as exc:
console.print(f"Ошибка: {exc}", style="red bold")
raise SystemExit(1)
except (FileNotFoundError,) as exc:
console.print(f"Ошибка: {exc}", style="red bold")
raise SystemExit(1)
except Exception as exc:
if _is_cuda_error(exc) and sys.platform == "win32":
console.print(
"GPU на Windows требует CUDA toolkit (включает cuBLAS).\n"
"Установите одним из способов:\n"
" choco install cuda\n"
" winget install -e --id Nvidia.CUDA\n"
"После установки перезапустите терминал.",
style="yellow",
)
if verbose:
console.print_exception()
else:
console.print(f"Ошибка: {exc}", style="red bold")
console.print("Запустите с --verbose для полного traceback.", style="dim")
raise SystemExit(1)
def _run_single(
file: Path,
defaults: ResolvedConfig,
output: Path | None,
verbose: bool,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
diarize: bool = False,
speakers: int | None = None,
) -> None:
"""Пайплайн одного файла: валидация → модель → транскрипция → запись."""
start = time.monotonic()
validated_file = validate_input_file(file)
requested_device = defaults["device"]
resolved_device = detect_device(requested_device)
strict = requested_device != "auto"
output_path = build_output_path(validated_file, output)
console.print(f"Файл: [bold]{validated_file.name}[/bold]")
def on_segment(seg: Segment) -> None:
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
model_obj, actual_device, backend, model_path = load_model(
defaults["model"],
resolved_device,
defaults["compute_type"],
on_status=lambda msg: console.print(msg),
strict_device=strict,
compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads,
)
actual_ct = (
getattr(backend, "actual_compute_type", defaults["compute_type"])
or defaults["compute_type"]
)
console.print(
f"Модель: [bold]{defaults['model']}[/bold] "
f"Устройство: [bold]{actual_device}[/bold] "
f"Compute: [bold]{actual_ct}[/bold]"
)
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
console.print(
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
style="dim",
)
speaker_diarizer = None
if diarize:
if not backend.word_timestamps_available:
raise ValueError(
"Выбранный движок или модель не поддерживает пословные таймкоды"
)
speaker_diarizer = load_speaker_diarizer(
speakers=speakers,
threads=cpu_threads,
on_status=lambda message: console.print(message),
)
with Status("Подготавливаю запуск...", console=console) as status:
tfr = _transcribe_file(
model=model_obj,
actual_device=actual_device,
backend=backend,
model_path=model_path,
file_path=validated_file,
model_name=defaults["model"],
compute_type=defaults["compute_type"],
language=defaults["language"] if defaults["language"] != "auto" else None,
on_segment=on_segment if verbose else None,
on_status=status.update,
strict_device=strict,
cpu_threads=cpu_threads,
)
result = tfr.result
speaker_transcript = None
diarization_warning = None
diarization_degraded = False
if speaker_diarizer is not None and result.segments:
with Status("Определяю говорящих...", console=console) as status:
speaker_transcript, diarization_warning, diarization_run = _diarize_result(
validated_file,
result,
speaker_diarizer,
on_status=(
(lambda message: console.print(message))
if verbose
else status.update
),
)
diarization_degraded = diarization_warning is not None
if diarization_run is not None and speaker_transcript is not None:
_print_diarization_report(
speaker_transcript,
diarization_run,
verbose,
)
if diarization_warning is not None:
console.print(f"Внимание: {diarization_warning}", style="yellow")
if tfr.actual_device != resolved_device:
if requested_device == "auto":
console.print(
f"Определено устройство {resolved_device}, "
f"но использовано {tfr.actual_device} (fallback)",
style="yellow",
)
else:
console.print(
f"Запрошено {requested_device}, использовано {tfr.actual_device}",
style="yellow",
)
if len(result.segments) == 0:
message = f"Речь не обнаружена в файле {validated_file.name}"
if speaker_diarizer is not None:
message += "; диаризация не запускалась"
console.print(message, style="yellow")
device_info = _format_device_info(result.device_used)
language_mode = _format_language_mode(defaults["language"], result)
content = format_transcript(
result=result,
source_filename=validated_file.name,
model_name=defaults["model"],
device_info=device_info,
language_mode=language_mode,
speaker_transcript=speaker_transcript,
diarization_warning=diarization_warning,
)
write_transcript(content, output_path)
elapsed = time.monotonic() - start
console.print(f'Транскрипт сохранён: "{output_path}"', style="green")
console.print(f" Сегментов: {len(result.segments)} Время: {elapsed:.1f}с")
_print_quality_warnings(result)
if diarization_degraded:
raise SystemExit(1)
def _run_batch(
files: list[Path],
defaults: ResolvedConfig,
verbose: bool,
force: bool,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
diarize: bool = False,
speakers: int | None = None,
) -> None:
"""Трёхфазный батч-пайплайн: prescan → загрузка модели → транскрипция."""
# Phase 1: Prescan — fail-fast + skip до загрузки модели (экономим ~2-5 сек)
to_process: list[Path] = []
skipped = 0
invalid = 0
for file in files:
try:
validated = validate_input_file(file)
except (FileNotFoundError, ValueError) as exc:
console.print(f" Ошибка: {file.name}: {exc}", style="red")
invalid += 1
continue
if not force and has_existing_transcript(validated):
console.print(
f" Пропуск: {file.name} (транскрипт существует)", style="dim"
)
skipped += 1
continue
to_process.append(validated)
if not to_process:
console.print(f"\nИтого: 0 обработано, {skipped} пропущено, {invalid} ошибок")
if invalid > 0:
raise SystemExit(1)
return
# Phase 2: Load model (ensure + create в одном вызове)
requested_device = defaults["device"]
resolved_device = detect_device(requested_device)
strict = requested_device != "auto"
model_obj, actual_device, backend, model_path = load_model(
defaults["model"],
resolved_device,
defaults["compute_type"],
on_status=lambda msg: console.print(msg),
strict_device=strict,
compute_type_explicit=compute_type_explicit,
cpu_threads=cpu_threads,
)
if actual_device == "openvino-gpu" and defaults["model"] != "large-v3":
console.print(
"Совет: --model large-v3 даёт лучшее качество на GPU (~2x дольше)",
style="dim",
)
if actual_device != resolved_device:
if requested_device == "auto":
console.print(
f"Определено устройство {resolved_device}, "
f"но используется {actual_device} (fallback)",
style="yellow",
)
else:
console.print(
f"Запрошено {requested_device}, используется {actual_device}",
style="yellow",
)
speaker_diarizer = None
if diarize:
if not backend.word_timestamps_available:
raise ValueError(
"Выбранный движок или модель не поддерживает пословные таймкоды"
)
speaker_diarizer = load_speaker_diarizer(
speakers=speakers,
threads=cpu_threads,
on_status=lambda message: console.print(message),
)
# Phase 3: Transcribe
processed = 0
degraded = 0
failed = 0
batch_start = time.monotonic()
for i, file in enumerate(to_process, 1):
try:
prefix = f"[{i}/{len(to_process)}] {file.name}"
console.print(f"{prefix}", style="bold")
file_start = time.monotonic()
def on_segment(seg: Segment) -> None:
console.print(f" [{seg.start:.2f}s] {seg.text.strip()}")
with Status(f"{prefix}...", console=console) as status:
tfr = _transcribe_file(
model=model_obj,
actual_device=actual_device,
backend=backend,
model_path=model_path,
file_path=file,
model_name=defaults["model"],
compute_type=defaults["compute_type"],
language=defaults["language"]
if defaults["language"] != "auto"
else None,
on_segment=on_segment if verbose else None,
on_status=status.update
if not verbose
else lambda msg: console.print(msg),
strict_device=strict,
cpu_threads=cpu_threads,
)
if tfr.actual_device != actual_device:
console.print(
f" {file.name}: fallback на {tfr.actual_device} при транскрипции",
style="yellow",
)
# Обновляем после возможного mid-stream fallback
model_obj = tfr.model
actual_device = tfr.actual_device
backend = tfr.backend
model_path = tfr.model_path
result = tfr.result
language_mode = _format_language_mode(defaults["language"], result)
speaker_transcript = None
diarization_warning = None
file_degraded = False
if speaker_diarizer is not None and result.segments:
with Status("Определяю говорящих...", console=console) as status:
speaker_transcript, diarization_warning, diarization_run = (
_diarize_result(
file,
result,
speaker_diarizer,
on_status=(
(lambda message: console.print(message))
if verbose
else status.update
),
)
)
file_degraded = diarization_warning is not None
if diarization_run is not None and speaker_transcript is not None:
_print_diarization_report(
speaker_transcript,
diarization_run,
verbose,
file_name=file.name,
)
if diarization_warning is not None:
console.print(
f" {file.name}: {diarization_warning}",
style="yellow",
)
if len(result.segments) == 0:
message = f" Речь не обнаружена: {file.name}"
if speaker_diarizer is not None:
message += "; диаризация не запускалась"
console.print(message, style="yellow")
device_info = _format_device_info(result.device_used)
content = format_transcript(
result=result,
source_filename=file.name,
model_name=defaults["model"],
device_info=device_info,
language_mode=language_mode,
speaker_transcript=speaker_transcript,
diarization_warning=diarization_warning,
)
write_transcript(content, build_output_path(file))
file_elapsed = time.monotonic() - file_start
console.print(
f" Готово: {file.name} "
f"Сегментов: {len(result.segments)} Время: {file_elapsed:.1f}с",
style="green",
)
processed += 1
if file_degraded:
degraded += 1
_print_quality_warnings(result, file.name)
except KeyboardInterrupt:
raise
except Exception as exc:
if verbose:
console.print_exception()
else:
console.print(f" Ошибка: {file.name}: {exc}", style="red")
failed += 1
total_failed = invalid + failed
batch_elapsed = time.monotonic() - batch_start
console.print(
f"\nИтого: {processed} обработано, {skipped} пропущено, "
f"{degraded} с деградацией, {total_failed} ошибок"
f" Время: {batch_elapsed:.1f}с"
)
if total_failed > 0 or degraded > 0:
raise SystemExit(1)
if __name__ == "__main__":
app()