feat(diarization): добавлено разделение транскрипта по говорящим
Зачем: - локальным транскриптам нужна структура реплик для конспектов и протоколов. Что: - добавлены пословные таймкоды для всех ASR-бэкендов и сведение с Sherpa-ONNX. - реализованы CLI-флаги, деградация без потери ASR и speaker Markdown. - добавлены проверяемый кеш моделей, тесты и документация. Проверка: - `pytest` — 283 passed, 1 skipped. - `pyright` — 0 errors. - Ruff и `git diff --check` — без ошибок. - выполнены три контрольных прогона на реальных записях.
This commit is contained in:
@@ -16,6 +16,11 @@ class Backend(Protocol):
|
||||
наследование не требуется.
|
||||
"""
|
||||
|
||||
@property
|
||||
def word_timestamps_available(self) -> bool:
|
||||
"""Гарантирует ли выбранный backend/model пословные таймкоды."""
|
||||
...
|
||||
|
||||
def ensure_model_available(
|
||||
self,
|
||||
model_name: str,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 "
|
||||
"для пословных таймкодов"
|
||||
)
|
||||
|
||||
+276
-47
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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"):
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user