369 lines
39 KiB
Markdown
369 lines
39 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 0x36` |
|
||
| `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 0x36, первый байт payload = subcmd)
|
||
|
||
Формат приёма: `entry->dgram[0]=svc_id`, служебный заголовок роутера до
|
||
`ROUTER_SVC_PAYLOAD_OFF=26`, затем subcmd и тело команды.
|
||
|
||
```
|
||
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}
|
||
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_END_ACK 0x08 {call_id:8}
|
||
CALL_MEDIA 0x10 {call_id:8, track_id:1, seq:2, ts_ms:4, capture_pts_us:8, capture_epoch:8, codec_delay_samples: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 кадров/с.
|
||
- Заголовок медиа — 36 байт с subcmd. `ts_ms` — время отправки для оценки сетевого джиттера;
|
||
`capture_pts_us` — время первого входного сэмпла в монотонной шкале отправителя, `INT64_MIN` при отсутствии метки.
|
||
`capture_epoch` различает разрывы захвата; `codec_delay_samples` передаёт Opus lookahead.
|
||
Старый формат не поддерживается; медиа принимается только от пира текущего звонка.
|
||
|
||
## 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 повторяются каждые 300мс до END_ACK,
|
||
не дольше 20с. Это необходимо при смене эпохи роутера: старые очереди удаляются,
|
||
даже если отправитель ранее получил успешный результат постановки команды в очередь.
|
||
Медиа и их локальные очереди освобождаются сразу, ENDED публикуется один раз.
|
||
В реестре до ACK/таймаута остаётся только завершённая сессия с CM handle и таймером.
|
||
Получатель подтверждает также повторные команды после удаления сессии, без повторного ENDED.
|
||
Команда и ACK проверяются по подписи, шифрованию, размеру, call_id, пиру и группе существующей сессии.
|
||
ACK не подтверждается. Новый звонок имеет другой call_id и не затрагивается поздними командами старого.
|
||
При одновременном завершении стороны независимо подтверждают команды друг друга.
|
||
После ACK сессия удаляется на ближайшем tick завершения; при удалённом завершении CM удерживается
|
||
ещё 500мс для отправки ACK. Остановка ядра отменяет таймер без повторных отправок.
|
||
Запоздалые кодограммы вызова при идущем разговоре (`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 из другого потока.
|
||
`CallTimeline` хранит фактическое соответствие входных участков SoundTouch выходному PCM.
|
||
Платформа публикует позицию воспроизведения с локальной монотонной меткой; `call_audio_get_clock`
|
||
возвращает исходный PTS слышимого участка. Локальная тишина и тоны удерживают PTS.
|
||
Невалидная/устаревшая отметка устройства не разрешает продвижение видео. Контракт и ограничения — `call_video.txt`.
|
||
|
||
GUI запускает аудиоустройство по CALL_ACCEPTED. При CALL_ENDED начинается финальный тон;
|
||
устройство останавливается перед `call_audio_release`. Явное завершение звонка прекращает
|
||
воспроизведение накопленной речи и включает тон завершения — на обеих платформах.
|
||
Android останавливает гудки до настройки звонкового аудиотракта. После выбора маршрута
|
||
capture/play потоки независимо создают и запускают AudioRecord/AudioTrack; каждый сразу обслуживает своё направление.
|
||
`start()` подтверждает запуск подготовки, ошибки устройств передаются владельцу на main через `onFailure`.
|
||
Ошибка любого направления завершает звонок. Общий `stop()` отменяет подготовку, разблокирует I/O и ждёт оба потока
|
||
до освобождения Android-устройств и native-аудиостека. Отдельные locks защищают подготовку/остановку каждого устройства.
|
||
Замер AEC планируется через 300мс после готовности обоих направлений и не задерживает воспроизведение.
|
||
При наличии Telecom Connection режимом и выходом звонка управляет только Telecom.
|
||
ACTIVE запрашивается до создания аудиоустройств; AudioManager/прямой preferred device не дублируют выбор.
|
||
На API 34+ маршрут запрашивается через доступный CallEndpoint, на старых — через Connection.setAudioRoute.
|
||
Подтверждённый маршрут приходит из callback системы, отдельно от желания пользователя; ошибка запроса логируется.
|
||
Если Connection ещё нет, используется AudioManager. Поздняя регистрация передаёт управление Telecom,
|
||
снимая заявку приложения на режим и прямые устройства. Рация сохраняет отдельный AudioManager-путь.
|
||
Реальное время запуска/переключения зависит от Android и требует проверки на устройстве.
|
||
Маршрутизатор использует один снимок входных/выходных устройств; обновляет его при старте,
|
||
событиях устройств/BT-профиля и явной переоценке. По завершении снимок освобождается.
|
||
INFO-логи Android показывают длительности настройки режима, регистрации callback, BT-профиля, выбора маршрута,
|
||
создания/старта устройств, первого чтения микрофона и первой записи в AudioTrack, а также готовность другого направления.
|
||
Общий `call_audio` логирует время ожидания первого PCM; запись в AudioTrack может содержать начальную тишину.
|
||
Эффективный рабочий буфер AudioTrack на всех поддерживаемых API при старте запрашивается размером
|
||
два блока PCM (40мс); на API 31+ стартовый порог следует фактически принятому размеру.
|
||
Ёмкость выделенной памяти может быть больше. Ограничение повторяется по callback фактической
|
||
смены выхода на main, под trackLock; callback старого AudioTrack игнорируется и снимается при stop.
|
||
Подачу PCM задаёт свободное место в AudioTrack, без дополнительного таймера 20мс.
|
||
Частичная запись удерживает остаток одного блока до полной передачи; следующий pull ждёт.
|
||
Сетевой запас остаётся в адаптивном JB, его рост и SoundTouch-догон не изменены.
|
||
Лог `playback buffering` показывает причину настройки, запрошенный/принятый размер, порог и ёмкость;
|
||
ежесекундный `audio summary` — queueUs, текущий порог и underruns; новый episode underrun имеет уровень WARN.
|
||
Это очередь AudioTrack, а не полная сквозная задержка и не сетевой jitter reserve.
|
||
Ранее на Samsung startFrames=1920 был принят, но без ограничения рабочего буфера
|
||
written-played оставался 120–160мс; одного стартового порога недостаточно.
|
||
Замер Samsung подтвердил постоянную очередь 40мс. При прежнем параллельном управлении маршрутом
|
||
AudioTrack пересоздавался, очередь временно достигала 160мс, а ожидание свободного места — 328–336мс.
|
||
При старте AudioTrack.play занимал 1080мс. После перехода к единственному владельцу Telecom
|
||
в проверенном звонке старт занял 62мс, очередь оставалась не более 40мс и при смене выхода.
|
||
Переключение всё ещё останавливало потребление на 293–328мс; этот всплеск принимает сетевой JB.
|
||
|
||
На desktop `AudioDiag: capture clock` при call=debug выводит секундную сводку исходных backend-блоков:
|
||
число блоков/сэмплов, накопленную последовательность, диапазон ошибки относительно конца предыдущего блока,
|
||
latency PulseAudio, число скачков >2мс, hole-сэмплы и блоки без достоверного измерения.
|
||
Первый скачок за секунду содержит обе latency/поправки конвертера, expected/actual PTS и монотонное время.
|
||
Backend публикует только числовой снимок в ограниченное кольцо; форматирование выполняет reporter.
|
||
Исходный backend-снимок остаётся наблюдением. В capture worker позиция PCM задаёт непрерывные PTS,
|
||
а backend медленно корректирует их привязку: постоянная времени 2с, предел 0.5% (100мкс на 20мс PCM).
|
||
Скорректированное время используется и для Opus, и для выбора AEC-референса. Сам PCM не изменяется.
|
||
Разрыв sequence, смена route и скачок исходных меток >20мс по-прежнему сбрасывают capture/AEC и эпоху;
|
||
порог 2мс в call_audio не увеличивается. Секундный `voice-io: capture clock` показывает phase/error/adjustment.
|
||
Тесты проверяют десять минут дрейфа +/-300ppm, скачки оценки до 14мс, разбиение чанков 25мс на Opus 20мс,
|
||
сохранность PCM, явную потерю, смену устройства и большой разрыв исходных часов.
|
||
Счётчик sequence описывает полученные backend-сэмплы; отсутствие потерь очереди само по себе
|
||
не доказывает непрерывность физического захвата. Проверять вместе с hole/overflow и route-событиями.
|
||
|
||
Для захвата звонка desktop явно запрашивает период 20мс вместо дефолта PulseAudio 25мс;
|
||
фактический период сообщает `audio: call-capture initialized`. Сборщик по-прежнему принимает произвольные чанки.
|
||
Секундные замеры задержки при `call=debug`:
|
||
- `voice-io: capture latency`: возраст первого сэмпла перед native feed (включает сам блок 20мс),
|
||
подготовка/AEC в worker и вызов native feed; диапазоны min/avg/max в микросекундах.
|
||
- `voice-io: playback latency`: приблизительный остаток PCM после mix, включая частичный блок,
|
||
длительность pull и недостающие сэмплы. Это очередь приложения, отдельно от backend.
|
||
- `AudioDiag: playback clock`: latency backend, задержка конвертера/cache и максимальный размер
|
||
backend-порции в микросекундах. При measured=0 нулевые границы не означают нулевую задержку.
|
||
- `call audio TX timing`: ожидание g_mtx, обработка/кодирование под ним, ожидание posted task;
|
||
post включает ожидание lock при выполнении задачи, его разрешение 100мкс. На Android этот же
|
||
замер обработки включает программный AEC, на desktop AEC уже выполнен в capture worker.
|
||
- `call audio TX queue`: число ожидающих кадров, возраст старейшего и avg/max ожидания кадров,
|
||
переданных роутеру за интервал. Отправка роутеру не является подтверждением доставки пиру.
|
||
- Android `audio capture latency`: возраст первого сэмпла перед native feed и число измеренных
|
||
привязок; дополнительные поля старта устройств разделяют min-buffer query, constructor, routing и buffer setup.
|
||
Возраст PCM содержит часть измеренной обработки: эти числа нельзя механически складывать.
|
||
PTS/возраст используют локальный MONOTONIC, интервалы внутри native — локальный get_time_us/get_time_tb;
|
||
эти временные базы и часы разных устройств напрямую не вычитаются. Все сводки формируются вне device callback.
|
||
Период захвата 20мс и новые измерения требуют контрольного звонка; дополнительная desktop PCM-очередь,
|
||
жизненный цикл подготовки Android и таймер показа видео этим изменением не менялись.
|
||
|
||
На Android `CallPlaybackBuffer` подбирает рабочую очередь отдельно от JB/SoundTouch:
|
||
после 30с наблюдаемого продвижения playback head без underrun пробует уменьшить её на один
|
||
блок PCM (960 сэмплов, 20мс), до одного блока или фактического минимума устройства.
|
||
При росте AudioTrack.underrunCount добавляет один блок в пределах выделенной ёмкости.
|
||
Интервал следующей пробы удваивается (60/120/240/300с), чтобы не повторять частые неудачные уменьшения.
|
||
После старта, запроса/подтверждения маршрута и увеличения даются 2с на стабилизацию;
|
||
после уменьшения первый underrun учитывается немедленно. Отсутствие продвижения head или
|
||
пропуск наблюдений >1с обнуляет стабильный участок. Сброс системного счётчика учитывается отдельно.
|
||
Фактическая смена выхода начинает подбор с 40мс заново. Непринятая меньшая заявка фиксирует
|
||
нижнюю границу до следующего выхода; ошибки API выключают подбор с WARN/ERROR, сохраняя воспроизведение.
|
||
Reporter управляет размером под trackLock независимо от фильтра debug; playback не берёт этот lock.
|
||
Нет flush/recreate, частичная запись PCM сохраняется. После смены размера/выхода через секунду
|
||
повторно измеряется задержка для AEC. Ожидание полного буфера и недостача сети не увеличивают его размер.
|
||
Лог настройки содержит reason, previous/requested/actualFrames и прирост underruns;
|
||
достижение ёмкости предупреждает не чаще раза в 10с. Принятие и стабильность 20мс ещё требуют
|
||
проверки на Samsung; буферы HAL и Bluetooth могут добавлять собственную задержку.
|
||
На desktop адаптация устройства ещё не реализована: выход принадлежит SoundManager,
|
||
изменение backend-буфера должно выполняться его владельцем, а не отдельным звонком.
|
||
|
||
Диагностика Android при `call=debug`: playback сохраняет один числовой снимок на блок (20мс)
|
||
в кольце 512 элементов, без форматирования/вывода. Отдельный reporter раз в 200мс забирает записи;
|
||
раз в секунду выводит `audio summary` с min/avg/max очередей, задержек pull/write и опоздания потока,
|
||
целевым запасом/разбросом доставки JB, темпом, недостающим PCM и отдельными счётчиками JB/AudioTrack.
|
||
Снимок JB не сбрасывает события для CALL_STATS. encodedMs — очередь Opus после pull,
|
||
processingMs — оставшийся PCM SoundTouch в длительности исходной речи; reserveMs зависит только от доставки.
|
||
Reporter опрашивает AudioTimestamp, routedDevice, underrunCount и размеры буфера не чаще раза в 200мс.
|
||
Playback читает только опубликованный неизменяемый снимок. Его begin/end и возраст deviceAgeUs
|
||
отличают задержку опроса от задержки аудио; stampQueryUs/routeQueryUs/propertiesQueryUs показывают стоимость запросов.
|
||
Android API может удерживать общие внутренние locks даже при вызове из reporter: перенос убирает
|
||
прямое ожидание диагностического запроса в playback, но сам по себе не доказывает отсутствие влияния на write/head.
|
||
frame/time аппаратного timestamp и время начала опроса печатаются отдельно;
|
||
при недоступности стоит stampOk=0. Времена trace — MONOTONIC; очередь устройства — written-played.
|
||
Эти показатели нельзя складывать с возрастом AudioTimestamp как независимые задержки.
|
||
В playback отдельно измеряются writeCallUs (только вызовы write), headCallUs (чтение позиции),
|
||
progressCallUs (публикация позиции ядру), jbSnapshotUs (нативный снимок), writeSpanUs (весь цикл записи)
|
||
и blockUs (pull до формирования строки). При частичных записях длительности вызовов суммируются за блок.
|
||
pullGapExcessUs — превышение интервала между началами соседних pull над 20мс;
|
||
включает ожидание устройства, а не только задержку планировщика (прежнее поле wakeLateUs удалено).
|
||
Старое поле writeUs включало чтение позиции и прогресс: его нельзя трактовать как длительность одного AudioTrack.write.
|
||
Stop дожидается reporter, включая непрерываемый Android-запрос, до освобождения AudioTrack.
|
||
|
||
`audio detail` сохраняет 2с до и 3с после старта, фактической смены routedDevice или новой аномалии:
|
||
очередь устройства >100мс в течение секунды, отсутствие продвижения head 100мс при наличии данных,
|
||
либо новый episode underrun (затухает через 3с без повторений). Порог очереди — диагностический,
|
||
не настройка JB и не изменение воспроизведения. События внутри окна объединяются, между выгрузками
|
||
не менее 10с; продолжающаяся аномалия не создаёт повторных дампов. Восстановление логируется INFO.
|
||
Подробные строки содержат по четыре снимка и заголовок с порядком колонок; при stop окно может быть неполным.
|
||
Переполнение кольца при задержке reporter имеет отдельный WARN lost=N. Аудиоданные не сохраняются.
|
||
Выключение call=debug прекращает сбор/выгрузку; буфер и reporter освобождаются с playback worker.
|
||
Сравнивать сводки следует по call ID и monoUs; рост сетевого запаса сам по себе не является ошибкой.
|
||
|
||
## 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.
|