Первоначальная версия VidConf
This commit is contained in:
254
workers/transcription/README.md
Normal file
254
workers/transcription/README.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Модуль транскрибации — 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`
|
||||
Reference in New Issue
Block a user