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:
1 parent
63ba41d906
commit
726718197f
23 files changed
+2522
-187
No files matched your search
@@ -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 "
|
||||
"для пословных таймкодов"
|
||||
)
|
||||
Reference in new issue
Block a user