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

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 теперь является воспроизводимым источником 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 сохраняет 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: архитектурная схема

Архитектурная схема
Архитектурная схема · Открыть в полном размере
Исходный 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

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

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 -> 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.

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

Исходник

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