239 lines
20 KiB
Markdown
239 lines
20 KiB
Markdown
# 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`. Явное завершение звонка прекращает
|
||
воспроизведение накопленной речи и включает тон завершения — на обеих платформах.
|
||
Android останавливает гудки до настройки звонкового аудиотракта. После выбора маршрута
|
||
capture/play потоки независимо создают и запускают AudioRecord/AudioTrack; каждый сразу обслуживает своё направление.
|
||
`start()` подтверждает запуск подготовки, ошибки устройств передаются владельцу на main через `onFailure`.
|
||
Ошибка любого направления завершает звонок. Общий `stop()` отменяет подготовку, разблокирует I/O и ждёт оба потока
|
||
до освобождения Android-устройств и native-аудиостека. Отдельные locks защищают подготовку/остановку каждого устройства.
|
||
Замер AEC планируется через 300мс после готовности обоих направлений и не задерживает воспроизведение.
|
||
Маршрутизатор использует один снимок входных/выходных устройств; обновляет его при старте,
|
||
событиях устройств/BT-профиля и явной переоценке. По завершении снимок освобождается.
|
||
INFO-логи Android показывают длительности настройки режима, регистрации callback, BT-профиля, выбора маршрута,
|
||
создания/старта устройств, первого чтения микрофона и первой записи в AudioTrack, а также готовность другого направления.
|
||
Общий `call_audio` логирует время ожидания первого PCM; запись в AudioTrack может содержать начальную тишину.
|
||
|
||
## 7. Буфер звонка и восстановление
|
||
|
||
`call_jitter` независим от `radio_jitter`: рация сохраняет свои правила задержки и сброса.
|
||
Буфер звонка хранит до 750 кодированных кадров (15с): 10с перебоя и 5с запаса.
|
||
Целевой запас — `clamp(max-min, 60, max_reserve_ms)` мс по изменению задержки доставки:
|
||
на входящем кадре накапливается разность интервалов приёма и отправки, десять секундных интервалов хранят её экстремумы.
|
||
Отправитель уже передаёт timestamp в CALL_MEDIA; смещение часов пиров исключается, переполнение uint32 учитывается.
|
||
Подготовка местного аудиоустройства и ускоренное воспроизведение очереди не изменяют оценку доставки.
|
||
Минимум/максимум глубины очереди для GUI собираются отдельно в аудиопотоке за те же десять секунд.
|
||
Верхний предел задаётся `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 — цикличные MP3 через `CallRingback`/`MediaPlayer`.
|
||
Переключение — по `CHAT_EVT_CALL_RINGING`,
|
||
остановка — по `CALL_ACCEPTED`/`CALL_ENDED`/`CALL_ERROR`/hangup.
|
||
- **Начальное ожидание первого PCM** → тишина без сигнала потери звука. Подготовка удалённого микрофона
|
||
и начальное накопление jitter не считаются перебоем. Существующий таймаут 20с без медиа действует с ACTIVE.
|
||
- **Недобор PCM ≥500мс после начала воспроизведения** → в `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.
|