Files
vidconf/docs/plugins/transcriber.md

390 lines
16 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.
# Плагин Transcriber — транскрибация аудио в текст
## Обзор
VidConf использует **Strategy pattern** для подключаемых реализаций транскрибации речи. Это позволяет менять провайдеров (faster-whisper на CPU, OpenAI Whisper, Vosk и т.д.) через конфигурацию без изменения ядра.
## Контракт Transcriber
### Интерфейс
Все реализации наследуют абстрактный класс `Transcriber` из `backend/core/plugins/transcriber.py`:
```python
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import ClassVar
@dataclass(frozen=True, slots=True)
class Segment:
"""Один транскрибированный сегмент трека."""
start: float # Начало в секундах от старта файла
end: float # Конец в секундах от старта файла
text: str # Распознанный текст
class Transcriber(ABC):
"""Интерфейс Strategy для реализаций преобразования речи в текст."""
provider: ClassVar[str] # Уникальный идентификатор провайдера
@abstractmethod
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
"""Транскрибировать аудиофайл в список сегментов."""
...
```
### Сигнатура метода transcribe
| Параметр | Тип | Описание |
|----------|-----|---------|
| `audio_path` | `str` | Абсолютный путь к аудиофайлу на диске |
| `language` | `str` | Код языка (ISO 639-1), дефолт `"ru"` |
| **Возврат** | `list[Segment]` | Упорядоченный список сегментов, отсортированный по `start` |
**Требования:**
- Если файл не найден → исключение `FileNotFoundError`
- Сегменты должны быть отсортированы по `start` в возрастающем порядке
- Дублирующиеся/перекрывающиеся сегменты допустимы (алгоритм фраз их обработает)
## Реализация по умолчанию: FasterWhisperCPU
### Описание
`FasterWhisperCPU` — оптимизированная реализация faster-whisper (CTranslate2-бэкэнд) для транскрибации на CPU с int8-квантизацией. Она встроена в проект как реализация по умолчанию.
**Файл:** `backend/core/plugins/faster_whisper.py`
### Параметры конструктора
```python
@register_transcriber
class FasterWhisperCPU(Transcriber):
provider: ClassVar[str] = "faster_whisper_cpu"
def __init__(
self,
model: str | None = None,
language: str = "ru",
download_root: str | None = None,
) -> None:
"""Инициализировать транскрибер.
Args:
model: Имя модели Whisper (small/base/medium/etc);
дефолт 'small' (оптимум для CPU).
language: Код языка (ISO 639-1), дефолт 'ru'.
download_root: Папка для кэша загруженных моделей;
дефолт ~/.cache/huggingface/hub.
"""
```
| Параметр | Тип | Дефолт | Описание |
|----------|-----|--------|---------|
| `model` | `str` | `"small"` | Модель Whisper: `tiny`, `base`, `small`, `medium`, `large` |
| `language` | `str` | `"ru"` | Язык распознавания (ISO 639-1) |
| `download_root` | `str` | `~/.cache/` | Папка для кэша моделей |
### Особенности
#### VAD (Voice Activity Detection)
FasterWhisperCPU включает встроенный **Silero VAD** для автоматического разбиения аудио на речевые сегменты и подавления молчания. Это улучшает качество транскрибации и снижает галлюцинации Whisper на тишине/шуме.
- **Параметр:** `vad_filter=True`
- **Порог молчания:** `vad_min_silence_duration_ms=500` (0.5 сек)
#### Отбрасывание коротких сегментов
Whisper часто халлюцинирует на очень коротких сегментах (шум, клики, тишина). FasterWhisperCPU автоматически отбрасывает сегменты, короче **0.3 секунды**:
```python
MIN_SEGMENT_DURATION_S = 0.3
# Фильтрация в transcribe()
return [
Segment(start=segment.start, end=segment.end, text=segment.text)
for segment in raw_segments
if (segment.end - segment.start) >= MIN_SEGMENT_DURATION_S
]
```
#### Ленивый импорт
Зависимость `faster-whisper` импортируется лениво — только при первом вызове `transcribe()`. Это позволяет API-процессу, который не использует транскрибацию, избежать загрузки тяжёлой библиотеки в память.
```python
def _get_model(self) -> "WhisperModel":
if FasterWhisperCPU._model is None:
from faster_whisper import WhisperModel # Импорт здесь, не в начале файла
FasterWhisperCPU._model = WhisperModel(...)
return FasterWhisperCPU._model
```
#### Синглтон модели на процесс
Модель Whisper создаётся один раз при первом вызове `transcribe()` и переиспользуется всеми последующими вызовами в пределах одного процесса воркера. Это снижает нагрузку на память и CPU (загрузка модели — дорогая операция).
> **Примечание:** Celery-воркер транскрибации запускается с флагом `--pool=solo --concurrency=1`, поэтому гонок за синглтоном не бывает.
### Конфигурация
В `config/plugins.yaml`:
```yaml
transcriber:
enabled: true
provider: "faster_whisper_cpu"
model: "small"
language: "ru"
options:
download_root: "/models/whisper"
```
Переменные окружения (если нужны):
```bash
# Опционально: переопределить папку кэша моделей
export HF_HOME=/models
```
### Производительность
- **Модель small:** ~8 минут аудио за 1 минуту на современном CPU (зависит от CPU)
- **Память:** ~1.5 ГБ на процесс (с моделью и бэкэндом)
- **Формат входа:** WAV, MP3, OGG, FLAC, M4A и др. (поддерживает ffmpeg-compatible форматы)
## Добавление нового Transcriber
### Пошаговая инструкция
#### Шаг 1: Создать файл реализации
Создайте новый файл в `backend/core/plugins/`, например `my_transcriber.py`:
```python
"""Плагин Transcriber на основе MyService."""
from typing import ClassVar
from core.plugins.factory import register_transcriber
from core.plugins.transcriber import Segment, Transcriber
@register_transcriber
class MyTranscriber(Transcriber):
"""Транскрибер, использующий MyService API."""
provider: ClassVar[str] = "my_service"
def __init__(
self,
model: str | None = None,
language: str = "ru",
api_key: str | None = None,
**options,
) -> None:
"""Инициализировать транскрибер.
Args:
model: Имя модели (опционально).
language: Язык распознавания.
api_key: API-ключ для сервиса.
**options: Дополнительные параметры из config/plugins.yaml.
"""
self.model = model or "default-model"
self.language = language
self.api_key = api_key or os.getenv("MY_SERVICE_API_KEY")
self.options = options
def transcribe(self, audio_path: str, language: str = "ru") -> list[Segment]:
"""Транскрибировать аудиофайл через MyService API.
Args:
audio_path: Путь к аудиофайлу на диске.
language: Язык распознавания (может переопределить конструктор).
Returns:
Упорядоченный список сегментов, отсортированный по start.
"""
# 1. Прочитать аудиофайл
with open(audio_path, "rb") as f:
audio_data = f.read()
# 2. Отправить на API (пример)
response = requests.post(
"https://api.myservice.com/transcribe",
files={"audio": audio_data},
json={"language": language, "model": self.model},
headers={"Authorization": f"Bearer {self.api_key}"},
)
response.raise_for_status()
# 3. Распарсить ответ в list[Segment]
result = response.json()
segments = [
Segment(
start=float(item["start"]),
end=float(item["end"]),
text=item["text"],
)
for item in result.get("segments", [])
]
# 4. Отсортировать по start (важно!)
segments.sort(key=lambda s: s.start)
return segments
```
**Важные правила:**
- Класс **должен** наследовать `Transcriber`
- Класс **должен** иметь декоратор `@register_transcriber` (регистрирует в реестре)
- Класс-переменная `provider: ClassVar[str]` **должна быть** уникальным идентификатором
- Конструктор **должен** принимать `model`, `language` и `**options`
- Метод `transcribe()` **должен** возвращать сегменты, отсортированные по `start`
#### Шаг 2: Импортировать в `__init__.py`
Добавьте импорт в `backend/core/plugins/__init__.py`:
```python
"""Пакет плагинов Transcriber/Summarizer."""
from core.plugins import faster_whisper as faster_whisper # noqa: F401
from core.plugins import my_transcriber as my_transcriber # noqa: F401 # ДОБАВИТЬ
from core.plugins import null as null # noqa: F401
```
> **Примечание:** `noqa: F401` подавляет предупреждение о неиспользуемом импорте — он нужен для побочного эффекта (регистрация). Используйте `as` для явности.
#### Шаг 3: Обновить конфигурацию
Отредактируйте `config/plugins.yaml`:
```yaml
transcriber:
enabled: true
provider: "my_service" # Совпадает с MyTranscriber.provider
model: "default-model"
language: "ru"
options:
api_key: "${MY_SERVICE_API_KEY}" # Переменная окружения
# Дополнительные параметры, специфичные для вашего сервиса
timeout_seconds: 300
retry_count: 3
```
#### Шаг 4: Добавить переменные окружения
Отредактируйте `.env` (или `.env.example`):
```bash
# MyService API
MY_SERVICE_API_KEY=sk-...
```
#### Шаг 5: Написать тесты
Добавьте тесты в `backend/tests/test_plugins_factory.py` или новый `test_my_transcriber.py`:
```python
import pytest
from core.plugins.factory import create_transcriber
from core.plugins.config import TranscriberConfig
def test_my_transcriber_registered():
"""Проверить регистрацию плагина."""
cfg = TranscriberConfig(provider="my_service", model="test")
transcriber = create_transcriber(cfg)
assert transcriber.provider == "my_service"
assert transcriber.model == "test"
def test_my_transcriber_transcribe(tmp_path):
"""Тест транскрибации с моком API."""
# Создать тестовый WAV-файл
audio_file = tmp_path / "test.wav"
# ... создать корректный WAV-файл ...
cfg = TranscriberConfig(provider="my_service", model="test")
transcriber = create_transcriber(cfg)
# Вызвать transcribe
segments = transcriber.transcribe(str(audio_file), language="ru")
# Проверить результат
assert len(segments) > 0
assert all(s.start < s.end for s in segments)
assert segments == sorted(segments, key=lambda s: s.start)
```
#### Шаг 6: Протестировать локально
```bash
cd backend
# Убедиться, что конфиг загружается
uv run python -c "
from core.plugins.factory import _TRANSCRIBERS
print('Зарегистрированные транскрибаторы:', list(_TRANSCRIBERS.keys()))
"
# Запустить тесты
uv run pytest tests/test_my_transcriber.py -v
# Запустить пайплайн с новым провайдером (интеграционный тест)
CELERY_ALWAYS_EAGER=True uv run pytest tests/test_build_phrases.py -v
```
### Отключение транскрибации
Если нужно полностью отключить модуль транскрибации, установите в `config/plugins.yaml`:
```yaml
transcriber:
enabled: false
provider: "null"
model: null
```
Поведение:
- Запись аудиотреков продолжает работать (egress в активном состоянии)
- При `room_finished` `run_pipeline()` завершится до инстанцирования плагина
- `pipeline_status` сеанса останется `"recording"` (не перейдёт в `"transcribing"`)
- Лог-сообщение: "transcriber отключён (enabled=false) — сеанс ... пропущен"
## Архитектурные решения
### Идемпотентный пайплайн пост-обработки
Пайплайн пост-обработки — идемпотентная машина состояний (см. `backend/models/session.py`):
```
recording → transcribing → summarizing → notified / failed
```
**run_pipeline()** диспетчер:
1. Проверяет текущий `pipeline_status` сеанса
2. Продолжает работу с последнего успешного шага
3. Отслеживает статус каждого трека (`session_audio_tracks.status`)
4. Коммитит результаты транскрибации в `session_audio_tracks.segments` (JSONB) после каждого трека
Это позволяет восстановиться после сбоя воркера без потери данных или дублирования.
### Атрибуция к участнику сеанса (ADR-002)
Фразы и аудиотреки атрибутированы не к `users`, а к `conference_participants` (участник сеанса). Это позволяет работать с гостями без `user_id`:
```python
# Получить спикера фразы
participant = session.query(ConferenceParticipant).get(phrase.participant_id)
user = participant.user # Может быть None для гостей
guest = participant.guest # Может быть None для пользователей
```
## Ссылки
- **ADR-002:** `docs/architecture/adr/002-phrase-attribution-session-participant.md`
- **Контракты плагинов:** `docs/plugins/contracts.md`
- **Модели:** `backend/models/audio_track.py`, `backend/models/phrase.py`
- **Пайплайн:** `workers/tasks/pipeline.py`
- **Реконструкция фраз:** `workers/transcription/phrases.py`
- **Тесты:** `backend/tests/test_plugins_factory.py`, `backend/tests/test_build_phrases.py`