feat(diarization): добавлено разделение транскрипта по говорящим

Зачем:
- локальным транскриптам нужна структура реплик для конспектов и протоколов.

Что:
- добавлены пословные таймкоды для всех ASR-бэкендов и сведение с Sherpa-ONNX.
- реализованы CLI-флаги, деградация без потери ASR и speaker Markdown.
- добавлены проверяемый кеш моделей, тесты и документация.

Проверка:
- `pytest` — 283 passed, 1 skipped.
- `pyright` — 0 errors.
- Ruff и `git diff --check` — без ошибок.
- выполнены три контрольных прогона на реальных записях.
This commit is contained in:
Dmitriy Dementiev committed 2026-08-14 18:55:42 +03:00
1 parent 63ba41d906
commit 726718197f
23 files changed
+2522 -187

No files matched your search

+276 -47
View File
@@ -11,6 +11,7 @@ from rich.status import Status
from .config import 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,
@@ -27,6 +28,7 @@ from .quality import (
find_repetition_blocks,
tail_gap,
)
from .speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer
from .transcriber import (
Segment,
TranscribeResult,
@@ -34,7 +36,12 @@ from .transcriber import (
_transcribe_file,
load_model,
)
from .types import UNKNOWN_LANGUAGE
from .types import (
UNKNOWN_LANGUAGE,
DiarizationRun,
SpeakerTranscript,
StatusCallback,
)
from .utils import (
build_output_path,
detect_device,
@@ -62,9 +69,7 @@ def _format_device_info(device_used: str) -> str:
return "CPU"
def _format_language_mode(
requested_language: str, result: TranscribeResult
) -> str:
def _format_language_mode(requested_language: str, result: TranscribeResult) -> str:
"""Описывает источник языка, не выдавая профиль модели за детектор."""
if requested_language != "auto":
return LANGUAGE_FORCED
@@ -92,7 +97,9 @@ def _format_repetition_blocks(
return summary
def _print_quality_warnings(result: TranscribeResult, file_name: str | None = None) -> None:
def _print_quality_warnings(
result: TranscribeResult, file_name: str | None = None
) -> None:
"""Печатает предупреждения о возможной потере содержания."""
is_batch = file_name is not None
use_hours = result.duration > 3600
@@ -102,8 +109,7 @@ def _print_quality_warnings(result: TranscribeResult, file_name: str | None = No
covered = format_duration(result.segments[-1].end)
total = format_duration(result.duration)
message = (
f"транскрипт покрывает {covered} из {total} — "
"возможна потеря хвоста записи"
f"транскрипт покрывает {covered} из {total} — возможна потеря хвоста записи"
)
if is_batch:
console.print(f" {file_name}: {message}", style="yellow")
@@ -128,6 +134,64 @@ def _print_quality_warnings(result: TranscribeResult, file_name: str | None = No
)
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()
def main(
files: list[Path] | None = typer.Argument(None, help="Пути к аудио/видеофайлам"),
@@ -141,26 +205,54 @@ def main(
language: str | None = typer.Option(
None, "--language", "-l", show_default=False, help="Язык [по умолч.: ru]"
),
output: Path | None = typer.Option(None, "--output", "-o", help="Путь к выходному файлу"),
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]"
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,
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 = дефолт библиотеки; рекомендуется = число физ. ядер)"
0,
"--threads",
"-t",
show_default=False,
min=0,
help="Потоки CPU (0 = дефолт библиотеки; рекомендуется = число физ. ядер)",
),
diarize: bool = typer.Option(
False,
"--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"),
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 с таймкодами.
@@ -170,25 +262,31 @@ def main(
if install_menu or uninstall_menu:
if install_menu and uninstall_menu:
console.print("--install-menu и --uninstall-menu несовместимы.", style="red bold")
console.print(
"--install-menu и --uninstall-menu несовместимы.", style="red bold"
)
raise SystemExit(2)
if files:
console.print("Флаги меню нельзя использовать вместе с файлами.", style="red bold")
console.print(
"Флаги меню нельзя использовать вместе с файлами.", style="red bold"
)
raise SystemExit(2)
if sys.platform != "win32":
console.print("Пункт меню SendTo доступен только на Windows.", style="red bold")
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")
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")
console.print(f'Пункт меню удалён: "{cmd_path}"', style="green")
except RuntimeError as exc:
console.print(f"Ошибка: {exc}", style="red bold")
raise SystemExit(1)
@@ -203,7 +301,12 @@ def main(
try:
config = load_config()
cli_values = {"model": model, "language": language, "device": device, "compute_type": compute_type}
cli_values = {
"model": model,
"language": language,
"device": device,
"compute_type": compute_type,
}
defaults = resolve_defaults(cli_values, config)
resolved_device = detect_device(defaults["device"])
@@ -218,13 +321,33 @@ def main(
is_batch = len(expanded) > 1
if is_batch and output is not None:
console.print("--output несовместим с несколькими файлами.", style="red bold")
console.print(
"--output несовместим с несколькими файлами.", style="red bold"
)
raise SystemExit(1)
if is_batch:
_run_batch(expanded, defaults, verbose, force, ct_explicit, cpu_threads=threads)
_run_batch(
expanded,
defaults,
verbose,
force,
ct_explicit,
cpu_threads=threads,
diarize=diarize or speakers is not None,
speakers=speakers,
)
else:
_run_single(expanded[0], defaults, output, verbose, ct_explicit, cpu_threads=threads)
_run_single(
expanded[0],
defaults,
output,
verbose,
ct_explicit,
cpu_threads=threads,
diarize=diarize or speakers is not None,
speakers=speakers,
)
except KeyboardInterrupt:
console.print("\nПрервано пользователем.", style="yellow")
raise SystemExit(130)
@@ -250,9 +373,7 @@ def main(
console.print_exception()
else:
console.print(f"Ошибка: {exc}", style="red bold")
console.print(
"Запустите с --verbose для полного traceback.", style="dim"
)
console.print("Запустите с --verbose для полного traceback.", style="dim")
raise SystemExit(1)
@@ -263,6 +384,8 @@ def _run_single(
verbose: bool,
compute_type_explicit: bool = False,
cpu_threads: int = 0,
diarize: bool = False,
speakers: int | None = None,
) -> None:
"""Пайплайн одного файла: валидация → модель → транскрипция → запись."""
start = time.monotonic()
@@ -279,12 +402,18 @@ def _run_single(
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,
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"]
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] "
@@ -296,6 +425,18 @@ def _run_single(
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,
@@ -313,6 +454,31 @@ def _run_single(
)
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":
@@ -328,9 +494,10 @@ def _run_single(
)
if len(result.segments) == 0:
console.print(
f"Речь не обнаружена в файле {validated_file.name}", style="yellow"
)
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)
@@ -341,13 +508,17 @@ def _run_single(
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'Транскрипт сохранён: "{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(
@@ -357,6 +528,8 @@ def _run_batch(
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 сек)
@@ -379,9 +552,7 @@ def _run_batch(
to_process.append(validated)
if not to_process:
console.print(
f"\nИтого: 0 обработано, {skipped} пропущено, {invalid} ошибок"
)
console.print(f"\nИтого: 0 обработано, {skipped} пропущено, {invalid} ошибок")
if invalid > 0:
raise SystemExit(1)
return
@@ -391,8 +562,11 @@ def _run_batch(
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,
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,
)
@@ -416,8 +590,21 @@ def _run_batch(
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()
@@ -439,9 +626,13 @@ def _run_batch(
file_path=file,
model_name=defaults["model"],
compute_type=defaults["compute_type"],
language=defaults["language"] if defaults["language"] != "auto" else None,
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),
on_status=status.update
if not verbose
else lambda msg: console.print(msg),
strict_device=strict,
cpu_threads=cpu_threads,
)
@@ -459,11 +650,44 @@ def _run_batch(
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:
console.print(
f" Речь не обнаружена: {file.name}", style="yellow"
)
message = f" Речь не обнаружена: {file.name}"
if speaker_diarizer is not None:
message += "; диаризация не запускалась"
console.print(message, style="yellow")
device_info = _format_device_info(result.device_used)
@@ -473,6 +697,8 @@ def _run_batch(
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
@@ -482,6 +708,8 @@ def _run_batch(
style="green",
)
processed += 1
if file_degraded:
degraded += 1
_print_quality_warnings(result, file.name)
except KeyboardInterrupt:
raise
@@ -495,10 +723,11 @@ def _run_batch(
total_failed = invalid + failed
batch_elapsed = time.monotonic() - batch_start
console.print(
f"\nИтого: {processed} обработано, {skipped} пропущено, {total_failed} ошибок"
f"\nИтого: {processed} обработано, {skipped} пропущено, "
f"{degraded} с деградацией, {total_failed} ошибок"
f" Время: {batch_elapsed:.1f}с"
)
if total_failed > 0:
if total_failed > 0 or degraded > 0:
raise SystemExit(1)