diff --git a/doc/chat_call_arch.md b/doc/chat_call_arch.md index eafa5c9b..f3663dcc 100644 --- a/doc/chat_call_arch.md +++ b/doc/chat_call_arch.md @@ -1,6 +1,6 @@ # P2P аудио-звонок пользователю — архитектура -Статус: спроектировано, ждёт реализации. +Статус: реализовано. Звонок использует роутер, CM подготавливает оптимальный путь. ## 1. Область @@ -11,7 +11,7 @@ DM-группа, маршрутизация через `etcp_router` идент (обе стороны компилируют общий стек `etcp_router` / `topo_group` (BGP) / `conn_mgr`). Полный цикл: проверка онлайна (BGP) → INVITE → звонок → ACCEPT/DECLINE → -установка соединения (conn_mgr) → аудиопоток → HANGUP. +аудиопоток через роутер → HANGUP. CM инициируется вызывающим при старте, вызываемым при принятии вызова. ## 2. Разбиение файлов (отдельный каталог `src/call/`) @@ -21,13 +21,12 @@ DM-группа, маршрутизация через `etcp_router` идент | `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 | +| `src/call/call_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). +Диагностика: категория `call` (`DEBUG_CATEGORY_CALL`); транспортная доставка — `etcp_route`. ## 3. Протокол (svc_id 0x34, первый байт payload = subcmd) @@ -55,8 +54,8 @@ CALL_MEDIA 0x10 {call_id:8, track_id:1, seq:2, ts_ms:4, payload} ## 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 +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 / @@ -69,18 +68,20 @@ NO_TRAFFIC / RING_TIMEOUT / CONNECT_FAIL`. - **watchdog 2с у вызываемого** — нет кодограммы 2с → вызов считается сорванным (отмена потерялась), сессия закрывается локально (`CALL_REASON_CANCEL`); - ring-timeout 45с (вызывающий, нет ответа → CANCEL); -- connect: `conn_mgr` `CONN_EVENT_TIMEOUT` → fail; +- `CONN_EVENT_DOWN/TIMEOUT` CM не завершают звонок: роутер использует доступные пути; - **NO_TRAFFIC 20с** — нет входящих медиа-кадров → авто HANGUP. Завершающие кодограммы (HANGUP/CANCEL/DECLINE) шлются без ретрансмиссий — потеря компенсируется watchdog'ом 2с у вызываемого. Запоздалые кодограммы вызова при -идущем разговоре (`CONNECTING`/`ACTIVE`) игнорируются. +идущем разговоре (`ACTIVE`) игнорируются. Онлайн-проверка: `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 минимален). +CM handle инициирует оптимальное подключение и удерживается до завершения звонка. +Медиа всегда идёт через `etcp_route_send`: роутер сам выбирает лучший путь и fallback. +Звонок не хранит ETCP_CONN и не завершается при потере отдельного транспорта. +Очистка записи CM отсоединяет handles и отменяет отложенные UP; handle закрывает владелец. +Закрытие CM handle не посылает DISCONNECT удалённым владельцам. Механизм NCD не меняется. ## 5. Отправка медиа — backpressure через локальный буфер @@ -96,43 +97,47 @@ NO_TRAFFIC / RING_TIMEOUT / CONNECT_FAIL`. В сессии: - `tx_q` — локальная FIFO очередь закодированных кадров (ll_entry с dgram), ёмкостью - ~1с (≈50 кадров). При переполнении — дроп старейшего + счётчик `c_tx_dropped`. + 15с (750 кадров). При переполнении — дроп старейшего + счётчик `c_tx_dropped`. - Алгоритм (`call_tx_flush`): пока `tx_q` не пуст: - - если `send_q_has_room` → `queue_data_get` + `etcp_route_send(force=0)`; + - если `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. Thread-модель (single-writer) +## 6. Потоки и жизненный цикл -| Объект | Владелец | -|---|---| -| `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` | аудио-поток | +`call_session`, CM handle, очередь TX и таймеры принадлежат uasync-потоку. +Аудиопоток кодирует PCM и постит `call_send_media`; приём в uasync кладёт Opus в `call_jitter`. +Декодер, SoundTouch и генератор тонов принадлежат аудиопотоку. +Кольцо кодированных кадров и снимок статистики защищены мьютексом; статистика не читает SoundTouch из другого потока. -Поток данных: -- 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` (+ тоны). +GUI запускает аудиоустройство по CALL_ACCEPTED. При CALL_ENDED начинается финальный тон; +устройство останавливается перед `call_audio_release`. Явное завершение звонка прекращает +воспроизведение накопленной речи и включает тон завершения — на обеих платформах. -`ajb` = mutex-кольцо PCM (гранулярность 20ms/50 ops в сек — мьютекс бесплатен); -SoundTouch инстанс заперт на pull-стороне. +## 7. Буфер звонка и восстановление -**Контракт жизненного цикла:** GUI стартует аудио-устройство по `CALL_ACCEPTED`, -останавливает по `CALL_ENDED` и только после остановки (join аудио-потока) вызывает -`call_release(call_id)` (post в uasync) → uasync освобождает session/encoder/decoder/jb. -Пока устройство работает — `call_pull_pcm`/`call_send_encoded` не гоняются с освобождением. +`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 и счётчиком. -## 7. Адаптивный джиттер-буфер (SoundTouch) - -Буфер ≤ **1000 мс**, target playout ~60 мс; при росте буфера плавно tempo → **1.6x**. +Роутер сохраняет последовательность и неподтверждённые данные при смене пути. +Для сервиса звонка срок без прогресса ACK увеличен примерно до 20с; прочие сервисы сохраняют прежний срок. +Перезапуск удалённого процесса/роутерной сессии не является кратковременным перебоем пути и может сбросить данные. 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. Тоны и диагностика @@ -144,9 +149,10 @@ API: `ajb_create / ajb_push / ajb_pull / ajb_stats / ajb_destroy`. (`call_connecting`/`call_ringing`), Android — `ToneGenerator` (`TONE_SUP_DIAL`/`TONE_SUP_RINGTONE`). Переключение — по `CHAT_EVT_CALL_RINGING`, остановка — по `CALL_ACCEPTED`/`CALL_ENDED`/`CALL_ERROR`/hangup. -- **Сталл >500мс** (нет входящих медиа) → в `call_pull_pcm` микшируется +- **Отсутствие медиа ≥500мс при недоборе PCM** (нет входящих медиа) → в `call_audio_pull_pcm` воспроизводится `CALL_TONE_GLITCH`: `пи-пи (2×150мс, gap 100мс) … тишина до 1.0с … пи(200мс) - пауза(200мс) пи(200мс)`, 1000 Гц, цикл пока длится сталл. + пауза(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с). @@ -159,7 +165,7 @@ 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_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: текущий маршрут/наличие гарнитуры) ``` diff --git a/src/call/call.c b/src/call/call.c index ada7839e..12ea5e3a 100644 --- a/src/call/call.c +++ b/src/call/call.c @@ -38,8 +38,7 @@ enum call_state { CALL_ST_IDLE = 0, CALL_ST_OUTGOING, /* caller: INVITE отправлен */ CALL_ST_RINGING, /* caller: получил RINGING; callee: отправил RINGING */ - CALL_ST_CONNECTING, /* обе стороны: conn_mgr_open в процессе */ - CALL_ST_ACTIVE, /* обе стороны: соединение поднято */ + CALL_ST_ACTIVE, /* вызов принят; доставка через роутер независимо от готовности CM */ CALL_ST_ENDED, }; @@ -55,7 +54,6 @@ struct call_session { uint8_t reason; /* CALL_REASON_* (при ENDED) */ struct call_ctx* ctx; /* обратная ссылка (для коллбэков conn_mgr/таймеров) */ struct CONN_MGR_HANDLE* cm_handle; - struct ETCP_CONN* data_conn; /* прямой data-коннект звонка (NULL для INDIRECT) */ void* ring_timer; /* caller: 45с ожидания ответа; callee: 2с watchdog кодограмм */ void* invite_timer; /* caller: кодограмма INVITE каждые 300мс (OUTGOING+RINGING) */ uint8_t no_route_strikes; /* caller: подряд ретрансмитов без маршрута (fail-fast) */ @@ -94,7 +92,6 @@ static const char* call_state_name(uint8_t st) { case CALL_ST_IDLE: return "IDLE"; case CALL_ST_OUTGOING: return "OUTGOING"; case CALL_ST_RINGING: return "RINGING"; - case CALL_ST_CONNECTING: return "CONNECTING"; case CALL_ST_ACTIVE: return "ACTIVE"; case CALL_ST_ENDED: return "ENDED"; default: return "?"; @@ -318,8 +315,7 @@ static void call_end(struct call_ctx* ctx, struct call_session* s, uint8_t reaso call_event_id(ctx, CHAT_EVT_CALL_ENDED, s->call_id, reason); - /* Разрыв conn_mgr — отложенно (500мс), чтобы HANGUP/DECLINE успел долететь. - * Без cm_handle рвать нечего — освобождаем сразу (отложенно). */ + /* Закрытие запроса CM откладываем на 500мс для отправки финального сигналинга. */ if (s->cm_handle) { s->teardown_timer = uasync_set_timeout(ctx->inst->ua, CALL_TEARDOWN_DELAY_TB, s, call_teardown_timer_cb, "call_teardown"); } else { @@ -329,38 +325,41 @@ static void call_end(struct call_ctx* ctx, struct call_session* s, uint8_t reaso /* ─────────────────── conn_mgr ─────────────────── */ +static void call_traffic_timeout_cb(void* arg); + +/* CM готовит оптимальный путь; звонок всегда отправляет через роутер с автоматическим fallback. */ static void call_conn_cb(struct CONN_MGR_HANDLE* h, uint64_t node_id, uint64_t group_id, enum conn_mgr_event event, void* arg) { - struct call_session* s = (struct call_session*)arg; - if (!s || !s->ctx) return; - struct call_ctx* ctx = s->ctx; - if (s->state == CALL_ST_ENDED) { - /* сессия завершена, ждёт teardown: conn_mgr сообщает об обрыве — handle больше - * не трогаем (entry чистится самим conn_mgr / удалённой стороной) */ - if (event == CONN_EVENT_DOWN || event == CONN_EVENT_TIMEOUT) s->cm_handle = NULL; - return; - } - + struct call_session* s = arg; + if (!s || s->state == CALL_ST_ENDED) return; if (event == CONN_EVENT_UP) { - s->cm_handle = h; - s->data_conn = conn_mgr_get_conn(h); /* NULL для INDIRECT */ - s->state = CALL_ST_ACTIVE; - DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: conn UP id=%016llx peer=0x%016llx -> ACTIVE", - CALL_ID, (unsigned long long)s->call_id, (unsigned long long)node_id); - call_event_id(ctx, CHAT_EVT_CALL_ACCEPTED, s->call_id, 0); - call_post_bgp_path(ctx, s); - if (!s->stats_timer) - s->stats_timer = uasync_set_timeout(ctx->inst->ua, 10000, s, call_stats_timer_cb, "call_stats"); - } else if (event == CONN_EVENT_DOWN) { - DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: conn DOWN id=%016llx peer=0x%016llx (temporary, keep alive)", - CALL_ID, (unsigned long long)s->call_id, (unsigned long long)node_id); - } else { /* CONN_EVENT_TIMEOUT */ - DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: conn TIMEOUT id=%016llx peer=0x%016llx -> fail", - CALL_ID, (unsigned long long)s->call_id, (unsigned long long)node_id); - /* entry уже чистится вызывающим (monitor) — handle не закрываем */ - s->cm_handle = NULL; - call_end(ctx, s, CALL_REASON_CONNECT_FAIL); + DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: CM path ready id=%016llx peer=%016llx", CALL_ID, + (unsigned long long)s->call_id, (unsigned long long)node_id); + if (s->state == CALL_ST_ACTIVE) call_post_bgp_path(s->ctx, s); + } else { + DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: CM event=%d id=%016llx peer=%016llx; delivery continues via router", + CALL_ID, event, (unsigned long long)s->call_id, (unsigned long long)node_id); } + (void)h; (void)group_id; +} + +static void call_open_cm(struct call_session* s) { + if (s->cm_handle) return; + if (conn_mgr_open(s->ctx->inst, s->group_id, s->peer_node_id, call_conn_cb, s, &s->cm_handle) != 0) + DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: CM request failed id=%016llx; using existing routing", CALL_ID, + (unsigned long long)s->call_id); +} + +static void call_activate(struct call_session* s) { + if (s->state == CALL_ST_ACTIVE || s->state == CALL_ST_ENDED) return; + struct call_ctx* ctx = s->ctx; + s->state = CALL_ST_ACTIVE; + DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: accepted id=%016llx -> ACTIVE (router delivery)", CALL_ID, + (unsigned long long)s->call_id); + call_event_id(ctx, CHAT_EVT_CALL_ACCEPTED, s->call_id, 0); + call_post_bgp_path(ctx, s); + s->traffic_timer = uasync_set_timeout(ctx->inst->ua, CALL_NO_TRAFFIC_TB, s, call_traffic_timeout_cb, "call_traffic"); + s->stats_timer = uasync_set_timeout(ctx->inst->ua, 10000, s, call_stats_timer_cb, "call_stats"); } /* ─────────────────── таймеры ─────────────────── */ @@ -392,7 +391,7 @@ static void call_invite_retry_cb(void* arg) { struct call_ctx* ctx = s->ctx; s->invite_timer = NULL; if (s->state == CALL_ST_ENDED) return; - if (s->state != CALL_ST_OUTGOING && s->state != CALL_ST_RINGING) return; /* CONNECTING/ACTIVE — звонок идёт */ + if (s->state != CALL_ST_OUTGOING && s->state != CALL_ST_RINGING) return; /* ACTIVE — звонок идёт */ if (etcp_router_has_route(ctx->inst, s->group_id, s->peer_node_id)) { s->no_route_strikes = 0; @@ -455,16 +454,17 @@ static void call_stats_timer_cb(void* arg) { if (s->state != CALL_ST_ACTIVE) return; int buffer_ms = 0, tempo_x100 = 100; + struct call_jitter_range range = {0, 0, 60}; uint32_t dropped = 0, underruns = 0; if (ctx->audio_ops && ctx->audio_ops->get_stats) - ctx->audio_ops->get_stats(s->call_id, &buffer_ms, &tempo_x100, &dropped, &underruns); + ctx->audio_ops->get_stats(s->call_id, &buffer_ms, &tempo_x100, &dropped, &underruns, &range); uint16_t rtt = 0; struct ETCP_ROUTER_CONN* rconn = etcp_router_conn_get(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL); if (rconn && rconn->recv_conn) rtt = rconn->recv_conn->rtt_last; - uint8_t buf[18]; + uint8_t buf[24]; size_t off = 0; memcpy(buf + off, &s->call_id, 8); off += 8; buf[off++] = (uint8_t)(rtt >> 8); buf[off++] = (uint8_t)(rtt & 0xFF); @@ -476,46 +476,16 @@ static void call_stats_timer_cb(void* arg) { buf[off++] = (uint8_t)(dr >> 8); buf[off++] = (uint8_t)(dr & 0xFF); uint16_t un = (uint16_t)(underruns > 0xFFFF ? 0xFFFF : underruns); buf[off++] = (uint8_t)(un >> 8); buf[off++] = (uint8_t)(un & 0xFF); + int values[] = {range.min_ms, range.max_ms, range.reserve_ms}; + for (size_t i = 0; i < 3; i++) { + uint16_t value = (uint16_t)values[i]; + buf[off++] = (uint8_t)(value >> 8); buf[off++] = (uint8_t)value; + } chat_event_post(ctx->inst, CHAT_EVT_CALL_STATS, buf, (int)off); s->stats_timer = uasync_set_timeout(ctx->inst->ua, 10000, s, call_stats_timer_cb, "call_stats"); } -/* ─────────────────── потеря соединения ─────────────────── */ - -/* На обрыв ETCP-соединения с пиром завершаем ACTIVE-звонки (robust fallback: - * HANGUP может не успеть долететь, если закрывающая сторона рвёт conn_mgr). */ -static void call_on_conn_status(struct ETCP_CONN* conn, int status, void* arg) { - struct UTUN_INSTANCE* inst = (struct UTUN_INSTANCE*)arg; - struct call_ctx* ctx = call_of(inst); - if (!ctx || !ctx->initialized || !conn) return; - if (status != ETCP_CONN_STATUS_DOWN && status != ETCP_CONN_STATUS_DELETE) return; - uint64_t peer = conn->peer_node_id; - if (!peer) return; - - struct ll_entry* e = ctx->sessions ? ctx->sessions->head : NULL; - while (e) { - struct ll_entry* next = e->next; - struct call_session* s = (struct call_session*)e->data; - if (s->peer_node_id == peer) { - if (s->state == CALL_ST_ACTIVE) { - /* Завершаем только если упал именно data-коннект звонка (DIRECT/REVERSE). - * Для INDIRECT data_conn==NULL — посторонний коннект к тому же пиру - * (topo_group_connect) не должен рвать работающий звонок. */ - if (s->data_conn && s->data_conn == conn) { - DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: peer conn lost id=%016llx peer=0x%016llx -> end", - CALL_ID, (unsigned long long)s->call_id, (unsigned long long)peer); - call_end(ctx, s, CALL_REASON_REMOTE_HANGUP); - } - } else if (s->state == CALL_ST_ENDED) { - /* удалённая сторона уже порвала (DISCONNECT чистит entry без коллбэка) */ - s->cm_handle = NULL; - } - } - e = next; - } -} - /* ─────────────────── обработчики приёма ─────────────────── */ static void call_handle_invite(struct call_ctx* ctx, uint64_t from, uint64_t group, @@ -529,7 +499,7 @@ static void call_handle_invite(struct call_ctx* ctx, uint64_t from, uint64_t gro memcpy(&inv_group, b + 9, 8); /* кодограмма-дубликат (тот же call_id): пока звоним — пере-взводим watchdog и - * идемпотентно отвечаем RINGING; в разговоре (CONNECTING/ACTIVE) — игнорируем. */ + * идемпотентно отвечаем RINGING; в разговоре (ACTIVE) — игнорируем. */ struct call_session* s = call_session_find(ctx, call_id); if (s) { if (s->role == CALL_ROLE_CALLEE && s->state == CALL_ST_RINGING) { @@ -606,18 +576,14 @@ static void call_handle_accept(struct call_ctx* ctx, uint64_t from, const uint8_ if (n < 9) return; uint64_t call_id; memcpy(&call_id, b + 1, 8); struct call_session* s = call_session_find(ctx, call_id); - if (!s || s->role != CALL_ROLE_CALLER || s->state == CALL_ST_ENDED || s->state == CALL_ST_CONNECTING) return; + if (!s || s->role != CALL_ROLE_CALLER || s->state == CALL_ST_ENDED || s->state == CALL_ST_ACTIVE) return; - DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: ACCEPT id=%016llx -> conn_mgr_open", CALL_ID, (unsigned long long)call_id); - s->state = CALL_ST_CONNECTING; + DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: ACCEPT id=%016llx", CALL_ID, (unsigned long long)call_id); if (s->ring_timer) { uasync_cancel_timeout(ctx->inst->ua, s->ring_timer); s->ring_timer = NULL; } if (s->invite_timer) { uasync_cancel_timeout(ctx->inst->ua, s->invite_timer); s->invite_timer = NULL; } - - int rc = conn_mgr_open(ctx->inst, s->group_id, s->peer_node_id, call_conn_cb, s, &s->cm_handle); - if (rc != 0) { - /* либо уже получили TIMEOUT (сессия ENDED), либо ошибка без коллбэка */ - if (s->state != CALL_ST_ENDED) call_end(ctx, s, CALL_REASON_CONNECT_FAIL); - } + call_open_cm(s); + call_activate(s); + (void)from; } static void call_handle_decline(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) { @@ -753,7 +719,6 @@ int call_init(struct UTUN_INSTANCE* inst) { u_free(ctx); inst->call = NULL; return -1; } - etcp_add_conn_status_cbk(inst, call_on_conn_status, inst); ctx->initialized = 1; DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: initialized (svc=0x%02x)", CALL_ID, ETCP_RT_ID_CALL); @@ -781,7 +746,6 @@ void call_destroy(struct UTUN_INSTANCE* inst) { } queue_free(ctx->sessions); etcp_router_unbind(inst, ETCP_RT_ID_CALL); - etcp_remove_conn_status_cbk(inst, call_on_conn_status, inst); inst->call = NULL; uint32_t started = ctx->c_calls_started, ended = ctx->c_calls_ended; u_free(ctx); @@ -837,6 +801,7 @@ static void call_tx_flush(struct call_session* s) { struct ll_entry* e = queue_data_get(s->tx_q); if (etcp_route_send(ctx->inst, s->group_id, s->peer_node_id, e, 1, 0) == 0) s->c_tx_frames++; + else DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: media enqueue failed id=%016llx", CALL_ID, (unsigned long long)s->call_id); } } @@ -868,7 +833,12 @@ int call_send_media(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t* if (queue_entry_count(s->tx_q) >= CALL_MAX_TX_FRAMES) { struct ll_entry* old = queue_data_get(s->tx_q); - if (old) { queue_dgram_free(old); queue_entry_free(old); s->c_tx_dropped++; } + if (old) { + queue_dgram_free(old); queue_entry_free(old); s->c_tx_dropped++; + if (s->c_tx_dropped == 1 || s->c_tx_dropped % 50 == 0) + DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: TX overflow id=%016llx queued=%zu dropped=%u", CALL_ID, + (unsigned long long)call_id, (size_t)queue_entry_count(s->tx_q), s->c_tx_dropped); + } } queue_data_put(s->tx_q, e); @@ -909,6 +879,7 @@ void call_start(struct UTUN_INSTANCE* inst, const char* ch_id, uint64_t peer_nod DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call_start id=%016llx peer=0x%016llx group=0x%016llx", CALL_ID, (unsigned long long)out_call_id, (unsigned long long)peer_node_id, (unsigned long long)group_id); + call_open_cm(s); if (call_send_invite(ctx, s) != 0) { call_end(ctx, s, CALL_REASON_CONNECT_FAIL); return; @@ -922,11 +893,10 @@ void call_accept(struct UTUN_INSTANCE* inst, uint64_t call_id) { struct call_ctx* ctx = call_of(inst); if (!ctx || !ctx->initialized) return; struct call_session* s = call_session_find(ctx, call_id); - if (!s || s->role != CALL_ROLE_CALLEE || s->state == CALL_ST_ENDED || s->state == CALL_ST_CONNECTING) return; + if (!s || s->role != CALL_ROLE_CALLEE || s->state == CALL_ST_ENDED || s->state == CALL_ST_ACTIVE) return; DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: call_accept id=%016llx -> ACCEPT + conn_mgr_open", CALL_ID, (unsigned long long)call_id); - s->state = CALL_ST_CONNECTING; if (s->ring_timer) { uasync_cancel_timeout(ctx->inst->ua, s->ring_timer); s->ring_timer = NULL; } /* watchdog кодограмм */ uint8_t pkt[sizeof(struct call_pkt_accept)]; @@ -935,10 +905,8 @@ void call_accept(struct UTUN_INSTANCE* inst, uint64_t call_id) { a->call_id = call_id; call_send(ctx, s->group_id, s->peer_node_id, pkt, sizeof(pkt), ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT); - int rc = conn_mgr_open(inst, s->group_id, s->peer_node_id, call_conn_cb, s, &s->cm_handle); - if (rc != 0 && s->state != CALL_ST_ENDED) { - call_end(ctx, s, CALL_REASON_CONNECT_FAIL); - } + call_open_cm(s); + call_activate(s); } void call_decline(struct UTUN_INSTANCE* inst, uint64_t call_id) { diff --git a/src/call/call.h b/src/call/call.h index abf6c329..fc4b2635 100644 --- a/src/call/call.h +++ b/src/call/call.h @@ -11,6 +11,7 @@ #define CALL_H #include +#include "call_jitter.h" struct UTUN_INSTANCE; @@ -60,7 +61,7 @@ struct call_audio_ops { /* uasync-поток: текущие метрики джиттер-буфера (buffer_ms, tempo_x100, * dropped/underruns — опционально, NULL допустим). */ int (*get_stats)(uint64_t call_id, int* buffer_ms, int* tempo_x100, - uint32_t* dropped, uint32_t* underruns); + uint32_t* dropped, uint32_t* underruns, struct call_jitter_range* range); }; void call_set_audio_ops(struct UTUN_INSTANCE* inst, const struct call_audio_ops* ops); diff --git a/src/call/call_audio.c b/src/call/call_audio.c index e2707456..d45f1689 100644 --- a/src/call/call_audio.c +++ b/src/call/call_audio.c @@ -3,13 +3,13 @@ // Один активный звонок: encoder (аудио-поток), decoder + адаптивный джиттер-буфер // vj (кодированные кадры + SoundTouch stretch на pull) и генератор тонов. // -// RX: call.c → call_audio_on_media (uasync) → vj_push (кодированный кадр); -// аудио-поток call_audio_pull_pcm → vj_pull (декод+stretch) (+ глитч/завершение). +// RX: call.c → call_audio_on_media (uasync) → call_jitter_push (кодированный кадр); +// аудио-поток call_audio_pull_pcm → call_jitter_pull (декод+stretch) (+ глитч/завершение). // TX: аудио-поток call_audio_feed_pcm → encode → uasync_post(call_send_media). #include "call_audio.h" #include "call.h" -#include "voice_jitter.h" +#include "call_jitter.h" #include "call_tones.h" #include "../chat/chat_setting.h" #include "../utun_instance.h" @@ -25,8 +25,7 @@ #define CALL_AUDIO_ID "call_audio" #define CALL_AUDIO_BITRATE 32000 -#define CALL_AJ_MAX_FRAMES 50 /* ~1000мс @20мс */ -#define CALL_STALL_TB 5000 /* 500мс без медиа → глитч-тон */ +#define CALL_STALL_TB 5000 /* 500мс без медиа и недобор PCM → глитч-тон */ #define CALL_AEC_FILTER_SAMPLES 14400 /* хвост эха 300мс @48кГц */ #define CALL_AEC_DELAY_FRAMES 2 /* desktop: минимальная задержка duplex */ @@ -34,12 +33,13 @@ static pthread_mutex_t g_mtx = PTHREAD_MUTEX_INITIALIZER; static struct UTUN_INSTANCE* g_inst = NULL; static int g_active = 0; static int g_ending = 0; +static int g_glitch_started = 0; /* только аудиопоток */ static int g_ended_started = 0; /* только аудио-поток: тон завершения запущен */ static uint64_t g_call_id = 0; static uint64_t g_last_media_tb = 0; static opus_codec_encoder_t* g_encoder = NULL; static opus_codec_decoder_t* g_decoder = NULL; -static struct vj* g_vj = NULL; +static struct call_jitter* g_vj = NULL; static struct call_tones* g_tones = NULL; static speex_aec_t* g_aec = NULL; static int g_aec_delay_frames = CALL_AEC_DELAY_FRAMES; @@ -54,7 +54,7 @@ static const struct call_audio_ops g_ops = { /* Создать AEC с текущей задержкой (объявление; определение ниже). */ static speex_aec_t* call_audio_aec_create(void); -/* vj decode-коллбэк: дёргается только из аудио-потока (vj_pull). */ +/* vj decode-коллбэк: дёргается только из аудио-потока (call_jitter_pull). */ static int call_audio_decode_cb(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples) { opus_codec_decoder_t* dec = (opus_codec_decoder_t*)arg; return opus_codec_decode(dec, enc, len, pcm, max_samples); @@ -84,7 +84,7 @@ void call_audio_on_media(struct UTUN_INSTANCE* inst, uint64_t call_id, pthread_mutex_lock(&g_mtx); if (g_active && call_id == g_call_id && g_vj && opus && len > 0) { - vj_push(g_vj, opus, len); + call_jitter_push(g_vj, opus, len); g_last_media_tb = get_time_tb(); } pthread_mutex_unlock(&g_mtx); @@ -125,8 +125,8 @@ int call_audio_start(uint64_t call_id) { return -1; } - struct vj* j = vj_create(CALL_AUDIO_SAMPLE_RATE, 1, CALL_AUDIO_FRAME_SAMPLES, CALL_AUDIO_FRAME_MS, - CALL_AJ_MAX_FRAMES, call_audio_decode_cb, dec); + int max_reserve_ms = chat_setting_get_int(g_inst, "call_jitter_max_reserve_ms", 1000); + struct call_jitter* j = call_jitter_create(call_audio_decode_cb, dec, max_reserve_ms); if (!j) { opus_codec_decoder_destroy(dec); opus_codec_encoder_destroy(enc); @@ -137,7 +137,7 @@ int call_audio_start(uint64_t call_id) { struct call_tones* tones = call_tones_create(); if (!tones) { - vj_destroy(j); + call_jitter_destroy(j); opus_codec_decoder_destroy(dec); opus_codec_encoder_destroy(enc); pthread_mutex_unlock(&g_mtx); @@ -184,6 +184,7 @@ int call_audio_start(uint64_t call_id) { g_active = 1; g_ending = 0; g_ended_started = 0; + g_glitch_started = 0; g_last_media_tb = 0; pthread_mutex_unlock(&g_mtx); @@ -206,7 +207,7 @@ void call_audio_begin_end(uint64_t call_id) { void call_audio_release(uint64_t call_id) { opus_codec_encoder_t* enc = NULL; opus_codec_decoder_t* dec = NULL; - struct vj* j = NULL; + struct call_jitter* j = NULL; struct call_tones* tones = NULL; speex_aec_t* aec = NULL; struct audio_compressor* ac = NULL; @@ -234,7 +235,7 @@ void call_audio_release(uint64_t call_id) { /* дальше on_media не тронет объекты (active=0, указатели NULL) */ if (enc) opus_codec_encoder_destroy(enc); if (dec) opus_codec_decoder_destroy(dec); - if (j) vj_destroy(j); + if (j) call_jitter_destroy(j); if (tones) call_tones_destroy(tones); if (aec) speex_aec_destroy(aec); if (ac) audio_compressor_destroy(ac); @@ -344,7 +345,7 @@ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) { if (!out || max_samples <= 0) return 0; int active = 0, ending = 0; - struct vj* j = NULL; + struct call_jitter* j = NULL; struct call_tones* tones = NULL; uint64_t last_media = 0; @@ -373,20 +374,23 @@ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) { if (call_tones_ended_pending(tones)) n = call_tones_render(tones, out, max_samples); } else { - n = vj_pull(j, out, max_samples); - if (n < max_samples) { - /* недобор: либо сталь (глитч-тон), либо тишина */ - uint64_t now = get_time_tb(); - int stalled = (last_media != 0) && (now - last_media > CALL_STALL_TB); - if (stalled) { - call_tones_glitch_start(tones); - n += call_tones_render(tones, out + n, max_samples - n); - } else { - call_tones_glitch_stop(tones); - } - for (int i = n; i < max_samples; i++) out[i] = 0; - n = max_samples; + n = call_jitter_pull(j, out, max_samples); + uint64_t now = get_time_tb(); + int stalled = n < max_samples && last_media != 0 && now - last_media >= CALL_STALL_TB; + if (stalled != g_glitch_started) { + g_glitch_started = stalled; + DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: gap tone %s call=%016llx media_gap=%llums pcm=%d/%d", + CALL_AUDIO_ID, stalled ? "started" : "stopped", (unsigned long long)call_id, + (unsigned long long)((now - last_media) / 10), n, max_samples); } + if (stalled) { + call_tones_glitch_start(tones); + n += call_tones_render(tones, out + n, max_samples - n); + } else { + call_tones_glitch_stop(tones); + } + for (int i = n; i < max_samples; i++) out[i] = 0; + n = max_samples; } /* рендер-референс для AEC: то, что реально пошло в динамик */ @@ -401,11 +405,11 @@ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples) { /* ── uasync-поток: метрики ── */ int call_audio_get_stats(uint64_t call_id, int* buffer_ms, int* tempo_x100, - uint32_t* dropped, uint32_t* underruns) { + uint32_t* dropped, uint32_t* underruns, struct call_jitter_range* range) { int ok = 0; pthread_mutex_lock(&g_mtx); if (g_active && call_id == g_call_id && g_vj) { - vj_get_stats(g_vj, buffer_ms, tempo_x100, dropped, underruns); + call_jitter_get_stats(g_vj, buffer_ms, tempo_x100, dropped, underruns, range); ok = 1; } pthread_mutex_unlock(&g_mtx); diff --git a/src/call/call_audio.h b/src/call/call_audio.h index 7b42dc01..bf80579a 100644 --- a/src/call/call_audio.h +++ b/src/call/call_audio.h @@ -3,7 +3,7 @@ // call_audio.h — сервисный аудио-движок P2P-звонка (единый для desktop/Android/headless). // -// Владеет Opus codec, адаптивным джиттер-буфером (voice_jitter: кодированные +// Владеет Opus codec, адаптивным джиттер-буфером (call_jitter: кодированные // кадры + SoundTouch time-stretch на pull) и тонами (глитч при сталле / завершение). // Излишек голоса держится до декодирования — декодируется только то, что готово // к воспроизведению. Потребитель (desktop miniaudio / Android AudioTrack) получает @@ -15,10 +15,11 @@ // - call_audio_feed_pcm / pull_pcm — аудио-поток (single-writer); // - call_audio_on_media / get_stats — uasync-поток (из call.c). // Общие указатели (encoder/decoder/vj/tones) защищены мьютексом; encoder и -// decoder — только аудио-поток (decoder дёргается из vj_pull через коллбэк), -// vj_push — только uasync-поток. +// decoder — только аудио-поток (decoder дёргается из call_jitter_pull через коллбэк), +// call_jitter_push — только uasync-поток. #include +#include "call_jitter.h" struct UTUN_INSTANCE; @@ -53,14 +54,14 @@ int call_audio_feed_pcm(uint64_t call_id, const int16_t* pcm, int count); * Возвращает число сэмплов (может быть < max — андерфлоу/конец тона). */ int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples); -/* uasync-поток (из call.c): принятый Opus-кадр — кладём в джиттер-буфер (vj_push, +/* uasync-поток (из call.c): принятый Opus-кадр — кладём в джиттер-буфер (call_jitter_push, * декодирование произойдёт на pull в аудио-потоке). */ void call_audio_on_media(struct UTUN_INSTANCE* inst, uint64_t call_id, uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len); /* uasync-поток: метрики джиттер-буфера для CALL_STATS (dropped/underruns — необязательные). */ int call_audio_get_stats(uint64_t call_id, int* buffer_ms, int* tempo_x100, - uint32_t* dropped, uint32_t* underruns); + uint32_t* dropped, uint32_t* underruns, struct call_jitter_range* range); /* Включить/выключить AEC (SpeexDSP) в рантайме. desktop — по настройке GUI, * Android — по спикерфону (route == SPEAKER). Работает и в активном звонке. */ diff --git a/src/call/call_headless.c b/src/call/call_headless.c index 3caea9b2..5e146113 100644 --- a/src/call/call_headless.c +++ b/src/call/call_headless.c @@ -55,6 +55,7 @@ struct call_audio { socket_t listen_fd; void* listen_sock_id; int running; + void* cleanup_timer; /* Opus-кодеки (одна активная звонка за раз — busy в call.c) */ uint64_t enc_call_id; opus_codec_encoder_t* enc; @@ -293,6 +294,8 @@ static void ca_accept_callback(socket_t fd, void* arg) { static void ca_cleanup(void* arg) { struct call_audio* ca = (struct call_audio*)arg; + ca->cleanup_timer = NULL; + if (!ca->running) return; struct call_audio_client** prev = &ca->clients; while (*prev) { if ((*prev)->closing) { @@ -301,7 +304,8 @@ static void ca_cleanup(void* arg) { u_free(dead); ca->client_count--; } else prev = &(*prev)->next; } - if (ca->ua) uasync_set_timeout(ca->ua, 5000, ca, ca_cleanup, "ca_cleanup"); + ca->cleanup_timer = uasync_set_timeout(ca->ua, 5000, ca, ca_cleanup, "ca_cleanup"); + if (!ca->cleanup_timer) DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: cleanup timer scheduling failed", CH_ID); } /* ── init / destroy ── */ @@ -337,7 +341,8 @@ int call_headless_init(struct UASYNC* ua, struct UTUN_INSTANCE* inst, if (!ca->listen_sock_id) { socket_close_wrapper(ca->listen_fd); ca->listen_fd = SOCKET_INVALID; return -1; } call_set_media_recv_cb(inst, ca_media_recv, ca); - uasync_set_timeout(ua, 5000, ca, ca_cleanup, "ca_cleanup"); + ca->cleanup_timer = uasync_set_timeout(ua, 5000, ca, ca_cleanup, "ca_cleanup"); + if (!ca->cleanup_timer) DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: cleanup timer scheduling failed", CH_ID); ca->running = 1; DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: audio socket listening on %s:%d", CH_ID, bind_ip, port); @@ -348,6 +353,7 @@ void call_headless_destroy(struct UTUN_INSTANCE* inst) { struct call_audio* ca = ca_of(inst); if (!ca || !ca->running) return; ca->running = 0; + if (ca->cleanup_timer) { uasync_cancel_timeout(ca->ua, ca->cleanup_timer); ca->cleanup_timer = NULL; } call_set_media_recv_cb(inst, NULL, NULL); if (ca->listen_sock_id && ca->ua) uasync_remove_socket_t(ca->ua, ca->listen_fd); if (ca->listen_fd != SOCKET_INVALID) { socket_close_wrapper(ca->listen_fd); ca->listen_fd = SOCKET_INVALID; } @@ -356,7 +362,7 @@ void call_headless_destroy(struct UTUN_INSTANCE* inst) { struct call_audio_client* c = ca->clients; while (c) { struct call_audio_client* n = c->next; - if (c->fd != SOCKET_INVALID) socket_close_wrapper(c->fd); + ca_close_client(c); u_free(c); c = n; } diff --git a/src/call/call_jitter.cpp b/src/call/call_jitter.cpp new file mode 100644 index 00000000..3b363bd6 --- /dev/null +++ b/src/call/call_jitter.cpp @@ -0,0 +1,172 @@ +// Буфер звонка: храним речь в порядке доставки, ускоряем накопление без изменения высоты голоса. +#include "call_jitter.h" +#include "call_jitter_window.h" +#include +#include "../../lib/debug_config.h" +#include "../../lib/mem.h" +#include "soundtouch/SoundTouch.h" + +#include +#include +#include +#include +#include +#include + +static constexpr int kSampleRate = 48000; +static constexpr int kFrameSamples = 960; +static constexpr int kMaxEncoded = 512; +static constexpr int kTargetMs = 60; +static constexpr double kMaxTempo = 1.6; +static constexpr int kTempoRampMs = 500; + +template static T clamp_value(T value, T low, T high) { + return std::max(low, std::min(value, high)); +} + +struct call_jitter { + std::mutex mtx; + uint8_t ring[CALL_JITTER_MAX_FRAMES][kMaxEncoded]; + int lens[CALL_JITTER_MAX_FRAMES]; + int head = 0, count = 0; + uint32_t dropped = 0, underruns = 0; + int processing_ms = 0, tempo_x100 = 100; /* снимок аудиопотока под mtx */ + call_jitter_window window; + call_jitter_range range = {0, 0, 60}; /* опубликованный снимок под mtx */ + call_jitter_decode_fn decode; + void* arg; + soundtouch::SoundTouch st; + int16_t pcm[kFrameSamples]; + float input[kFrameSamples]; + std::vector output; + bool primed = false, catching_up = false; + double tempo = 1.0, ema_ms = kTargetMs; +}; + +extern "C" { +struct call_jitter* call_jitter_create(call_jitter_decode_fn decode, void* arg, int max_reserve_ms) { + if (max_reserve_ms < 500 || max_reserve_ms > 2000) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_jitter: invalid max reserve=%dms", max_reserve_ms); + return nullptr; + } + if (!decode) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_jitter: missing decoder"); + return nullptr; + } + void* memory = u_malloc(sizeof(call_jitter)); + if (!memory) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_jitter: allocation failed"); + return nullptr; + } + auto* j = new (memory) call_jitter; + j->window.max_reserve = max_reserve_ms; + j->decode = decode; j->arg = arg; + j->st.setSampleRate(kSampleRate); j->st.setChannels(1); j->st.setTempo(1.0); + j->st.setSetting(SETTING_USE_QUICKSEEK, 1); + j->st.setSetting(SETTING_SEQUENCE_MS, 20); + j->st.setSetting(SETTING_SEEKWINDOW_MS, 10); + j->st.setSetting(SETTING_OVERLAP_MS, 4); + j->st.setSetting(SETTING_USE_AA_FILTER, 0); + DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_jitter: created capacity=%dms target=%dms max_reserve=%dms tempo=1.0..%.1f ramp=%dms", + CALL_JITTER_MAX_FRAMES * CALL_JITTER_FRAME_MS, kTargetMs, max_reserve_ms, kMaxTempo, kTempoRampMs); + return j; +} + +void call_jitter_destroy(struct call_jitter* j) { + if (!j) return; + j->~call_jitter(); u_free(j); +} + +void call_jitter_push(struct call_jitter* j, const uint8_t* enc, int len) { + if (!j || !enc || len <= 0 || len > kMaxEncoded) { + DEBUG_WARN(DEBUG_CATEGORY_CALL, "call_jitter: invalid frame len=%d", len); + return; + } + std::lock_guard lock(j->mtx); + if (j->count == CALL_JITTER_MAX_FRAMES) { + j->head = (j->head + 1) % CALL_JITTER_MAX_FRAMES; j->count--; j->dropped++; + if (j->dropped == 1 || j->dropped % 50 == 0) + DEBUG_WARN(DEBUG_CATEGORY_CALL, "call_jitter: overflow capacity=15000ms dropped=%u", j->dropped); + } + int idx = (j->head + j->count) % CALL_JITTER_MAX_FRAMES; + std::memcpy(j->ring[idx], enc, (size_t)len); j->lens[idx] = len; j->count++; +} + +int call_jitter_pull(struct call_jitter* j, int16_t* out, int max_samples) { + if (!j || !out || max_samples <= 0) return 0; + int count; + { std::lock_guard lock(j->mtx); count = j->count; } + // Готовый PCM учитываем в длительности исходной речи. + double depth = count * CALL_JITTER_FRAME_MS + + (j->st.numUnprocessedSamples() + j->st.numSamples() * j->tempo) * 1000.0 / kSampleRate; + int64_t now_ms = std::chrono::duration_cast( + std::chrono::steady_clock::now().time_since_epoch()).count(); + bool log_stats = j->window.observe(now_ms, (int)std::lround(depth)); + { + std::lock_guard lock(j->mtx); + j->range = {j->window.low, j->window.high, j->window.reserve}; + } + if (log_stats) + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call_jitter: depth=%.0fms min=%dms max=%dms reserve=%dms tempo=%.2f", + depth, j->window.low, j->window.high, j->window.reserve, j->tempo); + if (!j->primed) { + if (depth < j->window.reserve) return 0; + j->primed = true; + DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_jitter: playback resumed depth=%.0fms reserve=%dms", depth, j->window.reserve); + } + j->ema_ms += 0.2 * (depth - j->ema_ms); + double target = 1.0 + (kMaxTempo - 1.0) * clamp_value((std::min(depth, j->ema_ms) - j->window.reserve - 15.0) / 925.0, 0.0, 1.0); + // Линейный предел 1.0→1.6 за 500мс PCM. Снижение следует за глубиной буфера с тем же пределом. + double step = (kMaxTempo - 1.0) * max_samples * 1000.0 / (kSampleRate * kTempoRampMs); + j->tempo += clamp_value(target - j->tempo, -step, step); + j->st.setTempo(j->tempo); + bool catching = j->tempo > 1.05; + if (catching != j->catching_up) { + j->catching_up = catching; + DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_jitter: catch-up %s depth=%.0fms tempo=%.2f", + catching ? "started" : "finished", depth, j->tempo); + } + uint8_t enc[kMaxEncoded]; + while (j->st.numSamples() < (uint)max_samples) { + int len; + { + std::lock_guard lock(j->mtx); + if (!j->count) break; + len = j->lens[j->head]; std::memcpy(enc, j->ring[j->head], (size_t)len); + j->head = (j->head + 1) % CALL_JITTER_MAX_FRAMES; j->count--; + } + int n = j->decode(j->arg, enc, len, j->pcm, kFrameSamples); + if (n <= 0 || n > kFrameSamples) { + DEBUG_WARN(DEBUG_CATEGORY_CALL, "call_jitter: decode failed len=%d result=%d", len, n); + continue; + } + for (int i = 0; i < n; i++) j->input[i] = j->pcm[i] / 32768.0f; + j->st.putSamples(j->input, (uint)n); + } + j->output.resize((size_t)max_samples); + int n = (int)j->st.receiveSamples(j->output.data(), (uint)max_samples); + for (int i = 0; i < n; i++) out[i] = (int16_t)std::lrintf(clamp_value(j->output[i] * 32767.0f, -32768.0f, 32767.0f)); + int processing_ms = (int)std::lround((j->st.numUnprocessedSamples() + j->st.numSamples() * j->tempo) * 1000.0 / kSampleRate); + { + std::lock_guard lock(j->mtx); + j->processing_ms = processing_ms; j->tempo_x100 = (int)std::lround(j->tempo * 100); + if (n < max_samples) j->underruns++; + } + if (n < max_samples) { + j->primed = false; + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call_jitter: waiting for refill pcm=%d/%d processing=%dms", n, max_samples, processing_ms); + } + return n; +} + +void call_jitter_get_stats(struct call_jitter* j, int* depth_ms, int* tempo_x100, + uint32_t* dropped, uint32_t* underruns, struct call_jitter_range* range) { + if (!j) return; + std::lock_guard lock(j->mtx); + if (depth_ms) *depth_ms = j->count * CALL_JITTER_FRAME_MS + j->processing_ms; + if (tempo_x100) *tempo_x100 = j->tempo_x100; + if (range) *range = j->range; + if (dropped) *dropped = j->dropped; + if (underruns) *underruns = j->underruns; +} +} diff --git a/src/call/call_jitter.h b/src/call/call_jitter.h new file mode 100644 index 00000000..5cf9c541 --- /dev/null +++ b/src/call/call_jitter.h @@ -0,0 +1,28 @@ +#ifndef CALL_JITTER_H +#define CALL_JITTER_H + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* Звонок: FIFO Opus, адаптивный запас 60мс..max_reserve_ms (max-min за 10 секунд) и догон без сброса после паузы устройства. + * push — сетевой поток, pull/decode/SoundTouch — единственный аудиопоток. + * destroy — после остановки обоих потребителей. get_stats — любой поток. */ +#define CALL_JITTER_FRAME_MS 20 +#define CALL_JITTER_MAX_FRAMES 750 /* 10с перебоя + 5с запаса на накопление и восстановление */ +typedef int (*call_jitter_decode_fn)(void*, const uint8_t*, int, int16_t*, int); +struct call_jitter_range { int min_ms, max_ms, reserve_ms; }; +struct call_jitter; +struct call_jitter* call_jitter_create(call_jitter_decode_fn decode, void* arg, int max_reserve_ms); +void call_jitter_destroy(struct call_jitter* j); +void call_jitter_push(struct call_jitter* j, const uint8_t* enc, int len); +int call_jitter_pull(struct call_jitter* j, int16_t* out, int max_samples); +void call_jitter_get_stats(struct call_jitter* j, int* depth_ms, int* tempo_x100, + uint32_t* dropped, uint32_t* underruns, struct call_jitter_range* range); + +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/call/call_jitter_window.h b/src/call/call_jitter_window.h new file mode 100644 index 00000000..9eaa2b9b --- /dev/null +++ b/src/call/call_jitter_window.h @@ -0,0 +1,24 @@ +#pragma once +#include +#include + +// Десять секундных интервалов. Время передаёт аудиопоток из монотонных часов. +struct call_jitter_window { + struct bucket { int64_t second = -1; int low = 0, high = 0; } buckets[10]; + int low = 0, high = 0, reserve = 60; + int max_reserve = 1000; + bool observe(int64_t now_ms, int depth) { + int64_t second = now_ms / 1000; + auto& b = buckets[second % 10]; + bool changed_second = b.second != second; + if (changed_second) { b.second = second; b.low = b.high = depth; } + else { b.low = std::min(b.low, depth); b.high = std::max(b.high, depth); } + low = high = depth; + for (const auto& item : buckets) { + if (item.second < 0 || item.second > second || second - item.second >= 10) continue; + low = std::min(low, item.low); high = std::max(high, item.high); + } + reserve = std::max(60, std::min(max_reserve, high - low)); + return changed_second; + } +}; diff --git a/src/call/call_proto.h b/src/call/call_proto.h index 8bdb5f8d..d04a56f9 100644 --- a/src/call/call_proto.h +++ b/src/call/call_proto.h @@ -90,7 +90,7 @@ struct call_pkt_media { #define CALL_NO_ROUTE_MAX_STRIKES 20 /* 20 × 300мс ≈ 6с без маршрута → fail-fast CONNECT_FAIL */ #define CALL_NO_TRAFFIC_TB 200000 /* 20с — нет входящих медиа → HANGUP */ #define CALL_TEARDOWN_DELAY_TB 5000 /* 500мс — задержка разрыва conn_mgr после завершения */ -#define CALL_MAX_TX_FRAMES 50 /* ~1с буфер отправки (50 кадров × 20мс) */ +#define CALL_MAX_TX_FRAMES 750 /* 15с: 10с перебоя + 5с запаса */ #ifdef __cplusplus } diff --git a/src/call/voice_sources.cmake b/src/call/voice_sources.cmake index b7606483..3061eea8 100644 --- a/src/call/voice_sources.cmake +++ b/src/call/voice_sources.cmake @@ -1,6 +1,6 @@ # voice_sources.cmake — голосовой стек звонка (единый для desktop/Android/headless). # -# SoundTouch (time-stretch) + voice_jitter (кодированные кадры + decode-on-pull) +# SoundTouch (time-stretch) + call_jitter + radio_jitter (кодированные кадры + decode-on-pull) # + call_audio (Opus/тоны) + call_tones. C++ внутри, наружу — C-интерфейс # (call_audio.h). Экспортирует UTUN_VOICE_SOURCES и UTUN_VOICE_INCLUDE_DIRS. # @@ -19,7 +19,8 @@ function(utun_setup_voice LIB_DIR CALL_DIR) set(UTUN_VOICE_SOURCES ${CALL_DIR}/call_audio.c ${CALL_DIR}/call_tones.c - ${CALL_DIR}/voice_jitter.cpp + ${CALL_DIR}/call_jitter.cpp + ${_src_dir}/radio/radio_jitter.cpp ${_src_dir}/radio/radio_audio.c ${_st_dir}/source/SoundTouch/SoundTouch.cpp ${_st_dir}/source/SoundTouch/TDStretch.cpp diff --git a/src/chat/chat_event.h b/src/chat/chat_event.h index 265f27ff..96750916 100644 --- a/src/chat/chat_event.h +++ b/src/chat/chat_event.h @@ -59,7 +59,7 @@ extern "C" { #define CHAT_EVT_CALL_ACCEPTED 36 /* [call_id:8] */ #define CHAT_EVT_CALL_ENDED 37 /* [call_id:8][reason:1] */ #define CHAT_EVT_CALL_DECLINED 38 /* [call_id:8][reason:1] */ -#define CHAT_EVT_CALL_STATS 39 /* [call_id:8][rtt_ms:2][buffer_ms:2][tempo_x100:2] */ +#define CHAT_EVT_CALL_STATS 39 /* [call_id:8][rtt:2][buffer:2][tempo:2][dropped:2][underruns:2][min:2][max:2][reserve:2] */ #define CHAT_EVT_CALL_ERROR 40 /* [call_id:8][err:1][text:var] */ #define CHAT_EVT_DM_MESSAGES 41 /* [conv_id_len:1][conv_id:var][count:2][msg:var]* — расшифрованные сообщения беседы */ #define CHAT_EVT_CALL_PATH 42 /* [call_id:8][text:var] — BGP-маршрут до пира звонка */ diff --git a/src/chat/chat_setting.c b/src/chat/chat_setting.c index fc1f523a..b31d769b 100644 --- a/src/chat/chat_setting.c +++ b/src/chat/chat_setting.c @@ -56,6 +56,7 @@ static const struct chat_setting_def g_setting_defs[] = { {"whisper_model_path", CHAT_SETTING_STRING, 0, 0, 0}, {"group_autoconnect", CHAT_SETTING_BOOL, 1, 0, 1}, {"aec_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, /* AEC (SpeexDSP) в звонке, desktop GUI */ + {"call_jitter_max_reserve_ms", CHAT_SETTING_INT, 1000, 500, 2000}, {"call_compressor_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, {"call_compressor_max_gain_db",CHAT_SETTING_INT, 15, 1, 100}, }; diff --git a/src/radio/radio_audio.c b/src/radio/radio_audio.c index 94e175ba..da47fc99 100644 --- a/src/radio/radio_audio.c +++ b/src/radio/radio_audio.c @@ -1,12 +1,12 @@ // radio_audio.c — сервисный аудио-движок рации (Opus TX + per-source jitter + микшер). // -// Таблица говорящих: per-source инстанс (Opus-декодер + voice_jitter с time-stretch), +// Таблица говорящих: per-source инстанс (Opus-декодер + radio_jitter с time-stretch), // ключ = src_node_id. Один пир — один поток: новый stream_id сбрасывает инстанс // (не создаём дубль). Инстанс удаляется по FIN (после дренажа буфера) или по таймауту // (нет кадров 2с — потерян FIN / пир отвалился). // -// RX: radio.c → radio_set_frame_cb → radio_audio_on_frame (uasync) → vj_push (кодированный кадр); -// аудио-поток radio_audio_pull_pcm → vj_pull (декод + SoundTouch stretch) → микшер. +// RX: radio.c → radio_set_frame_cb → radio_audio_on_frame (uasync) → rj_push (кодированный кадр); +// аудио-поток radio_audio_pull_pcm → rj_pull (декод + SoundTouch stretch) → микшер. // TX: аудио-поток radio_audio_feed_pcm → encode → uasync_post(radio_talk_send_trampoline). // Микшер избыточных источников — насыщающее суммирование (soft-clip). Полудуплекс. // Синглтон на один активный group_id (GUI слушает один канал), защищён мьютексом. @@ -15,7 +15,7 @@ #include "radio.h" #include "radio_proto.h" #include "radio_vad.h" -#include "../call/voice_jitter.h" +#include "radio_jitter.h" #include "../chat/chat_setting.h" #include "../utun_instance.h" #include "../../lib/opus_codec.h" @@ -62,8 +62,8 @@ struct radio_source { uint8_t used; uint8_t ending; /* получен FIN — дренируем и удаляем */ uint64_t last_frame_tb; - opus_codec_decoder_t* dec; /* decode-аргумент для vj (stateless per call) */ - struct vj* vj; /* jitter-буфер с time-stretch */ + opus_codec_decoder_t* dec; /* decode-аргумент для rj (stateless per call) */ + struct rj* rj; /* jitter-буфер с time-stretch */ /* reorder по seq (mesh: кадры могут прийти не по порядку через разных соседей) */ uint16_t next_seq; /* ожидаемый следующий seq */ uint8_t have_seq; @@ -100,7 +100,7 @@ static uint32_t g_tx_frames = 0; /* кадров за интервал */ static uint32_t g_tx_bytes = 0; /* суммарный размер opus за интервал */ static uint32_t g_tx_tail = 0; /* дропнутых хвостовых сэмплов за интервал */ -/* vj decode-коллбэк: дёргается только из аудио-потока (vj_pull). */ +/* rj decode-коллбэк: дёргается только из аудио-потока (rj_pull). */ static int radio_audio_decode_cb(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples) { opus_codec_decoder_t* dec = (opus_codec_decoder_t*)arg; return opus_codec_decode(dec, enc, len, pcm, max_samples); @@ -108,8 +108,8 @@ static int radio_audio_decode_cb(void* arg, const uint8_t* enc, int len, int16_t /* ── джиттер-буфер рации (адаптивный, симметричный темп + pre-roll) ── */ -static struct vj* radio_vj_create(opus_codec_decoder_t* dec) { - struct vj_cfg cfg; +static struct rj* radio_rj_create(opus_codec_decoder_t* dec) { + struct rj_cfg cfg; cfg.sample_rate = RADIO_AUDIO_SAMPLE_RATE; cfg.channels = RADIO_AUDIO_MIX_CHANNELS; cfg.frame_samples = RADIO_AUDIO_FRAME_SAMPLES; @@ -121,7 +121,7 @@ static struct vj* radio_vj_create(opus_codec_decoder_t* dec) { cfg.pre_roll = 1; cfg.decode = radio_audio_decode_cb; cfg.decode_arg = dec; - return vj_create_cfg(&cfg); + return rj_create_cfg(&cfg); } /* ── reorder по seq (mesh: кадры одного источника могут прийти не по порядку) ── */ @@ -142,7 +142,7 @@ static void radio_src_flush_pending(struct radio_source* s) { if (!s->pending[i].used) continue; if (mi < 0 || (int16_t)(s->pending[i].seq - s->pending[mi].seq) < 0) mi = i; } - vj_push(s->vj, s->pending[mi].opus, s->pending[mi].len); + rj_push(s->rj, s->pending[mi].opus, s->pending[mi].len); s->next_seq = (uint16_t)(s->pending[mi].seq + 1); s->pending[mi].used = 0; s->reorder_count--; @@ -157,7 +157,7 @@ static void radio_src_flush_contiguous(struct radio_source* s) { progressed = 0; for (int i = 0; i < RADIO_AUDIO_REORDER_MAX; i++) { if (!s->pending[i].used || s->pending[i].seq != s->next_seq) continue; - vj_push(s->vj, s->pending[i].opus, s->pending[i].len); + rj_push(s->rj, s->pending[i].opus, s->pending[i].len); s->pending[i].used = 0; s->reorder_count--; s->next_seq++; @@ -201,7 +201,7 @@ static void radio_src_push_frame(struct radio_source* s, uint16_t seq, return; } } - vj_push(s->vj, opus, len); + rj_push(s->rj, opus, len); s->next_seq++; radio_src_flush_contiguous(s); } @@ -215,7 +215,7 @@ static struct radio_source* radio_src_find(uint64_t src) { } static void radio_src_free(struct radio_source* s) { - if (s->vj) { vj_destroy(s->vj); s->vj = NULL; } + if (s->rj) { rj_destroy(s->rj); s->rj = NULL; } if (s->dec) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; } s->used = 0; s->ending = 0; s->last_frame_tb = 0; radio_src_reorder_reset(s); @@ -230,9 +230,9 @@ static struct radio_source* radio_src_acquire(uint64_t src, uint16_t stream) { s->stream_id = stream; s->ending = 0; radio_src_reorder_reset(s); - vj_destroy(s->vj); - s->vj = radio_vj_create(s->dec); - if (!s->vj) { radio_src_free(s); return NULL; } + rj_destroy(s->rj); + s->rj = radio_rj_create(s->dec); + if (!s->rj) { radio_src_free(s); return NULL; } DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: source reset src=%016llx stream=%u", RADIO_AUDIO_ID, (unsigned long long)src, stream); } return s; @@ -242,8 +242,8 @@ static struct radio_source* radio_src_acquire(uint64_t src, uint16_t stream) { s = &g_sources[i]; s->dec = opus_codec_decoder_create(RADIO_AUDIO_SAMPLE_RATE, RADIO_AUDIO_MIX_CHANNELS); if (!s->dec) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: decoder create failed", RADIO_AUDIO_ID); return NULL; } - s->vj = radio_vj_create(s->dec); - if (!s->vj) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; return NULL; } + s->rj = radio_rj_create(s->dec); + if (!s->rj) { opus_codec_decoder_destroy(s->dec); s->dec = NULL; return NULL; } s->src_node_id = src; s->stream_id = stream; s->used = 1; s->ending = 0; s->last_frame_tb = 0; @@ -626,7 +626,7 @@ int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) { return 0; } -/* ── RX (uasync-поток): кодированный кадр → vj_push ── */ +/* ── RX (uasync-поток): кодированный кадр → rj_push ── */ void radio_audio_on_frame(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t src_node_id, uint16_t stream_id, uint16_t seq, @@ -689,20 +689,20 @@ int radio_audio_pull_pcm(uint64_t group_id, int16_t* out, int max_samples) { radio_src_flush_pending(s); } /* ending: снять pre-roll (если ещё не снят) и дренировать накопленный остаток. */ - if (s->ending) vj_end(s->vj); + if (s->ending) rj_end(s->rj); if (do_stats && nst < RADIO_AUDIO_MAX_SOURCES) { - vj_get_stats(s->vj, &st[nst].depth, &st[nst].tempo, &st[nst].dropped, &st[nst].under); + rj_get_stats(s->rj, &st[nst].depth, &st[nst].tempo, &st[nst].dropped, &st[nst].under); st[nst].src = s->src_node_id; st[nst].stream = s->stream_id; st[nst].reorder = s->reorder_count; nst++; } int16_t tmp[RADIO_AUDIO_MIX_SAMPLES]; - int n = vj_pull(s->vj, tmp, max_samples); + int n = rj_pull(s->rj, tmp, max_samples); for (int k = 0; k < n; k++) g_mix[k] += tmp[k]; if (s->ending && n == 0) { uint32_t dropped = 0, underruns = 0; - vj_get_stats(s->vj, NULL, NULL, &dropped, &underruns); + rj_get_stats(s->rj, NULL, NULL, &dropped, &underruns); DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: source ended src=%016llx dropped=%u underruns=%u", RADIO_AUDIO_ID, (unsigned long long)s->src_node_id, dropped, underruns); radio_src_free(s); diff --git a/src/call/voice_jitter.cpp b/src/radio/radio_jitter.cpp similarity index 85% rename from src/call/voice_jitter.cpp rename to src/radio/radio_jitter.cpp index f9d7ad1c..6ef2bbba 100644 --- a/src/call/voice_jitter.cpp +++ b/src/radio/radio_jitter.cpp @@ -1,6 +1,6 @@ -// voice_jitter.cpp — адаптивный джиттер-буфер голоса с time-stretch (SoundTouch). +// radio_jitter.cpp — адаптивный джиттер-буфер голоса с time-stretch (SoundTouch). // -// Кодированные кадры (Opus, до VJ_MAX_ENC байт) кладутся из uasync-потока в кольцо +// Кодированные кадры (Opus, до RJ_MAX_ENC байт) кладутся из uasync-потока в кольцо // и вытаскиваются аудио-потоком: кадр декодируется (decode-коллбэк) и подаётся в // SoundTouch с плавно меняющимся темпом вокруг целевой глубины: мельче target — темп // падает к min_tempo (накопление), глубже — растёт к max_tempo (догон live). При @@ -8,7 +8,7 @@ // только то, что готово к воспроизведению. // Кольцо защищено мьютексом (SPSC); decode и SoundTouch трогает только аудио-поток. -#include "voice_jitter.h" +#include "radio_jitter.h" #include "soundtouch/SoundTouch.h" @@ -19,8 +19,8 @@ #include #include -static const int VJ_MAX_ENC = 512; /* макс. размер кодированного кадра (Opus 20мс, стерео) */ -static const double kDefaultTargetMs = 60.0; /* целевая глубина буфера (звонок) */ +static const int RJ_MAX_ENC = 512; /* макс. размер кодированного кадра (Opus 20мс, стерео) */ +static const double kDefaultTargetMs = 60.0; /* целевая глубина буфера по умолчанию */ static const double kDefaultMinTempo = 1.0; static const double kDefaultMaxTempo = 1.6; static const double kMaxDepthMs = 1000.0; /* при этой глубине tempo == max_tempo */ @@ -30,24 +30,25 @@ static const double kPrimeTimeoutMs = 200.0; /* pre-roll: принудите static const double kStallGapMs = 80.0; /* gap между pull > это → сталь аудио-девайса */ static const double kResyncDepthMs = 400.0; /* бэклог > это после сталла → хард-ресинк к live */ -struct vj { +struct rj { int sr = 0; int ch = 0; int frame_samples = 0; int frame_ms = 0; int cap = 0; - vj_decode_fn decode = nullptr; + rj_decode_fn decode = nullptr; void* decode_arg = nullptr; std::mutex mtx; - std::vector ring; /* cap * VJ_MAX_ENC — кодированные кадры */ + std::vector ring; /* cap * RJ_MAX_ENC — кодированные кадры */ std::vector lens; /* cap — длины кадров */ int head = 0; int count = 0; uint32_t dropped = 0; - uint32_t underruns = 0; - uint32_t resyncs = 0; + std::atomic underruns{0}; + std::atomic resyncs{0}; + std::atomic processing_ms{0}; /* детект сталла аудио-девайса (gap между pull) */ std::chrono::steady_clock::time_point last_pull_tp; /* только аудио-поток */ @@ -55,7 +56,7 @@ struct vj { int stalled = 0; /* только аудио-поток */ soundtouch::SoundTouch st; /* только аудио-поток */ - uint8_t enc_buf[VJ_MAX_ENC]; /* scratch: вытащенный кодированный кадр */ + uint8_t enc_buf[RJ_MAX_ENC]; /* scratch: вытащенный кодированный кадр */ std::vector pcm_buf; /* scratch: декодированный кадр */ std::vector fin; /* scratch: кадр в float */ std::vector fout; /* scratch: выход float */ @@ -76,7 +77,7 @@ struct vj { /* Симметричный целевой темп с мёртвой зоной ±25% вокруг target: внутри зоны темп=1.0 * (без time-stretch, меньше артефактов), за её пределами — плавно к min/max. */ -static double vj_compute_tempo(double depth_ms, double target_ms, double min_tempo, double max_tempo) { +static double rj_compute_tempo(double depth_ms, double target_ms, double min_tempo, double max_tempo) { double dead = target_ms * 0.25; double lo = target_ms - dead; double hi = target_ms + dead; @@ -93,11 +94,11 @@ static double vj_compute_tempo(double depth_ms, double target_ms, double min_tem } /* Вытащить один кодированный кадр из кольца (под мьютексом). Возвращает 0 если пусто. */ -static int vj_pop_enc(struct vj* j, uint8_t* out, int* len_out) { +static int rj_pop_enc(struct rj* j, uint8_t* out, int* len_out) { std::lock_guard lk(j->mtx); if (j->count == 0) return 0; *len_out = j->lens[(size_t)j->head]; - std::memcpy(out, &j->ring[(size_t)j->head * VJ_MAX_ENC], (size_t)*len_out); + std::memcpy(out, &j->ring[(size_t)j->head * RJ_MAX_ENC], (size_t)*len_out); j->head = (j->head + 1) % j->cap; j->count--; return 1; @@ -105,14 +106,14 @@ static int vj_pop_enc(struct vj* j, uint8_t* out, int* len_out) { extern "C" { -struct vj* vj_create_cfg(const struct vj_cfg* cfg) { +struct rj* rj_create_cfg(const struct rj_cfg* cfg) { if (!cfg || cfg->sample_rate <= 0 || cfg->channels <= 0 || cfg->frame_samples <= 0 || cfg->frame_ms <= 0 || cfg->max_frames <= 0 || !cfg->decode) return nullptr; if (cfg->min_tempo <= 0.0 || cfg->max_tempo < cfg->min_tempo || cfg->target_ms <= 0.0) return nullptr; - vj* j = new vj(); + rj* j = new rj(); j->sr = cfg->sample_rate; j->ch = cfg->channels; j->frame_samples = cfg->frame_samples; @@ -124,7 +125,7 @@ struct vj* vj_create_cfg(const struct vj_cfg* cfg) { j->min_tempo = cfg->min_tempo; j->max_tempo = cfg->max_tempo; j->pre_roll = cfg->pre_roll; - j->ring.assign((size_t)cfg->max_frames * VJ_MAX_ENC, 0); + j->ring.assign((size_t)cfg->max_frames * RJ_MAX_ENC, 0); j->lens.assign(cfg->max_frames, 0); j->pcm_buf.assign((size_t)cfg->frame_samples * cfg->channels, 0); j->fin.assign((size_t)cfg->frame_samples * cfg->channels, 0.0f); @@ -140,9 +141,9 @@ struct vj* vj_create_cfg(const struct vj_cfg* cfg) { return j; } -struct vj* vj_create(int sample_rate, int channels, int frame_samples, int frame_ms, - int max_frames, vj_decode_fn decode, void* decode_arg) { - struct vj_cfg cfg; +struct rj* rj_create(int sample_rate, int channels, int frame_samples, int frame_ms, + int max_frames, rj_decode_fn decode, void* decode_arg) { + struct rj_cfg cfg; cfg.sample_rate = sample_rate; cfg.channels = channels; cfg.frame_samples = frame_samples; @@ -154,16 +155,16 @@ struct vj* vj_create(int sample_rate, int channels, int frame_samples, int frame cfg.pre_roll = 0; cfg.decode = decode; cfg.decode_arg = decode_arg; - return vj_create_cfg(&cfg); + return rj_create_cfg(&cfg); } -void vj_destroy(struct vj* j) { +void rj_destroy(struct rj* j) { if (!j) return; delete j; } -void vj_push(struct vj* j, const uint8_t* enc, int len) { - if (!j || !enc || len <= 0 || len > VJ_MAX_ENC) return; +void rj_push(struct rj* j, const uint8_t* enc, int len) { + if (!j || !enc || len <= 0 || len > RJ_MAX_ENC) return; std::lock_guard lk(j->mtx); if (j->count == j->cap) { j->head = (j->head + 1) % j->cap; /* дроп старейшего */ @@ -171,7 +172,7 @@ void vj_push(struct vj* j, const uint8_t* enc, int len) { j->dropped++; } int idx = (j->head + j->count) % j->cap; - std::memcpy(&j->ring[(size_t)idx * VJ_MAX_ENC], enc, (size_t)len); + std::memcpy(&j->ring[(size_t)idx * RJ_MAX_ENC], enc, (size_t)len); j->lens[(size_t)idx] = len; j->count++; } @@ -179,16 +180,17 @@ void vj_push(struct vj* j, const uint8_t* enc, int len) { /* Хард-ресинк к live: сбросить бэклог (кольцо + SoundTouch) и темп к 1.0. * Применяется после сталла аудио-девайса, когда бэклог > kResyncDepthMs — один * чистый срез вместо долгого догона темпом (chipmunk) + дропов по одному. */ -static void vj_hard_resync(struct vj* j) { +static void rj_hard_resync(struct rj* j) { { std::lock_guard lk(j->mtx); j->count = 0; j->head = 0; } j->st.clear(); + j->processing_ms.store(0); j->tempo = 1.0; j->tempo_x100.store(100); j->ema_depth_ms = j->target_ms; j->resyncs++; } -int vj_pull(struct vj* j, int16_t* out, int max_samples) { +int rj_pull(struct rj* j, int16_t* out, int max_samples) { if (!j || !out || max_samples <= 0) return 0; /* ── детект сталла аудио-девайса (gap между pull) + ресинк по его окончании ── */ @@ -208,7 +210,7 @@ int vj_pull(struct vj* j, int16_t* out, int max_samples) { { std::lock_guard lk(j->mtx); c = j->count; } double depth_ms = (double)c * j->frame_ms + (double)j->st.numSamples() * 1000.0 / (double)j->sr; - if (depth_ms > kResyncDepthMs) vj_hard_resync(j); + if (depth_ms > kResyncDepthMs) rj_hard_resync(j); } } } @@ -241,7 +243,7 @@ int vj_pull(struct vj* j, int16_t* out, int max_samples) { /* 1) глубина (кольцо + внутренний буфер SoundTouch, сглаженная) → целевой темп (плавно) */ double depth_ms = (double)cnt * j->frame_ms + (double)j->st.numSamples() * 1000.0 / (double)j->sr; j->ema_depth_ms = kEmaAlpha * depth_ms + (1.0 - kEmaAlpha) * j->ema_depth_ms; - double target = vj_compute_tempo(j->ema_depth_ms, j->target_ms, j->min_tempo, j->max_tempo); + double target = rj_compute_tempo(j->ema_depth_ms, j->target_ms, j->min_tempo, j->max_tempo); double d = target - j->tempo; if (d > kTempoStep) d = kTempoStep; else if (d < -kTempoStep) d = -kTempoStep; @@ -257,7 +259,7 @@ int vj_pull(struct vj* j, int16_t* out, int max_samples) { int guard = 0; while (j->st.numSamples() < max_slots && guard++ < j->cap + 4) { int elen = 0; - if (!vj_pop_enc(j, j->enc_buf, &elen)) break; + if (!rj_pop_enc(j, j->enc_buf, &elen)) break; int n = j->decode(j->decode_arg, j->enc_buf, elen, j->pcm_buf.data(), j->frame_samples); if (n <= 0) continue; /* битый/пустой кадр — пропускаем */ if (n > j->frame_samples) n = j->frame_samples; @@ -275,37 +277,38 @@ int vj_pull(struct vj* j, int16_t* out, int max_samples) { else if (v < -32768.0f) v = -32768.0f; out[i] = (int16_t)std::lrintf(v); } + j->processing_ms.store((int)std::lround((double)j->st.numSamples() * 1000.0 / j->sr)); if ((int)(n * (uint)ch) < max_samples) j->underruns++; return (int)(n * (uint)ch); } -void vj_end(struct vj* j) { +void rj_end(struct rj* j) { if (!j) return; j->primed = 1; /* только аудио-поток: снять pre-roll и дренировать остаток */ } -void vj_get_stats(struct vj* j, int* depth_ms, int* tempo_x100, uint32_t* dropped, uint32_t* underruns) { if (!j) return; +void rj_get_stats(struct rj* j, int* depth_ms, int* tempo_x100, uint32_t* dropped, uint32_t* underruns) { if (!j) return; + std::lock_guard lk(j->mtx); if (depth_ms) { /* полная глубина = кодированные кадры в кольце + невыданный PCM внутри SoundTouch */ - std::lock_guard lk(j->mtx); - double st_ms = (double)j->st.numSamples() * 1000.0 / (double)j->sr; + double st_ms = j->processing_ms.load(); *depth_ms = (int)((double)j->count * j->frame_ms + st_ms + 0.5); } if (tempo_x100) *tempo_x100 = j->tempo_x100.load(); if (dropped) *dropped = j->dropped; - if (underruns) *underruns = j->underruns; + if (underruns) *underruns = j->underruns.load(); } -uint32_t vj_dropped(struct vj* j) { +uint32_t rj_dropped(struct rj* j) { if (!j) return 0; std::lock_guard lk(j->mtx); return j->dropped; } -uint32_t vj_resyncs(struct vj* j) { +uint32_t rj_resyncs(struct rj* j) { if (!j) return 0; std::lock_guard lk(j->mtx); - return j->resyncs; + return j->resyncs.load(); } } // extern "C" diff --git a/src/call/voice_jitter.h b/src/radio/radio_jitter.h similarity index 77% rename from src/call/voice_jitter.h rename to src/radio/radio_jitter.h index 20e7c3ea..eb250140 100644 --- a/src/call/voice_jitter.h +++ b/src/radio/radio_jitter.h @@ -1,7 +1,7 @@ -#ifndef VOICE_JITTER_H -#define VOICE_JITTER_H +#ifndef RADIO_JITTER_H +#define RADIO_JITTER_H -// voice_jitter.h — адаптивный джиттер-буфер голоса с time-stretch (SoundTouch), C API. +// radio_jitter.h — адаптивный джиттер-буфер голоса с time-stretch (SoundTouch), C API. // // Хранит КОДИРОВАННЫЕ (Opus) кадры: uasync-поток кладёт кодированные кадры, // аудио-поток на pull декодирует их (через коллбэк decode) и пропускает через @@ -10,9 +10,9 @@ // разгрузка uasync-потока). // // Потоки: -// - vj_push — uasync-поток (producer); -// - vj_pull — аудио-поток (consumer; единственный владелец decode/SoundTouch); -// - vj_get_stats — любой поток. +// - rj_push — uasync-поток (producer); +// - rj_pull — аудио-поток (consumer; единственный владелец decode/SoundTouch); +// - rj_get_stats — любой поток. // Кольцо кодированных кадров защищено мьютексом (SPSC, дёшево: ~50 кадров/с). #include @@ -21,13 +21,13 @@ extern "C" { #endif -struct vj; +struct rj; /* decode: закодированный кадр → PCM. Возвращает число декодированных сэмплов. */ -typedef int (*vj_decode_fn)(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples); +typedef int (*rj_decode_fn)(void* arg, const uint8_t* enc, int len, int16_t* pcm, int max_samples); /* Полная конфигурация адаптивного джиттер-буфера. */ -struct vj_cfg { +struct rj_cfg { int sample_rate; int channels; int frame_samples; /* сэмплов в кадре (960 @20мс/48k) */ @@ -37,43 +37,43 @@ struct vj_cfg { double min_tempo; /* темп при пустом буфере (<1.0 = замедление, накопление) */ double max_tempo; /* темп при глубоком буфере (>1.0 = догон live) */ int pre_roll; /* 1 = не отдавать звук, пока глубина < target_ms */ - vj_decode_fn decode; + rj_decode_fn decode; void* decode_arg; }; /* Создать буфер с полной конфигурацией (pre-roll, симметричный темп). */ -struct vj* vj_create_cfg(const struct vj_cfg* cfg); +struct rj* rj_create_cfg(const struct rj_cfg* cfg); -/* Создать буфер с конфигурацией по умолчанию (совместимо со звонком: без pre-roll, +/* Создать буфер с конфигурацией по умолчанию (без pre-roll, * темп 1.0..1.6, target 60мс). frame_samples — сэмплов в кадре (960 @20мс/48k). */ -struct vj* vj_create(int sample_rate, int channels, int frame_samples, int frame_ms, - int max_frames, vj_decode_fn decode, void* decode_arg); +struct rj* rj_create(int sample_rate, int channels, int frame_samples, int frame_ms, + int max_frames, rj_decode_fn decode, void* decode_arg); -void vj_destroy(struct vj* j); +void rj_destroy(struct rj* j); /* producer: положить кодированный кадр (копируется). При переполнении дропает старейший. */ -void vj_push(struct vj* j, const uint8_t* enc, int len); +void rj_push(struct rj* j, const uint8_t* enc, int len); /* consumer: выдать до max_samples декодированного+растянутого PCM. Может вернуть * < max_samples при нехватке данных (андерфлоу). */ -int vj_pull(struct vj* j, int16_t* out, int max_samples); +int rj_pull(struct rj* j, int16_t* out, int max_samples); /* audio-поток: вход закончен (FIN) — принудительно снять pre-roll, чтобы дренировать * накопленный остаток до конца. Безопасно вызывать многократно. */ -void vj_end(struct vj* j); +void rj_end(struct rj* j); /* Диагностика: глубина буфера (мс), темп (×100, 100 = 1.0x), дропнуто кадров * при переполнении, число underrun-ов (pull вернул меньше запрошенного). */ -void vj_get_stats(struct vj* j, int* depth_ms, int* tempo_x100, uint32_t* dropped, uint32_t* underruns); +void rj_get_stats(struct rj* j, int* depth_ms, int* tempo_x100, uint32_t* dropped, uint32_t* underruns); /* Число дропнутых кадров при переполнении (для диагностики/тестов). */ -uint32_t vj_dropped(struct vj* j); +uint32_t rj_dropped(struct rj* j); /* Число хард-ресинков к live (после сталла аудио-девайса) — диагностика. */ -uint32_t vj_resyncs(struct vj* j); +uint32_t rj_resyncs(struct rj* j); #ifdef __cplusplus } #endif -#endif /* VOICE_JITTER_H */ +#endif /* RADIO_JITTER_H */ diff --git a/src/routing_layer/conn_mgr.h b/src/routing_layer/conn_mgr.h index 7b9300db..dc5f2b8d 100644 --- a/src/routing_layer/conn_mgr.h +++ b/src/routing_layer/conn_mgr.h @@ -1,8 +1,17 @@ -/* conn_mgr — соединение с узлом группы напрямую, в обратную сторону или через посредника. - * Один менеджер принадлежит одной группе; open выдаёт отдельный handle владельцу. - * DIRECT использует NCD, REVERSE просит пира подключиться, INDIRECT выбирает посредника. - * Используется сервисами для доставки данных. Групповые сессии открывают через - * topo_group_peer_open(). Все операции — в потоке uasync; handle закрывает вызывающий. */ +/* conn_mgr — оптимальная P2P-доставка больших объёмов данных: медиа, файлы, звонки. + * Один менеджер принадлежит одной группе; DIRECT/REVERSE/INDIRECT подбирают путь к получателю. + * + * Архитектурная роль CONN_MGR_HANDLE: + * - принадлежит сервису и выражает его потребность в доставке данных конкретному узлу; + * - существует, пока идёт звонок или передача файла; владелец закрывает его по окончании/отмене; + * - потеря пути и TIMEOUT попытки не освобождают handle и не отменяют потребность владельца; + * - не является NCD handle и не привязан временем жизни к одному ETCP_CONN. + * + * NCD обеспечивает общий прямой мультиадресный транспорт для BGP/topo_group. + * NCD handle попытки CM после установления связи должен перейти группе либо быть закрыт; + * CM handle остаётся у медиа-сервиса. Закрытие CM handle отменяет его запрос, а не владение группы. + * Это контракт разделения ролей; механизм передачи транспорта группе относится к NCD/topo_group. + * Групповые сессии открывают через topo_group_peer_open(). Все операции — в потоке uasync. */ #ifndef CONN_MGR_H #define CONN_MGR_H @@ -60,7 +69,10 @@ int conn_mgr_open(struct UTUN_INSTANCE* inst, conn_mgr_cb_t cb, void* cb_arg, struct CONN_MGR_HANDLE** out_handle); -/** Закрыть свой handle. Последний освобождает ресурсы CM; чужие NCD-владельцы сохраняют транспорт. */ +/** Закрыть свой handle и отменить его callbacks, в том числе отложенный UP. + * Допустимо из callback (также закрытие другого handle). После close указатель больше не использовать. + * Очистка внутренней записи CM отсоединяет handles, но владельцы всё равно обязаны вызвать close. + * Последний handle освобождает ресурсы CM; чужие NCD-владельцы сохраняют транспорт. */ void conn_mgr_close(struct CONN_MGR_HANDLE* h); /* ═══════ доступ к соединению ═══════ */ diff --git a/src/routing_layer/conn_mgr_core.c b/src/routing_layer/conn_mgr_core.c index 7f00e007..da7b3aa7 100644 --- a/src/routing_layer/conn_mgr_core.c +++ b/src/routing_layer/conn_mgr_core.c @@ -40,6 +40,7 @@ static struct CONN_MGR_HANDLE* cm_handle_new(struct CONN_MGR_ENTRY* entry, uint6 struct CONN_MGR_HANDLE* h = u_calloc(1, sizeof(*h)); if (!h) return NULL; h->node_id = entry->node_id; h->group_id = group_id; h->cb = cb; h->cb_arg = cb_arg; + h->ua = entry->mgr->instance->ua; h->entry = entry; h->next = entry->handles; entry->handles = h; return h; } @@ -47,10 +48,12 @@ static struct CONN_MGR_HANDLE* cm_handle_new(struct CONN_MGR_ENTRY* entry, uint6 static void cm_deliver_up_cb(void* arg) { struct CONN_MGR_HANDLE* h = (struct CONN_MGR_HANDLE*)arg; h->deliver_up_token = NULL; - if (h->cb) h->cb(h, h->node_id, h->group_id, CONN_EVENT_UP, h->cb_arg); + if (!h->closed && h->entry && h->entry->state == CONN_MGR_STATE_CONNECTED && h->cb) + h->cb(h, h->node_id, h->group_id, CONN_EVENT_UP, h->cb_arg); } -/* Рассылает событие ВСЕМ handle'ам entry. Одно соединение — много слушателей: +/* Закрытые handles освобождаются через call_soon, поэтому next переживает callbacks. + * Рассылает событие ВСЕМ handle'ам entry. Одно соединение — много слушателей: * каждый conn_mgr_open создал свой handle, все получают UP/DOWN/TIMEOUT. * Все поля handle'а читаются до вызова коллбэка — коллбэк может закрыть * handle (conn_mgr_close), после вызова к handle не обращаемся. */ @@ -66,7 +69,10 @@ void cm_deliver_event(struct CONN_MGR_ENTRY* entry, enum conn_mgr_event event) { uint64_t node_id = h->node_id; conn_mgr_cb_t cb = h->cb; void* cb_arg = h->cb_arg; - if (cb) cb(h, node_id, gid, event, cb_arg); + if (event != CONN_EVENT_UP && h->deliver_up_token) { + uasync_call_soon_cancel(h->ua, h->deliver_up_token); h->deliver_up_token = NULL; + } + if (!h->closed && h->entry == entry && cb) cb(h, node_id, gid, event, cb_arg); h = next; } } @@ -180,8 +186,25 @@ struct CONN_MGR_ENTRY* cm_ensure_entry(struct CONN_MGR* mgr, uint64_t node_id) { return new_e; } +static void cm_entry_free_cb(void* arg) { queue_entry_free(arg); } +static void cm_handle_free_cb(void* arg) { u_free(arg); } + void cm_entry_cleanup(struct CONN_MGR_ENTRY* entry) { - if (!entry || entry->state == CONN_MGR_STATE_DISCONNECTED) return; + if (!entry) return; + // Сначала исключаем из поиска и запрещаем NCD callbacks; освобождение памяти — вне текущего стека. + struct CONN_MGR* mgr = entry->mgr; + if (cm_find_entry(mgr, entry->node_id) != entry) return; + entry->state = CONN_MGR_STATE_DISCONNECTED; + queue_remove_data(mgr->entries, &entry->ll); + for (struct CONN_MGR_HANDLE* h = entry->handles; h; h = h->next) { + if (h->deliver_up_token) { + uasync_call_soon_cancel(h->ua, h->deliver_up_token); h->deliver_up_token = NULL; + } + h->entry = NULL; // handle остаётся у владельца до close + DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "conn_mgr: detached handle=%p node=%016llx", (void*)h, + (unsigned long long)h->node_id); + } + entry->handles = NULL; if (entry->main.timer) { uasync_cancel_timeout(entry->mgr->instance->ua, entry->main.timer); entry->main.timer = NULL; } if (entry->reverse_timer) { uasync_cancel_timeout(entry->mgr->instance->ua, entry->reverse_timer); entry->reverse_timer = NULL; } entry->reverse_active = 0; entry->indirect_active = 0; entry->indirect_ready = 0; @@ -200,13 +223,11 @@ void cm_entry_cleanup(struct CONN_MGR_ENTRY* entry) { } else pp = &(*pp)->next; } } - if (entry->ncd_handle) { node_conn_direct_force_close(entry->ncd_handle); entry->ncd_handle = NULL; } - entry->handles = NULL; + struct NODE_CONN_DIRECT* ncd = entry->ncd_handle; entry->ncd_handle = NULL; + if (ncd) node_conn_direct_force_close(ncd); cm_clear_nodeinfo(entry->mgr, entry->node_id); entry->state = CONN_MGR_STATE_DISCONNECTED; entry->conn_type = CONN_TYPE_NONE; - { struct CONN_MGR* m = entry->mgr; - queue_remove_data(m->entries, &entry->ll); - queue_entry_free(&entry->ll); } + uasync_call_soon(mgr->instance->ua, entry, cm_entry_free_cb); } void cm_clear_nodeinfo(struct CONN_MGR* mgr, uint64_t node_id) { @@ -458,33 +479,24 @@ int cm_open(struct CONN_MGR* mgr, uint64_t node_id, } void conn_mgr_close(struct CONN_MGR_HANDLE* h) { - if (!h) return; - struct CONN_MGR_ENTRY* entry = h->entry; - if (!entry) { u_free(h); return; } - /* отменяем отложенный UP-коллбэк (cm_deliver_up_cb), иначе он дёрнется - * с освобождённым handle (use-after-free при быстром teardown соединения) */ - if (h->deliver_up_token && entry->mgr && entry->mgr->instance) { - uasync_call_soon_cancel(entry->mgr->instance->ua, h->deliver_up_token); - h->deliver_up_token = NULL; + if (!h || h->closed) return; + h->closed = 1; + if (h->deliver_up_token) { uasync_call_soon_cancel(h->ua, h->deliver_up_token); h->deliver_up_token = NULL; } + struct CONN_MGR_ENTRY* entry = h->entry; h->entry = NULL; + DEBUG_DEBUG(DEBUG_CATEGORY_GENERAL, "conn_mgr: close handle=%p node=%016llx attached=%d", (void*)h, + (unsigned long long)h->node_id, entry != NULL); + if (entry) { + struct CONN_MGR_HANDLE** pp = &entry->handles; + while (*pp && *pp != h) pp = &(*pp)->next; + if (*pp) *pp = h->next; + if (!entry->handles) cm_entry_cleanup(entry); } - { struct CONN_MGR_HANDLE** pp = &entry->handles; - while (*pp) { if (*pp == h) { *pp = h->next; break; } pp = &(*pp)->next; } - } - if (entry->handles) { u_free(h); return; } - if (entry->state == CONN_MGR_STATE_CONNECTED && (entry->conn_type == CONN_TYPE_DIRECT || entry->conn_type == CONN_TYPE_REVERSE)) { - /* DISCONNECT через etcp_router — conn_mgr уровень */ - struct CM_DISCONNECT pkt; memset(&pkt, 0, sizeof(pkt)); - pkt.cmd = ETCP_RT_ID_CONN_MGR; pkt.subcmd = CM_SUBCMD_DISCONNECT; pkt.node_id = entry->node_id; - struct ll_entry* qe = queue_entry_new(0); - if (qe) { qe->dgram = u_malloc(1+sizeof(pkt)); qe->dgram[0] = ETCP_RT_ID_CONN_MGR; memcpy(qe->dgram+1, &pkt, sizeof(pkt)); qe->len = (uint16_t)(1+sizeof(pkt)); - etcp_route_send(entry->mgr->instance, entry->mgr->group->group_id, entry->node_id, qe, 1, 0); } - /* Транспортный уровень рвётся ниже в cm_entry_cleanup (node_conn_direct_force_close) */ - } - cm_entry_cleanup(entry); u_free(h); + // Не посылаем DISCONNECT: удалённые владельцы и групповой транспорт живут независимо. + uasync_call_soon(h->ua, h, cm_handle_free_cb); } struct ETCP_CONN* conn_mgr_get_conn(struct CONN_MGR_HANDLE* h) { - if (!h || !h->entry || !h->entry->ncd_handle) return NULL; + if (!h || h->closed || !h->entry || !h->entry->ncd_handle) return NULL; return node_conn_direct_get_conn(h->entry->ncd_handle); } diff --git a/src/routing_layer/conn_mgr_priv.h b/src/routing_layer/conn_mgr_priv.h index 13303a86..8eac076f 100644 --- a/src/routing_layer/conn_mgr_priv.h +++ b/src/routing_layer/conn_mgr_priv.h @@ -95,6 +95,8 @@ struct cm_exchange_pending { struct CONN_MGR_HANDLE { uint64_t node_id; uint64_t group_id; conn_mgr_cb_t cb; void* cb_arg; struct CONN_MGR_ENTRY* entry; struct CONN_MGR_HANDLE* next; + struct UASYNC* ua; + uint8_t closed; /* close отменяет callbacks; память освобождается вне текущего стека */ void* deliver_up_token; /* токен отложенного cm_deliver_up_cb (uasync_call_soon), NULL если не постился/уже сработал */ }; diff --git a/src/routing_layer/etcp_router.c b/src/routing_layer/etcp_router.c index 0a2c7ab9..8b1537e0 100644 --- a/src/routing_layer/etcp_router.c +++ b/src/routing_layer/etcp_router.c @@ -756,7 +756,9 @@ static void router_retrans_timer_cb(void* arg) { if (elapsed >= ROUTER_RETRANS_TIMEOUT_TB) { rconn->no_ack_count++; - if (rconn->no_ack_count >= ROUTER_NO_ACK_MAX_RETRANS) { + // Звонок сохраняет надёжную сессию во время перебоя; остальные сервисы используют прежний срок. + unsigned max_retrans = rconn->svc_id == ETCP_RT_ID_CALL ? ROUTER_CALL_NO_ACK_MAX_RETRANS : ROUTER_NO_ACK_MAX_RETRANS; + if (rconn->no_ack_count >= max_retrans) { DEBUG_WARN(DEBUG_CATEGORY_ETCPROUTE, "router: no ACK progress for %d retrans cycles, closing svc_id=%u remote=%016llx", rconn->no_ack_count, rconn->svc_id, (unsigned long long)rconn->remote_node_id); router_close_and_notify(rconn); diff --git a/src/routing_layer/etcp_router.h b/src/routing_layer/etcp_router.h index 8ee5081a..bdc9dc15 100644 --- a/src/routing_layer/etcp_router.h +++ b/src/routing_layer/etcp_router.h @@ -189,6 +189,7 @@ struct ETCP_ROUTER_CONN { #define ROUTER_RETRANS_TIMEOUT_TB 3000 // 300ms в timebase (0.1ms) #define ROUTER_INFLIGHT_HASH_SIZE 1024 #define ROUTER_NO_ROUTE_RETRY_TB 200 // 20ms проверка появления маршрута +#define ROUTER_CALL_NO_ACK_MAX_RETRANS 67 // 20с: запас для 10с перебоя звонка и восстановления #define ROUTER_NO_ACK_MAX_RETRANS 17 // 17 × 300ms ≈ 5s таймаут без ACK // minRTT measurement diff --git a/src/transport_layer/node_conn_direct.h b/src/transport_layer/node_conn_direct.h index 25303a78..d9a50eae 100644 --- a/src/transport_layer/node_conn_direct.h +++ b/src/transport_layer/node_conn_direct.h @@ -1,5 +1,15 @@ /* - * node_conn_direct — общий прямой транспорт к узлу по node_id. + * node_conn_direct — общий прямой мультиадресный транспорт к узлу по node_id для BGP/topo_group. + * Адреса узла используются для создания и обновления линков общего ETCP-соединения. + * + * Архитектурная роль NODE_CONN_DIRECT (NCD handle): + * - выражает владение прямым транспортом, в том числе во время установления связи; + * - группа удерживает его для своей BGP/topo_group-сессии; + * - временный handle попытки подключения после установления связи должен перейти группе + * либо быть закрыт; передачу владения выполняют через node_conn_direct_transfer(); + * - не выражает длительность звонка или передачи файла: эту потребность хранит CONN_MGR_HANDLE. + * CM подбирает оптимальный P2P-путь для больших объёмов данных (медиа, файлы, звонки), + * а NCD управляет прямым транспортом и его адресами независимо от конкретной медиа-операции. * * Модуль управляет ETCP-соединениями через непрозрачные handle'ы (NODE_CONN_DIRECT). * Каждый владелец открывает свой handle; несколько handle разделяют ETCP_CONN. @@ -7,7 +17,7 @@ * сразу; для UP начинает CLOSE/KEEP_ALIVE (до 5 s), пир может сохранить соединение. * * Кто использует: - * - conn_mgr (Connection Manager) — создаёт handle'ы для DIR/REV/IND-соединений + * - conn_mgr (Connection Manager) — временные handle'ы попыток установления прямого транспорта * - topo_group — владеет транспортом групповой сессии с начала CONNECTING * - протокол join — временный транспорт до добавления участника * - любой модуль, кому нужно надёжное ETCP-соединение с конкретным node_id diff --git a/tests/test_call.c b/tests/test_call.c index 677ad08f..23a536df 100644 --- a/tests/test_call.c +++ b/tests/test_call.c @@ -17,6 +17,7 @@ #include "../routing_layer/topo_node_sqlite.h" #include "../routing_layer/topo_group.h" #include "../routing_layer/topo_node.h" +#include "../routing_layer/conn_mgr_priv.h" #include "../utun_instance.h" #include "../transport_layer/etcp.h" #include "../transport_layer/secure_channel.h" @@ -335,6 +336,20 @@ static void call_tick(void* arg) { if (ea->accepted && eb->accepted && ea->accepted_call_id == t->call_id) t->phase = P_SEND_MEDIA; break; case P_SEND_MEDIA: { + // Потеря CM-пути не завершает звонок; роутер продолжает доставку. + struct TOPO_GROUP* g = topo_groups_find(A->topo_groups, strtoull(CH_ID, NULL, 10)); + struct CONN_MGR_ENTRY* ce = cm_find_entry(g->conn_mgr, nb); + if (ce) { + cm_deliver_event(ce, CONN_EVENT_DOWN); + cm_deliver_event(ce, CONN_EVENT_TIMEOUT); + } + struct ETCP_CONN* conn = instance_find_conn(A, nb); + if (conn) { + etcp_fire_conn_status(conn, ETCP_CONN_STATUS_DOWN); + etcp_fire_conn_status(conn, ETCP_CONN_STATUS_UP); + } + if (ea->ended || eb->ended) { fprintf(stderr, "temporary path loss ended call\n"); t->result = 2; break; } + /* тестовый «Opus-кадр» — релей произвольных байт, не валидируем кодек */ static const uint8_t opus[16] = { 0xf8, 0xff, 0xfe, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d }; if (call_send_media(A, t->call_id, opus, sizeof(opus)) != 0) { @@ -454,6 +469,7 @@ int main(int argc, char** argv) { if (t.inst[IDX_A]) utun_instance_destroy(t.inst[IDX_A]); if (t.inst[IDX_B]) utun_instance_destroy(t.inst[IDX_B]); + uasync_poll(ua, 0); uasync_destroy(ua, 0); if (ok) OK(); else FAIL("result=%d phase=%d", t.result, (int)t.phase); diff --git a/tests/test_call_headless.c b/tests/test_call_headless.c index e803b555..1ef3eed2 100644 --- a/tests/test_call_headless.c +++ b/tests/test_call_headless.c @@ -502,6 +502,10 @@ int main(int argc, char** argv) { if (t.inst[IDX_A]) utun_instance_destroy(t.inst[IDX_A]); if (t.inst[IDX_B]) utun_instance_destroy(t.inst[IDX_B]); + uasync_poll(ua, 0); // завершить отложенное освобождение CM и роутера + if (ua->timer_alloc_count != ua->timer_free_count || ua->socket_alloc_count != ua->socket_free_count) { + fprintf(stderr, "headless teardown left live timers or sockets\n"); ok = 0; + } uasync_destroy(ua, 0); if (ok) OK(); else FAIL("result=%d phase=%d", t.result, (int)t.phase); diff --git a/tests/test_conn_mgr_handles.c b/tests/test_conn_mgr_handles.c index 492423e0..b71512b3 100644 --- a/tests/test_conn_mgr_handles.c +++ b/tests/test_conn_mgr_handles.c @@ -20,6 +20,7 @@ #include "../src/utun_instance.h" #include "topo_group.h" #include "conn_mgr.h" +#include "../src/routing_layer/conn_mgr_priv.h" #include "../lib/u_async.h" #include "../lib/debug_config.h" #include "../lib/mem.h" @@ -36,6 +37,7 @@ static int pa = 0, pb = 0; static uint64_t g_nid_a = 0, g_nid_b = 0; static volatile int cb1_ok = 0, cb2_ok = 0; +static int close_in_callback, unexpected_callback; static struct CONN_MGR_HANDLE *gh1 = NULL, *gh2 = NULL; static int wf(const char* p, const char* f, ...) { @@ -53,11 +55,18 @@ static void to_cb(void* arg) { (void)arg; fail("timeout"); done(); } static void ccb1(struct CONN_MGR_HANDLE* h, uint64_t id, uint64_t gid, enum conn_mgr_event ev, void* arg) { (void)h; (void)arg; (void)gid; + if (close_in_callback) { + conn_mgr_close(gh1); gh1 = NULL; + conn_mgr_close(gh2); gh2 = NULL; + close_in_callback = 0; + return; + } cb1_ok = (ev == CONN_EVENT_UP); fprintf(stderr, "ccb1: event=%d node=0x%llx ok=%d\n", ev, (unsigned long long)id, cb1_ok); fflush(stderr); } static void ccb2(struct CONN_MGR_HANDLE* h, uint64_t id, uint64_t gid, enum conn_mgr_event ev, void* arg) { (void)h; (void)arg; (void)gid; + if (close_in_callback) unexpected_callback++; cb2_ok = (ev == CONN_EVENT_UP); fprintf(stderr, "ccb2: event=%d node=0x%llx ok=%d\n", ev, (unsigned long long)id, cb2_ok); fflush(stderr); } @@ -119,6 +128,26 @@ static void test3_verify(void* arg) { (void)arg; if (entry_type() != CONN_TYPE_NONE) { fail("test3: entry should be cleaned up"); done(); return; } fprintf(stderr, " OK: entry cleaned up (conn_type=NONE)\n"); fflush(stderr); + struct CONN_MGR* mgr = topo_groups_get_default(g_a->topo_groups)->conn_mgr; + fprintf(stderr, "Test 4: callback closes itself and next handle\n"); + // Новые handles добавляются в голову: gh1 должен получить событие раньше gh2. + if (conn_mgr_open(g_a, TOPO_GROUP_UTUN, g_nid_b, ccb2, NULL, &gh2) != 0 + || conn_mgr_open(g_a, TOPO_GROUP_UTUN, g_nid_b, ccb1, NULL, &gh1) != 0) { + fail("test4: open failed"); return; + } + cb2_ok = 0; close_in_callback = 1; + cm_deliver_event(cm_find_entry(mgr, g_nid_b), CONN_EVENT_UP); + if (gh1 || gh2 || cb2_ok || unexpected_callback) { fail("test4: closed handle received callback"); return; } + fprintf(stderr, "Test 5: remote cleanup detaches handles and cancels pending UP\n"); + if (conn_mgr_open(g_a, TOPO_GROUP_UTUN, g_nid_b, ccb1, NULL, &gh1) != 0) { + fail("test5: open failed"); return; + } + cb1_ok = 0; + cm_handle_disconnect(mgr, g_nid_b); + if (gh1->entry || gh1->deliver_up_token || conn_mgr_get_conn(gh1)) { fail("test5: handle not detached"); return; } + conn_mgr_close(gh1); gh1 = NULL; + uasync_poll(ua, 0); + if (cb1_ok) { fail("test5: stale UP delivered"); return; } fprintf(stderr, "=== ALL DONE ===\n"); fflush(stderr); done(); } @@ -154,7 +183,7 @@ done: if (gh2) { conn_mgr_close(gh2); gh2 = NULL; } if (g_a) { g_a->running = 0; utun_instance_destroy(g_a); g_a = NULL; } if (g_b) { g_b->running = 0; utun_instance_destroy(g_b); g_b = NULL; } - if (ua) { uasync_destroy(ua, 0); ua = NULL; } + if (ua) { uasync_poll(ua, 0); uasync_destroy(ua, 0); ua = NULL; } test_unlink(ca); test_unlink(cb); test_rmdir(tdir); return (result == 1) ? 0 : 1; } diff --git a/tests/test_etcp_router_faults.c b/tests/test_etcp_router_faults.c index 04ce20ac..1a36ec71 100644 --- a/tests/test_etcp_router_faults.c +++ b/tests/test_etcp_router_faults.c @@ -11,6 +11,8 @@ #include "../lib/mem.h" #define SVC 0x42 +static uint8_t svc_base = SVC; +static int drop_wire; #define COUNT 160 #define REQUIRE(x) do { if (!(x)) { fprintf(stderr, "FAIL %s:%d: %s\n", __func__, __LINE__, #x); return -1; } } while (0) @@ -35,11 +37,11 @@ static struct ll_entry* copy_packet(const struct ll_entry* e) { return copy; } static struct ETCP_ROUTER_CONN* rc(int side, int svc) { - return etcp_router_conn_get(&ep[side].inst, 0, ep[1-side].inst.node_id, SVC + svc); + return etcp_router_conn_get(&ep[side].inst, 0, ep[1-side].inst.node_id, svc_base + svc); } static void receive(struct ETCP_CONN* conn, struct ll_entry* e) { int side = conn->instance == &ep[0].inst ? 0 : 1; - unsigned svc = e->dgram[0] - SVC; + unsigned svc = e->dgram[0] - svc_base; uint64_t src, dst, group; memcpy(&src, e->dgram + ROUTER_SVC_SRC_OFF, 8); memcpy(&dst, e->dgram + ROUTER_SVC_DST_OFF, 8); memcpy(&group, e->dgram + ROUTER_SVC_GROUP_OFF, 8); @@ -71,6 +73,7 @@ static void wire(int from, struct ll_queue* q) { if (e->len < SVC_ROUTE_HDR_SIZE) { release(e); return; } struct SVC_ROUTE_HDR* h = (struct SVC_ROUTE_HDR*)e->dgram; if (e->dgram[e->len - 1] != 1) { failed = 1; release(e); return; } /* every source retry starts afresh */ + if (drop_wire) { release(e); return; } int data = e->len > SVC_ROUTE_HDR_SIZE + 9; if (!data && h->flags == ROUTER_FLAG_START && !saved_hello && from == 0) saved_hello = copy_packet(e); if (!data && h->flags == (ROUTER_FLAG_START | ROUTER_FLAG_RST) && !saved_confirm && from == 0) saved_confirm = copy_packet(e); @@ -79,7 +82,7 @@ static void wire(int from, struct ll_queue* q) { if (!data && h->flags == ROUTER_FLAG_RST) drop = &drop_challenge[from]; if (!data && h->flags == (ROUTER_FLAG_START | ROUTER_FLAG_RST)) drop = &drop_confirm[from]; if (drop && *drop) { --*drop; handshake_drops++; release(e); return; } - if (data && from == 0 && h->svc_id == SVC) { + if (data && from == 0 && h->svc_id == svc_base) { if (!saved_data) saved_data = copy_packet(e); if (drop_data && h->seq == 2) { drop_data--; dropped_data++; release(e); return; } if (reorder && h->seq == 3 && !held) { held = e; return; } @@ -103,12 +106,13 @@ static int setup(void) { memset(ep, 0, sizeof(ep)); failed = 0; ua = uasync_create(); REQUIRE(ua); drop_data = drop_ack = drop_all_ack = reorder = duplicated = dropped_data = dropped_ack = handshake_drops = closed_in_handler = 0; memset(drop_hello, 0, sizeof(drop_hello)); memset(drop_challenge, 0, sizeof(drop_challenge)); memset(drop_confirm, 0, sizeof(drop_confirm)); + drop_wire = 0; max_wire = 0; held = saved_data = saved_ack = saved_hello = saved_confirm = NULL; for (int i = 0; i < 2; i++) { ep[i].inst.ua = ua; ep[i].inst.node_id = i + 1; REQUIRE(etcp_router_init(&ep[i].inst) == 0); - REQUIRE(etcp_router_bind(&ep[i].inst, SVC, receive) == 0); - REQUIRE(etcp_router_bind(&ep[i].inst, SVC+1, receive) == 0); + REQUIRE(etcp_router_bind(&ep[i].inst, svc_base, receive) == 0); + REQUIRE(etcp_router_bind(&ep[i].inst, svc_base+1, receive) == 0); ep[i].conn.instance = ep[i].alternate.instance = &ep[i].inst; ep[i].conn.peer_node_id = ep[i].alternate.peer_node_id = 2-i; ep[i].conn.send_input_q = queue_new(ua, 0, 0, 0, "wire"); @@ -273,7 +277,7 @@ static int close_from_producer(void) { struct ETCP_ROUTER_CONN* c = rc(0,0); for (int i=0; icount == 0); @@ -327,6 +331,22 @@ static int simultaneous_restart(void) { REQUIRE(ep[0].notified[0] == 1 && ep[1].notified[0] == 1); cleanup(); return 0; } +static int call_ten_second_gap(void) { + svc_base = ETCP_RT_ID_CALL; + REQUIRE(setup() == 0); REQUIRE(send_one(0,0) == 0); REQUIRE(wait_settled(0,0) == 0); + uint64_t epoch = rc(0,0)->reset_id; + drop_wire = 1; + REQUIRE(send_one(0,0) == 0); + uint64_t deadline = get_time_tb() + 100000; + while (get_time_tb() < deadline && !failed) step(); + REQUIRE(!failed); REQUIRE(ep[0].notified[0] == 0); + REQUIRE(rc(0,0)->reset_id == epoch && rc(0,0)->inflight_q->count == 1); + drop_wire = 0; + REQUIRE(wait_settled(0,0) == 0); + REQUIRE(ep[1].received[0] == 2 && ep[1].notified[0] == 0); + REQUIRE(rc(0,0)->reset_id == epoch && rc(0,0)->c_retrans_done > 0); + cleanup(); svc_base = SVC; return 0; +} static int size_bounds(void) { REQUIRE(setup() == 0); uint8_t* data = u_calloc(1, PKTNORM_MAX_DGRAM_SIZE); @@ -352,7 +372,7 @@ int main(void) { {"restart rejects old DATA/ACK/CLOSE/HELLO/confirm", stale_session}, {"sequence wraparound", wraparound}, {"waiter lifetime after route switch", waiter_lifetime}, {"service closes during delivery", close_from_handler}, {"route switch while old next hop blocked", route_switch}, {"no route then restore without data loss", no_route_restore}, {"duplicate ACK cannot prevent timeout", timeout_with_duplicate_ack}, - {"simultaneous endpoint restart", simultaneous_restart}, {"payload size boundaries", size_bounds}, {"producer closes during send queue drain", close_from_producer} + {"simultaneous endpoint restart", simultaneous_restart}, {"call: 10s outage retains session and pending audio", call_ten_second_gap}, {"payload size boundaries", size_bounds}, {"producer closes during send queue drain", close_from_producer} }; for (unsigned i=0; i context.dataStore.data.first()[chatStorageAutoloadMaxsizeMb] ?: 10 key == "chat.storage_maxsize_gb" -> context.dataStore.data.first()[chatStorageMaxsizeGb] ?: 1 key == "chat.opus_codec_preset" -> context.dataStore.data.first()[chatOpusCodecPreset] ?: 1 + key == "chat.call_jitter_max_reserve_ms" -> context.dataStore.data.first()[callJitterMaxReserveMs] ?: 1000 key == "chat.compressor_enabled" -> context.dataStore.data.first()[chatCompressorEnabled] ?: 1 key == "chat.compressor_max_gain_db" -> context.dataStore.data.first()[chatCompressorMaxGainDb] ?: 15 key == "chat.compressor_rise_rate" -> context.dataStore.data.first()[chatCompressorRiseRate] ?: 2 @@ -175,6 +177,7 @@ class ConfigProvider(private val context: Context) { "chat.storage_autoload_maxsize_mb" -> prefs[chatStorageAutoloadMaxsizeMb] = value.toIntOrNull() ?: 10 "chat.storage_maxsize_gb" -> prefs[chatStorageMaxsizeGb] = value.toIntOrNull() ?: 1 "chat.opus_codec_preset" -> prefs[chatOpusCodecPreset] = value.toIntOrNull() ?: 1 + "chat.call_jitter_max_reserve_ms" -> prefs[callJitterMaxReserveMs] = (value.toIntOrNull() ?: 1000).coerceIn(500, 2000) "chat.compressor_enabled" -> prefs[chatCompressorEnabled] = value.toIntOrNull() ?: 1 "chat.compressor_max_gain_db" -> prefs[chatCompressorMaxGainDb] = value.toIntOrNull() ?: 15 "chat.compressor_rise_rate" -> prefs[chatCompressorRiseRate] = value.toIntOrNull() ?: 2 @@ -530,6 +533,7 @@ class ConfigProvider(private val context: Context) { appendLine("radio_capture_stereo=$radioCaptureStereo") appendLine("radio_compressor_enabled=$radioCompEnabled") appendLine("radio_compressor_max_gain_db=$radioCompMaxGain") + appendLine("call_jitter_max_reserve_ms=${getInt("chat.call_jitter_max_reserve_ms")}") appendLine("compressor_enabled=$chatCompEnabled") appendLine("compressor_max_gain_db=$chatCompMaxGain") appendLine("compressor_rise_rate=$chatCompRiseRate") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt index 1b122b18..5a15f860 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/CallScreen.kt @@ -10,6 +10,8 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.background +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape import androidx.compose.material.icons.Icons @@ -33,6 +35,7 @@ import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.core.content.ContextCompat @@ -142,16 +145,11 @@ fun CallScreen( modifier = Modifier.fillMaxSize().background(Color(0xFF0F1B22)), contentAlignment = Alignment.Center ) { - IconButton( - onClick = onMinimize, - modifier = Modifier - .align(Alignment.TopStart) - .statusBarsPadding() - .padding(8.dp) + Column( + modifier = Modifier.fillMaxWidth().statusBarsPadding().navigationBarsPadding() + .verticalScroll(rememberScrollState()).padding(top = 56.dp, bottom = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally ) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, "Свернуть звонок", tint = Color.White, modifier = Modifier.size(24.dp)) - } - Column(horizontalAlignment = Alignment.CenterHorizontally) { Box( modifier = Modifier.size(96.dp).background(Color(0xFF1F6FEB), CircleShape), contentAlignment = Alignment.Center @@ -249,6 +247,32 @@ fun CallScreen( } } } + // Статистика под всеми кнопками звонка; обновляется событием ядра раз в секунду. + if (phase == CallPhase.ACTIVE) { + st?.stats?.let { stats -> + Spacer(Modifier.height(20.dp)) + Text( + "Буфер: ${stats.bufferMs} мс · Запас: ${stats.reserveMs} мс\n" + + "За 10 с: минимум ${stats.minMs} · максимум ${stats.maxMs} мс\n" + + "Скорость: ${"%.2f".format(stats.tempoX100 / 100.0)}× · RTT: ${stats.rttMs} мс\n" + + "Потери буфера: ${stats.dropped} · Недостачи: ${stats.underruns}", + modifier = Modifier.padding(horizontal = 16.dp), + color = Color(0xFFAEBCC8), + fontSize = 12.sp, + textAlign = TextAlign.Center + ) + } + } + } + + IconButton( + onClick = onMinimize, + modifier = Modifier + .align(Alignment.TopStart) + .statusBarsPadding() + .padding(8.dp) + ) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, "Свернуть звонок", tint = Color.White, modifier = Modifier.size(24.dp)) } if (locked) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 53b77310..9a17aace 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -32,6 +32,8 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.layout.positionInWindow @@ -42,6 +44,7 @@ import androidx.compose.ui.semantics.semantics import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp @@ -172,8 +175,12 @@ fun ChatScreen( IconButton(onClick = { viewModel.toggleSound() }) { Text(if (soundOn) "\uD83D\uDD14" else "\uD83D\uDD15", fontSize = 18.sp) } - IconButton(onClick = { viewModel.toggleRadio() }) { - Text(if (radioActive) "\uD83D\uDCFB" else "\uD83D\uDD07", fontSize = 18.sp) + IconButton( + onClick = { viewModel.toggleRadio() }, + modifier = Modifier.background( + if (radioActive) Color(0xFF27AE60) else Color.Transparent, CircleShape) + ) { + PortableRadioIcon("Рация: ${if (radioActive) "выключить" else "включить"}") } IconButton(onClick = onInvite) { Icon(Icons.Filled.Link, "Invite") } IconButton(onClick = onMembersClick) { Icon(Icons.Filled.People, "Members") } @@ -505,3 +512,24 @@ private fun RadioFloatingIcon(label: String) { } } } + +/* Портативная рация: форма и цвет значка не зависят от включения. */ +@Composable +private fun PortableRadioIcon(label: String) { + val color = LocalContentColor.current + Canvas(Modifier.size(24.dp).semantics { contentDescription = label }) { + val unit = size.width / 24f + val stroke = 1.7f * unit + fun line(x1: Float, y1: Float, x2: Float, y2: Float) { + drawLine(color, Offset(x1 * unit, y1 * unit), Offset(x2 * unit, y2 * unit), + strokeWidth = stroke, cap = StrokeCap.Round) + } + line(8.5f, 1f, 8.5f, 9f) + line(14.5f, 7f, 14.5f, 9f) + drawRoundRect(color, Offset(7f * unit, 9f * unit), Size(10f * unit, 13f * unit), + CornerRadius(2f * unit), style = Stroke(stroke)) + drawRoundRect(color, Offset(9.5f * unit, 11.5f * unit), Size(5f * unit, 3f * unit), + CornerRadius(0.5f * unit), style = Stroke(stroke)) + for (y in listOf(16f, 18f, 20f)) line(9.5f, y, 14.5f, y) + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index a5ef86e5..d9aaebad 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -711,6 +711,8 @@ private fun AudioSettingsTab(vm: ChatViewModel) { var preset by remember { mutableStateOf(vm.getVoicePreset()) } var presetExpanded by remember { mutableStateOf(false) } + var jitterReserveMs by remember { mutableStateOf(provider.getInt("chat.call_jitter_max_reserve_ms")) } + /* Compressor params with defaults from desktop */ var compressorOn by remember { mutableStateOf(vm.getVoiceCompressor()) } var maxGainDb by remember { mutableStateOf(15) } @@ -827,6 +829,19 @@ private fun AudioSettingsTab(vm: ChatViewModel) { HorizontalDivider(Modifier.padding(vertical = 4.dp)) + Text("Звонки: максимальный запас джиттер-буфера", style = MaterialTheme.typography.titleMedium) + Text("${"%.1f".format(jitterReserveMs / 1000.0)} с · применяется к следующему звонку") + Slider( + value = jitterReserveMs.toFloat(), + onValueChange = { jitterReserveMs = (it / 100).toInt() * 100 }, + valueRange = 500f..2000f, + steps = 14, + onValueChangeFinished = { + scope.launch { provider.setValue("chat.call_jitter_max_reserve_ms", jitterReserveMs.toString()) } + NativeLib.setChatSetting("call_jitter_max_reserve_ms", jitterReserveMs.toString()) + } + ) + /* ── Voice Compressor ── */ Text("Voice Compressor", style = MaterialTheme.typography.titleMedium) Row(verticalAlignment = Alignment.CenterVertically) { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index c95770a0..2e5ed7c5 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -37,6 +37,17 @@ class ChatViewModel : ViewModel() { enum class CallPhase { NONE, OUTGOING, RINGING, INCOMING, ACTIVE, ENDED } + data class CallStats( + val rttMs: Int, + val bufferMs: Int, + val tempoX100: Int, + val dropped: Int, + val underruns: Int, + val minMs: Int, + val maxMs: Int, + val reserveMs: Int + ) + data class CallState( val callId: Long = 0, val channelId: String = "", @@ -45,7 +56,8 @@ class ChatViewModel : ViewModel() { val phase: CallPhase = CallPhase.NONE, val reason: Int = -1, val durationMs: Int = 0, - val bgpPath: String = "" + val bgpPath: String = "", + val stats: CallStats? = null ) private var repo: ChatRepository? = null @@ -677,6 +689,23 @@ class ChatViewModel : ViewModel() { val reason = data[8].toInt() and 0xFF finishCall(callId, reason, "DECLINED") } + 39 -> { /* CALL_STATS: call_id LE64, восемь BE16 метрик */ + if (data == null || data.size != 24) { + LogManager.addLog("WARN", "call", "CALL_STATS invalid size=${data?.size ?: 0}") + return + } + val callId = java.nio.ByteBuffer.wrap(data, 0, 8) + .order(java.nio.ByteOrder.LITTLE_ENDIAN).long + val st = _callState.value + if (st == null || st.callId != callId || st.phase != CallPhase.ACTIVE) return + val buf = java.nio.ByteBuffer.wrap(data, 8, 16).order(java.nio.ByteOrder.BIG_ENDIAN) + fun readMetric() = buf.short.toInt() and 0xFFFF + val stats = CallStats(readMetric(), readMetric(), readMetric(), readMetric(), + readMetric(), readMetric(), readMetric(), readMetric()) + if (st.stats == null) + LogManager.addLog("DEBUG", "call", "CALL_STATS started id=0x%016x reserve=${stats.reserveMs}ms".format(callId)) + _callState.value = st.copy(stats = stats) + } 40 -> { /* CALL_ERROR: [call_id:8][err:1][text:var] */ if (data == null || data.size < 9) return val callId = java.nio.ByteBuffer.wrap(data, 0, 8).order(java.nio.ByteOrder.LITTLE_ENDIAN).getLong() diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 6006b604..71f67da8 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -145,7 +145,6 @@ add_executable(vibechat src/radio_audio_engine.cpp src/ptt_hotkey_manager.cpp src/radiosettingspage.cpp - third_party/qhotkey/qhotkey.cpp transport/utun_node.cpp transport/node_config.cpp transport/config_updater.cpp @@ -156,23 +155,31 @@ add_executable(vibechat resources/chatgui.qrc ) -target_include_directories(vibechat PRIVATE ${CMAKE_SOURCE_DIR}/../../lib ${CMAKE_SOURCE_DIR}/../../src ${CMAKE_SOURCE_DIR}/db ${CMAKE_SOURCE_DIR}/../../lib/libopus/include ${CMAKE_SOURCE_DIR}/third_party/qhotkey ${FFMPEG_INCLUDE_DIRS}) +target_include_directories(vibechat PRIVATE ${CMAKE_SOURCE_DIR}/../../lib ${CMAKE_SOURCE_DIR}/../../src ${CMAKE_SOURCE_DIR}/db ${CMAKE_SOURCE_DIR}/../../lib/libopus/include ${FFMPEG_INCLUDE_DIRS}) target_compile_definitions(vibechat PRIVATE SQLITE_THREADSAFE=1 USE_SQLITE) if(FFMPEG_FOUND) target_compile_definitions(vibechat PRIVATE HAVE_FFMPEG) endif() set_source_files_properties(../../lib/sqlite3.c transport/miniaudio_impl.c PROPERTIES LANGUAGE C) -# QHotkey — глобальные хоткеи (PTT): X11 на Linux, RegisterHotKey на Windows -if(WIN32) - target_sources(vibechat PRIVATE third_party/qhotkey/qhotkey_win.cpp) -elseif(CMAKE_SYSTEM_NAME STREQUAL "Linux") +# Глобальное состояние клавиатуры для PTT: Windows user32 / Linux X11. +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") find_package(X11 REQUIRED) - target_sources(vibechat PRIVATE third_party/qhotkey/qhotkey_x11.cpp) + if(Qt6_FOUND) + find_package(Qt6 COMPONENTS DBus REQUIRED) + set(QT_DBUS Qt6::DBus) + else() + find_package(Qt5 COMPONENTS DBus REQUIRED) + set(QT_DBUS Qt5::DBus) + endif() + target_sources(vibechat PRIVATE src/ptt_portal.cpp) + target_compile_definitions(vibechat PRIVATE UTUN_PTT_PORTAL) + target_link_libraries(vibechat PRIVATE ${QT_DBUS}) + target_include_directories(vibechat PRIVATE ${X11_INCLUDE_DIR}) endif() if(WIN32) - target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun utun_voice pthread ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) + target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun utun_voice pthread user32 ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) else() target_link_libraries(vibechat PRIVATE ${QT_LIBS} rlottie::rlottie ZLIB::ZLIB OpenSSL::Crypto ZXing::ZXing utun utun_voice pthread dl ${X11_LIBRARIES} ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) endif() @@ -212,12 +219,17 @@ target_link_libraries(test_config_updater PRIVATE ${QT_CORE} pthread) add_test(NAME test_config_updater COMMAND test_config_updater) # тест адаптивного джиттер-буфера голоса (кодированные кадры + SoundTouch time-stretch) -add_executable(test_voice_jitter - tests/test_voice_jitter.cpp +add_executable(test_radio_jitter + tests/test_radio_jitter.cpp ) -target_include_directories(test_voice_jitter PRIVATE ${CMAKE_SOURCE_DIR}/../../src) -target_link_libraries(test_voice_jitter PRIVATE utun_voice pthread) -add_test(NAME test_voice_jitter COMMAND test_voice_jitter) +target_include_directories(test_radio_jitter PRIVATE ${CMAKE_SOURCE_DIR}/../../src) +target_link_libraries(test_radio_jitter PRIVATE utun_voice pthread) +add_test(NAME test_radio_jitter COMMAND test_radio_jitter) + +add_executable(test_call_jitter tests/test_call_jitter.cpp) +target_include_directories(test_call_jitter PRIVATE ${CMAKE_SOURCE_DIR}/../../src) +target_link_libraries(test_call_jitter PRIVATE utun_voice utun pthread) +add_test(NAME test_call_jitter COMMAND test_call_jitter) # тест аудио-движка рации (per-source jitter + микшер + soft-clip) add_executable(test_radio_audio @@ -245,3 +257,17 @@ if(WIN32) else() target_link_libraries(test_video_engine PRIVATE ${QT_LIBS} ${QT_CORE} utun pthread dl ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) endif() + +add_executable(test_ptt_key_chord tests/test_ptt_key_chord.cpp) +target_link_libraries(test_ptt_key_chord PRIVATE ${QT_CORE}) +add_test(NAME test_ptt_key_chord COMMAND test_ptt_key_chord) + +if(CMAKE_SYSTEM_NAME STREQUAL "Linux") + add_executable(test_ptt_portal tests/test_ptt_portal.cpp src/ptt_portal.cpp) + target_link_libraries(test_ptt_portal PRIVATE ${QT_DBUS} ${QT_TEST_LIB} utun pthread) + find_program(DBUS_RUN_SESSION dbus-run-session) + if(NOT DBUS_RUN_SESSION) + message(FATAL_ERROR "dbus-run-session is required for the isolated PTT portal test") + endif() + add_test(NAME test_ptt_portal COMMAND ${DBUS_RUN_SESSION} -- $) +endif() diff --git a/tools/chatgui/src/audiodevicesettingspage.cpp b/tools/chatgui/src/audiodevicesettingspage.cpp index a7223e1f..f63e5f45 100644 --- a/tools/chatgui/src/audiodevicesettingspage.cpp +++ b/tools/chatgui/src/audiodevicesettingspage.cpp @@ -20,6 +20,7 @@ extern "C" { #include #include #include +#include #include AudioDeviceSettingsPage::AudioDeviceSettingsPage(DbManager* db, AudioRecorder* recorder, QWidget* parent) @@ -152,6 +153,17 @@ AudioDeviceSettingsPage::AudioDeviceSettingsPage(DbManager* db, AudioRecorder* r sep4->setStyleSheet("QFrame { color: palette(mid); }"); layout->addWidget(sep4); + auto* callForm = new QFormLayout(); + m_jitterReserveSpin = new QDoubleSpinBox(this); + m_jitterReserveSpin->setRange(0.5, 2.0); + m_jitterReserveSpin->setDecimals(1); + m_jitterReserveSpin->setSingleStep(0.1); + m_jitterReserveSpin->setValue(1.0); + m_jitterReserveSpin->setSuffix(" s"); + m_jitterReserveSpin->setToolTip("Maximum adaptive jitter reserve for calls. Applies to the next call."); + callForm->addRow("Call: maximum jitter reserve", m_jitterReserveSpin); + layout->addLayout(callForm); + buildCompressorUI(layout); layout->addStretch(); @@ -197,6 +209,7 @@ void AudioDeviceSettingsPage::loadFromDb() { m_savedCompressorEnabled = m_db->getUiStateInt("compressor_enabled", 0); m_savedCompressorMaxGainDb = m_db->getUiStateInt("compressor_max_gain_db", 25); m_savedCompressorRiseRate = m_db->getUiStateInt("compressor_rise_rate", 10); + m_jitterReserveSpin->setValue(m_db->getUiStateInt("call_jitter_max_reserve_ms", 1000) / 1000.0); m_savedAecEnabled = m_db->getUiStateInt("aec_enabled", 1); } @@ -269,6 +282,8 @@ void AudioDeviceSettingsPage::applyAndSave() { saveKey("compressor_max_gain_db", compMaxGain); saveKey("compressor_rise_rate", compRiseRate); + int jitterReserveMs = (int)std::lround(m_jitterReserveSpin->value() * 1000); + saveKey("call_jitter_max_reserve_ms", jitterReserveMs); int aecEnabled = m_aecEnabledCb->isChecked() ? 1 : 0; saveKey("aec_enabled", aecEnabled); @@ -294,6 +309,7 @@ void AudioDeviceSettingsPage::applyAndSave() { postChat(m_configPath, "compressor_max_gain_db", compMaxGain); postChat(m_configPath, "compressor_rise_rate", compRiseRate); postChat(m_configPath, "aec_enabled", aecEnabled); + postChat(m_configPath, "call_jitter_max_reserve_ms", jitterReserveMs); } m_savedOutputIdx = outIdx; diff --git a/tools/chatgui/src/audiodevicesettingspage.h b/tools/chatgui/src/audiodevicesettingspage.h index d7e4dc7f..5b2ddec7 100644 --- a/tools/chatgui/src/audiodevicesettingspage.h +++ b/tools/chatgui/src/audiodevicesettingspage.h @@ -54,6 +54,7 @@ private: int m_savedCompressorMaxGainDb = 25; int m_savedCompressorRiseRate = 10; + class QDoubleSpinBox* m_jitterReserveSpin; QCheckBox* m_aecEnabledCb; int m_savedAecEnabled = 1; diff --git a/tools/chatgui/src/callwindow.cpp b/tools/chatgui/src/callwindow.cpp index 0d66c78f..1862d12a 100644 --- a/tools/chatgui/src/callwindow.cpp +++ b/tools/chatgui/src/callwindow.cpp @@ -137,7 +137,7 @@ static QWidget* wrapWithCaption(QWidget* btn, const QString& caption, QLabel** c CallWindow::CallWindow(QWidget* parent) : QWidget(parent, Qt::Window) { setWindowTitle(QStringLiteral("Звонок")); - setFixedSize(340, 400); + setFixedSize(360, 480); setAttribute(Qt::WA_DeleteOnClose, false); m_avatarLabel = new QLabel(this); @@ -161,6 +161,11 @@ CallWindow::CallWindow(QWidget* parent) m_bgpPathLabel->setWordWrap(true); m_bgpPathLabel->setStyleSheet("color: gray; font-size: 11px;"); m_bgpPathLabel->hide(); + m_statsLabel = new QLabel(this); + m_statsLabel->setAlignment(Qt::AlignCenter); + m_statsLabel->setWordWrap(true); + m_statsLabel->setStyleSheet("color: gray; font-size: 11px;"); + m_statsLabel->hide(); m_acceptBtn = makeRoundBtn(makeRoundIcon(QColor("#27ae60"), Glyph::Phone, 96), QStringLiteral("Принять"), this); m_declineBtn = makeRoundBtn(makeRoundIcon(QColor("#e74c3c"), Glyph::PhoneDown, 96), QStringLiteral("Отклонить"), this); @@ -181,6 +186,7 @@ CallWindow::CallWindow(QWidget* parent) lay->addWidget(m_statusLabel); lay->addWidget(m_durationLabel); lay->addWidget(m_bgpPathLabel); + lay->addWidget(m_statsLabel); lay->addStretch(1); m_acceptCell = wrapWithCaption(m_acceptBtn, QStringLiteral("Принять")); @@ -332,3 +338,13 @@ void CallWindow::updateDuration() { .arg(s / 60, 2, 10, QChar('0')) .arg(s % 60, 2, 10, QChar('0'))); } + +void CallWindow::setAudioStats(int rtt, int buffer, int tempo, int dropped, int underruns, + int minimum, int maximum, int reserve) { + m_statsLabel->setText(QStringLiteral("Буфер: %1 мс · Запас: %2 мс\n" + "За 10 с: минимум %3 · максимум %4 мс\n" + "Скорость: %5× · RTT: %6 мс\nПотери буфера: %7 · Недостачи: %8") + .arg(buffer).arg(reserve).arg(minimum).arg(maximum) + .arg(tempo / 100.0, 0, 'f', 2).arg(rtt).arg(dropped).arg(underruns)); + m_statsLabel->show(); +} diff --git a/tools/chatgui/src/callwindow.h b/tools/chatgui/src/callwindow.h index 0568c27f..f61c24bd 100644 --- a/tools/chatgui/src/callwindow.h +++ b/tools/chatgui/src/callwindow.h @@ -23,6 +23,7 @@ public: void setPeerName(const QString& name); void setBgpPath(const QString& path); + void setAudioStats(int rtt, int buffer, int tempo, int dropped, int underruns, int minimum, int maximum, int reserve); void setState(State s); State state() const { return m_state; } @@ -54,6 +55,7 @@ private: QLabel* m_statusLabel; QLabel* m_durationLabel; QLabel* m_bgpPathLabel; + QLabel* m_statsLabel; QPushButton* m_acceptBtn; QPushButton* m_declineBtn; QPushButton* m_hangupBtn; diff --git a/tools/chatgui/src/main.cpp b/tools/chatgui/src/main.cpp index bcbbd72a..82287ba6 100644 --- a/tools/chatgui/src/main.cpp +++ b/tools/chatgui/src/main.cpp @@ -72,6 +72,7 @@ int main(int argc, char *argv[]) { #endif QApplication app(argc, argv); app.setApplicationName("vibechat"); + app.setDesktopFileName("org.utun.vibechat"); app.setWindowIcon(QIcon(":/icons/app_icon.png")); app.setQuitOnLastWindowClosed(false); diff --git a/tools/chatgui/src/mainwindow.cpp b/tools/chatgui/src/mainwindow.cpp index 8fd6f558..ef5f212f 100644 --- a/tools/chatgui/src/mainwindow.cpp +++ b/tools/chatgui/src/mainwindow.cpp @@ -456,6 +456,12 @@ void MainWindow::setupBridgeCallbacks() { Q_UNUSED(text); Q_UNUSED(len); if (s_mainWindow) s_mainWindow->onCallError(call_id, err); }); + gui_bridge_set_call_stats_cb([](uint64_t id, int rtt, int buffer, int tempo, int dropped, + int underruns, int minimum, int maximum, int reserve) { + if (!s_mainWindow) return; + auto* w = s_mainWindow->m_activeCalls.value(id, nullptr); + if (w) w->setAudioStats(rtt, buffer, tempo, dropped, underruns, minimum, maximum, reserve); + }); gui_bridge_set_call_path_cb([](uint64_t call_id, const char* text, int len) { if (s_mainWindow) s_mainWindow->onCallPath(call_id, QString::fromUtf8(text, len)); }); diff --git a/tools/chatgui/src/messagelist.cpp b/tools/chatgui/src/messagelist.cpp index 558872c7..cc835b4c 100644 --- a/tools/chatgui/src/messagelist.cpp +++ b/tools/chatgui/src/messagelist.cpp @@ -13,6 +13,7 @@ #include #include #include +#include #include #include #include @@ -49,6 +50,30 @@ void chat_core_save_ui_state_trampoline(void* arg); #include #include +// Портативная рация: один и тот же значок во всех состояниях кнопки. +static QIcon makeRadioIcon(const QColor& color) { + QPixmap pix(48, 48); + pix.fill(Qt::transparent); + QPainter p(&pix); + p.setRenderHint(QPainter::Antialiasing); + p.scale(2, 2); + p.setPen(QPen(color, 1.7, Qt::SolidLine, Qt::RoundCap, Qt::RoundJoin)); + p.setBrush(Qt::NoBrush); + p.drawLine(QPointF(8.5, 1), QPointF(8.5, 9)); + p.drawLine(QPointF(14.5, 7), QPointF(14.5, 9)); + p.drawRoundedRect(QRectF(7, 9, 10, 13), 2, 2); + p.drawRoundedRect(QRectF(9.5, 11.5, 5, 3), 0.5, 0.5); + for (int y : {16, 18, 20}) p.drawLine(QPointF(9.5, y), QPointF(14.5, y)); + p.end(); + QIcon icon; + // Явно задаём все режимы: Qt не перекрашивает значок при checked/disabled. + for (auto mode : {QIcon::Normal, QIcon::Disabled, QIcon::Active, QIcon::Selected}) { + icon.addPixmap(pix, mode, QIcon::Off); + icon.addPixmap(pix, mode, QIcon::On); + } + return icon; +} + static QPixmap makeAvatar(const QColor &color, const QString &letter, int size = 36) { QPixmap pix(size, size); pix.fill(Qt::transparent); @@ -331,7 +356,10 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent m_talkingLabel->hide(); hl->addWidget(m_talkingLabel); - m_radioBtn = new QPushButton(QString::fromUtf8("\xF0\x9F\x93\xBB"), header); + m_radioBtn = new QPushButton(header); + m_radioBtn->setIcon(makeRadioIcon(header->palette().color(QPalette::ButtonText))); + m_radioBtn->setIconSize(QSize(24, 24)); + m_radioBtn->setAccessibleName(QStringLiteral("Рация")); m_radioBtn->setCheckable(true); m_radioBtn->setFixedSize(34, 30); m_radioBtn->setCursor(Qt::PointingHandCursor); @@ -373,8 +401,10 @@ MessageList::MessageList(DbManager* db, AudioRecorder* recorder, QWidget *parent m_pttBtn->setToolTip(QStringLiteral("Держите и говорите")); m_pttBtn->setStyleSheet( "QPushButton { border: 3px solid white; border-radius: 48px; background: #e74c3c; color: white; font-size: 36px; }" - "QPushButton:pressed { background: #c0392b; }"); + "QPushButton[transmitting=\"true\"] { background: #27ae60; }"); + m_pttBtn->setProperty("transmitting", false); m_pttBtn->hide(); + connect(RadioAudioEngine::instance(), &RadioAudioEngine::talkingChanged, this, [this](bool) { updateRadioUi(); }); connect(m_pttBtn, &QPushButton::pressed, this, []() { RadioAudioEngine::instance()->talkBegin(); }); @@ -1130,6 +1160,14 @@ void MessageList::onRadioToggled(bool on) { void MessageList::updateRadioUi() { if (!m_pttBtn) return; + auto* audio = RadioAudioEngine::instance(); + bool transmitting = audio->isTalking() && audio->groupId() == m_currentChannelId.toULongLong(); + if (m_pttBtn->property("transmitting").toBool() != transmitting) { + m_pttBtn->setProperty("transmitting", transmitting); + m_pttBtn->style()->unpolish(m_pttBtn); + m_pttBtn->style()->polish(m_pttBtn); + m_pttBtn->update(); + } bool show = m_radioOn && !isDmChannel(m_currentChannelId); m_pttBtn->setVisible(show); if (show) { diff --git a/tools/chatgui/src/ptt_hotkey_manager.cpp b/tools/chatgui/src/ptt_hotkey_manager.cpp index 17890bc2..1cb9526a 100644 --- a/tools/chatgui/src/ptt_hotkey_manager.cpp +++ b/tools/chatgui/src/ptt_hotkey_manager.cpp @@ -1,75 +1,252 @@ #include "ptt_hotkey_manager.h" #include "radio_audio_engine.h" #include "../db/db_manager.h" -#include "qhotkey.h" +#include "../../lib/debug_config.h" -#include -#include - -static PttHotkeyManager* g_pttHotkeys = nullptr; +#include +#include +#ifdef UTUN_PTT_PORTAL +#include "ptt_portal.h" +#endif +#ifdef Q_OS_WIN +#include +#elif defined(Q_OS_LINUX) +#include +#include +#include +#endif PttHotkeyManager* PttHotkeyManager::instance() { - if (!g_pttHotkeys) g_pttHotkeys = new PttHotkeyManager(); - return g_pttHotkeys; + static auto* manager = new PttHotkeyManager(); + return manager; +} + +PttHotkeyManager::PttHotkeyManager() : QObject(qApp) { +#ifdef Q_OS_LINUX + if (isSupported() && !usesPortal()) m_display = XOpenDisplay(nullptr); + if (isSupported() && !usesPortal() && !m_display) DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "PTT: cannot open X11 display"); +#endif +#ifdef UTUN_PTT_PORTAL + if (usesPortal()) { + m_portal = new PttPortal(this); + connect(m_portal, &PttPortal::talkingChanged, this, [](bool talking) { + RadioAudioEngine::instance()->setHotkeyTalking(talking); + }); + connect(m_portal, &PttPortal::descriptionsChanged, this, &PttHotkeyManager::portalChanged); + connect(m_portal, &PttPortal::statusChanged, this, &PttHotkeyManager::portalChanged); + } +#endif + m_timer.setInterval(10); + m_timer.setTimerType(Qt::PreciseTimer); + connect(&m_timer, &QTimer::timeout, this, &PttHotkeyManager::poll); +} + +PttHotkeyManager::~PttHotkeyManager() { + m_timer.stop(); +#ifdef Q_OS_LINUX + if (m_display) XCloseDisplay(static_cast(m_display)); +#endif } bool PttHotkeyManager::isSupported() { - return QHotkey::isPlatformSupported(); +#ifdef Q_OS_WIN + return true; +#elif defined(Q_OS_LINUX) + return usesPortal() || QGuiApplication::platformName() == "xcb"; +#else + return false; +#endif } void PttHotkeyManager::init(DbManager* db) { m_db = db; +#ifdef UTUN_PTT_PORTAL + if (m_portal) { m_portal->initialize(); return; } +#endif reload(); + if (isSupported()) { +#ifdef Q_OS_LINUX + if (!m_display) return; +#endif + m_timer.start(); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT: global keyboard polling enabled interval=10ms"); + } else DEBUG_WARN(DEBUG_CATEGORY_RADIO, "PTT: global keyboard unavailable platform=%s", qPrintable(QGuiApplication::platformName())); } QStringList PttHotkeyManager::hotkeys() const { - return m_seqs; + QStringList values; + for (const auto& chord : m_chords) values << pttEncodeChord(chord); + return values; } -void PttHotkeyManager::setHotkeys(const QStringList& seqs) { - applyHotkeys(seqs); +void PttHotkeyManager::setHotkeys(const QStringList& values) { + if (usesPortal()) return; + RadioAudioEngine::instance()->setHotkeyTalking(false); + m_talking = false; + m_chords.clear(); + for (const auto& value : values) { + auto chord = pttDecodeChord(value); + if (value.isEmpty()) continue; + if (chord.isEmpty()) { + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "PTT: invalid key chord '%s'", qPrintable(value)); + continue; + } + if (!m_chords.contains(chord)) m_chords << chord; + } + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT: configured %d key chords", int(m_chords.size())); } void PttHotkeyManager::reload() { - QStringList seqs; - if (m_db && m_db->isOpen()) { - const QString raw = m_db->getUiState("radio_ptt_hotkeys"); - if (!raw.isEmpty()) - seqs = raw.split('|', Qt::SkipEmptyParts); - } - applyHotkeys(seqs); + setHotkeys(m_db && m_db->isOpen() ? m_db->getUiState("radio_ptt_hotkeys").split('|', Qt::SkipEmptyParts) : QStringList()); } -void PttHotkeyManager::clearHotkeys() { - for (QHotkey* h : m_hotkeys) { - h->resetShortcut(); - h->deleteLater(); +bool PttHotkeyManager::readKeys(PttKeyChord& held) const { +#ifdef Q_OS_WIN + for (int key = 8; key < 256; key++) { + // Общие VK_SHIFT/CONTROL/MENU дублируют левые/правые — исключаем их. + if (key == VK_SHIFT || key == VK_CONTROL || key == VK_MENU) continue; + if (GetAsyncKeyState(key) & 0x8000) held.insert(key); } - m_hotkeys.clear(); + return true; +#elif defined(Q_OS_LINUX) + if (!m_display) return false; + char keys[32]; + if (!XQueryKeymap(static_cast(m_display), keys)) return false; + for (int key = 8; key < 256; key++) if ((unsigned char)keys[key / 8] & (1u << (key % 8))) held.insert(key); + return true; +#else + return false; +#endif } -void PttHotkeyManager::applyHotkeys(const QStringList& seqs) { - clearHotkeys(); - m_seqs.clear(); +void PttHotkeyManager::poll() { + PttKeyChord held; + if (!readKeys(held)) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "PTT: keyboard query failed; transmission disabled"); + m_timer.stop(); + RadioAudioEngine::instance()->setHotkeyTalking(false); + m_talking = false; + return; + } + if (m_capturing && !m_captureArmed && held.isEmpty()) m_captureArmed = true; + bool changed = held != m_held; + m_held = held; + bool talking = false; + if (!m_capturing && RadioAudioEngine::instance()->isActive()) { + for (const auto& chord : m_chords) if (pttChordHeld(chord, held)) { talking = true; break; } + } + if (talking != m_talking) { + m_talking = talking; + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "PTT: global transmission %s keys=%s", talking ? "started" : "stopped", + qPrintable(describe(held))); + } + RadioAudioEngine::instance()->setHotkeyTalking(talking); + if (m_capturing && m_captureArmed && changed) emit keysChanged(held); +} - for (const QString& s : seqs) { - QKeySequence seq(s); - if (seq.isEmpty()) continue; - auto* hk = new QHotkey(seq, true, this); - if (!hk->isRegistered()) { - qWarning("PttHotkey: failed to register '%s'", qPrintable(s)); - hk->deleteLater(); - continue; +bool PttHotkeyManager::beginCapture() { + if (!m_timer.isActive() || m_capturing) { + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "PTT: capture unavailable polling=%d capturing=%d", m_timer.isActive(), m_capturing); + return false; + } + m_capturing = true; + PttKeyChord held; + if (!readKeys(held)) { + m_capturing = false; + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "PTT: cannot read keys when starting capture"); + return false; + } + m_captureArmed = held.isEmpty(); // ждём отпускания клавиши, которой нажали кнопку + m_talking = false; + RadioAudioEngine::instance()->setHotkeyTalking(false); + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "PTT: key capture started"); + return true; +} + +void PttHotkeyManager::endCapture() { + if (!m_capturing) return; + m_capturing = false; + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "PTT: key capture finished"); +} + +QString PttHotkeyManager::keyName(int key) const { +#ifdef Q_OS_WIN + switch (key) { + case VK_LSHIFT: return "ShiftL"; case VK_RSHIFT: return "ShiftR"; + case VK_LCONTROL: return "CtrlL"; case VK_RCONTROL: return "CtrlR"; + case VK_LMENU: return "AltL"; case VK_RMENU: return "AltR"; + case VK_LWIN: return "WinL"; case VK_RWIN: return "WinR"; + } + UINT scan = MapVirtualKeyW(key, MAPVK_VK_TO_VSC_EX); + LONG param = LONG((scan & 0xff) << 16); + if (scan & 0xff00) param |= 1 << 24; + wchar_t name[128]; + if (GetKeyNameTextW(param, name, 128)) return QString::fromWCharArray(name); +#elif defined(Q_OS_LINUX) + if (m_display) { + KeySym sym = XkbKeycodeToKeysym(static_cast(m_display), key, 0, 0); + switch (sym) { + case XK_Shift_L: return "ShiftL"; case XK_Shift_R: return "ShiftR"; + case XK_Control_L: return "CtrlL"; case XK_Control_R: return "CtrlR"; + case XK_Alt_L: return "AltL"; case XK_Alt_R: return "AltR"; + case XK_Super_L: return "WinL"; case XK_Super_R: return "WinR"; + case XK_space: return "Space"; } - connect(hk, &QHotkey::activated, this, []() { - RadioAudioEngine::instance()->talkBegin(); - }); - connect(hk, &QHotkey::released, this, []() { - RadioAudioEngine::instance()->talkEnd(); - }); - m_hotkeys.append(hk); - m_seqs.append(seq.toString()); + const char* name = XKeysymToString(sym); + if (name) return QString::fromLatin1(name); } +#endif + return QString("Key %1").arg(key); +} - qDebug("PttHotkey: applied %d hotkey(s)", (int)m_hotkeys.size()); +QString PttHotkeyManager::describe(const PttKeyChord& chord) const { + auto keys = chord.values(); + std::sort(keys.begin(), keys.end()); + QStringList names; + for (int key : keys) names << keyName(key); + return names.isEmpty() ? QStringLiteral("Не задано") : names.join(" + "); +} + +bool PttHotkeyManager::usesPortal() { +#ifdef UTUN_PTT_PORTAL + return qEnvironmentVariable("XDG_SESSION_TYPE") == "wayland" || QGuiApplication::platformName().startsWith("wayland"); +#else + return false; +#endif +} +void PttHotkeyManager::configurePortal(QWidget* parentWindow) { +#ifdef UTUN_PTT_PORTAL + if (!m_portal) return; + QString id; + if (parentWindow && QGuiApplication::platformName() == "xcb") + id = "x11:" + QString::number(qulonglong(parentWindow->window()->winId()), 16); + m_portal->configure(id); +#else + Q_UNUSED(parentWindow); +#endif +} +QStringList PttHotkeyManager::portalDescriptions() const { +#ifdef UTUN_PTT_PORTAL + if (m_portal) return m_portal->descriptions(); +#endif + return {}; +} +QString PttHotkeyManager::portalStatus() const { +#ifdef UTUN_PTT_PORTAL + if (m_portal) return m_portal->status(); +#endif + return {}; +} +bool PttHotkeyManager::portalBusy() const { +#ifdef UTUN_PTT_PORTAL + if (m_portal) return m_portal->busy(); +#endif + return false; +} +void PttHotkeyManager::inhibitPortal(bool inhibited) { +#ifdef UTUN_PTT_PORTAL + if (m_portal) m_portal->setInhibited(inhibited); +#else + Q_UNUSED(inhibited); +#endif } diff --git a/tools/chatgui/src/ptt_hotkey_manager.h b/tools/chatgui/src/ptt_hotkey_manager.h index e4586aba..e7e68d4e 100644 --- a/tools/chatgui/src/ptt_hotkey_manager.h +++ b/tools/chatgui/src/ptt_hotkey_manager.h @@ -1,39 +1,54 @@ #pragma once +#include "ptt_key_chord.h" #include -#include -#include +#include -class QHotkey; class DbManager; +class PttPortal; +class QWidget; -/* Глобальные PTT-хоткеи рации (QHotkey): удержание любой комбинации активирует - * передачу (talkBegin), отпускание — завершает (talkEnd). Работает из любого - * приложения. Хранится в ui_state ("radio_ptt_hotkeys", разделитель "|"). */ +// Глобальный PTT: Windows/X11 — состояние физических клавиш; Wayland — системный портал. +// Физические комбинации хранит ui_state radio_ptt_hotkeys, Wayland-комбинации — портал. class PttHotkeyManager : public QObject { Q_OBJECT public: static PttHotkeyManager* instance(); - void init(DbManager* db); - - /* Текущий список хоткеев (QKeySequence-строки). */ QStringList hotkeys() const; - - /* Применить список хоткеев (без записи в БД — сохранение делает SettingsDialog). */ - void setHotkeys(const QStringList& seqs); - - /* Перечитать из ui_state и применить. */ + void setHotkeys(const QStringList& chords); void reload(); - static bool isSupported(); + static bool usesPortal(); + void configurePortal(QWidget* parentWindow); + QStringList portalDescriptions() const; + QString portalStatus() const; + bool portalBusy() const; + void inhibitPortal(bool inhibited); + QString describe(const PttKeyChord& chord) const; + bool beginCapture(); + void endCapture(); + +signals: + void portalChanged(); + void keysChanged(const PttKeyChord& held); private: - PttHotkeyManager() = default; - void clearHotkeys(); - void applyHotkeys(const QStringList& seqs); + PttHotkeyManager(); + ~PttHotkeyManager() override; + void poll(); + bool readKeys(PttKeyChord& held) const; + QString keyName(int key) const; +#ifdef UTUN_PTT_PORTAL + PttPortal* m_portal = nullptr; +#endif DbManager* m_db = nullptr; - QList m_hotkeys; - QStringList m_seqs; + QTimer m_timer; + void* m_display = nullptr; // собственное X11-соединение, только GUI-поток + QList m_chords; + PttKeyChord m_held; + bool m_capturing = false; + bool m_captureArmed = false; + bool m_talking = false; }; diff --git a/tools/chatgui/src/ptt_key_chord.h b/tools/chatgui/src/ptt_key_chord.h new file mode 100644 index 00000000..c623ae67 --- /dev/null +++ b/tools/chatgui/src/ptt_key_chord.h @@ -0,0 +1,39 @@ +#pragma once +#include +#include +#include +#include + +// Физические коды клавиш ОС: левый и правый модификаторы — отдельные элементы. +using PttKeyChord = QSet; +inline bool pttChordHeld(const PttKeyChord& chord, const PttKeyChord& held) { + return !chord.isEmpty() && (chord - held).isEmpty(); +} +inline QString pttEncodeChord(const PttKeyChord& chord) { + auto keys = chord.values(); + std::sort(keys.begin(), keys.end()); + QStringList values; + for (int key : keys) values << QString::number(key); + return values.join(','); +} +inline PttKeyChord pttDecodeChord(const QString& value) { + PttKeyChord chord; + if (value.isEmpty()) return chord; + for (const auto& part : value.split(',')) { + bool ok = false; + int key = part.toInt(&ok); + if (!ok || key < 8 || key > 255) return {}; + chord.insert(key); + } + return chord; +} + +// Сохраняем реально одновременное сочетание, а не объединение последовательных нажатий. +struct PttChordCapture { + PttKeyChord candidate; + bool observe(const PttKeyChord& held) { + if (held.isEmpty()) return !candidate.isEmpty(); + if (held.size() >= candidate.size()) candidate = held; + return false; + } +}; diff --git a/tools/chatgui/src/ptt_portal.cpp b/tools/chatgui/src/ptt_portal.cpp new file mode 100644 index 00000000..23646ebd --- /dev/null +++ b/tools/chatgui/src/ptt_portal.cpp @@ -0,0 +1,235 @@ +#include "ptt_portal.h" +#include "../../lib/debug_config.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +static const QString kService = "org.freedesktop.portal.Desktop"; +static const QString kPath = "/org/freedesktop/portal/desktop"; +static const QString kInterface = "org.freedesktop.portal.GlobalShortcuts"; +static const QString kRequestInterface = "org.freedesktop.portal.Request"; +static const QString kSessionInterface = "org.freedesktop.portal.Session"; + +QDBusArgument& operator<<(QDBusArgument& arg, const PttPortalShortcut& shortcut) { + arg.beginStructure(); arg << shortcut.id << shortcut.properties; arg.endStructure(); return arg; +} +const QDBusArgument& operator>>(const QDBusArgument& arg, PttPortalShortcut& shortcut) { + arg.beginStructure(); arg >> shortcut.id >> shortcut.properties; arg.endStructure(); return arg; +} + +PttPortal::PttPortal(QObject* parent, const QString& sessionToken) : QObject(parent), m_sessionToken(sessionToken) { + qDBusRegisterMetaType(); + qDBusRegisterMetaType(); + m_requestTimer.setSingleShot(true); + connect(&m_requestTimer, &QTimer::timeout, this, [this]() { fail("request timed out"); }); + auto* watcher = new QDBusServiceWatcher(kService, QDBusConnection::sessionBus(), QDBusServiceWatcher::WatchForUnregistration, this); + connect(watcher, &QDBusServiceWatcher::serviceUnregistered, this, [this]() { fail("portal service disappeared"); }); + auto bus = QDBusConnection::sessionBus(); + bool ok = bus.connect(kService, kPath, kInterface, "Activated", this, + SLOT(activated(QDBusObjectPath,QString,qulonglong,QVariantMap))); + ok &= bus.connect(kService, kPath, kInterface, "Deactivated", this, + SLOT(deactivated(QDBusObjectPath,QString,qulonglong,QVariantMap))); + ok &= bus.connect(kService, kPath, kInterface, "ShortcutsChanged", this, + SLOT(shortcutsChanged(QDBusObjectPath,PttPortalShortcuts))); + if (!ok) fail("cannot subscribe to portal signals"); +} + +PttPortal::~PttPortal() { closeSession(); } + +bool PttPortal::busy() const { + return m_state == State::Checking || m_state == State::Creating || m_state == State::Listing || m_state == State::Binding; +} +void PttPortal::setStatus(const QString& text) { m_status = text; emit statusChanged(); } + +void PttPortal::initialize() { + if (busy() || m_state == State::Ready || m_state == State::Unbound) return; + m_state = State::Checking; + setStatus("Подключение глобальных хоткеев…"); + auto message = QDBusMessage::createMethodCall(kService, kPath, "org.freedesktop.DBus.Properties", "Get"); + message.setArguments({kInterface, "version"}); + auto* watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), this); + uint epoch = m_epoch; + connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher, epoch]() { + QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + if (epoch != m_epoch || m_state != State::Checking) return; + if (reply.isError()) { fail(reply.error().message()); return; } + uint version = reply.value().variant().toUInt(); + if (version < 2) { fail(QString("GlobalShortcuts version %1; version 2 required").arg(version)); return; } + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT portal: connected version=%u", version); + m_state = State::Creating; + // Стабильный session token сохраняет идентичность обычного приложения в KDE без sandbox app_id. + request("CreateSession", {}, {{"session_handle_token", m_sessionToken}}); + }); +} + +void PttPortal::request(const QString& method, const QVariantList& arguments, QVariantMap options) { + auto bus = QDBusConnection::sessionBus(); + QString sender = bus.baseService().mid(1).replace('.', '_'); + QString token = "utun_ptt_" + QUuid::createUuid().toString(QUuid::Id128); + m_request = kPath + "/request/" + sender + "/" + token; + if (!bus.connect(kService, m_request, kRequestInterface, "Response", this, SLOT(response(uint,QVariantMap,QDBusMessage)))) { + fail("cannot subscribe to request response"); return; + } + options.insert("handle_token", token); + auto message = QDBusMessage::createMethodCall(kService, kPath, kInterface, method); + QVariantList args = arguments; args << options; message.setArguments(args); + QString expected = m_request; + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "PTT portal: %s request=%s session=%s", qPrintable(method), qPrintable(expected), qPrintable(m_session)); + // Binding может ждать действий пользователя; остальные операции ограничены 30 секундами. + if (m_state != State::Binding) m_requestTimer.start(30000); + auto* watcher = new QDBusPendingCallWatcher(bus.asyncCall(message), this); + connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher, expected]() { + QDBusPendingReply reply = *watcher; + watcher->deleteLater(); + if (m_request != expected) return; // Response уже обработан или сессия закрыта + if (reply.isError()) { fail(reply.error().message()); return; } + if (reply.value().path() != expected) fail("portal returned unexpected request path"); + }); +} + +void PttPortal::disconnectRequest() { + m_requestTimer.stop(); + if (m_request.isEmpty()) return; + QDBusConnection::sessionBus().disconnect(kService, m_request, kRequestInterface, "Response", this, SLOT(response(uint,QVariantMap,QDBusMessage))); + m_request.clear(); +} + +void PttPortal::response(uint code, const QVariantMap& results, const QDBusMessage& message) { + if (m_request.isEmpty() || message.path() != m_request) return; + State state = m_state; + disconnectRequest(); + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "PTT portal: response state=%d code=%u", int(state), code); + if (code != 0) { + fail(QString("request %1: response=%2%3").arg(int(state)).arg(code).arg(code == 1 ? " (cancelled)" : "")); + if (code == 1) setStatus("Назначение отменено. Нажмите кнопку, чтобы повторить."); + return; + } + if (state == State::Creating) { + m_session = results.value("session_handle").toString(); + if (!m_session.startsWith(kPath + "/session/")) { fail("invalid session handle"); return; } + if (!QDBusConnection::sessionBus().connect(kService, m_session, kSessionInterface, "Closed", this, SLOT(sessionClosed()))) { + fail("cannot subscribe to session closure"); return; + } + m_state = State::Listing; + request("ListShortcuts", {QVariant::fromValue(QDBusObjectPath(m_session))}, {}); + } else if (state == State::Listing) { + auto shortcuts = qdbus_cast(results.value("shortcuts")); + updateDescriptions(shortcuts); + QSet known; + for (const auto& shortcut : shortcuts) if (isPttId(shortcut.id)) known.insert(shortcut.id); + m_configureAfterBind = known.size() == 3 && m_configureRequested; + m_state = State::Unbound; + if (!known.isEmpty() || m_configureRequested) bind(); + else setStatus("Назначьте сочетание в системных настройках."); + } else if (state == State::Binding) { + m_state = State::Ready; + updateDescriptions(qdbus_cast(results.value("shortcuts"))); + setStatus("Глобальные хоткеи подключены."); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT portal: bindings ready session=%s", qPrintable(m_session)); + // Первое BindShortcuts само открывает окно назначения, повторное Configure — отдельный вызов. + m_configureRequested = false; + if (m_configureAfterBind) { m_configureAfterBind = false; openConfiguration(); } + } else fail("unexpected request response"); +} + +bool PttPortal::isPttId(const QString& id) { return id == "ptt1" || id == "ptt2" || id == "ptt3"; } + +void PttPortal::bind() { + PttPortalShortcuts shortcuts; + for (int i = 1; i <= 3; i++) shortcuts << PttPortalShortcut{QString("ptt%1").arg(i), {{"description", QString("Рация: передача %1").arg(i)}}}; + m_state = State::Binding; + setStatus("Назначение глобальных хоткеев…"); + request("BindShortcuts", {QVariant::fromValue(QDBusObjectPath(m_session)), QVariant::fromValue(shortcuts), m_parentWindow}, {}); +} + +void PttPortal::configure(const QString& parentWindow) { + m_parentWindow = parentWindow; + if (m_state == State::Ready) { openConfiguration(); return; } + m_configureRequested = true; + if (m_state == State::Unbound) bind(); + else initialize(); +} + +void PttPortal::openConfiguration() { + auto message = QDBusMessage::createMethodCall(kService, kPath, kInterface, "ConfigureShortcuts"); + message.setArguments({QVariant::fromValue(QDBusObjectPath(m_session)), m_parentWindow, QVariantMap{}}); + auto* watcher = new QDBusPendingCallWatcher(QDBusConnection::sessionBus().asyncCall(message), this); + uint epoch = m_epoch; + connect(watcher, &QDBusPendingCallWatcher::finished, this, [this, watcher, epoch]() { + QDBusPendingReply<> reply = *watcher; + watcher->deleteLater(); + if (epoch != m_epoch) return; + if (reply.isError()) { fail(reply.error().message()); return; } + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT portal: system configuration opened"); + }); +} + +void PttPortal::updateDescriptions(const PttPortalShortcuts& shortcuts) { + m_descriptions = {"", "", ""}; + for (const auto& shortcut : shortcuts) { + if (!isPttId(shortcut.id)) continue; + int index = shortcut.id.right(1).toInt() - 1; + m_descriptions[index] = shortcut.properties.value("trigger_description").toString(); + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "PTT portal: %s trigger=%s", qPrintable(shortcut.id), qPrintable(m_descriptions[index])); + } + emit descriptionsChanged(); +} + +void PttPortal::activated(const QDBusObjectPath& session, const QString& id, qulonglong, const QVariantMap&) { + if (session.path() != m_session || m_state != State::Ready || !isPttId(id) || m_inhibited) return; + m_active.insert(id); updateTalking(); +} +void PttPortal::deactivated(const QDBusObjectPath& session, const QString& id, qulonglong, const QVariantMap&) { + if (session.path() != m_session || !isPttId(id)) return; + m_active.remove(id); updateTalking(); +} +void PttPortal::shortcutsChanged(const QDBusObjectPath& session, const PttPortalShortcuts& shortcuts) { + if (session.path() != m_session) return; + m_active.clear(); updateTalking(); updateDescriptions(shortcuts); +} +void PttPortal::setInhibited(bool inhibited) { + m_inhibited = inhibited; + if (inhibited) { m_active.clear(); updateTalking(); } +} +void PttPortal::updateTalking() { + bool talking = !m_inhibited && !m_active.isEmpty(); + if (m_talking == talking) return; + m_talking = talking; + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "PTT portal: transmission %s active_shortcuts=%d", talking ? "started" : "stopped", int(m_active.size())); + emit talkingChanged(talking); +} + +void PttPortal::closeSession() { + m_epoch++; + auto bus = QDBusConnection::sessionBus(); + if (!m_request.isEmpty()) { + auto close = QDBusMessage::createMethodCall(kService, m_request, kRequestInterface, "Close"); + close.setAutoStartService(false); + bus.asyncCall(close); + disconnectRequest(); + } + if (!m_session.isEmpty()) { + bus.disconnect(kService, m_session, kSessionInterface, "Closed", this, SLOT(sessionClosed())); + auto close = QDBusMessage::createMethodCall(kService, m_session, kSessionInterface, "Close"); + close.setAutoStartService(false); + bus.asyncCall(close); + m_session.clear(); + } +} +void PttPortal::fail(const QString& detail) { + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "PTT portal: %s", qPrintable(detail)); + closeSession(); + m_active.clear(); updateTalking(); + m_configureRequested = m_configureAfterBind = false; + m_state = State::Failed; + m_descriptions = {"", "", ""}; emit descriptionsChanged(); + setStatus("Глобальные хоткеи недоступны. Повторите назначение; подробности в логе radio."); +} +void PttPortal::sessionClosed() { fail("portal session closed"); } diff --git a/tools/chatgui/src/ptt_portal.h b/tools/chatgui/src/ptt_portal.h new file mode 100644 index 00000000..29cdf873 --- /dev/null +++ b/tools/chatgui/src/ptt_portal.h @@ -0,0 +1,65 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include + +struct PttPortalShortcut { QString id; QVariantMap properties; }; +using PttPortalShortcuts = QList; +Q_DECLARE_METATYPE(PttPortalShortcut) +Q_DECLARE_METATYPE(PttPortalShortcuts) +QDBusArgument& operator<<(QDBusArgument& arg, const PttPortalShortcut& shortcut); +const QDBusArgument& operator>>(const QDBusArgument& arg, PttPortalShortcut& shortcut); + +// Wayland PTT: портал владеет сочетаниями и хранит их; приложение владеет сессией. +// Один запрос за раз. Подписка на Response устанавливается до вызова метода. +class PttPortal : public QObject { + Q_OBJECT +public: + explicit PttPortal(QObject* parent = nullptr, const QString& sessionToken = "utun_vibechat_ptt"); + ~PttPortal() override; + void initialize(); + void configure(const QString& parentWindow); + void setInhibited(bool inhibited); + QStringList descriptions() const { return m_descriptions; } + QString status() const { return m_status; } + bool busy() const; + +signals: + void descriptionsChanged(); + void statusChanged(); + void talkingChanged(bool talking); + +private slots: + void response(uint code, const QVariantMap& results, const QDBusMessage& message); + void activated(const QDBusObjectPath& session, const QString& id, qulonglong timestamp, const QVariantMap& options); + void deactivated(const QDBusObjectPath& session, const QString& id, qulonglong timestamp, const QVariantMap& options); + void shortcutsChanged(const QDBusObjectPath& session, const PttPortalShortcuts& shortcuts); + void sessionClosed(); + +private: + enum class State { Idle, Checking, Creating, Listing, Unbound, Binding, Ready, Failed }; + void request(const QString& method, const QVariantList& arguments, QVariantMap options); + void bind(); + void openConfiguration(); + void updateDescriptions(const PttPortalShortcuts& shortcuts); + void updateTalking(); + void setStatus(const QString& text); + void fail(const QString& detail); + void disconnectRequest(); + void closeSession(); + static bool isPttId(const QString& id); + + uint m_epoch = 0; + bool m_configureAfterBind = false; + State m_state = State::Idle; + QString m_sessionToken, m_session, m_request, m_parentWindow, m_status; + QStringList m_descriptions = {"", "", ""}; + QSet m_active; + QTimer m_requestTimer; + bool m_configureRequested = false, m_inhibited = false, m_talking = false; +}; diff --git a/tools/chatgui/src/radio_audio_engine.cpp b/tools/chatgui/src/radio_audio_engine.cpp index 93049c3b..2bcbe172 100644 --- a/tools/chatgui/src/radio_audio_engine.cpp +++ b/tools/chatgui/src/radio_audio_engine.cpp @@ -153,6 +153,7 @@ int RadioAudioEngine::initDevice() { void RadioAudioEngine::stop() { if (!m_active && !m_captureDevice && !m_playbackDevice && !m_captureStarted && !m_playbackStarted) return; quint64 gid = m_groupId.load(); + m_buttonTalking = m_hotkeyTalking = false; bool wasTalking = m_talking.exchange(false); if (wasTalking) radio_audio_talk_end(gid); /* честный FIN: пиры дренируют звук, а не ждут таймаут */ m_active = false; @@ -161,19 +162,34 @@ void RadioAudioEngine::stop() { radio_audio_stop(); m_groupId = 0; + if (wasTalking) emit talkingChanged(false); DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "RadioAudio: stopped"); } void RadioAudioEngine::talkBegin() { if (!m_active) return; - m_talking = true; - radio_audio_talk_begin(m_groupId.load()); + m_buttonTalking = true; + updateTalking(); } void RadioAudioEngine::talkEnd() { - if (!m_active) return; - m_talking = false; - radio_audio_talk_end(m_groupId.load()); + m_buttonTalking = false; + updateTalking(); +} + +void RadioAudioEngine::setHotkeyTalking(bool pressed) { + m_hotkeyTalking = pressed && m_active; + updateTalking(); +} + +void RadioAudioEngine::updateTalking() { + bool talking = m_active && (m_buttonTalking || m_hotkeyTalking); + if (m_talking.exchange(talking) == talking || !m_active) return; + if (talking) radio_audio_talk_begin(m_groupId.load()); + else radio_audio_talk_end(m_groupId.load()); + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "RadioAudio: PTT %s group=%016llx button=%d hotkey=%d", + talking ? "started" : "stopped", (unsigned long long)m_groupId.load(), m_buttonTalking, m_hotkeyTalking); + emit talkingChanged(talking); } void RadioAudioEngine::teardownDevice() { diff --git a/tools/chatgui/src/radio_audio_engine.h b/tools/chatgui/src/radio_audio_engine.h index 9c4e6dd2..06309f17 100644 --- a/tools/chatgui/src/radio_audio_engine.h +++ b/tools/chatgui/src/radio_audio_engine.h @@ -29,7 +29,9 @@ public: /* PTT: зажата / отпущена (только когда радио канала включено). */ void talkBegin(); void talkEnd(); + void setHotkeyTalking(bool pressed); // независимый владелец PTT — глобальная клавиатура + bool isTalking() const { return m_talking.load(); } bool isActive() const { return m_active.load(); } quint64 groupId() const { return m_groupId.load(); } @@ -37,8 +39,13 @@ public: void captureCallback(const void* in, unsigned int frames); void playbackCallback(void* out, unsigned int frames); +signals: + void talkingChanged(bool talking); // GUI-поток, суммарное состояние кнопки и хоткея + private: RadioAudioEngine() = default; + void updateTalking(); + bool m_buttonTalking = false, m_hotkeyTalking = false; // GUI-поток void teardownDevice(); int initDevice(); diff --git a/tools/chatgui/src/radiosettingspage.cpp b/tools/chatgui/src/radiosettingspage.cpp index 84190dd7..6682ca06 100644 --- a/tools/chatgui/src/radiosettingspage.cpp +++ b/tools/chatgui/src/radiosettingspage.cpp @@ -1,68 +1,177 @@ #include "radiosettingspage.h" #include "ptt_hotkey_manager.h" #include "../db/db_manager.h" +#include "../../lib/debug_config.h" -#include +#include +#include +#include #include -#include +#include +#include +#include -RadioSettingsPage::RadioSettingsPage(DbManager* db, QWidget* parent) - : QWidget(parent), m_db(db) { +RadioSettingsPage::RadioSettingsPage(DbManager* db, QWidget* parent) : QWidget(parent), m_db(db) { auto* layout = new QVBoxLayout(this); - auto* title = new QLabel("Рация (PTT)", this); title->setStyleSheet("font-weight: bold; font-size: 13px;"); layout->addWidget(title); - + if (PttHotkeyManager::usesPortal()) { + auto* hint = new QLabel("Глобальные хоткеи работают из любых приложений. " + "Назначьте обычную клавишу с Ctrl, Shift, Alt или Win в системном окне. " + "Изменения сохраняются системой сразу. Во время настройки передача с клавиатуры отключена.", this); + hint->setWordWrap(true); + layout->addWidget(hint); + auto* button = new QPushButton("Задать PTT хоткеи…", this); + layout->addWidget(button); + m_buttons << button; + for (int i = 0; i < 3; i++) { + auto* label = new QLabel(this); + label->setWordWrap(true); label->setTextFormat(Qt::PlainText); + layout->addWidget(label); m_labels << label; + } + m_portalStatus = new QLabel(this); + m_portalStatus->setWordWrap(true); + m_portalStatus->setTextFormat(Qt::PlainText); + m_portalStatus->setStyleSheet("color: gray; font-size: 11px;"); + layout->addWidget(m_portalStatus); + connect(button, &QPushButton::clicked, this, [this]() { PttHotkeyManager::instance()->configurePortal(this); }); + connect(PttHotkeyManager::instance(), &PttHotkeyManager::portalChanged, this, &RadioSettingsPage::updatePortalLabels); + updatePortalLabels(); + layout->addStretch(1); + return; + } auto* hint = new QLabel( - "Глобальные хоткеи передачи: удержание любой комбинации активирует PTT " - "из любого приложения (рация канала должна быть включена).", this); + "Удерживайте сочетание для передачи из любого приложения — даже когда ChatGUI не в фокусе. " + "Рация канала должна быть включена. Для записи зажмите нужные клавиши вместе и отпустите их. " + "Левые и правые Ctrl, Shift, Alt, Win различаются. Fn доступна, только если клавиатура передаёт её ОС.", this); hint->setWordWrap(true); hint->setStyleSheet("color: gray; font-size: 11px;"); layout->addWidget(hint); - if (!PttHotkeyManager::isSupported()) { - auto* warn = new QLabel("Глобальные хоткеи не поддерживаются на этой платформе.", this); + auto* warn = new QLabel("Глобальные хоткеи доступны на Windows и Linux/X11. На этой платформе запись недоступна.", this); + warn->setWordWrap(true); warn->setStyleSheet("color: #e74c3c; font-size: 12px;"); layout->addWidget(warn); } - - auto* form = new QFormLayout(); for (int i = 0; i < 3; i++) { - auto* edit = new QKeySequenceEdit(this); - edit->setStyleSheet( - "QKeySequenceEdit { border: 1px solid palette(mid); border-radius: 4px;" - " padding: 6px 10px; font-size: 13px; background: palette(base); }"); - m_edits.append(edit); - form->addRow(QString("PTT хоткей %1:").arg(i + 1), edit); + auto* row = new QHBoxLayout(); + auto* button = new QPushButton(QString("Задать PTT хоткей %1").arg(i + 1), this); + button->setEnabled(PttHotkeyManager::isSupported()); + auto* clear = new QPushButton("Очистить", this); + row->addWidget(button); row->addWidget(clear); + layout->addLayout(row); + auto* label = new QLabel(this); + label->setWordWrap(true); + label->setTextFormat(Qt::PlainText); + label->setStyleSheet("font-size: 13px; padding-bottom: 12px;"); + layout->addWidget(label); + m_buttons << button; m_labels << label; m_chords << PttKeyChord(); + connect(button, &QPushButton::clicked, this, [this, i]() { + if (m_captureIndex == i) finishCapture(false); + else startCapture(i); + }); + connect(clear, &QPushButton::clicked, this, [this, i]() { + finishCapture(false); + m_chords[i].clear(); updateLabel(i); + }); } - layout->addLayout(form); + connect(PttHotkeyManager::instance(), &PttHotkeyManager::keysChanged, this, [this](const PttKeyChord& held) { + if (m_captureIndex < 0) return; + if (m_capture.observe(held)) finishCapture(true); + else if (!held.isEmpty()) + m_labels[m_captureIndex]->setText(PttHotkeyManager::instance()->describe(m_capture.candidate)); + }); layout->addStretch(1); - loadFromDb(); } +RadioSettingsPage::~RadioSettingsPage() { + finishCapture(false); + if (m_portalStatus && isVisible()) PttHotkeyManager::instance()->inhibitPortal(false); +} + +void RadioSettingsPage::startCapture(int index) { + finishCapture(false); + if (!PttHotkeyManager::instance()->beginCapture()) { + m_labels[index]->setText("Не удалось начать запись клавиш. Подробности в логе radio."); + return; + } + m_captureIndex = index; + m_capture.candidate.clear(); + m_buttons[index]->setText("Отменить запись"); + m_labels[index]->setText("Нажмите одну или несколько клавиш вместе…"); + qApp->installEventFilter(this); +} + +void RadioSettingsPage::finishCapture(bool save) { + if (m_captureIndex < 0) return; + int index = m_captureIndex; + if (save) { + m_chords[index] = m_capture.candidate; + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "PTT: captured chord=%s", qPrintable(PttHotkeyManager::instance()->describe(m_capture.candidate))); + } + m_captureIndex = -1; + qApp->removeEventFilter(this); + PttHotkeyManager::instance()->endCapture(); + m_buttons[index]->setText(QString("Задать PTT хоткей %1").arg(index + 1)); + updateLabel(index); +} + +bool RadioSettingsPage::eventFilter(QObject* object, QEvent* event) { + if (m_captureIndex >= 0) { + if (event->type() == QEvent::ShortcutOverride) { event->accept(); return true; } + if (event->type() == QEvent::KeyPress || event->type() == QEvent::KeyRelease) return true; + } + return QWidget::eventFilter(object, event); +} + +void RadioSettingsPage::showEvent(QShowEvent* event) { + if (m_portalStatus) PttHotkeyManager::instance()->inhibitPortal(true); + QWidget::showEvent(event); +} + +void RadioSettingsPage::hideEvent(QHideEvent* event) { + if (m_portalStatus) PttHotkeyManager::instance()->inhibitPortal(false); + finishCapture(false); + QWidget::hideEvent(event); +} + +void RadioSettingsPage::updateLabel(int index) { + m_labels[index]->setText(PttHotkeyManager::instance()->describe(m_chords[index])); +} + void RadioSettingsPage::loadFromDb() { - QStringList seqs; - if (m_db && m_db->isOpen()) { - const QString raw = m_db->getUiState("radio_ptt_hotkeys"); - if (!raw.isEmpty()) - seqs = raw.split('|', Qt::SkipEmptyParts); + if (m_portalStatus) { updatePortalLabels(); return; } + finishCapture(false); + QStringList values; + if (m_db && m_db->isOpen()) values = m_db->getUiState("radio_ptt_hotkeys").split('|', Qt::KeepEmptyParts); + for (int i = 0; i < m_chords.size(); i++) { + m_chords[i] = i < values.size() ? pttDecodeChord(values[i]) : PttKeyChord(); + updateLabel(i); } - for (int i = 0; i < m_edits.size(); i++) - m_edits[i]->setKeySequence(i < seqs.size() ? QKeySequence(seqs[i]) : QKeySequence()); } QStringList RadioSettingsPage::hotkeys() const { - QStringList out; - for (QKeySequenceEdit* e : m_edits) { - const QKeySequence seq = e->keySequence(); - if (!seq.isEmpty()) - out << seq.toString(); - } - return out; + QStringList values; + // Пустые позиции тоже сохраняем, чтобы хоткей №3 не переезжал в №1 после открытия. + for (const auto& chord : m_chords) values << pttEncodeChord(chord); + return values; } void RadioSettingsPage::apply() { + if (m_portalStatus) return; + finishCapture(false); PttHotkeyManager::instance()->setHotkeys(hotkeys()); } + +void RadioSettingsPage::updatePortalLabels() { + auto* manager = PttHotkeyManager::instance(); + const auto descriptions = manager->portalDescriptions(); + for (int i = 0; i < m_labels.size(); i++) { + QString text = descriptions.value(i); + m_labels[i]->setText(QString("PTT хоткей %1: %2").arg(i + 1).arg(text.isEmpty() ? "Не задано" : text)); + } + m_portalStatus->setText(manager->portalStatus()); + m_buttons[0]->setEnabled(!manager->portalBusy()); +} diff --git a/tools/chatgui/src/radiosettingspage.h b/tools/chatgui/src/radiosettingspage.h index 2d7310eb..59a44a4b 100644 --- a/tools/chatgui/src/radiosettingspage.h +++ b/tools/chatgui/src/radiosettingspage.h @@ -1,29 +1,40 @@ #pragma once +#include "ptt_key_chord.h" #include -#include #include -class QKeySequenceEdit; class DbManager; +class QPushButton; +class QLabel; +class QHideEvent; +class QShowEvent; -/* Страница настроек рации: глобальные PTT-хоткеи (до 3 комбинаций). - * Удержание любой из них активирует передачу из любого приложения. */ +// Запись глобальных сочетаний PTT, включая отдельные левые/правые модификаторы. class RadioSettingsPage : public QWidget { Q_OBJECT public: explicit RadioSettingsPage(DbManager* db, QWidget* parent = nullptr); - - /* Загрузить хоткеи из ui_state в поля ввода. */ + ~RadioSettingsPage() override; void loadFromDb(); - - /* Список непустых QKeySequence-строк из полей. */ QStringList hotkeys() const; - - /* Применить текущие хоткеи (без записи в БД). */ void apply(); +protected: + bool eventFilter(QObject* object, QEvent* event) override; + void showEvent(QShowEvent* event) override; + void hideEvent(QHideEvent* event) override; + private: + void updatePortalLabels(); + QLabel* m_portalStatus = nullptr; + void startCapture(int index); + void finishCapture(bool save); + void updateLabel(int index); DbManager* m_db; - QVector m_edits; + QVector m_chords; + QVector m_buttons; + QVector m_labels; + PttChordCapture m_capture; + int m_captureIndex = -1; }; diff --git a/tools/chatgui/src/settingsdialog.cpp b/tools/chatgui/src/settingsdialog.cpp index 5d3197ea..c21d96dc 100644 --- a/tools/chatgui/src/settingsdialog.cpp +++ b/tools/chatgui/src/settingsdialog.cpp @@ -8,6 +8,7 @@ #include "audiodevicesettingspage.h" #include "storagesettingspage.h" #include "radiosettingspage.h" +#include "ptt_hotkey_manager.h" #include "connmonitorwindow.h" #include "../transport/gui_bridge.h" #include "../../lib/mem.h" @@ -56,6 +57,7 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod m_categoryList->addItem("Nodes"); m_categoryList->addItem("Sound"); m_categoryList->addItem("Audio"); + m_categoryList->addItem("Рация"); mainLayout->addWidget(m_categoryList); // --- Right panel: stacked pages --- @@ -295,8 +297,8 @@ void SettingsDialog::onSave() { m_storagePage->applyAndSave(); m_radioPage->apply(); - /* save radio PTT hotkeys to ui_state */ - { + /* Физические сочетания сохраняем в БД; Wayland-хоткеями владеет системный портал. */ + if (!PttHotkeyManager::usesPortal()) { const char* key = "radio_ptt_hotkeys"; QByteArray val = m_radioPage->hotkeys().join('|').toUtf8(); size_t klen = strlen(key), vlen = (size_t)val.size(); diff --git a/tools/chatgui/tests/test_call_jitter.cpp b/tools/chatgui/tests/test_call_jitter.cpp new file mode 100644 index 00000000..db91521b --- /dev/null +++ b/tools/chatgui/tests/test_call_jitter.cpp @@ -0,0 +1,151 @@ +// Сохранность FIFO, десятисекундный burst, догон при продолжающемся входе и повторное накопление. +#include "call/call_jitter.h" +#include "call/call_jitter_window.h" +#include "call/call_audio.h" +#include "../../../lib/opus_codec.h" +#include "../../../lib/debug_config.h" +#include +#include +#include +#include +#include +#include + +static int failures; +#define CHECK(c) do { if (!(c)) { std::printf("FAIL line %d: %s\n", __LINE__, #c); failures++; } } while (0) +struct decoder { std::vector seen; }; +static int decode(void* arg, const uint8_t* enc, int len, int16_t* pcm, int cap) { + auto* d = static_cast(arg); + int id = -1; + if (len == sizeof(id)) std::memcpy(&id, enc, sizeof(id)); + d->seen.push_back(id); + for (int i = 0; i < cap; i++) pcm[i] = (int16_t)(8000 * std::sin(2 * 3.141592653589793 * 440 * i / 48000)); + return cap; +} +static void push(call_jitter* j, int id) { call_jitter_push(j, (const uint8_t*)&id, sizeof(id)); } +int main() { + { + call_jitter_window w; + w.observe(0, 100); w.observe(500, 400); + CHECK(w.low == 100 && w.high == 400 && w.reserve == 300); + w.observe(1000, 2000); CHECK(w.reserve == 1000); + w.observe(10000, 2000); CHECK(w.low == 2000 && w.reserve == 60); + w.observe(30000, 80); CHECK(w.low == 80 && w.high == 80 && w.reserve == 60); + for (int cap : {500, 2000}) { + call_jitter_window limited; + limited.max_reserve = cap; + limited.observe(0, 0); limited.observe(500, 5000); + CHECK(limited.reserve == cap); + } + } + debug_set_level(DEBUG_LEVEL_WARN); + int16_t pcm[480]; + int depth, tempo; + uint32_t dropped, under; + { + decoder d; + auto* j = call_jitter_create(decode, &d, 1000); + CHECK(j); + push(j, 0); push(j, 1); + CHECK(call_jitter_pull(j, pcm, 480) == 0); + push(j, 2); + CHECK(call_jitter_pull(j, pcm, 480) > 0); + for (int i = 3; i < 503; i++) push(j, i); // 10с накопления, без удаления существовавшего хвоста + int next = 503, highest = 100; + for (int tick = 0; tick < 6000; tick++) { + if (tick % 2 == 0) push(j, next++); // непрерывный live: кадр каждые 20мс + call_jitter_pull(j, pcm, 480); + call_jitter_get_stats(j, &depth, &tempo, &dropped, &under, nullptr); + if (tempo > highest) highest = tempo; + } + call_jitter_range range; + call_jitter_get_stats(j, nullptr, nullptr, nullptr, nullptr, &range); + CHECK(range.reserve_ms == 1000 && range.max_ms >= 10000); + CHECK(range.min_ms <= range.max_ms); + CHECK(highest >= 150); + CHECK(depth < 1200); + CHECK(tempo <= 110); + CHECK(dropped == 0); + for (size_t i = 0; i < d.seen.size(); i++) CHECK(d.seen[i] == (int)i); + for (int i = 0; i < 100; i++) call_jitter_pull(j, pcm, 480); + size_t before = d.seen.size(); + push(j, next++); push(j, next++); + CHECK(call_jitter_pull(j, pcm, 480) == 0); + CHECK(d.seen.size() == before); + for (int i = 0; i < 50; i++) push(j, next++); + CHECK(call_jitter_pull(j, pcm, 480) > 0); + call_jitter_destroy(j); + } + { + decoder d; + auto* j = call_jitter_create(decode, &d, 1000); + for (int i = 0; i < CALL_JITTER_MAX_FRAMES; i++) push(j, i); + call_jitter_get_stats(j, &depth, nullptr, &dropped, nullptr, nullptr); + CHECK(depth == 15000 && dropped == 0); + push(j, CALL_JITTER_MAX_FRAMES); + call_jitter_get_stats(j, &depth, nullptr, &dropped, nullptr, nullptr); + CHECK(depth == 15000 && dropped == 1); + CHECK(call_jitter_pull(j, pcm, 480) > 0); + CHECK(d.seen.front() == 1); + call_jitter_destroy(j); + } + for (int samples : {240, 480, 960}) { + decoder d; + auto* j = call_jitter_create(decode, &d, 1000); + for (int i = 0; i < 500; i++) push(j, i); + std::vector output(samples); + int elapsed = 0, previous = 100; + for (; elapsed < 500; ) { + CHECK(call_jitter_pull(j, output.data(), samples) == samples); + elapsed += samples * 1000 / 48000; + call_jitter_get_stats(j, nullptr, &tempo, nullptr, nullptr, nullptr); + int expected = (int)std::lround(100 + 60.0 * elapsed / 500); + CHECK(std::abs(tempo - expected) <= 1); + previous = tempo; + } + CHECK(tempo == 160); + bool slowing = false; + for (int tick = 0; tick < 3000 * 960 / samples; tick++) { + call_jitter_pull(j, output.data(), samples); + call_jitter_get_stats(j, &depth, &tempo, &dropped, nullptr, nullptr); + int max_step = (int)std::ceil(120.0 * samples / 48000); + CHECK(std::abs(tempo - previous) <= max_step); + if (tempo < previous) slowing = true; + previous = tempo; + } + CHECK(slowing && tempo <= 110 && dropped == 0); + call_jitter_destroy(j); + } + { + const uint64_t id = 123; + CHECK(call_audio_start(id) == 0); + auto* encoder = opus_codec_encoder_create(48000, 1); + CHECK(encoder); + int16_t voice[960]; + for (int i = 0; i < 960; i++) voice[i] = (int16_t)(8000 * std::sin(2 * 3.141592653589793 * 440 * i / 48000)); + uint8_t encoded[512]; + int len = opus_codec_encode(encoder, voice, 960, encoded, sizeof(encoded)); + CHECK(len > 0); + for (int i = 0; i < 3; i++) call_audio_on_media(nullptr, id, i, 0, encoded, len); + for (int i = 0; i < 30; i++) call_audio_pull_pcm(id, pcm, 480); + // Аудиоустройства и uasync в тесте нет: проверяем реальный срок включения сигнала. + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + call_audio_pull_pcm(id, pcm, 480); + bool silent = true; + for (auto value : pcm) if (value) silent = false; + CHECK(silent); + std::this_thread::sleep_for(std::chrono::milliseconds(500)); + call_audio_pull_pcm(id, pcm, 480); + silent = true; + for (auto value : pcm) if (value) silent = false; + CHECK(!silent); + for (int i = 0; i < 6; i++) call_audio_on_media(nullptr, id, i+3, 0, encoded, len); + CHECK(call_audio_pull_pcm(id, pcm, 480) == 480); + call_audio_begin_end(id); // явное завершение прекращает догон и включает финальный тон + CHECK(call_audio_pull_pcm(id, pcm, 480) > 0); + call_audio_release(id); + opus_codec_encoder_destroy(encoder); + } + std::printf("call_jitter: %s\n", failures ? "FAILED" : "PASSED"); + return failures ? 1 : 0; +} diff --git a/tools/chatgui/tests/test_ptt_key_chord.cpp b/tools/chatgui/tests/test_ptt_key_chord.cpp new file mode 100644 index 00000000..212d3c86 --- /dev/null +++ b/tools/chatgui/tests/test_ptt_key_chord.cpp @@ -0,0 +1,40 @@ +#include "../src/ptt_key_chord.h" +#include + +static int failures; +#define CHECK(value) do { if (!(value)) { std::printf("FAIL line %d: %s\n", __LINE__, #value); failures++; } } while (0) +int main() { + // Физические CtrlL/WinL и ShiftL/ShiftR на Windows; алгоритм не зависит от платформы. + PttKeyChord ctrlWin{162, 91}, shifts{160, 161}; + CHECK(!pttChordHeld({}, {})); + CHECK(!pttChordHeld(ctrlWin, {162})); + CHECK(pttChordHeld(ctrlWin, {162, 91})); + CHECK(pttChordHeld(ctrlWin, {162, 91, 65})); + CHECK(!pttChordHeld(ctrlWin, {91})); // отпускание любого участника прекращает передачу + CHECK(!pttChordHeld(shifts, {160})); + CHECK(!pttChordHeld(shifts, {161})); + CHECK(pttChordHeld(shifts, {160, 161})); + CHECK(!pttChordHeld(ctrlWin, {163, 92})); // правые клавиши не подменяют левые + CHECK(pttChordHeld({65, 66}, {65, 66})); // две обычные клавиши + CHECK(pttDecodeChord(pttEncodeChord(shifts)) == shifts); + CHECK(pttEncodeChord(ctrlWin) == "91,162"); + CHECK(pttDecodeChord("0,162").isEmpty()); + CHECK(pttDecodeChord("256").isEmpty()); + CHECK(pttDecodeChord("Shift+F8").isEmpty()); + CHECK(pttDecodeChord("91,invalid").isEmpty()); + CHECK(pttDecodeChord("91,162,91") == ctrlWin); + PttChordCapture capture; + CHECK(!capture.observe({})); + CHECK(!capture.observe({160})); + CHECK(!capture.observe(shifts)); + CHECK(!capture.observe({161})); // отпускание первой клавиши сохраняет полную комбинацию + CHECK(capture.observe({})); + CHECK(capture.candidate == shifts); + capture = {}; + CHECK(!capture.observe({65})); + CHECK(!capture.observe({66})); + CHECK(capture.observe({})); + CHECK(capture.candidate == PttKeyChord{66}); // последовательные A/B не превращаются в A+B + std::printf("PTT key chords: %s\n", failures ? "FAILED" : "PASSED"); + return failures ? 1 : 0; +} diff --git a/tools/chatgui/tests/test_ptt_portal.cpp b/tools/chatgui/tests/test_ptt_portal.cpp new file mode 100644 index 00000000..6aa3cbff --- /dev/null +++ b/tools/chatgui/tests/test_ptt_portal.cpp @@ -0,0 +1,167 @@ +#include "../src/ptt_portal.h" +#include "../../../lib/debug_config.h" +#include +#include +#include +#include +#include +#include + +static const QString service = "org.freedesktop.portal.Desktop"; +static const QString root = "/org/freedesktop/portal/desktop"; + +class FakeRequest : public QObject { + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.freedesktop.portal.Request") +public: + using QObject::QObject; +public slots: + void Close() {} +signals: + void Response(uint code, const QVariantMap& results); +}; +class FakeSession : public QObject { + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.freedesktop.portal.Session") +public: + using QObject::QObject; +public slots: + void Close() { emit Closed({}); } +signals: + void Closed(const QVariantMap& details); +}; +class FakePortal : public QObject, protected QDBusContext { + Q_OBJECT + Q_CLASSINFO("D-Bus Interface", "org.freedesktop.portal.GlobalShortcuts") + Q_PROPERTY(uint version READ version) +public: + uint version() const { return 2; } + uint bindResponse = 0; + int bindCount = 0, configureCount = 0; + QString session; + PttPortalShortcuts shortcuts; + FakeSession* sessionObject = nullptr; + QDBusObjectPath answer(const QVariantMap& options, uint code, const QVariantMap& results) { + QString sender = message().service().mid(1).replace('.', '_'); + QString path = root + "/request/" + sender + "/" + options.value("handle_token").toString(); + auto* request = new FakeRequest(this); + QDBusConnection::sessionBus().registerObject(path, request, QDBusConnection::ExportAllSlots | QDBusConnection::ExportAllSignals); + QTimer::singleShot(0, request, [request, code, results]() { emit request->Response(code, results); }); + return QDBusObjectPath(path); + } +public slots: + QDBusObjectPath CreateSession(const QVariantMap& options) { + QString sender = message().service().mid(1).replace('.', '_'); + session = root + "/session/" + sender + "/" + options.value("session_handle_token").toString(); + QDBusConnection::sessionBus().unregisterObject(session); + sessionObject = new FakeSession(this); + QDBusConnection::sessionBus().registerObject(session, sessionObject, QDBusConnection::ExportAllSlots | QDBusConnection::ExportAllSignals); + return answer(options, 0, {{"session_handle", session}}); + } + QDBusObjectPath ListShortcuts(const QDBusObjectPath&, const QVariantMap& options) { + return answer(options, 0, {{"shortcuts", QVariant::fromValue(shortcuts)}}); + } + QDBusObjectPath BindShortcuts(const QDBusObjectPath&, const PttPortalShortcuts& requested, + const QString&, const QVariantMap& options) { + bindCount++; + if (bindResponse == 0) { + shortcuts = requested; + for (int i = 0; i < shortcuts.size(); i++) shortcuts[i].properties["trigger_description"] = QString("F%1").arg(8 + i); + } + return answer(options, bindResponse, {{"shortcuts", QVariant::fromValue(shortcuts)}}); + } + void ConfigureShortcuts(const QDBusObjectPath&, const QString&, const QVariantMap&) { configureCount++; } +signals: + void Activated(const QDBusObjectPath& session, const QString& id, qulonglong timestamp, const QVariantMap& options); + void Deactivated(const QDBusObjectPath& session, const QString& id, qulonglong timestamp, const QVariantMap& options); + void ShortcutsChanged(const QDBusObjectPath& session, const PttPortalShortcuts& shortcuts); +}; + +class PortalTest : public QObject { + Q_OBJECT +private slots: + void lifecycle() { + qDBusRegisterMetaType(); qDBusRegisterMetaType(); + FakePortal fake; + auto bus = QDBusConnection::sessionBus(); + QVERIFY(bus.registerService(service)); + QVERIFY(bus.registerObject(root, &fake, QDBusConnection::ExportAllSlots | QDBusConnection::ExportAllSignals | QDBusConnection::ExportAllProperties)); + { + PttPortal portal(nullptr, "utun_ptt_test"); + QSignalSpy talking(&portal, &PttPortal::talkingChanged); + portal.initialize(); + QTRY_VERIFY(!portal.busy()); + QVERIFY(!fake.session.isEmpty()); + QCOMPARE(fake.bindCount, 0); // при первом запуске не открываем системный диалог + portal.configure("x11:123"); + QTRY_COMPARE(fake.bindCount, 1); + QTRY_COMPARE(portal.descriptions().value(0), QString("F8")); + QTRY_VERIFY(!portal.busy()); + QDBusObjectPath session(fake.session); + emit fake.Activated(QDBusObjectPath("/unrelated"), "ptt1", 0, {}); + emit fake.Activated(session, "other_action", 0, {}); + emit fake.Activated(session, "ptt1", 1, {}); + QTRY_COMPARE(talking.size(), 1); + QCOMPARE(talking.last()[0].toBool(), true); + emit fake.Activated(session, "ptt1", 2, {}); // повторное событие не создаёт второй start + emit fake.Activated(session, "ptt2", 3, {}); + emit fake.Deactivated(session, "ptt1", 4, {}); + emit fake.Deactivated(session, "ptt2", 5, {}); + QTRY_COMPARE(talking.size(), 2); + QCOMPARE(talking.last()[0].toBool(), false); // отпускание одного из двух хоткеев не останавливает второй + portal.setInhibited(true); + emit fake.Activated(session, "ptt1", 6, {}); + emit fake.Deactivated(session, "ptt1", 7, {}); + QTest::qWait(20); + QCOMPARE(talking.size(), 2); + portal.setInhibited(false); + portal.configure("x11:123"); + QTRY_COMPARE(fake.configureCount, 1); // изменение через Configure, без повторного Bind + fake.shortcuts[0].properties["trigger_description"] = "Ctrl+F8"; + emit fake.ShortcutsChanged(session, fake.shortcuts); + QTRY_COMPARE(portal.descriptions().value(0), QString("Ctrl+F8")); + emit fake.Activated(session, "ptt1", 8, {}); + QTRY_COMPARE(talking.size(), 3); + emit fake.sessionObject->Closed({}); + QTRY_COMPARE(talking.size(), 4); + QCOMPARE(talking.last()[0].toBool(), false); + QVERIFY(portal.descriptions()[0].isEmpty()); + fake.bindResponse = 1; + portal.configure(""); + QTRY_COMPARE(fake.bindCount, 2); + QTRY_VERIFY(!portal.busy()); + QVERIFY(portal.descriptions()[0].isEmpty()); // отмена не оставляет активную сессию + fake.bindResponse = 0; + portal.configure(""); + QTRY_COMPARE(fake.bindCount, 3); + QTRY_COMPARE(fake.configureCount, 2); // сохранённые сочетания восстанавливаются, затем открывается настройка + QTRY_COMPARE(portal.descriptions().value(0), QString("F8")); + emit fake.Activated(QDBusObjectPath(fake.session), "ptt3", 9, {}); + QTRY_COMPARE(talking.size(), 5); + QVERIFY(bus.unregisterService(service)); + QTRY_COMPARE(talking.size(), 6); + QCOMPARE(talking.last()[0].toBool(), false); // потеря сервиса не оставляет передачу включённой + } + bus.unregisterObject(root, QDBusConnection::UnregisterTree); + } +}; + +int main(int argc, char** argv) { + QCoreApplication app(argc, argv); + debug_set_level(DEBUG_LEVEL_INFO); + if (app.arguments().contains("--probe")) { + PttPortal portal(nullptr, "utun_vibechat_ptt_probe"); + bool done = false; + QObject::connect(&portal, &PttPortal::statusChanged, &app, [&]() { + std::printf("portal: %s busy=%d\n", qPrintable(portal.status()), portal.busy()); + if (!portal.busy()) { done = true; QTimer::singleShot(0, &app, &QCoreApplication::quit); } + }); + QTimer::singleShot(10000, &app, &QCoreApplication::quit); + portal.initialize(); + app.exec(); + return done && !portal.status().contains("недоступны") ? 0 : 1; + } + PortalTest test; + return QTest::qExec(&test, argc, argv); +} +#include "test_ptt_portal.moc" diff --git a/tools/chatgui/tests/test_voice_jitter.cpp b/tools/chatgui/tests/test_radio_jitter.cpp similarity index 75% rename from tools/chatgui/tests/test_voice_jitter.cpp rename to tools/chatgui/tests/test_radio_jitter.cpp index 59865e94..94529d6d 100644 --- a/tools/chatgui/tests/test_voice_jitter.cpp +++ b/tools/chatgui/tests/test_radio_jitter.cpp @@ -1,10 +1,10 @@ -// test_voice_jitter.cpp — проверка адаптивного джиттер-буфера голоса (SoundTouch). +// test_radio_jitter.cpp — проверка адаптивного джиттер-буфера голоса (SoundTouch). // // Декодер подменяется генератором синуса (кодированный кадр — маленький маркер), // чтобы проверить буфер без Opus: 1) ёмкость/дроп, 2) догон (глубокий буфер → // темп > 1.0x), 3) стационар (темп ~1.0x без runaway). -#include "call/voice_jitter.h" +#include "radio/radio_jitter.h" #include #include @@ -35,102 +35,102 @@ int main(void) { /* ── 1. ёмкость и дроп ── */ { - vj* j = vj_create(48000, 1, FRAME, 20, CAP, sine_decode, nullptr); + rj* j = rj_create(48000, 1, FRAME, 20, CAP, sine_decode, nullptr); CHECK(j != nullptr, "create failed"); - for (int i = 0; i < CAP; i++) vj_push(j, marker, (int)sizeof(marker)); - vj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); + for (int i = 0; i < CAP; i++) rj_push(j, marker, (int)sizeof(marker)); + rj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); printf(" capacity: pushed %d frames, buffer_ms=%d dropped=%u\n", CAP, buffer_ms, (unsigned)dropped); CHECK(dropped == 0, "expected no drop at capacity, dropped=%u", (unsigned)dropped); CHECK(buffer_ms == CAP * 20, "expected buffer_ms=%d, got %d", CAP * 20, buffer_ms); - for (int i = 0; i < 5; i++) vj_push(j, marker, (int)sizeof(marker)); - vj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); + for (int i = 0; i < 5; i++) rj_push(j, marker, (int)sizeof(marker)); + rj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); printf(" overflow: dropped=%u (expect 5)\n", (unsigned)dropped); CHECK(dropped == 5, "expected 5 drops, got %u", (unsigned)dropped); - vj_destroy(j); + rj_destroy(j); } /* ── 2. догон: глубокий буфер → tempo > 1.0x ── */ { - vj* j = vj_create(48000, 1, FRAME, 20, CAP, sine_decode, nullptr); - for (int i = 0; i < 40; i++) vj_push(j, marker, (int)sizeof(marker)); /* 800мс burst */ + rj* j = rj_create(48000, 1, FRAME, 20, CAP, sine_decode, nullptr); + for (int i = 0; i < 40; i++) rj_push(j, marker, (int)sizeof(marker)); /* 800мс burst */ int max_tempo = 100; int total_out = 0; for (int it = 0; it < 200; it++) { - vj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); + rj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); if (buffer_ms == 0) break; if (tempo_x100 > max_tempo) max_tempo = tempo_x100; - int n = vj_pull(j, out.data(), (int)out.size()); + int n = rj_pull(j, out.data(), (int)out.size()); total_out += n; } - vj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); + rj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); printf(" catch-up: max_tempo_x100=%d total_out=%d final_buffer_ms=%d\n", max_tempo, total_out, buffer_ms); CHECK(max_tempo > 110, "expected tempo to exceed 1.10x during deep buffer, got %d", max_tempo); CHECK(total_out < 40 * FRAME, "expected output < input (stretch), out=%d in=%d", total_out, 40 * FRAME); CHECK(buffer_ms == 0, "expected drained buffer, buffer_ms=%d", buffer_ms); - vj_destroy(j); + rj_destroy(j); } /* ── 3. стационар: feed 1 frame → pull 1 frame, tempo ~1.0x ── */ { - vj* j = vj_create(48000, 1, FRAME, 20, CAP, sine_decode, nullptr); + rj* j = rj_create(48000, 1, FRAME, 20, CAP, sine_decode, nullptr); int max_tempo = 100; for (int i = 0; i < 60; i++) { - vj_push(j, marker, (int)sizeof(marker)); + rj_push(j, marker, (int)sizeof(marker)); int got = 0; while (got < FRAME) { - int n = vj_pull(j, out.data(), (int)out.size()); + int n = rj_pull(j, out.data(), (int)out.size()); if (n <= 0) break; got += n; } - vj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); + rj_get_stats(j, &buffer_ms, &tempo_x100, &dropped, &underruns); if (tempo_x100 > max_tempo) max_tempo = tempo_x100; } printf(" steady-state: max_tempo_x100=%d (expect ~100)\n", max_tempo); CHECK(max_tempo <= 110, "tempo should stay ~1.0x in steady state, got %d", max_tempo); - vj_destroy(j); + rj_destroy(j); } /* ── 4. pre-roll: не отдаём звук, пока глубина < target_ms ── */ { - struct vj_cfg cfg; + struct rj_cfg cfg; cfg.sample_rate = 48000; cfg.channels = 1; cfg.frame_samples = FRAME; cfg.frame_ms = 20; cfg.max_frames = CAP; cfg.target_ms = 80.0; cfg.min_tempo = 1.0; cfg.max_tempo = 1.6; cfg.pre_roll = 1; cfg.decode = sine_decode; cfg.decode_arg = nullptr; - vj* j = vj_create_cfg(&cfg); + rj* j = rj_create_cfg(&cfg); CHECK(j != nullptr, "cfg create failed"); - for (int i = 0; i < 3; i++) vj_push(j, marker, (int)sizeof(marker)); /* 60мс < 80мс */ - int n = vj_pull(j, out.data(), (int)out.size()); + for (int i = 0; i < 3; i++) rj_push(j, marker, (int)sizeof(marker)); /* 60мс < 80мс */ + int n = rj_pull(j, out.data(), (int)out.size()); printf(" pre-roll: 3 frames -> pull=%d (expect 0)\n", n); CHECK(n == 0, "pre-roll должен отдавать 0 до накопления target, got %d", n); - vj_push(j, marker, (int)sizeof(marker)); /* 80мс == target */ - n = vj_pull(j, out.data(), (int)out.size()); + rj_push(j, marker, (int)sizeof(marker)); /* 80мс == target */ + n = rj_pull(j, out.data(), (int)out.size()); printf(" pre-roll: 4 frames -> pull=%d (expect >0)\n", n); CHECK(n > 0, "после достижения target буфер должен отдавать PCM, got %d", n); - vj_destroy(j); + rj_destroy(j); } /* ── 5. симметричный темп: при depth < target темп < 1.0x ── */ { - struct vj_cfg cfg; + struct rj_cfg cfg; cfg.sample_rate = 48000; cfg.channels = 1; cfg.frame_samples = FRAME; cfg.frame_ms = 20; cfg.max_frames = CAP; cfg.target_ms = 80.0; cfg.min_tempo = 0.75; cfg.max_tempo = 1.6; cfg.pre_roll = 0; cfg.decode = sine_decode; cfg.decode_arg = nullptr; - vj* j = vj_create_cfg(&cfg); + rj* j = rj_create_cfg(&cfg); CHECK(j != nullptr, "cfg create failed (symmetric)"); - vj_push(j, marker, (int)sizeof(marker)); /* 1 кадр = 20мс << 80мс */ - vj_pull(j, out.data(), (int)out.size()); + rj_push(j, marker, (int)sizeof(marker)); /* 1 кадр = 20мс << 80мс */ + rj_pull(j, out.data(), (int)out.size()); int tx100 = 100; - vj_get_stats(j, nullptr, &tx100, nullptr, nullptr); + rj_get_stats(j, nullptr, &tx100, nullptr, nullptr); printf(" symmetric: depth tempo_x100=%d (expect <100)\n", tx100); CHECK(tx100 < 100, "при мелком буфере темп должен быть < 1.0x, got %d", tx100); - vj_destroy(j); + rj_destroy(j); } if (g_fail == 0) { diff --git a/tools/chatgui/transport/gui_bridge.h b/tools/chatgui/transport/gui_bridge.h index 27b541fb..64587657 100644 --- a/tools/chatgui/transport/gui_bridge.h +++ b/tools/chatgui/transport/gui_bridge.h @@ -46,7 +46,7 @@ struct UTUN_INSTANCE; #define GUI_EVT_CALL_ACCEPTED 36 /* data: [call_id:8] */ #define GUI_EVT_CALL_ENDED 37 /* data: [call_id:8][reason:1] */ #define GUI_EVT_CALL_DECLINED 38 /* data: [call_id:8][reason:1] */ -#define GUI_EVT_CALL_STATS 39 /* data: [call_id:8][rtt_ms:2][buffer_ms:2][tempo_x100:2] */ +#define GUI_EVT_CALL_STATS 39 /* data: [call_id:8][rtt:2][buffer:2][tempo:2][dropped:2][underruns:2][min:2][max:2][reserve:2] */ #define GUI_EVT_CALL_ERROR 40 /* data: [call_id:8][err:1][text:var] */ #define GUI_EVT_DM_MESSAGES 41 /* data: [conv_id_len:1][conv_id:var][count:2]([dir:1][seq:8][ts:8][author:8][ct_len:1][ct:var][data_len:2][data:var])* */ #define GUI_EVT_CALL_PATH 42 /* data: [call_id:8][text:var] — BGP-маршрут до пира звонка */ @@ -195,6 +195,11 @@ void gui_bridge_set_call_declined_cb(gui_call_ended_fn cb); typedef void (*gui_call_error_fn)(uint64_t call_id, uint8_t err, const char* text, int len); void gui_bridge_set_call_error_cb(gui_call_error_fn cb); +/* CALL_STATS: все длительности в мс; extrema относятся к последним 10 секундным интервалам. */ +typedef void (*gui_call_stats_fn)(uint64_t call_id, int rtt, int buffer, int tempo, + int dropped, int underruns, int minimum, int maximum, int reserve); +void gui_bridge_set_call_stats_cb(gui_call_stats_fn cb); + /* Callback для BGP-маршрута звонка (вызывается из GUI-потока) */ typedef void (*gui_call_path_fn)(uint64_t call_id, const char* text, int len); void gui_bridge_set_call_path_cb(gui_call_path_fn cb); diff --git a/tools/chatgui/transport/gui_bridge_impl.cpp b/tools/chatgui/transport/gui_bridge_impl.cpp index 576de68f..0e787e25 100644 --- a/tools/chatgui/transport/gui_bridge_impl.cpp +++ b/tools/chatgui/transport/gui_bridge_impl.cpp @@ -54,6 +54,7 @@ static gui_call_id_fn g_call_accepted_cb = nullptr; static gui_call_ended_fn g_call_ended_cb = nullptr; static gui_call_ended_fn g_call_declined_cb = nullptr; static gui_call_error_fn g_call_error_cb = nullptr; +static gui_call_stats_fn g_call_stats_cb = nullptr; static gui_call_path_fn g_call_path_cb = nullptr; static gui_radio_talk_fn g_radio_talk_cb = nullptr; static gui_dm_msg_received_fn g_dm_msg_received_cb = nullptr; @@ -323,21 +324,14 @@ void GuiBridgeReceiver::processPost(int eventType, QByteArray data) { } break; case GUI_EVT_CALL_STATS: - if (dlen >= 14) { + if (dlen == 24) { uint64_t callId; memcpy(&callId, d, 8); - uint16_t rtt = (uint16_t)((d[8] << 8) | d[9]); - uint16_t buf = (uint16_t)((d[10] << 8) | d[11]); - uint16_t tempo = (uint16_t)((d[12] << 8) | d[13]); - uint16_t dropped = 0, underruns = 0; - if (dlen >= 18) { - dropped = (uint16_t)((d[14] << 8) | d[15]); - underruns = (uint16_t)((d[16] << 8) | d[17]); - } - DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, - "gui_bridge: CALL_STATS id=0x%016llx rtt=%ums buffer=%ums tempo=%u.%02ux dropped=%u underruns=%u", - (unsigned long long)callId, (unsigned)rtt, (unsigned)buf, - (unsigned)(tempo / 100), (unsigned)(tempo % 100), - (unsigned)dropped, (unsigned)underruns); + int values[8]; + for (int i = 0; i < 8; i++) values[i] = (d[8 + i * 2] << 8) | d[9 + i * 2]; + if (g_call_stats_cb) g_call_stats_cb(callId, values[0], values[1], values[2], values[3], + values[4], values[5], values[6], values[7]); + } else { + DEBUG_WARN(DEBUG_CATEGORY_CALL, "gui_bridge: invalid CALL_STATS size=%d", dlen); } break; case GUI_EVT_RADIO_TALK: @@ -557,3 +551,5 @@ extern "C" void gui_nodeinfo_cb_impl(struct TOPO_GROUP* group, struct TOPO_GROUP } #include "gui_bridge_impl.moc" + +void gui_bridge_set_call_stats_cb(gui_call_stats_fn cb) { g_call_stats_cb = cb; }