12 KiB
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_mgrCONN_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*+ KotlinAudioRecord/AudioTrack. Переключение источника:AudioManager(API31+setCommunicationDevice+availableCommunicationDevices; fallbacksetSpeakerphoneOn+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попадёт через GLOBsrc/*.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. Порядок задач
call_proto.h+ svc_id +DEBUG_CATEGORY_CALL.call.c(машина состояний, backpressure-media, watchdog, события, API) +chat_coreхуки.audio_jitter.cpp(SoundTouch) +test_audio_jitter.c.call_tones.c+call_audio.c(Opus + фрейминг + тон-обвязка).- Desktop:
call_audio_engine.cpp+ UI + события. - Android: JNI + Kotlin AudioRecord/Track + переключение источника + UI.
- Сборка (Makefile.am / CMake обе).
test_call.c(два инстанса), интеграция desktop↔desktop и desktop↔android.