From 726718197f93306348118d7f6f6227c7ef428624 Mon Sep 17 00:00:00 2001 From: Dmitriy Dementiev Date: Fri, 14 Aug 2026 18:55:42 +0300 Subject: [PATCH] =?UTF-8?q?feat(diarization):=20=D0=B4=D0=BE=D0=B1=D0=B0?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=BE=20=D1=80=D0=B0=D0=B7=D0=B4=D0=B5?= =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D1=82=D1=80=D0=B0=D0=BD=D1=81?= =?UTF-8?q?=D0=BA=D1=80=D0=B8=D0=BF=D1=82=D0=B0=20=D0=BF=D0=BE=20=D0=B3?= =?UTF-8?q?=D0=BE=D0=B2=D0=BE=D1=80=D1=8F=D1=89=D0=B8=D0=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Зачем: - локальным транскриптам нужна структура реплик для конспектов и протоколов. Что: - добавлены пословные таймкоды для всех ASR-бэкендов и сведение с Sherpa-ONNX. - реализованы CLI-флаги, деградация без потери ASR и speaker Markdown. - добавлены проверяемый кеш моделей, тесты и документация. Проверка: - `pytest` — 283 passed, 1 skipped. - `pyright` — 0 errors. - Ruff и `git diff --check` — без ошибок. - выполнены три контрольных прогона на реальных записях. --- README.md | 44 ++ ...26-08-14-speaker-diarization-acceptance.md | 48 ++ pyproject.toml | 2 + src/local_transcriber/backends/base.py | 5 + .../backends/faster_whisper.py | 44 +- src/local_transcriber/backends/onnx_asr.py | 74 +- src/local_transcriber/backends/openvino.py | 63 +- src/local_transcriber/cli.py | 323 +++++++-- src/local_transcriber/diarization.py | 131 ++++ src/local_transcriber/formatter.py | 40 +- src/local_transcriber/speaker_diarizer.py | 222 ++++++ src/local_transcriber/transcriber.py | 60 +- src/local_transcriber/types.py | 61 +- tests/test_backend_faster_whisper.py | 66 ++ tests/test_backend_openvino.py | 160 ++++- tests/test_cli.py | 644 ++++++++++++++++-- tests/test_context_menu.py | 2 +- tests/test_diarization.py | 120 ++++ tests/test_formatter.py | 120 +++- tests/test_onnx_asr.py | 175 ++++- tests/test_speaker_diarizer.py | 231 +++++++ tests/test_transcriber.py | 44 +- uv.lock | 30 + 23 files changed, 2522 insertions(+), 187 deletions(-) create mode 100644 docs/benchmarks/2026-08-14-speaker-diarization-acceptance.md create mode 100644 src/local_transcriber/diarization.py create mode 100644 src/local_transcriber/speaker_diarizer.py create mode 100644 tests/test_backend_faster_whisper.py create mode 100644 tests/test_diarization.py create mode 100644 tests/test_speaker_diarizer.py diff --git a/README.md b/README.md index c658be1..a8c997c 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,7 @@ transcribe meeting.mp4 - **Полностью локально** — данные не покидают машину - **Авто-ускорение** — NVIDIA CUDA при наличии GPU, иначе ONNX на CPU - **Батч-режим** — обработка нескольких файлов за один вызов +- **Разделение говорящих** — локальная диаризация по флагу `--diarize` - **Из проводника Windows** — пункт Transcribe в меню «Отправить» ([установка](#контекстное-меню-проводника-windows)) - **Markdown с таймкодами** — удобен для суммаризации ИИ - **Аудио и видео** — mp3, wav, mp4, mkv и [другие форматы](#поддерживаемые-форматы) @@ -135,8 +136,36 @@ transcribe meeting.wav --device onnx --model gigaam-multilingual-large-ctc # Сохранить в конкретный файл transcribe interview.m4a --output result.md + +# Разделить встречу на реплики говорящих +transcribe meeting.mp4 --diarize + +# Если число участников известно заранее +transcribe interview.m4a --speakers 2 ``` +### Разделение говорящих + +`--diarize` добавляет к транскрипту реплики `Speaker 1`, `Speaker 2` и так +далее. `--speakers N` задаёт ожидаемое число участников и автоматически включает +диаризацию; без него число кластеров определяется автоматически. + +При первом таком запуске дополнительно скачиваются две ONNX-модели Sherpa-ONNX: +сегментация (~6 МБ) и голосовые эмбеддинги (~27 МБ). Они сохраняются в кеше +Hugging Face и используются повторно. Диаризация выполняется после распознавания +речи и добавляет отдельный проход по записи. На измеренном слабом Intel Core +i7-6820HQ последовательные ASR и диаризация увеличивали полное время примерно в +2,4 раза, но оставались быстрее реального времени; фактическая скорость зависит +от процессора и режима питания ([замеры](docs/benchmarks/2026-08-14-diarization-intel-i7.md)). + +Если найдено меньше двух говорящих или диаризация конкретного файла завершилась +ошибкой, текст не теряется: сохраняется обычный транскрипт, в Markdown +записывается причина, а команда завершается с кодом `1`. Если выбранный ASR-путь +не поддерживает пословные таймкоды или диаризатор не удалось инициализировать, +запуск останавливается до первого ASR и не создаёт частичных транскриптов. Малый +кластер только отмечается предупреждением и не удаляется. Слова без однозначного +говорящего попадают в реплику `Speaker ?`. + ### Батч-режим Обработка нескольких файлов за один вызов — модель загружается один раз: @@ -155,6 +184,8 @@ transcribe *.mp4 --force - Файлы с существующим транскриптом (`*-transcript.md`) автоматически пропускаются - `--force` / `-f` — перезаписать существующие транскрипты - При ошибке в одном файле остальные продолжают обрабатываться +- При ошибке диаризации сохраняется обычный транскрипт, остальные файлы + продолжают обрабатываться; итоговый код батча — `1` - `--output` несовместим с несколькими файлами ### Контекстное меню проводника (Windows) @@ -192,6 +223,8 @@ transcribe --uninstall-menu | `--device` | `-d` | `auto` | Устройство (auto, cpu, cuda, openvino, openvino-gpu, openvino-cpu, onnx) | | `--compute-type` | — | float16 (CUDA) / int8 (ONNX/OpenVINO) / float32 (CPU) | Тип вычислений | | `--threads` | `-t` | 0 (авто) | Потоки CPU (рекомендуется = число физ. ядер) | +| `--diarize` | — | — | Разделить текст на реплики говорящих | +| `--speakers` | — | авто | Ожидаемое число говорящих; включает `--diarize` | | `--force` | `-f` | — | Перезаписать существующие транскрипты | | `--verbose` | `-v` | — | Подробный вывод | @@ -404,6 +437,17 @@ device-aware дефолт недоступен для выбранной мод Если язык определить не удалось, строка выглядит так: `- **Язык**: не определён`. +С `--diarize` при успешном обнаружении нескольких говорящих основная часть +выглядит так: + +```markdown +[00:00] Speaker 1: Добрый день, коллеги. + +[00:04] Speaker 2: Начнём с результатов квартала. +``` + +Таймкод реплики показывает начало: `MM:SS`, а после часа — `HH:MM:SS`. + ## Поддерживаемые форматы diff --git a/docs/benchmarks/2026-08-14-speaker-diarization-acceptance.md b/docs/benchmarks/2026-08-14-speaker-diarization-acceptance.md new file mode 100644 index 0000000..e0992e4 --- /dev/null +++ b/docs/benchmarks/2026-08-14-speaker-diarization-acceptance.md @@ -0,0 +1,48 @@ +# Приёмка speaker diarization в CLI + +**Дата:** 2026-08-14 + +**Статус:** ручная приёмка реализации задачи #24 на трёх контрольных записях. + +## Профиль запуска + +- ASR: `onnx`, `gigaam-v3-e2e-rnnt`, русский язык; +- диаризация: Pyannote segmentation 3.0 и WeSpeaker ResNet34 LM; +- автоматическое число говорящих, порог кластеризации `0,89`; +- 8 потоков CPU, модели в локальном кеше; +- обычный ASR и запуск с `--diarize` выполнялись последовательно. + +## Результаты + +| Запись | Длительность | Обычный ASR | С диаризацией | Кластеры | Неназначенные слова | +|---|---:|---:|---:|---:|---:| +| Data Test | 26:00 | 137,5 с | 326,2 с | 4 (один малый, 19,1 с) | 201 | +| T2 BDMA | 14:51 | 83,0 с | 184,2 с | 2 | 65 | +| Yantar | 20:22 | 100,1 с | 259,7 с | 2 | 43 | + +Все три запуска завершились быстрее реального времени. Четвёртый прогон T2 +BDMA после исправления склейки пунктуации повторно подтвердил два кластера и +65 неназначенных слов. + +## Инварианты + +- после удаления только форматных пробелов перед Unicode-пунктуацией и + символами текст speaker-вывода на всех трёх записях в точности совпал с + обычным ASR: 21 788, 10 520 и 12 055 символов соответственно; +- повторный T2-прогон последней версии также дал точное совпадение 10 520 из + 10 520 символов; +- слова не потерялись и не поменяли порядок; неизвестный говорящий остаётся в + выводе как `Speaker ?`; +- CLI показал число кластеров, неназначенные слова и малый остаточный кластер, + не скрывая диагностические данные. + +## Память и стоимость + +Во время финального T2-прогона рабочий процесс наблюдался на уровне 905 МБ RSS. +Это согласуется с отдельным замером последовательных проходов: 900–1035 МБ для +ASR и 366–469 МБ для диаризации. Подробные условия и ограничения приведены в +[отчёте Intel i7](2026-08-14-diarization-intel-i7.md). + +Диаризация остаётся опциональной: на контрольных записях полное время выросло +примерно в 2,2–2,6 раза. При этом сбой диаризации не удаляет готовый ASR-текст и +не останавливает обработку остальных файлов batch-запуска. diff --git a/pyproject.toml b/pyproject.toml index 181b3a3..716cdb9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,9 @@ dependencies = [ "typer>=0.24.1,<1", "rich>=14.3.3,<15", "faster-whisper>=1.2.1,<2", + "httpx>=0.28,<1", "socksio>=1.0.0,<2", + "sherpa-onnx>=1.13.5,<2", "nvidia-cublas-cu12>=12.4,<13; sys_platform == 'linux' and platform_machine == 'x86_64'", "openvino-genai>=2026.3.0.0,<2026.4; sys_platform != 'darwin' and (platform_machine == 'x86_64' or platform_machine == 'AMD64')", "onnx-asr[cpu,hub]>=0.12,<0.13", diff --git a/src/local_transcriber/backends/base.py b/src/local_transcriber/backends/base.py index 63f7fa4..86036b8 100644 --- a/src/local_transcriber/backends/base.py +++ b/src/local_transcriber/backends/base.py @@ -16,6 +16,11 @@ class Backend(Protocol): наследование не требуется. """ + @property + def word_timestamps_available(self) -> bool: + """Гарантирует ли выбранный backend/model пословные таймкоды.""" + ... + def ensure_model_available( self, model_name: str, diff --git a/src/local_transcriber/backends/faster_whisper.py b/src/local_transcriber/backends/faster_whisper.py index f44fa24..35ff2d1 100644 --- a/src/local_transcriber/backends/faster_whisper.py +++ b/src/local_transcriber/backends/faster_whisper.py @@ -2,9 +2,6 @@ from __future__ import annotations -import gc -import io -import warnings from collections.abc import Callable from pathlib import Path from typing import Any @@ -18,7 +15,12 @@ from faster_whisper import WhisperModel # noqa: E402 from huggingface_hub import snapshot_download # noqa: E402 from huggingface_hub.errors import LocalEntryNotFoundError # noqa: E402 -from local_transcriber.types import Segment, TranscribeResult # noqa: E402 +from local_transcriber.types import ( # noqa: E402 + Segment, + TranscribeResult, + Word, + WordTimestampsUnavailableError, +) MODEL_REPOS = { "tiny": "Systran/faster-whisper-tiny", @@ -46,6 +48,8 @@ MODEL_REQUIRED_FILES = [ class FasterWhisperBackend: """Бэкенд транскрипции через faster-whisper (CTranslate2).""" + word_timestamps_available = True + def __init__(self): self.actual_compute_type: str | None = None @@ -92,7 +96,9 @@ class FasterWhisperBackend: """ try: return WhisperModel( - model_path, device=device, compute_type=compute_type, + model_path, + device=device, + compute_type=compute_type, cpu_threads=cpu_threads, ) except ImportError as exc: @@ -114,12 +120,25 @@ class FasterWhisperBackend: ) -> TranscribeResult: """Транскрибирует файл через faster-whisper.""" segment_generator, info = model.transcribe( - str(file_path), language=language, + str(file_path), + language=language, + word_timestamps=True, ) total_duration = info.duration segments: list[Segment] = [] + words: list[Word] = [] for raw_seg in segment_generator: seg = Segment(start=raw_seg.start, end=raw_seg.end, text=raw_seg.text) + raw_words = raw_seg.words or [] + if seg.text.strip() and not raw_words: + raise WordTimestampsUnavailableError( + "FasterWhisper не вернул пословные таймкоды " + "для распознанного сегмента" + ) + words.extend( + Word(start=raw_word.start, end=raw_word.end, text=raw_word.word) + for raw_word in raw_words + ) if on_segment is not None: on_segment(seg) segments.append(seg) @@ -135,6 +154,7 @@ class FasterWhisperBackend: language_probability=info.language_probability, duration=info.duration, device_used="", # оркестратор проставит actual_device + words=words, ) @@ -155,7 +175,9 @@ def _resolve_model_repo(model_name: str) -> str: repo_id = MODEL_REPOS.get(model_name) if repo_id is None: expected = ", ".join(MODEL_REPOS) - raise ValueError(f"Неподдерживаемая модель '{model_name}'. Ожидалось одно из: {expected}") + raise ValueError( + f"Неподдерживаемая модель '{model_name}'. Ожидалось одно из: {expected}" + ) return repo_id @@ -178,13 +200,17 @@ def _snapshot_download(repo_id: str, local_files_only: bool) -> str: def _validate_model_dir(model_dir: Path) -> None: missing = [ - filename for filename in MODEL_REQUIRED_FILES if not (model_dir / filename).exists() + 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}") + raise ValueError( + f"Неполная локальная модель в '{model_dir}': отсутствуют {missing_str}" + ) def _is_missing_socksio_error(exc: BaseException) -> bool: diff --git a/src/local_transcriber/backends/onnx_asr.py b/src/local_transcriber/backends/onnx_asr.py index c73212b..498bfb4 100644 --- a/src/local_transcriber/backends/onnx_asr.py +++ b/src/local_transcriber/backends/onnx_asr.py @@ -8,7 +8,13 @@ from dataclasses import dataclass from pathlib import Path from typing import Any -from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult +from local_transcriber.types import ( + UNKNOWN_LANGUAGE, + Segment, + TranscribeResult, + Word, + WordTimestampsUnavailableError, +) @dataclass(frozen=True) @@ -60,9 +66,7 @@ _WHISPER_MODEL_NAMES = frozenset( _OPENVINO_ONLY_WHISPER_MODELS = frozenset({"large-v3-turbo"}) MODEL_CATALOG: dict[str, OnnxModelSpec] = { - "gigaam-v3": OnnxModelSpec( - "gigaam-v3-ctc", _INT8_AND_FLOAT32, _RUSSIAN_ONLY - ), + "gigaam-v3": OnnxModelSpec("gigaam-v3-ctc", _INT8_AND_FLOAT32, _RUSSIAN_ONLY), "parakeet-v3": OnnxModelSpec( "nemo-parakeet-tdt-0.6b-v3", _INT8_AND_FLOAT32, @@ -135,6 +139,11 @@ class OnnxAsrBackend: self._model_spec: OnnxModelSpec | None = None self._vad: Any = None + @property + def word_timestamps_available(self) -> bool: + """Каталожные модели проверены; произвольный raw id отклоняется.""" + return self._model_spec is not None + def ensure_model_available( self, model_name: str, @@ -194,7 +203,7 @@ class OnnxAsrBackend: ) vad = onnx_asr.load_vad("silero") self._vad = vad - return model.with_vad(vad) + return model.with_vad(vad).with_timestamps() def transcribe( self, @@ -215,10 +224,13 @@ class OnnxAsrBackend: self._warn_if_language_unsupported(language) _notify(on_status, "Загружаю аудио...") audio_array = decode_audio(str(file_path), sampling_rate=16000) + if isinstance(audio_array, tuple): + raise TypeError("Декодер неожиданно вернул раздельные стереоканалы") duration = len(audio_array) / 16000.0 _notify(on_status, "Транскрибирую (onnx-asr)...") segments: list[Segment] = [] + words: list[Word] = [] result_language = ( language or _model_language(self._model_spec) or UNKNOWN_LANGUAGE ) @@ -235,6 +247,12 @@ class OnnxAsrBackend: end=end, text=vad_seg.text, ) + segment_words = _timestamped_segment_words(vad_seg, start, end) + if vad_seg.text.strip() and not segment_words: + raise WordTimestampsUnavailableError( + "ONNX-ASR не вернул пословные таймкоды для распознанного текста" + ) + words.extend(segment_words) if on_segment is not None: on_segment(seg) segments.append(seg) @@ -249,6 +267,7 @@ class OnnxAsrBackend: language_probability=1.0 if language else 0.0, duration=duration, device_used="", # оркестратор проставит + words=words, ) def _resolve_model(self, model_name: str) -> str: @@ -326,3 +345,48 @@ def _model_language(spec: OnnxModelSpec | None) -> str | None: def _notify(on_status: Callable[[str], None] | None, message: str) -> None: if on_status is not None: on_status(message) + + +def _timestamped_segment_words( + vad_segment: Any, + segment_start: float, + segment_end: float, +) -> list[Word]: + tokens = getattr(vad_segment, "tokens", None) + timestamps = getattr(vad_segment, "timestamps", None) + if not tokens or not timestamps or len(tokens) != len(timestamps): + return [] + + grouped: list[tuple[float, str]] = [] + current_start = float(timestamps[0]) + current_tokens: list[str] = [] + for token, timestamp in zip(tokens, timestamps, strict=True): + if token[:1].isspace() and current_tokens: + grouped.append((current_start, "".join(current_tokens))) + current_start = float(timestamp) + current_tokens = [] + current_tokens.append(token) + grouped.append((current_start, "".join(current_tokens))) + + words: list[Word] = [] + for index, (relative_start, text) in enumerate(grouped): + start = min( + segment_end, + max(segment_start, segment_start + relative_start), + ) + next_start = next( + ( + candidate_start + for candidate_start, _ in grouped[index + 1 :] + if candidate_start > relative_start + ), + None, + ) + end = max( + start, + min(segment_end, segment_start + next_start) + if next_start is not None + else segment_end, + ) + words.append(Word(start=start, end=end, text=text)) + return words diff --git a/src/local_transcriber/backends/openvino.py b/src/local_transcriber/backends/openvino.py index 2fc618a..e6ce106 100644 --- a/src/local_transcriber/backends/openvino.py +++ b/src/local_transcriber/backends/openvino.py @@ -2,9 +2,9 @@ from __future__ import annotations +import json import threading import time -import warnings from collections.abc import Callable from pathlib import Path from typing import Any @@ -12,7 +12,13 @@ from typing import Any from huggingface_hub import snapshot_download from huggingface_hub.errors import LocalEntryNotFoundError -from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult +from local_transcriber.types import ( + UNKNOWN_LANGUAGE, + Segment, + TranscribeResult, + Word, + WordTimestampsUnavailableError, +) # (model_alias, compute_type) → HF repo MODEL_REPOS: dict[tuple[str, str], str] = { @@ -43,12 +49,15 @@ _IMPLICIT_COMPUTE_TYPE_OVERRIDES: dict[str, str] = { MODEL_REQUIRED_FILES = [ "openvino_encoder_model.xml", "openvino_decoder_model.xml", + "generation_config.json", ] class OpenVINOBackend: """Бэкенд транскрипции через openvino-genai WhisperPipeline.""" + word_timestamps_available = True + def __init__( self, ov_device: str = "openvino-cpu", @@ -82,7 +91,9 @@ class OpenVINOBackend: except ValueError: _notify(on_status, f"Кэш модели {model_name} неполный, докачиваю...") - _notify(on_status, f"Скачиваю модель {model_name} (OpenVINO) из Hugging Face...") + _notify( + on_status, f"Скачиваю модель {model_name} (OpenVINO) из Hugging Face..." + ) downloaded_path = Path(snapshot_download(repo_id, local_files_only=False)) _validate_model_dir(downloaded_path) return str(downloaded_path) @@ -115,7 +126,11 @@ class OpenVINOBackend: ov_dev = self._resolve_ov_device() self.actual_ov_device = ov_dev - return ov_genai.WhisperPipeline(model_path, ov_dev) + return ov_genai.WhisperPipeline( + model_path, + ov_dev, + word_timestamps=True, + ) def transcribe( self, @@ -130,16 +145,23 @@ class OpenVINOBackend: _notify(on_status, "Загружаю аудио...") raw_speech = decode_audio(str(file_path), sampling_rate=16000) + if isinstance(raw_speech, tuple): + raise TypeError("Декодер неожиданно вернул раздельные стереоканалы") duration = len(raw_speech) / 16000.0 - kwargs: dict[str, Any] = {"return_timestamps": True} + kwargs: dict[str, Any] = { + "return_timestamps": True, + "word_timestamps": True, + } if language: kwargs["language"] = f"<|{language}|>" dur_min = int(duration // 60) duration_str = f"{dur_min} мин" if dur_min > 0 else f"{int(duration)} сек" pcm_list = raw_speech.tolist() - result = _generate_with_progress(model, pcm_list, kwargs, duration_str, on_status) + result = _generate_with_progress( + model, pcm_list, kwargs, duration_str, on_status + ) segments: list[Segment] = [] if hasattr(result, "chunks") and result.chunks: @@ -159,6 +181,16 @@ class OpenVINOBackend: f"Транскрибирую (OpenVINO)... [{len(segments)} сегм.]", ) + words = [] + for raw_word in getattr(result, "words", None) or []: + start = min(duration, max(0.0, raw_word.start_ts)) + end = min(duration, max(start, raw_word.end_ts)) + words.append(Word(start=start, end=end, text=raw_word.word)) + if any(segment.text.strip() for segment in segments) and not words: + raise WordTimestampsUnavailableError( + "OpenVINO не вернул пословные таймкоды для распознанного текста" + ) + detected_language = language or UNKNOWN_LANGUAGE language_probability = 1.0 if language else 0.0 @@ -168,6 +200,7 @@ class OpenVINOBackend: language_probability=language_probability, duration=duration, device_used="", # оркестратор проставит + words=words, ) def _resolve_repo(self, model_name: str, compute_type: str) -> tuple[str, str]: @@ -176,7 +209,10 @@ class OpenVINOBackend: Возвращает (repo_id, actual_compute_type). """ # Для неявного compute_type: override для конкретных моделей - if not self._compute_type_explicit and model_name in _IMPLICIT_COMPUTE_TYPE_OVERRIDES: + if ( + not self._compute_type_explicit + and model_name in _IMPLICIT_COMPUTE_TYPE_OVERRIDES + ): compute_type = _IMPLICIT_COMPUTE_TYPE_OVERRIDES[model_name] # Точное совпадение @@ -231,7 +267,10 @@ def _generate_with_progress( while thread.is_alive(): elapsed = int(time.monotonic() - start) elapsed_str = f"{elapsed // 60:02d}:{elapsed % 60:02d}" - _notify(on_status, f"Транскрибирую {duration_str} аудио (OpenVINO)... прошло {elapsed_str}") + _notify( + on_status, + f"Транскрибирую {duration_str} аудио (OpenVINO)... прошло {elapsed_str}", + ) thread.join(timeout=1.0) if error_box[0] is not None: @@ -251,3 +290,11 @@ def _validate_model_dir(model_dir: Path) -> None: raise ValueError( f"Неполная OpenVINO модель в '{model_dir}': отсутствуют {', '.join(missing)}" ) + generation_config = json.loads( + (model_dir / "generation_config.json").read_text(encoding="utf-8") + ) + if not generation_config.get("alignment_heads"): + raise ValueError( + f"OpenVINO модель в '{model_dir}' не содержит alignment_heads " + "для пословных таймкодов" + ) diff --git a/src/local_transcriber/cli.py b/src/local_transcriber/cli.py index 7698908..9305976 100644 --- a/src/local_transcriber/cli.py +++ b/src/local_transcriber/cli.py @@ -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) diff --git a/src/local_transcriber/diarization.py b/src/local_transcriber/diarization.py new file mode 100644 index 0000000..bd8c0ec --- /dev/null +++ b/src/local_transcriber/diarization.py @@ -0,0 +1,131 @@ +"""Сведение слов с временной привязкой и разметки говорящих.""" + +from collections import defaultdict +from math import isclose +from unicodedata import category + +from .types import ( + SmallSpeakerCluster, + SpeakerInterval, + SpeakerTranscript, + SpeakerTurn, + Word, +) + +_PAUSE_THRESHOLD_S = 2.0 +_MAX_TURN_S = 60.0 + + +def build_speaker_transcript( + words: list[Word], + intervals: list[SpeakerInterval], + recording_duration: float, +) -> SpeakerTranscript: + """Назначает словам говорящих и собирает линейные реплики.""" + cluster_numbers: dict[int, int] = {} + assigned: list[tuple[Word, int | None]] = [] + unassigned = 0 + + for word in words: + speaker_cluster = _assign_cluster(word, intervals) + if speaker_cluster is None: + speaker = None + unassigned += 1 + else: + speaker = cluster_numbers.setdefault( + speaker_cluster, + len(cluster_numbers) + 1, + ) + assigned.append((word, speaker)) + + return SpeakerTranscript( + turns=_group_words(assigned), + cluster_count=len({interval.cluster for interval in intervals}), + unassigned_word_count=unassigned, + small_clusters=_find_small_clusters( + intervals, + cluster_numbers, + recording_duration, + ), + ) + + +def _assign_cluster(word: Word, intervals: list[SpeakerInterval]) -> int | None: + overlaps: defaultdict[int, float] = defaultdict(float) + for interval in intervals: + overlap = min(word.end, interval.end) - max(word.start, interval.start) + if overlap > 0: + overlaps[interval.cluster] += overlap + + if not overlaps: + return None + largest = max(overlaps.values()) + winners = [ + cluster + for cluster, overlap in overlaps.items() + if isclose(overlap, largest, rel_tol=1e-9, abs_tol=1e-9) + ] + return winners[0] if len(winners) == 1 else None + + +def _group_words(assigned: list[tuple[Word, int | None]]) -> list[SpeakerTurn]: + if not assigned: + return [] + + turns: list[SpeakerTurn] = [] + first_word, current_speaker = assigned[0] + start = first_word.start + end = first_word.end + text = first_word.text + + for word, speaker in assigned[1:]: + should_split = ( + speaker != current_speaker + or word.start - end >= _PAUSE_THRESHOLD_S + or word.end - start > _MAX_TURN_S + ) + if should_split: + turns.append( + SpeakerTurn(start, end, _normalize_turn_text(text), current_speaker) + ) + start = word.start + text = word.text + current_speaker = speaker + else: + text = _append_word_text(text, word.text) + end = word.end + + turns.append(SpeakerTurn(start, end, _normalize_turn_text(text), current_speaker)) + return turns + + +def _append_word_text(current: str, word_text: str) -> str: + if not current or not word_text or word_text[:1].isspace(): + return current + word_text + if category(word_text[0])[:1] in {"P", "S"}: + return current + word_text + return f"{current} {word_text}" + + +def _normalize_turn_text(text: str) -> str: + return " ".join(text.split()) + + +def _find_small_clusters( + intervals: list[SpeakerInterval], + cluster_numbers: dict[int, int], + recording_duration: float, +) -> list[SmallSpeakerCluster]: + durations: defaultdict[int, float] = defaultdict(float) + for interval in intervals: + durations[interval.cluster] += max(0.0, interval.end - interval.start) + + threshold = max(5.0, recording_duration * 0.02) + return [ + SmallSpeakerCluster( + speaker=cluster_numbers.get(cluster), + duration=duration, + ) + for cluster, duration in durations.items() + if duration < threshold + ] diff --git a/src/local_transcriber/formatter.py b/src/local_transcriber/formatter.py index dc4a2e3..2b8ad2b 100644 --- a/src/local_transcriber/formatter.py +++ b/src/local_transcriber/formatter.py @@ -5,7 +5,7 @@ from datetime import datetime from pathlib import Path from .quality import TAIL_GAP_WARN_S, find_repetition_blocks, tail_gap -from .types import Segment, TranscribeResult +from .types import Segment, SpeakerTranscript, TranscribeResult _PAUSE_THRESHOLD_S = 2.0 # пауза между сегментами для разбиения на абзацы _MAX_PARAGRAPH_S = 60.0 # максимальная длительность абзаца @@ -88,6 +88,18 @@ def format_duration(seconds: float) -> str: return f"{m:02d}:{s:02d}" +def _format_speaker_timestamp(seconds: float, use_hours: bool) -> str: + total_seconds = int(seconds) + if use_hours: + hours = total_seconds // 3600 + minutes = (total_seconds % 3600) // 60 + secs = total_seconds % 60 + return f"{hours:02d}:{minutes:02d}:{secs:02d}" + minutes = total_seconds // 60 + secs = total_seconds % 60 + return f"{minutes:02d}:{secs:02d}" + + def format_transcript( result: TranscribeResult, source_filename: str, @@ -95,6 +107,8 @@ def format_transcript( device_info: str, language_mode: str, # см. LANGUAGE_MODES transcription_date: datetime | None = None, # None -> datetime.now() + speaker_transcript: SpeakerTranscript | None = None, + diarization_warning: str | None = None, ) -> str: """Собирает markdown-транскрипт: шапка с метаданными + абзацы с таймкодами.""" date = transcription_date or datetime.now() @@ -123,6 +137,24 @@ def format_transcript( f"- **Внимание**: повторы в [{start} - {end}] ({block.count}×) " "— возможны галлюцинации модели" ) + if speaker_transcript is not None: + lines.append(f"- **Голосовых кластеров**: {speaker_transcript.cluster_count}") + if speaker_transcript.unassigned_word_count: + lines.append( + "- **Внимание**: " + f"{speaker_transcript.unassigned_word_count} слов без назначенного говорящего" + ) + for cluster in speaker_transcript.small_clusters: + label = ( + f"Speaker {cluster.speaker}" + if cluster.speaker is not None + else "кластер без номера" + ) + lines.append( + f"- **Внимание**: малый кластер {label}: {cluster.duration:.1f} с" + ) + if diarization_warning is not None: + lines.append(f"- **Внимание**: {diarization_warning}") lines.append(f"- **Устройство**: {device_info}") lines.append("") lines.append("---") @@ -130,6 +162,12 @@ def format_transcript( if not result.segments: lines.append("") lines.append("*Речь не обнаружена.*") + elif speaker_transcript is not None and speaker_transcript.cluster_count >= 2: + for turn in speaker_transcript.turns: + timestamp = _format_speaker_timestamp(turn.start, use_hours) + speaker = turn.speaker if turn.speaker is not None else "?" + lines.append("") + lines.append(f"[{timestamp}] Speaker {speaker}: {turn.text}") else: for para in _group_segments(result.segments): start = format_timestamp(para.start, use_hours=use_hours) diff --git a/src/local_transcriber/speaker_diarizer.py b/src/local_transcriber/speaker_diarizer.py new file mode 100644 index 0000000..d83552c --- /dev/null +++ b/src/local_transcriber/speaker_diarizer.py @@ -0,0 +1,222 @@ +"""Адаптер офлайн-диаризации через sherpa-onnx.""" + +import shutil +import tarfile +from hashlib import sha256 +from pathlib import Path +from tempfile import NamedTemporaryFile +from time import perf_counter +from typing import Any + +from .types import DiarizationRun, SpeakerInterval, StatusCallback + +_SAMPLE_RATE = 16_000 +_CLUSTERING_THRESHOLD = 0.89 +_SEGMENTATION_FILENAME = "pyannote-segmentation-3.0.onnx" +_EMBEDDING_FILENAME = "wespeaker_en_voxceleb_resnet34_LM.onnx" +_SEGMENTATION_SHA256 = ( + "220ad67ca923bef2fa91f2390c786097bf305bceb5e261d4af67b38e938e1079" +) +_EMBEDDING_SHA256 = "e9848563da86f263117134dfd7ad63c92355b37de492b55e325400c9d9c39012" +_SEGMENTATION_URL = ( + "https://github.com/k2-fsa/sherpa-onnx/releases/download/" + "speaker-segmentation-models/" + "sherpa-onnx-pyannote-segmentation-3-0.tar.bz2" +) +_SEGMENTATION_ARCHIVE_MEMBER = "sherpa-onnx-pyannote-segmentation-3-0/model.onnx" +_EMBEDDING_URL = ( + "https://github.com/k2-fsa/sherpa-onnx/releases/download/" + "speaker-recongition-models/wespeaker_en_voxceleb_resnet34_LM.onnx" +) + + +class SpeakerDiarizer: + """Переиспользуемый в пределах команды диаризатор.""" + + def __init__(self, engine: Any): + self._engine = engine + + def process( + self, + file_path: Path, + on_status: StatusCallback = None, + ) -> DiarizationRun: + """Строит разметку говорящих для одного файла.""" + from faster_whisper import decode_audio + + if on_status is not None: + on_status("Загружаю аудио для диаризации...") + samples = decode_audio(str(file_path), sampling_rate=_SAMPLE_RATE) + if isinstance(samples, tuple): + raise TypeError("Декодер неожиданно вернул раздельные стереоканалы") + if on_status is not None: + on_status("Определяю говорящих...") + + started = perf_counter() + if on_status is None: + result = self._engine.process(samples) + else: + + def report_progress(processed: int, total: int) -> int: + on_status(f"Определяю говорящих... {processed} / {total}") + return 0 + + result = self._engine.process(samples, report_progress) + elapsed = perf_counter() - started + intervals = [ + SpeakerInterval( + start=float(segment.start), + end=float(segment.end), + cluster=int(segment.speaker), + ) + for segment in result.sort_by_start_time() + ] + return DiarizationRun(intervals=intervals, elapsed_seconds=elapsed) + + +def load_speaker_diarizer( + speakers: int | None, + threads: int = 0, + on_status: StatusCallback = None, +) -> SpeakerDiarizer: + """Проверяет модели и создаёт batch-owned диаризатор.""" + import sherpa_onnx + from huggingface_hub import cached_assets_path + + cache_dir = cached_assets_path( + library_name="local-transcriber", + namespace="diarization", + subfolder="models-v1", + ) + segmentation_path = cache_dir / _SEGMENTATION_FILENAME + embedding_path = cache_dir / _EMBEDDING_FILENAME + _ensure_cached_model( + segmentation_path, + _SEGMENTATION_SHA256, + _SEGMENTATION_URL, + on_status, + archive_member=_SEGMENTATION_ARCHIVE_MEMBER, + ) + _ensure_cached_model( + embedding_path, + _EMBEDDING_SHA256, + _EMBEDDING_URL, + on_status, + ) + + if on_status is not None: + on_status("Инициализирую диаризатор...") + + segmentation_kwargs: dict[str, Any] = { + "pyannote": sherpa_onnx.OfflineSpeakerSegmentationPyannoteModelConfig( + model=str(segmentation_path) + ), + "provider": "cpu", + } + embedding_kwargs: dict[str, Any] = { + "model": str(embedding_path), + "provider": "cpu", + } + if threads > 0: + segmentation_kwargs["num_threads"] = threads + embedding_kwargs["num_threads"] = threads + + config = sherpa_onnx.OfflineSpeakerDiarizationConfig( + segmentation=sherpa_onnx.OfflineSpeakerSegmentationModelConfig( + **segmentation_kwargs + ), + embedding=sherpa_onnx.SpeakerEmbeddingExtractorConfig(**embedding_kwargs), + clustering=sherpa_onnx.FastClusteringConfig( + num_clusters=speakers if speakers is not None else -1, + threshold=_CLUSTERING_THRESHOLD, + ), + min_duration_on=0.3, + min_duration_off=0.5, + ) + if not config.validate(): + raise RuntimeError("Конфигурация диаризатора недействительна") + + engine = sherpa_onnx.OfflineSpeakerDiarization(config) + if engine.sample_rate != _SAMPLE_RATE: + raise RuntimeError( + f"Диаризатор ожидает частоту {engine.sample_rate} Гц вместо {_SAMPLE_RATE} Гц" + ) + return SpeakerDiarizer(engine) + + +def _ensure_cached_model( + path: Path, + expected_sha256: str, + url: str, + on_status: StatusCallback, + archive_member: str | None = None, +) -> None: + if path.is_file() and _file_sha256(path) == expected_sha256: + return + + import httpx + + path.parent.mkdir(parents=True, exist_ok=True) + if on_status is not None: + on_status(f"Скачиваю модель диаризации {path.name}...") + + download_path = _temporary_path(path) + extracted_path: Path | None = None + try: + with ( + httpx.stream("GET", url, follow_redirects=True, timeout=60.0) as response, + download_path.open("wb") as output, + ): + response.raise_for_status() + for chunk in response.iter_bytes(): + output.write(chunk) + + candidate = download_path + if archive_member is not None: + extracted_path = _temporary_path(path) + with tarfile.open(download_path, mode="r:bz2") as archive: + try: + member = archive.getmember(archive_member) + except KeyError as exc: + raise RuntimeError( + f"В архиве модели отсутствует {archive_member}" + ) from exc + if not member.isfile(): + raise RuntimeError( + f"Элемент архива модели не является файлом: {archive_member}" + ) + source = archive.extractfile(member) + if source is None: + raise RuntimeError(f"Не удалось прочитать {archive_member}") + with source, extracted_path.open("wb") as output: + shutil.copyfileobj(source, output) + candidate = extracted_path + + actual_sha256 = _file_sha256(candidate) + if actual_sha256 != expected_sha256: + raise RuntimeError( + f"Контрольная сумма модели {path.name} не совпала: {actual_sha256}" + ) + candidate.replace(path) + finally: + download_path.unlink(missing_ok=True) + if extracted_path is not None: + extracted_path.unlink(missing_ok=True) + + +def _temporary_path(target: Path) -> Path: + with NamedTemporaryFile( + dir=target.parent, + prefix=f".{target.name}.", + suffix=".tmp", + delete=False, + ) as temporary: + return Path(temporary.name) + + +def _file_sha256(path: Path) -> str: + digest = sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() diff --git a/src/local_transcriber/transcriber.py b/src/local_transcriber/transcriber.py index fe90a44..1521835 100644 --- a/src/local_transcriber/transcriber.py +++ b/src/local_transcriber/transcriber.py @@ -12,6 +12,7 @@ from local_transcriber.types import ( # noqa: F401 Segment, TranscribeFileResult, TranscribeResult, + WordTimestampsUnavailableError, ) @@ -37,27 +38,36 @@ def load_model( try: _notify_status(on_status, f"Инициализирую модель на {device}...") - model = backend.create_model(model_path, device, compute_type, cpu_threads=cpu_threads) + model = backend.create_model( + model_path, device, compute_type, cpu_threads=cpu_threads + ) # Резолвим actual_device по реальному OpenVINO device ov_dev = getattr(backend, "actual_ov_device", None) if ov_dev == "GPU" and actual_device != "openvino-gpu": actual_device = "openvino-gpu" - elif ov_dev == "CPU" and actual_device.startswith("openvino") and actual_device != "openvino-cpu": + elif ( + ov_dev == "CPU" + and actual_device.startswith("openvino") + and actual_device != "openvino-cpu" + ): actual_device = "openvino-cpu" except (RuntimeError, ValueError) as exc: if device != "cpu" and _is_backend_error(exc, device): if strict_device: raise warnings.warn( - f"Не удалось загрузить модель на {device}: {exc}. " - "Переключение на CPU.", + f"Не удалось загрузить модель на {device}: {exc}. Переключение на CPU.", stacklevel=2, ) actual_device = "cpu" backend = get_backend("cpu") - model_path = backend.ensure_model_available(model_name, compute_type, on_status) + model_path = backend.ensure_model_available( + model_name, compute_type, on_status + ) _notify_status(on_status, "Инициализирую модель на cpu...") - model = backend.create_model(model_path, "cpu", compute_type, cpu_threads=cpu_threads) + model = backend.create_model( + model_path, "cpu", compute_type, cpu_threads=cpu_threads + ) else: raise @@ -96,11 +106,17 @@ def _transcribe_file( ) actual_device = "cpu" backend = get_backend("cpu") - model_path = backend.ensure_model_available(model_name, compute_type, on_status) + model_path = backend.ensure_model_available( + model_name, compute_type, on_status + ) _notify_status(on_status, "Инициализирую модель на cpu...") - model = backend.create_model(model_path, "cpu", compute_type, cpu_threads=cpu_threads) + model = backend.create_model( + model_path, "cpu", compute_type, cpu_threads=cpu_threads + ) _notify_status(on_status, "Транскрибирую...") - result = backend.transcribe(model, file_path, lang_arg, on_segment, on_status) + result = backend.transcribe( + model, file_path, lang_arg, on_segment, on_status + ) result.device_used = actual_device else: raise @@ -127,14 +143,26 @@ def transcribe( ) -> TranscribeResult: """High-level API: загрузка модели + транскрипция за один вызов.""" model, actual_device, backend, model_path = load_model( - model_name, device, compute_type, on_status, strict_device, + model_name, + device, + compute_type, + on_status, + strict_device, compute_type_explicit=True, # Python API — caller explicitly chose compute_type cpu_threads=cpu_threads, ) tfr = _transcribe_file( - model, actual_device, backend, model_path, - file_path, model_name, compute_type, - language, on_segment, on_status, strict_device, + model, + actual_device, + backend, + model_path, + file_path, + model_name, + compute_type, + language, + on_segment, + on_status, + strict_device, cpu_threads=cpu_threads, ) return tfr.result @@ -151,7 +179,9 @@ def ensure_model_available( if compute_type is None: device_defs = DEVICE_DEFAULTS.get(device, {}) - compute_type = device_defs.get("compute_type", HARDCODED_DEFAULTS["compute_type"]) + compute_type = device_defs.get( + "compute_type", HARDCODED_DEFAULTS["compute_type"] + ) explicit = False else: explicit = True @@ -167,6 +197,8 @@ def _is_cuda_error(exc: BaseException) -> bool: def _is_backend_error(exc: BaseException, device: str) -> bool: """Определяет, связана ли ошибка с конкретным бэкендом (а не с пользовательскими данными).""" + if isinstance(exc, WordTimestampsUnavailableError): + return False if device in ("cuda", "cpu"): return _is_cuda_error(exc) if device.startswith("openvino"): diff --git a/src/local_transcriber/types.py b/src/local_transcriber/types.py index 1dc84cb..d8b49c4 100644 --- a/src/local_transcriber/types.py +++ b/src/local_transcriber/types.py @@ -1,13 +1,17 @@ """Общие типы данных для всех бэкендов транскрипции.""" from collections.abc import Callable -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any # Единый признак «язык неизвестен» для всех бэкендов UNKNOWN_LANGUAGE = "unknown" +class WordTimestampsUnavailableError(RuntimeError): + """ASR распознал текст, но нарушил обязательный пословный контракт.""" + + @dataclass class Segment: start: float # seconds @@ -15,6 +19,60 @@ class Segment: text: str +@dataclass(frozen=True) +class Word: + """Слово с временной привязкой на шкале исходной записи.""" + + start: float + end: float + text: str + + +@dataclass(frozen=True) +class SpeakerInterval: + """Интервал разметки говорящих с анонимным голосовым кластером.""" + + start: float + end: float + cluster: int + + +@dataclass(frozen=True) +class SpeakerTurn: + """Реплика говорящего; ``speaker=None`` означает неизвестного говорящего.""" + + start: float + end: float + text: str + speaker: int | None + + +@dataclass(frozen=True) +class SmallSpeakerCluster: + """Малый голосовой кластер, о котором нужно предупредить пользователя.""" + + speaker: int | None + duration: float + + +@dataclass +class SpeakerTranscript: + """Результат сведения слов с разметкой говорящих.""" + + turns: list[SpeakerTurn] + cluster_count: int + unassigned_word_count: int + small_clusters: list[SmallSpeakerCluster] + + +@dataclass +class DiarizationRun: + """Разметка одного файла и длительность прохода диаризации.""" + + intervals: list[SpeakerInterval] + elapsed_seconds: float + + @dataclass class TranscribeResult: segments: list[Segment] @@ -22,6 +80,7 @@ class TranscribeResult: language_probability: float duration: float # seconds device_used: str # "cpu" / "cuda" / "onnx" / "openvino-gpu" / "openvino-cpu" + words: list[Word] = field(default_factory=list) @dataclass diff --git a/tests/test_backend_faster_whisper.py b/tests/test_backend_faster_whisper.py new file mode 100644 index 0000000..36503d8 --- /dev/null +++ b/tests/test_backend_faster_whisper.py @@ -0,0 +1,66 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock + +import pytest + +from local_transcriber.backends.faster_whisper import FasterWhisperBackend +from local_transcriber.types import Word + + +def test_transcribe_returns_canonical_words(tmp_path): + audio = tmp_path / "audio.wav" + raw_word = SimpleNamespace(start=0.2, end=0.7, word=" Привет") + raw_segment = SimpleNamespace( + start=0.0, + end=1.0, + text=" Привет", + words=[raw_word], + ) + info = SimpleNamespace(duration=1.0, language="ru", language_probability=0.99) + model = MagicMock() + model.transcribe.return_value = (iter([raw_segment]), info) + + result = FasterWhisperBackend().transcribe(model, audio, language="ru") + + assert result.words == [Word(start=0.2, end=0.7, text=" Привет")] + model.transcribe.assert_called_once_with( + str(audio), + language="ru", + word_timestamps=True, + ) + + +def test_transcribe_rejects_nonempty_result_without_word_timestamps(tmp_path): + raw_segment = SimpleNamespace( + start=0.0, + end=1.0, + text=" Текст есть", + words=None, + ) + info = SimpleNamespace(duration=1.0, language="ru", language_probability=1.0) + model = MagicMock() + model.transcribe.return_value = (iter([raw_segment]), info) + + with pytest.raises(RuntimeError, match="пословные таймкоды"): + FasterWhisperBackend().transcribe(model, tmp_path / "audio.wav", "ru") + + +def test_transcribe_rejects_one_nonempty_segment_without_word_timestamps(tmp_path): + timestamped = SimpleNamespace( + start=0.0, + end=1.0, + text=" Первое", + words=[SimpleNamespace(start=0.0, end=1.0, word=" Первое")], + ) + missing = SimpleNamespace( + start=1.0, + end=2.0, + text=" Второе", + words=None, + ) + info = SimpleNamespace(duration=2.0, language="ru", language_probability=1.0) + model = MagicMock() + model.transcribe.return_value = (iter([timestamped, missing]), info) + + with pytest.raises(RuntimeError, match="пословные таймкоды"): + FasterWhisperBackend().transcribe(model, tmp_path / "audio.wav", "ru") diff --git a/tests/test_backend_openvino.py b/tests/test_backend_openvino.py index 361bb6d..8d44dbb 100644 --- a/tests/test_backend_openvino.py +++ b/tests/test_backend_openvino.py @@ -11,8 +11,7 @@ from local_transcriber.backends.openvino import ( OpenVINOBackend, _validate_model_dir, ) -from local_transcriber.types import UNKNOWN_LANGUAGE, Segment - +from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, Word # === _resolve_repo === @@ -28,12 +27,18 @@ def test_model_catalog_contains_large_v3_turbo_profiles(): def test_resolve_repo_exact_match(): backend = OpenVINOBackend(compute_type_explicit=True) - assert backend._resolve_repo("medium", "int8") == ("OpenVINO/whisper-medium-int8-ov", "int8") + assert backend._resolve_repo("medium", "int8") == ( + "OpenVINO/whisper-medium-int8-ov", + "int8", + ) def test_resolve_repo_large_v3_fp16(): backend = OpenVINOBackend(compute_type_explicit=True) - assert backend._resolve_repo("large-v3", "fp16") == ("OpenVINO/whisper-large-v3-fp16-ov", "fp16") + assert backend._resolve_repo("large-v3", "fp16") == ( + "OpenVINO/whisper-large-v3-fp16-ov", + "fp16", + ) def test_resolve_repo_explicit_unsupported_pair_raises(): @@ -53,16 +58,17 @@ def test_resolve_repo_implicit_fallback(): """Неявный compute_type: если int8 недоступен для base, fallback на fp16.""" backend = OpenVINOBackend(compute_type_explicit=False) # base + int8 не существует, но base + fp16 есть - assert backend._resolve_repo("base", "int8") == ("OpenVINO/whisper-base-fp16-ov", "fp16") + assert backend._resolve_repo("base", "int8") == ( + "OpenVINO/whisper-base-fp16-ov", + "fp16", + ) @pytest.mark.parametrize( ("model_name", "expected_compute_type"), [("large-v3", "fp16"), ("large-v3-turbo", "int8")], ) -def test_resolve_repo_implicit_large_v3_profiles( - model_name, expected_compute_type -): +def test_resolve_repo_implicit_large_v3_profiles(model_name, expected_compute_type): """Неявный compute_type различает обычную и turbo-модель.""" backend = OpenVINOBackend(compute_type_explicit=False) @@ -75,7 +81,10 @@ def test_resolve_repo_implicit_large_v3_profiles( def test_resolve_repo_explicit_large_v3_int8_respected(): """Явный --compute-type int8 для large-v3 → уважается.""" backend = OpenVINOBackend(compute_type_explicit=True) - assert backend._resolve_repo("large-v3", "int8") == ("OpenVINO/whisper-large-v3-int8-ov", "int8") + assert backend._resolve_repo("large-v3", "int8") == ( + "OpenVINO/whisper-large-v3-int8-ov", + "int8", + ) @pytest.mark.parametrize("compute_type", ["int8", "fp16"]) @@ -107,6 +116,7 @@ def test_ensure_model_available_cache_hit(mock_download, tmp_path): model_dir.mkdir() (model_dir / "openvino_encoder_model.xml").write_text("") (model_dir / "openvino_decoder_model.xml").write_text("") + (model_dir / "generation_config.json").write_text('{"alignment_heads": [[1, 2]]}') mock_download.return_value = str(model_dir) backend = OpenVINOBackend(compute_type_explicit=True) @@ -125,6 +135,7 @@ def test_ensure_model_available_downloads(mock_download, tmp_path): model_dir.mkdir() (model_dir / "openvino_encoder_model.xml").write_text("") (model_dir / "openvino_decoder_model.xml").write_text("") + (model_dir / "generation_config.json").write_text('{"alignment_heads": [[1, 2]]}') mock_download.side_effect = [ LocalEntryNotFoundError("not cached"), @@ -145,6 +156,7 @@ def test_large_v3_turbo_model_is_resolved_and_created(mock_download, tmp_path): model_dir.mkdir() (model_dir / "openvino_encoder_model.xml").write_text("") (model_dir / "openvino_decoder_model.xml").write_text("") + (model_dir / "generation_config.json").write_text('{"alignment_heads": [[1, 2]]}') mock_download.return_value = str(model_dir) mock_ov = MagicMock() @@ -157,12 +169,28 @@ def test_large_v3_turbo_model_is_resolved_and_created(mock_download, tmp_path): "OpenVINO/whisper-large-v3-turbo-int8-ov", local_files_only=True, ) - mock_ov.WhisperPipeline.assert_called_once_with(str(model_dir), "CPU") + mock_ov.WhisperPipeline.assert_called_once_with( + str(model_dir), "CPU", word_timestamps=True + ) # === create_model === +def test_create_model_enables_word_timestamps(): + mock_ov = MagicMock() + backend = OpenVINOBackend(ov_device="openvino-cpu") + + with patch.dict("sys.modules", {"openvino_genai": mock_ov}): + backend.create_model("/path/to/model", "openvino-cpu", "int8") + + mock_ov.WhisperPipeline.assert_called_once_with( + "/path/to/model", + "CPU", + word_timestamps=True, + ) + + def test_create_model_cpu(): mock_ov = MagicMock() mock_pipeline = MagicMock() @@ -172,7 +200,9 @@ def test_create_model_cpu(): with patch.dict("sys.modules", {"openvino_genai": mock_ov}): model = backend.create_model("/path/to/model", "openvino-cpu", "int8") - mock_ov.WhisperPipeline.assert_called_once_with("/path/to/model", "CPU") + mock_ov.WhisperPipeline.assert_called_once_with( + "/path/to/model", "CPU", word_timestamps=True + ) assert model is mock_pipeline assert backend.actual_ov_device == "CPU" @@ -186,7 +216,9 @@ def test_create_model_gpu(): with patch.dict("sys.modules", {"openvino_genai": mock_ov}): model = backend.create_model("/path/to/model", "openvino-gpu", "fp16") - mock_ov.WhisperPipeline.assert_called_once_with("/path/to/model", "GPU") + mock_ov.WhisperPipeline.assert_called_once_with( + "/path/to/model", "GPU", word_timestamps=True + ) assert model is mock_pipeline assert backend.actual_ov_device == "GPU" @@ -202,11 +234,16 @@ def test_create_model_openvino_auto_detects_gpu(): backend = OpenVINOBackend(ov_device="openvino") with ( - patch.dict("sys.modules", {"openvino_genai": mock_ov, "openvino": MagicMock(Core=mock_core)}), + patch.dict( + "sys.modules", + {"openvino_genai": mock_ov, "openvino": MagicMock(Core=mock_core)}, + ), ): - model = backend.create_model("/path/to/model", "openvino", "int8") + backend.create_model("/path/to/model", "openvino", "int8") - mock_ov.WhisperPipeline.assert_called_once_with("/path/to/model", "GPU") + mock_ov.WhisperPipeline.assert_called_once_with( + "/path/to/model", "GPU", word_timestamps=True + ) assert backend.actual_ov_device == "GPU" @@ -221,11 +258,16 @@ def test_create_model_openvino_auto_falls_back_to_cpu(): backend = OpenVINOBackend(ov_device="openvino") with ( - patch.dict("sys.modules", {"openvino_genai": mock_ov, "openvino": MagicMock(Core=mock_core)}), + patch.dict( + "sys.modules", + {"openvino_genai": mock_ov, "openvino": MagicMock(Core=mock_core)}, + ), ): - model = backend.create_model("/path/to/model", "openvino", "int8") + backend.create_model("/path/to/model", "openvino", "int8") - mock_ov.WhisperPipeline.assert_called_once_with("/path/to/model", "CPU") + mock_ov.WhisperPipeline.assert_called_once_with( + "/path/to/model", "CPU", word_timestamps=True + ) assert backend.actual_ov_device == "CPU" @@ -248,13 +290,19 @@ def test_transcribe_maps_chunks_to_segments(): mock_result = MagicMock() mock_result.chunks = [chunk1, chunk2] + mock_result.words = [ + MagicMock(start_ts=0.0, end_ts=3.5, word=" Привет мир"), + MagicMock(start_ts=3.5, end_ts=7.0, word=" Тестовый сегмент"), + ] mock_model.generate.return_value = mock_result raw_audio = np.zeros(16000 * 10, dtype=np.float32) # 10 секунд with patch("faster_whisper.decode_audio", return_value=raw_audio): result = backend.transcribe( - mock_model, Path("test.mp3"), language="ru", + mock_model, + Path("test.mp3"), + language="ru", ) assert len(result.segments) == 2 @@ -269,6 +317,61 @@ def test_transcribe_maps_chunks_to_segments(): assert call_kwargs.kwargs["return_timestamps"] is True +def test_transcribe_maps_word_level_timestamps(): + backend = OpenVINOBackend() + mock_model = MagicMock() + raw_word = MagicMock() + raw_word.start_ts = 0.2 + raw_word.end_ts = 0.8 + raw_word.word = " Привет" + mock_result = MagicMock() + mock_result.chunks = [] + mock_result.words = [raw_word] + mock_model.generate.return_value = mock_result + + with patch( + "faster_whisper.decode_audio", + return_value=np.zeros(16_000, dtype=np.float32), + ): + result = backend.transcribe(mock_model, Path("test.mp3"), language="ru") + + assert result.words == [Word(start=0.2, end=0.8, text=" Привет")] + assert mock_model.generate.call_args.kwargs["word_timestamps"] is True + + +def test_transcribe_keeps_zero_duration_word_timestamp(): + backend = OpenVINOBackend() + mock_model = MagicMock() + raw_word = MagicMock(start_ts=1.0, end_ts=1.0, word=" Слово") + mock_result = MagicMock(chunks=[], words=[raw_word]) + mock_model.generate.return_value = mock_result + + with patch( + "faster_whisper.decode_audio", + return_value=np.zeros(16_000, dtype=np.float32), + ): + result = backend.transcribe(mock_model, Path("test.mp3"), language="ru") + + assert result.words == [Word(start=1.0, end=1.0, text=" Слово")] + + +def test_transcribe_rejects_nonempty_result_without_word_timestamps(): + backend = OpenVINOBackend() + chunk = MagicMock(start_ts=0.0, end_ts=1.0, text=" Текст") + mock_result = MagicMock(chunks=[chunk], words=None) + mock_model = MagicMock() + mock_model.generate.return_value = mock_result + + with ( + patch( + "faster_whisper.decode_audio", + return_value=np.zeros(16_000, dtype=np.float32), + ), + pytest.raises(RuntimeError, match="пословные таймкоды"), + ): + backend.transcribe(mock_model, Path("test.mp3"), language="ru") + + def test_transcribe_calls_tolist(): """raw_speech передаётся как list, не ndarray.""" backend = OpenVINOBackend() @@ -314,6 +417,7 @@ def test_transcribe_calls_on_segment(): chunk.text = " Test" mock_result = MagicMock() mock_result.chunks = [chunk] + mock_result.words = [MagicMock(start_ts=0.0, end_ts=2.0, word=" Test")] mock_model.generate.return_value = mock_result raw_audio = np.zeros(16000, dtype=np.float32) @@ -321,7 +425,10 @@ def test_transcribe_calls_on_segment(): with patch("faster_whisper.decode_audio", return_value=raw_audio): backend.transcribe( - mock_model, Path("test.mp3"), language="en", on_segment=callback, + mock_model, + Path("test.mp3"), + language="en", + on_segment=callback, ) callback.assert_called_once() @@ -336,6 +443,7 @@ def test_transcribe_calls_on_segment(): def test_validate_model_dir_ok(tmp_path): (tmp_path / "openvino_encoder_model.xml").write_text("") (tmp_path / "openvino_decoder_model.xml").write_text("") + (tmp_path / "generation_config.json").write_text('{"alignment_heads": [[1, 2]]}') _validate_model_dir(tmp_path) # should not raise @@ -343,3 +451,15 @@ def test_validate_model_dir_missing(tmp_path): (tmp_path / "openvino_encoder_model.xml").write_text("") with pytest.raises(ValueError, match="openvino_decoder_model.xml"): _validate_model_dir(tmp_path) + + +def test_validate_model_dir_requires_alignment_heads_for_word_timestamps(tmp_path): + (tmp_path / "openvino_encoder_model.xml").write_text("") + (tmp_path / "openvino_decoder_model.xml").write_text("") + (tmp_path / "generation_config.json").write_text( + '{"alignment_heads": []}', + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="alignment_heads"): + _validate_model_dir(tmp_path) diff --git a/tests/test_cli.py b/tests/test_cli.py index f40e2e0..a64aa33 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -12,15 +12,26 @@ from local_transcriber.formatter import ( LANGUAGE_FROM_MODEL, LANGUAGE_UNKNOWN, ) -from local_transcriber.transcriber import Segment, TranscribeFileResult, TranscribeResult -from local_transcriber.types import UNKNOWN_LANGUAGE +from local_transcriber.transcriber import ( + Segment, + TranscribeFileResult, + TranscribeResult, +) +from local_transcriber.types import ( + UNKNOWN_LANGUAGE, + DiarizationRun, + SpeakerInterval, + Word, +) runner = CliRunner() def _make_result(segments=None, language="ru", device_used="cpu", duration=60.0): return TranscribeResult( - segments=[Segment(start=0.0, end=2.0, text="Hello")] if segments is None else segments, + segments=[Segment(start=0.0, end=2.0, text="Hello")] + if segments is None + else segments, language=language, language_probability=0.95, duration=duration, @@ -36,7 +47,13 @@ def _make_backend(): return MagicMock(name="Backend") -def _make_tfr(result=None, model=None, actual_device="cpu", backend=None, model_path="/models/medium"): +def _make_tfr( + result=None, + model=None, + actual_device="cpu", + backend=None, + model_path="/models/medium", +): if result is None: result = _make_result() if model is None: @@ -44,8 +61,11 @@ def _make_tfr(result=None, model=None, actual_device="cpu", backend=None, model_ if backend is None: backend = _make_backend() return TranscribeFileResult( - result=result, model=model, actual_device=actual_device, - backend=backend, model_path=model_path, + result=result, + model=model, + actual_device=actual_device, + backend=backend, + model_path=model_path, ) @@ -58,9 +78,7 @@ def _make_tfr(result=None, model=None, actual_device="cpu", backend=None, model_ ("auto", UNKNOWN_LANGUAGE, 0.0, LANGUAGE_UNKNOWN), ], ) -def test_format_language_mode( - requested_language, language, probability, expected -): +def test_format_language_mode(requested_language, language, probability, expected): result = _make_result(language=language) result.language_probability = probability @@ -73,12 +91,17 @@ def _single_patches(result=None, tmp_file=None, actual_device="cpu"): result = _make_result(device_used=actual_device) model = _make_model() backend = _make_backend() - tfr = _make_tfr(result=result, model=model, actual_device=actual_device, backend=backend) + tfr = _make_tfr( + result=result, model=model, actual_device=actual_device, backend=backend + ) return [ patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=tmp_file), patch("local_transcriber.cli.detect_device", return_value=actual_device), - patch("local_transcriber.cli.load_model", return_value=(model, actual_device, backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, actual_device, backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli.write_transcript"), ] @@ -139,18 +162,28 @@ def test_cli_custom_options(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/small")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cuda", backend, "/models/small"), + ), patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"), ): - runner.invoke(app, [ - str(audio), - "--model", "small", - "--language", "ru", - "--device", "cuda", - "--compute-type", "float16", - ]) + runner.invoke( + app, + [ + str(audio), + "--model", + "small", + "--language", + "ru", + "--device", + "cuda", + "--compute-type", + "float16", + ], + ) call_kwargs = mock_transcribe_file.call_args[1] assert call_kwargs["model_name"] == "small" @@ -158,6 +191,266 @@ def test_cli_custom_options(tmp_path): assert call_kwargs["compute_type"] == "float16" +def test_cli_speakers_enables_diarization_and_writes_speaker_markdown(tmp_path): + audio = tmp_path / "meeting.mp3" + audio.write_bytes(b"fake") + result = _make_result( + segments=[Segment(0.0, 1.3, "Первый. Второй. Неясно.")], + duration=10.0, + ) + result.words = [ + Word(0.0, 0.5, "Первый."), + Word(0.5, 1.0, "Второй."), + Word(1.1, 1.3, "Неясно."), + ] + model = _make_model() + backend = _make_backend() + backend.word_timestamps_available = True + tfr = _make_tfr(result=result, model=model, backend=backend) + diarizer = MagicMock() + diarizer.process.return_value = DiarizationRun( + intervals=[ + SpeakerInterval(0.0, 0.5, 10), + SpeakerInterval(0.5, 1.0, 20), + ], + elapsed_seconds=0.2, + ) + write = MagicMock() + + with ( + patch("local_transcriber.cli.load_config", return_value={}), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cpu"), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), + patch("local_transcriber.cli._transcribe_file", return_value=tfr), + patch( + "local_transcriber.cli.load_speaker_diarizer", + return_value=diarizer, + ) as load_diarizer, + patch("local_transcriber.cli.write_transcript", write), + ): + out = runner.invoke( + app, + [str(audio), "--speakers", "2", "--threads", "3"], + ) + + assert out.exit_code == 0 + load_diarizer.assert_called_once() + assert load_diarizer.call_args.kwargs["speakers"] == 2 + assert load_diarizer.call_args.kwargs["threads"] == 3 + diarizer.process.assert_called_once() + assert "Speaker 1: Первый." in write.call_args.args[0] + assert "Speaker 2: Второй." in write.call_args.args[0] + assert "Speaker ?: Неясно." in write.call_args.args[0] + assert "1 слов без назначенного говорящего" in out.output + assert "малый кластер Speaker 1: 0.5 с" in out.output + + +def test_cli_diarization_error_writes_plain_transcript_and_exits_nonzero(tmp_path): + audio = tmp_path / "meeting.mp3" + audio.write_bytes(b"fake") + result = _make_result( + segments=[Segment(0.0, 1.0, "Полезный текст.")], + duration=10.0, + ) + result.words = [Word(0.0, 1.0, "Полезный текст.")] + model = _make_model() + backend = _make_backend() + backend.word_timestamps_available = True + tfr = _make_tfr(result=result, model=model, backend=backend) + diarizer = MagicMock() + diarizer.process.side_effect = RuntimeError("boom") + write = MagicMock() + + with ( + patch("local_transcriber.cli.load_config", return_value={}), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cpu"), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), + patch("local_transcriber.cli._transcribe_file", return_value=tfr), + patch( + "local_transcriber.cli.load_speaker_diarizer", + return_value=diarizer, + ), + patch("local_transcriber.cli.write_transcript", write), + ): + out = runner.invoke(app, [str(audio), "--diarize"]) + + assert out.exit_code == 1 + assert write.call_count == 1 + assert "Полезный текст." in write.call_args.args[0] + assert "Диаризация завершилась с ошибкой: boom" in write.call_args.args[0] + + +def test_cli_verbose_reports_diarization_counts_and_duration(tmp_path): + audio = tmp_path / "meeting.mp3" + audio.write_bytes(b"fake") + result = _make_result( + segments=[Segment(0.0, 1.0, "Раз два")], + duration=10.0, + ) + result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")] + model = _make_model() + backend = _make_backend() + backend.word_timestamps_available = True + tfr = _make_tfr(result=result, model=model, backend=backend) + diarizer = MagicMock() + diarizer.process.return_value = DiarizationRun( + intervals=[ + SpeakerInterval(0.0, 0.5, 1), + SpeakerInterval(0.5, 1.0, 2), + ], + elapsed_seconds=0.2, + ) + + with ( + patch("local_transcriber.cli.load_config", return_value={}), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cpu"), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), + patch("local_transcriber.cli._transcribe_file", return_value=tfr), + patch( + "local_transcriber.cli.load_speaker_diarizer", + return_value=diarizer, + ), + patch("local_transcriber.cli.write_transcript"), + ): + out = runner.invoke(app, [str(audio), "--diarize", "--verbose"]) + + assert out.exit_code == 0 + assert "2 кластеров, 2 интервалов" in out.output + assert "0.2 с" in out.output + + +def test_cli_empty_asr_skips_diarizer_and_reports_it(tmp_path): + audio = tmp_path / "silence.wav" + audio.write_bytes(b"fake") + result = _make_result(segments=[]) + model = _make_model() + backend = _make_backend() + backend.word_timestamps_available = True + tfr = _make_tfr(result=result, model=model, backend=backend) + diarizer = MagicMock() + + with ( + patch("local_transcriber.cli.load_config", return_value={}), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cpu"), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), + patch("local_transcriber.cli._transcribe_file", return_value=tfr), + patch( + "local_transcriber.cli.load_speaker_diarizer", + return_value=diarizer, + ), + patch("local_transcriber.cli.write_transcript"), + ): + out = runner.invoke(app, [str(audio), "--diarize"]) + + assert out.exit_code == 0 + diarizer.process.assert_not_called() + assert "диаризация не запускалась" in out.output + + +def test_cli_diarizer_preflight_failure_does_not_start_asr_or_write(tmp_path): + audio = tmp_path / "meeting.mp3" + audio.write_bytes(b"fake") + model = _make_model() + backend = _make_backend() + backend.word_timestamps_available = True + transcribe_file = MagicMock() + write = MagicMock() + + with ( + patch("local_transcriber.cli.load_config", return_value={}), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cpu"), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), + patch("local_transcriber.cli._transcribe_file", transcribe_file), + patch( + "local_transcriber.cli.load_speaker_diarizer", + side_effect=RuntimeError("модель повреждена"), + ), + patch("local_transcriber.cli.write_transcript", write), + ): + out = runner.invoke(app, [str(audio), "--diarize"]) + + assert out.exit_code == 1 + transcribe_file.assert_not_called() + write.assert_not_called() + + +@pytest.mark.parametrize( + ("intervals", "warning"), + [ + ([SpeakerInterval(0.0, 1.0, 1)], "только один голосовой кластер"), + ([], "не нашёл интервалов"), + ], +) +def test_cli_unsuccessful_diarization_shape_writes_plain_text_and_exits_nonzero( + tmp_path, intervals, warning +): + audio = tmp_path / "meeting.mp3" + audio.write_bytes(b"fake") + result = _make_result( + segments=[Segment(0.0, 1.0, "Раз два")], + duration=10.0, + ) + result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")] + model = _make_model() + backend = _make_backend() + backend.word_timestamps_available = True + tfr = _make_tfr(result=result, model=model, backend=backend) + diarizer = MagicMock() + diarizer.process.return_value = DiarizationRun(intervals, elapsed_seconds=0.1) + write = MagicMock() + + with ( + patch("local_transcriber.cli.load_config", return_value={}), + patch("local_transcriber.cli.validate_input_file", return_value=audio), + patch("local_transcriber.cli.detect_device", return_value="cpu"), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), + patch("local_transcriber.cli._transcribe_file", return_value=tfr), + patch( + "local_transcriber.cli.load_speaker_diarizer", + return_value=diarizer, + ), + patch("local_transcriber.cli.write_transcript", write), + ): + out = runner.invoke(app, [str(audio), "--diarize"]) + + assert out.exit_code == 1 + content = write.call_args.args[0] + assert warning in content + assert "[00:00.00 - 00:01.00] Раз два" in content + + +def test_cli_rejects_nonpositive_speaker_count(tmp_path): + audio = tmp_path / "meeting.mp3" + audio.write_bytes(b"fake") + + out = runner.invoke(app, [str(audio), "--speakers", "0"]) + + assert out.exit_code == 2 + + def test_cli_verbose_passes_on_segment_callback(tmp_path): audio = tmp_path / "test.mp3" audio.write_bytes(b"fake") @@ -171,7 +464,10 @@ def test_cli_verbose_passes_on_segment_callback(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), patch("local_transcriber.cli.write_transcript"), ): @@ -209,7 +505,10 @@ def test_cli_default_output_path(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli.write_transcript", mock_write), ): @@ -234,7 +533,10 @@ def test_cli_custom_output_path(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli.write_transcript", mock_write), ): @@ -257,7 +559,10 @@ def test_cli_passes_status_callback_to_transcribe(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), patch("local_transcriber.cli.write_transcript"), ): @@ -276,7 +581,9 @@ def test_cli_load_model_called_with_model_name(tmp_path): model = _make_model() backend = _make_backend() tfr = _make_tfr(result=result, model=model, backend=backend) - mock_load_model = MagicMock(return_value=(model, "cpu", backend, "/models/large-v3")) + mock_load_model = MagicMock( + return_value=(model, "cpu", backend, "/models/large-v3") + ) with ( patch("local_transcriber.cli.load_config", return_value={}), @@ -302,8 +609,14 @@ def test_cli_windows_cuda_diagnostic(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/medium")), - patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("CUDA error: no device")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cuda", backend, "/models/medium"), + ), + patch( + "local_transcriber.cli._transcribe_file", + side_effect=RuntimeError("CUDA error: no device"), + ), patch("local_transcriber.cli.sys") as mock_sys, ): mock_sys.platform = "win32" @@ -325,8 +638,14 @@ def test_cli_linux_cuda_error_no_windows_hint(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/medium")), - patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("CUDA error: no device")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cuda", backend, "/models/medium"), + ), + patch( + "local_transcriber.cli._transcribe_file", + side_effect=RuntimeError("CUDA error: no device"), + ), patch("local_transcriber.cli.sys") as mock_sys, ): mock_sys.platform = "linux" @@ -349,7 +668,10 @@ def test_cli_device_fallback_warning(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cuda", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli.write_transcript"), ): @@ -372,7 +694,10 @@ def test_cli_strict_device_passed_to_transcribe(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch("local_transcriber.cli.load_model", return_value=(model, "cuda", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cuda", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.get_gpu_name", return_value="RTX 3060"), @@ -390,7 +715,10 @@ def test_cli_strict_device_passed_to_transcribe(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", mock_transcribe_file), patch("local_transcriber.cli.write_transcript"), ): @@ -410,7 +738,10 @@ def test_cli_keyboard_interrupt(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", side_effect=KeyboardInterrupt), patch("local_transcriber.cli.write_transcript"), ): @@ -443,8 +774,14 @@ def test_cli_unexpected_error_verbose_traceback(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), - patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("unexpected boom")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), + patch( + "local_transcriber.cli._transcribe_file", + side_effect=RuntimeError("unexpected boom"), + ), patch("local_transcriber.cli.write_transcript"), ): out = runner.invoke(app, [str(audio), "--verbose"]) @@ -464,8 +801,14 @@ def test_cli_unexpected_error_no_verbose_hint(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", return_value=audio), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), - patch("local_transcriber.cli._transcribe_file", side_effect=RuntimeError("unexpected boom")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), + patch( + "local_transcriber.cli._transcribe_file", + side_effect=RuntimeError("unexpected boom"), + ), patch("local_transcriber.cli.write_transcript"), ): out = runner.invoke(app, [str(audio)]) @@ -493,7 +836,10 @@ def test_cli_batch_two_files(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli.write_transcript"), ): @@ -503,6 +849,111 @@ def test_cli_batch_two_files(tmp_path): assert "2 обработано" in out.output +def test_cli_batch_reuses_one_diarizer_for_all_nonempty_files(tmp_path): + first = tmp_path / "first.mp3" + second = tmp_path / "second.mp3" + first.write_bytes(b"fake") + second.write_bytes(b"fake") + result = _make_result( + segments=[Segment(0.0, 1.0, "Раз два")], + duration=10.0, + ) + result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")] + model = _make_model() + backend = _make_backend() + backend.word_timestamps_available = True + tfr = _make_tfr(result=result, model=model, backend=backend) + diarizer = MagicMock() + diarizer.process.return_value = DiarizationRun( + intervals=[ + SpeakerInterval(0.0, 0.5, 1), + SpeakerInterval(0.5, 1.0, 2), + ], + elapsed_seconds=0.1, + ) + + with ( + patch("local_transcriber.cli.load_config", return_value={}), + patch( + "local_transcriber.cli.validate_input_file", + side_effect=lambda path: path, + ), + patch("local_transcriber.cli.detect_device", return_value="cpu"), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), + patch("local_transcriber.cli._transcribe_file", return_value=tfr), + patch( + "local_transcriber.cli.load_speaker_diarizer", + return_value=diarizer, + ) as load_diarizer, + patch("local_transcriber.cli.write_transcript") as write, + ): + out = runner.invoke(app, [str(first), str(second), "--diarize"]) + + assert out.exit_code == 0 + load_diarizer.assert_called_once() + assert [call.args[0] for call in diarizer.process.call_args_list] == [ + first, + second, + ] + assert write.call_count == 2 + + +def test_cli_batch_continues_after_diarization_error_and_exits_nonzero(tmp_path): + first = tmp_path / "first.mp3" + second = tmp_path / "second.mp3" + first.write_bytes(b"fake") + second.write_bytes(b"fake") + result = _make_result( + segments=[Segment(0.0, 1.0, "Раз два")], + duration=10.0, + ) + result.words = [Word(0.0, 0.5, "Раз"), Word(0.5, 1.0, "два")] + model = _make_model() + backend = _make_backend() + backend.word_timestamps_available = True + tfr = _make_tfr(result=result, model=model, backend=backend) + diarizer = MagicMock() + diarizer.process.side_effect = [ + RuntimeError("boom"), + DiarizationRun( + [ + SpeakerInterval(0.0, 0.5, 1), + SpeakerInterval(0.5, 1.0, 2), + ], + elapsed_seconds=0.1, + ), + ] + + with ( + patch("local_transcriber.cli.load_config", return_value={}), + patch( + "local_transcriber.cli.validate_input_file", + side_effect=lambda path: path, + ), + patch("local_transcriber.cli.detect_device", return_value="cpu"), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), + patch("local_transcriber.cli._transcribe_file", return_value=tfr), + patch( + "local_transcriber.cli.load_speaker_diarizer", + return_value=diarizer, + ), + patch("local_transcriber.cli.write_transcript") as write, + ): + out = runner.invoke(app, [str(first), str(second), "--diarize"]) + + assert out.exit_code == 1 + assert write.call_count == 2 + assert "Диаризация завершилась с ошибкой: boom" in write.call_args_list[0].args[0] + assert "Speaker 1" in write.call_args_list[1].args[0] + assert "1 с деградацией" in out.output + + def test_cli_batch_skips_existing(tmp_path): a = tmp_path / "a.mp3" b = tmp_path / "b.mp3" @@ -519,7 +970,10 @@ def test_cli_batch_skips_existing(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli.write_transcript"), ): @@ -540,16 +994,22 @@ def test_cli_batch_all_skipped_no_model_load(tmp_path): (tmp_path / "b-transcript.md").write_text("existing") mock_load_model = MagicMock() + mock_load_diarizer = MagicMock() with ( patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.load_model", mock_load_model), + patch( + "local_transcriber.cli.load_speaker_diarizer", + mock_load_diarizer, + ), ): - out = runner.invoke(app, [str(a), str(b)]) + out = runner.invoke(app, [str(a), str(b), "--diarize"]) assert out.exit_code == 0 mock_load_model.assert_not_called() + mock_load_diarizer.assert_not_called() def test_cli_batch_force_overwrites(tmp_path): @@ -568,7 +1028,10 @@ def test_cli_batch_force_overwrites(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli.write_transcript"), ): @@ -602,8 +1065,13 @@ def test_cli_batch_per_file_error(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), - patch("local_transcriber.cli._transcribe_file", side_effect=transcribe_side_effect), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), + patch( + "local_transcriber.cli._transcribe_file", side_effect=transcribe_side_effect + ), patch("local_transcriber.cli.write_transcript"), ): out = runner.invoke(app, [str(a), str(b)]) @@ -631,9 +1099,15 @@ def test_cli_batch_invalid_in_prescan(tmp_path): with ( patch("local_transcriber.cli.load_config", return_value={}), - patch("local_transcriber.cli.validate_input_file", side_effect=validate_side_effect), + patch( + "local_transcriber.cli.validate_input_file", + side_effect=validate_side_effect, + ), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli.write_transcript"), ): @@ -768,7 +1242,10 @@ def test_cli_batch_fallback_warning(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli.write_transcript"), ): @@ -795,8 +1272,13 @@ def test_cli_batch_empty_speech_warning(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), - patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_empty, tfr_ok]), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), + patch( + "local_transcriber.cli._transcribe_file", side_effect=[tfr_empty, tfr_ok] + ), patch("local_transcriber.cli.write_transcript"), ): out = runner.invoke(app, [str(a), str(b)]) @@ -817,15 +1299,24 @@ def test_cli_batch_midstream_fallback_warning(tmp_path): model_cpu = _make_model() backend = _make_backend() result = _make_result(device_used="cpu") - tfr_fallback = _make_tfr(result=result, model=model_cpu, actual_device="cpu", backend=backend) - tfr_ok = _make_tfr(result=result, model=model_cpu, actual_device="cpu", backend=backend) + tfr_fallback = _make_tfr( + result=result, model=model_cpu, actual_device="cpu", backend=backend + ) + tfr_ok = _make_tfr( + result=result, model=model_cpu, actual_device="cpu", backend=backend + ) with ( patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.detect_device", return_value="cuda"), - patch("local_transcriber.cli.load_model", return_value=(model_gpu, "cuda", backend, "/models/medium")), - patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_fallback, tfr_ok]), + patch( + "local_transcriber.cli.load_model", + return_value=(model_gpu, "cuda", backend, "/models/medium"), + ), + patch( + "local_transcriber.cli._transcribe_file", side_effect=[tfr_fallback, tfr_ok] + ), patch("local_transcriber.cli.write_transcript"), ): out = runner.invoke(app, [str(a), str(b)]) @@ -864,8 +1355,14 @@ def test_cli_batch_model_loaded_once(tmp_path): def test_format_device_info_openvino_gpu(): - with patch("local_transcriber.cli.get_intel_gpu_name", return_value="Intel(R) Arc(TM) 140T GPU"): - assert _format_device_info("openvino-gpu") == "OpenVINO (Intel(R) Arc(TM) 140T GPU)" + with patch( + "local_transcriber.cli.get_intel_gpu_name", + return_value="Intel(R) Arc(TM) 140T GPU", + ): + assert ( + _format_device_info("openvino-gpu") + == "OpenVINO (Intel(R) Arc(TM) 140T GPU)" + ) def test_format_device_info_openvino_gpu_no_name(): @@ -900,9 +1397,13 @@ def test_cli_openvino_gpu_happy_path(tmp_path): audio.write_bytes(b"fake") result = _make_result(device_used="openvino-gpu") - patches = _single_patches(result=result, tmp_file=audio, actual_device="openvino-gpu") + patches = _single_patches( + result=result, tmp_file=audio, actual_device="openvino-gpu" + ) with patches[0], patches[1], patches[2], patches[3], patches[4], patches[5]: - with patch("local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T"): + with patch( + "local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T" + ): out = runner.invoke(app, [str(audio), "--device", "openvino-gpu"]) assert out.exit_code == 0 @@ -915,8 +1416,12 @@ def test_cli_openvino_alias_resolves_to_gpu(tmp_path): result = _make_result(device_used="openvino-gpu") model = _make_model() backend = _make_backend() - tfr = _make_tfr(result=result, model=model, actual_device="openvino-gpu", backend=backend) - mock_load_model = MagicMock(return_value=(model, "openvino-gpu", backend, "/models/medium")) + tfr = _make_tfr( + result=result, model=model, actual_device="openvino-gpu", backend=backend + ) + mock_load_model = MagicMock( + return_value=(model, "openvino-gpu", backend, "/models/medium") + ) with ( patch("local_transcriber.cli.load_config", return_value={}), @@ -925,7 +1430,9 @@ def test_cli_openvino_alias_resolves_to_gpu(tmp_path): patch("local_transcriber.cli.load_model", mock_load_model), patch("local_transcriber.cli._transcribe_file", return_value=tfr), patch("local_transcriber.cli.write_transcript"), - patch("local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T"), + patch( + "local_transcriber.cli.get_intel_gpu_name", return_value="Intel Arc 140T" + ), ): out = runner.invoke(app, [str(audio), "--device", "openvino"]) @@ -1000,7 +1507,9 @@ def test_cli_install_menu_success(tmp_path): cmd_path = tmp_path / "Transcribe.cmd" with ( - patch("local_transcriber.cli.install_context_menu", return_value=cmd_path) as mock_install, + patch( + "local_transcriber.cli.install_context_menu", return_value=cmd_path + ) as mock_install, patch("local_transcriber.cli.load_config") as mock_load_config, patch("local_transcriber.cli.sys") as mock_sys, ): @@ -1018,7 +1527,9 @@ def test_cli_uninstall_menu_success(tmp_path): cmd_path = tmp_path / "Transcribe.cmd" with ( - patch("local_transcriber.cli.uninstall_context_menu", return_value=cmd_path) as mock_uninstall, + patch( + "local_transcriber.cli.uninstall_context_menu", return_value=cmd_path + ) as mock_uninstall, patch("local_transcriber.cli.load_config") as mock_load_config, patch("local_transcriber.cli.sys") as mock_sys, ): @@ -1079,7 +1590,10 @@ def test_cli_menu_flags_available_only_on_windows(): def test_cli_menu_runtime_error_has_no_verbose_hint(): with ( - patch("local_transcriber.cli.install_context_menu", side_effect=RuntimeError("нет APPDATA")), + patch( + "local_transcriber.cli.install_context_menu", + side_effect=RuntimeError("нет APPDATA"), + ), patch("local_transcriber.cli.sys") as mock_sys, ): mock_sys.platform = "win32" @@ -1169,7 +1683,10 @@ def test_cli_quality_warning_batch_includes_file_name(tmp_path): patch("local_transcriber.cli.load_config", return_value={}), patch("local_transcriber.cli.validate_input_file", side_effect=lambda p: p), patch("local_transcriber.cli.detect_device", return_value="cpu"), - patch("local_transcriber.cli.load_model", return_value=(model, "cpu", backend, "/models/medium")), + patch( + "local_transcriber.cli.load_model", + return_value=(model, "cpu", backend, "/models/medium"), + ), patch("local_transcriber.cli._transcribe_file", side_effect=[tfr_warn, tfr_ok]), patch("local_transcriber.cli.write_transcript"), patch("local_transcriber.cli.console", Console(stderr=True, width=1000)), @@ -1178,8 +1695,7 @@ def test_cli_quality_warning_batch_includes_file_name(tmp_path): assert out.exit_code == 0 assert ( - " a.mp3: транскрипт покрывает 01:00 из 10:00 — " - "возможна потеря хвоста записи" + " a.mp3: транскрипт покрывает 01:00 из 10:00 — возможна потеря хвоста записи" ) in out.output diff --git a/tests/test_context_menu.py b/tests/test_context_menu.py index 1e33c3d..f38b7ed 100644 --- a/tests/test_context_menu.py +++ b/tests/test_context_menu.py @@ -125,4 +125,4 @@ def test_get_transcribe_exe_requires_existing_exe(tmp_path, monkeypatch): monkeypatch.setattr(context_menu.sys, "executable", str(python_exe)) with pytest.raises(RuntimeError, match="uv sync"): - context_menu.get_transcribe_exe() \ No newline at end of file + context_menu.get_transcribe_exe() diff --git a/tests/test_diarization.py b/tests/test_diarization.py new file mode 100644 index 0000000..c3d3a63 --- /dev/null +++ b/tests/test_diarization.py @@ -0,0 +1,120 @@ +from local_transcriber.diarization import build_speaker_transcript +from local_transcriber.types import ( + SmallSpeakerCluster, + SpeakerInterval, + SpeakerTurn, + Word, +) + + +def test_build_speaker_transcript_assigns_and_groups_words(): + words = [ + Word(start=0.0, end=0.8, text="Добрый"), + Word(start=0.8, end=1.4, text="день."), + Word(start=1.5, end=2.1, text="Привет!"), + ] + intervals = [ + SpeakerInterval(start=0.0, end=1.4, cluster=7), + SpeakerInterval(start=1.4, end=2.3, cluster=3), + ] + + transcript = build_speaker_transcript(words, intervals, recording_duration=30.0) + + assert [ + (turn.speaker, turn.start, turn.end, turn.text) for turn in transcript.turns + ] == [ + (1, 0.0, 1.4, "Добрый день."), + (2, 1.5, 2.1, "Привет!"), + ] + assert transcript.cluster_count == 2 + assert transcript.unassigned_word_count == 0 + + +def test_build_speaker_transcript_reports_small_cluster_without_filtering_it(): + words = [ + Word(start=0.0, end=1.0, text="Редкая реплика."), + Word(start=5.0, end=6.0, text="Основная реплика."), + ] + intervals = [ + SpeakerInterval(start=0.0, end=4.9, cluster=4), + SpeakerInterval(start=5.0, end=10.0, cluster=9), + ] + + transcript = build_speaker_transcript(words, intervals, recording_duration=100.0) + + assert [turn.speaker for turn in transcript.turns] == [1, 2] + assert transcript.small_clusters == [SmallSpeakerCluster(speaker=1, duration=4.9)] + + +def test_build_speaker_transcript_keeps_equal_overlap_unassigned(): + words = [Word(start=0.0, end=1.0, text="Спорное слово")] + intervals = [ + SpeakerInterval(start=0.0, end=0.1, cluster=8), + SpeakerInterval(start=0.3, end=0.5, cluster=8), + SpeakerInterval(start=0.0, end=0.3, cluster=2), + ] + + transcript = build_speaker_transcript(words, intervals, recording_duration=10.0) + + assert transcript.turns[0].speaker is None + assert transcript.unassigned_word_count == 1 + + +def test_build_speaker_transcript_keeps_word_without_overlap_unknown(): + transcript = build_speaker_transcript( + [Word(start=5.0, end=6.0, text="Вне разметки")], + [SpeakerInterval(start=0.0, end=1.0, cluster=1)], + recording_duration=10.0, + ) + + assert transcript.turns == [SpeakerTurn(5.0, 6.0, "Вне разметки", None)] + assert transcript.unassigned_word_count == 1 + + +def test_build_speaker_transcript_splits_at_two_second_pause(): + transcript = build_speaker_transcript( + [ + Word(0.0, 1.0, "До паузы."), + Word(3.0, 4.0, "После паузы."), + ], + [SpeakerInterval(0.0, 4.0, 1)], + recording_duration=10.0, + ) + + assert [turn.text for turn in transcript.turns] == [ + "До паузы.", + "После паузы.", + ] + + +def test_build_speaker_transcript_does_not_exceed_sixty_seconds(): + transcript = build_speaker_transcript( + [ + Word(0.0, 30.0, "Начало."), + Word(30.0, 60.0, "Продолжение."), + Word(60.0, 61.0, "Новая реплика."), + ], + [SpeakerInterval(0.0, 61.0, 1)], + recording_duration=70.0, + ) + + assert [turn.text for turn in transcript.turns] == [ + "Начало. Продолжение.", + "Новая реплика.", + ] + + +def test_build_speaker_transcript_preserves_punctuation_without_leading_space(): + transcript = build_speaker_transcript( + [ + Word(0.0, 0.4, "Тарадата"), + Word(0.4, 0.5, "+"), + Word(0.5, 0.7, "Click"), + Word(0.7, 0.8, "—"), + Word(0.8, 1.0, "это"), + ], + [SpeakerInterval(0.0, 1.0, 1)], + recording_duration=10.0, + ) + + assert transcript.turns[0].text == "Тарадата+ Click— это" diff --git a/tests/test_formatter.py b/tests/test_formatter.py index ac1e3d5..c71c084 100644 --- a/tests/test_formatter.py +++ b/tests/test_formatter.py @@ -1,5 +1,4 @@ from datetime import datetime -from pathlib import Path from local_transcriber.formatter import ( LANGUAGE_DETECTED, @@ -11,7 +10,12 @@ from local_transcriber.formatter import ( write_transcript, ) from local_transcriber.transcriber import Segment, TranscribeResult -from local_transcriber.types import UNKNOWN_LANGUAGE +from local_transcriber.types import ( + UNKNOWN_LANGUAGE, + SmallSpeakerCluster, + SpeakerTranscript, + SpeakerTurn, +) def test_format_timestamp_minutes(): @@ -59,6 +63,118 @@ def test_format_transcript_basic(): assert "[00:00.00 - 00:09.15] Добрый день, коллеги. Первый вопрос." in content +def test_format_transcript_speaker_turns_use_truncated_start_timestamps(): + result = TranscribeResult( + segments=[Segment(start=547.96, end=560.0, text=" Обычный текст")], + language="ru", + language_probability=0.97, + duration=700.0, + device_used="cpu", + ) + speakers = SpeakerTranscript( + turns=[ + SpeakerTurn(547.96, 550.0, "Первая реплика.", 1), + SpeakerTurn(558.4, 560.0, "Ответ.", 2), + ], + cluster_count=2, + unassigned_word_count=0, + small_clusters=[], + ) + + content = format_transcript( + result, + source_filename="meeting.mp4", + model_name="medium", + device_info="CPU", + language_mode=LANGUAGE_DETECTED, + speaker_transcript=speakers, + ) + + assert "- **Голосовых кластеров**: 2" in content + assert "[09:07] Speaker 1: Первая реплика." in content + assert "[09:18] Speaker 2: Ответ." in content + assert "[09:07.96 -" not in content + + +def test_format_transcript_speaker_turns_use_hours_after_one_hour(): + result = TranscribeResult( + segments=[Segment(start=3661.9, end=3663.0, text=" Длинная встреча")], + language="ru", + language_probability=1.0, + duration=3700.0, + device_used="cpu", + ) + speakers = SpeakerTranscript( + turns=[SpeakerTurn(3661.9, 3663.0, "Длинная встреча", 1)], + cluster_count=2, + unassigned_word_count=0, + small_clusters=[], + ) + + content = format_transcript( + result, + source_filename="meeting.mp4", + model_name="medium", + device_info="CPU", + language_mode=LANGUAGE_FORCED, + speaker_transcript=speakers, + ) + + assert "[01:01:01] Speaker 1: Длинная встреча" in content + + +def test_format_transcript_reports_unknown_words_and_small_clusters(): + result = TranscribeResult( + segments=[Segment(start=0.0, end=8.0, text=" Текст")], + language="ru", + language_probability=1.0, + duration=20.0, + device_used="cpu", + ) + speakers = SpeakerTranscript( + turns=[SpeakerTurn(1.2, 2.0, "Неясная реплика.", None)], + cluster_count=2, + unassigned_word_count=3, + small_clusters=[SmallSpeakerCluster(speaker=2, duration=4.2)], + ) + + content = format_transcript( + result, + source_filename="meeting.mp4", + model_name="medium", + device_info="CPU", + language_mode=LANGUAGE_FORCED, + speaker_transcript=speakers, + ) + + assert "[00:01] Speaker ?: Неясная реплика." in content + assert "3 слов без назначенного говорящего" in content + assert "малый кластер Speaker 2: 4.2 с" in content + + +def test_format_transcript_keeps_plain_body_with_diarization_warning(): + result = TranscribeResult( + segments=[Segment(start=0.0, end=2.0, text=" Полезный текст.")], + language="ru", + language_probability=1.0, + duration=5.0, + device_used="cpu", + ) + + content = format_transcript( + result, + source_filename="meeting.mp4", + model_name="medium", + device_info="CPU", + language_mode=LANGUAGE_FORCED, + diarization_warning="Диаризация завершилась с ошибкой: boom", + ) + + assert "**Внимание**: Диаризация завершилась с ошибкой: boom" in content + assert "[00:00.00 - 00:02.00] Полезный текст." in content + assert "Speaker" not in content + + def test_format_transcript_unknown_language_without_placeholder(): """Неизвестный язык печатается одной строкой, без служебного значения.""" result = TranscribeResult( diff --git a/tests/test_onnx_asr.py b/tests/test_onnx_asr.py index d29aeb0..e6e2734 100644 --- a/tests/test_onnx_asr.py +++ b/tests/test_onnx_asr.py @@ -5,16 +5,18 @@ import warnings import pytest from local_transcriber.backends.onnx_asr import OnnxAsrBackend -from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult +from local_transcriber.types import UNKNOWN_LANGUAGE, Segment, TranscribeResult, Word class FakeVadSegment: """Mimics onnx-asr SegmentResult.""" - def __init__(self, start, end, text): + def __init__(self, start, end, text, tokens=None, timestamps=None): self.start = start self.end = end self.text = text + self.tokens = [f" {text}"] if tokens is None else tokens + self.timestamps = [0.0] if timestamps is None else timestamps class TestEnsureModelAvailable: @@ -59,6 +61,9 @@ class TestEnsureModelAvailable: def with_vad(self, vad): return self + def with_timestamps(self): + return self + def fake_load_model(*, model, quantization): quantizations.append(quantization) return FakeAsrAdapter() @@ -82,21 +87,45 @@ class TestEnsureModelAvailable: class TestCreateModel: + def test_wraps_vad_model_with_timestamps(self, monkeypatch): + timestamped_model = object() + + class FakeVadAdapter: + def with_timestamps(self): + return timestamped_model + + class FakeAsrAdapter: + def with_vad(self, vad): + return FakeVadAdapter() + + monkeypatch.setattr("onnx_asr.load_model", lambda **kwargs: FakeAsrAdapter()) + monkeypatch.setattr("onnx_asr.load_vad", lambda model: object()) + + model = OnnxAsrBackend().create_model("gigaam-v3-e2e-rnnt", "onnx", "int8") + + assert model is timestamped_model + def test_calls_load_model_with_correct_args(self, monkeypatch): """Verify create_model passes correct args to onnx_asr.load_model.""" calls = [] - def fake_load_model(model=None, path=None, quantization=None, - **kwargs): - calls.append({ - "model": model, "path": path, "quantization": quantization, - }) + def fake_load_model(model=None, path=None, quantization=None, **kwargs): + calls.append( + { + "model": model, + "path": path, + "quantization": quantization, + } + ) return FakeAsrAdapter() class FakeAsrAdapter: def with_vad(self, vad): return self + def with_timestamps(self): + return self + monkeypatch.setattr("onnx_asr.load_model", fake_load_model) backend = OnnxAsrBackend() @@ -123,6 +152,9 @@ class TestCreateModel: self._vad = vad return self + def with_timestamps(self): + return self + monkeypatch.setattr("onnx_asr.load_model", fake_load_model) monkeypatch.setattr("onnx_asr.load_vad", fake_load_vad) @@ -143,6 +175,9 @@ class TestCreateModel: def with_vad(self, vad): return self + def with_timestamps(self): + return self + monkeypatch.setattr("onnx_asr.load_model", fake_load_model) monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None) @@ -167,6 +202,9 @@ class TestCreateModel: def with_vad(self, vad): return self + def with_timestamps(self): + return self + monkeypatch.setattr("onnx_asr.load_model", fake_load_model) monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None) @@ -187,6 +225,9 @@ class TestCreateModel: def with_vad(self, vad): return self + def with_timestamps(self): + return self + monkeypatch.setattr("onnx_asr.load_model", fake_load_model) monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None) @@ -207,6 +248,9 @@ class TestCreateModel: def with_vad(self, vad): return self + def with_timestamps(self): + return self + monkeypatch.setattr("onnx_asr.load_model", fake_load_model) monkeypatch.setattr("onnx_asr.load_vad", lambda model, **kw: None) @@ -298,6 +342,7 @@ class TestTranscribe: def fake_decode_audio(path, sampling_rate=16000): import numpy as np + return np.array(audio_samples, dtype=np.float32) class FakeModel: @@ -310,7 +355,9 @@ class TestTranscribe: backend = OnnxAsrBackend() backend.actual_compute_type = "int8" result = backend.transcribe( - FakeModel(), wav_file, language=None, + FakeModel(), + wav_file, + language=None, ) assert isinstance(result, TranscribeResult) @@ -319,6 +366,110 @@ class TestTranscribe: assert result.segments[1] == Segment(start=1.0, end=2.5, text="world") assert result.duration == 1.0 # 16000 samples / 16000 Hz + def test_transcribe_converts_vad_token_timestamps_to_global_words( + self, monkeypatch, tmp_path + ): + wav_file = tmp_path / "test.wav" + wav_file.write_bytes(b"fake audio") + monkeypatch.setattr( + "faster_whisper.decode_audio", + lambda path, sampling_rate=16000: [0.0] * 16_000, + ) + + timestamped_segment = FakeVadSegment( + 10.0, + 12.0, + "Привет, мир", + tokens=[" ", "П", "р", "и", "в", "е", "т", ",", " ", "м", "и", "р"], + timestamps=[0.0, 0.1, 0.1, 0.1, 0.2, 0.2, 0.3, 0.3, 0.5, 0.6, 0.6, 0.7], + ) + + class FakeModel: + def recognize(self, waveform, sample_rate, language=None): + yield timestamped_segment + + result = OnnxAsrBackend().transcribe(FakeModel(), wav_file, language="ru") + + assert result.words == [ + Word(start=10.0, end=10.5, text=" Привет,"), + Word(start=10.5, end=12.0, text=" мир"), + ] + assert "".join(word.text for word in result.words).strip() == "Привет, мир" + + def test_transcribe_rejects_nonempty_segment_without_token_timestamps( + self, monkeypatch, tmp_path + ): + wav_file = tmp_path / "test.wav" + wav_file.write_bytes(b"fake audio") + monkeypatch.setattr( + "faster_whisper.decode_audio", + lambda path, sampling_rate=16000: [0.0] * 16_000, + ) + segment = FakeVadSegment(0.0, 1.0, "Текст") + segment.tokens = None + segment.timestamps = None + + class FakeModel: + def recognize(self, waveform, sample_rate, language=None): + yield segment + + with pytest.raises(RuntimeError, match="пословные таймкоды"): + OnnxAsrBackend().transcribe(FakeModel(), wav_file, language="ru") + + def test_transcribe_keeps_words_with_equal_emission_timestamps( + self, monkeypatch, tmp_path + ): + wav_file = tmp_path / "test.wav" + wav_file.write_bytes(b"fake audio") + monkeypatch.setattr( + "faster_whisper.decode_audio", + lambda path, sampling_rate=16000: [0.0] * 16_000, + ) + segment = FakeVadSegment( + 10.0, + 12.0, + "Да нет потом", + tokens=[" ", "Да", " ", "нет", " ", "потом"], + timestamps=[0.0, 0.0, 0.0, 0.0, 0.5, 0.5], + ) + + class FakeModel: + def recognize(self, waveform, sample_rate, language=None): + yield segment + + result = OnnxAsrBackend().transcribe(FakeModel(), wav_file, language="ru") + + assert [word.text for word in result.words] == [" Да", " нет", " потом"] + assert [(word.start, word.end) for word in result.words] == [ + (10.0, 10.5), + (10.0, 10.5), + (10.5, 12.0), + ] + assert "".join(word.text for word in result.words).strip() == segment.text + + def test_transcribe_keeps_word_clamped_to_segment_end(self, monkeypatch, tmp_path): + wav_file = tmp_path / "test.wav" + wav_file.write_bytes(b"fake audio") + monkeypatch.setattr( + "faster_whisper.decode_audio", + lambda path, sampling_rate=16000: [0.0] * 16_000, + ) + segment = FakeVadSegment( + 10.0, + 12.0, + "Позднее", + tokens=[" ", "Позднее"], + timestamps=[2.0, 2.0], + ) + + class FakeModel: + def recognize(self, waveform, sample_rate, language=None): + yield segment + + result = OnnxAsrBackend().transcribe(FakeModel(), wav_file, language="ru") + + assert result.words == [Word(start=12.0, end=12.0, text=" Позднее")] + def test_transcribe_calls_on_segment(self, monkeypatch, tmp_path): """Verify on_segment callback is invoked per segment.""" wav_file = tmp_path / "test.wav" @@ -326,6 +477,7 @@ class TestTranscribe: def fake_decode_audio(path, sampling_rate=16000): import numpy as np + return np.array([0.0] * 16000, dtype=np.float32) segments_captured = [] @@ -339,7 +491,9 @@ class TestTranscribe: backend = OnnxAsrBackend() backend.transcribe( - FakeModel(), wav_file, language=None, + FakeModel(), + wav_file, + language=None, on_segment=lambda s: segments_captured.append(s), ) @@ -354,6 +508,7 @@ class TestTranscribe: def fake_decode_audio(path, sampling_rate=16000): import numpy as np + return np.array([0.0] * 16000, dtype=np.float32) lang_received = [] @@ -377,6 +532,7 @@ class TestTranscribe: def fake_decode_audio(path, sampling_rate=16000): import numpy as np + return np.array([0.0] * 16000, dtype=np.float32) class FakeModel: @@ -419,6 +575,7 @@ class TestTranscribe: class TestBackendRegistration: def test_get_backend_returns_onnx_backend(self): from local_transcriber.backends import get_backend + backend = get_backend("onnx") assert isinstance(backend, OnnxAsrBackend) diff --git a/tests/test_speaker_diarizer.py b/tests/test_speaker_diarizer.py new file mode 100644 index 0000000..589d5d0 --- /dev/null +++ b/tests/test_speaker_diarizer.py @@ -0,0 +1,231 @@ +import hashlib +import io +import sys +import tarfile +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +import numpy as np +import pytest + +from local_transcriber.speaker_diarizer import SpeakerDiarizer, load_speaker_diarizer +from local_transcriber.types import SpeakerInterval + + +def test_process_returns_sorted_domain_intervals(tmp_path): + audio = tmp_path / "meeting.mp3" + raw_result = MagicMock() + raw_result.sort_by_start_time.return_value = [ + SimpleNamespace(start=0.2, end=1.1, speaker=4), + SimpleNamespace(start=1.3, end=2.0, speaker=2), + ] + engine = MagicMock() + engine.process.return_value = raw_result + diarizer = SpeakerDiarizer(engine) + samples = np.zeros(16_000, dtype=np.float32) + + with patch("faster_whisper.decode_audio", return_value=samples) as decode: + run = diarizer.process(audio) + + assert run.intervals == [ + SpeakerInterval(start=0.2, end=1.1, cluster=4), + SpeakerInterval(start=1.3, end=2.0, cluster=2), + ] + decode.assert_called_once_with(str(audio), sampling_rate=16_000) + engine.process.assert_called_once_with(samples) + + +def test_process_reports_engine_progress(tmp_path): + raw_result = MagicMock() + raw_result.sort_by_start_time.return_value = [] + engine = MagicMock() + + def process(samples, callback): + assert callback(2, 4) == 0 + return raw_result + + engine.process.side_effect = process + statuses = [] + + with patch( + "faster_whisper.decode_audio", + return_value=np.zeros(16_000, dtype=np.float32), + ): + SpeakerDiarizer(engine).process( + tmp_path / "meeting.mp3", + on_status=statuses.append, + ) + + assert "Определяю говорящих... 2 / 4" in statuses + + +def test_load_speaker_diarizer_uses_verified_cache_and_calibrated_config( + tmp_path, monkeypatch +): + segmentation = tmp_path / "pyannote-segmentation-3.0.onnx" + embedding = tmp_path / "wespeaker_en_voxceleb_resnet34_LM.onnx" + segmentation.write_bytes(b"segmentation") + embedding.write_bytes(b"embedding") + monkeypatch.setattr( + "local_transcriber.speaker_diarizer._SEGMENTATION_SHA256", + hashlib.sha256(segmentation.read_bytes()).hexdigest(), + ) + monkeypatch.setattr( + "local_transcriber.speaker_diarizer._EMBEDDING_SHA256", + hashlib.sha256(embedding.read_bytes()).hexdigest(), + ) + + captured = {} + + def config_factory(**kwargs): + config = SimpleNamespace(**kwargs, validate=lambda: True) + captured["config"] = config + return config + + engine = SimpleNamespace(sample_rate=16_000) + sherpa = SimpleNamespace( + OfflineSpeakerSegmentationPyannoteModelConfig=lambda **kwargs: SimpleNamespace( + **kwargs + ), + OfflineSpeakerSegmentationModelConfig=lambda **kwargs: SimpleNamespace( + **kwargs + ), + SpeakerEmbeddingExtractorConfig=lambda **kwargs: SimpleNamespace(**kwargs), + FastClusteringConfig=lambda **kwargs: SimpleNamespace(**kwargs), + OfflineSpeakerDiarizationConfig=config_factory, + OfflineSpeakerDiarization=lambda config: engine, + ) + + with ( + patch("huggingface_hub.cached_assets_path", return_value=tmp_path), + patch.dict(sys.modules, {"sherpa_onnx": sherpa}), + patch("httpx.stream", side_effect=AssertionError("network is not expected")), + ): + diarizer = load_speaker_diarizer(speakers=None, threads=0) + + config = captured["config"] + assert config.clustering.num_clusters == -1 + assert config.clustering.threshold == 0.89 + assert config.min_duration_on == 0.3 + assert config.min_duration_off == 0.5 + assert not hasattr(config.segmentation, "num_threads") + assert not hasattr(config.embedding, "num_threads") + assert isinstance(diarizer, SpeakerDiarizer) + + +def test_load_speaker_diarizer_downloads_and_verifies_missing_models( + tmp_path, monkeypatch +): + segmentation_bytes = b"downloaded segmentation" + embedding_bytes = b"downloaded embedding" + archive_buffer = io.BytesIO() + with tarfile.open(fileobj=archive_buffer, mode="w:bz2") as archive: + member = tarfile.TarInfo("sherpa-onnx-pyannote-segmentation-3-0/model.onnx") + member.size = len(segmentation_bytes) + archive.addfile(member, io.BytesIO(segmentation_bytes)) + + monkeypatch.setattr( + "local_transcriber.speaker_diarizer._SEGMENTATION_SHA256", + hashlib.sha256(segmentation_bytes).hexdigest(), + ) + monkeypatch.setattr( + "local_transcriber.speaker_diarizer._EMBEDDING_SHA256", + hashlib.sha256(embedding_bytes).hexdigest(), + ) + + class FakeResponse: + def __init__(self, content): + self.content = content + + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def raise_for_status(self): + return None + + def iter_bytes(self): + yield self.content + + requested_urls = [] + + def fake_stream(method, url, **kwargs): + requested_urls.append(url) + content = ( + archive_buffer.getvalue() if "segmentation" in url else embedding_bytes + ) + return FakeResponse(content) + + config = SimpleNamespace(validate=lambda: True) + engine = SimpleNamespace(sample_rate=16_000) + sherpa = SimpleNamespace( + OfflineSpeakerSegmentationPyannoteModelConfig=lambda **kwargs: SimpleNamespace( + **kwargs + ), + OfflineSpeakerSegmentationModelConfig=lambda **kwargs: SimpleNamespace( + **kwargs + ), + SpeakerEmbeddingExtractorConfig=lambda **kwargs: SimpleNamespace(**kwargs), + FastClusteringConfig=lambda **kwargs: SimpleNamespace(**kwargs), + OfflineSpeakerDiarizationConfig=lambda **kwargs: config, + OfflineSpeakerDiarization=lambda actual_config: engine, + ) + + with ( + patch("huggingface_hub.cached_assets_path", return_value=tmp_path), + patch.dict(sys.modules, {"sherpa_onnx": sherpa}), + patch("httpx.stream", side_effect=fake_stream), + ): + load_speaker_diarizer(speakers=2, threads=4) + + assert ( + tmp_path / "pyannote-segmentation-3.0.onnx" + ).read_bytes() == segmentation_bytes + assert ( + tmp_path / "wespeaker_en_voxceleb_resnet34_LM.onnx" + ).read_bytes() == embedding_bytes + assert len(requested_urls) == 2 + assert list(tmp_path.glob("*.tmp")) == [] + + +def test_load_speaker_diarizer_keeps_corrupt_cache_when_download_is_invalid( + tmp_path, monkeypatch +): + segmentation = tmp_path / "pyannote-segmentation-3.0.onnx" + segmentation.write_bytes(b"existing corrupt model") + monkeypatch.setattr( + "local_transcriber.speaker_diarizer._SEGMENTATION_SHA256", + hashlib.sha256(b"expected model").hexdigest(), + ) + + archive_buffer = io.BytesIO() + with tarfile.open(fileobj=archive_buffer, mode="w:bz2") as archive: + payload = b"wrong downloaded model" + member = tarfile.TarInfo("sherpa-onnx-pyannote-segmentation-3-0/model.onnx") + member.size = len(payload) + archive.addfile(member, io.BytesIO(payload)) + + class FakeResponse: + def __enter__(self): + return self + + def __exit__(self, *args): + return False + + def raise_for_status(self): + return None + + def iter_bytes(self): + yield archive_buffer.getvalue() + + with ( + patch("huggingface_hub.cached_assets_path", return_value=tmp_path), + patch("httpx.stream", return_value=FakeResponse()), + pytest.raises(RuntimeError, match="Контрольная сумма"), + ): + load_speaker_diarizer(speakers=None) + + assert segmentation.read_bytes() == b"existing corrupt model" + assert list(tmp_path.glob("*.tmp")) == [] diff --git a/tests/test_transcriber.py b/tests/test_transcriber.py index e23d360..70d1f6d 100644 --- a/tests/test_transcriber.py +++ b/tests/test_transcriber.py @@ -11,7 +11,7 @@ from local_transcriber.transcriber import ( load_model, transcribe, ) - +from local_transcriber.types import WordTimestampsUnavailableError # === Helpers === @@ -314,7 +314,9 @@ def test_load_model_returns_backend_and_path(mock_get_backend): backend = _make_backend(model_path="/mock/model/path") mock_get_backend.return_value = backend - model, actual_device, returned_backend, model_path = load_model("tiny", "cpu", "int8") + model, actual_device, returned_backend, model_path = load_model( + "tiny", "cpu", "int8" + ) assert returned_backend is backend assert model_path == "/mock/model/path" @@ -389,7 +391,9 @@ def test_ensure_model_available_uses_cache_first(mock_snapshot_download, tmp_pat @patch("local_transcriber.backends.faster_whisper._validate_model_dir") @patch("local_transcriber.backends.faster_whisper.snapshot_download") -def test_ensure_model_available_downloads_on_cache_miss(mock_snapshot_download, mock_validate_model_dir): +def test_ensure_model_available_downloads_on_cache_miss( + mock_snapshot_download, mock_validate_model_dir +): from huggingface_hub.errors import LocalEntryNotFoundError mock_snapshot_download.side_effect = [ @@ -433,7 +437,9 @@ def test_ensure_model_available_rejects_unsupported_alias(): @patch("local_transcriber.backends.faster_whisper.snapshot_download") -def test_ensure_model_available_redownloads_incomplete_cache(mock_snapshot_download, tmp_path): +def test_ensure_model_available_redownloads_incomplete_cache( + mock_snapshot_download, tmp_path +): incomplete = tmp_path / "incomplete" incomplete.mkdir() (incomplete / "config.json").write_text("{}") @@ -489,7 +495,9 @@ def test_load_model_openvino_gpu_fallback_to_cpu(mock_get_backend): with pytest.warns(UserWarning, match="Переключение на CPU"): model, actual_device, backend, model_path = load_model( - "medium", "openvino-gpu", "fp16", + "medium", + "openvino-gpu", + "fp16", ) assert actual_device == "cpu" @@ -514,7 +522,9 @@ def test_load_model_openvino_cpu_fallback_to_cpu(mock_get_backend): with pytest.warns(UserWarning, match="Переключение на CPU"): model, actual_device, backend, model_path = load_model( - "medium", "openvino-cpu", "int8", + "medium", + "openvino-cpu", + "int8", ) assert actual_device == "cpu" @@ -555,6 +565,28 @@ def test_transcribe_file_openvino_gpu_midstream_fallback(mock_get_backend): assert tfr.model_path == "/mock/cpu/model" +@patch("local_transcriber.transcriber.get_backend") +def test_transcribe_file_does_not_fallback_for_missing_word_timestamps( + mock_get_backend, +): + ov_backend = _make_backend( + transcribe_error=WordTimestampsUnavailableError("нет таймкодов"), + ) + + with pytest.raises(WordTimestampsUnavailableError, match="нет таймкодов"): + _transcribe_file( + model=MagicMock(), + actual_device="openvino-gpu", + backend=ov_backend, + model_path="/mock/ov/model", + file_path=Path("test.mp3"), + model_name="medium", + compute_type="fp16", + ) + + mock_get_backend.assert_not_called() + + @patch("local_transcriber.transcriber.get_backend") def test_transcribe_file_midstream_fallback_preserves_cpu_threads(mock_get_backend): ov_backend = _make_backend( diff --git a/uv.lock b/uv.lock index 1e60fd4..36579d6 100644 --- a/uv.lock +++ b/uv.lock @@ -259,11 +259,13 @@ version = "0.1.0" source = { editable = "." } dependencies = [ { name = "faster-whisper" }, + { name = "httpx" }, { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'" }, { name = "onnx-asr", extra = ["cpu", "hub"] }, { name = "onnxruntime" }, { name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')" }, { name = "rich" }, + { name = "sherpa-onnx" }, { name = "socksio" }, { name = "typer" }, ] @@ -276,11 +278,13 @@ dev = [ [package.metadata] requires-dist = [ { name = "faster-whisper", specifier = ">=1.2.1,<2" }, + { name = "httpx", specifier = ">=0.28,<1" }, { name = "nvidia-cublas-cu12", marker = "platform_machine == 'x86_64' and sys_platform == 'linux'", specifier = ">=12.4,<13" }, { name = "onnx-asr", extras = ["cpu", "hub"], specifier = ">=0.12,<0.13" }, { name = "onnxruntime", specifier = ">=1.28,<2" }, { name = "openvino-genai", marker = "(platform_machine == 'AMD64' and sys_platform != 'darwin') or (platform_machine == 'x86_64' and sys_platform != 'darwin')", specifier = ">=2026.3.0.0,<2026.4" }, { name = "rich", specifier = ">=14.3.3,<15" }, + { name = "sherpa-onnx", specifier = ">=1.13.5,<2" }, { name = "socksio", specifier = ">=1.0.0,<2" }, { name = "typer", specifier = ">=0.24.1,<1" }, ] @@ -593,6 +597,32 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, ] +[[package]] +name = "sherpa-onnx" +version = "1.13.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/a1/8a7d8751bc71a0814f3b9332717909512a5ff919d07ed53e4baa861ef8c7/sherpa_onnx-1.13.5.tar.gz", hash = "sha256:14bebfe71365a2c678dd94cd08efa8e79df06318b17fe8e97b2e802a7881fd5a", size = 1037285, upload-time = "2026-08-11T08:17:31.817Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/61/6f/b70ae4c7e367e3d2dca99efc65152d22ad5d942619add19172d24267c14b/sherpa_onnx-1.13.5-cp313-cp313-linux_armv7l.whl", hash = "sha256:15a02d9d74143f336156cb8b4da826bc65c11daa28bd5c9099e9397b3660e7df", size = 11999210, upload-time = "2026-08-11T09:26:07.356Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ec/d471d042cc85c505515e3fbaed55223f54718a9ac3a8f228a8e1b81dab6d/sherpa_onnx-1.13.5-cp313-cp313-macosx_10_15_universal2.whl", hash = "sha256:371b0eb51caa12f5f3e9c327c440a4aee6f77c163c89666469844eaefb7d5351", size = 4439247, upload-time = "2026-08-11T08:30:33.777Z" }, + { url = "https://files.pythonhosted.org/packages/28/58/2f60eb29db5f41c01ad844be34ed1adfdecce9493b12cc20bbdc6b6ad5a8/sherpa_onnx-1.13.5-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:2bdfefa5d16896c90d1685bde43682818e70b95e134dccc95b91dc0819adde1c", size = 2340028, upload-time = "2026-08-11T07:18:47.753Z" }, + { url = "https://files.pythonhosted.org/packages/6e/71/25f067216edcde5e78cf0d9a7ac053a2b3b17bdf131acac69447daa23356/sherpa_onnx-1.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:05a3effd8ea5ee78735bc28909c0fce26d9f23bf61db663e6f75f04ec0247ba4", size = 2137522, upload-time = "2026-08-11T07:43:35.186Z" }, + { url = "https://files.pythonhosted.org/packages/5a/77/b36cd40906bef1b3a6593200587eea5c4e7c1e94d29c4fd88a876e1cd6ca/sherpa_onnx-1.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5a6cc5ac96043670faa0f5c0e56310315a4600cf7b764fee014e7dd75fda00f", size = 4167203, upload-time = "2026-08-11T07:48:34.601Z" }, + { url = "https://files.pythonhosted.org/packages/9a/3b/817cdb5770f8beafd22a04a8eda0c8253a6d07a518a09ce982d6e4152e3c/sherpa_onnx-1.13.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:cb7c1f5ca5455b3ec008f3e09438bca6038d22b4fe5d64adb5c09bb6b84eeb82", size = 4392107, upload-time = "2026-08-11T08:03:49.714Z" }, + { url = "https://files.pythonhosted.org/packages/55/75/b8494e30923a18db144e0035851f0364c32e63d776b62f3d2667f717f499/sherpa_onnx-1.13.5-cp313-cp313-win32.whl", hash = "sha256:80d9ec3ce2fe8b566285ef92fa2e72b4fa0aee10f73290ac74219fe415806980", size = 1961338, upload-time = "2026-08-11T08:35:46.591Z" }, + { url = "https://files.pythonhosted.org/packages/5e/28/e8644784a897e4ba6cdc7a635d821107ce31567c90e1835a0c008ba2a322/sherpa_onnx-1.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:440174433b5d3f855a800d757e66812e998ec9715f5f42690afb27a6ff456586", size = 2279915, upload-time = "2026-08-11T08:51:50.171Z" }, + { url = "https://files.pythonhosted.org/packages/02/99/1aa369391f62a2753b5d97b0b9ecd99475410c51eada10f7079628a58c4f/sherpa_onnx-1.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:38de7f31754dfa7212beb4cc773ba05f78d028adb4c0e008920128388657f778", size = 2239783, upload-time = "2026-08-11T07:46:44.679Z" }, + { url = "https://files.pythonhosted.org/packages/21/a3/ac238819cca1955b6b80ae802671cc7e4d688c673fb98001c21111922045/sherpa_onnx-1.13.5-cp314-cp314-linux_armv7l.whl", hash = "sha256:a224466e750a435089311d4c763617a4d83478a2f958c311b473ea59f9cce3ae", size = 11997852, upload-time = "2026-08-11T08:54:11.47Z" }, + { url = "https://files.pythonhosted.org/packages/02/82/3f5b50f84b9a8571bdfe9939983a8e6dff75110961305315887bbc83f626/sherpa_onnx-1.13.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:1a4ea5fa45caf31093ff832ab4d198e097edbeeb2244a0d4f958e96bf888110a", size = 4442303, upload-time = "2026-08-11T07:36:28.234Z" }, + { url = "https://files.pythonhosted.org/packages/fe/90/6d3a1112bc4496b0b918d3a807036912f1f34fb4a4dc506f0cabf398c4a9/sherpa_onnx-1.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:7582ca42ed2d80bacc984b46904cbdd058ec2b6149a8c8996d7263217a4cc963", size = 2340527, upload-time = "2026-08-11T07:54:35.482Z" }, + { url = "https://files.pythonhosted.org/packages/84/9d/0cb152e3fd1aa5787ade99c4863cd8cf80d800b879e8ff47f8f99412af78/sherpa_onnx-1.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97471fe025fc1d655a1df2f4ffb5d3fe843c269f2016d5e1cca4b0d168a49169", size = 2140247, upload-time = "2026-08-11T08:23:33.405Z" }, + { url = "https://files.pythonhosted.org/packages/e6/84/38531f16e2c2a0b077257b25c34c02a1c8f7f1f833d5bb61e2b2f510e260/sherpa_onnx-1.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2a51751f6c925c405ef6ebcb2f7c44794810da79d0f2743fe57ee3daa5bc4df5", size = 4172385, upload-time = "2026-08-11T08:00:34.952Z" }, + { url = "https://files.pythonhosted.org/packages/45/91/56a439945713a5cf49080a833fbd0953936fcd50c651c17afe64478c9922/sherpa_onnx-1.13.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:eb4d580d959e16c42dbd404cef0f5764ed1c024edf48d3edc43b5e2f1c96f069", size = 4393503, upload-time = "2026-08-11T07:38:11.97Z" }, + { url = "https://files.pythonhosted.org/packages/d5/27/cbdfa8bdcf618b28ec5cf383b073be851ff3178ea06d6f1a0fcb3c7532e8/sherpa_onnx-1.13.5-cp314-cp314-win32.whl", hash = "sha256:64cb492cf8a9751f632685c6f169fef7e5a2f449efb9923bc21b6e783a911dba", size = 2000807, upload-time = "2026-08-11T08:57:11.235Z" }, + { url = "https://files.pythonhosted.org/packages/d6/c2/162840897a528a528bc0f76cf3516d4e3ac1445db9089caf7733df756cfc/sherpa_onnx-1.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:001e1c3440fe335a589c9283a68105f379ffb63f2d1d7c2878e99a5d72eaf5af", size = 2345302, upload-time = "2026-08-11T08:11:46.752Z" }, + { url = "https://files.pythonhosted.org/packages/28/5e/9a9c63e1dcd646c5f21df6d16e6770fedd956f8a54241f3382343c6289af/sherpa_onnx-1.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:a7e97d8457a51725ffa3fcb44cb1d57bfdb780bc390a9460a65e05bf3d2851b4", size = 2303336, upload-time = "2026-08-11T07:33:25.917Z" }, +] + [[package]] name = "socksio" version = "1.0.0"