17 KiB
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/TIMEOUTCM не завершают звонок: роутер использует доступные пути;- 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.
- фаза 1 «пытаемся связаться» (
- Отсутствие медиа ≥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*+ 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.