Голосовые системы

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

Sequence Diagram
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

UML State Diagram
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.

Главные 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:
  4. PYTORCH_CUDA_ALLOC_CONF=backend:cudaMallocAsync
  5. PYTORCH_NO_CUDA_MEMORY_CACHING=1
  6. Проверить ту же модель на другой версии PyTorch/CUDA wheels, а не только на 2.8.0+cu126.

Исходник

Скачать Markdown · К материалам исследования