# README For Dummer

Этот файл написан максимально простым языком.

Цель: объяснить, как у нас сейчас работает `Transcribe + Diarize`, почему diarization не равна transcription, какие модели реально участвуют в процессе, и куда смотреть в коде, если нужно этим владеть руками.

Если нужен более технический документ с диаграммами и production-проблемами, смотри:

- [DOCS/FILE_DIARIZATION_STACK.md](</experiments/voice-ai/archive/y/DOCS/FILE_DIARIZATION_STACK.md>)
- [CODEMAP.md](</experiments/voice-ai/archive/y/CODEMAP.md>)

## Самый короткий ответ

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

1. `transcription` = превратить звук в слова
2. `diarization` = понять, кто из спикеров говорил в каждый момент времени

Это делают две разные модели и два разных сервиса:

- текст делает Whisper-модель `large-v3-turbo` внутри сервиса `linto_stt_whisper_http`
- спикеров по умолчанию размечает модель `pyannote/speaker-diarization-community-1` внутри сервиса `pyannote_diarization`; для сравнения также есть реальные sidecar-ы `sherpa_onnx_diarization` и `sortformer_diarization`

Потом обычный Python-код берёт:

- слова и таймкоды из transcription
- интервалы спикеров из diarization

и склеивает их в итог:

```text
SPEAKER_00: Привет всем
SPEAKER_01: Добрый день
```

То есть `pyannote` сама текст не пишет. Она только говорит: "с 0.0 по 2.4 секунды говорил один человек, с 2.4 по 5.1 другой".

## Очень простая аналогия

Представь два разных работника:

- Первый работник слушает запись и печатает слова.
- Второй работник не печатает слова вообще. Он только отмечает на шкале времени, какой голос когда звучал.

Потом третий работник берёт результаты первых двух и совмещает их.

У нас:

- первый работник = Whisper ASR
- второй работник = pyannote diarization
- третий работник = наш код в `stt_whisper_to_nats.py` и `diarization.py`

## Какие модели используются прямо сейчас

### 1. Модель для transcription

Где включена:

- [../../../rag-stack/stack/llm-models.drs](#archive-availability)

Что именно видно в deploy:

- сервис: `linto_stt_whisper_http`
- `env MODEL: 'large-v3-turbo'`
- `env DEVICE: 'cuda'`

Это значит:

- именно эта модель превращает звук в текст
- именно этот сервис сейчас использует GPU для transcription

Кодовые ссылки:

- [../../../rag-stack/stack/llm-models.drs](#archive-availability)
- [src/webui/file_mode.py](</experiments/voice-ai/archive/y/src/webui/file_mode.py>)
- [src/stt_whisper_to_nats.py](</experiments/voice-ai/archive/y/src/stt_whisper_to_nats.py>)

### 2. Модель для diarization

Где включена:

- [../../../rag-stack/stack/diarization-models.drs](#archive-availability)

Что именно видно в deploy:

- сервисы: `pyannote_diarization_shared` и `pyannote_diarization_isolated`
- `env PYANNOTE_PIPELINE_ID: 'pyannote/speaker-diarization-community-1'`
- shared/isolated runtime выбирается URL-ом sidecar, а batch/clustering параметры приходят request fields из WebUI

Это значит:

- эта модель не пишет текст
- эта модель только ищет границы спикеров во времени

Кодовые ссылки:

- [src/pyannote_diarization_server.py](</experiments/voice-ai/archive/y/src/pyannote_diarization_server.py>)
- [src/requirements.diarization.txt](</experiments/voice-ai/archive/y/src/requirements.diarization.txt>)
- [../../../rag-stack/stack/diarization-models.drs](#archive-availability)

### 3. Модель для VAD

Есть ещё отдельный кусок логики, который помогает резать длинный звук на куски перед transcription.

Где видно:

- в deploy для `linto_stt_whisper` и `linto_stt_whisper_http`
- `env VAD: 'silero'`

Это не transcription и не diarization.
Это просто "детектор, где есть речь, а где тишина".

Кодовые ссылки:

- [../../../rag-stack/stack/llm-models.drs](#archive-availability)
- [src/env.example](#archive-availability)

## Главная мысль: diarization не делает transcription

Это самый важный кусок.

### Почему так

Потому что в коде `pyannote_diarization` возвращает только:

- `model`
- `duration_s`
- `speaker_count`
- `speakers`
- `segments`
- `exclusive_segments`

Это видно здесь:

- [src/pyannote_diarization_server.py](</experiments/voice-ai/archive/y/src/pyannote_diarization_server.py>)

Там нет поля `text`.

То есть sidecar diarization вообще не возвращает распознанные слова.

### Где это видно совсем прямо

В requirements sidecar тоже видно, что это не transcription-сервис:

- [src/requirements.diarization.txt](</experiments/voice-ai/archive/y/src/requirements.diarization.txt>)

Там установлены:

- `pyannote.audio`
- `aiohttp`
- `nats-py`

Там нет отдельного Whisper transcription runtime.

### Как код это доказывает

После того как diarization вернула интервалы спикеров, наш код делает не "распознавание речи", а "приклеивание спикера к уже готовым словам".

Вот ключевые функции:

- [src/diarization.py](</experiments/voice-ai/archive/y/src/diarization.py>)
  - `parse_diarization_result`
  - `attribute_words`
  - `build_speaker_turns`
  - `render_speaker_text`

Что они делают:

1. берут `exclusive_segments` от pyannote
2. берут список слов `TimedWord` из ASR
3. для каждого слова выбирают говорящего
4. собирают слова в реплики
5. рендерят итоговый `Speaker transcript`

Очень важно:

- `attribute_words` принимает уже готовые `words: list[TimedWord]`
- эти `TimedWord` пришли не из pyannote
- они пришли из transcription

Кодовые ссылки:

- [src/diarization.py](</experiments/voice-ai/archive/y/src/diarization.py>)
- [src/webui/file_mode.py](</experiments/voice-ai/archive/y/src/webui/file_mode.py>)
- [src/stt_whisper_to_nats.py](</experiments/voice-ai/archive/y/src/stt_whisper_to_nats.py>)

## Если говорить совсем по-человечески

`transcription` отвечает на вопрос:

> Что было сказано?

`diarization` отвечает на вопрос:

> Кто говорил в этот момент?

Итоговый "текст по спикерам" получается только после склейки этих двух ответов.

## Путь одного файла от кнопки до результата

Ниже тот же процесс, но очень медленно и по шагам.

## Полный графический обзор `transcribe/diarize`

Этот раздел показывает не только "куда нажали", а весь обмен между сервисами: HTTP, NATS, файлы на диске, payload types и где именно соединяются слова со спикерами.

### Диаграмма 1. Карта сервисов и внешних границ

![PNG: Диаграмма 1. Карта сервисов и внешних границ](#archive-availability)

```mermaid
flowchart LR
    Browser["Browser\n/whisper-trasncription\n/whisper-diarization\n/whisper-summary\n/status"] --> Traefik["Traefik routes\nwebrtc-komaroff.gis-master.ru"]

    Traefik --> TranscribeUi["web-rtc-komaroff_py_faster_whisper\nWEBUI_PAGE_MODE=transcribe_only"]
    Traefik --> DiarizeUi["web-rtc-komaroff_py_faster_whisper_diarize\nWEBUI_PAGE_MODE=diarize"]
    Traefik --> SummaryUi["web-rtc-komaroff_py_faster_whisper_summary\nWEBUI_PAGE_MODE=summary"]
    Traefik --> StatusUi["web-rtc-komaroff_py_faster_whisper_status\nWEBUI_PAGE_MODE=diagnostics"]
    Traefik --> Ws["web-rtc-komaroff_audio_nats\n/ws websocket"]

    TranscribeUi --> Lock["web-rtc-komaroff_whisper_file_job_lock\n/health + lock"]
    DiarizeUi --> Lock
    SummaryUi --> Lock

    TranscribeUi --> Linto["H100 LinTO HTTP\nlinto_stt_whisper_http.h100.local\n/transcribe"]
    DiarizeUi --> Linto
    SummaryUi --> Linto

    DiarizeUi --> Pyannote["H100 pyannote shared\npyannote-diarization-shared.h100.local\n/diarize"]
    SummaryUi --> Pyannote
    SummaryUi --> Sherpa["H100 sherpa-onnx\nsherpa-onnx-diarization.h100.local\n/diarize"]
    SummaryUi --> Sortformer["H100 sortformer\nsortformer-diarization.h100.local\n/diarize"]

    TranscribeUi --> Nats["audio_nats\nlocal account"]
    DiarizeUi --> Nats
    SummaryUi --> Nats
    StatusUi --> Nats
    Nats -. leafnode import/export .-> Inference["inference NATS / JetStream\nH100"]
```

Что читать по этой диаграмме:

- страницы `/whisper-*` и `/status` обслуживаются одним Python image, но разными service/env режимами в `webrtc.drs`;
- LinTO отвечает за `transcription`, diarization sidecar-ы отвечают только за speaker intervals;
- `audio_nats` нужен для live/progress/result событий и для browser `/ws`;
- `/status/` использует те же границы, но ничего пользовательского не транскрибирует: он делает health/smoke probes.

### Диаграмма 2. Upload sequence для `Transcribe + Diarize`

![PNG: Диаграмма 2. Upload sequence для Transcribe + Diarize](#archive-availability)

```mermaid
sequenceDiagram
    autonumber
    participant B as Browser
    participant W as WebUI server.py
    participant R as FileJobWorkerService
    participant FS as Job storage
    participant L as LinTO /transcribe
    participant D as Diarization /diarize
    participant N as audio_nats

    B->>W: POST /api/file-transcribe multipart/form-data
    Note over B,W: file, job_id, session_id, language,<br/>run_diarization, num/min/max_speakers
    W->>FS: save uploaded source file
    W->>D: optional submit-time GET /healthz
    W->>R: start local file job in same service
    R->>FS: write normalized.wav, job state
    R->>N: publish file progress
    par Transcription branch
        R->>FS: split normalized.wav into chunk_*.wav
        loop for each chunk
            R->>L: POST /transcribe multipart file, Accept: application/json
            L-->>R: JSON text + words[start,end]
            R->>FS: append transcript artifact
            R->>N: publish file progress/result event
        end
    and Diarization branch
        R->>D: POST /diarize multipart normalized audio
        D-->>R: JSON diar_done with segments/exclusive_segments
    end
    R->>R: attribute_words + build_speaker_turns + render_speaker_text
    R->>FS: write diarization payload + speaker transcript
    R->>N: publish diar_done + diar_text_done
    N-->>B: browser receives progress/result through /ws
```

Главная идея sequence: transcription и diarization стартуют из одного file job, но идут параллельно. Speaker transcript появляется только после того, как обе ветки дали данные: ASR words и diarization exclusive segments.

### Диаграмма 3. Transcribe-only path

![PNG: Диаграмма 3. Transcribe-only path](#archive-availability)

```mermaid
flowchart TB
    Input["Uploaded audio/video\nwav/mp3/m4a/mp4"] --> Normalize["write_normalized_audio_wav\nmono 16 kHz wav"]
    Normalize --> Chunks["iter_normalized_wav_chunk_files\nchunk_0001.wav ..."]
    Chunks --> LintoReq["HTTP POST LinTO /transcribe\nmultipart file\nAccept: application/json\noptional language query"]
    LintoReq --> LintoResp["LinTO JSON response\ntext: string\nwords: list of {word/text,start,end}"]
    LintoResp --> Builder["TimedTranscriptBuilder\nrequires word timestamps"]
    Builder --> Plain["Plain transcript text"]
    Builder --> Words["TimedWord[] timeline"]
    Plain --> FileEvent["NATS file event\ninference.whisper.file.event.<job_id>"]
    Plain --> SessionEvent["NATS session event\ninference.whisper.file.session.<session_id>"]
```

Типы данных в этой ветке:

| Boundary | Transport | Request | Response |
| --- | --- | --- | --- |
| Browser -> WebUI | HTTP multipart | audio/video file + form fields | accepted job JSON |
| WebUI -> LinTO | HTTP multipart | `file`, optional `language`; headers `Accept`, optional `Host` | JSON object with `text` and `words` |
| Worker -> NATS | publish JSON | progress/result payload | no direct reply |
| Browser -> NATS | WebSocket | subscribe to session/job subjects | JSON events |

### Диаграмма 4. Diarization + speaker transcript path

![PNG: Диаграмма 4. Diarization + speaker transcript path](#archive-availability)

```mermaid
flowchart TB
    Normalized["normalized audio artifact"] --> DiarReq["HTTP POST sidecar /diarize\nmultipart file\njob_id/session_id\nnum/min/max_speakers"]
    DiarReq --> Sidecar{"Selected sidecar"}
    Sidecar --> Pyannote["pyannote\nspeaker-diarization-community-1"]
    Sidecar --> Sherpa["sherpa-onnx\npyannote3 + 3dspeaker"]
    Sidecar --> Sortformer["sortformer\nnvidia diar streaming"]

    Pyannote --> DiarPayload["DiarizationResult\nsegments[]\nexclusive_segments[]\nspeakers[]"]
    Sherpa --> DiarPayload
    Sortformer --> DiarPayload

    Words["TimedWord[] from LinTO"] --> Glue["attribute_words(words, exclusive_segments)"]
    DiarPayload --> Glue
    Glue --> Attributed["AttributedWord[]\nspeaker,text,start_s,end_s"]
    Attributed --> Turns["build_speaker_turns\nSpeakerTurn[]"]
    Turns --> Text["render_speaker_text\nSPEAKER_00: ..."]
    Turns --> Syntax["optional syntax postprocess\nturns_syntax/text_syntax"]

    Text --> DiarTextDone["diar_text_done event"]
    DiarPayload --> DiarDone["diar_done event"]
    Syntax --> DiarTextDone
```

Типы данных в этой ветке:

| Тип | Где рождается | Главные поля | Для чего нужен |
| --- | --- | --- | --- |
| `DiarizationResult` | `parse_diarization_result` | `model`, `duration_s`, `speaker_count`, `speakers`, `segments`, `exclusive_segments` | нормализует sidecar JSON |
| `TimedWord` | `TimedTranscriptBuilder` | `text`, `start_s`, `end_s` | слова ASR с таймкодами |
| `AttributedWord` | `attribute_words` | `speaker`, `text`, `start_s`, `end_s` | слово уже привязано к speaker |
| `SpeakerTurn` | `build_speaker_turns` | `speaker`, `text`, `start_s`, `end_s` | готовая реплика одного speaker |
| `diar_done` | worker NATS publish | model/speakers/segments/words/turns/text | полный технический payload |
| `diar_text_done` | worker NATS publish | `text`, `turns`, syntax fields | компактный payload для UI |

### Диаграмма 5. NATS subjects и payload types

![PNG: Диаграмма 5. NATS subjects и payload types](#archive-availability)

```mermaid
flowchart LR
    Worker["FileJobWorkerService"] --> FileJob["inference.whisper.file.event.<job_id>\ntype=file_progress/file_done/file_error"]
    Worker --> FileSession["inference.whisper.file.session.<session_id>\nlatest plain transcript state"]
    Worker --> DiarJob["inference.whisper.file.diar.event.<job_id>\ntype=diar_progress/diar_done/diar_error"]
    Worker --> DiarTextJob["inference.whisper.file.diar_text.event.<job_id>\ntype=diar_text_progress/diar_text_done/diar_text_error"]
    Worker --> DiarTextSession["inference.whisper.file.diar_text.session.<session_id>\nlatest speaker transcript state"]

    Sidecar["pyannote/sherpa/sortformer sidecar"] -. optional progress .-> DiarJob
    Sidecar -. optional text progress .-> DiarTextJob

    FileJob --> Browser["Browser NATS WS client"]
    FileSession --> Browser
    DiarJob --> Browser
    DiarTextJob --> Browser
    DiarTextSession --> Browser
```

Subject prefixes come from `FileModeSettings`:

- `ASR_FILE_EVENT_PREFIX` -> `inference.whisper.file.event.`
- `ASR_FILE_SESSION_EVENT_PREFIX` -> `inference.whisper.file.session.`
- `ASR_FILE_DIAR_EVENT_PREFIX` -> `inference.whisper.file.diar.event.`
- `ASR_FILE_DIAR_TEXT_EVENT_PREFIX` -> `inference.whisper.file.diar_text.event.`
- `ASR_FILE_DIAR_TEXT_SESSION_EVENT_PREFIX` -> `inference.whisper.file.diar_text.session.`

### Диаграмма 6. State machine одного file job

![PNG: Диаграмма 6. State machine одного file job](#archive-availability)

```mermaid
stateDiagram-v2
    [*] --> Uploaded
    Uploaded --> Normalizing: source file saved
    Normalizing --> Running: normalized.wav ready
    Running --> Transcribing: chunks start
    Running --> Diarizing: sidecar task starts
    Transcribing --> PlainTextReady: all chunks returned text + words
    Diarizing --> SpeakerSegmentsReady: sidecar returned exclusive_segments
    PlainTextReady --> Combining
    SpeakerSegmentsReady --> Combining
    Combining --> SpeakerTextReady: words attributed to speakers
    SpeakerTextReady --> Done

    Running --> PlainOnlyDone: run_diarization=false
    PlainTextReady --> DiarizationFailed: sidecar error or timeout
    DiarizationFailed --> PlainTranscriptFallback

    Uploaded --> Failed: invalid upload
    Normalizing --> Failed: ffmpeg/wav error
    Transcribing --> Failed: LinTO error
    Combining --> Failed: missing word timestamps
```

Практический смысл этой state machine:

- plain transcript может быть готов раньше, чем speaker transcript;
- diarization failure не всегда означает потерю plain transcript;
- без `TimedWord[]` нельзя построить `SpeakerTurn[]`, даже если `exclusive_segments[]` уже есть;
- optional syntax postprocess меняет только форму speaker turns/text, а не исходные ASR words или sidecar segments.

### Диаграмма 7. Сводная карта приложения, сервисов и основных data contracts

Эта схема объединяет UI, WebUI-сервисы, job storage, H100 backends, NATS и `/status/`. Внутри блоков указаны не все внутренние переменные, а поля, которые реально переходят через границы сервисов.

![PNG: Диаграмма 7. Сводная карта приложения, сервисов и data contracts](#archive-availability)

```mermaid
flowchart LR
    Browser["Browser UI\nroutes: /whisper-trasncription, /whisper-diarization, /whisper-summary, /status\nUploadRequest fields:\nfile, file_name, job_id, session_id, language,\nrun_diarization, syntax_postprocess,\nnum_speakers, min_speakers, max_speakers"]

    Traefik["Traefik\nhost: webrtc-komaroff.gis-master.ru\nroute prefixes:\n/whisper-*, /status, /ws"]

    WebUI["py_faster_whisper WebUI services\nmodes: transcribe_only, diarize, summary, diagnostics\nJobState fields:\njob_id, session_id, file_name, language,\npage_mode, requested_diarization, run_diarization,\nsyntax_postprocess, backend, created_at"]

    Store["FileJobStore\nartifacts:\nsource upload, normalized.wav, chunk_*.wav,\ntranscript.txt, diarization.json,\nspeaker_transcript.json, state.json"]

    Lock["whisper_file_job_lock\n/health\nlock fields:\njob_id, owner, heartbeat, ttl"]

    LinTO["LinTO HTTP on H100\nPOST /transcribe\nrequest fields:\nfile=chunk.wav, language, Accept\nresponse fields:\ntext, words[{text,start,end}]"]

    Sidecars["Diarization sidecars on H100\npyannote shared / isolated\nsherpa-onnx\nsortformer\nrequest fields:\nfile, job_id, session_id,\nnum/min/max_speakers\nresponse fields:\ntype=diar_done, model, duration_s,\nspeakers, segments, exclusive_segments"]

    Worker["FileJobWorkerService\njoins data:\nTimedWord[] + exclusive_segments[]\noutputs:\nAttributedWord[], SpeakerTurn[],\nplain text, speaker text"]

    Nats["audio_nats\nsubjects:\nfile.event.<job_id>\nfile.session.<session_id>\nfile.diar.event.<job_id>\nfile.diar_text.event.<job_id>\nfile.diar_text.session.<session_id>"]

    Ws["/ws browser NATS WebSocket\nbrowser subscribes and renders:\nprogress, job_done, diar_done,\ndiar_text_done, errors"]

    Status["/status diagnostics\nDiagnosticsPayload fields:\nstatus, generated_at_ms, duration_ms,\nsmoke, groups[{id,label,status,checks[]}]"]

    Browser --> Traefik
    Traefik --> WebUI
    Traefik --> Status
    Traefik --> Ws
    WebUI --> Lock
    WebUI --> Store
    WebUI --> Worker
    Worker --> Store
    Worker --> LinTO
    Worker --> Sidecars
    Worker --> Nats
    Nats --> Ws
    Ws --> Browser
    Status --> WebUI
    Status --> LinTO
    Status --> Sidecars
    Status --> Nats
```

Ключевые поля на этой общей карте:

| Data object | Поля | Где используется |
| --- | --- | --- |
| `UploadRequest` | `file`, `file_name`, `job_id`, `session_id`, `language`, `run_diarization`, `syntax_postprocess`, `num_speakers`, `min_speakers`, `max_speakers` | browser -> WebUI |
| `JobState` | `job_id`, `session_id`, `file_name`, `language`, `backend`, `page_mode`, `requested_diarization`, `run_diarization`, `syntax_postprocess` | local job state + UI refresh |
| `TranscribeResponse` | `text`, `words[{text,start,end}]`, `_response_debug` | LinTO -> worker |
| `DiarizationResult` | `model`, `duration_s`, `speaker_count`, `speakers`, `segments`, `exclusive_segments` | sidecar -> worker |
| `DiagnosticsPayload` | `status`, `generated_at_ms`, `duration_ms`, `smoke`, `groups[].checks[]` | `/status/api/diagnostics` -> browser |

### Диаграмма 8. Сводный sequence полного `Transcribe + Diarize` с полями

Эта схема показывает happy path одного файла. Plain transcript и speaker transcript идут разными событиями: сначала UI может получить обычный `job_done`, а затем отдельный `diar_text_done`.

![PNG: Диаграмма 8. Сводный sequence Transcribe + Diarize с полями](#archive-availability)

```mermaid
sequenceDiagram
    autonumber
    participant B as Browser
    participant W as WebUI server.py
    participant S as FileJobStore
    participant R as FileJobWorkerService
    participant L as LinTO /transcribe
    participant D as Diarization /diarize
    participant N as audio_nats
    participant WS as Browser /ws

    B->>W: POST /api/file-transcribe<br/>file, file_name, job_id, session_id,<br/>language, run_diarization, syntax_postprocess,<br/>num/min/max_speakers
    W->>S: create job state<br/>job_id, session_id, file_name, page_mode,<br/>requested_diarization, run_diarization
    W->>R: start local file job
    R->>S: write source + normalized.wav + chunks<br/>chunk_index, chunk_total, start_s, end_s, keep_from_s
    R->>N: publish prepare_media<br/>type, job_id, file_name, phase,<br/>duration_s, chunk_total, session_id
    N-->>WS: progress event

    par Transcription branch
        loop each chunk
            R->>N: chunk_started / chunk_progress<br/>chunk_index, chunk_total, attempt,<br/>chunk_start_s, chunk_end_s, elapsed_s
            R->>L: POST multipart /transcribe<br/>file=chunk_*.wav, language,<br/>Accept=application/json
            L-->>R: TranscribeResponse<br/>text, words[{text,start,end}]
            R->>S: append TimedWord[] and transcript text
            R->>N: chunk_done<br/>chunks_completed, chars_total
            N-->>WS: chunk state
        end
        R->>N: job_done<br/>type, job_id, file_name, phase=done,<br/>chunk_total, chunks_completed, chars_total
        N-->>WS: plain transcript is ready
    and Diarization branch
        R->>D: POST multipart /diarize<br/>file, job_id, session_id,<br/>num_speakers, min_speakers, max_speakers
        D-->>R: DiarizationResult<br/>type=diar_done, model, duration_s,<br/>speaker_count, speakers,<br/>segments[], exclusive_segments[]
    end

    R->>R: attribute_words<br/>TimedWord[] + exclusive_segments[] -> AttributedWord[]
    R->>R: build_speaker_turns + optional syntax postprocess<br/>AttributedWord[] -> SpeakerTurn[] -> text
    R->>S: store diarization payload<br/>segments, exclusive_segments, words, turns,<br/>text, raw fields, syntax fields
    R->>N: publish diar_done signal<br/>type, job_id, file_name, model,<br/>speaker_count, speakers, syntax counters
    R->>N: publish diar_text_done signal<br/>type, job_id, file_name, duration_s,<br/>speaker_count, syntax counters
    N-->>WS: speaker transcript signal
    WS->>W: optional fetch artifacts<br/>includeRaw=true for full diarization payload
    W-->>B: speaker text + turns + raw payload when requested
```

Основные события sequence:

| Event | Главные поля | Что значит |
| --- | --- | --- |
| `prepare_media` | `job_id`, `file_name`, `phase`, `duration_s`, `chunk_total`, `session_id` | файл принят, нормализация и нарезка готовы |
| `chunk_started` | `chunk_index`, `chunk_total`, `attempt`, `chunk_start_s`, `chunk_end_s` | worker начал отправлять chunk в LinTO |
| `chunk_progress` | `elapsed_s`, `chunk_index`, `attempt` | LinTO еще обрабатывает chunk |
| `chunk_done` | `chunks_completed`, `chars_total` | chunk добавлен в plain transcript |
| `job_done` | `chunk_total`, `chunks_completed`, `chars_total` | plain transcript готов |
| `diar_done` | `model`, `speaker_count`, `speakers`, syntax counters | speaker separation готова технически |
| `diar_text_done` | `text`, `turns`, `text_raw`, `turns_raw`, `text_syntax`, `turns_syntax` | speaker transcript готов для UI |

### Диаграмма 9. Общая модель данных и payload fields

Эта схема показывает, какие структуры данных превращаются друг в друга. `DiarizationResult` рождается из HTTP JSON ответа выбранного diarization sidecar `/diarize` через `parse_diarization_result`. Важно: `DiarizationResult` сам по себе не содержит текста; текст появляется только после соединения `TimedWord[]` с `exclusive_segments[]`.

![PNG: Диаграмма 9. Общая модель данных и payload fields](#archive-availability)

```mermaid
classDiagram
    class UploadRequest {
        string file_name
        bytes file
        string job_id
        string session_id
        string language
        bool run_diarization
        bool syntax_postprocess
        int num_speakers_optional
        int min_speakers_optional
        int max_speakers_optional
    }

    class JobState {
        string job_id
        string session_id
        string file_name
        string language
        string backend
        string page_mode
        bool requested_diarization
        bool run_diarization
        bool syntax_postprocess
    }

    class MediaChunk {
        int index
        int total
        float start_s
        float end_s
        float keep_from_s
        Path path
    }

    class TranscribeResponse {
        string text
        TimedWord[] words
        object response_debug
    }

    class TimedWord {
        string text
        float start_s
        float end_s
    }

    class DiarizationSegment {
        string speaker
        float start_s
        float end_s
    }

    class DiarizationHttpRequest {
        bytes file
        string job_id
        string session_id
        int num_speakers_optional
        int min_speakers_optional
        int max_speakers_optional
    }

    class DiarizationSidecar {
        string endpoint_POST_diarize
        string health_GET_healthz
        string backend_pyannote_sherpa_sortformer
    }

    class DiarizationHttpResponse {
        string type_diar_done
        string model
        float duration_s
        int speaker_count
        string[] speakers
        DiarizationSegment[] segments
        DiarizationSegment[] exclusive_segments
    }

    class ParseDiarizationResult {
        function parse_diarization_result
        output DiarizationResult
    }

    class DiarizationResult {
        string model
        float duration_s
        int speaker_count
        string[] speakers
        DiarizationSegment[] segments
        DiarizationSegment[] exclusive_segments
    }

    class AttributedWord {
        string speaker
        string text
        float start_s
        float end_s
    }

    class SpeakerTurn {
        string speaker
        string text
        float start_s
        float end_s
    }

    class JobEvent {
        string type
        string job_id
        string session_id
        string file_name
        string phase
        int chunk_index
        int chunk_total
        int chunks_completed
        int chars_total
        string details
    }

    class DiarDonePayload {
        string type
        string job_id
        string file_name
        string phase
        float duration_s
        string model
        int speaker_count
        string[] speakers
        DiarizationSegment[] segments
        DiarizationSegment[] exclusive_segments
        AttributedWord[] words
        SpeakerTurn[] turns
        string text
        bool syntax_postprocess_applied
    }

    class DiarTextDonePayload {
        string type
        string job_id
        string file_name
        string phase
        float duration_s
        int speaker_count
        string text
        SpeakerTurn[] turns
        string text_raw
        SpeakerTurn[] turns_raw
        string text_syntax
        SpeakerTurn[] turns_syntax
        int syntax_sentence_count
        int syntax_low_confidence_count
    }

    UploadRequest --> JobState : creates
    JobState --> MediaChunk : splits normalized audio
    JobState --> DiarizationHttpRequest : run_diarization true
    DiarizationHttpRequest --> DiarizationSidecar : POST /diarize
    DiarizationSidecar --> DiarizationHttpResponse : returns JSON
    DiarizationHttpResponse --> ParseDiarizationResult : normalize sidecar payload
    ParseDiarizationResult --> DiarizationResult : creates
    MediaChunk --> TranscribeResponse : LinTO returns
    TranscribeResponse --> TimedWord : contains
    DiarizationHttpResponse --> DiarizationSegment : contains raw segments
    DiarizationResult --> DiarizationSegment : contains normalized segments
    TimedWord --> AttributedWord : add speaker
    DiarizationSegment --> AttributedWord : speaker by overlap
    AttributedWord --> SpeakerTurn : grouped by speaker and gap
    SpeakerTurn --> DiarTextDonePayload : rendered for UI
    DiarizationResult --> DiarDonePayload : stored raw technical payload
    JobState --> JobEvent : progress and terminal events
```

Краткий словарь полей:

| Поле | Где встречается | Значение |
| --- | --- | --- |
| `job_id` | все file/NATS payloads | ID конкретной обработки файла |
| `session_id` | upload, session subjects, UI state | ID browser/session, нужен для latest-session events |
| `phase` | `JobEvent`, diar events | текущий этап: `prepare_media`, `stream`, `transcribe_wait`, `done`, `diarize`, `merge`, `cancelled` |
| `chunk_index`, `chunk_total` | chunk events, `MediaChunk` | номер chunk-а и всего chunk-ов |
| `start_s`, `end_s` | words, segments, turns | таймкоды в секундах |
| `keep_from_s` | `MediaChunk` | граница отсечения overlap при склейке chunk-ов |
| `words` | LinTO response, `DiarDonePayload` | сначала ASR words, после join это уже speaker-attributed words |
| `segments` | `DiarizationResult` | интервалы speaker activity, могут пересекаться |
| `exclusive_segments` | `DiarizationResult` | интервалы без пересечения, именно по ним выбирается speaker для слова |
| `turns` | `DiarDonePayload`, `DiarTextDonePayload` | сгруппированные реплики speaker-а для UI |
| `text_raw`, `turns_raw` | `DiarTextDonePayload` | результат до syntax postprocess |
| `text_syntax`, `turns_syntax` | `DiarTextDonePayload` | результат syntax postprocess, если он включен и применен |
| `syntax_sentence_count`, `syntax_low_confidence_count` | diar signals/payloads | диагностические счетчики syntax postprocess |

![PNG: Базовый поток Transcribe + Diarize](#archive-availability)

```mermaid
flowchart LR
    A["Человек нажал Transcribe + Diarize"] --> B["WebUI принял файл"]
    B --> C["WebUI сделал health check diarization"]
    C --> D["Локальный file runner внутри того же сервиса"]
    D --> E["Сделать normalized.wav"]
    E --> F["Отправить куски в Whisper ASR"]
    E --> G["Если backend жив: отправить весь файл в выбранный diarization sidecar"]
    F --> H["Получить слова и таймкоды"]
    G --> I["Получить интервалы спикеров"]
    H --> J["Склеить слова + спикеров"]
    I --> J
    J --> K["Отправить plain text и speaker transcript обратно в UI"]
```

### Шаг 1. Пользователь открывает страницу

Страница лежит здесь:

- [src/webui/static/index.html](</experiments/voice-ai/archive/y/src/webui/static/index.html>)

Логика страницы лежит здесь:

- [src/webui/static/app.js](</experiments/voice-ai/archive/y/src/webui/static/app.js>)

HTTP-страницу отдаёт этот сервер:

- [src/webui/server.py](</experiments/voice-ai/archive/y/src/webui/server.py>)

### Шаг 2. Браузер отправляет файл

Браузер делает `POST /api/file-transcribe`.

Где это обрабатывается:

- [src/webui/server.py](</experiments/voice-ai/archive/y/src/webui/server.py>)

Что именно там происходит:

- принимается файл
- читаются `language`, `session_id`, `job_id`
- файл сохраняется на диск
- если страница `/whisper-diarization`, сначала проверяется доступность выбранного diarization sidecar через `/healthz`
- потом в том же процессе запускается local file runner

Это точка входа всего file pipeline.

### Шаг 3. Локальный runner запускает file job

Основной orchestration-код тут:

- [src/stt_whisper_to_nats.py](</experiments/voice-ai/archive/y/src/stt_whisper_to_nats.py>)

Этот worker не является моделью.
Он просто управляющий код.

Его задача:

- взять файл
- привести его к правильному виду
- вызвать ASR
- вызвать diarization
- соединить результаты
- отправить progress и финальные payload обратно в NATS

### Шаг 4. Файл приводится к общему формату

Worker делает `normalized.wav`.

Где это видно:

- [src/webui/file_mode.py](</experiments/voice-ai/archive/y/src/webui/file_mode.py>)

Полезные места:

- `TARGET_SAMPLE_RATE = 16000`
- `write_normalized_audio_wav`
- `probe_wav_duration_s`

Что это значит очень просто:

- независимо от исходного формата (`wav/mp3/m4a/mp4`)
- мы делаем один нормальный `wav`
- моно
- 16 kHz

Зачем:

- чтобы downstream-сервисам было проще и стабильнее работать

### Шаг 5. После нормализации стартуют две параллельные ветки

Это очень важный момент.

Worker делает сразу две вещи:

1. отправляет куски аудио в ASR
2. отправляет весь `normalized.wav` в diarization

Это видно здесь:

- [src/stt_whisper_to_nats.py](</experiments/voice-ai/archive/y/src/stt_whisper_to_nats.py>)

Там есть явный старт background-task для diarization, а chunk transcription идёт своим циклом.

### Шаг 6. Ветка transcription

#### Что делает worker

Worker режет `normalized.wav` на куски.

Где это видно:

- [src/webui/file_mode.py](</experiments/voice-ai/archive/y/src/webui/file_mode.py>)

Полезные функции:

- `iter_normalized_wav_chunk_files`
- `estimate_chunk_total`

#### Куда отправляются куски

В сервис:

- `linto_stt_whisper_http`

Где это wired:

- [../../../rag-stack/stack/llm-models.drs](#archive-availability)

Где HTTP-вызов в коде:

- [src/webui/file_mode.py](</experiments/voice-ai/archive/y/src/webui/file_mode.py>)
- [src/stt_whisper_to_nats.py](</experiments/voice-ai/archive/y/src/stt_whisper_to_nats.py>)

#### Что возвращает ASR

ASR возвращает:

- plain text
- список слов
- таймкоды слов

Это видно по тому, что builder работает с `TimedWord`, а worker требует word timestamps.

Кодовые ссылки:

- [src/webui/file_mode.py](</experiments/voice-ai/archive/y/src/webui/file_mode.py>)
- [src/stt_whisper_to_nats.py](</experiments/voice-ai/archive/y/src/stt_whisper_to_nats.py>)

#### Где склеивается итоговый plain text

За это отвечает:

- `TimedTranscriptBuilder`

Где смотреть:

- [src/webui/file_mode.py](</experiments/voice-ai/archive/y/src/webui/file_mode.py>)

Очень важная деталь:

- worker создаёт `TimedTranscriptBuilder(... require_word_timestamps=True)`

Это значит:

- для нашего speaker transcript нужны не просто буквы
- нужны именно слова с таймкодами

Если таймкодов слов нет, builder выбрасывает ошибку.

Это ещё одно доказательство, что speaker transcript нельзя собрать одной diarization-моделью.

### Шаг 7. Ветка diarization

#### Куда отправляется файл

Worker отправляет целиком `normalized.wav` в:

- `DIARIZATION_HTTP_URL`

HTTP-клиент лежит тут:

- [src/diarization.py](</experiments/voice-ai/archive/y/src/diarization.py>)

Функция:

- `diarize_http_json`

Что туда дополнительно передаётся:

- `job_id`
- `session_id`
- `job_file_name`
- ограничения по числу спикеров, если заданы

#### Кто принимает этот запрос

Sidecar:

- [src/pyannote_diarization_server.py](</experiments/voice-ai/archive/y/src/pyannote_diarization_server.py>)

Endpoint:

- `POST /diarize`

#### Что делает sidecar

1. читает файл
2. грузит `pyannote/speaker-diarization-community-1`
3. прогоняет файл через `Pipeline.from_pretrained(...)`
4. получает интервалы говорящих
5. возвращает JSON без текста

Где это видно:

- [src/pyannote_diarization_server.py](</experiments/voice-ai/archive/y/src/pyannote_diarization_server.py>)

Особенно важно смотреть:

- `load_settings`
- `_load_blocking`
- `_run_pipeline_blocking`
- `create_app`

#### Что sidecar публикует в NATS

Sidecar умеет публиковать live progress:

- `diar_progress`
- `diar_text_progress`

Где это видно:

- [src/pyannote_diarization_server.py](</experiments/voice-ai/archive/y/src/pyannote_diarization_server.py>)

Это нужно только для прогресса.
Финальный speaker transcript всё равно собирает не sidecar, а worker.

### Шаг 8. Где именно происходит "магия склейки"

Вот она:

- [src/stt_whisper_to_nats.py](</experiments/voice-ai/archive/y/src/stt_whisper_to_nats.py>)
- [src/diarization.py](</experiments/voice-ai/archive/y/src/diarization.py>)

Порядок такой:

1. worker ждёт завершения transcription
2. worker ждёт завершения diarization
3. worker берёт `transcript.timed_words()`
4. worker берёт `diarization_payload.exclusive_segments`
5. вызывает `attribute_words(...)`
6. вызывает `build_speaker_turns(...)`
7. вызывает `render_speaker_text(...)`

То есть итоговый speaker transcript строится не в модели, а в нашей glue-логике.

## Что такое `exclusive_segments` простыми словами

Это список интервалов времени, где pyannote решила:

- вот здесь говорил спикер A
- вот здесь говорил спикер B

Пример мысленно:

```text
0.0 - 1.8  SPEAKER_00
1.8 - 3.2  SPEAKER_01
3.2 - 5.0  SPEAKER_00
```

Это ещё не текст.
Это просто раскрашенная линейка времени.

Потом сверху на эту линейку накладываются слова из ASR.

## Почему без transcription нельзя получить speaker transcript

Потому что для speaker transcript нужны одновременно две вещи:

- какие были слова
- кто именно произносил каждое слово

`pyannote` знает вторую часть, но не знает первую.

Если выключить ASR и оставить только diarization, на выходе будет что-то вроде:

```json
{
  "speaker_count": 2,
  "segments": [
    {"speaker": "SPEAKER_00", "start_s": 0.0, "end_s": 1.8},
    {"speaker": "SPEAKER_01", "start_s": 1.8, "end_s": 3.2}
  ]
}
```

Это полезно, но это ещё не:

```text
SPEAKER_00: Привет
SPEAKER_01: Добрый день
```

Для второй формы нужен ещё Whisper.

## Где сейчас включены GPU runtime и batch параметры

Главные deploy-файлы:

- [../../../rag-stack/stack/llm-models.drs](#archive-availability)
- [../../../rag-stack/stack/diarization-models.drs](#archive-availability)

Что там важно:

- `linto_stt_whisper_http`
  - `MODEL: 'large-v3-turbo'`
  - `DEVICE: 'cuda'`
- `pyannote_diarization_shared`
  - `PYANNOTE_PIPELINE_ID: 'pyannote/speaker-diarization-community-1'`
- `pyannote_diarization_isolated`
  - отдельный MIG slice для тестов без конкуренции с LinTO
- `sherpa_onnx_diarization`
  - реальный ONNX sidecar, полезен как CPU/lightweight кандидат и независимый контроль против PyTorch/MIG проблем
- `sortformer_diarization`
  - реальный NeMo/NVIDIA sidecar на GPU, быстрый кандидат для длинных файлов, но с ограничением до четырёх спикеров

То есть на сегодня:

- transcription = GPU
- diarization = отдельный diarization stack; pyannote batch/clustering меняются из WebUI без размножения stack-ов

## Почему diarization вынесена отдельно

Потому что production падал не на уровне "наш код что-то не так сделал", а внутри CUDA allocator / NVML path PyTorch.

Кратко:

- import и startup проходили
- pipeline загружался
- падение происходило на первом реальном inference

Технический симптом:

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

Что из этого следует:

- проблема была ниже бизнес-логики приложения
- смена HTTP логики не лечит эту проблему
- shared MIG может конкурировать с ASR
- isolated MIG даёт честный benchmark batch sizes 1/2/4/8 без запуска новых stack-ов

Где читать подробнее:

- [DOCS/FILE_DIARIZATION_STACK.md](</experiments/voice-ai/archive/y/DOCS/FILE_DIARIZATION_STACK.md>)
- [../../../rag-stack/README.md](#archive-availability)
- [../../../rag-stack/stack/diarization-models.drs](#archive-availability)

## Где сейчас переключать shared/isolated и batch

В двух местах:

- runtime services: [../../../rag-stack/stack/diarization-models.drs](#archive-availability)
- request options: `/whisper-summary/cases/pyannote_diarization`

Ищи:

```ruby
PYANNOTE_RUNTIME = 'cpu' # cpu | gpu
```

Если поменять на:

```ruby
PYANNOTE_RUNTIME = 'gpu'
```

то deploy поднимет diarization уже на GPU-конфигурации.

Важно:

- transcription от этого не меняется
- меняется только runtime сервиса `pyannote_diarization`

## Что видит UI

Фронтенд здесь:

- [src/webui/static/index.html](</experiments/voice-ai/archive/y/src/webui/static/index.html>)
- [src/webui/static/app.js](</experiments/voice-ai/archive/y/src/webui/static/app.js>)
- [src/webui/static/styles.css](</experiments/voice-ai/archive/y/src/webui/static/styles.css>)

Что получает UI:

- обычные file events по transcription
- отдельные diarization progress events
- отдельный `Speaker transcript`
- либо transcript-only fallback reason, если pyannote backend недоступен на submit-time

Что важно понимать:

- plain text может приехать раньше
- speaker transcript может приехать позже
- это нормально, потому что diarization идёт своей веткой
- если diarization backend не стартовал, страница `/whisper-diarization` всё равно остаётся рабочей и показывает обычный transcript

## Страница `/status/`: быстро понять, что живо

Есть отдельная страница диагностики:

- `/status/` — человек открывает в браузере
- `/status/health` — простой liveness самого diagnostics WebUI
- `/status/api/diagnostics` — JSON со всеми проверками
- `/status/api/diagnostics?smoke=1` — те же проверки плюс короткие smoke tests
- `/health/` остаётся compatibility alias на тот же diagnostics WebUI

Самая простая мысль: это приборная панель. Она не чинит сервисы сама, а быстро показывает, на какой границе сломалось: браузер, NATS, core, agent, LinTO, pyannote или другой sidecar.

![PNG: Страница status: обзор проверок](#archive-availability)

```mermaid
flowchart LR
    A["Открыли /status/"] --> B["Diagnostics WebUI"]
    B --> C["Проверил webrtc-komaroff"]
    B --> D["Проверил NATS"]
    B --> E["Проверил LinTO HTTP GPU"]
    B --> F["Проверил diarization sidecar-ы"]
    B --> G["Проверил agent/workflow/LLM"]
    C --> H["OK / DEGRADED / FAIL"]
    D --> H
    E --> H
    F --> H
    G --> H
```

Что считается красным:

- `src_core` не отвечает
- NATS не подключается
- active workflow health не отвечает
- LinTO HTTP `/healthcheck` красный
- shared `pyannote` `/healthz` красный
- browser не может открыть `/ws`

Что считается жёлтым:

- optional sidecar сломан: `sherpa-onnx`, `sortformer`, isolated `pyannote`
- optional страница сломана: `/whisper-staged`, `/whisper-bench`

![PNG: Страница status: state machine](#archive-availability)

```mermaid
stateDiagram-v2
    [*] --> Проверка
    Проверка --> OK: всё обязательное и optional зелёное
    Проверка --> DEGRADED: обязательное зелёное\noptional сломан
    Проверка --> FAIL: обязательная граница сломана
```

Подробная техническая схема лежит тут:

- [../webrtc-komaroff/DOCS/DIAGNOSTICS.md](</experiments/voice-ai/archive/c/DOCS/DIAGNOSTICS.md>)

### Почему `/status/` может открывать старый Voice Assistant

Если `https://webrtc-komaroff.gis-master.ru/status/` открывает обычный `Voice Assistant`, а не `Voice Chat Status`, значит новый route не попал в production stack. Это не чинится ручным `docker` на `gis-master`: надо, чтобы GitLab deploy job для `webrtc-komaroff` успешно выполнил `dry-stack` на `ssh://gis-master.ru`.

Для этого у deploy job должна быть SSH identity. Сейчас поддержаны два нормальных GitLab-варианта:

- runner volume `ssh-agent-socket` уже содержит ключ;
- в CI/CD variables проекта или группы `voice-chat` задан protected `GIS_MASTER_SSH_PRIVATE_KEY_B64`.

`GIS_MASTER_SSH_PRIVATE_KEY_B64` — это base64 от приватного deploy-ключа:

```bash
base64 -w0 ~/.ssh/<deploy-key>
```

Сам ключ должен быть разрешен на `[адрес скрыт]`. После добавления variable надо rerun pipeline `webrtc-komaroff`; в логах должно быть `loading SSH key from GitLab CI base64 variable GIS_MASTER_SSH_PRIVATE_KEY_B64`.

## Какие NATS subjects участвуют

Основные subject-ы:

- `inference.whisper.file.event.<job_id>`
- `inference.whisper.file.session.<session_id>`
- `inference.whisper.file.diar.event.<job_id>`
- `inference.whisper.file.diar_text.event.<job_id>`
- `inference.whisper.file.diar_text.session.<session_id>`

Важно:

- отдельного `inference.whisper.file.job` больше нет
- file job стартует локально внутри WebUI runtime
- NATS используется для progress/result и для real-time diarization updates

Где они wired:

- [src/env.example](#archive-availability)
- [src/webui/file_mode.py](</experiments/voice-ai/archive/y/src/webui/file_mode.py>)
- [src/stt_whisper_to_nats.py](</experiments/voice-ai/archive/y/src/stt_whisper_to_nats.py>)
- [src/pyannote_diarization_server.py](</experiments/voice-ai/archive/y/src/pyannote_diarization_server.py>)
- [../../../rag-stack/stack/llm-models.drs](#archive-availability)

## Какие файлы читать по порядку, если хочется реально владеть кодом

Если читать совсем с нуля, открывай в таком порядке:

1. [src/webui/static/index.html](</experiments/voice-ai/archive/y/src/webui/static/index.html>)
2. [src/webui/static/app.js](</experiments/voice-ai/archive/y/src/webui/static/app.js>)
3. [src/webui/server.py](</experiments/voice-ai/archive/y/src/webui/server.py>)
4. [src/webui/file_mode.py](</experiments/voice-ai/archive/y/src/webui/file_mode.py>)
5. [src/stt_whisper_to_nats.py](</experiments/voice-ai/archive/y/src/stt_whisper_to_nats.py>)
6. [src/diarization.py](</experiments/voice-ai/archive/y/src/diarization.py>)
7. [src/pyannote_diarization_server.py](</experiments/voice-ai/archive/y/src/pyannote_diarization_server.py>)
8. [src/sherpa_onnx_diarization_server.py](</experiments/voice-ai/archive/y/src/sherpa_onnx_diarization_server.py>)
9. [src/sortformer_diarization_server.py](</experiments/voice-ai/archive/y/src/sortformer_diarization_server.py>)
10. [src/env.example](#archive-availability)
11. [docker/docker-compose.yml](#archive-availability)
12. [docker/diarization/Dockerfile](#archive-availability)
13. [docker/sherpa-onnx/Dockerfile](#archive-availability)
14. [docker/sortformer/Dockerfile](#archive-availability)
15. [tests/test_diarization.py](</experiments/voice-ai/archive/y/tests/test_diarization.py>)
16. [DOCS/FILE_DIARIZATION_STACK.md](</experiments/voice-ai/archive/y/DOCS/FILE_DIARIZATION_STACK.md>)
17. [CODEMAP.md](</experiments/voice-ai/archive/y/CODEMAP.md>)
18. [../../../rag-stack/stack/llm-models.drs](#archive-availability)

## На что смотреть в коде в первую очередь

Если нужна очень короткая дорожная карта:

- "откуда прилетает файл" -> [src/webui/server.py](</experiments/voice-ai/archive/y/src/webui/server.py>)
- "как файл нормализуется и режется" -> [src/webui/file_mode.py](</experiments/voice-ai/archive/y/src/webui/file_mode.py>)
- "как worker вызывает transcription и diarization" -> [src/stt_whisper_to_nats.py](</experiments/voice-ai/archive/y/src/stt_whisper_to_nats.py>)
- "как worker вызывает sidecar" -> [src/diarization.py](</experiments/voice-ai/archive/y/src/diarization.py>)
- "что именно возвращают diarization sidecar-ы" -> [src/pyannote_diarization_server.py](</experiments/voice-ai/archive/y/src/pyannote_diarization_server.py>), [src/sherpa_onnx_diarization_server.py](</experiments/voice-ai/archive/y/src/sherpa_onnx_diarization_server.py>), [src/sortformer_diarization_server.py](</experiments/voice-ai/archive/y/src/sortformer_diarization_server.py>)
- "как слова получают speaker label" -> [src/diarization.py](</experiments/voice-ai/archive/y/src/diarization.py>)
- "где выбрать shared/isolated и batch для diarization" -> `/whisper-summary/cases/pyannote_diarization`

## Очень короткий FAQ

### Кто пишет текст?

Whisper в сервисе `linto_stt_whisper_http`.

### Кто определяет спикеров?

`pyannote/speaker-diarization-community-1` в сервисе `pyannote_diarization`.

### Кто делает итоговый speaker transcript?

Не модель.
Его делает наш Python glue-код после склейки двух результатов.

### Можно ли получить speaker transcript одной diarization-моделью?

Нет, не в текущей архитектуре.
Она не возвращает текст.

### Почему plain text приходит раньше, чем speaker transcript?

Потому что это две разные ветки вычисления.
ASR может закончиться раньше, чем diarization.

### Что будет, если diarization выключить?

Останется обычный plain transcription.
Speaker transcript исчезнет.

### Что будет, если transcription сломается, а diarization нет?

Будут интервалы спикеров, но не будет нормального speaker transcript, потому что не будет слов с таймкодами.

## Короткий вывод

В нашей схеме:

- Whisper отвечает за слова
- pyannote отвечает за спикеров
- `stt_whisper_to_nats` и `diarization.py` отвечают за склейку

Поэтому фраза:

> "диаризация делает транскрипцию"

для текущего кода неверна.

Правильно так:

> "транскрипция делает слова, диаризация делает говорящих, а наш glue-код делает speaker transcript".
