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

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

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

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

No files matched your search

+131
View File
@@ -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
]