"""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()