# Whisper Diarization Stack

Этот документ описывает рабочий stack для `/whisper-diarization`, фактический обмен данными между сервисами и проблемы, которые проявились в production при запуске pyannote diarization.

## Что сейчас считается рабочей схемой

- browser открывает `/whisper-diarization`
- `src/webui/server.py` принимает upload и прямо в том же процессе запускает `FileJobWorkerService`
- перед стартом job на `/whisper-diarization` сервер делает submit-time `GET DIARIZATION_HEALTH_URL`
- `src/stt_whisper_to_nats.py` нормализует медиа в `normalized.wav`, режет его на chunk-ы и отправляет chunk-ы в `LINTO_HTTP_URL`
- при healthy diarization backend тот же runner параллельно отправляет компактный audio-only artifact в `DIARIZATION_HTTP_URL`; в `auto` режиме AAC-дорожка из MP4/M4A копируется без повторного encode
- при unhealthy diarization backend job сохраняет `requested_diarization=true`, но стартует с `run_diarization=false` и дружелюбной fallback reason
- `src/pyannote_diarization_server.py` выполняет speaker diarization и публикует real progress по NATS
- browser получает:
  - file job progress/result из `inference.whisper.file.event.<job_id>`
  - diarization progress/result из `inference.whisper.file.diar_text.session.<session_id>`, только если `run_diarization=true`
  - HTTP snapshot и обычные file state event'ы с durable полями `requested_diarization`, `run_diarization`, `diarization_backend_status`, `diarization_fallback_reason`

Текущий production deploy:

- ASR (`linto_stt_whisper_http`) работает на GPU
- stack config для diarization вынесен в `diarization-models.drs`
- `pyannote_diarization_shared` оставлен для совместимого shared MIG режима, а `pyannote_diarization_isolated` использует отдельный MIG slice
- image rollout для `pyannote_diarization` отделён от остальных `voice-chat/*` runtime images через `PYANNOTE_DIARIZATION_IMAGE_TAG`
- progress speaker separation приходит двумя слоями:
  - coarse stage events из `stt_whisper_to_nats`
  - hook-based real progress из `pyannote_diarization`
- если backend отвечает `404/5xx` или недоступен по transport, UI больше не показывает сырой ingress error, а деградирует в transcript-only на той же странице

## Архитектурная схема

```mermaid
flowchart LR
    Browser["Browser /whisper-diarization"] -->|POST file-transcribe| WebUI["py_faster_whisper WebUI"]
    WebUI -->|GET /healthz| Health["pyannote_diarization /healthz"]
    WebUI -->|start_job(payload)| Worker["in-process FileJobWorkerService"]
    Worker -->|normalized.wav chunks| LinTO["linto_stt_whisper_http /transcribe"]
    Worker -->|audio artifact + job_id/session_id| Pyannote["pyannote_diarization /diarize"]
    Worker -->|job events| NATS["NATS inference.*"]
    LinTO -->|chunk JSON| Worker
    Pyannote -->|final diarization JSON| Worker
    Pyannote -->|diar_progress / diar_text_progress| NATS
    Worker -->|job events + diar stage events| NATS
    NATS -->|file.event + diar_text.session| Browser
```

## Sequence Diagram

```mermaid
sequenceDiagram
    participant B as Browser
    participant W as WebUI
    participant H as Healthz
    participant F as File Runner
    participant N as NATS
    participant L as LinTO HTTP
    participant P as Pyannote Sidecar

    B->>W: POST /api/file-transcribe (file, session_id)
    W->>H: GET /healthz
    H-->>W: 200 ok or 503/404
    W->>F: start_job(requested_diarization, run_diarization, fallback_reason)
    F->>N: prepare_media / chunk_* progress
    F->>P: POST /diarize (only when run_diarization=true)
    F->>L: POST /transcribe chunk #1..N
    P->>N: diar_text_progress(step, completed, total, percent)
    L-->>F: chunk transcript JSON
    F->>N: job_done(text)
    P-->>F: diarization JSON
    F->>N: diar_done + diar_text_done
    N-->>B: live updates in status + speaker transcript
```

## UML State Diagram

```mermaid
stateDiagram-v2
    [*] --> Uploaded
    Uploaded --> BackendProbe
    BackendProbe --> PreparingAudio: diarization ready
    BackendProbe --> TranscriptOnlyFallback: diarization unavailable
    TranscriptOnlyFallback --> PreparingAudio
    PreparingAudio --> ChunkTranscription
    PreparingAudio --> DiarizationStarted: run_diarization=true
    ChunkTranscription --> ChunkTranscription: chunk_started / chunk_progress / chunk_done
    DiarizationStarted --> DiarizationRunning: diar_text_progress
    ChunkTranscription --> PlainTextReady: job_done
    PlainTextReady --> WaitingSpeakerText: diarization still running
    DiarizationRunning --> WaitingSpeakerText
    WaitingSpeakerText --> SpeakerTranscriptReady: diar_text_done
    DiarizationRunning --> DiarizationFailed: diar_text_error
    ChunkTranscription --> Failed: job_error
    SpeakerTranscriptReady --> [*]
    DiarizationFailed --> [*]
    Failed --> [*]
```

## Data Exchange

### 1. Browser -> WebUI

- endpoint: `POST /api/file-transcribe`
- fields:
  - `job_id`
  - `session_id`
  - `language`
  - `syntax_postprocess`
  - `file`
- intent:
  - `/whisper-trasncription` всегда даёт `requested_diarization=false`
  - `/whisper-diarization` всегда даёт `requested_diarization=true`

### 2. WebUI -> Pyannote Health Probe

- transport: HTTP `GET /healthz`
- host override: `DIARIZATION_HEALTH_HOST`
- outcome:
  - health ok -> `run_diarization=true`
  - backend unavailable / pipeline not ready -> `run_diarization=false`, сохраняется `diarization_fallback_reason`

### 3. WebUI -> Local File Runner

- transport: direct in-process call `file_runner.start_job(...)`
- durable flags:
  - `requested_diarization`
  - `run_diarization`
  - `diarization_backend_status`
  - `diarization_fallback_reason`
- worker копирует эти поля в каждый `file.event.<job_id>` / `file.session.<session_id>` payload, поэтому browser и snapshot читают уже готовый state без дополнительных inference rules

### 4. Worker -> LinTO

- transport: HTTP `POST /transcribe`
- unit of work: one normalized audio chunk
- result: JSON with `text` and `words`

### 5. Worker -> Pyannote Sidecar

- transport: HTTP `POST /diarize`
- file: compact audio-only artifact; `auto` may copy source AAC only while it stays under the upload guard, and `normalized.wav` is used only for guarded short-file WAV mode
- additive metadata fields:
  - `job_id`
  - `session_id`
  - `job_file_name`
- speaker constraints:
  - `num_speakers`
  - `min_speakers`
  - `max_speakers`
- этот шаг пропускается, если submit-time probe уже решил `run_diarization=false`

### 6. Sidecar -> NATS

- structured subject:
  - `inference.whisper.file.diar.event.<job_id>`
- text/session subjects:
  - `inference.whisper.file.diar_text.event.<job_id>`
  - `inference.whisper.file.diar_text.session.<session_id>`

#### Progress events

- `diar_progress`
- `diar_text_progress`

Текущие поля progress payload:

- `type`
- `job_id`
- `session_id`
- `file_name`
- `phase = diarize_running`
- `step`
- `completed`
- `total`
- `percent`
- `details`

#### Final events

- `diar_done`
- `diar_text_done`
- `diar_error`
- `diar_text_error`

### 7. WebUI Snapshot -> Browser

- endpoint: `GET /api/file-job?job_id=...`
- snapshot fields:
  - `requested_diarization`
  - `run_diarization`
  - `diarization_backend_status`
  - `diarization_fallback_reason`
- те же поля уже присутствуют в file state event'ах; snapshot используется для дочитывания transcript и raw diarization artifacts
- `/whisper-diarization` фильтрует jobs по `requested_diarization`, а не по `run_diarization`, поэтому downgraded transcript-only job остаётся видимым на той же странице

## Runtime Codemap

Краткая карта файлов вынесена отдельно в [../CODEMAP.md](</experiments/voice-ai/archive/y/CODEMAP.md>).

Главные entrypoints этого stack:

- `src/stt_whisper_to_nats.py`
- `src/pyannote_diarization_server.py`
- `src/webui/server.py`
- `src/webui/static/app.js`
- `docker/diarization-base/Dockerfile`
- `docker/diarization/Dockerfile`
- `../rag-stack/stack/diarization-models.drs`

## Проблемы, которые уже всплывали

### 1. TorchCodec / FFmpeg mismatch

Симптом:

- `Could not load libtorchcodec`
- `undefined symbol: aoti_torch_create_device_guard`

Что оказалось причиной:

- несовместимая пара `torch 2.8.0+cu126` и `torchcodec 0.11.0+cu126`
- build graph держал старый override версии `torchcodec`

Что сделали:

- зафиксировали `torchcodec 0.7.0+cu126`
- убрали stale override
- сделали fail-fast smoke import на build/startup

### 2. Healthcheck restart loop

Симптом:

- `pyannote_diarization` стартовал, готовил pipeline, а потом контейнер пересоздавался примерно раз в минуту

Что оказалось причиной:

- сломанный inline `HEALTHCHECK` с невалидным `python -c`

Что сделали:

- вынесли probe в `src.pyannote_healthcheck`

### 3. GPU rollout и shared MIG

Исторический симптом production runtime:

- `RuntimeError: NVML_SUCCESS == r INTERNAL ASSERT FAILED at "/pytorch/c10/cuda/CUDACachingAllocator.cpp":1131`

Что важно:

- ошибка возникала не на import/load, а на первом реальном inference внутри pyannote
- перенос sidecar на отдельный MIG slice проблему не снял
- это указывает не на бизнес-логику diarization, а на связку:
  - PyTorch CUDA allocator
  - NVML
  - MIG runtime
  - host driver / CUDA compatibility

Что изменилось сейчас:

- stack снова таргетит GPU runtime для `pyannote_diarization`
- batch sizes жёстко зажаты до `1`
- сервис делит MIG slice с LinTO, поэтому operational failures теперь могут быть двух типов:
  - GPU/NVML/runtime проблема во время inference
  - service availability проблема до старта inference

То есть приложение больше не предполагает, что GPU всегда стабилен. Оно проверяет доступность backend-а заранее и умеет не блокировать transcription, если diarization backend сейчас недоступен.

### 4. Ingress 404 и backend unavailable

Симптом:

- `Speaker transcript error (worker_error): Diarization HTTP error 404: 404 page not found`
- либо пользователь видел plain text, но не понимал, почему speaker branch не стартовал

Что оказалось причиной:

- ingress route был жив, но за ним не было работающего `pyannote_diarization`
- возможные operational причины:
  - сервис оставлен в `scale 0`
  - image tag отсутствует в registry
  - backend ещё грузится и отдаёт `503 pipeline_not_ready`

Что сделали:

- ввели submit-time health probe через `DIARIZATION_HEALTH_URL`
- `404/5xx` и transport errors теперь схлопываются в дружелюбное `Speaker diarization unavailable: diarization backend is not reachable.`
- `/whisper-diarization` сохраняет `requested_diarization=true`, но может запускать job с `run_diarization=false`
- UI остаётся на diarization page, показывает transcript-only fallback и не ждёт speaker transcript бесконечно

### 5. Почему в UI стало понятнее, что происходит

Сейчас одновременно работают две защиты:

- coarse stage events из worker
- real hook-based progress из pyannote sidecar по NATS

Если diarization реально не стартовал, UI больше не маскирует это под "ещё подождите", а показывает явную fallback reason.

## Текущие operational риски

- `pyannote_diarization` теперь деплоится с отдельным `PYANNOTE_DIARIZATION_IMAGE_TAG`; это убирает coupling с pinned `VOICE_CHAT_IMAGE_TAG`, но требует держать отдельный image tag в registry.
- shared MIG с LinTO остаётся узким местом для latency и memory pressure.
- health probe решает только доступность backend-а в момент submit. Если backend умрёт посередине job, transcription завершится, а diarization уйдёт в friendly error.

Для отдельной GPU-диагностики остаются те же практические направления:

1. Уйти с shared MIG на отдельный slice или полный GPU и повторить pyannote inference.
2. Проверить host driver / NVML against `torch==2.8.0+cu126` runtime и пересобрать image при смене CUDA wheel set.
3. Протестировать allocator flags:
   - `PYTORCH_CUDA_ALLOC_CONF=backend:cudaMallocAsync`
   - `PYTORCH_NO_CUDA_MEMORY_CACHING=1`
4. Проверить ту же модель на другой версии PyTorch/CUDA wheels, а не только на `2.8.0+cu126`.
