# Потоковое распознавание без перебивания

## Что меняется

Для голосового чата CPU-мост теперь может работать в `ASR_MODE=streaming`.
Он последовательно проверяет новые 32-мс окна Silero, а не пересканирует всю
накопленную запись. У каждого подключения собственное состояние VAD. Веса ONNX
общие и неизменяемые; рекуррентное состояние не делится между чатами.

После обнаружения речи мост передаёт 200 мс предзаписи и следующие PCM-кадры
отдельному GPU-worker. Worker повторно декодирует ограниченное окно, выдавая
гипотезы по мере поступления речи. После 450 мс паузы мост запрашивает final.
Из-за 32-мс окон граница паузы округляется; смешанное окно на границе речи
добавляет ещё до 32 мс. Final относится к фразе, а не к закрытию транспорта.

```text
Core: PCM 16 кГц → CPU-мост: Silero → NATS inference → GPU: faster-whisper
                          ↑                               ↓
                          └──────── partial / final ───────┘
                                      ↓
                     Core → один черновик UI / один запрос агенту
```

При ответе агента микрофон выключается. Частичные гипотезы не запускают агента.
Отмену ответа и перебивание этот путь не реализует. Файловый LinTO HTTP runner
и страницы транскрипции/диаризации не переведены на streaming. По умолчанию
остаётся прежний `ASR_MODE=batch`.

## Wire contract

Пакет: `[длина JSON: u32 BE][JSON UTF-8][PCM16 LE]`. PCM — mono, 16000 Гц,
2 байта на сэмпл. Это не WAV/FLAC, не Opus и не Base64. Декодирование контейнера
и ресэмплинг выполняются до упаковки. Один frame содержит не больше 200 мс.

```json
{
  "type": "frame",
  "seq": 42,
  "session_id": "uuid-чата",
  "stream_id": "uuid-подключения",
  "sample_rate": 16000,
  "channels": 1,
  "sample_width": 2,
  "traceparent": "00-<32 hex trace id>-<16 hex span id>-01"
}
```

На GPU-плече добавляются `phrase_id` и `speech_samples`; `seq` начинает новую
последовательность для каждой фразы. JSON заголовок ограничен 4 KiB. Stream,
session и phrase IDs не могут содержать NATS wildcard или точку.

| Плечо | Subject |
|---|---|
| Core → CPU-мост | `webrtc.komaroff.asr.stream.<session>` |
| CPU → локальный inference export | `to.inference.webrtc.asr.audio.<stream>` |
| GPU получает | `inference.webrtc.asr.audio.<stream>` |
| GPU публикует | `inference.webrtc.asr.events.<stream>` |
| CPU получает через import | `from.inference.webrtc.asr.events.<stream>` |
| CPU → Core | `webrtc.komaroff.asr.text.<session>` |

```json
{
  "version": 1,
  "result_type": "partial",
  "session_id": "uuid-чата",
  "stream_id": "uuid-подключения",
  "phrase_id": "uuid-фразы",
  "revision": 2,
  "text": "два плюс"
}
```

Revision 1 — пустое уведомление CPU-моста о начале черновика; GPU продолжает
с revision 2. `final`, `rejected` и `error` терминальны. Ошибка содержит
короткий `code`, а не строку исключения. Пустой final не отправляется агенту.
Core и UI проверяют session, stream generation, revision и терминальную запись.
Повторные final игнорируются в рамках текущего подключения. Ledger ограничен
2048 записями; это не долговечная exactly-once гарантия NATS после рестарта.

## Очереди и ошибки

VAD-очередь ограничена 32000 сэмплами. GPU ограничивает отдельно необработанные
сэмплы двумя секундами и их возраст двумя секундами; сохранённое акустическое
окно ограничено восемью секундами. Оно не является очередью новых данных.
Одновременно допускается восемь фраз; один decoder обслуживает их по очереди.

Пропуск seq, переполнение или разрыв завершают повреждённую фразу с ошибкой.
После ошибки CPU-мост не распознаёт её оставшийся хвост: он ждёт полную паузу
и только потом принимает следующую фразу. На GPU abort делает tombstone,
поэтому запоздалый результат декодирования не возвращает закрытую фразу.
Фразы "Продолжение следует" и "Субтитры сделаны..." не запрещаются по тексту.
Приём зависит от наличия речи и показателей decoder, а не от списка слов.

## Модели и автономная поставка

Начальная модель — `large-v3-turbo`, русский язык, beam 1, temperature 0,
без `condition_on_previous_text`. Веса и Silero скачиваются только в model
stage сборки. Ревизии закреплены в
[prepare_models.py](#archive-availability). Runtime использует
локальный путь, `local_files_only` и offline-переменные. Нет файла модели —
нет готового worker, а не загрузка из интернета во время запроса.

Worker запускается неактивным: `ASR_ACCEPT_REQUESTS=0`. После загрузки модели,
warm-up и подключения к NATS `/healthz` возвращает `ready=true`, но
`accepts_requests=false`. При потере NATS readiness становится false.

Офлайн-проверка модели на машине разработчика:

```bash
# Выполнять из корня этого repository; зависимости — в отдельном venv.
python3 -m venv .asr-venv
.asr-venv/bin/pip install -r src/requirements.bridge.txt -r src/requirements.asr-worker.txt
# Эта команда предназначена для подготовки поставки и требует сети.
.asr-venv/bin/python -m src.streaming_asr.prepare_models --output ./models/asr
# Само распознавание использует уже подготовленные локальные веса.
HF_HUB_OFFLINE=1 .asr-venv/bin/python scripts/benchmark-streaming-asr.py \
  --file /путь/к/контролю.flac --model ./models/asr/large-v3-turbo \
  --vad ./models/asr/vad/silero_vad.onnx --show-text
```

Проба через поставленные CPU-мост и GPU-worker, без вызова агента:

```bash
# NATS_URL передаётся из одобренного credential channel, не из аргумента CLI.
# Probe генерирует отдельные session/stream IDs; Core-чату они не принадлежат.
.asr-venv/bin/python scripts/probe-streaming-asr.py --file /путь/к/контролю.flac
```

У probe собственные монотонные часы. Его roundtrip не следует выдавать за
серверную задержку после акустического конца речи. `--show-text` печатает текст
только локально; по умолчанию остаются числа и тип результата.

## OTLP и замеры

Trace context проходит в JSON заголовке, не в PCM. `voice.audio_receive` связан
с `asr.vad_queue`, `asr.vad`, `asr.gpu_queue`, `asr.decode`, `asr.result`,
`asr.publish` и `voice.agent_request`. Correlation/request IDs не заменяются.

Exporter работает в фоне, имеет ограниченную очередь и таймаут. Текст, аудио,
SDP и credentials не входят в атрибуты. Метрики используют только короткие
stage/result/code labels. `first_partial` измеряется от обнаружения речи;
`speech_final` — от поступления последнего речевого PCM-кадра до терминального
результата, включая паузу VAD; `endpoint_final` исключает ожидание паузы.
Это часы CPU-моста. Браузер отдельно показывает событие → следующий UI-кадр
в панели подключения. Смешивать эти часы вычитанием timestamp нельзя.

## Проверено 2026-10-02 - 2026-10-03 и что ещё не принято

- 150 Python tests passed, включая настоящий локальный NATS с двумя аккаунтами,
  два чата, sequence gap и восстановление только после паузы.
- После watchdog/переподключения хвост повреждённой фразы остаётся в карантине
  до паузы. Явный EOF закрывает generation; поздние кадры её не открывают.
  Health проверяет не только NATS, но и работающий decode loop; переполнение
  очереди декодирования даёт terminal error, а не потерянное ожидание.
- Символический PCM-контроль длинной фразы сохраняет все 80 токенов без дублей
  при ограничении окна. Он проверяет управление буфером, а не точность модели.
- OTLP HTTP-пакеты получены тестовым collector; секретный маркер и текстовый
  маркер не попали в payload. Отказ collector не заблокировал audio-path spans.
- Настоящие локальные Silero и faster-whisper turbo INT8 на CPU: чистый
  `2+2.flac`, белый шум 10 dB и gain 0.1 дали осмысленный контрольный вопрос;
  `raven.flac` дал непустой принятый результат. Для этих личных записей нет
  вручную проверенного полного word-level эталона, поэтому WER не заявляется.
- Синтетический eSpeak контроль имеет известный текст. На чистом звуке WER
  25%, с белым шумом 10 dB — 50%; фраза "Продолжение следует" не была заблокирована. Это показывает
  оставшиеся ошибки точности, а не обещание качества Codex.

Обновление 2026-10-03: worker поставлен на H100 в отдельный `webrtc-asr`,
использует одобренный существующий MIG `0:2`. Source image `8f9b2768`, версия
`0.1.203`, actual manifest
`sha256:e0f2171d47dbb10a9e8f3d810a5c01edc6c89f130ffd0f41fa52c6296cb3025d`.
Readiness, warm-up, NATS и реальный обмен inference подтверждены отдельно.
Voice CPU-мост работает в `ASR_MODE=streaming`, с этим же source image.
Geometry MIG, LinTO и общий rag-stack deploy не менялись.

После исправления OTLP `2+2.flac` через CPU/Silero + GPU дал 4 непустых partial
и один правильный final; первый текст через 1.29 с от начала paced file probe.
Параллельные `raven.flac` и синтетический контроль с белым шумом 10 dB дали
по одному final без ошибок очереди/seq. В синтетической речи остались две
ошибки словоформ. Личный transcript Raven не выводился. Эти пробы не создают
Core-чата и не вызывают агента. Заявлять общий WER или качество Codex нельзя.

Ещё нужны: production traces/метрики в Tempo/Grafana, p95 на прогретом GPU,
сравнение large-v3 на том же эталоне, длинные фразы и полный WebRTC/UI/agent
end-to-end. Сохранённый dashboard не является доказательством этих результатов;
Grafana сейчас снова требует входа Slim Auth. Файловая проба не проверяет
браузерный AEC/NS и Opus ingress. Ручной UI-сценарий остаётся за владельцем.

Первые реальные сборки выявили две ошибки поставки. `.dockerignore` исключал
новые requirements-файлы: добавлены точные исключения и тест контекста. Затем
системный pip GPU-образа упал в `get_topological_weights` с `AssertionError`.
Worker получает собственный venv и [pip 25.3](<https://pypi.org/project/pip/25.3/>),
а image build выполняет `pip check` и импорт его библиотек. Похожее падение
resolver зафиксировано в [issue pip](<https://github.com/pypa/pip/issues/10851>).
Прохождение этих проверок не заменяет CUDA/readiness-пробу на выделенном GPU.
CPU-образ при сборке выполняет одно окно Silero на включённых в поставку весах,
чтобы отличить наличие файла от рабочей ONNX-модели.

[Pipeline 4862](<https://git.giscloud.ru/voice-chat/py_faster_whisper/-/pipelines/4862>)
для `5be9a292ff13f52d4295e81f9be410d765b7175b` завершился успешно.
[Job 9546](<https://git.giscloud.ru/voice-chat/py_faster_whisper/-/jobs/9546>)
выполнила проверки библиотек и упакованной Silero, затем опубликовала
`stt_whisper_to_nats` и `webrtc_streaming_asr` в канал
`streaming-asr-no-barge-in:latest`. Это тестовая ветка, не `main` и не rollout.
Поздний docs-only commit не меняет исходники этих образов.

## Адресная CI-цепочка

### Ошибка первой live-пробы 2026-10-03

Worker впервые поставлен в отдельный `webrtc-asr` на существующий MIG `0:2`.
Прогрев прошёл, `/healthz` сообщил готовность модели и NATS. После разрешения
запросов изолированная проба `2+2.flac` через WSS не получила текст: пришли
`audio_queue_overflow`, затем `sequence_gap` и `stream_disconnected`.

Лог конкретной задачи показал первичную причину: `View("webrtc.asr.duration")`
передавал строку как `instrument_type`, а SDK ожидал Python type. Проверка
`isinstance` падала при первой записи метрики, до добавления PCM в decoder.
Watchdog видел накопление очереди; его собственный счётчик тоже падал и
останавливал worker. Новый контейнер получал хвост уже начатой последовательности.
Ошибки очереди и seq были следствиями, не доказательством нехватки GPU или
неверных прав NATS.

Исправление использует явный `instrument_name`. Metric wrappers прекращают
записывать неисправный instrument после одного предупреждения, но не прерывают
приём PCM и публикацию terminal event. Значение исключения не логируется.
Тест сначала воспроизвёл падение на старом коде. Теперь проверяются все четыре
реальные OTLP-метрики, отказ collector и завершение фразы при искусственной
поломке metric SDK. 153 tests passed. GPU image выполняет такую же запись
метрик в своём Python 3.10 runtime при сборке. Результат повторной live-пробы
фиксируется после публикации исправленного образа, не выводится из этих tests.

Дополнительный regression проверяет pacing probe: `publish` не ожидает
сетевой RTT после каждого 20-мс кадра; flush выполняется на границах.
154 tests passed и pipeline 4873 подтвердил checks. Этот commit меняет только
probe/tests вне image context: build-labels выбрал `services: {}` и не публиковал
новый image. Исправленный `main:latest` опубликован именно job 9574 pipeline 4871,
source `8f9b2768`. Последующая task/manifest-проверка отличила настоящий rollout
от успешного повторного deploy неизменённого tag с `--resolve-image=never`.

`streaming checks` проверяет новый контракт, локальный NATS и OTLP перед сборкой.
`build streaming asr` публикует только CPU-мост и новый GPU-worker через
[streaming-compose.yml](#archive-availability). Прежние сборка всех
моделей и deploy остаются manual: их не нужно запускать для этой поставки.
Образы имеют обычный branch:latest; digest используется для проверки, не
вписывается в DRS. Новый worker ставится отдельной job из rag-stack и сначала
не принимает запросы. Core/UI должны быть поставлены до включения streaming.

Общий образ CPU-моста используется несколькими сервисами. Даже адресная
публикация этого образа может привести к их обновлению registry_listener.
Поэтому совместимость `ASR_MODE=batch` обязательна, а публикация образа — не
доказательство переключения голосового чата.

## Code map

| Файл | Ответственность |
|---|---|
| [protocol.py](#archive-availability) | Валидация бинарного пакета и типов результата |
| [vad.py](#archive-availability) | Однократная обработка окон, preroll, endpoint, quarantine |
| [bridge.py](#archive-availability) | Изоляция потоков, очереди и NATS namespace |
| [worker.py](#archive-availability) | Локальный decoder, scheduler, partial/final, readiness |
| [telemetry.py](#archive-availability) | W3C context, spans, метрики и background export |
| [probe-streaming-asr.py](#archive-availability) | Изолированный файловый NATS probe без агента |
| [benchmark-streaming-asr.py](#archive-availability) | Offline модель, VAD, шум и WER по заданному эталону |
| [streaming-compose.yml](#archive-availability) | Адресная публикация двух ASR-образов |

Тесты контракта: `tests/test_streaming_asr.py`; broker/account mapping:
`tests/test_streaming_nats_integration.py`; настоящий OTLP HTTP export:
`tests/test_streaming_telemetry.py`. Старые файловые tests остаются в suite.
