Files

Модуль транскрибации — workers/transcription/

Пакет логики реконструкции фраз из сегментов Whisper по алгоритму ТЗ §1.3.

Структура

workers/transcription/
├── __init__.py
├── phrases.py          # Реконструкция фраз (чистая функция)
└── README.md           # Этот файл

Основной модуль: phrases.py

Функция build_phrases()

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.
    """

Алгоритм (ТЗ §1.3)

  1. Слияние в таймлайн: сегменты всех участников с применением смещений
  2. Сортировка: по времени начала (start)
  3. Группировка: подряд идущие сегменты одного участника → одна фраза
  4. Коротки вставки: если другой участник говорит < 1.5 сек:
    • НЕ прерывает текущую фразу
    • Но сохраняется как отдельная фраза
  5. Перекрытия: речь двух участников одновременно → обе фразы сохраняются
  6. Тишина: молчание между сегментами одного участника НЕ рвёт фразу

Типы данных

@dataclass(frozen=True, slots=True)
class Segment:
    """Выход faster-whisper."""
    start: float      # Секунды от начала аудиофайла
    end: float
    text: str

@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      # Секунды от t_start сеанса
    end: float
    text: str

Использование в пайплайне (workers/tasks/pipeline.py)

Контекст

После успешной транскрибации всех треков сеанса run_pipeline() вызывает build_phrases() для реконструкции:

async def run_pipeline_async(task, session_id, *, plugins_config=None):
    # ...
    # После транскрибации каждого трека
    
    # Собрать сегменты и смещения
    segments_by_participant, track_offsets = _collect_transcribed(tracks, session_record)
    
    # Реконструировать фразы
    phrases = build_phrases(segments_by_participant, track_offsets)
    
    # Вставить в БД (с преобразованием времени в UTC)
    for phrase in phrases:
        session.add(
            Phrase(
                participant_id=phrase.participant_id,
                session_id=session_id,
                data=phrase.text,
                t_start=session_record.t_start + timedelta(seconds=phrase.start),
                t_end=session_record.t_start + timedelta(seconds=phrase.end),
            )
        )

Смещение треков (track_offsets)

Если разные участники подключились в разные моменты:

  • Участник A: присоединился в 0 сек → смещение = 0.0
  • Участник B: присоединился в 30 сек → смещение = 30.0

Сегменты B's автоматически сдвигаются на 30 сек, что корректно отражает их место на общей шкале времени сеанса.

track_offsets = {
    participant_a_id: 0.0,      # Присоединился в начале
    participant_b_id: 30.0,     # Присоединился на 30-й секунде
}

Тестирование

Юнит-тесты (backend/tests/test_build_phrases.py)

Покрывают все сценарии алгоритма:

cd backend
uv run pytest tests/test_build_phrases.py -v

Тестовые кейсы

  • Один спикер: один участник говорит (no-op)
  • Два спикера по очереди: смена без перекрытия
  • Перекрытие речи: два спикера говорят одновременно
  • «Угу» (interjection): короткое (<1.5 сек) высказывание другого не прерывает текущего
  • Тишина: пауза в речи одного спикера не рвёт фразу
  • Пустой трек: сегментов нет → пустой результат

Интеграционный тест диспетчера пайплайна

backend/tests/test_pipeline.py покрывает run_pipeline() целиком (реконструкция фраз для пользователя и гостя, идемпотентность при обрыве посреди прогона, no-op при выключенном транскрибаторе, retry для ещё записывающихся треков, обработка зависших/провалившихся треков) с транскрибатором, замоканным через monkeypatch:

cd backend
uv run pytest tests/test_pipeline.py -v

Параметры алгоритма

INTERJECTION_THRESHOLD_S

INTERJECTION_THRESHOLD_S = 1.5

Максимальная длительность высказывания другого участника, которое не прерывает текущую фразу. Примеры:

  • < 1.5 сек: "угу", "ага", вздох — сохраняется отдельной фразой, но не прерывает основного спикера
  • ≥ 1.5 сек: полноценный ответ или реплика → граница фразы

Может быть перенастроена, но требует пересчета тестов.

Константы faster-whisper (backend/core/plugins/faster_whisper.py)

Интеграция с транскрибатором:

Константа Значение Описание
MIN_SEGMENT_DURATION_S 0.3 Минимальная длительность сегмента (отбрасываются галлюцинации Whisper)
VAD_MIN_SILENCE_DURATION_MS 500 Порог Silero VAD для разбиения на речевые куски

Примеры

Пример 1: два участника по очереди

Входные сегменты:

  • Участник A (трек 0): [Segment(0, 10, "Привет"), Segment(15, 25, "как дела")]
  • Участник B (трек 1): [Segment(10, 15, "Привет")]

Смещения:

  • A: 0.0 (присоединился в начале)
  • B: 0.0 (присоединился в начале)

Таймлайн после слияния (с сортировкой по start):

  1. A: (0, 10, "Привет")
  2. B: (10, 15, "Привет")
  3. A: (15, 25, "как дела")

Результат (3 фразы):

PhraseDraft(participant_id=A, start=0, end=10, text="Привет")
PhraseDraft(participant_id=B, start=10, end=15, text="Привет")
PhraseDraft(participant_id=A, start=15, end=25, text="как дела")

Пример 2: участник присоединился позже

Входные сегменты:

  • Участник A: [Segment(0, 20, "Начну без вас"), Segment(30, 40, "итак")]
  • Участник B: [Segment(5, 15, "А я здесь")]

Смещения:

  • A: 0.0
  • B: 5.0 (присоединился на 5-й секунде)

Таймлайн:

  1. A: (0, 20, "Начну без вас") [перекрытие с B от 5 до 15]
  2. B: (10, 20, "А я здесь") [смещение 5 + исходное 5-15]
  3. A: (30, 40, "итак")

Результат (3 фразы с перекрытием):

PhraseDraft(participant_id=A, start=0, end=20, text="Начну без вас")
PhraseDraft(participant_id=B, start=10, end=20, text="А я здесь")
PhraseDraft(participant_id=A, start=30, end=40, text="итак")

Архитектурные решения

Чистая функция (без побочных эффектов)

build_phrases() не обращается к БД, не логирует, не создаёт файлы. Она:

  • Принимает данные в памяти (dict/list)
  • Возвращает новый список
  • Переиспользуется в тестах и в основном пайплайне

Это упрощает тестирование и делает логику прозрачной.

Работа в секундах, не в datetime

Входные смещения и выходные start/end — в секундах (float) от начала сеанса. Конвертация в UTC datetime происходит в run_pipeline():

t_start_utc = session_record.t_start + timedelta(seconds=phrase.start)

Это отделяет логику фраз от логики временных зон и БД.

Immutable-типы данных (frozen dataclasses)

Segment, PhraseDraft, SpeakerSegment неизменяемые (frozen=True), что предотвращает случайные мутации и упрощает тестирование.

Ссылки

  • Пайплайн (диспетчер): workers/tasks/pipeline.py
  • Контракт Transcriber: backend/core/plugins/transcriber.py
  • Реализация FasterWhisper: backend/core/plugins/faster_whisper.py
  • Модели БД: backend/models/phrase.py, backend/models/audio_track.py
  • ADR-002 (атрибуция): docs/architecture/adr/002-phrase-attribution-session-participant.md
  • Плагины (общее): docs/plugins/transcriber.md