# Модуль транскрибации — workers/transcription/ Пакет логики реконструкции фраз из сегментов Whisper по алгоритму ТЗ §1.3. ## Структура ``` workers/transcription/ ├── __init__.py ├── phrases.py # Реконструкция фраз (чистая функция) └── README.md # Этот файл ``` ## Основной модуль: phrases.py ### Функция build_phrases() ```python 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. **Тишина:** молчание между сегментами одного участника НЕ рвёт фразу ### Типы данных ```python @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()` для реконструкции: ```python 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 сек, что корректно отражает их место на общей шкале времени сеанса. ```python track_offsets = { participant_a_id: 0.0, # Присоединился в начале participant_b_id: 30.0, # Присоединился на 30-й секунде } ``` ## Тестирование ### Юнит-тесты (backend/tests/test_build_phrases.py) Покрывают все сценарии алгоритма: ```bash 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: ```bash cd backend uv run pytest tests/test_pipeline.py -v ``` ## Параметры алгоритма ### INTERJECTION_THRESHOLD_S ```python 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()`: ```python 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`