Files
vidconf/workers/transcription/phrases.py

165 lines
7.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Реконструкция фраз из сегментов Whisper по алгоритму ТЗ §1.3.
Определение фразы (ТЗ §1.3): фрагмент монолога одного участника между
монологами других участников, либо между `t_start` сеанса и первым
монологом, либо между последним монологом и `t_end`.
Алгоритм:
1. Сегменты всех треков (участников) сливаются в единый таймлайн с учётом
смещения каждого трека (`track_offsets`) и сортируются по `start`.
2. Идём по таймлайну и группируем подряд идущие сегменты одного и того же
участника в одну фразу; смена участника — граница фразы.
3. Короткая вставка другого участника короче `INTERJECTION_THRESHOLD_S`
(например, «угу») не прерывает текущую фразу, но сама сохраняется
отдельной фразой.
4. Тишина (пауза) между сегментами одного участника без чужой речи фразу
не рвёт.
5. Перекрытия речи двух участников длиннее порога — обе фразы сохраняются
с пересекающимися интервалами (без «доминирующего спикера»).
Функция `build_phrases` — чистая: работает в секундах, относительных
`t_start` сеанса; конвертацию в абсолютное время UTC делает вызывающий код
(оркестратор пайплайна).
"""
import uuid
from dataclasses import dataclass, field
from core.plugins.transcriber import Segment
INTERJECTION_THRESHOLD_S: float = 1.5
"""Порог длительности (в секундах) короткой вставки другого спикера,
которая не прерывает текущую фразу, но фиксируется как отдельная фраза."""
@dataclass(frozen=True, slots=True)
class SpeakerSegment:
"""Сегмент речи одного участника на объединённом таймлайне сеанса."""
participant_id: uuid.UUID
start: float # секунды от t_start сеанса
end: float
text: str
@dataclass(frozen=True, slots=True)
class PhraseDraft:
"""Черновик реконструированной фразы — без привязки к сеансу/БД."""
participant_id: uuid.UUID
start: float
end: float
text: str
@dataclass(slots=True)
class _OpenPhrase:
"""Мутируемое состояние фразы, которая ещё собирается в процессе обхода таймлайна."""
participant_id: uuid.UUID
start: float
end: float
texts: list[str] = field(default_factory=list)
def to_draft(self) -> PhraseDraft:
"""Зафиксировать накопленное состояние как неизменяемый `PhraseDraft`."""
return PhraseDraft(
participant_id=self.participant_id,
start=self.start,
end=self.end,
text=" ".join(text for text in self.texts if text),
)
def _merge_timeline(
segments_by_participant: dict[uuid.UUID, list[Segment]],
track_offsets: dict[uuid.UUID, float],
) -> list[SpeakerSegment]:
"""Слить сегменты всех участников в единый таймлайн, отсортированный по `start`.
Смещение (`track_offsets`) — время старта записи трека участника
относительно `t_start` сеанса; при отсутствии участника в словаре
смещений используется 0.0.
"""
timeline: list[SpeakerSegment] = []
for participant_id, segments in segments_by_participant.items():
offset = track_offsets.get(participant_id, 0.0)
for segment in segments:
timeline.append(
SpeakerSegment(
participant_id=participant_id,
start=segment.start + offset,
end=segment.end + offset,
text=segment.text,
)
)
timeline.sort(key=lambda s: (s.start, s.end))
return timeline
def build_phrases(
segments_by_participant: dict[uuid.UUID, list[Segment]],
track_offsets: dict[uuid.UUID, float],
) -> list[PhraseDraft]:
"""Реконструировать фразы по объединённому таймлайну сегментов участников.
Аргументы:
segments_by_participant: сегменты Whisper каждого участника,
относительные началу его собственного трека.
track_offsets: смещение (в секундах от `t_start` сеанса) начала
записи каждого трека.
Возвращает список `PhraseDraft`, отсортированный по `start`.
"""
timeline = _merge_timeline(segments_by_participant, track_offsets)
if not timeline:
return []
finished: list[PhraseDraft] = []
current: _OpenPhrase | None = None
for segment in timeline:
if current is None:
current = _OpenPhrase(
participant_id=segment.participant_id,
start=segment.start,
end=segment.end,
texts=[segment.text],
)
continue
if segment.participant_id == current.participant_id:
# тот же спикер: продолжаем фразу, тишина между сегментами её не рвёт
current.end = max(current.end, segment.end)
current.texts.append(segment.text)
continue
duration = segment.end - segment.start
if duration < INTERJECTION_THRESHOLD_S:
# короткая вставка чужого спикера: не прерывает текущую фразу,
# но фиксируется как отдельная фраза
finished.append(
PhraseDraft(
participant_id=segment.participant_id,
start=segment.start,
end=segment.end,
text=segment.text,
)
)
continue
# реальная смена спикера (или длинное перекрытие) — граница фразы
finished.append(current.to_draft())
current = _OpenPhrase(
participant_id=segment.participant_id,
start=segment.start,
end=segment.end,
texts=[segment.text],
)
if current is not None:
finished.append(current.to_draft())
finished.sort(key=lambda p: (p.start, p.end))
return finished