webrtc-komaroff ↔ Codex CLI / ChatGPT / Community

Свой голосовой агент без внешних моделей

Разбор текущего кода, сравнение архитектуры и план автономного контура. Таблицы, кликабельные схемы, code-map и проверяемые критерии вместо обещанного ускорения.

01Что стоит перенять и что оставляем своим

Из Codex полезно взять разделение звука, управления разговором и работы агента. Для изолированной сети эту схему нужно собрать на собственных ASR, LLM и TTS. Перенос ChatGPT endpoint или токена автономности не даст.

У нас уже есть большая часть нужных границ: браузерный клиент, WebRTC в src_core, отдельное распознавание, src_agent, сценарии и Postgres. Я бы не начинал с замены всего стека. Сначала ограничил бы очереди и работу в полёте, замкнул зависимости внутри каждой площадки и подключил обратный аудиотрек. После этого - единые события сессии и перебивание ответа.

Этот учебник идёт от фактического пути речи к варианту автономного развёртывания, затем к изменениям и их проверке. Таблицы сравнивают конкретные обязанности. Числа в критериях приёмки - предложенные цели для испытаний, а не уже полученные показатели.

Исследование кода: 2 октября 2026 года. Сервисы, Desktop, proxy/direct и основной вход не запускались и не менялись. Исходники приложения не редактировались, commit/push/deploy не выполнялись.

Какие версии сравниваются

СистемаИсследованный срезЧто это доказывает
webrtc-komaroff, GitLab main1fc1702859381062eb7d3ffec2d3b0e310b1ebef, получен через fetch без checkoutОсновной источник сравнения. Не доказывает запущенный image или live-конфигурацию.
Локальный webrtc-komarofff0a6eac965c7d6be8a353265775b676248855905Старее main. Существующие untracked DOCS, presentations и videos сохранены.
ASR bridge py-faster-whisper21c85b0c7bc7a96497ce78ec2ead44b113f18db6, соседний локальный checkoutПодтверждает его реализацию. Совпадение с image в работающем стеке не проверено.
Codex CLIОфициальные Rust-исходники rust-v0.156.1, сохранённый срез из предыдущего исследованияПодтверждает устройство клиента, RPC и signaling. Серверные модели ChatGPT закрыты.
Community fork60cd00805b777bced0e6069d8bc52e529cc05cbb + незакоммиченные аудиодемо; Desktop-бандл 26.928.20755 повторно прочитан 2 октябряФрагменты захвата проверены статически. Это не проверка настроек запущенного окна, качества звука или всего installed payload.

Успешные обращения к ChatGPT из прежнего учебника - исторические результаты, здесь они не повторялись. Для этого сравнения достаточно клиентского контракта. Мы не знаем внутреннее устройство серверного ASR/TTS, аппаратные ресурсы ChatGPT и его задержки в одинаковых с нашим проектом условиях.

Опора: docs/codex-cli-voice-textbook.html:1 · src_core/handlers/handle_offer.py:63 · src_agent/agent.py:138

К началу главы

02Сначала отделим транспорт от распознавания

WebRTC доставляет звук, но не понимает слова. ASR, или STT, превращает звук в текст. LLM выбирает или формирует текстовый ответ. TTS превращает этот текст в звук. Агент отличается от обычной модели тем, что может выбрать сценарий, вызвать инструмент, сохранить состояние и вернуть результат действия.

ТерминПростое объяснениеЗачем он здесь
SDP / signalingТекстовое описание возможностей соединения и обмен им до разговораPOST /core/offer передаёт не запись голоса, а предложение WebRTC.
ICE, STUN, TURNICE выбирает путь; STUN помогает найти адрес; TURN ретранслирует медиа, когда прямой путь не получаетсяВ автономной площадке эти службы либо местные, либо не нужны при доступных host candidates.
Opus / RTP / SRTPКодек сжимает звук; RTP переносит пакеты; SRTP защищает медиаданныеЭто не WAV, не multipart и не JSON с Base64.
PCM16LEСырые знаковые 16-битные сэмплы с little-endian порядком байтовПосле декодирования WebRTC у нас ASR получает mono 16 кГц.
VADОпределение участков речи и тишиныГраница фразы влияет на момент запуска распознавания и задержку ответа.
SidebandОтдельный канал событий и команд, рядом с медиапотокомРаспознанный текст, состояние, отмена и вызовы tools не обязаны ехать вместе со звуком.
Сессия / ход / itemСессия - разговор; ход - одна работа; item - реплика или результат внутри ходаИдентификаторы не дают старому ответу попасть в новый разговор.
BackpressureЧто делаем, когда потребитель медленнее источникаНужно ограничение памяти и явная политика: ждать, отбрасывать или завершать поток с ошибкой.
p95 / RTFp95 - граница для 95% замеров; RTF - время вычисления, делённое на длительность аудиоRTF < 1 нужен для устойчивого непрерывного распознавания на выбранной нагрузке; это не гарантия малой задержки первого текста.

Поэтому вопрос "у нас тоже WebRTC, почему ответ медленнее?" решается измерением всего пути. После быстрого транспорта могут идти ожидание конца фразы, файл, несколько вызовов модели и ожидание полного TTS. Сам WebRTC эти задержки не убирает.

Опора: src_core/handlers/handle_track.py:18 · src/stt_whisper_to_nats.py:341

К началу главы

03Как сейчас проходит речь в webrtc-komaroff

Браузер согласовывает соединение через Core, а затем отправляет микрофон по WebRTC. Core декодирует и нормализует аудио, передаёт кадры в NATS. Соседний ASR bridge находит законченные фразы, записывает временный WAV и отправляет файл в LinTO. Только после получения текста начинается работа агента.

Текущий путь webrtc-komaroff: речь приходит, ответ возвращается текстомOpus / SRTPNATS: meta + PCMmultipart WAVJSON запространзакцияtext/plain → bridgeNATS JSON текст → Corenats.agent.<user>: текстNATS events /ws → текст UIБраузерmic + WebRTCsrc_coreaiortc → PCM 16kASR bridgeVAD → фраза WAVLinTO ASRHTTP /transcribesrc_agentход + workflowPostgresruntime + историяLLM + toolsNATS request/reply
Текущий путь webrtc-komaroff: речь приходит, ответ возвращается текстом. Блоки открывают пояснения; сплошная стрелка - медиаданные, пунктир - сигнализация, события и вызовы сервисов.

Полученный ответ сценария сохраняется вместе с состоянием сессии и публикуется как текст или команды интерфейса. На схеме LinTO и LLM вынесены отдельно: текущая конфигурация использует соседний вычислительный контур, а не обязательно интернет. Для независимой площадки этот контур всё равно надо перенести внутрь её границы.

Обратный аудиотрек предусмотрен типом соединения sendrecv и обработчиком peer.ontrack в браузере. Но в проверенных handle_track и графе нет TTS producer и назначения его трека в sender Core. Наличие HTML audio element не доказывает, что агент говорит. Этот вывод относится к просмотренному voice-agent пути, а не ко всем экспериментам и TTS-демонстрациям репозитория.

Опора: src_core/handlers/handle_offer.py:63 · src_core/handlers/handle_track.py:18 · src_core/processors/whisper_stream_node.py:76 · src/stt_whisper_to_nats.py:341 · src_agent/agent.py:138 · src_agent/ui_events.py:15

К началу главы

04Что передаётся на каждом участке

У одного разговора несколько разных форматов. Между браузером и Core идут сжатые медиапакеты. Между Core и bridge идут сырые сэмплы. Между bridge и ASR снова файл с контейнером. Агент получает только текст. Ни один из этих форматов нельзя без преобразования подставить вместо другого.

Порядок: соединение, аудио, конец фразы, распознавание и ответBrowserCoreASR bridgeLinTOAgentPOST /core/offer: JSON SDPJSON SDP answer + session_idOpus / SRTP: непрерывное медиаNATS meta + PCM16LE 16kVAD конец фразы → multipart WAVASR HTTP: финальный text/plainNATS: text + phrase/session IDsNATS request: text → workflow + модели/toolsNATS /ws: финальный ответ UI после сохраненияПауза VAD и время HTTP ASR - разные задержки. Обратный TTS в этой последовательности пока не подключён.
Время идёт сверху вниз. После SDP медиа живёт своим циклом; шаги ниже описывают одну завершённую фразу.

Речь поступает кадрами, пока пользователь говорит. Распознавание одной фразы стартует после решения VAD, а агент - после final transcript. Pending показывается раньше текста, но не означает, что partial уже распознан. В этой последовательности результат Agent публикуется по NATS/WS: стрелка к браузеру не является новым прямым HTTP вызовом Agent.

Участок / endpointЗапросОтвет / следствие
Browser → POST /core/offerapplication/json: {sdp,type:"offer",session_id?}. SDP с ICE/DTLS параметрами, без аудио.application/json: {sdp,type:"answer",session_id}. Core добавляет session_id после establish_connection.
Browser ↔ выбранный ICE peer CoreOpus в защищённом WebRTC RTP-потоке; кодек уточняется из SDP/getStatsДекодированные AudioFrame. Это не HTTP endpoint и не файл.
Core → inference.whisper.stream.<session>4 байта BE длины JSON + UTF-8 JSON metadata + raw PCM16LE; mono, 16000 Гц, шаг 20 мсseq считает пакеты, но producer не ждёт ASR acknowledgement каждого кадра.
ASR bridge → LINTO_HTTP_URLPOST, multipart поле file, WAV PCM16 mono 16k; query language=ru, Accept: text/plain. В DRS URL заканчивается /transcribe.Ответ читается целиком, извлекается текст. Через настроенный proxy используется CONNECT, без скрытого direct fallback.
Bridge → inference.whisper.text.<session>{pending:true,phrase_id,session_id}, затем {text,phrase_id,duration,session_id} либо errorЭто pending и финальный текст, не доказанный поток partial transcript.
Core → nats.agent.<USER_ID>JSON TraceEnvelope + text + turn_id + session_id + edit + user_idAgentInboundResponse; UI события публикуются отдельно. NATS request/reply не означает durable обработку.
Workflow → nats.llm.<user>JSON LlmRequest: mode, input, constraints и trace envelopeJSON LlmResponse. Ollama adapter вызывает POST /api/chat с stream:false, local adapter - llama.cpp.
UI события → browser /wsNATS по WebSocket; subject из конфигурации nats.events.user123ServerEvent: type, kind, data, uid, service, time. Payload некоторых событий содержит trace/session_id; не единый session-specific поток.

Один кадр: 640 байт аудио, не считая метаданных

Иллюстрация текущей упаковки без отправки пакета. Нулевой звук взят вместо личной записи.

# Модуль нужен для UTF-8 представления метаданных перед сырыми сэмплами.
import json
# Производитель объявляет формат сырых сэмплов, а не MIME файла.
meta = {"type": "frame", "seq": 1, "sample_rate": 16000,
        "sample_width": 2, "channels": 1, "session_id": "00000000-0000-4000-8000-000000000001"}
# 20 мс × 16000 сэмплов/с × 2 байта × 1 канал = 640 байт PCM.
raw = bytes(640)
meta_bytes = json.dumps(meta, ensure_ascii=False).encode("utf-8")
# Длина метаданных идёт big-endian, сами сэмплы - little-endian.
packet = len(meta_bytes).to_bytes(4, "big") + meta_bytes + raw

Пакет type:end заканчивает поток и не содержит аудио. 16 кГц × 2 байта дают 32 000 байт/с и 50 сообщений/с при шаге 20 мс на одного клиента, до NATS overhead. Это арифметика, не замер пропускной способности. Base64 здесь не используется и ради сходства с OpenAI добавлять его незачем.

Старые поля LINTO_WS_URL в DRS не отменяют код _transcribe_and_publish: в исследованном bridge он вызывает HTTP. Какой image сейчас работает в production, в этом исследовании не проверялось.

Опора: src_front/src/webrtc/negotiate.ts:3 · src_core/handlers/handle_offer.py:63 · src_core/processors/whisper_stream_node.py:76 · src/utils/wire_packet.py:9 · src/webui/file_mode.py:1202 · src/model_transport.py:6 · src_llm/clients/ollama_chat.py:12

К началу главы

05Как устроен голос Codex и чем он отличается от диктовки

В CLI 0.156.1 команда /voice начинает разговор: helper работает с устройствами и WebRTC, app-server ведёт управляющую часть, а ядро согласует разговор с агентной работой. Локальный app-server - не удалённая нейросеть и не обязательный посредник для каждого аудиопакета.

CLI /voice: медиа и события идут разными соединениямиtyped request / JSON-RPChelper IPC: SDP, mute, stopHTTPS: SDP + sessionWSS sideband: событияOpus / SRTP: отдельный медиапутьаудиоответ тем же WebRTCTUI / CLI/voice, команды, текстapp-server + ядроauth, signaling, handoffvoice-hostмикрофон и OpusRealtime serviceзакрытая серверная часть
CLI /voice: медиа и события идут разными соединениями. Блоки открывают пояснения; сплошная стрелка - медиаданные, пунктир - сигнализация, события и вызовы сервисов.

App-server создаёт call по HTTPS и принимает текстовые события по WSS sideband. Helper отправляет и получает медиа непосредственно через WebRTC. Эти роли могут быть разделены логически, даже когда app-server встроен в процесс TUI: штатный embedded client использует InProcess/mpsc, а не строки JSON в stdin. STDIO относится к нашим отдельным RPC-демо.

ПутьEndpoint / форматЧто можно взять
CLI /voice, ChatGPT backend V3POST https://chatgpt.com/backend-api/codex/realtime/calls?intent=quicksilver&architecture=avas; JSON {sdp,session} → plain SDP answer + Location/call ID. Sideband wss://api.openai.com/v1/live/<call_id>; медиа Opus/SRTP по ICE.Разделение сигнализации, событий и звука; call/thread/turn identities. Не сам закрытый endpoint.
Desktop диктовкаPOST /backend-api/transcribe, multipart аудиофайл → JSON text; в бандле есть и отдельная потоковая ветка диктовки.Отдельный файловый STT режим и строгая проверка MIME/контейнера. Не путать с voice conversation.
Desktop / ChatGPT voice conversationВ исследованном Desktop встречается иной маршрут /tbo/{tbo_id}/voice/calls; фактическая доступность режима для аккаунта не проверена.Не утверждать, что весь Desktop использует CLI V3.
Read Aloud ChatGPTGET /backend-api/synthesize?conversation_id=…&message_id=…&voice=…&format=mp3; ID существующего ответа → аудио.Связь озвучки с конкретной сохранённой репликой; локальная реализация хранит собственный text/response_id.
Linux Read Aloud форкаЛокальный Rust helper / CLI / MCP → Kokoro или custom command, Speech Dispatcher и другие backend; Piper подключается командой.Provider adapter и локальные модели. MCP - оболочка инструмента, не медиа транспорт.
app-server appendAudio / appendSpeechthread/realtime/appendAudio: Base64 audio + sampleRate/numChannels; appendSpeech: threadId + text. В WS-ветке outputAudio/delta возвращает PCM.Типизированный контракт и события. Это альтернативный путь, не доказательство, что V3 WebRTC-аудио проходит через RPC.

Границы того, что известно о ChatGPT

Клиентские исходники показывают отправленные поля и обработку событий. Они не показывают серверные ASR/TTS модели и не доказывают, что ChatGPT внутри собран как отдельные STT → LLM → TTS сервисы. В автономной системе мы сознательно предлагаем именно такой каскад: его компоненты можно поставить локально, заменить и проверить по отдельности. Сравнение не обещает разговорное качество ChatGPT на меньшем оборудовании.

Структура backend V3 в демо: session.model="gpt-live-1-codex", audio.output.voice="cove", delegation.type="client". Это исследованный версионный контракт, не публичная гарантия долговременной совместимости. Собственный сервис не должен эмулировать эту авторизацию или копировать cookies/attestation.

К началу главы

06Сопоставление обязанностей: что у нас уже есть

Сравнивать надо не имена библиотек, а границы работы. У нас есть аналог управляющей части app-server, но он распределён между Core, Agent и workflow runtime. Дополнительный сервис с названием app-server не нужен сам по себе: сначала достаточно согласованного интерфейса этих компонентов.

ОбязанностьCodex / forkwebrtc-komaroff mainРешение
Медиа отдельно от бизнес-логикиvoice-host и Realtime service; app-server управляетaiortc Core отдельно от Agent; после декодирования PCM идёт через NATSСохранить разделение. Замена PCM/NATS - только после нагрузочного доказательства узкого места.
Управление жизнью разговораrealtime start/stop, события, cancellation tokenconnect/disconnect и pc_lifecycle; voice blocked через UIДобавить явные состояния и единый cancel. Cleanup при ошибке уже есть в main.
Текстовые события и прогрессitem/transcript delta и завершение ходаpending, transcription, message, thought, model statusesЕдиный версионированный envelope, фильтр session/turn, resume. Partial STT не приписывать существующему bridge.
Ограничение незавершённой работыBounded channels в ядре; демо ограничивает input queueFanOut queue без лимита; publish task на каждый кадрИсправить лимиты и политику перегрузки прежде роста нагрузки.
Речь агента в ответДвусторонний WebRTC audio; локальный Read Aloud отдельноТекст ответа есть, обратный TTS producer в voice path не найденДобавить локальный TTS и обратный track, сохраняя text-only режим.
Идентификаторы и долговечное состояниеthread/call/realtime session/turn/itemsession/phrase/turn/request/trace; PostgresНе заменить IDs одной UUID; добавить generation/epoch и response_id. Транзакция уже исправлена.
Инструменты и делегацияHandoff соединяет голос с ядром агентаWorkflow вызывает собственные tools по NATSПеренять явную связь request/result/cancel/permission, а не shell-функции Codex.
Полная автономность площадкиChatGPT voice зависит от удалённого сервиса; локальная озвучка - иной путьЛокальный LLM путь есть; DRS указывает центральные ASR/LLM и leafnodeЗамкнуть media, control, модели, assets и управление внутри каждой площадки.

Три уже выполненных улучшения исключены из нового плана: deadline ICE gathering, очистка front ресурсов при отказе signaling и транзакция runtime+history. Остаются вопросы порядка ходов, отмены и идемпотентности tools: SQL-транзакция не отменяет уже выполненное внешнее действие.

Опора: src_core/processors/base.py:16 · src_core/processors/whisper_stream_node.py:76 · src_core/utils/ice.py:6 · src_front/src/webrtc/session.ts:19 · src_agent/agent.py:138 · codex-rs/core/src/realtime_conversation.rs:623

К началу главы

07Что означает независимая сеть на практике

Изоляция означает, что новая площадка может загрузиться и вести разговор без основной сети, интернет-DNS, центрального брокера и внешних моделей. "Всё в Docker" для этого недостаточно: контейнер может обратиться к основному H100, скачать модель или загрузить style.json снаружи.

ЗависимостьЧто найденоЧто требуется внутри площадки
Медиа адресаБраузер: stun.gis-master.ru; Core: iceServers=[]Доступные host candidates либо свой STUN/TURN. При разных подсетях/запрете UDP нужен проверенный TURN; HTTPS ingress не ретранслирует RTP автоматически.
ASRDRS: llm-models_linto_stt_whisper_http:80/transcribe через NATS proxyСвой ASR с весами и VAD. Direct LAN HTTP или локальный proxy с контрактом CONNECT; не зависеть от main-network receiver.
LLMLLM_MODE remote, nats-proxy-ollama.gis-master.ru; local llama.cpp путь существуетСвой Ollama в этой LAN либо локальный GGUF. Название remote в коде означает HTTP provider, а не обязательно интернет.
NATS topologyDRS: leafnode на 192.168.28.134:37422; локальный account + inference mappingsСамостоятельный broker и local responders. Удалённый leafnode в offline profile не нужен. Не смешивать изолированные площадки по user123.
МоделиLLM downloader обращается к Hugging Face при cache miss; VAD к GitHub при отсутствии ONNXПредустановленные точные веса, лицензии, SHA256, проверка отсутствия файла до старта. Offline mode должен явно запрещать download, а не ждать DNS timeout.
Браузер и картаMapLibre style: demotiles.maplibre.org/style.jsonЛокальные style, glyphs, sprites и tiles; отключаемый map fallback. Карта не должна блокировать голос. Это контекст UI, не замена исследования WebRTC.
HTTPS и устройстваgetUserMedia требует безопасного контекстаЛокальный доверенный CA, сертификаты, DNS и время. Для другого компьютера LAN HTTP обычно не подходит; localhost - специальное исключение.
Образы и пакетыОбразы и сборка используют registries и package sourcesЗаранее собранные images с digest, wheel/npm/model bundle; offline startup не запускает build/install/pull.
Состояние и наблюдаемостьPostgres, NATS events и OTel в проектеСобственные DB, backup, collector, логи и secret store на каждой площадке; никакого обязательного центрального telemetry sink.

Не предлагаю свести всё к одной машине. Независимая сеть может содержать несколько серверов, включая собственный GPU. Для одной CPU-машины нужны отдельные тесты качества и задержек. Если одновременно модели и их локальные аналоги отсутствуют, останется только заранее описанный сценарный помощник с записанными репликами; свободный разговор обещать нельзя.

Опора: src_front/src/config/appConfig.ts:54 · src_core/handlers/handle_offer.py:63 · stack/webrtc.drs:51 · src_llm/utils/model_downloader.py:12 · src/stt_whisper_to_nats.py:83 · src_front/src/map/mapController.ts:40

К началу главы

08Как собрать автономный контур, сохранив существующий проект

Предлагаю оставить браузер, aiortc, Agent, workflow и Postgres. Вокруг них задаём local-only профиль и добавляем обратное аудио. Голосовой Gateway здесь - ответственность Core, а не требование написать новый продукт или поставить Codex app-server в корпоративную систему.

Предложение: весь голосовой контур принадлежит одной изолированной площадкеPCM micOpus / SRTPNATS PCM16LE 16kSTT events: final + IDsLLM/tool JSON, deadlineanswer text + response IDcommit + eventsPCM → output track CoreWebRTC аудиоответPCM проигрывания + задержка → AECWSS control/eventsАудиоклиентмикрофон + динамикиОбработка звукаbrowser AEC / SonoraVoice Gatewayсуществующий Core + limitsASR / VADсобственные весаSession + AgentAgent / workflowLLM + toolsтолько локальный контурTTS workerсвои PCM/WAV → trackPostgres / eventsистория, result, outbox
Предложение: весь голосовой контур принадлежит одной изолированной площадке. Блоки открывают пояснения; сплошная стрелка - медиаданные, пунктир - сигнализация, события и вызовы сервисов.

Браузер: звук и понятное состояние

Браузер отправляет микрофон и воспроизводит обратный трек. В отдельном событийном канале он видит текущие session/turn IDs, распознанный текст, источник ответа, отмену и деградацию. Локальные NATS WS можно сохранить на первом шаге, но credentials должны быть с узкими правами. Позже application WSS gateway может скрыть broker от браузера и проверять доступ к сессии.

На схеме первые два блока находятся на одном клиентском устройстве. Для браузера сохраняем его встроенную обработку. Sonora предлагаем как альтернативу для нативного клиента, не как второй обязательный фильтр после browser AEC. Возвратная стрелка в AEC означает локальную копию реально проигрываемого PCM и его задержку, а не повторную отправку TTS на ASR.

Core: медиа, согласование формата и границы жизни

Core владеет peer connection, аудиочасами, bounded queues и ресемплингом. Он переводит WebRTC в собственный ASR контракт и ответ TTS обратно в WebRTC. Бизнес-сценарий остаётся в Agent: не надо переносить туда aiortc, а в Core - prompts и права tools. Локальный брокер соединяет workers, но не делает их автоматически независимыми от центральных моделей.

Опциональное подключение к основной сети строится позже как явно разрешённый adapter. Его отказ не должен менять local-only сценарий. В строгом offline profile fallback наружу запрещён. Можно синхронизировать выбранные обезличенные события, когда сеть снова появилась, но модельный контекст и голос не должны уходить туда без отдельной политики.

Опора: src_core/handlers/handle_track.py:18 · src_langgraph/runtime_io.py:48 · src_agent/agent.py:138 · src_front/src/assistant/assistantApp.ts:249

К началу главы

09Звук на клиенте: Sonora и проверка Opus

Sonora: предлагаемая обработка до отправки

Opus сжимает звук для сети. AEC убирает из микрофона ответ, который звучит в динамиках; NS подавляет шум, AGC регулирует усиление. В CLI 0.156.1 Sonora включена внутри voice-host. Обработчик получает PCM микрофона и копию воспроизводимого звука с задержкой, обрабатывает блоки по 10 мс при 48 кГц mono и собирает Opus-пакеты по 20 мс.

У нас startMic уже запрашивает браузерные AEC/NS; defaults включены. Сначала сохраняем в диагностике фактические track.getSettings() исходного микрофона. Sonora 0.2.0 включаем в список улучшений как прототип нативного клиента. Она работает локально, не требует ChatGPT или внешней модели. Браузерная интеграция потребует отдельного прототипа: готовое подключение WASM/AudioWorklet здесь не подтверждено. Возможности и лицензия BSD-3-Clause описаны в документации Sonora.

Что реализоватьПочему именно здесьКак проверить
Нативный audio adapter + Sonora под feature flagНа клиенте доступны микрофон, реальный playout PCM и временные метки. Один серверный фильтр перед ASR не знает фактическую громкость и задержку динамиков.48 кГц mono, блоки 480 float-сэмплов, согласованный render/capture delay; после mute, gap и смены устройства нет старых кадров. Проверить callback latency и CPU.
Сравнение со встроенным browser AEC/NSЗапрошенный флаг не подтверждает качество. Не включать обе цепочки последовательно без проверки.Тот же корпус и устройства: шум, только ответ из динамиков, одновременная речь. Остаточное эхо, ложные перебивания, WER, clipping, p95 задержки и CPU. Ни улучшение качества, ни экономия ресурсов пока не измерены.
Нативный reference test, затем акустический стендСинтетический сигнал проверяет алгоритм и временные метки, но не комнату и устройство.Upstream rendered_reference_changes_decoded_echo_capture есть, здесь не запускался. Наушники не заменяют испытание с динамиками; при провале оставить half-duplex и отключить прототип.

Opus: не добавлять заново, а проверить соединение

Мы используем browser WebRTC и aiortc 1.14.0, в котором Opus уже поддерживается и стоит первым в audio codecs. Это не доказывает выбор кодека в живом соединении. В исходниках собственного принудительного выбора Opus не найдено. Проверяем оба направления и отличаем SDP-предложение от фактически используемого codecId.

ПроверкаЧто фиксироватьКак трактовать
Offer и answer SDPСогласованный payload type, rtpmap audio/opus, fmtp, ptime/maxptime при наличии.opus/48000/2 означает RTP-clock 48 кГц и объявленную поддержку каналов; не доказывает stereo речи или 48 кГц исходной TTS-записи. Не задавать payload type жёстко.
pc.getStats в обе стороныinbound/outbound-rtp codecId → codec.mimeType/clockRate/channels/sdpFmtpLine; bytes/packets, loss, jitter, RTT и selected ICE pair, если доступны.Проверять активные audio streams с положительными приращениями счётчиков. Само наличие codec record не доказывает передачу. Недоступную метрику обозначать неизвестной, не нулём.
Параметры и потериИз согласованных параметров и измерений: bitrate, packet duration, mono/stereo, FEC/DTX при наличии. Для bitrate брать разность bytes за время, а не один накопленный счётчик.Указанный в fmtp FEC/DTX не доказывает фактическое использование в каждом пакете. Проверить декодированный ответ и качество при контролируемых потерях и jitter.
Приоритет, только если нуженsetCodecPreferences из getCapabilities; сохранить совместимые fallback codecs.Если согласуется нежелательный кодек, отдельно задать Opus первым и повторить negotiation/reconnect. Не пересжимать уже готовый WebRTC-поток; серверный ASR получает декодированный PCM.

Пример только читает настройки и статистику. Не меняет кодек, микрофон или соединение и не выполняется автоматически учебником.

// Пример для будущей диагностики рядом с connectSession, не внедрённый код.
// micTrack - исходный микрофон до replaceTrack/VAD, peer - текущий peer connection.
console.log("Параметры микрофона", micTrack.getSettings());
const report = await peer.getStats(); // Читаем статистику уже работающего соединения.
for (const stream of report.values()) {
  // Смотрим только локальные отчёты об отправленном или полученном аудио.
  if (!["outbound-rtp", "inbound-rtp"].includes(stream.type)) continue;
  if ((stream.kind ?? stream.mediaType) !== "audio" || stream.isRemote) continue;
  const codec = report.get(stream.codecId); // Связываем поток с его кодеком.
  const packets = stream.type === "outbound-rtp" ? stream.packetsSent : stream.packetsReceived;
  console.log("Поток и кодек", stream.id, packets ?? "неизвестно", codec?.mimeType ?? "неизвестно");
  // Полный отчёт нужен для clockRate/channels/fmtp и доступных метрик сети.
  // Повторный снимок покажет активность; этот вывод не измеряет качество AEC.
}

Опоры для параметров: RFC 7587 и W3C WebRTC Stats. Обработка Sonora и Opus выполняется без внешних сервисов, но независимость всей системы всё равно требует локальных ASR/LLM/TTS и сетевой приёмки.

Опора: src/aiortc/codecs/__init__.py:27 · src_front/src/webrtc/session.ts:19 · src_core/handlers/handle_offer.py:63

К началу главы

10Как очищается звук у нас, в CLI и в Desktop

В выдержке из бокового чата верно разделены Silero и Sonora. Но наш путь содержит ещё один этап: до отправки звука браузер может пропустить его через energy gate. Поэтому сравниваем не названия библиотек, а последовательность обработки и то, что остаётся на выходе каждого этапа.

AEC удаляет акустическое эхо ответа из микрофона, NS уменьшает шум, AGC регулирует уровень. Gate закрывает сигнал ниже порога, а VAD находит участки речи. Opus решает транспортную задачу: сжимает звук. Эти механизмы не взаимозаменяемы; Silero можно сохранить после Sonora или browser AEC/NS.

ПутьОчистка / регулировкаОтбор речиЧто подтверждено и что нет
Наш браузерgetUserMedia запрашивает echoCancellation и noiseSuppression; defaults=true. AGC явно не задан.Опциональный energy gate: RMS/dB → gain 0 или 1.SOURCE. Реальные getSettings, акустическое качество и настройки конкретного устройства не измерены.
Наш Core → ASR bridgeДекодирование и приведение к PCM16LE mono 16k. В просмотренной цепочке отдельный denoiser/AEC не найден.Silero ONNX VAD возвращает интервалы; bridge вырезает исходные сэмплы и пишет WAV.SOURCE. Это сегментация, не очищенная нейросетью запись; внутренний preprocessing ASR provider не установлен.
Codex CLI 0.156.1Локальный voice-host включает Sonora AEC/NS/AGC2; получает playout reference и задержку.Серверная сегментация голосового разговора не установлена этим разбором.SOURCE. Качество на наших устройствах не проверено; app-server сам звук не очищает.
Desktop: Codex voiceВ просмотренном web provider явно mono + noiseSuppression:true. AEC/AGC не заданы явно.Не приравнивается к серверному VAD или Silero нашего bridge.STATIC BUNDLE. Отсутствие параметра не означает false; фактическая обработка браузера неизвестна.
Desktop: ChatGPT voiceAEC запрашивается по device policy; код читает getSettings().echoCancellation. Есть ducking воспроизведения.Монитор активности помогает управлять громкостью ответа, но не доказывает серверную сегментацию.STATIC BUNDLE. NS/AGC в этом фрагменте явно не заданы. Ducking не очищает микрофон.
Desktop: диктовкаОтдельные browser и native capture ветки. В browser-вызове явно задан mono, не универсальный набор AEC/NS.Запись или streaming adapter → удалённый STT.STATIC BUNDLE. Обработка native capture и закрытого STT не установлена; это не вызов voice-host Sonora.
CLI исследован по открытым Rust-исходникам 0.156.1, Desktop - по локальному бандлу 26.928.20755. Выбор флагов найден в коде, но качество и реальная настройка трека требуют отдельного теста. Эти версии нельзя объявлять одним аудиотрактом.
Наш тракт: очистка в браузере, отсечение тихого звука и отдельная сегментация на серверезвукMediaStreamNATS meta + PCMmultipart WAVOpus / SRTP → декодированиеМикрофонMediaStreamTrackBrowser AEC / NSзапрошены при захватеEnergy gateопционально: RMS → gain 0/1WebRTC / Coredecoder → PCM16LE 16kSilero ONNX VADинтервалы start/endASR providerWAV-фраза → текст
Наш тракт: очистка в браузере, отсечение тихого звука и отдельная сегментация на сервере. Блоки открывают пояснения; сплошная стрелка - медиаданные, пунктир - сигнализация, события и вызовы сервисов.

У нас: AEC и NS запрашиваются на стороне браузера

startMic передаёт два флага из audio state в getUserMedia. Они включены в defaults, но браузер и устройство могут обработать запрос по-разному. В просмотренном коде нет отдельного вызова Sonora, RNNoise или SpeexDSP в этой точке. На выходе - MediaStream, уже после запрошенной браузерной обработки. Снять настройки нужно у исходного микрофонного трека, до замены его треком Web Audio.

Параметры из нашего mic.ts; diagnostics getSettings предложена выше, но в приложение здесь не добавлена.

// Фрагмент параметров startMic. Это запрос обработки, а не замер качества.
const stream = await navigator.mediaDevices.getUserMedia({
  audio: {
    echoCancellation: state.echoCancellation, // Просим убрать эхо динамиков.
    noiseSuppression: state.noiseSuppression, // Просим уменьшить шум.
  },
  video: false, // Видео для этого захвата не нужно.
});
// autoGainControl здесь явно не задан; нельзя считать его выключенным.

Energy gate: тихий сигнал закрывается, речь не очищается

Если gate включён, connectSession вызывает createEnergyVad и заменяет sender track. Web Audio оценивает RMS - среднеквадратичный уровень сигнала - и переводит его в dB. Выше порога gain стремится к 1, ниже - к 0; переключение сглаживается attack/release. Это не нейросетевое выделение голоса и не AGC: во время открытого gate шум внутри речи может пройти вместе с ней. Тихие начала и окончания слов могут пропасть до Silero.

Упрощённый фрагмент порогового gate. Проверка - тихая речь, шум и WER при gate on/off на одинаковом корпусе.

// Смысл участка createEnergyVad; получение RMS/dB опущено.
const active = db > gate; // Порог сравнивается с общей энергией, не смыслом речи.
const target = active ? 1.0 : 0.0; // Пропускаем либо закрываем весь сигнал.
gainNode.gain.setTargetAtTime(target, ctx.currentTime, T); // Сглаживаем переход.
// Это gain 0/1, а не восстановление речи из смеси с шумом.
Опора: src_front/src/audio/vad.ts:5 · src_front/src/webrtc/session.ts:19

Core меняет формат аудио, а не устраняет эхо

WebRTC доставляет закодированное медиа; aiortc декодирует его. TrackSourceNode приводит аудио к mono PCM16LE 16 кГц и кадрам по 20 мс, WhisperStreamNode добавляет метаданные и публикует в NATS. Название normalization здесь относится к формату и длительности кадров. В этом коде не найден процессор очистки или автоматического выравнивания громкости. Детальный бинарный пример находится в главе о кадре NATS.

Опора: src_core/processors/track_source_node.py:122 · src_core/processors/whisper_stream_node.py:76

Silero выбирает фразы из накопленного PCM

На стороне ASR bridge _get_timestamps вызывает ONNX VAD и получает start/end. Затем код берёт audio[start_sample:end_sample] из исходной записи. Silero не возвращает заменённую очищенную волну: выбираются участки того же звука. Именно их bridge упаковывает в WAV и отправляет в HTTP ASR. Это соответствует контракту Silero VAD.

Распознавание выполняет следующий ASR provider. VAD задаёт границы, а не текст и не очищенный звук.

# Фрагмент существующего выделения фразы по интервалам VAD.
segment_audio = audio[start_sample:end_sample] # Берём исходные сэмплы речи.
# Далее bridge пишет этот сегмент в WAV и отправляет распознавателю.
# Denoised audio от Silero этот участок не получает.

Для изолированной поставки важна ещё загрузка VAD: при отсутствии ONNX-файла _ensure_vad_loaded может скачать модель. Её нужно включить в поставку с контрольной суммой и лицензией, проверить локальный путь и cold start с запрещённой внешней сетью. Silero оставляем и при добавлении Sonora; перенос сегментации и очистки в один блок только запутает диагностику.

Вернуться к схеме нашего тракта

CLI 0.156.1: Sonora обрабатывает capture с учётом реально воспроизводимого ответаPCMOpus / SRTPаудиоответPCM проигрывания + задержка → AECМикрофонлокальные PCM-сэмплыSonora в voice-hostAEC + NS + AGC2Realtime serviceOpus / SRTP, речь и ответДинамикиреально сыгранный PCM
CLI 0.156.1: Sonora обрабатывает capture с учётом реально воспроизводимого ответа. Блоки открывают пояснения; сплошная стрелка - медиаданные, пунктир - сигнализация, события и вызовы сервисов.

CLI: обработка находится в voice-host перед Opus

В CLI 0.156.1 микрофон обслуживает helper, а не UI терминала или app-server. Processor::new включает в Sonora эхоподавление, шумоподавление и адаптивную цифровую регулировку усиления. Capture обрабатывается блоками по 10 мс при 48 кГц mono; затем собирается 20-мс пакет Opus. По сети идёт кодированное медиа, не ONNX VAD и не файл WAV. Никакой внешний API для самого Sonora-процессора не нужен.

Почему CLI передаёт в AEC ещё и проигранный ответ

render_output сохраняет реально проигранные сэмплы и временные данные. Capture worker сначала подаёт reference в процессор, затем обрабатывает микрофон; это позволяет сопоставить ответ из динамиков с его эхом. После mute или разрыва обработка сбрасывается, чтобы не использовать старую опору. Поставить Sonora на сервер только после получения PCM не равно этому пути: без синхронизированного render reference там нет сведений о реальном воспроизведении у пользователя.

App-server управляет сеансом, signaling, событиями и агентом. В этом V3-пути он не выполняет capture DSP и не несёт Opus между устройством и сервисом. Подробности - в архитектурном сравнении. В upstream есть синтетический тест влияния reference; здесь он не запускался и не заменяет акустический стенд.

Вернуться к схеме CLI

Desktop: три разных пути захвата, а не один общий процессор SonoraMediaStreamмедиаMediaStreamOpus / SRTPзаписьHTTP / WSSPCM ответа → воспроизведениеактивность пользователя → volumeCodex voice: micmono + NS=trueWeb Audio providerбуферизация / muteCodex RealtimeWebRTC Opus / SRTPChatGPT voice: micAEC по device policyBrowser WebRTCsettings / media trackChatGPT voiceзвук в обе стороныPlayout / duckingпри речи громкость нижеДиктовка: micbrowser / native captureSTT adapterфайл / аудиопотокУдалённый STTаудио → текст
Desktop: три разных пути захвата, а не один общий процессор Sonora. Блоки открывают пояснения; сплошная стрелка - медиаданные, пунктир - сигнализация, события и вызовы сервисов.

Desktop Codex voice: собственный web provider с NS

В просмотренном provider Wbo содержит channelCount:1 и noiseSuppression:true. Класс Gbo открывает MediaStream, использует Web Audio/AudioWorklet для буферизации и mute и отдаёт трек WebRTC. В этом участке AEC и AGC явно не заданы; нельзя дописать из этого, что браузер их выключил. Отдельный процессор Sonora в этой ветке не подтверждён.

Это статический фрагмент Desktop, не команда для запуска и не измеренные settings текущего микрофона.

// Дословные поля provider из минифицированного бандла; оформление раскрыто.
const Wbo = { channelCount: 1, noiseSuppression: true };
// Gbo передаёт параметры в захват и строит трек Web Audio для WebRTC.
// Здесь нет явного echoCancellation или autoGainControl.

Desktop ChatGPT voice: AEC зависит от policy, результат читается из трека

Другой голосовой путь использует функцию, которая при device policy nowhere возвращает пустой объект, иначе - echoCancellation:true. Пустой объект не означает запрет AEC. Код создания сессии читает getSettings().echoCancellation и записывает наблюдаемое значение в состояние. Для текущего устройства это значение здесь не снято. NS и AGC в этом фрагменте не заданы явно, а возможная native/provider ветка имеет отдельные условия.

Связаны два места кода: policy захвата и чтение settings после получения трека. Это иллюстрация, не новый DSP.

// Смысл rt(device): точная минифицированная строка сохранена в evidence JSON.
const constraints = policy === "nowhere" ? {} : { echoCancellation: true };
// Пустой объект оставляет настройки реализации, а не принудительно выключает AEC.
const { echoCancellation } = microphoneTrack.getSettings(); // Наблюдаемое значение.
// Отсутствующий параметр нужно обозначать неизвестным, не заменять false.

Ducking уменьшает громкость ответа, но не очищает запись

В updateDucking при активности пользователя уменьшается playback volume; после паузы громкость восстанавливается. Монитор активности может использовать energy/Silero-ветку. Пользователю проще говорить поверх ответа, а эхо может стать тише. Но ducking не вычитает шум и эхо из микрофонных сэмплов. Это управление воспроизведением рядом с AEC, не его замена. Наличие Silero в таком мониторе не означает одинаковую с нашим ASR bridge сегментацию.

Диктовка Desktop не запускает тот же voice processor

У диктовки отдельный recorder/streaming adapter. В бандле есть выбор native microphoneCapture и browser capture; в просмотренном browser-вызове явно задаётся mono. Дальше адаптер отправляет запись или аудиопоток удалённому STT и получает текст. Этот код не доказывает Sonora, полный набор AEC/NS/AGC, обработку native capture или очистку внутри закрытого сервиса. Кнопку микрофона нельзя описывать как тот же путь, что разговор или CLI /voice.

Сохранённые фрагменты Desktop с SHA256 и byte offsets позволяют проверить эти выводы без абсолютных путей к компьютеру. Там только код обработки звука; credentials и пользовательские реплики не включены. Названия минифицированных функций относятся к этому бандлу и изменятся при пересборке.

Вернуться к схеме Desktop

Какие альтернативы сравнивать и что измерять

Для существующего браузерного клиента сначала проверяем встроенную обработку, gate и Opus. Для собственного native клиента начинаем с Sonora, поскольку в Codex виден пример всей цепочки capture/render. Остальные варианты нужны для сравнительного стенда, а не для автоматического включения всех фильтров подряд.

ВариантНазначение и условияСтатус для нашего проекта
Browser AEC/NS + SileroКлиентская очистка и серверные границы фраз. Проверить settings исходного track, тихую речь, шум, динамики и double-talk.Уже есть в коде; качество не измерено. Gate on/off - отдельный фактор.
SonoraЛокальные AEC/NS/AGC; native capture + render reference. API и лицензия.Предложенный прототип, не внедрён. Silero сохраняется после очистки.
C++ WebRTC Audio ProcessingCapture ProcessStream и render ProcessReverseStream. Контракт APM.Альтернатива нативной интеграции; не подтверждение конкретного алгоритма браузера/Desktop.
SpeexDSPPreprocessor и echo canceller для локального тракта; нужны reference и корректные параметры. Руководство.Кандидат стенда. Сравнение качества и CPU на наших записях не выполнялось.
RNNoiseНейросетевое шумоподавление, не самостоятельная замена AEC или VAD. Исходники и пример.Кандидат для шума. Веса/сборку поставить заранее: autogen.sh upstream может скачивать модель.

Одинаковый стенд должен включать обычную и тихую речь, фоновый шум, только ответ из динамиков и одновременную речь собеседника с ответом. Сравниваем WER, потерянные границы VAD, ложные перебивания, остаточное эхо, clipping, задержку и CPU. Остаточное эхо сначала измеряем при молчащем пользователе; double-talk оцениваем отдельно, чтобы не принять подавленную живую речь за хорошее AEC. Ни Sonora, ни другая библиотека ещё не показали у нас выигрыш; цели и порядок внедрения приведены в плане работ.

К началу главы

11STT: сначала управляем фразой, потом решаем, нужен ли streaming

У текущего bridge уже есть Silero VAD, phrase_id, pending и финальный текст. Это сохраняем. Первое изменение - локальный ASR provider с тем же HTTP-контрактом, заранее поставленными весами и явной readiness. Затем измеряем, сколько времени занимает ожидание границы фразы отдельно от распознавания.

ИзменениеПочемуПриёмка и измеримый эффект
Зафиксировать VAD/start/end policyEnergy gate в browser и Silero на сервере решают разные задачи. Жёсткий browser gate может срезать тихие слова до ASR.Корпус обычной/тихой речи и шума; WER, false end, missed start, p95 конца фразы. Цель оптимизации: уменьшить p95 end→final на ≥20% относительно baseline без ухудшения WER >1 процентного пункта.
Накопитель PCM с правильным хвостомTrackSourceNode дополняет хвост каждого resampled frame до 20 мс. Это не равнозначно накоплению remainder между кадрами.Для входа 44100/48000 Гц сравнить входную и выходную длительность на 60 с. Цель: drift ≤20 мс после flush; padding только одного конечного неполного кадра. Искажение сейчас не измерено.
Дополнительно streaming ASR adapterПервые partial можно показать до окончания длинной фразы; текущий HTTP final путь этого не даёт.Поток partial/final привязан к utterance_id; только final запускает необратимые tools. Цель для локального RTF и t_first_partial задаётся после теста железа; пример: p95 partial ≤500 мс после начала речи.
Правила ошибки ASRОшибка модели не должна висеть как вечный pending или превращаться в вопрос агенту.При error/timeout UI получает финальную ошибку по тому же phrase_id; ни одного agent call на пустой/ошибочный transcript. Offline fallback - ручной текстовый ввод.

Цель в 500 мс - кандидат для будущего испытания, не утверждение о доступной нам модели. Если локальный ASR распознаёт только после конца фразы и этого хватает пользователю, streaming можно не добавлять. Файловый режим полезен для больших записей, но его очередь и таймауты отделяем от разговорных.

Опора: src/stt_whisper_to_nats.py:83 · src/stt_whisper_to_nats.py:341 · src_core/processors/track_source_node.py:122 · src_core/handlers/handle_transcription.py:34

К началу главы

12TTS: подключаем недостающую половину разговора

Первый рабочий шаг - озвучить подтверждённый ответ сценария своим TTS и отправить его по обратному WebRTC-треку. Сначала можно синтезировать целую короткую реплику. Потоковая генерация и синтез предложениями появляются позже, когда есть cancellation, порядок chunks и контроль того, что пользователь действительно услышал.

ВариантЧто переиспользоватьОграничение
TTS worker на площадкеИдею backend adapter из Linux Read Aloud: text + voice/config → проверяемый audio resultMCP helper запускает локальное воспроизведение на своём компьютере. Его нельзя без адаптации назвать серверным WebRTC TTS.
Piper или другой локальный движокИзолированное демо загрузки model+config и создания WAV, проверки ffprobe/полного decodeВыбор русского голоса, лицензии, CPU/GPU, качество терминов и RTF проверяются отдельно. Не обещаем равенство ChatGPT.
Предсинтезированные служебные репликиСвязь аудио с response_id + checksum и явной версией текстаДля "ASR недоступен" можно не ждать нейросеть. Нельзя выдать запись за свежий ответ инструмента.
Synthesis по завершённым фразамНумерация chunks и output lifecycle как в Realtime clientsНаш LLM adapter stream:false; mode возвращает структурированный JSON. Нельзя озвучивать токены JSON или ещё не проверенные результаты tools.
Browser speechSynthesisТолько дополнительный доступный режимГолос браузера/ОС не всегда локальный или одинаковый на устройствах. Для строгой автономности не основной путь.

Проектируемая граница TTS. Значение ru-local - placeholder, а не гарантированно установленный голос.

# Предложение собственного интерфейса, не существующий endpoint проекта.
job = {"session_id": "s-1", "turn_id": "t-1", "response_id": "r-1",
       "generation": 4, "text": "Найден один аэропорт.", "voice": "ru-local"}
# TTS выдаёт сэмплы в своём формате, например PCM16LE mono 22050 Гц.
# Voice Gateway ресемплирует их, задаёт монотонный PTS и кодирует в Opus.
# RTP-clock Opus = 48000; он не объявляет исходные сэмплы TTS как 48000 Гц.
# Каждый chunk несёт response_id, generation и seq; отменённые chunks удаляются.

Цель первой реализации: для 100 коротких ответов text→decoded audio завершается 100/100 раз, у каждого ответ и waveform имеют один response_id. После отмены удаляем ещё не отправленный хвост и не принимаем новые chunks старого ответа. Уже отправленные RTP пакеты и буфер браузера отозвать нельзя; слышимый хвост измеряем отдельно. Затем проверяем p95 first audio, RTF TTS и задержку перебивания. Наличие WAV и успешный decode - необходимые проверки, но качество произношения и слышимость проверяются отдельно.

Опора: src_core/handlers/handle_offer.py:63 · src_core/handlers/handle_track.py:18 · read-aloud-linux/src/main.rs:204 · src_llm/clients/ollama_chat.py:12 · docs/codex-cli-voice-textbook.html:1

К началу главы

13Перебивание, mute и Stop - три разных действия

Сейчас Core на время запроса агента публикует voice blocked, а frontend выключает sender.track. Такой полудуплексный режим не даёт пользователю перебить работу голосом. Его можно оставить как явный fallback, но для разговорного режима микрофон остаётся активным, а новая речь отменяет старый ответ.

ДействиеЧто должно произойтиЧто проверяем
MuteНе отправлять реальную запись; очистить capture queue. Если политика требует освобождения микрофона, stop capture и reacquire при unmute; медиа может продолжать синтетическую тишину.После mute ни один кадр предыдущей generation не попадёт в сеть. Track.enabled сам по себе не отменяет Agent и не доказывает остановку устройства.
Перебивание (barge-in)Обнаружить речь, прекратить playout, отменить TTS и незавершённую генерацию; открыть новый ходp95 speech-start→тишина старого ответа ≤300 мс - предложенная цель; AEC обязателен к проверке, чтобы ответ из динамиков не считался речью пользователя.
Stop ответаCancel по конкретному turn_id/response_id; session и соединение могут остатьсяCancellation идемпотентна; поздние result/audio deltas не возвращают старый ответ в UI.
Завершить разговорОстановить capture, workers и subscriptions, peer закрыть, сохранить остаток событий100 connect/disconnect циклов: tasks/subscriptions/peers возвращаются к baseline; никаких новых tools после закрытия.
Неразрывное действие toolНе обещать физическую отмену уже совершённого действияУказать в UI "ответ остановлен, действие выполнено" и сохранить результат. Cancel не равен rollback бизнес-операции.

Псевдокод политики. Для реальной реализации нужны bounded queues, cancel propagation и тесты порядка.

# Предлагаемая защита от ответа предыдущего хода.
generation += 1
old_turn.cancel()       # Отменяем вычисления, которые действительно поддерживают отмену.
playback_queue.clear() # Убираем ещё не услышанное аудио.
# Сетевой callback со старой generation завершается без показа и воспроизведения.
if chunk_generation != generation:
    return

Демо WebRTC из предыдущей работы уже использует epoch и очистку входной очереди на mute. Это полезный образец, но не готовый production barge-in: там нет полноценного AEC и агентные delegation requests отклоняются. После перебивания в историю должен попасть факт, что часть озвучки не была услышана. Иначе следующий ответ опирается на реплику, которую пользователь пропустил.

Опора: src_core/handlers/handle_transcription.py:34 · src_front/src/assistant/assistantApp.ts:249 · scripts/webrtc-voice-isolated/voice-console.py:117 · codex-rs/core/src/realtime_conversation.rs:623 · src_core/utils/pc_lifecycle.py:22

К началу главы

14Ограничиваем не только очередь, но и задачи, байты и возраст аудио

В FanOutNode.subscribe стоит asyncio.Queue() без maxsize. Ветка drop-oldest есть, но QueueFull у такой очереди не возникнет. В WhisperStreamNode.handle_frame каждый кадр создаёт task, которая затем ждёт semaphore. Значение 3 ограничивает одновременно выполняемые publish, но не накопленные task.

Результат нашей локальной пробы 2 октября: publish заменён ожидающим mock, ни одного сетевого запроса.

{
  "frames": 100,
  "queue_maxsize": 0,
  "queued_frames": 100,
  "pending_publish_tasks": 100,
  "publish_calls_entered": 3,
  "network_requests": 0
}

Проба доказывает механизм роста, а не утечку или падение production. После восстановления потребителя очередь может уйти; время восстановления и рост памяти на реальной нагрузке ещё не измерены. Остановившийся потребитель способен оставить память пропорционально длине ожидания, поэтому одного semaphore недостаточно.

ГраницаПредлагаемая политикаКритерий
PCM frame queueНачальный лимит 10 × 20 мс = 200 мс; одновременно maxbytes и age. При overflow - gap/overload и явное решение ASR reset или отказqsize ≤10 при stalled consumer; старый звук не догоняет разговор через секунды. Silent drop может испортить слова и не подходит автоматически для записи файла.
NATS publishingОдин или небольшой фиксированный набор worker, bounded вход; preserve seq order или contract gap detectionЧисло task не растёт с длительностью stall; память выходит на плато в 10-минутном тесте.
ASR phrase jobsЛимит количества/байтов/возраста фраз до постановки task; конечный deadlineПри недоступном ASR новая фраза получает overload, UI выходит из pending. Большие файлы - отдельная durable очередь.
TTS playbackНебольшой prebuffer, лимит будущего аудио и sequence continuityCancel очищает непроигранный хвост; underrun/dropped audio измеряются, не скрываются.
Graph shutdownCancel tasks и wait с deadline, повторное close безопасноShutdown завершается даже при блокированном NATS/ASR. End marker не ждёт бесконечно занятого транспорта.

Повторяемая проба очередей. Ожидаемый результат относится к main 1fc1702; после исправления её проверки должны измениться.

# Запустить из корня webrtc-komaroff в окружении с numpy, av и nats-py.
# --source-root указывает на исследуемый checkout; сеть не используется.
python3 DOCS/codex-voice-comparison-2026-10-02/probe_audio_bounds.py --source-root .

Прямая экономия от лимита - ограниченное число удерживаемых кадров и задач, а не гарантированное ускорение ASR. В качестве исходной политики 200 мс годятся для разговора, но её надо проверить на packet loss и точности. Для обязательной полной записи сбрасывать кадры нельзя: нужна остановка с ошибкой либо иной надёжный канал.

Опора: src_core/processors/base.py:16 · src_core/processors/whisper_stream_node.py:76 · src/stt_whisper_to_nats.py:341 · codex-rs/core/src/realtime_conversation.rs:623 · scripts/webrtc-voice-isolated/voice-console.py:117

К началу главы

15События сессии: из ответа целиком в наблюдаемый жизненный цикл

Сейчас есть trace envelope между сервисами и часть событий содержит session_id. Но browser подписан на общий events subject, а handleNatsPayload передаёт событие обработчику без единого фильтра активной сессии. Для двух окон или двух разговоров одного пользователя это граница, которую надо проверить. UUID сессии в payload ещё не является авторизацией.

Предложение событийного envelope, не нынешний контракт. JSON не поддерживает комментарии; поля объяснены ниже.

{
  "schema_version": 1,
  "event_id": "e-42",
  "session_id": "s-1",
  "call_id": "c-2",
  "turn_id": "t-3",
  "response_id": "r-4",
  "generation": 7,
  "seq": 42,
  "type": "tts.started",
  "ts_ms": 1790899200000,
  "payload": {"voice": "ru-local", "sample_rate": 22050, "channels": 1}
}
ПолеЗначение и правило
session_id / call_idSession владеет историей; call - одно медиасоединение. Reconnect создаёт новый call, но не должен обнулить историю.
turn_id / response_idUser turn и ответ связаны, но не равны. У отменённого ответа остаётся собственный status.
generation / seqGeneration отсеивает старую работу; seq задаёт порядок событий внутри договорённого потока. Нельзя сравнивать seq независимых producers без агрегатора.
event_id / schema_versionEvent_id обеспечивает дедупликацию/возобновление; версия даёт совместимость старых consumers.
ts_msВремя для журналов. Для latency измерять monotonic на одном процессе или учитывать синхронизацию часов.
payloadТолько данные разрешённого типа. Для TTS format и voice; для transcript текст и final; credentials сюда не попадают.

Состояния удобно держать раздельно: соединение connecting/connected/closed, вход listening/muted, работа transcribing/thinking/tool_running/speaking/cancelling. Mute и speaking могут быть одновременно, поэтому одна линейная строка статуса на всё будет давать ложные переходы. UI показывает наблюдаемое состояние, а не считает HTTP 200 доказательством слышимого ответа.

При reconnect клиент просит snapshot + события после cursor. Для media потерянное время не воспроизводится; для завершённых результатов допустим replay. Цель: в двух одновременных сессиях ни одного чужого сообщения/voice block; после reconnect - snapshot без дублирования финального ответа. Core NATS не хранит события для отсутствующего подписчика, это надо обеспечить на уровне приложения или отдельного durable stream.

К началу главы

16Сохранение хода, повтор запроса и права инструментов

В свежем main runtime_state и история сохраняются в одной транзакции. Тесты также подтверждают, что обычная ошибка сохранения не повторяет workflow. Но конфликт версии состояния ещё вызывает повтор workflow, а tool мог успеть выполнить действие до SQL commit. Для голоса с reconnect и повтором фразы это особенно чувствительно.

Что добавитьЗачемКак измерить
Idempotency на границе входного ходаПовтор того же final ASR/HTTP request не должен создавать второй результатДесять повторов одного ключа → один принятый ход и один final result. Ключ scoped по site/user/session/turn, а не просто строка text.
Idempotency key для toolПовтор workflow при конфликте не должен второй раз менять внешний объектCrash after tool success before commit: при восстановлении одно действие. Инструмент либо поддерживает ключ, либо требует reconcile; exactly-once не обещать.
Очередь/lease по сессииДва хода не должны одновременно менять последовательную память разговораДве параллельные фразы → детерминированная политика: очередь, reject либо interrupt, без потери версии.
Outbox результатаПосле commit сервис может упасть до publish UI событияCrash after commit before publish: после restart/reconnect ответ появляется один раз, без повторного tool.
Разрешения и аудитГолосовая модель сообщает намерение, но не является источником правЛюбой недопустимый tool отклоняется server-side; speech injection не меняет права. Отменённое действие имеет явный terminal status.
История слышимого ответаСохранённый полный текст не равен прослушанномуДля interrupted response сохранить playback position/статус. Не транслировать время в слова без aligner; контексту явно сообщать об усечении.

В этом месте полезна идея Codex handoff: голосовой канал отдаёт агенту конкретную задачу и получает конкретный результат, сохраняя связь идентификаторов. Копировать терминал и unrestricted shell не надо. Нужны только доменные инструменты площадки с собственной проверкой прав и сроком жизни запроса.

Опора: src_agent/agent.py:138 · tests/test_agent_persistence.py:38 · src_langgraph/runtime_io.py:48 · codex-rs/core/src/realtime_conversation.rs:623 · codex-rs/codex-api/src/endpoint/realtime_websocket/protocol_frameless_bidi.rs:20

К началу главы

17Локальные модели: совместимость интерфейса не гарантирует качество

В проекте уже два пути LLM: llama.cpp внутри процесса и Ollama через HTTP. Ollama можно поставить в той же изолированной сети. Для CPU-площадки GGUF путь полезен, но текущий init не содержит явной настройки GPU layers; установка GPU сама по себе не доказывает ускорение этого adapter.

РешениеПочему именно такРезультат и цена
Сначала local-only profile на существующем adapterМеньше изменений в workflow/NATS контрактахНоль обращений к основной сети при cold start и разговоре. Качество structured JSON и tools проверяется на локальной модели.
Фиксировать модель и формат ответаDownloader без model_file может выбрать минимальный GGUF. Замена файла меняет качество и память.SHA256/version/license и конкретный GGUF path в manifest. Нет автоматического выбора другой quantization в offline run.
Readiness после реального model warmupСуществующий ready после ensure_model_path ещё не доказывает загрузку в llama.cpp или успешный inferОдин локальный canary: JSON по схеме и простой ответ; отдельно время cold/warm. Дешёвый health не должен постоянно запускать большие inference.
Детерминированные доменные путиДля разрешённых команд можно не вызывать модель на каждом шагеВ main уже есть guard явного поиска аэропорта до _infer_json. Продолжение требует extraction/tools и не означает полную работу без LLM. Считать число infer/turn и точность маршрутизации.
Streaming LLM - позжеСейчас stream:false и структурированные modes. Голос не должен читать невалидированный JSONОтдельный разговорный text stream с финальной валидацией. Текст tools и критичные факты озвучиваются только после результата.
Деградация при недоступной моделиПолная автономность не равна наличию любого inferenceПредложить text-only/recorded prompts/детерминированные команды согласно профилю; UI честно сообщает ограничение. Не пробовать тайно внешний provider.

Выбор конкретной ASR/LLM/TTS модели стоит сделать по одному русскому набору: тихая речь, имена аэропортов, номера рейсов, паузы, шум и попытки вызвать недопустимый инструмент. Без такого сравнения нельзя обосновать, что меньшая модель даст тот же результат быстрее. Ускорение измеряем на одном железе и одной нагрузке, качество - отдельно от синтаксической валидности JSON.

Опора: src_llm/clients/local_chat.py:8 · src_llm/clients/ollama_chat.py:12 · src_llm/utils/model_downloader.py:12 · src_llm/service.py:109 · src_langgraph/runtime_io.py:48

К началу главы

18Что можно переносить как код, а что только как приём

Совпадение архитектурной идеи не требует копирования большого куска Codex. Для нашего Python/TypeScript проекта дешевле собственный небольшой interface и тесты, чем зависимость от всего Rust-ядра с облачным разговорным контрактом. Перед переносом файлов отдельно проверить лицензии исходников, пакетов, весов и конкретного голоса.

ИсточникПереносАдаптация / проверка
WebRTC isolated demo: queue/epoch/mute/cleanupНебольшие алгоритмы и сценарии тестов; Python, aiortc и PyAV совпадают по классу задачиНе копировать ChatGPT auth/signaling. Демо не имеет полноценного AEC и production reconnect.
Codex bounded channels + cancellationПриём управления временем жизни; реализовать asyncio equivalentsПроверить shutdown и late callbacks. Не переносить числа capacity без измерения нашего latency.
Codex protocol types / item lifecycleПринцип start/delta/done + ID и completionНаш envelope с schema_version; не объявлять совместимость OpenAI RPC, если семантика другая.
Локальные TTS demos / helperПроверку файлов/формата и adapter подготовки моделиBackend должен вернуть PCM/audio в Gateway, а не проиграть звук на сервере. Установленные voices и лицензии не переносим по умолчанию.
Proxy observation demoРаздельный учёт HTTPS/WSS и ICE/RTPДля isolated LAN - allowlist фактических peers и отсутствие egress. HTTP proxy не закрывает UDP маршрут автоматически.
Штатные HTTP диктовка / synthesisКонтрактная дисциплина: точный MIME, correlation, полное декодирование результатаНе зависимость от ChatGPT endpoints. Собственный ASR/TTS контракт и локальная авторизация.
ChatGPT model/session internalsНе переносимЗакрытые модели/веса/сервис недоступны. Нельзя обещать on-prem ChatGPT voice из fork исходников.
MCP / CLI read_aloudВозможный административный или тестовый интерфейсMCP не заменяет WebRTC, VAD, сессию и потоковый playback.

LiveKit/SFU не включаю в обязательный первый этап. Сейчас разговор один-на-один с сервером; aiortc уже используется. Рассматривать смену transport runtime стоит после проблем распределения peers, TURN эксплуатации или измеренной нагрузки. Новый framework добавит серверы, зависимости и offline packaging; само его имя не уменьшает latency модели.

К началу главы

19Какими числами проверяем пользу изменений

Базовая единица замера - один законченный пользовательский ход в конкретной сессии. Отдельно пишем длительность аудио, размер моделей/quantization, версии, CPU/GPU/RAM, число клиентов, сеть и cold/warm режим. Сравниваем одинаковые входы. Тест на наушниках не заменяет тест AEC с динамиками.

МетрикаТочки времени / счётчикПредлагаемая цель, не достигнутый результат
t_first_partial / t_final_STTНачало речи→первый partial; конец акустической речи→finalСначала baseline 100+ реплик; p50/p95. Для streaming-кандидата partial p95 ≤500 мс; конец→final снизить ≥20% без потери WER >1 п.п.
t_agent / TTFTFinal ASR→agent result; для stream - первый разрешённый текстСчитать infer/turn и tools duration. Снижение infer calls должно сопровождаться сохранением task success, не только speed.
t_first_audioКонец пользовательской речи→первые декодированные сэмплы ответа у browserБюджет-предложение для короткого локального сценария p95 ≤2 с; подтвердить на целевом железе. HTTP 200/TTS accepted - не first audio.
t_interruptРечь пользователя во время ответа→прекращение слышимого старого аудиоp95 ≤300 мс как цель; старые chunks не принимаются после cancel generation. Уже переданные/буферизованные RTP пакеты нельзя отозвать: фактический слышимый хвост измеряется отдельно.
RTF ASR/TTS и вместимостьCompute time/audio duration, GPU memory и очередь при N clientsRTF <1 на N_accept, без бесконечного queue growth. N_accept устанавливается из испытания, не из MAX_CONCURRENCY.
МедиаSelected candidate pair, RTP byte/packet counters, loss/jitter, underrunНет нежелательного peer вне LAN; формат декодируется. Норматив jitter зависит от сети; не выдумывать обещание zero-loss.
Bounded workqsize, pending tasks, age_ms, bytes, RSSQueue ≤10 frames как стартовый бюджет; task count фиксирован; stalled consumer 10 мин → память выходит на плато, overload виден.
СемантикаWER, task success, JSON validity, duplicate actions, session leak0 чужих сессий и дублированных tool side effects в fault-тестах; WER/task success сравниваются на неизменном корпусе.
АвтономностьПроцессы и трафик при отключённой основной сети10/10 cold starts + 100 диалогов без внешних успешных соединений и без попыток egress приложения; missing model даёт local error, не download.
Ресурсы и восстановление100 close/reconnect циклов, worker failure, CPU/RAM и task countРесурсы возвращаются к baseline; cancel/shutdown deadline соблюдается; результат сохранённого tool не повторяется.

Предложение instrumentation. В общие журналы не выводить SDP, ICE credentials, tokens и личный transcript.

// Пример чтения медиаметрик в месте, где приложение владеет RTCPeerConnection.
// Это не готовая команда для браузерной консоли: pc берётся из кода приложения.
const report = await pc.getStats();
for (const item of report.values()) {
  // Счётчики RTP и jitter относятся к медиа; их недостаточно для ASR latency.
  if (item.type === "inbound-rtp" && item.kind === "audio") {
    console.log({packetsReceived: item.packetsReceived,
                 packetsLost: item.packetsLost, jitter: item.jitter});
  }
}

Сумма p95 отдельных этапов не равна p95 всей цепочки. Общую задержку измеряем непосредственно. При отсутствии синхронизации часов межпроцессная разность ts_ms может быть неверной; используем связанные spans, локальные monotonic интервалы и отмечаем точность clock sync. Рост RTP counters ещё не доказывает качество распознавания или полный слышимый ответ.

К началу главы

20Испытания: от контракта до холодного старта без сети

Для сравнения достаточно локальных тестов; сегодня они не доказывают работу всего стенда. Будущую приёмку проводим в отдельном namespace/VM площадки, а не отключением сети на рабочей машине. Любые firewall/netem изменения относятся к будущему стенду и в этом исследовании не выполнялись.

СлойСценарийОжидаемый результат
АудиоконтрактPCM16 mono 16k; WAV в raw поле; odd byte length; неверная metadata length; channels/rate/width mismatchНекорректная упаковка отвергается до ASR; ошибочная частота может дать искажённый звук, а не HTTP error. WAV header не интерпретируется как речь.
СогласованиеOffer валиден/обрезан; ICE никогда не complete; peer disconnected; denied micDeadline и cleanup уже покрыты частью тестов main; добавить повторный connect и частично созданные ресурсы.
Входные кадрыКадры 44.1/48 kHz с неполным remainder; 20 мс normalizer; stall publisherТочная длительность после resample/flush; bounded queues и task count; gap policy проверяется на WER.
STT/agentPartial меняется, final повторяется, pending→error, неверный phrase_idPartial не запускает tool; один final принят один раз; ошибочный transcript не становится запросом.
Двустороннее mediaФайл-фикстура → local ASR → agent echo/controlled reply → local TTS → browser captureСоответствие каждому request ID; полный decode и параметры; отдельно ручная оценка текста, произношения и слышимости.
СессииДва browser клиента одного пользователя; reconnect с cursor; позднее событие old callНи одного чужого voice block/ответа; generation отсекает старый поток, snapshot не дублирует результат.
ИнструментыПовтор запроса; crash до/после tool/commit/publish; конфликт runtimeНет повторного side effect; outbox/reconcile даёт финальный результат; права проверяются сервером.
ПрерываниеMute/unmute с queued capture; речь на фоне TTS; Stop во время ASR/LLM/toolОтдельная политика каждого действия. Late callback не оживляет ответ; незавершённые задачи освобождены.
Сеть и ресурсыHost candidates; разные VLAN; UDP forbidden; только локальный TURN; отказ DNS/ASR/NATSПроверяем selected ICE candidate pair и все application peers. Отказ одного этапа виден как конкретная ошибка, не бесконечный spinner.
Offline cold startПакет предзагружен, internet/main-network отключены; отдельно отсутствует модельВ первом случае диалог работает; во втором отказ локальный и понятный. Блокировка egress без попыток сети - более строгий критерий, чем просто отсутствие успешного HTTP.

Негативные тесты должны показать точную границу отказа: parser, ASR provider, Agent, TTS или media. Для load-теста увеличиваем число сессий, пока хотя бы один критерий не нарушится; последняя успешная нагрузка становится подтверждённым N_accept. Команду "docker compose up" нельзя объявить проверкой полной автономности.

Опора: src/utils/wire_packet.py:9 · tests/test_core_ice.py:12 · tests/test_agent_persistence.py:38 · src_front/src/webrtc/session.test.ts:62

К началу главы

21Порядок работ: сначала автономность и ограничения, затем разговор

Менять транспорт, модель, схему сессии и workflow сразу неудобно: при регрессии непонятно, где искать причину. Ниже этапы, после каждого остаётся рабочий режим. Это проект изменений, не внесённые этим документом изменения приложения.

ЭтапОбласть и конкретная работаКак принимаемЦена / откат
0. Baseline и версияПодтвердить deployed SHA/image отдельно от main; собрать 100 реплик и запись этапов. Использовать свежие ICE/cleanup/transaction исправления.Воспроизводимый baseline с hardware/model config и p50/p95.Только read-only сбор. Никакой обещанной экономии до baseline.
0a. Клиентский звук и OpusФактические getSettings, SDP/getStats и активный codec в обе стороны. Sonora: отдельно нативный прототип с playout reference и задержкой, без двойной обработки по умолчанию.Сравнить browser AEC и Sonora на динамиках: эхо, false barge-in, WER, p95 задержки/CPU; loss/jitter тест Opus. Выбор кодека подтверждён счётчиками.Feature flag; browser path сохраняется. Sonora не заменяет ASR/TTS и не переносится вслепую на сервер.
1. Изолированный профильLocal DNS/CA/STUN/TURN по необходимости; свои NATS/DB/ASR/LLM; модели/assets/images заранее; startup без downloads.10/10 cold starts, внешний/основной network недоступен; fault missing model.Новые config/packaging, не замена runtime. Online профиль отдельно, без hidden fallback.
2. Bounded audio и jobsCore queues, фиксированные publisher workers, ASR admission, timestamps/gap rules, shutdown deadlines.Повтор queue probe после изменения; 10 мин stall и нагрузка без роста памяти; WER контролируется.Меньше удерживаемого аудио, но overload надо честно показывать. File-mode не теряет кадры молча.
3. Обратная озвучкаЛокальный TTS provider → Core output track; response_id, полное decode, text-only fallback.100/100 коротких реплик сопоставлены тексту и audio; звук доходит до клиента.Новый worker и ресурсы; выключаем feature - прежний text-only путь сохраняется.
4. Session lifecycleSchema-versioned events, фильтр/права, cursor+snapshot, generation/cancel, outbox и tool idempotency.Две сессии, reconnect, crash matrix и duplicate request tests.Межсервисные контракты: dual-read/migration, consumers обновляются согласованно.
5. Полный разговорBarge-in, AEC tests, правильный heard context, настройка VAD, при необходимости partial STT/TTS.t_interrupt p95 ≤300 мс как цель; меньше задержка при неизменном task success и WER.Full duplex feature flag; явный half-duplex fallback, если устройство/echo не прошли тест.
6. Масштаб только по даннымИзмерить CPU/GPU/NATS/peer load; если transport ownership мешает, отдельный media worker/SFU PoC.N_accept и bottleneck подтверждены; сравнить до/после на одном корпусе.LiveKit/новый runtime не обязательны. Realtime PCM не записывать в JetStream по умолчанию.

Приоритетный набор для первой доработки: автономный профиль, bounded работа и локальная озвучка. Они закрывают зависимость от основной сети, предел ресурсов и отсутствие аудиоответа. Streaming и новая библиотека WebRTC могут быть следующими решениями, но только если измеренный сценарий этого требует.

Опора: stack/webrtc.drs:51 · src_core/processors/base.py:16 · src_core/processors/whisper_stream_node.py:76 · src_agent/agent.py:138 · src_core/handlers/handle_track.py:18

К началу главы

22Куда идти в коде за каждым блоком схемы

Ссылки на webrtc-komaroff закреплены на GitLab main 1fc1702, а не на старом локальном checkout. В каждой записи указаны вход, результат и граница ответственности. Ссылки на незакоммиченные аудиодемо ведут в соседний локальный checkout; для чтения самого учебника этот каталог не нужен.

Browser: connectSession → negotiate

Вход - пользователь включает микрофон. connectSession открывает MediaStream, создаёт peer, назначает sender и при необходимости energy gate. negotiate собирает SDP и вызывает /core/offer. Выход - session_id и peer; при отказе signaling свежий main закрывает открытые ресурсы.

Опора: src_front/src/webrtc/session.ts:19 · src_front/src/webrtc/negotiate.ts:3 · src_front/src/config/appConfig.ts:54 · src_front/src/webrtc/session.test.ts:62

Core: offer → handle_track → AudioGraph

handle_offer_connect создаёт peer и UUID сессии; incoming audio запускает handle_track. Source нормализует звук, WhisperStreamNode упаковывает кадры и подписывается на текст. Выход - PCM через NATS и последующий agent request; TTS producer в этом графе не назначен.

Здесь NATS и incoming track отмечают асинхронные границы. Работа ASR не выполняется внутри Python call stack Core.

# Выборочное дерево вызовов, не временная диаграмма.
POST /core/offer
  handle_offer → handle_offer_connect
    _webrtc_pc_create → aiortc.RTCPeerConnection
    on_track → handle_track
      AudioGraph → TrackSourceNode._normalized_chunk_stream
        WhisperStreamNode.handle_frame → _publish → NATS
      _handle_whisper_message → handle_transcription
        NATS request nats.agent.<user>
Опора: src_core/handlers/handle_offer.py:63 · src_core/handlers/handle_track.py:18 · src_core/processors/track_source_node.py:122 · src_core/processors/whisper_stream_node.py:76 · src_core/handlers/handle_transcription.py:34

Bridge: кадры → VAD → WAV → HTTP

_process_message разбирает wire packet; _handle_frame накапливает сэмплы; VAD выбирает сегменты; _transcribe_and_publish пишет временный WAV, ждёт слот и вызывает transcribe_http_text. После обработки WAV удаляется в finally. Это не доказательство удаления всех данных сервера LinTO или журналов.

Agent: входной текст → workflow → result/UI

AgentServer.handle_request валидирует AgentInboundRequest, запускает runner и публикует UI события. Runner передаёт сценарную работу выбранному workflow через NATS, сохраняет состояние и историю, возвращает typed response. Голосовые identifiers полезны для корреляции, но нынешняя обработка остаётся текстовой.

Опора: src_agent/service.py:151 · src_agent/agent.py:138 · src_langgraph/runtime_io.py:48 · src_agent/ui_events.py:15

Postgres: атомарное состояние и история

Свежий runner использует одну db.transaction() и передаёт один connection в save_runtime_state и persist_chat_turn. Это защищает два SQL эффекта вместе. Outbox, lease и идемпотентность внешних tools остаются отдельными предложениями.

Опора: src_agent/agent.py:138 · tests/test_agent_persistence.py:38

LLM: local и HTTP - способы вызова, а не география

Local adapter вызывает llama.cpp; HTTP adapter делает Ollama POST /api/chat. Оба возвращают JSON message без потоковой озвучки. Локальный model downloader умеет использовать уже существующий путь; при отсутствии веса обращается к Hugging Face. Offline профиль обязан выбирать конкретный файл и проверять его наличие.

Опора: src_llm/clients/local_chat.py:8 · src_llm/utils/model_downloader.py:12 · src_llm/service.py:109 · src_llm/clients/ollama_chat.py:12

Codex: управление разговором и helper

TUI вызывает thread/realtime/start с WebRTC transport. RealtimeCallClient создаёт удалённый call; core ведёт session/events и handoff. voice-host владеет медиасоединением. Код сервера, выполняющего распознавание и генерацию аудио, здесь отсутствует; для него подтверждены клиентский контракт и исторические наблюдения, не внутреннее устройство.

Локальный Processor::new включает Sonora AEC/NS/AGC; render_output сохраняет реально воспроизводимые сэмплы, CaptureWorker передаёт их в обработчик перед capture. Это дополнение к исследованию транспорта, не функция app-server. Подробности и план переноса - в разделе Sonora, проверка кодека - в разделе Opus.

Реестр исходников

ПроектФайл / строкаРоль
webrtcsrc_core/handlers/handle_offer.py:63Создание aiortc peer и SDP answer
webrtcsrc_front/src/webrtc/negotiate.ts:3POST /core/offer, SDP offer/answer
webrtcsrc_front/src/webrtc/session.ts:19Микрофон, WebRTC, cleanup при ошибке
webrtcsrc_front/src/config/appConfig.ts:54STUN и событийный subject
webrtcsrc_front/src/audio/mic.ts:3Запрос браузерного эхоподавления и шумоподавления
webrtcsrc_front/src/audio/vad.ts:5Energy gate: порог RMS/dB и gain 0/1, не нейросетевой denoiser
webrtcsrc_core/handlers/handle_track.py:18Сборка графа входного аудио
webrtcsrc_core/processors/track_source_node.py:122Resample, 20 мс и padding хвоста
webrtcsrc_core/processors/base.py:16Очередь без maxsize
webrtcsrc_core/processors/whisper_stream_node.py:76Бинарный NATS packet и задачи publish
asrsrc/stt_whisper_to_nats.py:341Фраза в WAV, HTTP ASR, текст
asrsrc/stt_whisper_to_nats.py:83Silero VAD, интервалы и настройка буфера
asrsrc/stt_whisper_to_nats.py:303Выделение фраз из исходных сэмплов по интервалам Silero
asrsrc/stt_whisper_to_nats.py:424Загрузка ONNX Silero; при отсутствии файла возможна загрузка модели
asrsrc/utils/wire_packet.py:9Разбор метаданных и PCM16LE
asrsrc/webui/file_mode.py:1202Multipart file, query language, ответ text/plain
asrsrc/model_transport.py:6CONNECT или прямое соединение, без fallback
webrtcsrc_core/handlers/handle_transcription.py:34Текст в agent request; voice blocked
webrtcsrc_agent/agent.py:138Workflow и атомарное сохранение runtime + истории
webrtcsrc_agent/service.py:151Валидация запроса, ответ и UI publisher
webrtcsrc_agent/ui_events.py:15Ответ и команды UI с trace
webrtcsrc_front/src/assistant/assistantApp.ts:249Обработка событий и управление микрофоном
webrtcsrc_shared/contracts/common.py:22trace/request/correlation/session IDs
webrtcsrc_langgraph/runtime_io.py:48Отдельные NATS-вызовы LLM и tools
webrtcsrc_llm/clients/local_chat.py:8Локальный llama.cpp chat completion
webrtcsrc_llm/utils/model_downloader.py:12Путь к GGUF или Hugging Face download
webrtcsrc_llm/service.py:109Создание локальной модели без явного GPU placement
webrtcsrc_llm/clients/ollama_chat.py:12POST /api/chat с stream:false
webrtcstack/webrtc.drs:51Желаемые ASR, proxy, Ollama и leafnode связи
webrtcsrc_front/src/map/mapController.ts:40Внешний style.json для карты
webrtcsrc_core/utils/ice.py:6Ограниченный deadline ICE gathering
webrtcsrc_core/utils/pc_lifecycle.py:22Остановка графа при disconnected/failed/closed
webrtctests/test_core_ice.py:12Три теста deadline ICE
webrtctests/test_agent_persistence.py:38Транзакция и повторы workflow
webrtcsrc_front/src/webrtc/session.test.ts:62Очистка ресурсов при ошибке signaling
codexcodex-rs/codex-api/src/endpoint/realtime_call.rs:129Создание WebRTC call
codexcodex-rs/core/src/realtime_conversation.rs:623Bounded channels и cancellation
codexcodex-rs/app-server-protocol/src/protocol/v2/realtime.rs:143RPC audio chunk и realtime transport
codexcodex-rs/app-server-client/src/lib.rs:1InProcess, daemon и typed app-server client
codexcodex-rs/tui/src/app_server_session/realtime.rs:20TUI запускает V3 через app-server
codexcodex-rs/codex-api/src/endpoint/realtime_websocket/protocol_frameless_bidi.rs:20Transcript, turn.done и delegation
codexcodex-rs/voice-host/src/processing.rs:107Sonora: AEC/NS/AGC, PCM 48k mono и Opus
codexcodex-rs/voice-host/src/devices.rs:390Копия воспроизводимого звука и время вывода для AEC
codexcodex-rs/voice-host/src/capture_worker.rs:61Reference перед capture, сброс обработки на mute и разрыве
codexcodex-rs/voice-host/src/processing_tests.rs:3Тест удаления синтетического эха; здесь не запускался
aiortcsrc/aiortc/codecs/__init__.py:27Поддерживаемые кодеки aiortc 1.14.0; Opus первый в списке
desktopdesktop-audio-processing-evidence.json:1Desktop 26.928.20755: фрагменты захвата, AEC и ducking с SHA256
forkscripts/webrtc-voice-isolated/voice-console.py:117Лимит кадров, mute, epoch и signaling
forkread-aloud-linux/src/main.rs:204Локальные backend и custom command
forkscripts/voice-probes/chatgpt-transcribe.sh:1HTTP диктовка вне Desktop
forkscripts/webrtc-voice-proxy/README.md:1Раздельное наблюдение HTTP/WSS и ICE/RTP
forkdocs/codex-cli-voice-textbook.html:1Предыдущий учебник и срез Rust-исходников

К началу главы

23Что подтверждено, что предложено и чего пока не знаем

VERIFIED / SOURCE означает, что связь найдена в коде или конфигурации. LOCAL TEST - что конкретная проверка выполнена без живых сервисов. INFERRED - механизм риска или ожидаемый эффект без нужного замера. PROPOSED - проект изменения. UNKNOWN - сведений недостаточно. Эти статусы не превращаются в production evidence от наличия хорошей схемы.

IDУтверждениеСтатусОпораГраница
C01Core создаёт peer, media после SDP идёт по WebRTC, не HTTP uploadVERIFIED / SOURCEoffer, negotiate, trackICE codec/peer конкретного runtime не измерен
C02Core передаёт PCM16LE mono 16k, 20 мс по NATS с metadata prefixVERIFIED / SOURCEtrack, pcm, publishПодтверждено в main; deployed image не проверен
C03Исследованный ASR bridge пишет фразу WAV и вызывает HTTPVERIFIED / SOURCEasr, asr-httpСоседний checkout ASR, не runtime trace
C04Voice path возвращает текст; TTS producer не найден в graphVERIFIED / SOURCEtrack, ui-events, front-eventsНе утверждает отсутствие TTS экспериментов во всём repository
C05Очередь без maxsize; pending publish tasks не ограничены semaphoreVERIFIED / SOURCE + LOCAL TESTqueue, publishMock stall: 100 frames → 100 tasks, network=0; не production RAM benchmark
C06Main исправляет ICE deadline, error cleanup и SQL transactionVERIFIED / SOURCE + LOCAL TESTice, tests-core, front-session, tests-front, agent, tests-agent13 Python + 1 frontend unit tests; не e2e
C07Voice blocked выключает sender track во время agent requestVERIFIED / SOURCEtranscription, front-eventsНе полный cancel и не остановка device capture
C08Browser не имеет единого session filter в dispatch событийVERIFIED / SOURCEfront-events, front-configРиск межсессионного смешения INFERRED; live leak не проверен
C09DRS зависит от центрального ASR/LLM/leafnodeVERIFIED / SOURCE CONFIGstackDesired config, не фактическое подключение production
C10Local GGUF путь есть, cache miss вызывает downloadVERIFIED / SOURCEllm-local, llm-modelНаличие файла/качество модели на offline host UNKNOWN
C11CLI V3 разделяет signaling/sideband и медиапутьVERIFIED / SOURCEcodex-call, codex-tui, codex-eventsCLI rust-v0.156.1; серверная реализация UNKNOWN
C12Код WebRTC демо использует bounded capture queue и epochVERIFIED / SOURCEvoice-demoНаше демо, не автоматически поведение Desktop/CLI
C13Возможен padding на хвосте каждого resampled frameVERIFIED / SOURCE; влияние INFERREDpcmDrift и влияние на ASR не измерены
C14Ожидаемое снижение latency и рост ёмкостиPROPOSED / NOT MEASUREDqueue, publish, asrВсе процентные и временные цели - критерии будущего benchmark
C15CLI voice-host включает локальные Sonora AEC/NS/AGC и playout referenceVERIFIED / SOURCEcodex-processing, codex-render, codex-capture, codex-aec-testUpstream тест найден, не запускался; акустическое качество не измерено; перенос в наш клиент PROPOSED
C16Browser запрашивает AEC/NS; aiortc поддерживает OpusVERIFIED / SOURCEfront-mic, front-config, aiortc-codecsФактические getSettings, selected codec, FEC/DTX и качество конкретного runtime UNKNOWN
C17Energy gate закрывает тихий сигнал; Silero вырезает фразы из исходного PCM, не очищает егоVERIFIED / SOURCEfront-gate, front-session, asr-segments, asr-vad-loadКачество gate/VAD и внутренний preprocessing ASR provider UNKNOWN; ONNX надо заранее поставить для offline
C18Desktop имеет разные web capture настройки для Codex voice, ChatGPT voice и диктовки; ducking меняет playout volumeVERIFIED / STATIC BUNDLEdesktop-audioБандл 26.928.20755 повторно прочитан; настройки живого трека, DSP native capture и качество UNKNOWN; Sonora в этих web ветках не подтверждена

Какие проверки выполнены в этой работе

Из remote main извлечена временная копия только нужных исходников; рабочая папка не переключалась. В отдельном venv установлены test requirements. Выполнены python -m unittest discover -s tests -v: 13/13 passed (3 persistence, 3 ICE, 7 routing). После подготовки зависимостей frontend vitest run: 1/1 passed, cleanup при отказе signaling. Модели не запускались, в тестах нет живого ASR/LLM/NATS.

Проба очередей импортирует настоящие классы Core и заменяет только publish ожидающим mock. Получены queue size=100, pending tasks=100, publish entered=3; network requests=0. Это отдельное доказательство роста незавершённой работы, а не тест качества звука.

Выполнены 12 статических проверок HTML и 8 тестов навигационного JS на mock DOM. Проверены внутренние anchors, ссылки схем, меню всех разделов, JSON-примеры, относительные ссылки и отсутствие сетевых загрузок. SVG отдельно отрисованы через ffmpeg/librsvg: это проверка схем, не всей страницы.

Дополнение Sonora/Opus и сравнение очистки проверены отдельно: 11 тестов пояснений, evidence, ссылок схем и краткости выжимки. Пример getStats исполнен на фиктивных потоках в обе стороны, включая неизвестный кодек. Пересобраны 9 SVG учебника и выжимки, просмотрены пять новых или изменённых схем. Микрофон, реальные RTP и обработка Sonora в этих тестах не запускались.

Пять сохранённых Desktop-фрагментов сверены непосредственно с локальным ASAR: версия package.json, SHA256 трёх бандлов, byte offsets и связанные фрагменты совпали. Данные входа не читались. Эта проверка подтверждает источник текста, но не настройку текущего устройства и не качество удаления эха.

Браузер отклонил открытие file:// по политике окружения. Обход через HTTP-сервер или headless browser не выполнялся. Поэтому реальный UI, мобильная раскладка, печать и клавиатурная навигация в браузере остаются непроверенными. Полный протокол - в verification.json; повторяемые команды - в README.txt.

Что требует отдельного стенда

Не проверены deployed SHA/images и live DRS, фактический selected ICE pair/codec, реальное качество ASR/TTS, RTF моделей на целевой CPU/GPU, AEC при перебивании, end-to-end задержки, конкурентные сессии и полностью offline cold start. Также не установлен внутренний STT/TTS стек ChatGPT. Для утверждения автономности нужен весь комплект локальных зависимостей и сетевой тест, а не только source review.

Опора: tests/test_core_ice.py:12 · tests/test_agent_persistence.py:38 · src_front/src/webrtc/session.test.ts:62

К началу главы

24Проверяем понимание и переходим к реализации

Вопрос нового участникаОтвет
Если HTTPS работает, почему голос не проходит?Signaling и медиа используют разные пути. Нужно проверить ICE candidate pair, доступность UDP/TURN и decoded audio; успешный /core/offer недостаточен.
Почему у нас потоковая передача, а распознавание ждёт?Поток кадрами доходит до bridge, который ждёт границу фразы и отправляет WAV. Это file-based ASR внутри разговорного тракта.
Можно просто подключить Codex app-server и получить offline voice?Нет. Его исследованный voice путь вызывает удалённый сервис. Полезны обязанности/контракты, но локальные модели и инфраструктуру надо поставить самим.
У нас уже есть session_id. Зачем ещё generation?Session сохраняется при reconnect; generation различает работу до и после отмены. UUID сессии не отсеивает запоздалый ответ внутри той же сессии.
Семафор 3 разве не держит очередь маленькой?Нет: задачи создаются до ожидания semaphore. Лимит нужен до постановки task или через fixed workers с bounded входом.
Зачем оставлять NATS, если в Codex медиа идёт напрямую?У нас он уже соединяет Core с ASR workers. Codex тоже имеет сетевой медиасервер, просто удалённый. Удалять NATS стоит по bottleneck measurements, не по сходству схем.
Один PCM chunk можно положить в WAV и отправить как raw?Нет. WAV содержит RIFF header, raw PCM - только сэмплы. Header станет шумом; неверная частота меняет время/тембр и не обязана вызвать ошибку.
Сколько именно мы выиграем?Уже доказан механизм накопления и предложено его ограничение. Ускорение/ёмкость не измерены; их принимаем по baseline и одинаковому корпусу с метриками из главы о замерах.
Что потребуется перед первой доработкой?Целевые CPU/GPU/RAM и N_accept, модельные файлы с лицензиями, доверенный CA/DNS, local network topology, выбранный TTS голос и допустимое качество. Отсутствующие данные не мешают читать документ, но мешают обещать конкретные SLA.

Начать можно с двух независимых результатов: комплект для cold start без основной сети и ограниченные очереди на существующем transport. Затем локальный TTS закрывает аудиоответ. Когда эти части проверены, имеет смысл доводить полнодуплексный разговор, а не заменять весь проект ради сходства с ChatGPT.

Связанные проектные документы: текущая архитектура, NATS контракты, точки входа. Предыдущая работа: аудиоучебник Codex в соседнем checkout. Его ссылки необязательны для открытия этого автономного HTML.

Официальные источники для протоколов

Роли app-server и lifecycle операций: OpenAI app-server. Характер Core NATS и необходимость отдельного хранения завершённых событий: NATS Core. Opus RTP-clock 48 кГц при разных частотах входа: RFC 7587. Peer и getStats: W3C WebRTC. Условия доступа к микрофону: MDN getUserMedia. Ссылки не загружаются автоматически и не являются runtime dependencies учебника.

К началу главы