Первоначальная версия VidConf

This commit is contained in:
2026-07-23 01:04:01 +03:00
commit 896455381a
335 changed files with 61527 additions and 0 deletions

View File

@@ -0,0 +1,164 @@
"""Реконструкция фраз из сегментов 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