# 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
- выбранный sidecar (`src/pyannote_diarization_server.py`, `src/sherpa_onnx_diarization_server.py` или `src/sortformer_diarization_server.py`) выполняет speaker diarization и публикует совместимый JSON результат
- 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 runtime распределён между несколькими владельцами:

- public file WebUI и routes объявлены в `../webrtc-komaroff/stack/webrtc.drs`
- ASR (`linto_stt_whisper_http`) работает на GPU и подключается через внешний `rag-stack`
- stack config для diarization вынесен во внешний `rag-stack/stack/diarization-models.drs`
- `pyannote_diarization_shared` оставлен для совместимого shared MIG режима, а `pyannote_diarization_isolated` использует отдельный MIG slice
- `sherpa_onnx_diarization` и `sortformer_diarization` развернуты как реальные sidecar-кандидаты с тем же `/healthz` и `/diarize` контрактом
- image rollout для sidecar-ов отделён от остальных `voice-chat/*` runtime images через `PYANNOTE_DIARIZATION_IMAGE_TAG`, `SHERPA_ONNX_DIARIZATION_IMAGE_TAG`, `SORTFORMER_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 на той же странице

Собственный [`stack/stack.drs`](#archive-availability) теперь является воспроизводимым источником legacy CPU stack `py_faster_whisper`; deployment выполняется только из default branch. H100 модели и public WebRTC ingress сохраняют отдельных владельцев. CI image build, runtime stack и public ingress проверяются как отдельные proof layers.

### Передача аудио в LinTO через NATS

`LINTO_HTTP_URL` задаёт настоящий backend URL, доступный receiver-у на H100.
`LINTO_HTTP_PROXY_URL` задаёт внутренний HTTP CONNECT endpoint requester-а.
Оба CPU stack используют CONNECT через существующий NATS bridge: multipart
передаётся бинарными потоковыми frames, а не одним JSON request envelope.
Обычный HTTP bridge в `nats-proxy 0.4.16` теряет multipart upload, что приводит
к LinTO `422: file required`; большие WAV также нельзя помещать в один NATS message.
CONNECT не требует новых ingress, SOCKS listener или изменения VPN/firewall.
При ошибке proxy прямого fallback нет. Без `LINTO_HTTP_PROXY_URL` сохраняется
прямой HTTP transport для локального запуска. Одна и та же функция отправляет
file chunks и live phrases; JSON/text ответы и multipart contract LinTO не меняются.

Для диаризации используется тот же `src/model_transport.py`: `DIARIZATION_HTTP_URL`
задаёт backend `/diarize`, а `DIARIZATION_HTTP_PROXY_URL` — CONNECT requester.
Это относится к общей функции `diarize_http_json`, используемой file/live,
staged и summary, и к diagnostics smoke. Обычный HTTP bridge может сохранять
multipart Content-Type, потеряв тело, поэтому pyannote возвращает `400: Could
not find starting boundary` ещё до запуска модели. Зелёный `/healthz` не
подтверждает передачу файла.

В WebRTC shared pyannote принимает запрос на
`http://diarization-models_pyannote_diarization_shared:8080/diarize` через
`http://web-rtc-komaroff_nats_proxy_pyannote_shared:7000`. CPU sidecar в legacy
stack сохраняет прямой локальный HTTP без proxy. Bench/staged config хранит
отдельный proxy URL для выбранного backend; альтернативный backend не наследует
чужой proxy. При ошибке CONNECT прямого fallback нет, GPU placement не меняется.

Summary case выбирает transport по `case_id` и `runtime` из серверного
`BENCH_CONFIGS_JSON`, а не подменой Host на общем pyannote endpoint. Неописанный
runtime (например, isolated без своего route config) отклоняется явно; запрос
не отправляется молча в shared. Остальные request-level параметры сохраняются.

### Альтернативные модели через NATS

WebRTC использует отдельную пару requester/receiver для каждого backend:

| Runtime | Proxy service (в обоих stack) | H100 backend в сети `diarization-models_default` | Subject root на H100 |
| --- | --- | --- | --- |
| Pyannote shared | `nats_proxy_pyannote_shared` | `diarization-models_pyannote_diarization_shared:8080` | `inference.pyannote.shared` |
| Pyannote isolated | `nats_proxy_pyannote_isolated` | `diarization-models_pyannote_diarization_isolated:8080` | `inference.pyannote.isolated` |
| Sherpa ONNX | `nats_proxy_sherpa_onnx` | `diarization-models_sherpa_onnx_diarization:8080` | `inference.sherpa.onnx` |
| Sortformer | `nats_proxy_sortformer` | `diarization-models_sortformer_diarization:8080` | `inference.sortformer` |

Requester находится в `web-rtc-komaroff`, receiver — в `llm-models`.
Requester публикует `to.<root>`, принимает `from.<root>` в Core NATS;
H100 receiver слушает `<root>.requests.>` в JetStream. Существующие account
imports/exports покрывают эти roots; новые credentials и ingress не нужны.

В `BENCH_CONFIGS_JSON` `/diarize` содержит реальный H100 backend URL,
`diarization_http_proxy_url` — соответствующий requester на `:7000`.
Preflight GET идёт на `/healthz` того же requester-а и пересылается receiver-ом
в backend. Не заменять его на `/healthcheck`: этот путь проверяет только сам proxy.
Для `/status/` используются backend health URL и явный `*_HEALTH_PROXY_URL`;
smoke передаёт файл через CONNECT и `*_DIARIZE_PROXY_URL` той же пары.

При новом запуске шага staged/summary сохранённый `bench_config` заново получает
пять transport-полей из текущего серверного конфига по `case_id`/`runtime`.
Старые прямые H100 URL больше не фиксируют маршрут известной модели навсегда.
Аудио, speaker hints и параметры эксперимента не меняются; уже исполняющийся
шаг и его внутренние retries используют выбранный в начале шага маршрут.
Legacy custom config без `case_id` не переинтерпретируется автоматически.

Прямая проверка `192.168.28.134:80` вынесена в группу `network`. Только при
`DIAGNOSTICS_H100_INGRESS_INFORMATIONAL=1` она не влияет на service health:
реальная ошибка соединения остаётся видимой как FAIL, а группа помечена INFO.
Ошибка health или smoke любой модели по NATS по-прежнему ухудшает общий статус.

### Sortformer: компилятор нужен в runtime

Triton создаёт host-side launcher при обработке аудио, поэтому
[`docker/sortformer/Dockerfile`](#archive-availability) сохраняет
`build-essential` (компиляторы и libc headers) в конечном образе, а не только
на build-stage. Без него `/healthz` может быть зелёным, но `/diarize` возвращает
`500: Failed to find C compiler` после успешного приёма и декодирования файла.
Это дефект зависимости образа, не NATS, multipart или размещения GPU.
Приёмка требует реального `/diarize` smoke после публикации образа; один health
не проверяет отложенную компиляцию. CI, модель, CUDA и GPU selectors не меняются.

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

![PNG: Whisper diarization stack: архитектурная схема](</experiments/voice-ai/archive/y/DOCS/diagrams/file_diarization_stack/file-diarization-01-architecture.png>)

```mermaid
flowchart LR
    Browser["Browser /whisper-diarization"] -->|POST file-transcribe| WebUI["py_faster_whisper WebUI"]
    WebUI -->|GET /healthz| Health["selected diarization sidecar /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/options| Pyannote["selected sidecar /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

![PNG: Whisper diarization stack: sequence](</experiments/voice-ai/archive/y/DOCS/diagrams/file_diarization_stack/file-diarization-02-sequence.png>)

```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 Diarization 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 with selected engine options (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

![PNG: Whisper diarization stack: state machine](</experiments/voice-ai/archive/y/DOCS/diagrams/file_diarization_stack/file-diarization-03-state.png>)

```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 -> Diarization 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 -> Diarization 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`
- engine-specific request options:
  - pyannote: runtime URL, `segmentation_batch_size`, `embedding_batch_size`, `cluster_threshold`, speaker hints
  - sherpa-onnx: `cluster_threshold`, optional speaker count hint
  - Sortformer: profile, batch size, workers, long-window seconds, overlap seconds, maximum four 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/sherpa_onnx_diarization_server.py`
- `src/sortformer_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. Держать три реальные ветки benchmark: pyannote shared/isolated, sherpa-onnx CPU/ONNX, Sortformer GPU/NeMo.
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`.
