Files

255 lines
11 KiB
Markdown
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.
# Модуль транскрибации — 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`