# P2P аудио-звонок пользователю — архитектура Статус: спроектировано, ждёт реализации. ## 1. Область Голос 1:1. Звонок можно инициировать **из CHAT-группы или из p2p-чата (DM)** — модуль не привязан к типу группы: он оперирует `group_id` + `peer_node_id` (для DM `group_id` — DM-группа, маршрутизация через `etcp_router` идентична). Протокол расширяем под видео (track-модель), реализуется только аудио. Работает desktop↔desktop и desktop↔android (обе стороны компилируют общий стек `etcp_router` / `topo_group` (BGP) / `conn_mgr`). Полный цикл: проверка онлайна (BGP) → INVITE → звонок → ACCEPT/DECLINE → установка соединения (conn_mgr) → аудиопоток → HANGUP. ## 2. Разбиение файлов (отдельный каталог `src/call/`) | Файл | Язык | Сборки | Назначение | |---|---|---|---| | `src/call/call_proto.h` | C | везде | wire-формат, subcmd, svc_id `ETCP_RT_ID_CALL 0x34` | | `src/call/call.h` / `call.c` | C | везде (autotools + GUI) | сигналинг, реестр сессий, таймеры, watchdog (20с по приёму), релей медиа с backpressure, счётчики | | `src/call/call_audio.h/c` | C | только GUI-сборки | Opus encode/decode, фрейминг, pull-обвязка с тонами; регистрация через `call_audio_ops` | | `src/call/call_tones.h/c` | C | только GUI-сборки | генератор тонов (glitch/ended), синус | | `src/call/audio_jitter.h/cpp` | C++ | только GUI-сборки | SoundTouch джиттер-буфер, C API | Autotools-сборка демона остаётся C-only (`.cpp` не компилируется); сигналинг `call.c` присутствует и в демоне, аудиопроцессинг — только там, где есть GUI. Новая категория логирования: `DEBUG_CATEGORY_CALL = 31` (добавить в `debug_config.h`, `DEBUG_CATEGORY_COUNT` → 32). ## 3. Протокол (svc_id 0x34, первый байт payload = subcmd) Формат приёма (как media_delivery, одноуровневый): `entry->dgram[0]=svc_id`, `entry->dgram[1]=subcmd`, `entry->dgram[2..]=payload`. ``` CALL_INVITE 0x01 {call_id:8, group_id:8, tracks:[{type:AUDIO, codec:OPUS, sr:4, ch:1, bitrate:4}]} CALL_RINGING 0x02 {call_id:8} CALL_ACCEPT 0x03 {call_id:8, agreed_tracks} CALL_DECLINE 0x04 {call_id:8, reason:1} CALL_BUSY 0x05 {call_id:8} CALL_CANCEL 0x06 {call_id:8} CALL_HANGUP 0x07 {call_id:8, reason:1} CALL_MEDIA 0x10 {call_id:8, track_id:1, seq:2, ts_ms:4, payload} ``` - `call_id` — случайный uint64, генерит вызывающий; идентифицирует сессию на обеих сторонах. - `group_id` — CHAT-группа или DM-группа (общая маршрутизация). - Сигналинг: `etcp_route_send(..., ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT)`. - Медиа: без подписи (уже внутри шифрованного канала etcp_router). - `CALL_MEDIA` — общий контейнер; `track_id` оставляет место видео (`type=VIDEO`). - Аудио: Opus 48kHz mono, 20ms (960 сэмплов), ~24–32 kbps. ~50 кадров/с. ## 4. Машина состояний ``` Caller: IDLE → OUTGOING(INVITE) → RINGING(RINGING) → CONNECTING(ACCEPT, conn_mgr_open) → ACTIVE → ENDED Callee: IDLE → INCOMING(INVITE) → RINGING(sent RINGING) → ACCEPTING(accept, conn_mgr_open) → ACTIVE → ENDED ``` `ENDED` reasons: `LOCAL_HANGUP / REMOTE_HANGUP / DECLINE / BUSY / CANCEL / NO_TRAFFIC / RING_TIMEOUT / CONNECT_FAIL`. Таймауты: - ring-timeout 45с (нет ответа → CANCEL); - connect: `conn_mgr` `CONN_EVENT_TIMEOUT` → fail; - **NO_TRAFFIC 20с** — нет входящих медиа-кадров → авто HANGUP. Онлайн-проверка: `topo_node_find_by_id(group, peer) == NULL` → «не в сети» (event). Соединение: на ACCEPT обе стороны `conn_mgr_open(inst, group_id, peer, cb, ...)`; `CONN_EVENT_UP` → ACTIVE. Медиа идёт `etcp_route_send` (после подъёма прямого линка роутер идёт напрямую — RTT минимален). ## 5. Отправка медиа — backpressure через локальный буфер Требование: не дропать аудио при переполнении роутера — держим в локальном буфере отправки, дренируем при появлении места. Механика (`etcp_router` уже предоставляет): - `etcp_route_send(force=0)` возвращает `-1`, когда `send_q` роутера полон (`count >= ROUTER_MAX_SEND_Q_PACKETS`). - `etcp_router_send_q_has_room(...)` — есть ли место (порог `ROUTER_MAX_SEND_Q_PACKETS-1`). - `etcp_router_on_send_ready(..., &waiter, drain_cb, arg)` — одноразовый waiter: колбэк вызывается, когда `send_q->count <= threshold` (освободилось место). В сессии: - `tx_q` — локальная FIFO очередь закодированных кадров (ll_entry с dgram), ёмкостью ~1с (≈50 кадров). При переполнении — дроп старейшего + счётчик `c_tx_dropped`. - Алгоритм (`call_tx_flush`): пока `tx_q` не пуст: - если `send_q_has_room` → `queue_data_get` + `etcp_route_send(force=0)`; - иначе — если waiter не зарегистрирован → `etcp_router_on_send_ready(... drain_cb)` и выход. - `drain_cb(q, arg)` → снова `call_tx_flush(session)` (re-arm при необходимости). - Весь код — в uasync-потоке (single-threaded, реентерабельность как в `media_delivery.c:stream_send_chunk_cb`). ## 6. Thread-модель (single-writer) | Объект | Владелец | |---|---| | `call_session` (состояние, encoder, decoder, tx_q, таймеры) | uasync-поток | | Opus encoder (send) | аудио-поток (encode) | | Opus decoder (recv) | uasync-поток (`decode → ajb_push`) | | `ajb` push | uasync-поток | | `ajb` pull + SoundTouch | аудио-поток | | `call_tones` | аудио-поток | Поток данных: - TX: аудио-поток кодирует → `call_send_encoded(call_id, opus, len)` → `uasync_post` → uasync: `tx_q` + `call_tx_flush` (backpressure). - RX: uasync принимает `CALL_MEDIA` → decode → `ajb_push`; аудио-поток `call_pull_pcm` → `ajb_pull` (+ тоны). `ajb` = mutex-кольцо PCM (гранулярность 20ms/50 ops в сек — мьютекс бесплатен); SoundTouch инстанс заперт на pull-стороне. **Контракт жизненного цикла:** GUI стартует аудио-устройство по `CALL_ACCEPTED`, останавливает по `CALL_ENDED` и только после остановки (join аудио-потока) вызывает `call_release(call_id)` (post в uasync) → uasync освобождает session/encoder/decoder/jb. Пока устройство работает — `call_pull_pcm`/`call_send_encoded` не гоняются с освобождением. ## 7. Адаптивный джиттер-буфер (SoundTouch) Буфер ≤ **1000 мс**, target playout ~60 мс; при росте буфера плавно tempo → **1.6x**. SoundTouch: `USE_QUICKSEEK=1, SEQUENCE_MS=20, SEEKWINDOW_MS=10, OVERLAP_MS=4`. API: `ajb_create / ajb_push / ajb_pull / ajb_stats / ajb_destroy`. ## 8. Тоны и диагностика - **Сталл >500мс** (нет входящих медиа) → в `call_pull_pcm` микшируется `CALL_TONE_GLITCH`: `пи-пи (2×150мс, gap 100мс) … тишина до 1.0с … пи(200мс) пауза(200мс) пи(200мс)`, 1000 Гц, цикл пока длится сталл. - **Завершение звонка → всегда** `CALL_TONE_ENDED`: 3 нисходящих 700/500/400 Гц по 150 мс. - Счётчики: tx/rx кадры, `c_tx_dropped`/`c_rx_dropped`, дропы буфера, сталлы, tempo-события, причины завершения. Логи `DEBUG_CATEGORY_CALL` (+стата раз в 1с). ## 9. GUI-события (extend `chat_event.h` + `gui_bridge.h` + android) ``` CHAT_EVT_CALL_INCOMING 32 [ch_id_len][ch_id][caller_node_id:8][call_id:8] CHAT_EVT_CALL_RINGING 33 [call_id:8] CHAT_EVT_CALL_ACCEPTED 34 [call_id:8] CHAT_EVT_CALL_ENDED 35 [call_id:8][reason:1] CHAT_EVT_CALL_DECLINED 36 [call_id:8][reason:1] CHAT_EVT_CALL_STATS 37 [call_id:8][rtt_ms:2][buffer_ms:2][tempo_x100:2] CHAT_EVT_CALL_ERROR 38 [call_id:8][err:1][text:var] CHAT_EVT_CALL_AUDIO_ROUTE 39 (Android: текущий маршрут/наличие гарнитуры) ``` ## 10. Публичный API (call.h) ``` int call_start(ch_id, peer_node_id, out_call_id); // caller (post в uasync) void call_accept(call_id); // callee void call_decline(call_id); void call_hangup(call_id); int call_is_online(ch_id, peer_node_id); // BGP-проверка для UI // аудио-поток (общий процессинг в C-ядре): int call_encode_pcm(call_id, pcm, n, opus_out, cap); // single-writer: audio thread int call_send_encoded(call_id, opus, len); // post → uasync → tx_q + flush int call_pull_pcm(call_id, pcm_out, max); // single-writer: audio thread (ajb+tones) void call_release(call_id); // post → uasync: освободить аудио-объекты ``` `call_init / call_destroy` — вызываются из `chat_core_init / chat_core_destroy`. ## 11. Платформенный I/O - **Desktop:** `tools/chatgui/src/call_audio_engine.cpp` — miniaudio full-duplex (capture → `call_encode_pcm`, `call_pull_pcm` → playback) + окно звонка (UI) + события в `gui_bridge_impl.cpp`. - **Android:** JNI `nativeCall*` + Kotlin `AudioRecord`/`AudioTrack`. **Переключение источника:** `AudioManager` (API31+ `setCommunicationDevice` + `availableCommunicationDevices`; fallback `setSpeakerphoneOn` + `startBluetoothSco`), детект гарнитуры (`ACTION_HEADSET_PLUG`, BT-профиль, `ACTION_SCO_AUDIO_STATE_UPDATED`), кнопка-цикл трубка/громкая/гарнитура, авто-выбор гарнитуры. `nativeCallSetAudioRoute(route)`. ## 12. Сборка - `src/Makefile.am` + `tests/Makefile.am`: `call.c` (C, без `.cpp`). `src/call/` в include path. - `tools/chatgui/libutun/CMakeLists.txt`: +`call_audio.c`, `call_tones.c`, `audio_jitter.cpp`, soundtouch sources (AAFilter, FIFOSampleBuffer, FIRFilter, InterpolateCubic/Linear/Shannon, RateTransposer, SoundTouch, TDStretch, cpu_detect_x86, mmx_optimized, sse_optimized) как C++. `call.c` попадёт через GLOB `src/*.c`. - `tools/chatgui-android/libutun_lite/CMakeLists.txt` + `utun_sources.cmake`: `LANGUAGES C CXX`, те же файлы. - `src/transport_layer/etcp_api.h`: `#define ETCP_RT_ID_CALL 0x34`. - `lib/debug_config.h`: `DEBUG_CATEGORY_CALL 31`, `DEBUG_CATEGORY_COUNT` → 32. ## 13. Порядок задач 1. `call_proto.h` + svc_id + `DEBUG_CATEGORY_CALL`. 2. `call.c` (машина состояний, backpressure-media, watchdog, события, API) + `chat_core` хуки. 3. `audio_jitter.cpp` (SoundTouch) + `test_audio_jitter.c`. 4. `call_tones.c` + `call_audio.c` (Opus + фрейминг + тон-обвязка). 5. Desktop: `call_audio_engine.cpp` + UI + события. 6. Android: JNI + Kotlin AudioRecord/Track + переключение источника + UI. 7. Сборка (Makefile.am / CMake обе). 8. `test_call.c` (два инстанса), интеграция desktop↔desktop и desktop↔android.