You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 
 

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/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.