# P2P аудио-звонок пользователю — архитектура Статус: реализовано. Звонок использует роутер, CM подготавливает оптимальный путь. ## 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 → аудиопоток через роутер → HANGUP. CM инициируется вызывающим при старте, вызываемым при принятии вызова. ## 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/call_jitter.h/cpp` | C++ | только GUI-сборки | SoundTouch джиттер-буфер, C API | Autotools-сборка демона остаётся C-only (`.cpp` не компилируется); сигналинг `call.c` присутствует и в демоне, аудиопроцессинг — только там, где есть GUI. Диагностика: категория `call` (`DEBUG_CATEGORY_CALL`); транспортная доставка — `etcp_route`. ## 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) → ACTIVE(ACCEPT) → ENDED Callee: IDLE → INCOMING(INVITE) → RINGING(sent RINGING) → ACTIVE(accept) → ENDED ``` `ENDED` reasons: `LOCAL_HANGUP / REMOTE_HANGUP / DECLINE / BUSY / CANCEL / NO_TRAFFIC / RING_TIMEOUT / CONNECT_FAIL`. Таймауты: - **кодограмма INVITE 300мс** — вызывающий шлёт её весь период вызова (`OUTGOING` + `RINGING`) как «сердцебиение»: покрывает потерю INVITE/RINGING и держит маршрут живым; - **watchdog 2с у вызываемого** — нет кодограммы 2с → вызов считается сорванным (отмена потерялась), сессия закрывается локально (`CALL_REASON_CANCEL`); - ring-timeout 45с (вызывающий, нет ответа → CANCEL); - `CONN_EVENT_DOWN/TIMEOUT` CM не завершают звонок: роутер использует доступные пути; - **NO_TRAFFIC 20с** — нет входящих медиа-кадров → авто HANGUP. Завершающие кодограммы (HANGUP/CANCEL/DECLINE) шлются без ретрансмиссий — потеря компенсируется watchdog'ом 2с у вызываемого. Запоздалые кодограммы вызова при идущем разговоре (`ACTIVE`) игнорируются. Онлайн-проверка: `topo_node_find_by_id(group, peer) == NULL` → «не в сети» (event). CM handle инициирует оптимальное подключение и удерживается до завершения звонка. Медиа всегда идёт через `etcp_route_send`: роутер сам выбирает лучший путь и fallback. Звонок не хранит ETCP_CONN и не завершается при потере отдельного транспорта. Очистка записи CM отсоединяет handles и отменяет отложенные UP; handle закрывает владелец. Закрытие CM handle не посылает DISCONNECT удалённым владельцам. Механизм NCD не меняется. ## 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), ёмкостью 15с (750 кадров). При переполнении — дроп старейшего + счётчик `c_tx_dropped`. - Алгоритм (`call_tx_flush`): пока `tx_q` не пуст: - если `send_q_has_room` → `queue_data_get` + `etcp_route_send(force=1)` (после проверки места); - иначе — если 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. Потоки и жизненный цикл `call_session`, CM handle, очередь TX и таймеры принадлежат uasync-потоку. Аудиопоток кодирует PCM и постит `call_send_media`; приём в uasync кладёт Opus в `call_jitter`. Декодер, SoundTouch и генератор тонов принадлежат аудиопотоку. Кольцо кодированных кадров и снимок статистики защищены мьютексом; статистика не читает SoundTouch из другого потока. GUI запускает аудиоустройство по CALL_ACCEPTED. При CALL_ENDED начинается финальный тон; устройство останавливается перед `call_audio_release`. Явное завершение звонка прекращает воспроизведение накопленной речи и включает тон завершения — на обеих платформах. ## 7. Буфер звонка и восстановление `call_jitter` независим от `radio_jitter`: рация сохраняет свои правила задержки и сброса. Буфер звонка хранит до 750 кодированных кадров (15с): 10с перебоя и 5с запаса. Целевой запас — `clamp(max-min, 60, max_reserve_ms)` мс: десять секундных интервалов хранят минимум и максимум глубины, старые интервалы истекают по монотонным часам. Верхний предел задаётся `call_jitter_max_reserve_ms`: 500–2000 мс, по умолчанию 1000 мс. Настройка есть в аудиопараметрах desktop и Android и применяется к следующему звонку. Начальное и повторное накопление ждёт этот запас; увеличение запаса не прерывает текущую речь. Ускорение зависит от превышения запаса. При отсутствии входящих кадров обычное воспроизведение может исчерпать запас: это не жёсткий запрет воспроизведения ниже цели. Статистика раз в секунду отображается в desktop-окне и Android-экране звонка (под кнопками): глубина, запас, минимум/максимум, темп, RTT, удаления при переполнении и недостачи PCM. Диагностика буфера — категория `call=debug`. После восстановления кадры последовательно декодируются; темп плавно растёт до 1.6x без изменения высоты голоса: линейный предел роста 1.0x→1.6x за 500мс воспроизведения. При уменьшении накопления целевой темп снижается к 1.0x; скорость снижения ограничена тем же пределом. Пауза аудиоустройства не сбрасывает буфер. Переполнение удаляет старейший кадр с WARN и счётчиком. Роутер сохраняет последовательность и неподтверждённые данные при смене пути. Для сервиса звонка срок без прогресса ACK увеличен примерно до 20с; прочие сервисы сохраняют прежний срок. Перезапуск удалённого процесса/роутерной сессии не является кратковременным перебоем пути и может сбросить данные. SoundTouch: `USE_QUICKSEEK=1, SEQUENCE_MS=20, SEEKWINDOW_MS=10, OVERLAP_MS=4`. ## 8. Тоны и диагностика - **Исходящий звонок — две фазы с разными гудками** (у вызывающего): - фаза 1 «пытаемся связаться» (`OUTGOING`: INVITE отправлен/ретранслируется, маршрут устанавливается) — гудок «соединение» (короткие бипы); - фаза 2 «звоним» (`RINGING`: получен RINGING, у пира идёт вызов) — длинные гудки (ringback). Гудки генерируются платформенно-нативно: desktop — цикличные MP3 через `SoundManager` (`call_connecting`/`call_ringing`), Android — `ToneGenerator` (`TONE_SUP_DIAL`/`TONE_SUP_RINGTONE`). Переключение — по `CHAT_EVT_CALL_RINGING`, остановка — по `CALL_ACCEPTED`/`CALL_ENDED`/`CALL_ERROR`/hangup. - **Отсутствие медиа ≥500мс при недоборе PCM** (нет входящих медиа) → в `call_audio_pull_pcm` воспроизводится `CALL_TONE_GLITCH`: `пи-пи (2×150мс, gap 100мс) … тишина до 1.0с … пи(200мс) пауза(200мс) пи(200мс)`, 1000 Гц, цикл пока длится сталл. До порога — тишина при нехватке PCM. При восстановлении медиа или полном запросе PCM сигнал останавливается. - **Завершение звонка → всегда** `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 39 [call_id:8][rtt:2][buffer:2][tempo:2][dropped:2][underruns:2][min:2][max:2][reserve: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.