diff --git a/doc/ble_etcp.txt b/doc/ble_etcp.txt index 2e7bc55d8..6dd7bd9ed 100644 --- a/doc/ble_etcp.txt +++ b/doc/ble_etcp.txt @@ -1,6 +1,12 @@ BLE GATT → ETCP (chatgui desktop + Android) -Текущее состояние (29.10.2026): +Обновление30.10.2026: Android предпочитает PHY1M. Common BLE ограничивает число +целых DATA до ACK последнего фрагмента (config.tx_window); очередь tx_limit +и защитный предел12500байт учитываются дополнительно. Формат DATA/ACK прежний. +Выбор окна и актуальный контракт — doc/ble_transport.txt. Новые аппаратные +измерения выполнены отдельным стендом драйвера без ETCP. + +Историческое состояние (29.10.2026): Write Without Response/Notify, HIGH и PHY2M включены. BLE version3 добавляет 16-bit ID каждого DATA GATT-value, накопительный ACK по отдельной характеристике не чаще15ms и окно12500байт на направление (фиксированные2Мбит/с ×50ms). diff --git a/doc/ble_transport.txt b/doc/ble_transport.txt index 44ef11a69..11a0199dc 100644 --- a/doc/ble_transport.txt +++ b/doc/ble_transport.txt @@ -61,13 +61,17 @@ RX при заполнении отбрасывает целый новый ка если он присутствует. Это не Ethernet payload MTU 1500 (которому нужно >=1514). На link не больше одной незавершённой GATT-передачи. Она хранит свой fragment до завершения ОС. Очередь ядра/Qt не используется как неограниченный TX-буфер. -DATA дополнительно ограничен неподтверждёнными GATT-values: фиксированные2Мбит/с -×50ms/8 =12500байт на направление, включая flow/framing headers. Локальный sent -не возвращает этот кредит: его освобождает накопительный ACK удалённого драйвера. +DATA дополнительно ограничен config.tx_window: 0 означает BLE_TX_WINDOW_FRAMES=3, +явное значение1..64. В окно входят все принятые tx_put кадры: очередь, текущая +фрагментация и ожидание удалённого ACK последнего фрагмента. Локальный sent +и ACK середины кадра не освобождают слот. Это учёт существующих flow ACK, +без новой wire-команды и без гарантии принятия кадра приложением. +Независимый защитный предел GATT-values —12500байт на направление, включая +flow/framing headers; байтовый кредит освобождается накопительным удалённым ACK. При заполнении ble_tx_ready=false; ACK будит writable после появления места. Текущий кадр может остановиться посреди фрагментации, INVITE и ACK продолжают работать. -Предел12500 — расчётный размер по фиксированной скорости, не гарантия фактических50ms -при медленном радио. Сверх окна остаётся только ограниченная собственная TX-очередь. +Предел12500 не задаёт скорость или время доставки. Собственная TX-очередь входит +в окно целых кадров; предел config.tx_limit дополнительно ограничивает её размер. Переподключение не переносит старые кадры и не повторяет неоднозначные отправки. Wire protocol @@ -152,7 +156,7 @@ Value/Confirm без адреса клиента для серверной пе Выходящий клиент: DATA/ACK WriteValue(type=command), INVITE WriteValue(type=request), StartNotify для TX, INVITE и ACK; открытие после всех трёх подписок. Публичный Device1 D-Bus API не задаёт per-link PHY/connection interval: согласованием -управляют ОС и peer; Android запрашивает 2M также в серверной роли. +управляют ОС и peer; Android запрашивает 1M также в серверной роли. Не вызывается глобальный Device1.Disconnect: ACL может обслуживать другие сервисы. Проверено с BlueZ 5.85; требуется серверный AcquireNotify с indication completion. @@ -400,15 +404,35 @@ Instrumentation временно включает echo, возвращает с Профиль скорости Android ------------------------ После подключения client просит CONNECTION_PRIORITY_HIGH и ATT MTU 517. -Обе роли запрашивают PHY_LE_2M для TX/RX при поддержке локального контроллера. +Обе роли запрашивают PHY_LE_1M для TX/RX: аппаратные сравнения A52/Linux показали +падение полезной скорости на2M при занятой очереди контроллера. Запрос не гарантирует смену PHY: onPhyUpdate/onPhyRead логируют фактические TX/RX. -При отсутствии поддержки остаётся согласованный PHY. S2/S8 — параметры только -LE Coded PHY (дальность/скорость), с выбранным 2M применяется NO_PREFERRED. +S2/S8 — параметры только LE Coded PHY; для1M применяется NO_PREFERRED. У Android BluetoothGattServer нет public requestConnectionPriority; входящий link использует дополнительный BluetoothGatt handle того же ACL только для HIGH. Запрос/результат логируются, handle закрывается при teardown; нового ACL для настройки нет. Сам UUID/advertising остаётся legacy -1M для обнаружения; PHY 2M относится к установленному соединению. +1M для обнаружения; предпочтение PHY отдельно задаётся установленному соединению. + +Подбор окна 2026-10-30 +--------------------- +Отдельный стенд без ETCP: A52 Android-central ↔ Linux/BlueZ, PHY1M, MTU517, +interval15ms, DLE251/time2120us. Каждая проба:5s туда,5s обратно,5s дуплекс, +побайтовая проверка1500B кадров. Окно квитанций стенда64, ограничивает драйвер. +Скорости полезной нагрузки, кбит/с: окно1 —178/222, дуплекс241; окно2 (два +прохода) —374/399..406, дуплекс341..343; окно3 (два прохода) —378..385/413..416, +дуплекс390..395; окно4 —385/418, дуплекс418. Max RTT до квитанции соответственно +137/155/196/245ms. Окно3 выбрано как минимальное близкое к насыщению канала +с RTT<=200ms в этих пробах; окно2 снижает дуплексную скорость примерно на13%. +Окно драйвера освобождается flow ACK, поэтому не равно окну квитанций стенда. +С активной BLE-мышью DLE time падал до340us, наблюдались RTT>200ms даже при +малом окне. Фиксированное окно не гарантирует200ms при произвольной нагрузке +радио. Эти короткие замеры не являются долговременным SLA и не включают ETCP. +Счётчики frames/window_frames/peak_frames и frames_released показывают +ограничение и освобождение кадров; PHY callbacks показывают фактический PHY. +Контроль после финальной сборки со штатными defaults:381/423кбит/с, дуплекс388, +maxRTT177.018ms, обе стороны PASS. При Linux-central окно3 также прошло: +378/420кбит/с, дуплекс381, maxRTT181.328ms. Все три BLE unit/mock-теста PASS. Проверка 2026-10-29 ------------------ diff --git a/doc/call_audio_batch.txt b/doc/call_audio_batch.txt new file mode 100644 index 000000000..d849b8709 --- /dev/null +++ b/doc/call_audio_batch.txt @@ -0,0 +1,126 @@ +Адаптивная упаковка Opus в звонке +================================ + +Сервис CALL_AUDIO использует одну Router-кодограмму для 1..10 Opus-кадров. +Кодек и длительность кадра (20мс) не меняются. Новый формат должен быть +на обеих сторонах; старый одиночный CALL_MEDIA не поддерживается. + +Wire, packed, порядок байтов хоста как у остального CALL: + Общий header, 27 байт: + subcmd:u8, call_id:u64, track_id:u8, capture_epoch:u64, + codec_delay_samples:u32, sent_ms:u32, count:u8. + Далее count раз: + seq:u16, capture_pts_us:i64, length:u16, opus[length]. + Накладные расходы: 27 + 12*N байт CALL на N кадров, плюс один Router. + +count=1..10, весь CALL_MEDIA <=1024 байт. Пачка может быть меньше target из-за +размера, срока отправки или смены capture epoch/codec delay. PTS/seq каждого +кадра сохраняются; PTS может быть INT64_MIN. Парсер проверяет всю кодограмму +до выдачи первого кадра, отвергает хвостовые байты и незавершённые записи. +Headless callback по-прежнему получает отдельные Opus-кадры. + +Пример для Opus по 37 байт, прямой Router с SIGN|ENCRYPT (+163 байта): + N кадров CALL payload Router wire байт Router на кадр + 1 76 239 239 + 2 125 288 144 + 3 174 337 112,33 + 5 272 435 87 + 10 517 680 68 +Обрамление ETCP/BLE и ACK не включены. Normalizer может объединять/разбивать +кодограммы. Старый расход: 236 байт Router на каждый такой Opus-кадр; +историческое разложение — call_audio_packet_overhead.txt. + +Источник обратной связи +----------------------- +etcp_router_tx_stats(group_id, peer_node_id, ETCP_RT_ID_CALL_AUDIO). +Это конкретный логический аудиоканал, не общая сумма очередей ядра. +Интерфейс уже используется видеотреком звонка; stream_publisher использует +другой, общий снимок ETCP и для данного регулятора не подходит. + +Router сохраняет исходное монотонное enqueued_us в send_q и переносит его +в inflight. Ретрансмит не меняет эту метку. При первом подтверждении: + sample = ACK time - enqueued_us; + delivery_rtt_us = EWMA sample с весом 1/8 (первый sample без сглаживания). +Снимок также отдаёт queued_age_us/inflight_age_us головы от исходного enqueue +и last_ack_us. Возраст пустой очереди равен нулю. При reset история очищается. +Прежние rtt_us/baseline_rtt_us и алгоритм видеорегулятора не меняются. + +Полный delivery_rtt_us включает исходную очередь Router, нижние очереди, +путь до приёмника и возврат ACK. Базовый RTT НЕ вычитается. Это не время +доставки в динамик и не измерение односторонней задержки. + +Регулятор +--------- +Снимок каждые50мс в uasync; управление целиком в call_session. + D = max(delivery_rtt_us, queued_age_us, inflight_age_us). +Собственное намеренное накопление пачки в D не добавляется. + +Начальное target=4, deadline60мс от первого кадра: это время сбора ещё трёх +кадров по20мс. При смене local/peer Router epoch или исчезновении канала +target/история сбрасываются. Первый пригодный ACK сразу переводит deadline +на измеренную задержку, даже до заполнения окна статистики500мс; на быстром +канале ожидание сразу выключается. Для изменения target нужно полное окно. +До первого ACK сохраняется стартовое ожидание60мс; при no_route, receive_busy +и minRTT probe ожидание и рост выключены. + +При D>100мс непрерывно200мс target увеличивается на1 до10. Между изменениями +не менее max(500мс, 2*delivery_rtt_us). Высокий обычный RTT без очереди тоже +может включить упаковку — это намеренное поведение. +При D<40мс и пустом Router send_q в течение2с target уменьшается на1. +Промежуточные значения сбрасывают отсчёты high/low, сохраняя target. +Если ACK не продвигался дольше max(2с, 2*delivery_rtt_us, 500мс), рост +и намеренное ожидание выключаются; это состояние не считается свободной полосой. + +Предельное время накопления: + budget = clamp((D - 40мс) / 4, 0, 40мс); + hold = min((target - 1)*20мс, budget). +На быстром канале hold сразу равен0, даже пока target постепенно уменьшается. +При150мс полного RTT допустим hold до27,5мс, при200мс и более — до40мс. + +Отправка и приём +--------------- +Пачка собирается из собственной FIFO до передачи Router. Переданные Router +кодограммы не изменяются и не изымаются из inflight. Подпись и оба уровня +шифрования сохраняются. При отсутствии места остаётся прежний отменяемый +waiter Router; новые кадры/наблюдение/освобождение очереди продолжают drain. + +Отправляем при target кадров, достижении лимита байт, несовместимости эпохи +следующего кадра или deadline головы. Новый кадр не продлевает deadline. +40мс — предел адаптивного ожидания (до первого ACK60мс), а не предел +backpressure или полного RTT. +10 кадров — максимальный размер готовой пачки; ожидания180мс ради её наполнения нет. +Прежняя защитная FIFO750 и её удаление старейшего при overflow сохраняются; +данное изменение не обещает жёсткую задержку200мс при остановившемся канале. + +sent_ms ставится перед подачей пачки Router. Приёмник передаёт его всем кадрам, +но обновляет оценку сетевого jitter только по первому. Capture PTS остаётся +отдельной временной шкалой каждого кадра; учитываются реальные сетевые паузы, +а собственное накопление пачки не создаёт N фиктивных измерений jitter. +После полной проверки кадры идут в прежние decoder/jitter/headless callbacks. + +Первое воспроизведение ждёт накопления не менее200мс аудио в jitter buffer +(10 кадров по20мс), либо сетевого запаса плюс PCM-блок, если это больше. +Захват и отправка начинаются сразу. До первой выдачи PCM нет сигнала перебоя. +После старта действуют прежние адаптивный запас и плавное ускорение излишка; +200мс не закрепляются как постоянная глубина. Повторное накопление после +недостачи и сброс устройства после начала речи используют обычный запас. +Это порог начального заполнения, не ограничение полной сквозной задержки. + +cleanup/ENDED/destroy отменяют оба новых таймера и прежний Router waiter до +освобождения FIFO. Доступ к ядру только в uasync; audio capture worker API +не меняется. Счётчики tx/rx кадров и пачек разделены. + +Диагностика +----------- +call=info: стартовые target/hold, изменение target с причиной, D, полным RTT, +возрастами и hold; порог JB и фактическая глубина при первом воспроизведении. +call=debug: flow раз в секунду и TX/RX каждой пачки (count, bytes, seq, age). +etcp_route=debug: подтверждение с enqueue->ACK, EWMA и числом попыток. +Ошибки формата/аллокации/таймеров/отправки логируются. Нужны обе новые сборки. + +Проверки: test_call_audio_batch (формат и регулятор), test_call (реальная +доставка 10 кадров/неполной пачки и teardown), test_etcp_router (ACK-метрики +после backpressure), test_call_jitter (один arrival на пачку, поздний пакет, +отсутствие декодирования при180мс и старт при200мс), +headless/admission и прежние проверки аудио. Аппаратный эффект проверяется +отдельным звонком и сравнением логов, не выводится из одних unit-тестов. diff --git a/doc/call_audio_packet_overhead.txt b/doc/call_audio_packet_overhead.txt new file mode 100644 index 000000000..43e75701f --- /dev/null +++ b/doc/call_audio_packet_overhead.txt @@ -0,0 +1,124 @@ +Аудиозвонок через BLE: размер пакета по слоям +========================================== + +ИСТОРИЧЕСКИЙ СРЕЗ ДО ВВЕДЕНИЯ ПАЧЕК. Текущий формат и размеры описаны +в call_audio_batch.txt. Таблица ниже сохранена для сравнения с исходным звонком. + +Срез реализации и измерение: 30.10.2026, Linux -> Samsung A52, кодек 16 кбит/с. +Один Opus-кадр представляет 20 мс звука. В 2021 аудиопакете, отправленном Router +за ACTIVE-интервал 40,571045 с: средний размер Opus 36,939 байта, медиана 37, +минимум 21, максимум 57, p95 44 байта. При постоянных 16 кбит/с за 20 мс было +бы 40 байт; фактический размер закодированного кадра меняется. + +Пример ниже: Opus 37 байт, прямой маршрут, одна аудиокодограмма в одном +ETCP DATA-пакете, один BLE-фрагмент. Базовая ETCP ACK-секция содержит count=0; +дополнительных timestamp/measurement/filler/ACK_REQUEST секций нет. +Это расчёт конкретной структуры пакета, не средний расход всех кадров звонка. + +Слой / поле Добавлено, байт Всего, байт +----------------------------------------------------------------------------- +Opus 37 37 +CALL_MEDIA header 36 73 +Router header 57 130 +Router E2E: nonce 13 + CRC32 4 + AES-CCM tag 16 33 163 +Router: Ed25519 signature 64 227 +Router path: node_id 8 + count 1 9 236 +Normalizer: длина кодограммы 2 238 +ETCP: timestamp 2 + flags 1 3 241 +ETCP session: type 1 + sender epoch 8 + peer epoch 8 17 258 +ETCP: базовая ACK-секция 8 266 +ETCP DATA: type 1 + seq 4 + attempt 2 7 273 +ETCP link encryption: nonce 13 + CRC32 4 + tag 16 33 306 +BLE flow: номер фрагмента 2 308 +BLE frame fragment: type 1 + seq 2 + total 2 + off 2 7 315 +ATT: opcode 1 + characteristic handle 2 3 318 +L2CAP: length 2 + channel ID 2 4 322 + +Границы счётчиков: +- 236 байт: Router wire-кодограмма. +- 238 байт: данные normalizer, до обрамления ETCP. +- 306 байт: зашифрованный ETCP-пакет; целый кадр BLE wire TX/RX в журнале. +- 315 байт: значение характеристики GATT; BLE DATA TX/RX в журнале. +- 322 байта: L2CAP PDU, до фрагментации и обрамления Link Layer. + +IP/UDP в этом BLE-пути нет. Адаптер ble_etcp передаёт готовый ETCP-пакет +в ble_tx_put без дополнительного собственного заголовка. +Заголовки Link Layer, MIC при включённом шифровании BLE, служебные пакеты, +межпакетные интервалы и радиоповторы в таблицу не включены. Один GATT value +может занимать несколько Link Layer пакетов. HCI не является счётчиком эфира. + +Состав CALL_MEDIA header, 36 байт +-------------------------------- +subcmd 1 + call_id 8 + track_id 1 + seq 2 + ts_ms 4 + capture_pts_us 8 ++ capture_epoch 8 + codec_delay_samples 4 = 36. + +Состав Router header, 57 байт +---------------------------- +cmd 1 + group_id 8 + dst_node_id 8 + src_node_id 8 + seq 4 + svc_id 1 ++ flags 1 + timestamp 2 + reset_id 8 + peer_reset_id 8 + challenge 8 = 57. + +Router с защитой и прямым путём добавляет 57 + 33 + 64 + 9 = 163 байта. +CALL и Router вместе добавляют 36 + 163 = 199 байт на каждый Opus-кадр +в текущем формате. Шифрование Router и шифрование ETCP — разные слои: +сквозная защита сервисного содержимого и защита соединения соответственно. + +Переменные расходы и подтверждения +---------------------------------- +1. ETCP ACK: базовые 8 байт уже включены в таблицу. При N>0 дополнительно + 10*N байт записей (seq 4 + attempt 2 + receiver_delay_us 4) и 16 байт точки + приёма (накопленные DATA bytes 8 + receive time 8). Итого ACK 24 + 10*N; + при N=0 — 8 байт. ACK может идти вместе с DATA или отдельным ETCP-пакетом. + +2. Дополнительный ETCP timestamp: 5 байт. ACK_REQUEST: 1 байт. + При измерительной серии возможны MEAS_TS 9, MEAS_RESP 13 и FILLER 3 + N + байт. Это условные секции, их нельзя приписывать каждому DATA-пакету. + Для примера таблицы: одна ACK-запись и дополнительный timestamp добавляют + 10 + 16 + 5 = 31 байт; кадр BLE становится 337, GATT value — 346 байт. + +3. Router ACK обратного аудиопотока: 57 + 9 = 66 байт на прямом пути до + normalizer/ETCP/BLE. Это самостоятельная Router-кодограмма; подтверждения + накопительные, поэтому нет обязательного соотношения один ACK на кадр. + +4. BLE ACK: отдельное двухбайтовое GATT value, дополнительно ATT 3 и L2CAP 4. + Обрамление BLE DATA 9 байт к нему не добавляется. ACK накопительный; + возможны повторы. Его расход нельзя добавлять ровно один раз на аудиокадр. + +5. Каждый дополнительный Router-hop добавляет 8 байт к списку пути. + После разбиения кадра BLE каждый DATA-фрагмент получает свои 9 байт + BLE-обрамления и собственные ATT/L2CAP-заголовки. + +6. Normalizer может объединять кодограммы и разбивать поток на фрагменты: + 2 байта длины приходятся на каждую Router-кодограмму; обрамление ETCP — + на каждый передаваемый ETCP-пакет. CALL/Router-заголовки при этом сохраняются. + Поэтому средние размеры всего трафика нельзя получать простым умножением + одного примера на число Opus-кадров. Повторы также считаются отдельно. + +Проверка по последнему звонку +---------------------------- +Исходящие Linux audio DATA во время ACTIVE: + Opus 74654 байта = 14,721 кбит/с + CALL header 72756 байт = 14,346 кбит/с + Router/crypto/path 329423 байта = 64,957 кбит/с + Всего audio Router DATA 476833 байта = 94,024 кбит/с + Router ACK обратного аудио 109428 байт = 21,578 кбит/с +Все скорости десятичные. Это разные статьи трафика, ACK не входит в DATA. + +Полный поток кадров BLE с ETCP и другим трафиком: Linux -> A52 182,179 кбит/с, +A52 -> Linux 170,851 кбит/с. Это не скорость одного аудиопотока и не физическая +скорость PHY. Локальный исходный материал (не включён в репозиторий): +tools/ble-bench/call-diagnosis/gates-20261030/{linux.log,throughput.json}. + +Основания в коде +---------------- +src/call/call_proto.h: call_pkt_media, CALL_MEDIA_HDR_SIZE, CALL_OPUS_FRAME_MS. +src/call/call.c: call_send_media, call_tx_flush. +src/routing_layer/etcp_router.h: SVC_ROUTE_HDR. +src/routing_layer/etcp_router.c: router_path_append, router_source_send. +src/routing_layer/route_crypto.c: route_crypto_encode. +src/transport_layer/pkt_normalizer.c: etcp_input_ready_cb (длина кодограммы). +src/transport_layer/etcp_connections.h: ETCP_*_SIZE, SC_ENCRYPT_OVERHEAD. +src/transport_layer/etcp_connections.c: etcp_encode_plain, etcp_datagram_send. +src/transport_layer/etcp.c: сборка секций в etcp_request_pkt. +src/transport_layer/secure_channel.h: SC_NONCE_SIZE, SC_CRC32_SIZE, SC_TAG_SIZE, SC_SIGN_SIZE. +tools/cgathui_common/ble_etcp.c: send_packet. +lib/ble/ble_transport.c: pump, HEADER, FLOW_HEADER. diff --git a/doc/chat_call_arch.md b/doc/chat_call_arch.md index 161ad5274..12db8f58c 100644 --- a/doc/chat_call_arch.md +++ b/doc/chat_call_arch.md @@ -170,7 +170,8 @@ CALL_PHONE_BUSY 0x09 {call_id:8} Аудио (`0x3F`): ``` -CALL_MEDIA 0x10 {call_id:8, track_id:1, seq:2, ts_ms:4, capture_pts_us:8, capture_epoch:8, codec_delay_samples:4, payload} +CALL_MEDIA 0x10 {call_id:8, track_id:1, capture_epoch:8, codec_delay_samples:4, sent_ms:4, count:1, frames[count]} +frame {seq:2, capture_pts_us:8, length:2, opus[length]} ``` - `call_id` — случайный uint64, генерит вызывающий; идентифицирует сессию на обеих сторонах. @@ -186,11 +187,14 @@ CALL_MEDIA 0x10 {call_id:8, track_id:1, seq:2, ts_ms:4, capture_pts_us:8, capt Надбавка к сервисным данным: nonce(13) + CRC(4) + CCM tag(16) + Ed25519(64) = 97 байт. - `CALL_MEDIA` переносит только аудио (`track_id=CALL_TRACK_AUDIO`); остальные значения приёмник отклоняет. Видео имеет отдельный формат и транспорт: `call_video.h`, `call_video_transport.h`, [call_video.txt](call_video.txt). -- Аудио: Opus 48kHz mono, 20ms (960 сэмплов), целевой bitrate32кбит/с, ~50 кадров/с. -- Заголовок медиа — 36 байт с subcmd. `ts_ms` — время отправки для оценки сетевого джиттера; +- Аудио: Opus 48kHz mono, 20ms (960 сэмплов), bitrate16/32/64кбит/с по настройке, ~50 кадров/с. +- Общий заголовок пачки — 27 байт с subcmd, на кадр — ещё12 байт. count=1..10, вся пачка <=1024 байт. + `sent_ms` — время подачи пачки в Router; сетевой jitter измеряется один раз на пачку; `capture_pts_us` — время первого входного сэмпла в монотонной шкале отправителя, `INT64_MIN` при отсутствии метки. `capture_epoch` различает разрывы захвата; `codec_delay_samples` передаёт Opus lookahead. Старый формат не поддерживается; медиа принимается только от пира текущего звонка. + Размер пачки регулируется по полному времени Router enqueue→ACK и возрасту очередей; + быстрый канал не ждёт накопления. Алгоритм и ограничения: [call_audio_batch.txt](call_audio_batch.txt). ## 4. Машина состояний @@ -447,8 +451,10 @@ Stop дожидается reporter, включая непрерываемый An Минимум/максимум глубины очереди для GUI собираются отдельно в аудиопотоке за те же десять секунд. Верхний предел задаётся `call_jitter_max_reserve_ms`: 500–2000 мс, по умолчанию 1000 мс. Настройка есть в аудиопараметрах desktop и Android и применяется к следующему звонку. -Начальное и повторное накопление ждёт этот запас плюс размер запрошенного PCM-блока; -после первой полной выдачи остаётся целевой запас. Увеличение запаса не прерывает текущую речь. +Первое накопление ждёт минимум200мс аудио либо этот запас плюс размер запрошенного PCM-блока, если это больше. +Захват и отправка идут сразу; до первой выдачи PCM сигнал перебоя не включается. +После старта излишек плавно сокращается обычным регулятором. Повторное накопление ждёт +сетевой запас плюс PCM-блок. Увеличение запаса не прерывает текущую речь. Ускорение зависит от превышения запаса. При отсутствии входящих кадров обычное воспроизведение может исчерпать запас: это не жёсткий запрет воспроизведения ниже цели. Статистика раз в секунду отображается в desktop-окне и Android-экране звонка (под кнопками): глубина, запас, минимум/максимум, diff --git a/doc/tasks.md b/doc/tasks.md index bfd7fbe2e..9afe1cc17 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -4,6 +4,80 @@ ## Текущие задачи +[+] **Звонок: адаптивные пачки Opus 1..10 по полному времени Router enqueue→ACK** — 30.10.2026. + `call_audio_batch` объединяет кадры в одну подписанную/зашифрованную Router-кодограмму; общий CALL header27, + описание кадра12 байт. seq/PTS сохраняются, capture epoch/codec delay общие; count1..10, пакет до1024 байт. + Router хранит исходный enqueue timestamp через send_q/inflight/ретрансмит и отдаёт полный RTT EWMA, + возраст головы очередей и время последнего ACK через существующий etcp_router_tx_stats. + Полный RTT не вычитается: высокий обычный RTT тоже включает упаковку. Быстрый канал сразу прекращает + намеренное накопление. Рост при >100мс в течение200мс; уменьшение при <40мс без send_q в течение2с. + Старт с target4/deadline60мс до первого пригодного ACK; далее deadline зависит от задержки, максимум40мс. + Десять кадров не требуют ожидания180мс. Первый вывод PCM ждёт минимум200мс в JB; захват/отправка идут сразу. + Сетевой jitter измеряется один раз на пачку. cleanup отменяет оба таймера; счётчики кадров/пачек разделены. + Формат требует обновления обоих участников. Ядро/desktop/Android APK собраны; unit, Router, звонок, + headless/admission и23 voice-проверки PASS. ./check.sh:231/231, proxy/burst/load PASS. + В первом BLE-звонке глитчи были в начале; в логе совпадают с ростом Router RTT/возраста inflight. + Версия со стартом4/JB200 установлена на A52 и Linux GUI, BLE восстановлен; эффект старта требует нового звонка. + Алгоритм/формат: [call_audio_batch.txt](call_audio_batch.txt); прежние размеры: [call_audio_packet_overhead.txt](call_audio_packet_overhead.txt). + +[ ] **BLE-звонок 16 кбит/с: задержки Router/ETCP, RTO/pacing и окно по байтам** — 30.10.2026. + В звонке cc621497ae36edfa (40,571 с) прослежены все 2024 исходящих аудиопакета Linux: + Router enqueue → первая успешная передача драйверу BLE, медиана 387,245 мс, p95 676,071 мс, max 895,534 мс. + Средние по стадиям: Router 124,332 мс, normalizer 12,414 мс, ETCP input 28,444 мс, send_q 240,316 мс. + Это задержка до радио, не до воспроизведения. Подтверждено минимум 8,863 с ожидания pacing при готовом BLE; + 16,703 с ожидания BLE, включая 3,873 с одновременным pacing; 15,005 с оставлены неклассифицированными. + 169 RTO: в 135 случаях ACK пришёл до повтора, в 34 после повтора. BBR получает loss уже при RTO. + Исходящих BGP/Merkle и повторов аудио Router в ACTIVE нет; присутствуют DM/media и ACK. + Добавлена диагностика границ очередей и причин ожидания; алгоритмы RTO/BBR и окно в этом прогоне не менялись. + Следующий шаг: отдельная оценка полного времени ACK для RTO без изменения смысла RTT для BBR, затем A/B; + после этого подбор окна по байтам на пакетах звонка. Проверить обе стороны и влияние подробных логов. + Сборка ядра/GUI, ETCP windows, normalizer, Router, BLE ETCP и ETCP packet link PASS. + Локальный отчёт: `tools/ble-bench/call-diagnosis/gates-20261030/RESULTS.txt` (стенд и логи исключены из Git). + +[+] **BLE: проверить пробуждение отправителя при освобождении окна** — 30.10.2026. + Цепочка `wake_writer → ble_etcp.writable → packet_link.state_changed → etcp_conn_wake_sender` проверена; + потерянных пробуждений в проверенных сценариях не обнаружено, рабочий код транспорта не менялся. + Добавлены регрессии MTU 23/517: ACK до/после completion, повтор ACK, повторное заполнение из writable, + tx_limit=1/window=3, чередование 7/1500 байт. Следующий кадр подаётся только callback, без polling/отказа put. + В test_ble_etcp убрана маскирующая пробная отправка при busy; ожидание совместной доставки приглашения и DATA + исправлено с 1000 poll(0) на ожидание результата с deadline: лог подтвердил завершение после ACK через ~15 мс. + BLE transport, BlueZ private-bus, BLE ETCP, packet_link и ETCP packet adapter PASS; проверены счётчики памяти. + В реальном Linux-логе звонка 16 кбит/с: 3985 интервалов от восстановленной готовности до следующего принятого + кадра, медиана 1,201 мс, p95 17,316 мс, max 47,731 мс. Это включает ETCP/BBR, не чистое время callback. + Задержки >200 мс на этом участке не найдены; результат не исключает другие причины глитчей или редкие сбои. + Локальные материалы: `tools/ble-bench/call-diagnosis/call4-resume-audit.json`, `REPORT.txt`, `resume-*.log`. + +[ ] **Desktop: завершение GUI после остановки ядра по SIGTERM** — 30.10.2026. + Два наблюдения на Linux: PID 744782 после BLE-звонка и PID 747101 при обновлении desktop ChatGUI. + Повтор 15:00:56–15:01:18: активного звонка не было; при teardown лог `call: destroyed (started=0 ended=0)`. + Значит, завершённый звонок не является обязательным условием; зависимость от BLE пока не установлена. + Воспроизведение наблюдавшегося сценария: запустить обычный `tools/chatgui/build/vibechat`, дождаться + соединений с A52 по BLE и colo2 по сети, открыть монитор соединений, отправить SIGTERM только этому PID. + Ожидается сохранение UI-state, однократная остановка служб/worker и выход процесса без SIGKILL. + Фактически процесс остаётся жив более 22 секунд; в 15:01:07 `ps` показывал 36 потоков, состояние `Ssl`. + Подтверждённая последовательность второго случая: + — 15:00:56.192: BLE закрыт с `reason=transport stopped`; `utun_node: poll stopped ... running=0 requested=1`. + — 15:00:56.196–.199: освобождаются call_audio, radio, chat и транспорт; есть `etcp_router destroyed`. + — с 15:00:56.389 каждые 200 мс повторяется `desktop shutdown queue unavailable; stopping directly`. + — каждый цикл также пытается сохранить `window_geometry`, `window_maximized`, `window_splitter`: + `GUI setting rejected: ... core unavailable`, `UI setting was not queued`, `GUI command rejected`. + GUI-таймер продолжает исполняться; мост к ядру уже отключён. Полный возврат из destroy и завершение + worker не подтверждены: нельзя считать доказанным, что ядро полностью освободилось, а зависло только окно. + Проверить `MainWindow::onShutdownCheck/requestShutdown` (`tools/chatgui/src/mainwindow.cpp`): + сигнал вызывает повторный shutdown через таймер 200 мс, проверка выхода зависит от `m_node->isRunning()`. + Проследить `UtunNode::runLoop`, `requestStop`, `isRunning`, доставку `stopped` и join worker + (`tools/chatgui/transport/utun_node.cpp`), включая ожидание GUI readers и остаток `utun_instance_destroy`. + Это направления проверки, точная точка блокировки пока неизвестна; backtrace не получен + (в первом случае attach gdb был недоступен). Добавить контрольные логи до/после teardown и join, + с generation, идентификатором потока и состояниями stop/running/GUI-detached; проверить фильтры sys/general. + В обоих случаях после ожидания пришлось применить SIGKILL к своему зависшему процессу и запустить GUI снова; + после второго случая новый PID 749434, BLE восстановлен. Это обход, исправления ещё нет. + Лог повтора сохранён локально: `tools/ble-bench/call-diagnosis/gui-shutdown-20261030-150056.log` + (300000 байт, исходный хвост `/tmp/utun-monitor-shutdown-tail.log`; каталог стенда исключён из Git). + Проверка исправления: SIGTERM и «Выход» завершают процесс с открытым/закрытым монитором, после звонка + и без звонка, с BLE и без него; повторный stop не сохраняет UI-state заново и не запускает второй teardown. + Логи должны подтверждать завершение worker и GUI; без зависания, потери ожидающих записей UI и SIGKILL. + [+] **BLE/ETCP: ограничить очередь удалёнными ACK GATT-фрагментов** — 29.10.2026. BLE version3: циклический ID16bit каждого DATA GATT-value, отдельная ACK Write/Indicate характеристика. Окно12500байт на направление = фиксированные2Мбит/с ×50ms; локальный completion не возвращает кредит. diff --git a/lib/ble/ble_transport.c b/lib/ble/ble_transport.c index 1c3cc7adf..63a55b6e0 100644 --- a/lib/ble/ble_transport.c +++ b/lib/ble/ble_transport.c @@ -15,7 +15,7 @@ #define STATS_US 5000000ULL struct ble_stats { uint64_t since, tx_bytes, rx_bytes, wait_us, max_wait_us; - unsigned tx_frames, rx_frames, fragments, dropped, missing, ack_tx, ack_rx, max_flight; + unsigned tx_frames, rx_frames, fragments, dropped, missing, ack_tx, ack_rx, max_flight, max_frames; }; struct ble_channel_state { struct ll_queue *tx, *rx; @@ -34,8 +34,10 @@ struct ble_connection { struct ble_channel_state channels[BLE_CHANNEL_COUNT]; unsigned int capacity; uint16_t pending_bytes; - /* ID монотонен по модулю 65536; кольцо хранит только размеры, payload остаётся у ETCP. */ - uint16_t tx_next, tx_acked, rx_last, ack_sent, flight_head, flight_count, flight_bytes, flight_sizes[FLOW_SLOTS]; + /* ID монотонен по модулю 65536; кольцо хранит размеры и границы кадров, собственных ретрансляций нет. */ + uint16_t tx_next, tx_acked, rx_last, ack_sent, flight_head, flight_count, flight_bytes; + struct { uint16_t size; uint8_t frame_end; } flight[FLOW_SLOTS]; /* Последний фрагмент освобождает слот кадра. */ + unsigned tx_frames_pending; /* Принятые DATA, включая очередь и текущий кадр, до удалённого ACK конца. */ uint64_t flight_deadline, ack_due, ack_completed; void* ack_timer; int rx_flow_seen, ack_dirty, pending_ack; @@ -88,10 +90,11 @@ static void log_stats(struct ble_connection* c, uint64_t now) { queue_entry_count(c->channels[BLE_CHANNEL_INVITE].tx) + !!c->channels[BLE_CHANNEL_INVITE].current, (unsigned long long)(c->pending ? (now - c->submitted) / 1000 : 0)); DEBUG_DEBUG(CAT, "BLE flow link=%llu flight=%u/%u bytes=%u/%u peak_bytes=%u tx_next=%u acked=%u " - "rx_last=%u ACK_tx=%u ACK_rx=%u ack_dirty=%d ack_pending=%d", + "rx_last=%u ACK_tx=%u ACK_rx=%u ack_dirty=%d ack_pending=%d frames=%u/%u peak_frames=%u", (unsigned long long)c->id, c->flight_count, (unsigned)FLOW_SLOTS, c->flight_bytes, BLE_TX_WINDOW_BYTES, s->max_flight, c->tx_next, c->tx_acked, c->rx_last, - s->ack_tx, s->ack_rx, c->ack_dirty, c->pending_ack && c->pending); + s->ack_tx, s->ack_rx, c->ack_dirty, c->pending_ack && c->pending, + c->tx_frames_pending, c->owner->config.tx_window, s->max_frames); } memset(s, 0, sizeof(*s)); s->since = now; } @@ -228,7 +231,10 @@ static void pump(struct ble_transport* t, struct ble_connection* c) { c->ack_sent = c->rx_last; } else if (wire == BLE_WIRE_DATA) { if (!c->flight_count) c->flight_deadline = c->submitted + DEADLINE_US; - c->flight_sizes[(c->flight_head + c->flight_count++) % FLOW_SLOTS] = len; + unsigned slot = (c->flight_head + c->flight_count++) % FLOW_SLOTS; + struct ble_channel_state* ch = &c->channels[BLE_CHANNEL_DATA]; + c->flight[slot].size = len; + c->flight[slot].frame_end = c->pending_bytes && ch->offset + c->pending_bytes == ch->current->len; c->flight_bytes += len; if (c->flight_bytes > c->stats.max_flight) c->stats.max_flight = c->flight_bytes; DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "BLE DATA TX link=%llu seq=%u type=%u bytes=%zu flight=%u/%u", @@ -271,7 +277,7 @@ static void tick(void* arg) { if (!t->timer) { DEBUG_ERROR(CAT, "BLE timer allocation failed"); ble_backend_status(t, 0, "timer allocation failed"); } } struct ble_transport* ble_create(struct UASYNC* ua, const struct ble_config* config) { - if (!ua || !config || !config->node_id || config->tx_limit > BLE_QUEUE_MAX || + if (!ua || !config || !config->node_id || config->tx_limit > BLE_QUEUE_MAX || config->tx_window > BLE_QUEUE_MAX || (config->adapter && strlen(config->adapter) >= 64)) { DEBUG_ERROR(CAT, "BLE create invalid configuration"); return NULL; } @@ -279,6 +285,7 @@ struct ble_transport* ble_create(struct UASYNC* ua, const struct ble_config* con if (!t) { DEBUG_ERROR(CAT, "BLE create allocation failed"); return NULL; } t->ua = ua; t->config = *config; if (!t->config.tx_limit) t->config.tx_limit = BLE_QUEUE_MAX; + if (!t->config.tx_window) t->config.tx_window = BLE_TX_WINDOW_FRAMES; if (config->adapter) { strcpy(t->adapter, config->adapter); t->config.adapter = t->adapter; } return t; } @@ -331,7 +338,8 @@ int ble_channel_tx_ready(struct ble_transport* t, ble_link_id id, enum ble_chann struct ble_connection* c = lookup(t, id); struct ble_channel_state* ch = c ? &c->channels[channel] : NULL; return c && c->ready && (channel != BLE_CHANNEL_DATA || - (BLE_TX_WINDOW_BYTES - c->flight_bytes >= FLOW_MIN_VALUE && c->flight_count < FLOW_SLOTS)) && + (c->tx_frames_pending < t->config.tx_window && + BLE_TX_WINDOW_BYTES - c->flight_bytes >= FLOW_MIN_VALUE && c->flight_count < FLOW_SLOTS)) && (unsigned)queue_entry_count(ch->tx) + !!ch->current < (channel == BLE_CHANNEL_DATA ? t->config.tx_limit : 1); } int ble_channel_tx_put(struct ble_transport* t, ble_link_id id, enum ble_channel channel, struct ll_entry* e) { @@ -349,6 +357,12 @@ int ble_channel_tx_put(struct ble_transport* t, ble_link_id id, enum ble_channel } /* Без лимита/индекса/consumer put не имеет отказа после проверок и не вызывает чужой callback. */ if (queue_data_put(ch->tx, e) != 0) { DEBUG_ERROR(CAT, "BLE TX queue invariant failed"); return BLE_ERROR; } + if (channel == BLE_CHANNEL_DATA) { + c->tx_frames_pending++; + if (c->tx_frames_pending > c->stats.max_frames) c->stats.max_frames = c->tx_frames_pending; + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "BLE frame queued link=%llu frames=%u/%u bytes=%u", + (unsigned long long)id, c->tx_frames_pending, t->config.tx_window, e->len); + } if (!ble_channel_tx_ready(t, id, channel)) ch->blocked = 1; pump(t, c); return BLE_OK; } @@ -389,9 +403,9 @@ void ble_backend_opened(struct ble_transport* t, ble_link_id id, unsigned int mt c->opened = 1; DEBUG_INFO(CAT, "BLE GATT open link=%llu mtu=%u fragment_payload=%u outgoing=%d DATA=without_response/notify INVITE=request/indicate tx_limit=%u", (unsigned long long)id, mtu, c->capacity - HEADER - FLOW_HEADER, c->outgoing, t->config.tx_limit); - DEBUG_INFO(CAT, "BLE flow configured link=%llu rate_bits=%u window_ms=%u window_bytes=%u " + DEBUG_INFO(CAT, "BLE flow configured link=%llu window_frames=%u window_bytes=%u " "ACK=without_response/notify ACK_interval_us=%u ACK_repeat_us=%u id_bits=16", - (unsigned long long)id, BLE_TX_RATE_BITS, BLE_TX_WINDOW_MS, BLE_TX_WINDOW_BYTES, BLE_ACK_INTERVAL_US, BLE_ACK_REPEAT_US); + (unsigned long long)id, t->config.tx_window, BLE_TX_WINDOW_BYTES, BLE_ACK_INTERVAL_US, BLE_ACK_REPEAT_US); if (c->outgoing) { c->control[0] = HELLO; write64(c->control + 1, t->config.node_id); write64(c->control + 9, c->peer); c->control_pending = 1; @@ -424,14 +438,17 @@ void ble_backend_received(struct ble_transport* t, ble_link_id id, enum ble_wire (unsigned long long)id, ack, c->tx_acked, c->tx_next, c->flight_count); close_link(t, c, "ACK outside flight"); return; } - unsigned bytes = 0; + unsigned bytes = 0, frames = 0; for (unsigned i = 0; i < advance; i++) { - bytes += c->flight_sizes[c->flight_head]; c->flight_head = (c->flight_head + 1) % FLOW_SLOTS; + bytes += c->flight[c->flight_head].size; frames += c->flight[c->flight_head].frame_end; + c->flight_head = (c->flight_head + 1) % FLOW_SLOTS; } + c->tx_frames_pending -= frames; c->flight_count -= advance; c->flight_bytes -= bytes; c->tx_acked = ack; c->stats.ack_rx++; c->flight_deadline = c->flight_count ? get_time_us() + DEADLINE_US : 0; - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "BLE ACK RX link=%llu id=%u released=%u/%u flight=%u/%u", (unsigned long long)id, - ack, advance, bytes, c->flight_count, c->flight_bytes); + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "BLE ACK RX link=%llu id=%u released=%u/%u flight=%u/%u frames_released=%u frames=%u/%u", + (unsigned long long)id, ack, advance, bytes, c->flight_count, c->flight_bytes, + frames, c->tx_frames_pending, t->config.tx_window); wake_writer(t, c, BLE_CHANNEL_DATA); pump(t, c); return; } enum ble_channel channel = wire == BLE_WIRE_DATA ? BLE_CHANNEL_DATA : BLE_CHANNEL_INVITE; diff --git a/lib/ble/ble_transport.h b/lib/ble/ble_transport.h index de5ab2258..d87f70d18 100644 --- a/lib/ble/ble_transport.h +++ b/lib/ble/ble_transport.h @@ -20,10 +20,8 @@ extern "C" { #define BLE_ENDPOINT_MAX 256 #define BLE_LINKS_MAX 16 #define BLE_QUEUE_MAX 64 -/* Расчётная скорость фиксирована; окно включает DATA GATT-values с заголовками. */ -#define BLE_TX_RATE_BITS 2000000u -#define BLE_TX_WINDOW_MS 50u -#define BLE_TX_WINDOW_BYTES (BLE_TX_RATE_BITS / 8u * BLE_TX_WINDOW_MS / 1000u) +#define BLE_TX_WINDOW_FRAMES 3u /* Целые DATA от tx_put до ACK последнего фрагмента; баланс скорости/RTT на A52/BlueZ. */ +#define BLE_TX_WINDOW_BYTES 12500u /* Дополнительный предел GATT-байтов; не оценка скорости/задержки. */ #define BLE_ACK_INTERVAL_US 15000u #define BLE_ACK_REPEAT_US 100000u /* Повтор последнего ACK при отсутствии нового DATA. */ struct ble_transport; @@ -52,6 +50,7 @@ struct ble_config { struct ble_callbacks callbacks; void* arg; /* Заимствованный контекст callbacks, живёт до destroy. */ unsigned tx_limit; /* Целых TX-пакетов, включая фрагментируемый: 0=64, 1..64. */ + unsigned tx_window; /* DATA в очереди/отправке/ожидании удалённого ACK: 0=BLE_TX_WINDOW_FRAMES, 1..64. */ }; /* Создать остановленный transport. ua/config заимствованы только ua; возвращает handle или NULL с логом. */ struct ble_transport* ble_create(struct UASYNC* ua, const struct ble_config* config); @@ -74,7 +73,7 @@ void ble_disconnect(struct ble_transport* transport, ble_link_id link); /* Принять целый ll_entry в TX. При 0 владение передано; при любом отказе entry остаётся вызывающему. * entry должен быть вне очереди, dgram!=NULL, len=1..1500. Заполнение: WOULD_BLOCK, затем writable. */ int ble_tx_put(struct ble_transport* transport, ble_link_id link, struct ll_entry* entry); -/* Снимок готовности принять пакет; заполнение учитывает текущую фрагментацию. +/* Снимок готовности принять пакет; учитывает очередь и кадры до ACK последнего фрагмента. * После заполнения writable вызывается при освобождении места, без обязательного отказа tx_put. */ int ble_tx_ready(struct ble_transport* transport, ble_link_id link); /* Извлечь целый кадр из RX или NULL; вызывающий освобождает queue_dgram_free + queue_entry_free. */ diff --git a/lib/ble/tests/test_ble_bluez.c b/lib/ble/tests/test_ble_bluez.c index a2e426754..22e8e18e7 100644 --- a/lib/ble/tests/test_ble_bluez.c +++ b/lib/ble/tests/test_ble_bluez.c @@ -137,9 +137,10 @@ static int acquire(const char* device, int channel) { "AcquireNotify", replied, &reply, "a{sv}", 2, "device", "o", device, "mtu", "q", 23) >= 0); poll_all(); CHECK(reply.done && !reply.failed && reply.fd >= 0); sd_bus_slot_unref(reply.slot); return reply.fd; } -static void write_value(const char* device, const uint8_t* data, size_t len, int invitation) { +static void write_value(const char* device, const uint8_t* data, size_t len, int channel) { struct reply reply = {0}; sd_bus_message* m = NULL; - CHECK(sd_bus_message_new_method_call(bus, &m, application, invitation ? ROOT "/service0/invite" : ROOT "/service0/rx", "org.bluez.GattCharacteristic1", "WriteValue") >= 0); + const char* path = channel == 2 ? ROOT "/service0/ack" : channel ? ROOT "/service0/invite" : ROOT "/service0/rx"; + CHECK(sd_bus_message_new_method_call(bus, &m, application, path, "org.bluez.GattCharacteristic1", "WriteValue") >= 0); CHECK(sd_bus_message_append_array(m, 'y', data, len) >= 0); CHECK(sd_bus_message_append(m, "a{sv}", 2, "device", "o", device, "mtu", "q", 23) >= 0); CHECK(sd_bus_call_async(bus, &reply.slot, m, replied, &reply, 1000000) >= 0); sd_bus_message_unref(m); @@ -219,6 +220,8 @@ int main(void) { CHECK(recv(fd1, data, sizeof(data), MSG_DONTWAIT) == (part == 22 ? 18 : 20)); CHECK(data[8] == part && data[9] == 0xd4); } + /* Flow ACK последнего DATA-фрагмента, независимо от ATT confirmation канала INVITE. */ + write_value(ADAPTER "/dev_A", data, 2, 2); CHECK(send(invite2, "\1", 1, MSG_NOSIGNAL) == 1); poll_all(); } CHECK(ack_values >= 2); diff --git a/lib/ble/tests/test_ble_transport.c b/lib/ble/tests/test_ble_transport.c index 94c665998..ad522d513 100644 --- a/lib/ble/tests/test_ble_transport.c +++ b/lib/ble/tests/test_ble_transport.c @@ -110,6 +110,78 @@ static struct ll_entry* frame(unsigned int size, uint8_t value) { struct ll_entry* e = ll_alloc_lldgram(size); CHECK(e); e->len = size; memset(e->dgram, value, size); return e; } static void release(struct ll_entry* e) { queue_dgram_free(e); queue_entry_free(e); } +struct resume_writer { struct ble_transport* radio; unsigned sent, wakes, goal; int fragmented; }; +/* Следующий кадр подаёт только writable: ни polling tx_ready, ни пробный отказ не маскируют потерянное пробуждение. */ +static void resume_write(void* arg, ble_link_id id) { + struct resume_writer* w = arg; w->wakes++; + CHECK(ble_tx_ready(w->radio, id)); + if (w->sent == w->goal) return; + unsigned size = w->fragmented && w->sent % 2 ? 1500 : 7; + CHECK(!ble_tx_put(w->radio, id, frame(size, w->sent))); w->sent++; +} +/* ACK до completion не освобождает занятую локальную очередь; обратный порядок ждёт ACK окна из одного кадра. */ +static void exercise_resume_order(unsigned test_mtu, int ack_first) { + mtu = test_mtu; hold = hold_ack = drop = reject_peer = 0; + struct UASYNC* ua = uasync_create(); CHECK(ua); + struct resume_writer writer = {0}; + struct ble_config cfg = {.node_id = 1, .tx_limit = 1, .tx_window = 1, .arg = &writer}; + cfg.callbacks.writable = resume_write; + struct ble_transport* a = ble_create(ua, &cfg); writer.radio = a; + cfg.node_id = 2; cfg.arg = NULL; cfg.callbacks.writable = NULL; + struct ble_transport* b = ble_create(ua, &cfg); CHECK(a && b && !ble_start(a) && !ble_start(b)); drain(ua); + struct ble_endpoint ep = {"1"}; ble_link_id id = ble_connect(a, &ep, 2); CHECK(id); drain(ua); + ble_link_id other = incoming_ids[1]; hold = hold_ack = 1; + writer = (struct resume_writer){.radio = a, .goal = 8}; + CHECK(!ble_tx_put(a, id, frame(7, 0))); writer.sent = 1; + for (unsigned i = 0; i < writer.goal; i++) { + struct fake_link* f = slot(backends[0], id); CHECK(f && f->pending && f->channel == BLE_WIRE_DATA); + uint8_t ack[2] = {f->data[0], f->data[1]}; + CHECK(!ble_tx_ready(a, id) && writer.wakes == i); + ble_backend_received(b, other, f->channel, f->data, f->len); + if (ack_first) ble_backend_received(a, id, BLE_WIRE_ACK, ack, sizeof(ack)); + else { f->pending = 0; ble_backend_sent(a, id, 1); } + CHECK(!ble_tx_ready(a, id) && writer.wakes == i); + if (ack_first) { f->pending = 0; ble_backend_sent(a, id, 1); } + else ble_backend_received(a, id, BLE_WIRE_ACK, ack, sizeof(ack)); + CHECK(writer.wakes == i + 1); + ble_backend_received(a, id, BLE_WIRE_ACK, ack, sizeof(ack)); CHECK(writer.wakes == i + 1); + struct ll_entry* e = ble_rx_get(b, other); CHECK(e && e->len == 7 && e->dgram[0] == i); release(e); + } + CHECK(writer.sent == writer.goal && ble_tx_ready(a, id)); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "PASS BLE resume order: MTU=%u ACK_first=%d wakes=%u frames=%u; no refused put/polling", + test_mtu, ack_first, writer.wakes, writer.sent); + ble_destroy(a); ble_destroy(b); uasync_destroy(ua, 0); hold = hold_ack = 0; +} +/* Production tx_limit=1/window=3: только callback подаёт следующие короткие и фрагментированные кадры. */ +static void exercise_resume_stream(unsigned test_mtu) { + mtu = test_mtu; hold = hold_ack = drop = reject_peer = 0; + struct UASYNC* ua = uasync_create(); CHECK(ua); + struct resume_writer writer = {0}; + struct ble_config cfg = {.node_id = 1, .tx_limit = 1, .tx_window = 3, .arg = &writer}; + cfg.callbacks.writable = resume_write; + struct ble_transport* a = ble_create(ua, &cfg); writer.radio = a; + cfg.node_id = 2; cfg.arg = NULL; cfg.callbacks.writable = NULL; + struct ble_transport* b = ble_create(ua, &cfg); CHECK(a && b && !ble_start(a) && !ble_start(b)); drain(ua); + struct ble_endpoint ep = {"1"}; ble_link_id id = ble_connect(a, &ep, 2); CHECK(id); drain(ua); + ble_link_id other = incoming_ids[1]; + writer = (struct resume_writer){.radio = a, .goal = 24, .fragmented = 1}; + CHECK(!ble_tx_put(a, id, frame(7, 0))); writer.sent = 1; + uint64_t started = get_time_us(); unsigned received = 0; + while ((received < writer.goal || writer.wakes < writer.goal) && get_time_us() - started < 3000000) { + uasync_poll(ua, 10); + struct ll_entry* e; + while ((e = ble_rx_get(b, other))) { + CHECK(e->len == (received % 2 ? 1500 : 7)); + for (unsigned i = 0; i < e->len; i++) CHECK(e->dgram[i] == received); + received++; release(e); + } + } + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "BLE resume stream: MTU=%u TX=%u RX=%u wakes=%u elapsed_us=%llu", + test_mtu, writer.sent, received, writer.wakes, (unsigned long long)(get_time_us() - started)); + CHECK(writer.sent == writer.goal && received == writer.goal && writer.wakes == writer.goal); + CHECK(ble_tx_ready(a, id)); + ble_destroy(a); ble_destroy(b); uasync_destroy(ua, 0); +} /* Непрерывный RX при ACK completion >15ms не должен вытеснять встречный DATA. */ static void exercise_ack_fairness(void) { mtu = 517; hold = drop = reject_peer = hold_ack = 0; ack_delay_us = 0; @@ -141,7 +213,7 @@ static void exercise(unsigned int test_mtu) { struct UASYNC* ua = uasync_create(); CHECK(ua); struct ble_transport* t[3]; for (int i = 0; i < 3; i++) { - struct ble_config cfg = {0}; cfg.node_id = i + 1; cfg.company_id = 0xffff; + struct ble_config cfg = {.tx_window = BLE_QUEUE_MAX}; cfg.node_id = i + 1; cfg.company_id = 0xffff; cfg.callbacks.validate = validate; cfg.callbacks.connection = connection; cfg.callbacks.writable = writable; t[i] = ble_create(ua, &cfg); CHECK(t[i] && !ble_start(t[i])); } @@ -208,7 +280,8 @@ static void exercise(unsigned int test_mtu) { static void exercise_flow(void) { mtu = 517; hold = drop = reject_peer = hold_ack = 0; struct UASYNC* ua = uasync_create(); CHECK(ua); - struct ble_config cfg = {.node_id = 1}; cfg.callbacks.writable = writable; cfg.callbacks.connection = connection; + struct ble_config cfg = {.node_id = 1, .tx_window = BLE_QUEUE_MAX}; + cfg.callbacks.writable = writable; cfg.callbacks.connection = connection; struct ble_transport* a = ble_create(ua, &cfg); cfg.node_id = 2; struct ble_transport* b = ble_create(ua, &cfg); CHECK(a && b && !ble_start(a) && !ble_start(b)); drain(ua); struct ble_endpoint ep = {"1"}; ble_link_id id = ble_connect(a, &ep, 2); CHECK(id); drain(ua); @@ -254,7 +327,7 @@ static void exercise_flow(void) { (unsigned long long)(get_time_us() - recovery_started)); drain(ua); /* Больше полного оборота wire ID; новые данные действительно приходят, старые ACK не открывают окно. */ - unsigned sent = 0, delivered = 0; deadline = get_time_us() + 10000000; + unsigned sent = 0, delivered = 0; deadline = get_time_us() + 30000000; while (delivered < 70000 && get_time_us() < deadline) { while (sent < 70000 && ble_tx_ready(a, id)) { CHECK(!ble_tx_put(a, id, frame(1, sent))); sent++; } uasync_poll(ua, 0); @@ -273,11 +346,52 @@ static void exercise_flow(void) { ble_destroy(a); ble_destroy(b); uasync_destroy(ua, 0); puts("BLE flow PASS (12500-byte duplex bound, ACK >=15ms, duplicate ACK, resume, 70000 IDs, invalid ACK, timeout, teardown)"); } +/* Локальное завершение и ACK середины кадра не освобождают окно; ACK конца освобождает ровно один слот. */ +static void exercise_frame_window(unsigned test_mtu) { + mtu = test_mtu; hold = drop = reject_peer = hold_ack = 0; + struct UASYNC* ua = uasync_create(); CHECK(ua); + struct ble_config cfg = {.node_id = 1, .tx_window = 2}; cfg.callbacks.writable = writable; + struct ble_transport* a = ble_create(ua, &cfg); cfg.node_id = 2; + struct ble_transport* b = ble_create(ua, &cfg); CHECK(a && b && !ble_start(a) && !ble_start(b)); drain(ua); + struct ble_endpoint ep = {"1"}; ble_link_id id = ble_connect(a, &ep, 2); CHECK(id); drain(ua); + ble_link_id other = incoming_ids[1]; hold_ack = 1; + CHECK(!ble_tx_put(a, id, frame(1500, 0x41))); drain(ua); + struct fake_link* receiver = slot(backends[1], other); CHECK(receiver && receiver->ack_held); + uint16_t first_end = receiver->ack_id, partial = first_end - 1; + uint8_t ack[2] = {partial >> 8, partial}; + ble_backend_received(a, id, BLE_WIRE_ACK, ack, sizeof(ack)); + CHECK(!ble_tx_put(a, id, frame(17, 0x42))); drain(ua); + CHECK(!ble_tx_ready(a, id)); + struct ll_entry* e = frame(7, 0x43); + CHECK(ble_tx_put(a, id, e) == BLE_WOULD_BLOCK && !e->owner); + unsigned before_writable = writable_count; + ack[0] = first_end >> 8; ack[1] = first_end; + ble_backend_received(a, id, BLE_WIRE_ACK, ack, sizeof(ack)); + CHECK(ble_tx_ready(a, id) && writable_count > before_writable); + CHECK(!ble_tx_put(a, id, e) && !ble_tx_ready(a, id)); + ble_backend_received(a, id, BLE_WIRE_ACK, ack, sizeof(ack)); CHECK(!ble_tx_ready(a, id)); + CHECK(!ble_channel_tx_put(a, id, BLE_CHANNEL_INVITE, frame(3, 0x44))); drain(ua); + e = ble_channel_rx_get(b, other, BLE_CHANNEL_INVITE); CHECK(e && e->len == 3); release(e); + hold_ack = 0; schedule(backends[1]); drain(ua); + unsigned sizes[] = {1500, 17, 7}; + for (unsigned i = 0; i < 3; i++) { + e = ble_rx_get(b, other); CHECK(e && e->len == sizes[i] && e->dgram[0] == 0x41 + i); release(e); + } + CHECK(!ble_rx_get(b, other) && ble_tx_ready(a, id)); + ble_destroy(a); ble_destroy(b); uasync_destroy(ua, 0); + printf("BLE frame window PASS (MTU=%u, two frames, partial/final/duplicate ACK, writable, independent INVITE)\n", test_mtu); +} int main(void) { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); + debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_INFO); size_t before = u_get_allocated_count(); + for (unsigned i = 0; i < 2; i++) { + exercise_resume_order(i ? 517 : 23, 0); exercise_resume_order(i ? 517 : 23, 1); + exercise_resume_stream(i ? 517 : 23); CHECK(u_get_allocated_count() == before); + } exercise_ack_fairness(); CHECK(u_get_allocated_count() == before); exercise(23); exercise(247); exercise(517); + exercise_frame_window(23); exercise_frame_window(517); exercise_flow(); CHECK(u_get_allocated_count() == before); puts("BLE transport tests PASS (MTU 23/247/517, duplex, loss, backpressure, peers, teardown)"); return 0; } diff --git a/src/Makefile.am b/src/Makefile.am index 9269798e5..6addd6167 100644 --- a/src/Makefile.am +++ b/src/Makefile.am @@ -208,6 +208,8 @@ utun_CORE_SOURCES = \ dm/dm_mailbox_media.c \ dm/dm_mailbox_media.h \ call/call.c \ + call/call_audio_batch.c \ + call/call_audio_batch.h \ call/call_video_rate.c \ call/call_video_rate.h \ call/call_video.c \ @@ -402,6 +404,8 @@ libutun_a_SOURCES = \ dm/dm_mailbox_media.c \ dm/dm_mailbox_media.h \ call/call.c \ + call/call_audio_batch.c \ + call/call_audio_batch.h \ call/call_video_rate.c \ call/call_video_rate.h \ call/call_video.c \ diff --git a/src/call/call.c b/src/call/call.c index a908739ef..53c032cb9 100644 --- a/src/call/call.c +++ b/src/call/call.c @@ -40,6 +40,16 @@ #define CALL_ID "call" +/* Локальный кадр до упаковки: время создания не зависит от сетевого wire timestamp пачки. */ +struct call_audio_pending { + uint64_t created_us; + struct call_capture_time timing; + uint16_t seq; +}; +static void call_tx_flush(struct call_session* s); +static void call_audio_batch_tick(void* arg); + + /* ───────────────────────────────────────────────────────────────────────── */ static struct call_ctx* call_of(struct UTUN_INSTANCE* inst) { @@ -140,6 +150,7 @@ static struct call_session* call_session_new(struct call_ctx* ctx, uint64_t call s->state = CALL_ST_IDLE; s->started_tb = get_time_tb(); s->ctx = ctx; + call_audio_batch_init(&s->audio_batch, get_time_us()); s->tx_q = queue_new(ctx->inst->ua, 0, 0, 0, "call_txq"); if (!s->tx_q) { queue_entry_free(qe); return NULL; } // При провале queue_data_put_with_index сам освобождает qe (вместе с s), поэтому @@ -161,6 +172,9 @@ static void call_session_free_cb(void* arg) { * дренировать tx_q (без отправки). Безопасно вызывать из uasync-потока. */ static void call_session_cleanup(struct call_ctx* ctx, struct call_session* s) { call_video_transport_stop(s); + if (s->audio_batch_timer) uasync_cancel_timeout(ctx->inst->ua, s->audio_batch_timer); + if (s->audio_flush_timer) uasync_cancel_timeout(ctx->inst->ua, s->audio_flush_timer); + s->audio_batch_timer = s->audio_flush_timer = NULL; etcp_router_cancel_send_ready(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL_AUDIO, &s->send_waiter); if (s->tx_q) { struct ll_entry* e; @@ -417,6 +431,9 @@ 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; + call_audio_batch_tick(s); + DEBUG_INFO(DEBUG_CATEGORY_CALL, "audio batch startup call=%llx target=%u hold_us=%u", + (unsigned long long)s->call_id, s->audio_batch.target, s->audio_batch.hold_us); 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); @@ -513,8 +530,8 @@ static void call_post_stats(struct call_session* s) { uint16_t rtt = rtt_tb / 10; /* ETCP хранит RTT в 0.1мс; CALL_STATS передаёт миллисекунды. */ uint32_t oldest_ms = 0; if (s->tx_q && s->tx_q->head) { - struct call_pkt_media oldest; memcpy(&oldest, s->tx_q->head->dgram + 1, sizeof(oldest)); - oldest_ms = (uint32_t)(get_time_tb() / 10) - oldest.ts_ms; + struct call_audio_pending *oldest = (struct call_audio_pending*)s->tx_q->head->data; + oldest_ms = (uint32_t)((get_time_us() - oldest->created_us) / 1000); } DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "call audio TX queue id=%016llx drained=%u queued=%zu oldest_ms=%u wait_avg_max_ms=%llu/%u", (unsigned long long)s->call_id, s->tx_wait_frames, s->tx_q ? (size_t)queue_entry_count(s->tx_q) : 0, oldest_ms, @@ -756,46 +773,33 @@ static void call_handle_hangup(struct call_ctx* ctx, uint64_t from, const uint8_ } static void call_handle_media(struct call_ctx* ctx, uint64_t from, const uint8_t* b, size_t n) { - if (n <= CALL_MEDIA_HDR_SIZE) { DEBUG_WARN(DEBUG_CATEGORY_CALL, "call media truncated bytes=%zu", n); return; } - uint64_t call_id; memcpy(&call_id, b + 1, 8); - struct call_session* s = call_session_find(ctx, call_id); - if (!s || s->state != CALL_ST_ACTIVE) { - /* медиа вне ACTIVE — игнорируем (гонка с завершением) */ - return; + struct call_audio_batch_view batch; + if (call_audio_batch_parse(b, n, &batch)) return; + struct call_pkt_media *h = &batch.header; + struct call_session* s = call_session_find(ctx, h->call_id); + if (!s || s->state != CALL_ST_ACTIVE) return; + if (from != s->peer_node_id) { + DEBUG_WARN(DEBUG_CATEGORY_CALL, "audio batch wrong peer=%llx expected=%llx", + (unsigned long long)from, (unsigned long long)s->peer_node_id); return; } - struct call_pkt_media packet; - memcpy(&packet, b, sizeof(packet)); - if (from != s->peer_node_id || packet.track_id != CALL_TRACK_AUDIO || packet.codec_delay_samples > CALL_OPUS_SAMPLE_RATE - || (packet.capture_pts_us != INT64_MIN && (packet.capture_pts_us < 0 || packet.capture_pts_us > INT64_MAX / 2 - || !packet.capture_epoch))) { - DEBUG_WARN(DEBUG_CATEGORY_CALL, "call media invalid peer=%016llx expected=%016llx track=%u pts=%lld epoch=%llu delay=%u", - (unsigned long long)from, (unsigned long long)s->peer_node_id, packet.track_id, - (long long)packet.capture_pts_us, (unsigned long long)packet.capture_epoch, packet.codec_delay_samples); - return; - } - struct call_capture_time timing = {packet.capture_pts_us, packet.capture_epoch, packet.codec_delay_samples}; - s->c_rx_frames++; + ++s->rx_batches; + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "audio batch RX call=%llx frames=%u bytes=%zu first_seq=%u sent_ms=%u", + (unsigned long long)s->call_id, h->count, n, batch.frames[0].header.seq, h->sent_ms); s->last_media_tb = get_time_tb(); - - uint16_t seq = 0; - uint32_t ts_ms = 0; - memcpy(&seq, b + 10, 2); - memcpy(&ts_ms, b + 12, 4); - - /* аудио-движок (декод + джиттер-буфер) — если зарегистрирован */ - const struct call_audio_ops* audio = ctx->inst->call_audio_ops; - if (audio && audio->on_media) - audio->on_media(ctx->inst, call_id, seq, ts_ms, b + CALL_MEDIA_HDR_SIZE, (int)(n - CALL_MEDIA_HDR_SIZE), &timing); - - /* релей Opus-кадра потребителю (headless-аудио-сокет) */ - if (ctx->media_recv_fn) - ctx->media_recv_fn(ctx->inst, call_id, b + CALL_MEDIA_HDR_SIZE, - (int)(n - CALL_MEDIA_HDR_SIZE), ctx->media_recv_arg); - - /* watchdog: пере-взводим таймер no-traffic на каждый принятый кадр */ - if (s->traffic_timer) { uasync_cancel_timeout(ctx->inst->ua, s->traffic_timer); s->traffic_timer = NULL; } + for (unsigned i = 0; i < h->count && s->state == CALL_ST_ACTIVE; i++) { + const struct call_audio_frame_view *f = &batch.frames[i]; + struct call_capture_time timing = {f->header.capture_pts_us, h->capture_epoch, h->codec_delay_samples}; + s->c_rx_frames++; + const struct call_audio_ops* audio = ctx->inst->call_audio_ops; + if (audio && audio->on_media) + audio->on_media(ctx->inst, s->call_id, f->header.seq, h->sent_ms, f->opus, f->header.length, &timing, i == 0); + if (ctx->media_recv_fn && s->state == CALL_ST_ACTIVE) + ctx->media_recv_fn(ctx->inst, s->call_id, f->opus, f->header.length, ctx->media_recv_arg); + } + if (s->state != CALL_ST_ACTIVE) return; + if (s->traffic_timer) uasync_cancel_timeout(ctx->inst->ua, s->traffic_timer); s->traffic_timer = uasync_set_timeout(ctx->inst->ua, CALL_NO_TRAFFIC_TB, s, call_traffic_timeout_cb, "call_traffic"); - (void)from; + if (!s->traffic_timer) DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio RX watchdog allocation failed"); } /* ─────────────────── приём (etcp_router) ─────────────────── */ @@ -1001,77 +1005,125 @@ static void call_tx_drain_cb(struct ll_queue* q, void* arg) { call_tx_flush(s); } -/* Дренируем tx_q, пока роутер готов; иначе — одноразовый waiter (re-arm в drain_cb). */ +/* Deadline относится к возрасту головы; новые кадры не отодвигают срок отправки. */ +static void call_audio_flush_timer(void* arg) { + struct call_session *s = arg; s->audio_flush_timer = NULL; call_tx_flush(s); +} +static void call_audio_batch_tick(void* arg) { + struct call_session *s = arg; s->audio_batch_timer = NULL; + if (s->state != CALL_ST_ACTIVE) return; + uint64_t now = get_time_us(); + struct ETCP_ROUTER_TX_STATS net = {0}; + int found = etcp_router_tx_stats(s->ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL_AUDIO, now, &net) == 0; + if (call_audio_batch_step(&s->audio_batch, now, found ? &net : NULL)) s->audio_batch.hold_us = 0; + if (now - s->audio_report_us >= 1000000) { + s->audio_report_us = now; + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, + "audio batch flow call=%llx target=%u hold_us=%u router_rtt_us=%llu queued=%u/%llu inflight=%u/%llu " + "ack_bytes_500ms=%llu local_frames=%d tx_frames=%u tx_batches=%u rx_frames=%u rx_batches=%u", + (unsigned long long)s->call_id, s->audio_batch.target, s->audio_batch.hold_us, + (unsigned long long)net.delivery_rtt_us, net.queued_packets, (unsigned long long)net.queued_age_us, + net.inflight_packets, (unsigned long long)net.inflight_age_us, (unsigned long long)net.window.bytes, + s->tx_q ? queue_entry_count(s->tx_q) : 0, s->c_tx_frames, s->tx_batches, s->c_rx_frames, s->rx_batches); + } + call_tx_flush(s); + if (s->state != CALL_ST_ACTIVE) return; + s->audio_batch_timer = uasync_set_timeout(s->ctx->inst->ua, 500, s, call_audio_batch_tick, "audio_batch"); + if (!s->audio_batch_timer) { + s->audio_batch.hold_us = 0; + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio batch timer allocation failed; immediate TX"); + } +} + +/* Пачка формируется до Router: принятые им кодограммы не изменяются. */ static void call_tx_flush(struct call_session* s) { struct call_ctx* ctx = s->ctx; - if (s->state == CALL_ST_ENDED) return; - /* гарантируем router conn — иначе has_room/on_send_ready не видят его */ - etcp_router_conn_get(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL_AUDIO); - while (s->tx_q && s->tx_q->head) { + if (s->state != CALL_ST_ACTIVE || !s->tx_q || !s->tx_q->head) return; + if (s->audio_flush_timer) { uasync_cancel_timeout(ctx->inst->ua, s->audio_flush_timer); s->audio_flush_timer = NULL; } + if (!etcp_router_conn_get(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL_AUDIO)) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio router channel unavailable; retaining frames"); return; + } + while (s->state == CALL_ST_ACTIVE && s->tx_q && s->tx_q->head) { if (!etcp_router_send_q_has_room(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL_AUDIO)) { etcp_router_on_send_ready(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL_AUDIO, &s->send_waiter, call_tx_drain_cb, s); return; } - struct ll_entry* e = queue_data_get(s->tx_q); - struct call_pkt_media packet; memcpy(&packet, e->dgram + 1, sizeof(packet)); - uint32_t wait_ms = (uint32_t)(get_time_tb() / 10) - packet.ts_ms; - ++s->tx_wait_frames; s->tx_wait_total_ms += wait_ms; - if (wait_ms > s->tx_wait_max_ms) s->tx_wait_max_ms = wait_ms; - if (etcp_route_send(ctx->inst, s->group_id, s->peer_node_id, e, 1, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT) == 0) { - s->c_tx_frames++; - if (packet.capture_pts_us != INT64_MIN) call_video_audio_sent(s, packet.capture_pts_us + 20000); - } else DEBUG_WARN(DEBUG_CATEGORY_CALL, "%s: media enqueue failed id=%016llx", CALL_ID, (unsigned long long)s->call_id); + struct call_audio_pending *first = (struct call_audio_pending*)s->tx_q->head->data; + struct call_audio_batch_view batch = {.header = {.subcmd = CALL_SUBCMD_MEDIA, .call_id = s->call_id, + .track_id = CALL_TRACK_AUDIO, .capture_epoch = first->timing.epoch, + .codec_delay_samples = first->timing.delay_samples, .sent_ms = (uint32_t)(get_time_tb() / 10)}}; + size_t size = CALL_MEDIA_HDR_SIZE; + struct ll_entry *next = s->tx_q->head; + while (next && batch.header.count < s->audio_batch.target) { + struct call_audio_pending *p = (struct call_audio_pending*)next->data; + if (p->timing.epoch != first->timing.epoch || p->timing.delay_samples != first->timing.delay_samples + || size + CALL_AUDIO_FRAME_HDR_SIZE + next->len > CALL_MEDIA_MAX_BYTES) break; + batch.frames[batch.header.count++] = (struct call_audio_frame_view){ + .header = {p->seq, p->timing.pts_us, next->len}, .opus = next->dgram}; + size += CALL_AUDIO_FRAME_HDR_SIZE + next->len; next = next->next; + } + uint64_t now = get_time_us(), age = now - first->created_us; + if (!next && batch.header.count < s->audio_batch.target && age < s->audio_batch.hold_us) { + s->audio_flush_timer = uasync_set_timeout(ctx->inst->ua, (s->audio_batch.hold_us - age + 199) / 100, + s, call_audio_flush_timer, "audio_batch_flush"); + if (s->audio_flush_timer) return; + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio flush timer allocation failed; sending incomplete batch"); + } + struct ll_entry *wire = ll_alloc_lldgram(1 + size); + if (!wire) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio batch allocation failed bytes=%zu", size); return; } + wire->dgram[0] = ETCP_RT_ID_CALL_AUDIO; + size_t encoded = 0; + if (call_audio_batch_encode(&batch, wire->dgram + 1, size, &encoded)) { + queue_dgram_free(wire); queue_entry_free(wire); return; + } + wire->len = 1 + encoded; + unsigned count = batch.header.count; + int64_t last_pts = batch.frames[count - 1].header.capture_pts_us; + /* Снять исходные кадры до синхронных callbacks Router, которые могут завершить звонок. */ + for (unsigned i = 0; i < count; i++) { + struct ll_entry *e = queue_data_get(s->tx_q); + uint32_t wait_ms = (uint32_t)((now - ((struct call_audio_pending*)e->data)->created_us) / 1000); + s->tx_wait_frames++; s->tx_wait_total_ms += wait_ms; + if (wait_ms > s->tx_wait_max_ms) s->tx_wait_max_ms = wait_ms; + queue_dgram_free(e); queue_entry_free(e); + } + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "audio batch TX call=%llx frames=%u target=%u bytes=%zu oldest_us=%llu first_seq=%u", + (unsigned long long)s->call_id, count, s->audio_batch.target, encoded, + (unsigned long long)age, batch.frames[0].header.seq); + if (etcp_route_send(ctx->inst, s->group_id, s->peer_node_id, wire, 1, ROUTE_CRYPTO_SIGN | ROUTE_CRYPTO_ENCRYPT) == 0) { + s->c_tx_frames += count; s->tx_batches++; + if (s->state == CALL_ST_ACTIVE && last_pts != INT64_MIN) call_video_audio_sent(s, last_pts + 20000); + } else { + s->c_tx_dropped += count; + DEBUG_WARN(DEBUG_CATEGORY_CALL, "audio batch enqueue failed call=%llx frames=%u", (unsigned long long)s->call_id, count); + } } } int call_send_media(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t* opus, int len, const struct call_capture_time* timing) { struct call_ctx* ctx = call_of(inst); - if (!ctx || !ctx->initialized || !opus || len <= 0) return -1; + if (!ctx || !ctx->initialized) return -1; struct call_session* s = call_session_find(ctx, call_id); if (!s || s->state != CALL_ST_ACTIVE) return -1; - if (CALL_MEDIA_HDR_SIZE + (size_t)len > 1024) return -1; - if (timing && (timing->delay_samples > CALL_OPUS_SAMPLE_RATE || (timing->pts_us != INT64_MIN - && (timing->pts_us < 0 || timing->pts_us > INT64_MAX / 2 || !timing->epoch)))) { - DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call media invalid capture metadata pts=%lld epoch=%llu delay=%u", - (long long)timing->pts_us, (unsigned long long)timing->epoch, timing->delay_samples); - return -1; + if (!opus || len <= 0 || (size_t)len > CALL_AUDIO_MAX_OPUS || (timing && (timing->delay_samples > CALL_OPUS_SAMPLE_RATE + || (timing->pts_us != INT64_MIN && (timing->pts_us < 0 || timing->pts_us > INT64_MAX / 2 || !timing->epoch))))) { + DEBUG_WARN(DEBUG_CATEGORY_CALL, "audio frame invalid length/timing len=%d", len); return -1; } - - uint8_t buf[1024]; - struct call_pkt_media* m = (struct call_pkt_media*)buf; - m->subcmd = CALL_SUBCMD_MEDIA; - m->call_id = call_id; - m->track_id = CALL_TRACK_AUDIO; - m->seq = s->tx_media_seq++; - m->ts_ms = (uint32_t)(get_time_tb() / 10); - m->capture_pts_us = timing ? timing->pts_us : INT64_MIN; - m->capture_epoch = timing ? timing->epoch : 0; - m->codec_delay_samples = timing ? timing->delay_samples : 0; - memcpy(buf + CALL_MEDIA_HDR_SIZE, opus, (size_t)len); - size_t mlen = CALL_MEDIA_HDR_SIZE + (size_t)len; - - /* entry: [svc_id][media-пакет] — кладём в tx_q (переполнение → дроп старейшего) */ - struct ll_entry* e = queue_entry_new(0); - if (!e) return -1; - e->dgram = u_malloc(1 + mlen); - if (!e->dgram) { queue_entry_free(e); return -1; } - e->dgram[0] = ETCP_RT_ID_CALL_AUDIO; - memcpy(e->dgram + 1, buf, mlen); - e->len = (uint16_t)(1 + mlen); - + struct ll_entry* e = queue_entry_new(sizeof(struct call_audio_pending)); + if (!e) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio frame metadata allocation failed"); return -1; } + e->dgram = u_malloc(len); + if (!e->dgram) { queue_entry_free(e); DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio frame allocation failed len=%d", len); return -1; } + *(struct call_audio_pending*)e->data = (struct call_audio_pending){get_time_us(), + timing ? *timing : (struct call_capture_time){INT64_MIN, 0, 0}, s->tx_media_seq++}; + memcpy(e->dgram, opus, len); e->len = len; 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 (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_dgram_free(old); queue_entry_free(old); s->c_tx_dropped++; + DEBUG_WARN(DEBUG_CATEGORY_CALL, "audio TX overflow call=%llx dropped=%u", (unsigned long long)call_id, s->c_tx_dropped); } queue_data_put(s->tx_q, e); - call_tx_flush(s); return 0; } diff --git a/src/call/call.h b/src/call/call.h index b989901a0..7bb29c4b0 100644 --- a/src/call/call.h +++ b/src/call/call.h @@ -74,7 +74,7 @@ uint8_t call_phone_status(struct UTUN_INSTANCE* inst); // ── медиа (релей Opus-кадров, uasync-поток) ── /* Отправить закодированный Opus-кадр пиру (оборачивает в CALL_MEDIA). - * Только ACTIVE: len=1..988, opus/timing копируются до возврата; timing=NULL — без capture PTS. + * Только ACTIVE: len=1..985, opus/timing копируются до возврата; timing=NULL — без capture PTS. * seq/ts выставляются автоматически. 0 = принят в локальную FIFO, -1 = неверные данные/нет ACTIVE/ошибка памяти. * Результат не подтверждает доставку: FIFO750 удаляет старейший кадр при заполнении, роутер может отказать при drain. */ int call_send_media(struct UTUN_INSTANCE* inst, uint64_t call_id, const uint8_t* opus, int len, @@ -91,17 +91,20 @@ void call_set_media_recv_cb(struct UTUN_INSTANCE* inst, call_media_recv_fn fn, v /* ── аудио-движок (опционально, GUI-сборки) ── * Сервисный аудио-контур (codec + джиттер-буфер + тоны) регистрируется через - * call_set_audio_ops. call.c зовёт on_media на каждый принятый медиа-кадр (с seq/ts) + * call_set_audio_ops. call.c зовёт on_media на каждый принятый медиа-кадр (с seq и временем подачи пачки в Router) * и get_stats для события CALL_STATS. Демон без аудио (headless) — ops не ставит. */ struct call_audio_ops { - // uasync-поток: принятый Opus-кадр; opus/timing заимствованы до возврата. Декодирование определяет frontend. + // uasync: Opus/PTS заимствованы; ts_ms — время пачки, packet_start=1 только у её первого кадра. void (*on_media)(struct UTUN_INSTANCE* inst, uint64_t call_id, - uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len, const struct call_capture_time* timing); + uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len, const struct call_capture_time* timing, int packet_start); /* uasync-поток: метрики (buffer_ms, tempo_x100=100 для 1x, накопительные dropped/underruns, range). * Выходы необязательны. 0 = снимок получен, -1 = нет активного DSP; реализация может потреблять события диагностики. */ int (*get_stats)(uint64_t call_id, int* buffer_ms, int* tempo_x100, uint32_t* dropped, uint32_t* underruns, struct call_jitter_range* range); + /* uasync: применить opus_codec_preset (0/1/2 = 16/32/64 кбит/с) к текущему и следующим звонкам. + * Необязательный callback; 0 = применено, -1 = ошибка, прежний битрейт сохранён. */ + int (*set_codec_preset)(struct UTUN_INSTANCE* inst, int preset); }; /* Только uasync: сохранить заимствованный ops у ядра (NULL снимает регистрацию), допустимо до запуска чата. * Объект живёт до снятия/уничтожения ядра. Stop/start чата сохраняет регистрацию, состояние звонка — нет. */ diff --git a/src/call/call_audio.c b/src/call/call_audio.c index a6879bd00..8843279c8 100644 --- a/src/call/call_audio.c +++ b/src/call/call_audio.c @@ -24,7 +24,6 @@ #include #define CALL_AUDIO_ID "call_audio" -#define CALL_AUDIO_BITRATE 32000 #define CALL_STALL_SAMPLES (CALL_AUDIO_SAMPLE_RATE / 2) /* 500мс без PCM из jitter → глитч-тон */ #define CALL_AEC_FILTER_SAMPLES 14400 /* хвост эха 300мс @48кГц */ #define CALL_AEC_DELAY_FRAMES 2 /* desktop: минимальная задержка duplex */ @@ -39,6 +38,7 @@ static int g_ended_started = 0; /* только аудио- static uint64_t g_call_id = 0; static int g_gap_samples = 0; /* только аудиопоток; насыщение на пороге */ static opus_codec_encoder_t* g_encoder = NULL; +static int g_codec_preset = 1; /* Снимок настройки из uasync; защищён g_mtx. */ static opus_codec_decoder_t* g_decoder = NULL; static struct call_jitter* g_vj = NULL; static struct call_tones* g_tones = NULL; @@ -102,9 +102,32 @@ static void call_audio_send(void* arg) { if (send) call_send_media(tx->inst, tx->call_id, tx->opus, tx->len, &tx->timing); } +static int call_audio_set_codec_preset(struct UTUN_INSTANCE* inst, int preset) { + if (preset < 0 || preset > 2) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: invalid codec preset=%d", CALL_AUDIO_ID, preset); return -1; + } + int bitrate = 16000 << preset; + pthread_mutex_lock(&g_mtx); + if (g_inst != inst) { + pthread_mutex_unlock(&g_mtx); + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: codec setting for detached instance", CALL_AUDIO_ID); return -1; + } + if (g_encoder && opus_codec_encoder_bitrate_set(g_encoder, bitrate) < 0) { + pthread_mutex_unlock(&g_mtx); + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: codec bitrate update failed preset=%d bitrate=%d", CALL_AUDIO_ID, preset, bitrate); + return -1; + } + g_codec_preset = preset; + DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: codec preset=%d bitrate=%d active=%d call=%016llx", CALL_AUDIO_ID, + preset, bitrate, g_active, (unsigned long long)g_call_id); + pthread_mutex_unlock(&g_mtx); + return 0; +} + static const struct call_audio_ops g_ops = { .on_media = call_audio_on_media, .get_stats = call_audio_get_stats, + .set_codec_preset = call_audio_set_codec_preset, }; /* Создать AEC с текущей задержкой (объявление; определение ниже). */ @@ -128,7 +151,7 @@ int call_audio_init(struct UTUN_INSTANCE* inst) { } call_set_audio_ops(inst, &g_ops); pthread_mutex_unlock(&g_mtx); - return 0; + return call_audio_set_codec_preset(inst, chat_setting_get_int(inst, "opus_codec_preset", 1)); } void call_audio_destroy(struct UTUN_INSTANCE* inst) { @@ -147,12 +170,12 @@ void call_audio_destroy(struct UTUN_INSTANCE* inst) { /* ── uasync-поток: приём медиа (кладём кодированный кадр в jitter-буфер) ── */ 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, const struct call_capture_time* timing) { + uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len, const struct call_capture_time* timing, int packet_start) { (void)seq; pthread_mutex_lock(&g_mtx); if (inst == g_inst && g_active && call_id == g_call_id && g_vj && opus && len > 0) { - call_jitter_push(g_vj, opus, len, ts_ms, (int64_t)(get_time_tb() / 10), timing); + call_jitter_push(g_vj, opus, len, ts_ms, (int64_t)(get_time_tb() / 10), timing, packet_start); } pthread_mutex_unlock(&g_mtx); } @@ -190,7 +213,13 @@ static int call_audio_start_impl(uint64_t call_id, int prepared) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: encoder create failed", CALL_AUDIO_ID); return -1; } - opus_codec_encoder_bitrate_set(enc, CALL_AUDIO_BITRATE); + int preset = g_codec_preset, bitrate = 16000 << preset; + if (opus_codec_encoder_bitrate_set(enc, bitrate) < 0) { + opus_codec_encoder_destroy(enc); + pthread_mutex_unlock(&g_mtx); + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "%s: encoder bitrate failed preset=%d bitrate=%d", CALL_AUDIO_ID, preset, bitrate); + return -1; + } int codec_delay = 0; if (opus_codec_encoder_delay(enc, &codec_delay) < 0) { opus_codec_encoder_destroy(enc); @@ -277,8 +306,8 @@ static int call_audio_start_impl(uint64_t call_id, int prepared) { g_aec_reported_over = g_aec_reported_under = g_pcm_events = 0; pthread_mutex_unlock(&g_mtx); - DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: started call=0x%016llx aec=%d delay=%d compressor=%s gain=%d", CALL_AUDIO_ID, - (unsigned long long)call_id, g_aec ? 1 : 0, g_aec_delay_frames, + DEBUG_INFO(DEBUG_CATEGORY_CALL, "%s: started call=0x%016llx preset=%d bitrate=%d aec=%d delay=%d compressor=%s gain=%d", CALL_AUDIO_ID, + (unsigned long long)call_id, preset, bitrate, g_aec ? 1 : 0, g_aec_delay_frames, ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable", compressor_gain); return 0; diff --git a/src/call/call_audio.h b/src/call/call_audio.h index eab1887e5..b83a82599 100644 --- a/src/call/call_audio.h +++ b/src/call/call_audio.h @@ -45,7 +45,8 @@ int call_audio_init(struct UTUN_INSTANCE* inst); * Поздний detach прежнего inst не затрагивает нового владельца. Codec/jitter освобождаются отдельно через stop/release. */ void call_audio_destroy(struct UTUN_INSTANCE* inst); -/* GUI-поток: создать codec + jitter + tones. Старый API принимает raw PCM и использует core AEC. +/* GUI-поток: создать codec + jitter + tones. Битрейт берётся из opus_codec_preset; runtime-set обновляет его без restart. + * Старый API принимает raw PCM и использует core AEC. * Нужен привязанный через init живой inst; без него -1. Для desktop нужен start_prepared: * AEC и stereo reference принадлежат VoiceAudioIo. Повтор активного ID сохраняет DSP; * смена raw/prepared для него отклоняется. До смены ID остановить оба I/O-направления. 0 = успех, -1 = ошибка. */ @@ -80,9 +81,9 @@ int call_audio_feed_pcm_muted(uint64_t call_id, const int16_t* pcm, int count, i int call_audio_pull_pcm(uint64_t call_id, int16_t* out, int max_samples); /* uasync-поток (из call.c): кадр текущего inst/активного DSP передаётся в call_jitter_push; - * данные копируются, декодирование произойдёт на pull в аудио-потоке. Чужой inst/ID или отсутствие DSP — no-op. */ + * данные копируются, декодирование на pull. ts_ms — время пачки; packet_start=1 только на первом кадре. Чужой inst/ID или отсутствие DSP — no-op. */ 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, const struct call_capture_time* timing); + uint16_t seq, uint32_t ts_ms, const uint8_t* opus, int len, const struct call_capture_time* timing, int packet_start); /* Playback worker: номер PCM-фрейма и время его вывода, epoch берётся из get_clock после start/reset. * UI/video: get_clock возвращает 0 для существующего звонка; valid внутри снимка может быть 0. diff --git a/src/call/call_audio_batch.c b/src/call/call_audio_batch.c new file mode 100644 index 000000000..0819b0677 --- /dev/null +++ b/src/call/call_audio_batch.c @@ -0,0 +1,105 @@ +#include "call_audio_batch.h" +#include "../routing_layer/etcp_router.h" +#include "../../lib/debug_config.h" +#include + +static int valid_header(const struct call_pkt_media *h) { + return h->subcmd == CALL_SUBCMD_MEDIA && h->call_id && h->track_id == CALL_TRACK_AUDIO + && h->count && h->count <= CALL_AUDIO_BATCH_MAX && h->codec_delay_samples <= CALL_OPUS_SAMPLE_RATE; +} +static int valid_frame(const struct call_pkt_media *h, const struct call_pkt_audio_frame *f) { + return f->length && f->length <= CALL_AUDIO_MAX_OPUS && (f->capture_pts_us == INT64_MIN + || (f->capture_pts_us >= 0 && f->capture_pts_us <= INT64_MAX / 2 && h->capture_epoch)); +} +int call_audio_batch_encode(const struct call_audio_batch_view *batch, uint8_t *out, size_t capacity, size_t *length) { + if (!batch || !out || !length || !valid_header(&batch->header)) goto invalid; + size_t total = CALL_MEDIA_HDR_SIZE; + for (unsigned i = 0; i < batch->header.count; i++) { + if (!batch->frames[i].opus || !valid_frame(&batch->header, &batch->frames[i].header)) goto invalid; + total += CALL_AUDIO_FRAME_HDR_SIZE + batch->frames[i].header.length; + } + if (total > capacity || total > CALL_MEDIA_MAX_BYTES) goto invalid; + memcpy(out, &batch->header, CALL_MEDIA_HDR_SIZE); + size_t offset = CALL_MEDIA_HDR_SIZE; + for (unsigned i = 0; i < batch->header.count; i++) { + const struct call_audio_frame_view *f = &batch->frames[i]; + memcpy(out + offset, &f->header, CALL_AUDIO_FRAME_HDR_SIZE); offset += CALL_AUDIO_FRAME_HDR_SIZE; + memcpy(out + offset, f->opus, f->header.length); offset += f->header.length; + } + *length = total; + return 0; +invalid: + DEBUG_WARN(DEBUG_CATEGORY_CALL, "audio batch encode invalid fields/capacity=%zu", capacity); return -1; +} +int call_audio_batch_parse(const uint8_t *wire, size_t length, struct call_audio_batch_view *out) { + if (!wire || !out || length < CALL_MEDIA_HDR_SIZE || length > CALL_MEDIA_MAX_BYTES) goto invalid; + struct call_audio_batch_view result = {0}; + memcpy(&result.header, wire, CALL_MEDIA_HDR_SIZE); + if (!valid_header(&result.header)) goto invalid; + size_t offset = CALL_MEDIA_HDR_SIZE; + for (unsigned i = 0; i < result.header.count; i++) { + struct call_audio_frame_view *f = &result.frames[i]; + if (length - offset < CALL_AUDIO_FRAME_HDR_SIZE) goto invalid; + memcpy(&f->header, wire + offset, CALL_AUDIO_FRAME_HDR_SIZE); offset += CALL_AUDIO_FRAME_HDR_SIZE; + if (!valid_frame(&result.header, &f->header) || f->header.length > length - offset) goto invalid; + f->opus = wire + offset; offset += f->header.length; + } + if (offset != length) goto invalid; + *out = result; + return 0; +invalid: + DEBUG_WARN(DEBUG_CATEGORY_CALL, "audio batch RX malformed bytes=%zu", length); return -1; +} + +void call_audio_batch_init(struct call_audio_batch_control *c, uint64_t now) { + if (!c) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio batch reset without state"); return; } + *c = (struct call_audio_batch_control){.target = 4, .hold_us = 60000, .last_us = now}; +} +int call_audio_batch_step(struct call_audio_batch_control *c, uint64_t now, const struct ETCP_ROUTER_TX_STATS *net) { + if (!c || !c->target || now < c->last_us || (net && net->last_ack_us > now)) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio batch invalid control sample time=%llu", (unsigned long long)now); return -1; + } + unsigned previous = c->target; + if ((!net && (c->local_epoch || c->peer_epoch || c->feedback_received)) + || (net && (net->local_epoch != c->local_epoch || net->peer_epoch != c->peer_epoch))) { + call_audio_batch_init(c, now); + if (net) { c->local_epoch = net->local_epoch; c->peer_epoch = net->peer_epoch; } + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "audio batch reset router epoch=%llx/%llx previous=%u", + (unsigned long long)c->local_epoch, (unsigned long long)c->peer_epoch, previous); + } + c->last_us = now; + c->delay_us = net ? net->delivery_rtt_us : 0; + if (net && net->queued_age_us > c->delay_us) c->delay_us = net->queued_age_us; + if (net && net->inflight_age_us > c->delay_us) c->delay_us = net->inflight_age_us; + uint64_t interval = net && net->delivery_rtt_us > 250000 ? net->delivery_rtt_us * 2 : 500000; + uint64_t fresh_us = interval > 2000000 ? interval : 2000000; + int measured = net && net->ready && !net->no_route && !net->receive_busy && !net->probing + && net->acked_packets && now - net->last_ack_us <= fresh_us; + if (measured) c->feedback_received = 1; + int usable = measured && net->window.valid; + const char *reason = "hold"; + if (!usable) { c->high = c->low = 0; reason = "unavailable"; } + else if (c->delay_us > 100000) { + c->low = 0; + if (!c->high) { c->high = 1; c->high_since_us = now; } + if (c->target < CALL_AUDIO_BATCH_MAX && now >= c->next_change_us && now - c->high_since_us >= 200000) { + c->target++; c->next_change_us = now + interval; reason = "enqueue_ack_delay"; + } + } else if (c->delay_us < 40000 && !net->queued_packets) { + c->high = 0; + if (!c->low) { c->low = 1; c->low_since_us = now; } + if (c->target > 1 && now >= c->next_change_us && now - c->low_since_us >= 2000000) { + c->target--; c->next_change_us = now + interval; c->low_since_us = now; reason = "recovered"; + } + } else c->high = c->low = 0; + /* Даже при медленном снижении target быстрый канал сразу перестаёт ждать накопления. */ + unsigned budget = c->delay_us > 40000 ? (unsigned)((c->delay_us - 40000) / 4 > 40000 ? 40000 : (c->delay_us - 40000) / 4) : 0; + unsigned target_wait = (c->target - 1) * 20000; + int starting = !c->feedback_received && (!net || (!net->no_route && !net->receive_busy && !net->probing)); + c->hold_us = measured ? (target_wait < budget ? target_wait : budget) : (starting ? 60000 : 0); + if (c->target != previous) DEBUG_INFO(DEBUG_CATEGORY_CALL, + "audio batch target=%u->%u reason=%s delay_us=%llu router_rtt_us=%llu queued_us=%llu flight_us=%llu hold_us=%u", + previous, c->target, reason, (unsigned long long)c->delay_us, (unsigned long long)(net ? net->delivery_rtt_us : 0), + (unsigned long long)(net ? net->queued_age_us : 0), (unsigned long long)(net ? net->inflight_age_us : 0), c->hold_us); + return 0; +} diff --git a/src/call/call_audio_batch.h b/src/call/call_audio_batch.h new file mode 100644 index 000000000..12f1d9f3a --- /dev/null +++ b/src/call/call_audio_batch.h @@ -0,0 +1,39 @@ +/* Упаковка Opus и регулятор пачек звонка. Только uasync; wire views заимствуют входной буфер. + * Контроллер использует полный Router enqueue->ACK, не вычитает базовый RTT. + * Владелец снимает статистику каждые 50мс, ограничивает ожидание пачки hold_us и отменяет свои таймеры. */ +#ifndef CALL_AUDIO_BATCH_H +#define CALL_AUDIO_BATCH_H +#include "call_proto.h" +#include +#ifdef __cplusplus +extern "C" { +#endif +struct ETCP_ROUTER_TX_STATS; +struct call_audio_frame_view { + struct call_pkt_audio_frame header; /* Исходные seq/PTS/length; payload не копируется. */ + const uint8_t *opus; /* Не менее length байт до конца encode/потребления view. */ +}; +struct call_audio_batch_view { + struct call_pkt_media header; /* Общие поля; count определяет число frames. */ + struct call_audio_frame_view frames[CALL_AUDIO_BATCH_MAX]; +}; +struct call_audio_batch_control { + uint64_t local_epoch, peer_epoch, last_us, high_since_us, low_since_us, next_change_us; + uint64_t delay_us; /* max полного RTT и возраста Router-очередей; собственное накопление сюда не входит. */ + unsigned target, hold_us; /* 1..10 кадров; старт4/60000мкс, после измерения 0..40000мкс. */ + int feedback_received; /* Есть первый пригодный ACK в текущей Router epoch. */ + int high, low; +}; +/* Encode/parse: 0/-1. Проверяют весь пакет до изменения out/length/view; ошибки логируются. + * Encode не допускает перекрытие output с Opus; capacity >= вычисленного размера, максимум 1024. */ +int call_audio_batch_encode(const struct call_audio_batch_view *batch, uint8_t *out, size_t capacity, size_t *length); +int call_audio_batch_parse(const uint8_t *wire, size_t length, struct call_audio_batch_view *out); +/* Начать с четырёх кадров и deadline60мс до первого измерения ACK. Reset нужен на каждый новый звонок. */ +void call_audio_batch_init(struct call_audio_batch_control *control, uint64_t now_us); +/* Снимок NULL — канал пока отсутствует. Смена эпохи сбрасывает историю; 0/-1. + * BLOCKED/нет свежих ACK: рост запрещён. Значения порогов и причин смены пишет DEBUG_CATEGORY_CALL. */ +int call_audio_batch_step(struct call_audio_batch_control *control, uint64_t now_us, const struct ETCP_ROUTER_TX_STATS *net); +#ifdef __cplusplus +} +#endif +#endif diff --git a/src/call/call_jitter.cpp b/src/call/call_jitter.cpp index a4908e07b..da7f5e41a 100644 --- a/src/call/call_jitter.cpp +++ b/src/call/call_jitter.cpp @@ -38,7 +38,7 @@ struct call_jitter { CallDelay delay{jitter_decode, this, [](void* context, const soundtouch::SourceSpan& span) { static_cast(context)->timeline.source(span); }, this}; - bool primed = false; + bool primed = false, playback_started = false; int budget = 0, attempts = 0, error_result = 0, error_len = 0; uint32_t decode_errors = 0; }; @@ -100,8 +100,8 @@ struct call_jitter* call_jitter_create(call_jitter_decode_fn decode, void* arg, DEBUG_ERROR(DEBUG_CATEGORY_CALL, "call_jitter: delay processor unavailable"); j->~call_jitter(); u_free(j); return nullptr; } - DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_jitter: created capacity=%dms reserve=%d..%dms adaptive_pcm=1", - CALL_JITTER_MAX_FRAMES * CALL_JITTER_FRAME_MS, CALL_JITTER_MIN_RESERVE_MS, max_reserve_ms); + DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_jitter: created capacity=%dms reserve=%d..%dms startup=%dms adaptive_pcm=1", + CALL_JITTER_MAX_FRAMES * CALL_JITTER_FRAME_MS, CALL_JITTER_MIN_RESERVE_MS, max_reserve_ms, CALL_JITTER_STARTUP_MS); return j; } @@ -111,7 +111,7 @@ void call_jitter_destroy(struct call_jitter* j) { } void call_jitter_push(struct call_jitter* j, const uint8_t* enc, int len, uint32_t sent_ms, int64_t received_ms, - const struct call_capture_time* timing) { + const struct call_capture_time* timing, int packet_start) { if (!j || !enc || len <= 0 || len > kMaxEncoded || received_ms < 0) { DEBUG_WARN(DEBUG_CATEGORY_CALL, "call_jitter: invalid frame len=%d received=%lldms", len, (long long)received_ms); return; @@ -122,7 +122,7 @@ void call_jitter_push(struct call_jitter* j, const uint8_t* enc, int len, uint32 (long long)j->arrival_window.last_received_ms, (long long)received_ms); return; } - j->arrival_window.observe_arrival(received_ms, sent_ms); + if (packet_start) j->arrival_window.observe_arrival(received_ms, sent_ms); j->range.reserve_ms = j->arrival_window.reserve; if (j->count == CALL_JITTER_MAX_FRAMES) { j->head = (j->head + 1) % CALL_JITTER_MAX_FRAMES; j->count--; j->dropped++; @@ -148,7 +148,9 @@ int call_jitter_pull(struct call_jitter* j, int16_t* out, int max_samples) { } if (!j->primed) { // После первого полного блока остаётся сетевой запас, не скрытый запас DSP. - if (depth_samples < reserve * 48 + max_samples) { + int required = reserve * 48 + max_samples; + if (!j->playback_started) required = std::max(required, CALL_JITTER_STARTUP_MS * 48); + if (depth_samples < required) { std::lock_guard lock(j->mtx); j->diagnostics.depth_ms = (int)depth; j->diagnostics.reserve_ms = reserve; j->diagnostics.tempo_x100 = j->tempo_x100; j->diagnostics.waiting = 1; @@ -162,6 +164,11 @@ int call_jitter_pull(struct call_jitter* j, int16_t* out, int max_samples) { int n = (int)j->delay.pull(out, (unsigned)max_samples, count * kFrameSamples, reserve * 48); auto state = j->delay.stats(); j->primed = n == max_samples; + if (n > 0 && !j->playback_started) { + j->playback_started = true; + DEBUG_INFO(DEBUG_CATEGORY_CALL, "call_jitter: startup complete buffered_ms=%.1f target_ms=%d reserve_ms=%d pcm=%d", + depth, CALL_JITTER_STARTUP_MS, reserve, n); + } { std::lock_guard lock(j->mtx); j->timeline.read((unsigned)n); diff --git a/src/call/call_jitter.h b/src/call/call_jitter.h index 9adf85ac5..c5c5368ad 100644 --- a/src/call/call_jitter.h +++ b/src/call/call_jitter.h @@ -10,12 +10,13 @@ extern "C" { /* Звонок: FIFO Opus, запас 20мс..max_reserve_ms по изменению задержки доставки за 10 секунд и догон после паузы устройства. * push — сетевой поток, pull/decode/CallDelay — единственный аудиопоток. - * Старт/восстановление ждут запас плюс запрошенный блок; при выдаче PCM запас не резервируется повторно. + * Первый старт ждёт не менее200мс; восстановление — сетевой запас плюс блок; при выдаче PCM запас не резервируется повторно. * destroy — после остановки всех обращений. get_stats/diagnostics/clock — любой поток, * внутренний mutex защищает их снимки. Все операции playback (включая silence/reset) * сериализует владелец; mutex не делает допустимыми два одновременных pull. */ #define CALL_JITTER_FRAME_MS 20 #define CALL_JITTER_MIN_RESERVE_MS 20 +#define CALL_JITTER_STARTUP_MS 200 /* Минимальная накопленная речь перед первым декодированием/выводом. */ #define CALL_JITTER_MAX_FRAMES 750 /* 10с перебоя + 5с запаса на накопление и восстановление */ /* Заимствованный context, encoded/len и выход/capacity (сэмплы, до 960); вернуть 1..capacity или <=0 при ошибке. * Callback вызывается синхронно на pull без сетевого mutex; encoded действителен только до возврата. */ @@ -44,13 +45,15 @@ void call_jitter_destroy(struct call_jitter* j); * Абсолютное смещение часов пиров не влияет на оценку джиттера. enc (1..512 байт) и timing копируются; * timing=NULL означает отсутствие PTS. FIFO сохраняет порядок push, сортировки/дедупликации нет. * При заполнении удаляется старейший кадр; неверные аргументы/регрессия received_ms отклоняются с WARN. */ +/* packet_start=1: учитывать arrival/sent_ms как один сетевой пакет; остальные кадры пачки не меняют jitter. */ void call_jitter_push(struct call_jitter* j, const uint8_t* enc, int len, uint32_t sent_ms, int64_t received_ms, - const struct call_capture_time* timing); + const struct call_capture_time* timing, int packet_start); /* Только после остановки playback-операций: удалить PCM/DSP, увеличить локальную epoch, * оставить последние ceil(reserve/20) Opus-кадров. Оценка доставки и суммарные dropped/underruns сохраняются. */ void call_jitter_reset(struct call_jitter* j); /* out — max_samples mono int16 @48кГц; вернуть 0..max_samples, без дополнения тишиной. - * Старт/восстановление ждут reserve плюс размер запроса. Decode ограничен min(750, 8+max_samples/480) попытками. + * Первый старт ждёт max(200мс, reserve плюс размер запроса); восстановление — reserve плюс размер запроса. + * Decode ограничен min(750, 8+max_samples/480) попытками. * NULL/неположительный размер дают 0. Вызывающий учитывает дополненную локальную тишину через silence. */ int call_jitter_pull(struct call_jitter* j, int16_t* out, int max_samples); /* Playback worker: учесть только дополненные после pull локальные сэмплы; их PTS удерживается. */ diff --git a/src/call/call_priv.h b/src/call/call_priv.h index 8f6c19ed9..94812423b 100644 --- a/src/call/call_priv.h +++ b/src/call/call_priv.h @@ -10,6 +10,7 @@ #ifndef CALL_PRIV_H #define CALL_PRIV_H #include "call.h" +#include "call_audio_batch.h" #include "../../lib/ll_queue.h" /* IDLE — только создание; RINGING бывает у обеих ролей, WAIT_DECISION — ожидание force @@ -33,7 +34,11 @@ struct call_session { uint8_t connection_reported, connection_type; /* Было событие CONNECTION / последний router path.type (0..3). */ uint64_t connection_next_hop, last_media_tb; /* Кэш next hop и время последнего RX; watchdog использует traffic_timer. */ uint16_t tx_media_seq; /* Следующий sequence аудиопакета с wrap uint16; расходуется до выделения TX entry. */ - struct ll_queue *tx_q; /* Собственная FIFO готовых audio packets [svc][media]; до 750, переполнение удаляет голову. */ + struct call_audio_batch_control audio_batch; /* Только uasync; reset при смене Router epoch. */ + void *audio_batch_timer, *audio_flush_timer; /* Наблюдение 50мс и deadline неполной пачки. */ + uint64_t audio_report_us; + uint32_t tx_batches, rx_batches; /* Число Router-кодограмм, отдельно от аудиокадров. */ + struct ll_queue *tx_q; /* FIFO Opus с локальными seq/PTS/created_us в entry->data; до 750, overflow удаляет голову. */ struct queue_waiter_handle send_waiter; /* Отменяемое одноразовое ожидание места в router audio send_q. */ uint32_t c_tx_frames, c_rx_frames, c_tx_dropped, c_rx_dropped; /* TX принят router / валидный RX / TX overflow / RX не ведётся. */ uint64_t tx_wait_total_ms; /* Сумма времени audio enqueue->drain, включая отказ router; сброс при отчёте stats. */ diff --git a/src/call/call_proto.h b/src/call/call_proto.h index 12a3f556a..e80d95a31 100644 --- a/src/call/call_proto.h +++ b/src/call/call_proto.h @@ -76,21 +76,30 @@ struct call_pkt_cancel { uint8_t subcmd; uint64_t call_id; }; struct call_pkt_hangup { uint8_t subcmd; uint64_t call_id; uint8_t reason; }; struct call_pkt_end_ack { uint8_t subcmd; uint64_t call_id; }; +/* Одна Router-кодограмма содержит 1..10 Opus-кадров одной capture epoch. + * После header: повторяются [call_pkt_audio_frame][opus length байт]. Без старого формата. */ struct call_pkt_media { - uint8_t subcmd; /* CALL_SUBCMD_MEDIA */ - uint64_t call_id; /* ID существующего ACTIVE-звонка; peer/group проверяет call.c. */ - uint8_t track_id; /* CALL_TRACK_AUDIO. */ - uint16_t seq; /* Счётчик отправителя с переполнением; call_audio FIFO не сортирует по seq. */ - uint32_t ts_ms; /* Монотонное время постановки в TX FIFO, миллисекунды modulo 2^32. */ - int64_t capture_pts_us; /* Монотонный PTS первого входного сэмпла; INT64_MIN — часы неизвестны. */ - uint64_t capture_epoch; /* Ненулевая эпоха capture при известном PTS; смена означает разрыв. */ - uint32_t codec_delay_samples; /* Opus lookahead при48кГц, не более48000. */ - /* Далее непустой Opus payload; call_send_media ограничивает общий media-пакет1024 байтами. */ + uint8_t subcmd; /* CALL_SUBCMD_MEDIA */ + uint64_t call_id; /* ACTIVE-звонок; peer/group проверяет call.c. */ + uint8_t track_id; /* CALL_TRACK_AUDIO */ + uint64_t capture_epoch; /* Общая эпоха источника; с известным PTS ненулевая. */ + uint32_t codec_delay_samples; /* Opus lookahead при 48кГц. */ + uint32_t sent_ms; /* Монотонное время подачи пачки в Router; одно измерение jitter на пачку. */ + uint8_t count; /* 1..CALL_AUDIO_BATCH_MAX */ +}; +struct call_pkt_audio_frame { + uint16_t seq; /* Исходный номер кадра, допускает пропуски/переполнение. */ + int64_t capture_pts_us; /* PTS первого сэмпла; INT64_MIN при отсутствии. */ + uint16_t length; /* Непустой Opus, полностью помещается в кодограмме. */ }; #pragma pack(pop) -#define CALL_MEDIA_HDR_SIZE sizeof(struct call_pkt_media) /* 36 */ +#define CALL_MEDIA_HDR_SIZE sizeof(struct call_pkt_media) /* 27 */ +#define CALL_AUDIO_FRAME_HDR_SIZE sizeof(struct call_pkt_audio_frame) /* 12 */ +#define CALL_AUDIO_BATCH_MAX 10u +#define CALL_MEDIA_MAX_BYTES 1024u +#define CALL_AUDIO_MAX_OPUS (CALL_MEDIA_MAX_BYTES - CALL_MEDIA_HDR_SIZE - CALL_AUDIO_FRAME_HDR_SIZE) /* ── параметры аудио (Opus 48kHz mono, 20ms) ── */ #define CALL_OPUS_SAMPLE_RATE 48000 diff --git a/src/chat/chat_core.h b/src/chat/chat_core.h index 7db12790d..338b06a92 100644 --- a/src/chat/chat_core.h +++ b/src/chat/chat_core.h @@ -307,7 +307,8 @@ struct chat_node_arg { struct UTUN_INSTANCE* inst; uint64_t node_id; }; /* Полные метрики одного соединения, вызывается в uasync. arg — u_malloc struct chat_node_arg, * trampoline освобождает arg; inst остаётся заимствованным. CHAT_EVT_CONN_METRICS содержит UTF-8 - * без завершающего NUL в длине; буфер растёт по числу линков, при OOM нет частичного события. + * без завершающего NUL в длине; включает очереди Router для пира/TX/RX-пути и transit, очереди normalizer. + * Буфер растёт по числу линков и router-сессий, при OOM нет частичного события. * Данные ответа заимствованы только на время синхронного event callback. */ void chat_core_collect_conn_metrics_trampoline(void* arg); diff --git a/src/chat/chat_setting.c b/src/chat/chat_setting.c index e575e5fce..d44438bf4 100644 --- a/src/chat/chat_setting.c +++ b/src/chat/chat_setting.c @@ -38,7 +38,7 @@ static const struct chat_setting_def g_setting_defs[] = { {"stream_subblocks", CHAT_SETTING_INT, 8, 1, 255}, {"stream_rs_redundant", CHAT_SETTING_INT, 0, 0, 254}, {"storage_autoload", CHAT_SETTING_BOOL, 1, 0, 1}, - {"opus_codec_preset", CHAT_SETTING_INT, 1, 0, 4}, + {"opus_codec_preset", CHAT_SETTING_INT, 1, 0, 2}, {"radio_opus_mode", CHAT_SETTING_INT, 0, 0, 1}, {"radio_opus_bitrate", CHAT_SETTING_INT, 32, 16, 128}, {"radio_capture_stereo", CHAT_SETTING_INT, 0, 0, 1}, @@ -210,6 +210,10 @@ int chat_setting_set(struct UTUN_INSTANCE* inst, const char* name, const char* v struct chat_setting_state* s = cs_state(inst); if (!s) { DEBUG_ERROR(CS_ID, "chat_setting: instance is NULL"); return -2; } int result = chat_setting_set_state(s, name, value); + if (!result && !strcmp(name, "opus_codec_preset") && inst->call_audio_ops && inst->call_audio_ops->set_codec_preset && + inst->call_audio_ops->set_codec_preset(inst, chat_setting_get_int(inst, name, 1))) { + DEBUG_ERROR(DEBUG_CATEGORY_CALL, "chat_setting: '%s' stored but codec apply failed; retry setting to reapply", name); return -3; + } if (!result && !strcmp(name, "incoming_call_policy")) call_set_phone_state(inst, inst->phone_state, (unsigned)chat_setting_get_int(inst, name, CALL_POLICY_PUBLISH_PHONE)); if (!result && (!strcmp(name, "local_invites_enabled") || !strcmp(name, "local_broadcasts_enabled")) && diff --git a/src/chat/chat_setting.h b/src/chat/chat_setting.h index d1401b54c..9b8043c99 100644 --- a/src/chat/chat_setting.h +++ b/src/chat/chat_setting.h @@ -52,8 +52,8 @@ int chat_setting_get_int_state(const struct chat_setting_state* s, const char* /* ─── instance-уровень (runtime) ─── */ /* Те же коды; NULL inst — -2. Большинство потребителей читают значение при следующем обращении/старте. - * incoming_call_policy сразу обновляет телефонную политику, local_* — LAN permissions. - * -3 — значение сохранено, применение LAN не удалось; повтор set того же значения повторяет apply. */ + * incoming_call_policy сразу обновляет телефонную политику, local_* — LAN permissions, opus_codec_preset — битрейт звонка. + * -3 — значение сохранено, применение не удалось; повтор set того же значения повторяет apply. */ int chat_setting_set(struct UTUN_INSTANCE* inst, const char* name, const char* value); /* Instance-варианты чтения с теми же lifetime/default; NULL inst даёт NULL/def. */ const char* chat_setting_get(struct UTUN_INSTANCE* inst, const char* name); diff --git a/src/chat/chat_status.c b/src/chat/chat_status.c index 9eabce2e4..4dcd27480 100644 --- a/src/chat/chat_status.c +++ b/src/chat/chat_status.c @@ -24,6 +24,7 @@ #include "../routing_layer/topo_node.h" #include "../routing_layer/topo_node_sqlite.h" #include "../routing_layer/topo_group.h" +#include "../routing_layer/etcp_router.h" #include struct address_profile_text { char text[80]; }; @@ -220,6 +221,65 @@ static void collect_conn_list(struct UTUN_INSTANCE* inst) { } /* ── Полный текстовый дамп одного соединения ── */ +/* Читаемые имена основных сервисов; числовой svc выводится для каждого, включая неизвестные. */ +static const char* router_service_name(uint8_t svc) { + switch (svc) { + case ETCP_RT_ID_CALL_AUDIO: return "call audio"; + case ETCP_RT_ID_CALL: return "call signaling"; + case ETCP_RT_ID_CALL_VIDEO: return "call video"; + case ETCP_RT_ID_CALL_VIDEO_CONTROL: return "video control"; + case ETCP_RT_ID_DM: return "DM"; + case ETCP_RT_ID_DM_MEDIA: return "DM media"; + case ETCP_RT_ID_FILE_TRANSFER: return "file transfer"; + case ETCP_RT_ID_MEMBER_FILES: return "member files"; + case ETCP_RT_ID_CONN_MGR: return "connection manager"; + default: return "service"; + } +} + +/* Только чтение в uasync: сессии выбранного пира, текущего TX-пути или последнего RX-пути и его transit FIFO. + * Байты — счётчики очередей, не wire size. Ошибка форматирования возвращается сборщику для общего ERROR. */ +static int append_router_metrics(struct strbuf* text, struct UTUN_INSTANCE* inst, struct ETCP_CONN* conn) { + if (strbuf_addf(text, "\n--- Router queues ---\n" + "Scope: peer / current TX hop / last RX hop\n" + "send_q: before ETCP; wait_ack: service ACK pending (may still be queued in ETCP)\n") < 0) return -1; + unsigned sessions = 0; + for (struct ll_entry* e = inst->router_conns ? inst->router_conns->head : NULL; e; e = e->next) { + struct ETCP_ROUTER_CONN* rc = (struct ETCP_ROUTER_CONN*)e; + struct ETCP_ROUTER_PATH path = etcp_router_get_path(inst, rc->group_id, rc->remote_node_id, rc->svc_id); + if (rc->remote_node_id != conn->peer_node_id && rc->recv_conn != conn && path.next_hop_node_id != conn->peer_node_id) continue; + sessions++; + if (strbuf_addf(text, "\ngroup=%016llX peer=%016llX svc=%u (%s)\n" + "ready=%u no_route=%u send_blocked=%u peer_busy=%u recv_blocked=%u transport_wait=%u\n" + "RTT=%.1fms inflight_limit=%u retrans=%u\n", + (unsigned long long)rc->group_id, (unsigned long long)rc->remote_node_id, rc->svc_id, router_service_name(rc->svc_id), + rc->peer_reset_id != 0, rc->no_route, rc->send_blocked, rc->peer_receive_blocked, rc->receive_blocked, + rc->send_waiter_q != NULL, rc->rtt / 10.0, rc->inflight_limit, rc->c_retrans_done) < 0) return -1; + struct ll_queue* queues[] = {rc->send_q, rc->inflight_q, rc->incoming_q, rc->recv_q}; + const char* names[] = {"send_q", "wait_ack", "incoming_q", "recv_q"}; + for (unsigned i = 0; i < 4; i++) { + if (strbuf_addf(text, "%s: %zuB/%dp%s", names[i], queues[i] ? queue_total_bytes(queues[i]) : 0, + queue_entry_count(queues[i]), i % 2 ? "\n" : " ") < 0) return -1; + } + } + if (strbuf_addf(text, "sessions: %u\n", sessions) < 0) return -1; + size_t transit_bytes = 0; + unsigned transit_packets = 0; + for (struct ll_entry* e = conn->transit_queues ? conn->transit_queues->head : NULL; e; e = e->next) { + struct TRANSIT_QUEUE* tq = (struct TRANSIT_QUEUE*)e; + size_t bytes = tq->q ? queue_total_bytes(tq->q) : 0; + unsigned packets = queue_entry_count(tq->q); + transit_bytes += bytes; transit_packets += packets; + if (strbuf_addf(text, "transit group=%016llX src=%016llX dst=%016llX: %zuB/%up\n", + (unsigned long long)tq->group_id, (unsigned long long)tq->src_node_id, + (unsigned long long)tq->dst_node_id, bytes, packets) < 0) return -1; + } + if (strbuf_addf(text, "transit total: %zuB/%up\n", transit_bytes, transit_packets) < 0) return -1; + DEBUG_TRACE(DEBUG_CATEGORY_CHAT, "connection router metrics: peer=%016llX sessions=%u transit=%zuB/%up", + (unsigned long long)conn->peer_node_id, sessions, transit_bytes, transit_packets); + return 0; +} + static void collect_conn_metrics(struct UTUN_INSTANCE* inst, uint64_t peer_node_id) { struct strbuf text = strbuf_new(); @@ -294,6 +354,8 @@ static void collect_conn_metrics(struct UTUN_INSTANCE* inst, uint64_t peer_node_ if (conn->output_queue && strbuf_addf(&text, "out_q: %uB/%dp\n", (unsigned)queue_total_bytes(conn->output_queue), queue_entry_count(conn->output_queue)) < 0) goto failed; + if (append_router_metrics(&text, inst, conn) < 0) goto failed; + if (strbuf_addf(&text, "\n--- ACK Debug ---\n" "hit_inf=%u hit_sndq=%u miss=%u link_wait=%u\n", conn->cnt_ack_hit_inf, conn->cnt_ack_hit_sndq, conn->cnt_ack_miss, conn->cnt_link_wait) < 0) goto failed; @@ -303,8 +365,11 @@ static void collect_conn_metrics(struct UTUN_INSTANCE* inst, uint64_t peer_node_ struct PKTNORM* pn = conn->normalizer; if (strbuf_addf(&text, "\n--- Normalizer ---\n" + "input_q: %zuB/%dp output_q: %zuB/%dp\n" "in: %llu pkts / %llu B out: %llu pkts / %llu B\n" "frag_size=%u data_ptr=%u data_size=%u alloc_err=%u logic_err=%u\n", + pn->input ? queue_total_bytes(pn->input) : 0, queue_entry_count(pn->input), + pn->output ? queue_total_bytes(pn->output) : 0, queue_entry_count(pn->output), (unsigned long long)pn->in_total_pkts, (unsigned long long)pn->in_total_bytes, (unsigned long long)pn->out_total_pkts, (unsigned long long)pn->out_total_bytes, pn->frag_size, pn->data_ptr, pn->data_size, pn->alloc_errors, pn->logic_errors) < 0) goto failed; diff --git a/src/routing_layer/etcp_router.c b/src/routing_layer/etcp_router.c index 27e35cdca..a9b1fe38f 100644 --- a/src/routing_layer/etcp_router.c +++ b/src/routing_layer/etcp_router.c @@ -85,6 +85,11 @@ static int router_path_append(struct ll_entry* e, uint64_t node_id, uint8_t coun static int router_source_send(struct ETCP_CONN* conn, struct ll_entry* e) { if (router_path_append(e, conn->instance->node_id, 0) < 0) return -1; + const struct SVC_ROUTE_HDR* h = (const struct SVC_ROUTE_HDR*)e->dgram; + DEBUG_TRACE(DEBUG_CATEGORY_TRAFFIC, "route TX conn=%s svc=%u flags=%02x seq=%u len=%u src=%016llx dst=%016llx group=%016llx epoch=%016llx", + conn->log_name, h->svc_id, h->flags, h->seq, e->len, + (unsigned long long)h->src_node_id, (unsigned long long)h->dst_node_id, + (unsigned long long)h->group_id, (unsigned long long)h->reset_id); return etcp_send(conn, e); } @@ -334,6 +339,9 @@ struct ETCP_ROUTER_PATH etcp_router_get_path(struct UTUN_INSTANCE* inst, uint64_ return path; } +/* Локальные метаданные pending; время enqueue переживает переход в inflight и повторы. */ +struct router_pending { uint64_t enqueued_us; uint32_t seq; uint8_t mode; }; + // Собрать и закодировать (подпись/шифрование) пакет в начале отправки. // Возвращает финальный wire-пакет [SVC_ROUTE_HDR][payload][sig?] (u_malloc). static int router_build_packet(struct ETCP_ROUTER_CONN* rconn, struct ll_entry* pending, @@ -346,13 +354,13 @@ static int router_build_packet(struct ETCP_ROUTER_CONN* rconn, struct ll_entry* hdr->group_id = rconn->group_id; hdr->dst_node_id = rconn->remote_node_id; hdr->src_node_id = rconn->inst->node_id; - memcpy(&hdr->seq, pending->data, 4); + hdr->seq = ((struct router_pending*)pending->data)->seq; hdr->svc_id = rconn->svc_id; hdr->reset_id = rconn->reset_id; hdr->peer_reset_id = rconn->peer_reset_id; hdr->timestamp = get_current_timestamp(); memcpy(base + SVC_ROUTE_HDR_SIZE, pending->dgram, pending->len); - int ret = route_crypto_encode(rconn->inst, rconn->remote_node_id, base, len, pending->data[4], out, out_len); + int ret = route_crypto_encode(rconn->inst, rconn->remote_node_id, base, len, ((struct router_pending*)pending->data)->mode, out, out_len); u_free(base); return ret; } @@ -528,7 +536,7 @@ static int router_conn_reset(struct ETCP_ROUTER_CONN* rconn) { rconn->peer_receive_blocked = 0; rconn->recv_conn = NULL; - rconn->rtt = 0; rconn->rtt_jitter = 0; + rconn->rtt = 0; rconn->rtt_jitter = 0; rconn->delivery_rtt_us = 0; rconn->max_inflight = ROUTER_MAX_INFLIGHT; rconn->inflight_limit = ROUTER_MAX_INFLIGHT; rconn->minrtt = MINRTT_DEFAULT_TB; @@ -646,7 +654,8 @@ static void router_send_drain_cb(struct ll_queue* q, void* arg) { free_entry(e); u_free(inf->dgram); u_free(inf); return; } memcpy(e->dgram, inf->dgram, len); e->len = len; - memcpy(&inf->seq, pending->data, 4); inf->ll.size = 4; inf->ll.len = pending->len; + inf->seq = ((struct router_pending*)pending->data)->seq; inf->ll.size = 4; inf->ll.len = pending->len; + inf->enqueued_us = ((struct router_pending*)pending->data)->enqueued_us; inf->dgram_len = len; inf->last_sent_tb = get_time_tb(); inf->send_count = 1; uint32_t seq = inf->seq; queue_data_put_with_index(rconn->inflight_q, &inf->ll); @@ -814,12 +823,18 @@ static int router_enqueue_send(struct ETCP_ROUTER_CONN* rconn, const uint8_t* pa return -1; } if (!force && queue_entry_count(rconn->send_q) >= ROUTER_MAX_SEND_Q_PACKETS) return -1; - struct ll_entry* e = queue_entry_new(5); + struct ll_entry* e = queue_entry_new(sizeof(struct router_pending)); if (!e) { DEBUG_ERROR(DEBUG_CATEGORY_ETCPROUTE, "router enqueue: allocation failed"); return -1; } e->dgram = u_malloc(len); if (!e->dgram) { DEBUG_ERROR(DEBUG_CATEGORY_ETCPROUTE, "router enqueue: payload allocation failed"); queue_entry_free(e); return -1; } - memcpy(e->data, &rconn->tx_seq, 4); e->data[4] = mode; + *(struct router_pending*)e->data = (struct router_pending){get_time_us(), rconn->tx_seq, (uint8_t)mode}; memcpy(e->dgram, payload, len); e->len = len; + DEBUG_TRACE(DEBUG_CATEGORY_TRAFFIC, "route enqueue peer=%016llx group=%016llx epoch=%016llx svc=%u seq=%u bytes=%zu " + "queued=%d inflight=%d limit=%u ready=%u no_route=%u peer_busy=%u waiter=%u", + (unsigned long long)rconn->remote_node_id, (unsigned long long)rconn->group_id, + (unsigned long long)rconn->reset_id, rconn->svc_id, rconn->tx_seq, len, + queue_entry_count(rconn->send_q), queue_entry_count(rconn->inflight_q), rconn->inflight_limit, + rconn->peer_reset_id != 0, rconn->no_route, rconn->peer_receive_blocked, rconn->send_waiter_q != NULL); rconn->tx_seq++; queue_data_put(rconn->send_q, e); router_send_kick(rconn); @@ -1205,7 +1220,12 @@ static void router_handle_ack(struct UTUN_INSTANCE* inst, struct ETCP_ROUTER_CON while (rconn->inflight_q->head) { struct ROUTER_INFLIGHT* inf = (struct ROUTER_INFLIGHT*)rconn->inflight_q->head; if ((int32_t)(inf->seq - seq) >= 0) break; - route_tx_meter_ack(&rconn->delivery, get_time_us(), inf->ll.len); + uint64_t now_us = get_time_us(), elapsed = now_us - inf->enqueued_us; + rconn->delivery_rtt_us = rconn->delivery.packets ? rconn->delivery_rtt_us - rconn->delivery_rtt_us / 8 + elapsed / 8 : elapsed; + DEBUG_DEBUG(DEBUG_CATEGORY_ETCPROUTE, "router delivery svc=%u seq=%u enqueue_ack_us=%llu average_us=%llu attempts=%u", + rconn->svc_id, inf->seq, (unsigned long long)elapsed, + (unsigned long long)rconn->delivery_rtt_us, inf->send_count); + route_tx_meter_ack(&rconn->delivery, now_us, inf->ll.len); queue_remove_data(rconn->inflight_q, &inf->ll); u_free(inf->dgram); u_free(inf); } @@ -1341,6 +1361,9 @@ static void etcp_router_recv_cb(struct ETCP_CONN* conn, struct ll_entry* entry) struct SVC_ROUTE_HDR* h = (struct SVC_ROUTE_HDR*)entry->dgram; size_t body = router_path_check(conn, entry); if (!body) { free_entry(entry); return; } + DEBUG_TRACE(DEBUG_CATEGORY_TRAFFIC, "route RX conn=%s svc=%u flags=%02x seq=%u len=%u src=%016llx dst=%016llx", + conn->log_name, h->svc_id, h->flags, h->seq, entry->len, + (unsigned long long)h->src_node_id, (unsigned long long)h->dst_node_id); if (h->dst_node_id != inst->node_id) { if (router_path_append(entry, inst->node_id, entry->dgram[entry->len - 1]) < 0) { inst->router_path_errors++; free_entry(entry); return; @@ -1572,12 +1595,17 @@ int etcp_router_tx_stats(struct UTUN_INSTANCE *inst, uint64_t group, uint64_t pe .queued_bytes = rconn->send_q->total_bytes, .inflight_bytes = rconn->inflight_q->total_bytes, .queued_packets = rconn->send_q->count, .inflight_packets = rconn->inflight_q->count, .acked_bytes = rconn->delivery.bytes, .acked_packets = rconn->delivery.packets, + .delivery_rtt_us = rconn->delivery_rtt_us, .last_ack_us = rconn->delivery.packets ? rconn->delivery.last_us : 0, .rtt_us = (uint32_t)rconn->rtt * 100, .baseline_rtt_us = rconn->minrtt_window_count ? (uint32_t)rconn->minrtt * 100 : 0, .ready = rconn->peer_reset_id != 0, .no_route = rconn->no_route, .receive_busy = rconn->peer_receive_blocked, .probing = rconn->minrtt_probe }; if (route_tx_meter_window(&rconn->delivery, now_us, &result.window) < 0) return -1; + if (rconn->send_q->head) + result.queued_age_us = now_us - ((struct router_pending*)rconn->send_q->head->data)->enqueued_us; + if (rconn->inflight_q->head) + result.inflight_age_us = now_us - ((struct ROUTER_INFLIGHT*)rconn->inflight_q->head)->enqueued_us; *out = result; return 0; } diff --git a/src/routing_layer/etcp_router.h b/src/routing_layer/etcp_router.h index f1e07fce9..6886b121e 100644 --- a/src/routing_layer/etcp_router.h +++ b/src/routing_layer/etcp_router.h @@ -179,6 +179,7 @@ struct ETCP_ROUTER_CONN { uint32_t c_oob_dropped; // дропнуто out-of-bounds seq uint32_t c_stale_ack; // ACK вне диапазона или без продвижения seq, включая допустимый повтор RECEIVE_BUSY uint32_t c_sign_fail; // signature verification failures + uint64_t delivery_rtt_us; // EWMA enqueue->ACK (1/8), включает send_q/нижние очереди/обратный путь. struct route_tx_meter delivery; // первые подтверждения исходного payload, без wire overhead }; @@ -187,6 +188,8 @@ struct ETCP_ROUTER_CONN { struct ETCP_ROUTER_TX_STATS { uint64_t local_epoch, peer_epoch, queued_bytes, inflight_bytes, acked_bytes, acked_packets; uint32_t queued_packets, inflight_packets; + uint64_t delivery_rtt_us, last_ack_us; // Полный RTT enqueue->ACK и монотонное время последнего продвижения ACK. + uint64_t queued_age_us, inflight_age_us; // Возраст головы от исходного enqueue; 0 при пустой очереди. struct route_tx_window window; uint32_t rtt_us, baseline_rtt_us; int ready, no_route, receive_busy, probing; @@ -225,6 +228,7 @@ int etcp_router_tx_stats(struct UTUN_INSTANCE *inst, uint64_t group, uint64_t pe struct ROUTER_INFLIGHT { struct ll_entry ll; // индекс по seq (4 байта, offset 0) uint32_t seq; + uint64_t enqueued_us; // Исходное время постановки в send_q, неизменно при повторах. uint64_t last_sent_tb; // время последней отправки (0.1ms) uint8_t send_count; // успешные отправки, включая первую; диагностический циклический счётчик uint8_t* dgram; // неизменяемое тело [SVC_ROUTE_HDR][payload][sig?], без visited_ids/count diff --git a/src/transport_layer/etcp.c b/src/transport_layer/etcp.c index 5bbca5191..e507cff65 100644 --- a/src/transport_layer/etcp.c +++ b/src/transport_layer/etcp.c @@ -932,7 +932,8 @@ static void input_queue_cb(struct ll_queue* q, void* arg) { // memory_pool_free(etcp->io_pool, in_pkt);// перемещаем из io_pool в inflight_pool queue_entry_free(&in_pkt->ll); - DEBUG_TRACE(DEBUG_CATEGORY_ETCP, "[%s] TX input -> inflight (seq=%u, len=%u); Qlen: in=%d snd=%d wait=%d", etcp->log_name, p->seq, p->ll.len, etcp->input_queue->count, etcp->input_send_q->count, etcp->input_wait_ack->count); + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "[%s] TX input -> inflight seq=%u len=%u input=%d send=%d wait=%d", + etcp->log_name, p->seq, p->ll.len, etcp->input_queue->count, etcp->input_send_q->count, etcp->input_wait_ack->count); int len=p->ll.len;// сохраним len // Add to send queue @@ -1076,6 +1077,38 @@ static void etcp_link_ready_callback(struct ETCP_CONN* etcp) { etcp_conn_wake_sender(etcp); } +/* Снимок после прохода отправителя: причины могут пересекаться, full относится к голове очереди. + * Только BLE и включённая debug-категория; не вызывает selection/BBR и не меняет состояние очередей. */ +static void trace_ble_tx_wait(struct ETCP_CONN* conn) { + if (!debug_should_output(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_DEBUG) || conn->close_requested || conn->state == 2) return; + struct PKTNORM* pn = conn->normalizer; + if (!conn->input_send_q->count && !conn->input_queue->count && + (!pn || (!queue_entry_count(pn->input) && !pn->pending && !pn->data_ptr))) return; + struct INFLIGHT_PACKET* head = (struct INFLIGHT_PACKET*)conn->input_send_q->head; + unsigned input_len = conn->input_queue->head ? conn->input_queue->head->len : 0; + uint64_t now = get_time_tb(); + for (struct ETCP_LINK* link = conn->links; link; link = link->next) { + struct packet_link* channel = etcp_link_io_get(link); + if (!channel || strcmp(packet_link_name(channel), "BLE")) continue; + unsigned bytes = head ? head->ll.len : input_len; + int cwnd_full = bytes && (bytes > link->inflight_lim_bytes || link->inflight_bytes > link->inflight_lim_bytes - bytes); + int attempts_full = conn->active_attempts >= 2 * MAX_INFLIGHT_SIZE || queue_entry_count(link->attempts) >= MAX_INFLIGHT_SIZE; + int input_full = input_len && (input_len > conn->optimal_inflight || conn->unacked_bytes > conn->optimal_inflight - input_len); + uint64_t pacing_tb = link->shaper_load_time_tb >= now + SHAPER_BURST_DELAY_TB ? + link->shaper_load_time_tb - now - SHAPER_BURST_DELAY_TB + 1 : 0; + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "[%s] TX gate path=%u head_seq=%u bytes=%u retry=%u io=%d up=%u init=%u " + "cwnd_full=%d attempts_full=%d pacing=%d pacing_us=%llu rate_Bps=%u flight=%u/%u " + "input_full=%d unacked=%u/%u peer_full=%d input=%d send=%d norm=%d pending=%u buffered=%u token=%d", + conn->log_name, link->local_link_id, head ? head->seq : 0, bytes, head && head->last_attempt_id != 0, + etcp_link_io_ready(link), link->link_status, link->initialized, cwnd_full, attempts_full, + link->shaper_timer != NULL, (unsigned long long)(pacing_tb * 100), link->bbr_pacing_rate, + link->inflight_bytes, link->inflight_lim_bytes, input_full, conn->unacked_bytes, conn->optimal_inflight, + (uint32_t)(conn->next_tx_id - conn->rx_ack_till) > MAX_INFLIGHT_SIZE, + conn->input_queue->count, conn->input_send_q->count, pn ? queue_entry_count(pn->input) : 0, + pn && pn->pending ? pn->pending->len - pn->pending_in_ptr : 0, pn ? pn->data_ptr : 0, conn->tx_token != NULL); + } +} + // Process packets in send queue and transmit them static void etcp_conn_process_send_queue(struct ETCP_CONN* etcp) {// вызываем когда есть элемент в send_q или надо отправить ack if (etcp->tx_running || etcp->close_requested || etcp->state == 2) return; @@ -1088,6 +1121,7 @@ static void etcp_conn_process_send_queue(struct ETCP_CONN* etcp) {// вызыв } etcp->callbacks_running--; etcp->tx_running = 0; + trace_ble_tx_wait(etcp); } /* Один запуск обслуживает накопленные события; заблокированный DATA ждёт ACK/RTO/shaper, а не следующий poll. */ diff --git a/src/transport_layer/etcp_api.c b/src/transport_layer/etcp_api.c index cc54c54bf..b910fc74e 100644 --- a/src/transport_layer/etcp_api.c +++ b/src/transport_layer/etcp_api.c @@ -245,6 +245,10 @@ int etcp_send(struct ETCP_CONN* conn, struct ll_entry* entry) { (void*)conn, q->count, q->size_limit, q->hash_size); return -1; } + if (entry->dgram && entry->len) { + DEBUG_TRACE(DEBUG_CATEGORY_TRAFFIC, "enqueue id=0x%02x conn=%s len=%u pending=%d", + entry->dgram[0], conn->log_name, entry->len, q->count); + } return queue_data_put(conn->send_input_q, entry); } diff --git a/src/transport_layer/etcp_link_io.c b/src/transport_layer/etcp_link_io.c index 16f0e69ca..1587f272e 100644 --- a/src/transport_layer/etcp_link_io.c +++ b/src/transport_layer/etcp_link_io.c @@ -159,7 +159,12 @@ static void changed(struct packet_link* channel, void* arg) { goto done; } } - if (state.tx_ready) etcp_conn_wake_sender(conn); + if (state.tx_ready) { + if (!io->native) DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "[%s] carrier ready path=%u driver=%s input=%d send=%d token=%d", + conn->log_name, owner->local_link_id, packet_link_name(channel), queue_entry_count(conn->input_queue), + queue_entry_count(conn->input_send_q), conn->tx_token != NULL); + etcp_conn_wake_sender(conn); + } done: etcp_link_release(owner); conn->callbacks_running--; } diff --git a/src/transport_layer/pkt_normalizer.c b/src/transport_layer/pkt_normalizer.c index ac777d950..390839252 100644 --- a/src/transport_layer/pkt_normalizer.c +++ b/src/transport_layer/pkt_normalizer.c @@ -217,7 +217,8 @@ static int pn_send_to_etcp(struct PKTNORM* pn) { pn->data = NULL; pn->data_ptr = 0; - DEBUG_DEBUG(DEBUG_CATEGORY_ETCP, "pn->etcp: size=%d memlen=%d frag_size=%d", frag->ll.len, frag->ll.memlen, pn->frag_size); + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "[%s] normalizer TX fragment bytes=%u capacity=%u frag_size=%u input=%d", + pn->etcp->log_name, frag->ll.len, frag->ll.memlen, pn->frag_size, queue_entry_count(pn->input)); if (debug_should_output(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_DUMP)) log_dump(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_DUMP, "NORM->ETCP", frag->ll.dgram, frag->ll.len); if (queue_data_put(pn->etcp->input_queue, (struct ll_entry*)frag) < 0) { diff --git a/tests/Makefile.am b/tests/Makefile.am index 0a173005e..8196b72c1 100644 --- a/tests/Makefile.am +++ b/tests/Makefile.am @@ -13,6 +13,7 @@ check_PROGRAMS = \ test_video_no_ffmpeg \ test_video_plane \ test_route_tx_stats \ + test_call_audio_batch \ test_call_video_rate \ test_call_video_playout \ test_call_video_port \ @@ -1070,6 +1071,9 @@ test_call_SOURCES = test_call.c test_route_tx_stats_SOURCES = test_route_tx_stats.c test_route_tx_stats_LDADD = $(LIBUTUN) $(COMMON_LIBS) +test_call_audio_batch_SOURCES = test_call_audio_batch.c +test_call_audio_batch_LDADD = $(LIBUTUN) $(COMMON_LIBS) + test_call_video_rate_SOURCES = test_call_video_rate.c test_call_video_rate_LDADD = $(LIBUTUN) $(COMMON_LIBS) diff --git a/tests/test_call.c b/tests/test_call.c index feb02792d..57e0f1117 100644 --- a/tests/test_call.c +++ b/tests/test_call.c @@ -69,6 +69,7 @@ enum call_phase { P_WAIT_ACCEPTED, /* обе ACTIVE */ P_SEND_MEDIA, /* A: call_send_media (тестовый Opus-кадр) */ P_WAIT_MEDIA, /* B: media_recv_cb получил кадр */ + P_BATCH_SEND, P_BATCH_WAIT, P_PARTIAL_WAIT, P_VIDEO_CONFIG, P_VIDEO_SEND, P_VIDEO_HOLD, P_VIDEO_BUSY, P_VIDEO_RESUME, P_VIDEO_RANGE_WAIT, P_VIDEO_SWITCH, P_VIDEO_SWITCH_WAIT, P_VIDEO_SHRINK_WAIT, P_VIDEO_PORT_OPEN, P_VIDEO_PORT_RECEIVE, P_VIDEO_PORT_SEND, P_VIDEO_PORT_ACK, P_VIDEO_DECODE_OK, P_VIDEO_DECODE_REJECT, @@ -134,12 +135,14 @@ struct media_evt { }; static struct media_evt g_media[2]; static struct call_capture_time g_capture_timing; +static unsigned g_audio_packets; static const struct call_capture_time sent_timing = {1234567890123ll, 19, 312}; static void audio_recv_cb(struct UTUN_INSTANCE* inst, uint64_t call_id, uint16_t seq, uint32_t ts, - const uint8_t* opus, int len, const struct call_capture_time* timing) { - (void)inst; (void)call_id; (void)seq; (void)ts; (void)opus; (void)len; + const uint8_t* opus, int len, const struct call_capture_time* timing, int packet_start) { + (void)inst; (void)call_id; (void)seq; (void)ts; (void)opus; (void)len; (void)packet_start; g_capture_timing = *timing; + g_audio_packets += packet_start != 0; } static const struct call_audio_ops audio_ops = {.on_media = audio_recv_cb}; @@ -586,6 +589,48 @@ static void call_tick(void* arg) { || g_capture_timing.epoch != sent_timing.epoch || g_capture_timing.delay_samples != sent_timing.delay_samples) { fprintf(stderr, "B: capture timing changed in transit\n"); t->result = 2; break; } + t->phase = P_BATCH_SEND; + } + break; + case P_BATCH_SEND: { + struct call_session *cs = call_session_find(A->call, t->call_id); + if (cs->audio_batch_timer) uasync_cancel_timeout(t->ua, cs->audio_batch_timer); + cs->audio_batch_timer = NULL; cs->audio_batch.target = 10; cs->audio_batch.hold_us = 40000; + uint8_t opus[16] = {0}; + for (unsigned i = 1; i <= 10; i++) { + struct call_capture_time timing = sent_timing; timing.pts_us += i * 20000; + if (call_send_media(A, t->call_id, opus, sizeof(opus), &timing)) { t->result = 2; break; } + } + if (cs->tx_batches != 2 || cs->c_tx_frames != 11 || cs->audio_flush_timer) { + fprintf(stderr, "full batch was not sent once/cancelled timer\n"); t->result = 2; + } + t->phase = P_BATCH_WAIT; break; + } + case P_BATCH_WAIT: + if (g_media[IDX_B].frames >= 11) { + if (g_media[IDX_B].frames != 11 || g_audio_packets != 2 || g_capture_timing.pts_us != sent_timing.pts_us + 200000) { + fprintf(stderr, "batch frames/timing not preserved\n"); t->result = 2; break; + } + struct call_session *cs = call_session_find(A->call, t->call_id); + uint8_t opus[16] = {0}; + for (unsigned i = 11; i <= 13; i++) { + struct call_capture_time timing = sent_timing; timing.pts_us += i * 20000; + if (call_send_media(A, t->call_id, opus, sizeof(opus), &timing)) { t->result = 2; break; } + } + if (cs->tx_batches != 2 || !cs->audio_flush_timer || cs->tx_q->count != 3) { + fprintf(stderr, "partial batch did not wait for deadline\n"); t->result = 2; + } + t->phase = P_PARTIAL_WAIT; + } + break; + case P_PARTIAL_WAIT: + if (g_media[IDX_B].frames >= 14) { + struct call_session *cs = call_session_find(A->call, t->call_id); + if (g_media[IDX_B].frames != 14 || g_audio_packets != 3 || cs->tx_batches != 3 || cs->audio_flush_timer + || g_capture_timing.pts_us != sent_timing.pts_us + 260000) { + fprintf(stderr, "partial batch deadline delivery failed\n"); t->result = 2; break; + } + cs->audio_batch.target = 1; cs->audio_batch.hold_us = 0; t->phase = P_VIDEO_CONFIG; } break; @@ -642,7 +687,7 @@ static void call_tick(void* arg) { } case P_VIDEO_BUSY: if (g_video_frames) { fprintf(stderr, "video bypassed receive backpressure\n"); t->result = 2; break; } - if (++t->phase_ticks < 10 || g_media[IDX_B].frames != 2) break; + if (++t->phase_ticks < 10 || g_media[IDX_B].frames != 15) break; g_video_ready = 1; call_video_resume_receive(B, t->call_id); t->phase = P_VIDEO_RESUME; break; case P_VIDEO_RESUME: if (!g_video_frames || g_video_preroll != 6 || !g_rate_commands) break; @@ -779,9 +824,15 @@ static void call_tick(void* arg) { call_video_port_request_key(t->call_id); t->previous_call_id = t->call_id; g_drop_acks[IDX_B] = 2; + struct call_session *pending_audio = call_session_find(B->call, t->call_id); + pending_audio->audio_batch.target = 10; pending_audio->audio_batch.hold_us = 40000; + uint8_t unfinished_opus[2] = {1, 2}; + if (call_send_media(B, t->call_id, unfinished_opus, sizeof(unfinished_opus), &sent_timing) + || !pending_audio->audio_flush_timer) { t->result = 2; break; } call_hangup(B, t->call_id); struct call_session* ended = call_session_find(B->call, t->call_id); - if (!ended || ended->tx_q || ended->video || !ended->end_subcmd || !ended->teardown_timer) { + if (!ended || ended->tx_q || ended->video || ended->audio_flush_timer || ended->audio_batch_timer + || !ended->end_subcmd || !ended->teardown_timer) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "ended call retained media or lost final command"); t->result = 2; break; } t->phase = P_VIDEO_PORT_CANCELLED; break; diff --git a/tests/test_call_audio_batch.c b/tests/test_call_audio_batch.c new file mode 100644 index 000000000..0b5feb410 --- /dev/null +++ b/tests/test_call_audio_batch.c @@ -0,0 +1,100 @@ +/* Wire: границы/атомарный отказ. Регулятор: полный RTT, очередь без ACK, гистерезис и смена эпохи. */ +#include "../src/call/call_audio_batch.h" +#include "../src/routing_layer/etcp_router.h" +#include "../lib/debug_config.h" +#include +#include +#define CHECK(x) do { if (!(x)) { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "audio batch test line=%d: %s", __LINE__, #x); exit(1); } } while (0) + +static void wire_test(void) { + uint8_t opus[57], wire[CALL_MEDIA_MAX_BYTES], saved[CALL_MEDIA_MAX_BYTES]; + memset(opus, 0xa5, sizeof(opus)); + struct call_audio_batch_view b = {.header = {.subcmd = CALL_SUBCMD_MEDIA, .call_id = 1, .track_id = CALL_TRACK_AUDIO, + .capture_epoch = 17, .codec_delay_samples = 312, .sent_ms = UINT32_MAX}}; + size_t length = 0; + for (unsigned n = 1; n <= 10; n++) { + b.header.count = n; + b.frames[n - 1] = (struct call_audio_frame_view){.header = {(uint16_t)(65532 + n), 1000000 + n * 20000, 37}, .opus = opus}; + CHECK(call_audio_batch_encode(&b, wire, sizeof(wire), &length) == 0 && length == 27 + n * 49); + struct call_audio_batch_view out; + CHECK(call_audio_batch_parse(wire, length, &out) == 0 && out.header.count == n && out.header.sent_ms == UINT32_MAX); + for (unsigned i = 0; i < n; i++) CHECK(out.frames[i].header.seq == b.frames[i].header.seq + && out.frames[i].header.capture_pts_us == b.frames[i].header.capture_pts_us + && out.frames[i].header.length == 37 && !memcmp(out.frames[i].opus, opus, 37)); + } + struct call_audio_batch_view out, before; + memset(&out, 0x77, sizeof(out)); before = out; + for (size_t n = 0; n < length; n++) { + CHECK(call_audio_batch_parse(wire, n, &out) == -1 && !memcmp(&out, &before, sizeof(out))); + } + CHECK(call_audio_batch_parse(wire, length + 1, &out) == -1); + memcpy(saved, wire, sizeof(wire)); + wire[CALL_MEDIA_HDR_SIZE - 1] = 11; + CHECK(call_audio_batch_parse(wire, length, &out) == -1); + memcpy(wire, saved, sizeof(wire)); + struct call_pkt_audio_frame invalid = {1, 0, UINT16_MAX}; + memcpy(wire + CALL_MEDIA_HDR_SIZE, &invalid, sizeof(invalid)); + CHECK(call_audio_batch_parse(wire, length, &out) == -1); + memset(wire, 0x55, sizeof(wire)); memcpy(saved, wire, sizeof(wire)); + CHECK(call_audio_batch_encode(&b, wire, length - 1, &length) == -1 && !memcmp(wire, saved, sizeof(wire))); + b.header.count = 1; b.header.capture_epoch = 0; + CHECK(call_audio_batch_encode(&b, wire, sizeof(wire), &length) == -1); + b.frames[0].header.capture_pts_us = INT64_MIN; + CHECK(call_audio_batch_encode(&b, wire, sizeof(wire), &length) == 0); + CHECK(call_audio_batch_parse(wire, length, &out) == 0 && out.frames[0].header.capture_pts_us == INT64_MIN); +} + +static void step(struct call_audio_batch_control *c, struct ETCP_ROUTER_TX_STATS *n, uint64_t t) { + n->last_ack_us = t; CHECK(call_audio_batch_step(c, t, n) == 0); +} +static void control_test(void) { + struct call_audio_batch_control c; + struct ETCP_ROUTER_TX_STATS n = {.local_epoch = 1, .peer_epoch = 2, .ready = 1, .acked_packets = 1, + .delivery_rtt_us = 20000, .window = {.valid = 1}}; + call_audio_batch_init(&c, 0); + CHECK(c.target == 4 && c.hold_us == 60000); + CHECK(call_audio_batch_step(&c, 0, NULL) == 0 && c.target == 4 && c.hold_us == 60000); + n.ready = 0; n.acked_packets = 0; n.window.valid = 0; + step(&c, &n, 50000); CHECK(c.target == 4 && c.hold_us == 60000); + n.ready = 1; n.acked_packets = 1; + step(&c, &n, 100000); CHECK(c.target == 4 && c.hold_us == 0); // Первый ACK, не ждём окна500мс. + n.window.valid = 1; + for (unsigned t = 150000; t < 2000000; t += 50000) step(&c, &n, t); + CHECK(c.target == 4 && c.hold_us == 0); + /* Изолированная задержка не включает упаковку. */ + n.delivery_rtt_us = 150000; step(&c, &n, 2000000); step(&c, &n, 2050000); + n.delivery_rtt_us = 20000; step(&c, &n, 2100000); CHECK(c.target == 4); + /* Даже без queued/inflight высокий обычный RTT включает пачки. */ + n.delivery_rtt_us = 150000; + for (unsigned t = 2150000; t <= 2350000; t += 50000) step(&c, &n, t); + CHECK(c.target == 5 && c.hold_us == 27500); + for (unsigned t = 2400000; t <= 2850000; t += 50000) step(&c, &n, t); + CHECK(c.target == 6 && c.hold_us == 27500); + n.delivery_rtt_us = 300000; + for (unsigned t = 2900000; t <= 9000000; t += 50000) step(&c, &n, t); + CHECK(c.target == 10 && c.hold_us == 40000); + n.delivery_rtt_us = 10000; step(&c, &n, 9050000); + CHECK(c.target == 10 && c.hold_us == 0); + for (unsigned t = 9100000; t <= 11050000; t += 50000) step(&c, &n, t); + CHECK(c.target == 9); + n.peer_epoch = 3; step(&c, &n, 11100000); CHECK(c.target == 4); + n.inflight_age_us = 150000; + for (unsigned t = 11150000; t <= 11350000; t += 50000) step(&c, &n, t); + CHECK(c.target == 5); + n.receive_busy = 1; + for (unsigned t = 11400000; t <= 13000000; t += 50000) step(&c, &n, t); + CHECK(c.target == 5 && c.hold_us == 0); + n.receive_busy = 0; CHECK(call_audio_batch_step(&c, 20000000, &n) == 0 && c.target == 5 && c.hold_us == 0); + CHECK(call_audio_batch_step(&c, 19999999, &n) == -1); + CHECK(call_audio_batch_step(&c, 20000001, NULL) == 0 && c.target == 4 && c.hold_us == 60000); + n.inflight_age_us = 0; + for (unsigned t = 20100000; t <= 27000000; t += 50000) step(&c, &n, t); + CHECK(c.target == 1 && c.hold_us == 0); +} +int main(void) { + debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); + wire_test(); control_test(); + debug_set_level(DEBUG_LEVEL_INFO); + DEBUG_INFO(DEBUG_CATEGORY_CALL, "PASS audio batch: 1..10, partial/trailing/invalid wire, full RTT, hysteresis, deadline budget, epochs/stall"); + return 0; +} diff --git a/tests/test_chat_member.c b/tests/test_chat_member.c index 2e13b4a81..585c9001a 100644 --- a/tests/test_chat_member.c +++ b/tests/test_chat_member.c @@ -4,6 +4,8 @@ #include "../src/routing_layer/topo_node_sqlite.h" #include "../src/transport_layer/etcp.h" #include "../src/transport_layer/etcp_connections.h" +#include "../src/transport_layer/pkt_normalizer.h" +#include "../src/routing_layer/etcp_router.h" #include "../lib/strbuf.h" #include @@ -20,6 +22,7 @@ static uint8_t detail[CHAT_MEMBER_DETAIL_MAX_SIZE]; static size_t detail_size; static unsigned text_events; static int expect_large_text; +static int expect_router_metrics; static int matches(const char* sql) { return target && !strncmp(sql, target, strlen(target)); } static int take(enum fault point, const char* sql, int index) { @@ -86,6 +89,16 @@ static void event(struct UTUN_INSTANCE* inst, int type, const uint8_t* data, int if (type == CHAT_EVT_STATUS_REFRESH || type == CHAT_EVT_CONN_METRICS) { assert(size > 0); if (expect_large_text) assert(size > 8192 && memmem(data, (size_t)size, "LINK#299", 8)); + if (type == CHAT_EVT_CONN_METRICS && expect_router_metrics) { + const char* expected[] = {"--- Router queues ---", "peer=0000000000000009 svc=63 (call audio)", + "peer=000000000000000B svc=52 (DM)", "sessions: 2\n", "send_q: 202B/2p wait_ack: 303B/1p", + "incoming_q: 44B/1p recv_q: 55B/1p", "send_q: 0B/0p wait_ack: 0B/0p", + "transit total: 666B/2p", "input_q: 777B/1p output_q: 0B/0p"}; + for (unsigned i = 0; i < sizeof(expected) / sizeof(expected[0]); i++) + assert(memmem(data, (size_t)size, expected[i], strlen(expected[i]))); + const char* excluded = "peer=000000000000000C svc="; + assert(!memmem(data, (size_t)size, excluded, strlen(excluded))); + } text_events++; return; } if (type == CHAT_EVT_MEMBER_DETAIL) { @@ -232,6 +245,16 @@ static void node_names(struct UTUN_INSTANCE* inst) { DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS node names:4 SQL faults/retries,cleared failure output and zero-size guard"); } +/* Очереди без callbacks: снимок обязан сохранить элементы и байтовые счётчики. */ +static struct ll_queue* metrics_queue(struct UTUN_INSTANCE* inst, unsigned count, uint16_t bytes) { + struct ll_queue* q = queue_new(inst->ua, 0, 0, 0, "metrics_test"); assert(q); + for (unsigned i = 0; i < count; i++) { + struct ll_entry* e = ll_alloc_lldgram(bytes); assert(e); e->len = bytes; + assert(!queue_data_put(q, e)); + } + return q; +} + static void diagnostics(struct UTUN_INSTANCE* inst) { struct ETCP_CONN connection = {.instance = inst, .peer_node_id = 9, .unacked_bytes = UINT32_MAX}; struct ETCP_LINK* links = u_calloc(300, sizeof(*links)); assert(links); connection.links = links; @@ -242,17 +265,50 @@ static void diagnostics(struct UTUN_INSTANCE* inst) { struct ll_entry* entry = queue_entry_new(sizeof(struct conn_queue_entry)); assert(entry); struct conn_queue_entry* item = (struct conn_queue_entry*)entry->data; item->peer_node_id = 9; item->conn = &connection; assert(!queue_data_put_with_index(inst->connections, entry)); expect_large_text = 1; unsigned before = text_events; + struct ll_queue* saved_router = inst->router_conns; + inst->router_conns = metrics_queue(inst, 0, 0); + struct ETCP_ROUTER_CONN* sessions[3]; + for (unsigned i = 0; i < 3; i++) { + sessions[i] = (struct ETCP_ROUTER_CONN*)queue_entry_new(sizeof(*sessions[i]) - sizeof(struct ll_entry)); assert(sessions[i]); + sessions[i]->inst = inst; sessions[i]->remote_node_id = i ? 10 + i : 9; + sessions[i]->svc_id = i ? ETCP_RT_ID_DM : ETCP_RT_ID_CALL_AUDIO; + assert(!queue_data_put(inst->router_conns, &sessions[i]->ll)); + } + sessions[1]->recv_conn = &connection; + struct ll_queue* queues[] = {metrics_queue(inst, 2, 101), metrics_queue(inst, 1, 303), metrics_queue(inst, 1, 44), + metrics_queue(inst, 1, 55), metrics_queue(inst, 2, 333), metrics_queue(inst, 1, 777)}; + sessions[0]->send_q = queues[0]; sessions[0]->inflight_q = queues[1]; + sessions[0]->incoming_q = queues[2]; sessions[0]->recv_q = queues[3]; + struct TRANSIT_QUEUE* transit = (struct TRANSIT_QUEUE*)queue_entry_new(sizeof(*transit) - sizeof(struct ll_entry)); assert(transit); + transit->q = queues[4]; transit->conn = &connection; transit->src_node_id = 11; transit->dst_node_id = 12; + connection.transit_queues = metrics_queue(inst, 0, 0); assert(!queue_data_put(connection.transit_queues, &transit->ll)); + struct PKTNORM normalizer = {.input = queues[5]}; connection.normalizer = &normalizer; + expect_router_metrics = 1; chat_core_collect_status_trampoline(inst); struct chat_node_arg* req = u_malloc(sizeof(*req)); assert(req); *req = (struct chat_node_arg){inst, 9}; chat_core_collect_conn_metrics_trampoline(req); assert(text_events == before + 2); target = "strbuf.c"; fault = F_REALLOCATE; chat_core_collect_status_trampoline(inst); assert(fault == F_NONE && text_events == before + 2); chat_core_collect_status_trampoline(inst); assert(text_events == before + 3); expect_large_text = 0; + req = u_malloc(sizeof(*req)); assert(req); *req = (struct chat_node_arg){inst, 9}; + fault = F_REALLOCATE; chat_core_collect_conn_metrics_trampoline(req); assert(fault == F_NONE && text_events == before + 3); + req = u_malloc(sizeof(*req)); assert(req); *req = (struct chat_node_arg){inst, 9}; chat_core_collect_conn_metrics_trampoline(req); + assert(text_events == before + 4); expect_router_metrics = 0; + const unsigned counts[] = {2, 1, 1, 1, 2, 1}; + const size_t bytes[] = {202, 303, 44, 55, 666, 777}; + for (unsigned i = 0; i < sizeof(queues) / sizeof(queues[0]); i++) { + assert(queue_entry_count(queues[i]) == (int)counts[i] && queue_total_bytes(queues[i]) == bytes[i]); + assert(!queue_check_consistency(queues[i])); queue_clear(queues[i]); queue_free(queues[i]); + } + queue_clear(connection.transit_queues); queue_free(connection.transit_queues); connection.transit_queues = NULL; + queue_clear(inst->router_conns); queue_free(inst->router_conns); inst->router_conns = saved_router; + connection.normalizer = NULL; queue_remove_data(inst->connections, entry); queue_entry_free(entry); u_free(links); queue_free(acks); if (SIZE_MAX > UINT32_MAX) { size_t baseline = u_get_allocated_count(); struct strbuf text; strbuf_init(&text, (size_t)UINT32_MAX + 1); assert(!text.buf && !text.cap && !text.len && u_get_allocated_count() == baseline); } DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS diagnostics:300 links,full status/metrics beyond8KiB,OOM suppresses partial event,retry,allocator capacity boundary"); + DEBUG_INFO(DEBUG_CATEGORY_CHAT_SYNC, "PASS router metrics:peer/RX scope,unrelated peer excluded,queue bytes/packets,transit,normalizer,null queues,read-only"); } static void member_limit(struct UTUN_INSTANCE* inst) { diff --git a/tests/test_etcp_router.c b/tests/test_etcp_router.c index 8c458f717..183abf5fd 100644 --- a/tests/test_etcp_router.c +++ b/tests/test_etcp_router.c @@ -543,6 +543,12 @@ static void monitor(void* arg) { uint32_t cycle_target = g_cycle_start_seq + g_cycle_sent; struct ETCP_ROUTER_CONN* rc = etcp_router_conn_get(cli, TOPO_GROUP_UTUN, server_node_id, TEST_SVC_ID); if (g_total_rcvd == cycle_target && rc->tx_acked == rc->tx_seq && !rc->send_q->count && !rc->inflight_q->count) { + struct ETCP_ROUTER_TX_STATS stats; + if (etcp_router_tx_stats(cli, TOPO_GROUP_UTUN, server_node_id, TEST_SVC_ID, get_time_us(), &stats) + || !stats.delivery_rtt_us || !stats.last_ack_us || stats.queued_age_us || stats.inflight_age_us + || stats.acked_packets != cycle_target) { + printf("[FAIL] Router delivery timing/counters after drain\n"); g_done = -1; return; + } t_cycle_drain_ms[g_cycle] = toc_ms(&t_phase_start); printf(" cycle %d drain: %7.1f ms rcvd=%u/%u\n", g_cycle + 1, t_cycle_drain_ms[g_cycle], g_total_rcvd, cycle_target); diff --git a/tools/cgathui_common/tests/test_ble_etcp.c b/tools/cgathui_common/tests/test_ble_etcp.c index 05b108baf..5c7ac780d 100644 --- a/tools/cgathui_common/tests/test_ble_etcp.c +++ b/tools/cgathui_common/tests/test_ble_etcp.c @@ -252,6 +252,19 @@ static void invitations(void) { assert(!a->send(a->ctx, endpoints[1].inst.node_id, 2, link, strlen(link))); assert(!b->send(b->ctx, endpoints[0].inst.node_id, 3, link, strlen(link))); exchange(4); drain(); + /* 1000 poll(0) не ждут 15ms BLE ACK: ждём конкретную доставку, а не скорость CPU. */ + uint64_t started = get_time_us(), deadline = started + 5000000; + for (int i = 0; i < 2; i++) { + struct ETCP_CONN* c = endpoints[i].conn; + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "BLE ETCP invitation overlap: side=%d received=%u target=5 input=%d send=%d ready=%d token=%d", + i, endpoints[i].received, queue_entry_count(c->input_queue), queue_entry_count(c->input_send_q), + etcp_link_io_ready(c->links), c->tx_token != NULL); + } + while ((endpoints[0].received < 5 || endpoints[1].received < 5 || invites_delivered[0] < 2 || invites_delivered[1] < 1) && + get_time_us() < deadline) uasync_poll(ua, 10); + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "BLE ETCP invitation overlap completed: RX=%u/%u invitations=%u/%u elapsed_us=%llu", + endpoints[0].received, endpoints[1].received, invites_delivered[0], invites_delivered[1], + (unsigned long long)(get_time_us() - started)); assert(invites_received[0] == 1 && invites_received[1] == 1); assert(invites_delivered[0] == 2 && invites_delivered[1] == 1); assert(delivery_status[0] == LOCAL_DELIVERY_RECEIVED && delivery_status[1] == LOCAL_DELIVERY_RECEIVED); @@ -266,7 +279,7 @@ static void invitations(void) { drop_invite_ack = 1; assert(!a->send(a->ctx, endpoints[1].inst.node_id, 5, link, strlen(link))); drain(); assert(invites_received[1] == 2 && invites_delivered[0] == 4); - uint64_t deadline = get_time_us() + 11000000ULL; + deadline = get_time_us() + 11000000ULL; while (invites_delivered[0] == 4 && get_time_us() < deadline) uasync_poll(ua, 100); assert(invites_delivered[0] == 5 && delivery_status[0] == LOCAL_DELIVERY_RECEIVED && invites_received[1] == 2); hold = 1; @@ -307,6 +320,7 @@ static void invitation_without_etcp(void) { } int main(void) { debug_config_init(); debug_set_level(getenv("UTUN_TEST_DEBUG") ? DEBUG_LEVEL_DEBUG : DEBUG_LEVEL_WARN); + debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_INFO); size_t baseline = u_get_allocated_count(); setup(); for (int i = 0; i < 2; i++) { struct ble_config config = {.node_id = endpoints[i].inst.node_id, .company_id = 0xffff}; @@ -357,9 +371,7 @@ int main(void) { for (int i = 0; i < 2; i++) { ble_link_id id = backends[i]->links[0].id; assert(!ble_tx_ready(backends[i]->owner, id)); - struct ll_entry* refused = ll_alloc_lldgram(1); assert(refused); refused->len = 1; - assert(ble_tx_put(backends[i]->owner, id, refused) == BLE_WOULD_BLOCK && !refused->owner); - queue_dgram_free(refused); queue_entry_free(refused); + assert(!etcp_link_io_ready(endpoints[i].conn->links)); /* Без отказа put, который мог бы замаскировать потерю wake. */ } int before = fragments; drain(); assert(before == fragments); /* Нет busy loop, когда GATT занят. */ if (n == 1) drop = 2; /* Локальный send успешен, но DATA-фрагмент теряется: восстанавливает ETCP. */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BleRadio.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BleRadio.kt index 9a98d7ad6..db6a52aca 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BleRadio.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/BleRadio.kt @@ -315,16 +315,14 @@ object BleRadio { BluetoothDevice.PHY_LE_CODED -> "CODED"; else -> "unknown($phy)" } log(if (status == 0) "INFO" else "WARN", "$role PHY address=$address tx=${name(txPhy)} rx=${name(rxPhy)} status=$status") } - // 2M — запрос предпочтения. S2/S8 относятся только к CODED PHY, здесь они не применяются. + // 1M: на проверенной Android/Linux паре 2M замедляет очередь контроллера. Результат проверяется callbacks. private fun preferFastPhy(device: BluetoothDevice, gatt: BluetoothGatt?) { try { - if (adapter?.isLe2MPhySupported == true) { - if (gatt != null) gatt.setPreferredPhy(BluetoothDevice.PHY_LE_2M_MASK, - BluetoothDevice.PHY_LE_2M_MASK, BluetoothDevice.PHY_OPTION_NO_PREFERRED) - else checkNotNull(server).setPreferredPhy(device, BluetoothDevice.PHY_LE_2M_MASK, - BluetoothDevice.PHY_LE_2M_MASK, BluetoothDevice.PHY_OPTION_NO_PREFERRED) - log("INFO", "PHY 2M requested address=${device.address} outgoing=${gatt != null}") - } else log("INFO", "Local controller has no PHY 2M; retaining negotiated PHY address=${device.address}") + if (gatt != null) gatt.setPreferredPhy(BluetoothDevice.PHY_LE_1M_MASK, + BluetoothDevice.PHY_LE_1M_MASK, BluetoothDevice.PHY_OPTION_NO_PREFERRED) + else checkNotNull(server).setPreferredPhy(device, BluetoothDevice.PHY_LE_1M_MASK, + BluetoothDevice.PHY_LE_1M_MASK, BluetoothDevice.PHY_OPTION_NO_PREFERRED) + log("INFO", "PHY 1M requested address=${device.address} outgoing=${gatt != null}") if (gatt != null) gatt.readPhy() else checkNotNull(server).readPhy(device) } catch (e: RuntimeException) { log("WARN", "PHY tuning unavailable address=${device.address}: ${e.message}") } } diff --git a/tools/chatgui/src/settingsdialog.cpp b/tools/chatgui/src/settingsdialog.cpp index 32fbaf600..941c6ba82 100644 --- a/tools/chatgui/src/settingsdialog.cpp +++ b/tools/chatgui/src/settingsdialog.cpp @@ -5,7 +5,6 @@ #include "bluetoothsettingspage.h" #include "databasesettingspage.h" #include "statuspage.h" -#include "nodespage.h" #include "soundsettingspage.h" #include "audiodevicesettingspage.h" #include "storagesettingspage.h" @@ -13,7 +12,6 @@ #include "avatars/avatarmanager.h" #include "avatars/avatarcrop.h" #include "ptt_hotkey_manager.h" -#include "connmonitorwindow.h" #include "../transport/gui_bridge.h" #include "../db/db_manager.h" #include "../transport/utun_node.h" @@ -69,10 +67,8 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Profile")); m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Storage")); m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Network")); - m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Connections")); m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Database")); m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Status")); - m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Nodes")); m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Sound")); m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Audio")); m_categoryList->addItem(QCoreApplication::translate("ChatGui", "Radio")); @@ -203,22 +199,6 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod m_networkPage = new NetworkSettingsPage(m_pages); m_pages->addWidget(m_networkPage); - // Connections page — button to open standalone window - { - auto* connPage = new QWidget(m_pages); - auto* connLayout = new QVBoxLayout(connPage); - connLayout->setAlignment(Qt::AlignCenter); - auto* hintLabel = new QLabel(QCoreApplication::translate("ChatGui", "Live ETCP connection monitor\nwith per-link BBR statistics"), connPage); - hintLabel->setAlignment(Qt::AlignCenter); - hintLabel->setStyleSheet("font-size: 12px; color: palette(text); padding: 20px;"); - connLayout->addWidget(hintLabel); - auto* openBtn = new QPushButton(QCoreApplication::translate("ChatGui", "Open Connection Monitor"), connPage); - openBtn->setStyleSheet("QPushButton { padding: 10px 20px; font-size: 13px; }"); - connect(openBtn, &QPushButton::clicked, this, [] { ConnMonitorWindow::showMonitor(); }); - connLayout->addWidget(openBtn); - m_pages->addWidget(connPage); - } - // Database page m_databasePage = new DatabaseSettingsPage(db, m_pages); m_pages->addWidget(m_databasePage); @@ -227,10 +207,6 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod m_statusPage = new StatusPage(node, m_pages); m_pages->addWidget(m_statusPage); - // Nodes page - m_nodesPage = new NodesPage(db, m_pages); - m_pages->addWidget(m_nodesPage); - m_soundPage = new SoundSettingsPage(db, m_pages); m_pages->addWidget(m_soundPage); @@ -340,9 +316,8 @@ void SettingsDialog::onCategoryChanged(int row) { m_pages->setCurrentIndex(row); DEBUG_DEBUG(DEBUG_CATEGORY_CONFIG, "Settings: selected page=%d; draft retained", row); // При переключении обновляем только диагностические страницы. - if (row == 4) m_databasePage->refreshStatus(); - if (row == 5) m_statusPage->refreshStatus(); - if (row == 6) m_nodesPage->refreshNodes(); + if (m_pages->currentWidget() == m_databasePage) m_databasePage->refreshStatus(); + if (m_pages->currentWidget() == m_statusPage) m_statusPage->refreshStatus(); } void SettingsDialog::onSave() { diff --git a/tools/chatgui/src/settingsdialog.h b/tools/chatgui/src/settingsdialog.h index a043861be..9b4e0b051 100644 --- a/tools/chatgui/src/settingsdialog.h +++ b/tools/chatgui/src/settingsdialog.h @@ -18,7 +18,6 @@ class BluetoothSettingsPage; class NetworkSettingsPage; class DatabaseSettingsPage; class StatusPage; -class NodesPage; class SoundSettingsPage; class AudioDeviceSettingsPage; class StorageSettingsPage; @@ -36,7 +35,7 @@ public: AudioRecorder* recorder, QWidget* parent = nullptr); private slots: - void onCategoryChanged(int row); // Выбрать страницу; DB/status/nodes обновляются, редактируемый черновик сохраняется. + void onCategoryChanged(int row); // Выбрать страницу; DB/status обновляются, редактируемый черновик сохраняется. /* Пройти запись/применение страниц и вызвать accept при завершении UI-шагов. * Команды владеют снимками значений; accept не подтверждает commit SQLite/применение ядром. Общей транзакции нет. */ void onSave(); @@ -62,7 +61,6 @@ private: NetworkSettingsPage* m_networkPage; // Черновик listen-сокетов и LAN-флагов. DatabaseSettingsPage* m_databasePage; // Сведения/обслуживание SQLite, обновление при выборе категории. StatusPage* m_statusPage; // Диагностика ядра/времени, обновление при выборе категории. - NodesPage* m_nodesPage; // Снимок известных узлов БД, обновление при выборе категории. SoundSettingsPage* m_soundPage; // Черновик звуков событий. AudioDeviceSettingsPage* m_audioDevicePage; // Выбор устройств/аудиопараметров и тесты микрофона/динамика. StorageSettingsPage* m_storagePage; // Черновик автозагрузки и лимитов media-хранилища. diff --git a/tools/chatgui/tests/test_android_call_mute.cpp b/tools/chatgui/tests/test_android_call_mute.cpp index 993cd36fe..e7be18b9e 100644 --- a/tools/chatgui/tests/test_android_call_mute.cpp +++ b/tools/chatgui/tests/test_android_call_mute.cpp @@ -112,7 +112,7 @@ int main(int argc, char** argv) { auto incoming = [&](uint32_t timestamp) { for (auto& sample : remote) { rng = rng * 1664525u + 1013904223u; sample = int(rng >> 18) - 8192; } uint8_t opus[256]; int len = opus_codec_encode(remote_encoder, remote.data(), Samples, opus, sizeof(opus)); CHECK(len > 0); - call_audio_on_media(&inst, 42, seq++, timestamp, opus, len, nullptr); + call_audio_on_media(&inst, 42, seq++, timestamp, opus, len, nullptr, 1); }; incoming(0); incoming(0); // Настоящий стартовый запас FIFO, приватное состояние не подменяется. for (unsigned frame = 0; frame < Total; ++frame) { diff --git a/tools/chatgui/tests/test_call_audio_lifecycle.c b/tools/chatgui/tests/test_call_audio_lifecycle.c index a94edaae4..530d3c139 100644 --- a/tools/chatgui/tests/test_call_audio_lifecycle.c +++ b/tools/chatgui/tests/test_call_audio_lifecycle.c @@ -64,10 +64,10 @@ int main(int argc, char** argv) { int16_t pcm[960] = {0}; uint8_t packet[256]; int length = opus_codec_encode(encoder, pcm, 960, packet, sizeof(packet)); CHECK_AUDIO(length > 0); struct UTUN_INSTANCE foreign = {0}; - call_audio_on_media(&foreign, 43, 0, 0, packet, length, NULL); + call_audio_on_media(&foreign, 43, 0, 0, packet, length, NULL, 1); struct call_jitter_diagnostics diagnostic = {0}; CHECK_AUDIO(!call_audio_get_diagnostics(43, &diagnostic) && diagnostic.encoded_ms == 0); - for (unsigned i = 0; i < 3; ++i) provider->on_media(inst, 43, i, i * 20, packet, length, NULL); + for (unsigned i = 0; i < 3; ++i) provider->on_media(inst, 43, i, i * 20, packet, length, NULL, 1); CHECK_AUDIO(!call_audio_get_diagnostics(43, &diagnostic) && diagnostic.encoded_ms == 60); opus_codec_encoder_destroy(encoder); call_audio_stop(); call_audio_destroy(inst); diff --git a/tools/chatgui/tests/test_call_audio_tx.cpp b/tools/chatgui/tests/test_call_audio_tx.cpp index 3c58b7b02..48aba70b7 100644 --- a/tools/chatgui/tests/test_call_audio_tx.cpp +++ b/tools/chatgui/tests/test_call_audio_tx.cpp @@ -3,6 +3,8 @@ extern "C" { #include "utun_instance.h" #include "call/call_audio.h" +#include "chat/chat_setting.h" +#include "opus_codec.h" #include "debug_config.h" #include "u_async.h" #include "mem.h" @@ -24,15 +26,19 @@ static std::array mask{}; static std::atomic test_time{100000}; static unsigned sent_new, sent_old, extra_new, expected_extra, extra_first = 8; static std::vector messages; +static std::vector last_packet; extern "C" uint64_t __wrap_get_time_tb() { return test_time.load(); } -extern "C" int __wrap_chat_setting_get_int(UTUN_INSTANCE*, const char* key, int fallback) { - return !strcmp(key, "call_compressor_enabled") || !strcmp(key, "aec_enabled") ? 0 : fallback; +extern "C" int __real_chat_setting_get_int(UTUN_INSTANCE*, const char*, int); +extern "C" int __wrap_chat_setting_get_int(UTUN_INSTANCE* inst, const char* key, int fallback) { + return !strcmp(key, "call_compressor_enabled") || !strcmp(key, "aec_enabled") ? 0 + : __real_chat_setting_get_int(inst, key, fallback); } extern "C" int __wrap_call_send_media(UTUN_INSTANCE*, uint64_t id, const uint8_t* data, int len, const call_capture_time* timing) { REQUIRE(data && len > 0 && timing); REQUIRE(id == old_id || id == new_id); + last_packet.assign(data, data + len); if (id == new_id) ++sent_new; else ++sent_old; return 0; } @@ -69,6 +75,40 @@ static void drain(UASYNC* ua) { uasync_poll(ua, 0); /* Tasks, опубликованные из extra, исполняются следующим проходом. */ } +/* Сравнение настоящих Opus-пакетов с независимым encoder: сохранённая настройка, + * изменение во время звонка и новый звонок без повторного init frontend. */ +static void check_codec_setting(UTUN_INSTANCE& inst) { + REQUIRE(chat_setting_set(&inst, "opus_codec_preset", "0") == 0); + begin(inst, new_id); + auto* reference = opus_codec_encoder_create(48000, 1); REQUIRE(reference); + unsigned frame = 0; uint32_t noise = 1; + for (int preset : {0, 2, 1, 0}) { + if (frame) REQUIRE(chat_setting_set(&inst, "opus_codec_preset", std::to_string(preset).c_str()) == 0); + REQUIRE(opus_codec_encoder_bitrate_set(reference, 16000 << preset) == 0); + REQUIRE(chat_setting_set(&inst, "opus_codec_preset", "3") == -2); + for (unsigned i = 0; i < 12; ++i, ++frame) { + for (auto& sample : pcm) { noise = noise * 1664525u + 1013904223u; sample = int16_t(noise >> 16) / 4; } + uint8_t expected[256]; + int size = opus_codec_encode(reference, pcm.data(), pcm.size(), expected, sizeof(expected)); REQUIRE(size > 0); + REQUIRE(feed(new_id, frame, 1) == 1); drain(inst.ua); + REQUIRE(last_packet.size() == size && !memcmp(last_packet.data(), expected, size)); + } + } + opus_codec_encoder_destroy(reference); call_audio_stop(); + REQUIRE(chat_setting_set(&inst, "opus_codec_preset", "2") == 0); + REQUIRE(call_audio_start_prepared(new_id) == 0); + reference = opus_codec_encoder_create(48000, 1); REQUIRE(reference); + REQUIRE(opus_codec_encoder_bitrate_set(reference, 64000) == 0); + uint8_t expected[256]; + int size = opus_codec_encode(reference, pcm.data(), pcm.size(), expected, sizeof(expected)); REQUIRE(size > 0); + REQUIRE(feed(new_id, 0, 1) == 1); drain(inst.ua); + REQUIRE(last_packet.size() == size && !memcmp(last_packet.data(), expected, size)); + opus_codec_encoder_destroy(reference); unbind(inst); + REQUIRE(chat_setting_set(&inst, "opus_codec_preset", "1") == 0); + pcm.fill(0); sent_new = sent_old = 0; + DEBUG_INFO(DEBUG_CATEGORY_CALL, "call-audio-tx: saved/live/restarted codec 16/32/64 kbit/s packets match reference PASS"); +} + int main(int argc, char** argv) { debug_config_init(); debug_set_level(DEBUG_LEVEL_ERROR); @@ -102,6 +142,7 @@ int main(int argc, char** argv) { UASYNC* ua = uasync_create(); REQUIRE(ua); inst.ua = other.ua = ua; + if (!strcmp(mode, "fresh")) check_codec_setting(inst); unsigned initial_new = 0, expected_initial = 8; if (!strcmp(mode, "fresh") || !strcmp(mode, "idempotent")) { uasync_post(ua, extra, nullptr); diff --git a/tools/chatgui/tests/test_call_jitter.cpp b/tools/chatgui/tests/test_call_jitter.cpp index 2b6d72f2e..ef2728ce4 100644 --- a/tools/chatgui/tests/test_call_jitter.cpp +++ b/tools/chatgui/tests/test_call_jitter.cpp @@ -28,7 +28,7 @@ static int decode_bad(void* arg, const uint8_t*, int, int16_t*, int) { return -1; } static void push(call_jitter* j, int id, int64_t received_ms = -1) { - call_jitter_push(j, (const uint8_t*)&id, sizeof(id), (uint32_t)(id * 20), received_ms < 0 ? id * 20 : received_ms, nullptr); + call_jitter_push(j, (const uint8_t*)&id, sizeof(id), (uint32_t)(id * 20), received_ms < 0 ? id * 20 : received_ms, nullptr, 1); } static void test_network_recovery() { @@ -52,7 +52,7 @@ static void test_network_recovery() { } CHECK(largest_reserve >= 60 && state.reserve_ms == 20); CHECK(state.delay_mode == 0 && state.phase_samples == 0 && state.tempo_ppm == 1000000); - CHECK(state.underruns > 0 && state.underruns <= 2); + CHECK(state.underruns <= 2); DEBUG_INFO(DEBUG_CATEGORY_CALL, "recovery block=%d max_reserve=%dms final=%dms phase=%u underruns=%u", block, largest_reserve, state.reserve_ms, state.phase_samples, state.underruns); call_jitter_destroy(j); @@ -79,7 +79,7 @@ static void test_audio_clock() { for (int s = 0; s < 960; s++) voice[s] = (int16_t)(8000 * std::sin((sent * 960ll + s) * 0.09)); int len = opus_codec_encode(encoder, voice, 960, packet, sizeof(packet)); CHECK(len > 0); call_capture_time timing{1000000 + sent * 20000ll, 9, (uint32_t)delay}; - call_audio_on_media(&audio_owner, id, (uint16_t)sent, 0, packet, len, &timing); + call_audio_on_media(&audio_owner, id, (uint16_t)sent, 0, packet, len, &timing, 1); } CHECK(call_audio_pull_pcm(id, output, 480) == 480); written += 480; int64_t now = tick * 10000ll; @@ -115,7 +115,26 @@ static void test_audio_clock() { CHECK(call_audio_try_clock(id, 43000000, &clock) == -1 && !clock.valid); std::printf("audio clock: real Opus, burst/tempo, device progress, local tones and reset checked\n"); } +static void test_packet_arrival_sampling() { + decoder d; auto *j = call_jitter_create(decode, &d, 1000); + for (unsigned packet = 0; packet < 4; packet++) { + for (int frame = 0; frame < 3; frame++) { + int id = packet * 3 + frame; + call_jitter_push(j, (const uint8_t*)&id, sizeof(id), packet * 60, + 1000 + packet * 60 + frame, nullptr, frame == 0); + } + } + call_jitter_diagnostics diag{}; call_jitter_get_diagnostics(j, &diag, 0); + CHECK(diag.arrival_spread_ms == 0); + int id = 12; + call_jitter_push(j, (const uint8_t*)&id, sizeof(id), 240, 1400, nullptr, 1); + call_jitter_get_diagnostics(j, &diag, 0); + CHECK(diag.arrival_spread_ms == 160); + call_jitter_destroy(j); +} + int main() { + test_packet_arrival_sampling(); CHECK(call_audio_init(&audio_owner) == 0); { call_jitter_window w; @@ -160,10 +179,15 @@ int main() { decoder d; auto* j = call_jitter_create(decode, &d, 1000); CHECK(j); - push(j, 0); // Сохраняем запас 20мс после первого блока, а не расходуем его при старте. - CHECK(call_jitter_pull(j, pcm, 480) == 0); - push(j, 1); - CHECK(call_jitter_pull(j, pcm, 480) > 0); + for (int i = 0; i < 9; i++) { + push(j, i); + CHECK(call_jitter_pull(j, pcm, 480) == 0); + CHECK(d.seen.empty()); // До200мс не декодируем и не расходуем накопленный звук. + } + call_jitter_get_stats(j, &depth, nullptr, nullptr, &under, nullptr); + CHECK(depth == 180 && under == 0); + push(j, 9); + CHECK(call_jitter_pull(j, pcm, 480) == 480); call_jitter_diagnostics diagnostic{}; call_jitter_get_diagnostics(j, &diagnostic, 0); CHECK(diagnostic.resumed == 1 && diagnostic.pcm_samples == 480 && diagnostic.reserve_ms == 20); @@ -175,13 +199,13 @@ int main() { call_jitter_get_diagnostics(j, &diagnostic, 0); CHECK(diagnostic.resumed == 0); call_jitter_range range; - push(j, 2); - for (int i = 3; i < 503; i++) push(j, i, 10060); // 10с сетевой задержки, затем пачка + push(j, 10); + for (int i = 11; i < 511; i++) push(j, i, 10220); // 10с сетевой задержки, затем пачка call_jitter_get_stats(j, nullptr, nullptr, nullptr, nullptr, &range); CHECK(range.reserve_ms == 1000); - int next = 503, highest = 100; + int next = 511, highest = 100; for (int tick = 0; tick < 6000; tick++) { - if (tick % 2 == 0) push(j, next++, 10060 + tick * 10); // непрерывный live + if (tick % 2 == 0) push(j, next++, 10220 + tick * 10); // непрерывный live call_jitter_pull(j, pcm, 480); call_jitter_get_stats(j, &depth, &tempo, &dropped, &under, nullptr); if (tempo > highest) highest = tempo; @@ -298,10 +322,10 @@ int main() { for (auto value : pcm) CHECK(value == 0); } // Первый пакет ещё не заполнил запас PCM: продолжается тихое ожидание. - call_audio_on_media(&audio_owner, id, 0, 0, encoded, len, nullptr); + call_audio_on_media(&audio_owner, id, 0, 0, encoded, len, nullptr, 1); CHECK(call_audio_pull_pcm(id, pcm, 480) == 480); for (auto value : pcm) CHECK(value == 0); - for (int i = 1; i < 6; i++) call_audio_on_media(&audio_owner, id, i, 0, encoded, len, nullptr); + for (int i = 1; i < 10; i++) call_audio_on_media(&audio_owner, id, i, 0, encoded, len, nullptr, 1); CHECK(call_audio_pull_pcm(id, pcm, 480) == 480); bool audible = false; for (auto value : pcm) if (value) audible = true; @@ -314,14 +338,14 @@ int main() { } CHECK(audible); // Малого запаса недостаточно для восстановления: сигнал продолжает звучать. - call_audio_on_media(&audio_owner, id, 6, 0, encoded, len, nullptr); + call_audio_on_media(&audio_owner, id, 10, 0, encoded, len, nullptr, 1); audible = false; for (int tick = 0; tick < 100; tick++) { CHECK(call_audio_pull_pcm(id, pcm, 480) == 480); for (auto value : pcm) if (std::abs((int)value) > 1000) audible = true; } CHECK(audible); - for (int i = 7; i < 30; i++) call_audio_on_media(&audio_owner, id, i, 0, encoded, len, nullptr); + for (int i = 11; i < 34; i++) call_audio_on_media(&audio_owner, id, i, 0, encoded, len, nullptr, 1); CHECK(call_audio_pull_pcm(id, pcm, 480) == 480); call_audio_begin_end(id); // явное завершение прекращает догон и включает финальный тон CHECK(call_audio_pull_pcm(id, pcm, 480) > 0); @@ -337,7 +361,7 @@ int main() { uint8_t encoded[512]; int len = opus_codec_encode(encoder, silence, 960, encoded, sizeof(encoded)); CHECK(len > 0); - for (int i = 0; i < 6; i++) call_audio_on_media(&audio_owner, 124, i, 0, encoded, len, nullptr); + for (int i = 0; i < 10; i++) call_audio_on_media(&audio_owner, 124, i, 0, encoded, len, nullptr, 1); std::vector output(777); under = 0; for (int tick = 0; tick < 30 && !under; tick++) {