From 123b47c612c141ae84138519345a3ae4b8a254ef Mon Sep 17 00:00:00 2001 From: evgeny Date: Sun, 4 Oct 2026 14:36:52 +0200 Subject: [PATCH] Improve Android VAD lifecycle, audio settings and diagnostics --- doc/tasks.md | 31 ++++ src/call/call.c | 2 +- src/lwip_tcp/lwip_tcp.c | 8 +- src/proxy/tcp_proxy_client.c | 12 +- src/radio/radio_audio.c | 146 ++++++++++++++---- tools/chatgui-android/app/build.gradle.kts | 1 + .../java/com/utun/chat/ChatApplication.kt | 2 - .../main/java/com/utun/chat/MainActivity.kt | 2 + .../java/com/utun/chat/data/ConfigProvider.kt | 46 +++++- .../main/java/com/utun/chat/data/NativeLib.kt | 16 +- .../com/utun/chat/data/RadioAudioEngine.kt | 114 ++++++++++---- .../java/com/utun/chat/data/RadioVadWindow.kt | 47 ++++++ .../utun/chat/ui/screens/ChannelListScreen.kt | 19 ++- .../com/utun/chat/ui/screens/ChatScreen.kt | 8 +- .../utun/chat/ui/screens/SettingsScreen.kt | 42 ++--- .../com/utun/chat/viewmodel/ChatViewModel.kt | 2 +- .../app/src/main/res/values-ru/strings.xml | 2 +- .../app/src/main/res/values/strings.xml | 2 +- .../jni_bridge/android_jni_bridge.c | 14 +- .../libutun_lite/instance_lite.c | 3 - tools/chatgui-android/libutun_lite/standby.c | 2 +- .../libutun_lite/voice_recorder.c | 4 +- .../tests/test_radio_vad_window.kt | 47 ++++++ tools/chatgui/tests/test_voice_tx.cpp | 45 ++++++ 24 files changed, 490 insertions(+), 127 deletions(-) create mode 100644 tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioVadWindow.kt create mode 100644 tools/chatgui-android/tests/test_radio_vad_window.kt diff --git a/doc/tasks.md b/doc/tasks.md index f283295c..7be13ae6 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -3,6 +3,37 @@ Сюда пишется список задач с короткой аннотацией. Если задача большая и имеет ТЗ - то ТЗ оформляется отдельным файлом, а сюда помещается аннотация и ссылка на ТЗ. ## Текущая задача +[+] **Android: диагностика несрабатывания VAD** — 03.10.2026: в `debug` добавлены + решения по каждому изменению состояния и сводки PCM/AGC/модели раз в секунду; + `debug=trace` показывает каждое окно 32мс. Логи различают сброс подтверждения, + чужой RX, закрытое окно Android, задержки inference и отложенный BEGIN. + Регрессии radio_vad, Silero, radio_audio и voice_tx прошли. На Samsung в 19:36:23 + подтверждён отказ после истечения 15-минутного окна: `reason=window_expired`, + `requested=true screen=true capture=false nativeVad=false`; ручной PTT работает. + Кнопка на панели показывает сохранённое включение, а не фактическую активность VAD. + Лог: `/tmp/vad-sm-current.log`. Исправление таймера и индикации — следующая запись. + +[+] **Android: индикация и фоновый таймер VAD** — 03.10.2026: кнопка показывает + фактическую активность захвата, гаснет при его остановке; повторное включение + открывает новое окно. 15-минутный таймер расходуется только в фоне, при видимом + приложении сбрасывается: после каждого сворачивания доступны полные 15 минут. + После истечения возврат в приложение возобновляет VAD; погасший экран по-прежнему + останавливает микрофон, включение экрана открывает новое окно. + `tests/test_radio_vad_window.kt` проверяет 2 часа в foreground, сброс таймера при + возврате, полное фоновое окно, границу истечения, экран и сохранение окна при перенастройке. + На Samsung проверены фактические lifecycle-переходы и выключенная кнопка после + остановки захвата: `/tmp/vad-background-sm-transitions.log`. Чистая сборка APK прошла, + Samsung SM-A525F и Q8 обновлены; на Samsung восстановлены прежний канал и активный VAD. + Уточнение: при возвращении в приложение таймер теперь полностью сбрасывается, + соответствующая регрессия прошла. Новая чистая сборка — `/tmp/vad-reset-android-build.log`, + установлена на оба телефона. Повторная проверка lifecycle на Samsung после этого + обновления не выполнена: телефон заблокирован; правило сброса проверено тестом с искусственным временем. + +[ ] **Android: время жизни config provider** — 03.10.2026: `utun_bridge_init()` + передаёт адрес локального `utun_config_provider_t` в `utun_config_set_provider()`, + который хранит указатель. После возврата из init он недействителен; перед + использованием `cfg_get_*` обеспечить постоянное хранение provider. + [+] **DM: надёжная доставка сообщений, первый этап pm.txt** — 01.10.2026: атомарные история/seq/outbox, подписанные точные квитанции после commit, live BGP-маршрут и durable очередь на суперузле с проверкой подписанной роли. diff --git a/src/call/call.c b/src/call/call.c index 709abcba..da1d21dc 100644 --- a/src/call/call.c +++ b/src/call/call.c @@ -490,7 +490,7 @@ static void call_post_stats(struct call_session* s) { etcp_router_conn_get(ctx->inst, s->group_id, s->peer_node_id, ETCP_RT_ID_CALL); if (rconn && rconn->recv_conn) rtt_tb = rconn->recv_conn->rtt_last; uint16_t rtt = rtt_tb / 10; /* ETCP хранит RTT в 0.1мс; CALL_STATS передаёт миллисекунды. */ - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "%s: stats id=%016llx recv_peer=%016llx rtt_tb=%u rtt_ms=%u buffer=%dms tempo=%d dropped=%u underruns=%u", + DEBUG_DEBUG(DEBUG_CATEGORY_CALL, "%s: stats id=%016llx recv_peer=%016llx rtt_tb=%u rtt_ms=%u buffer=%dms tempo=%d dropped=%u underruns=%u", CALL_ID, (unsigned long long)s->call_id, (unsigned long long)(rconn && rconn->recv_conn ? rconn->recv_conn->peer_node_id : 0), rtt_tb, rtt, buffer_ms, tempo_x100, dropped, underruns); diff --git a/src/lwip_tcp/lwip_tcp.c b/src/lwip_tcp/lwip_tcp.c index a050fe53..33ddcf53 100644 --- a/src/lwip_tcp/lwip_tcp.c +++ b/src/lwip_tcp/lwip_tcp.c @@ -221,7 +221,7 @@ void tcp_free(struct tcp_pcb *pcb) tcp_free_listen(pcb); return; } - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "LWIP_FREE pcb=%p state=%s lport=%u rport=%u arg=%p", + DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "LWIP_FREE pcb=%p state=%s lport=%u rport=%u arg=%p", (void*)pcb, tcp_debug_state_str(pcb->state), pcb->local_port, pcb->remote_port, pcb->callback_arg); memory_pool_free(pcb->ctx->pcb_pool, pcb); } @@ -389,11 +389,11 @@ void tcp_abandon(struct tcp_pcb *pcb, int reset) if (!pcb) return; if (pcb->state == LISTEN) return; - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "LWIP_ABANDON pcb=%p state=%s reset=%d errf=%p arg=%p", + DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "LWIP_ABANDON pcb=%p state=%s reset=%d errf=%p arg=%p", (void*)pcb, tcp_debug_state_str(pcb->state), reset, (void*)pcb->errf, pcb->callback_arg); if (pcb->state == TIME_WAIT) { - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "LWIP_ABANDON_TW pcb=%p lport=%u", (void*)pcb, pcb->local_port); + DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "LWIP_ABANDON_TW pcb=%p lport=%u", (void*)pcb, pcb->local_port); TCP_RMV(&pcb->ctx->tw_pcbs, pcb); tcp_free(pcb); return; @@ -1169,7 +1169,7 @@ struct tcp_pcb *tcp_alloc(struct lwip_tcp_ctx *ctx, uint8_t prio) pcb->ssthresh = TCP_SND_BUF; pcb->recv = tcp_recv_null; pcb->keep_idle = TCP_KEEPIDLE_DEFAULT; - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "LWIP_ALLOC pcb=%p prio=%u", (void*)pcb, prio); + DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "LWIP_ALLOC pcb=%p prio=%u", (void*)pcb, prio); } return pcb; } diff --git a/src/proxy/tcp_proxy_client.c b/src/proxy/tcp_proxy_client.c index 24582161..5b2eca9c 100644 --- a/src/proxy/tcp_proxy_client.c +++ b/src/proxy/tcp_proxy_client.c @@ -216,7 +216,7 @@ static err_t tcp_proxy_client_accept_cb(void *arg, struct tcp_pcb *newpcb, err_t struct tcp_proxy_client_conn *pc = u_calloc(1, sizeof(struct tcp_proxy_client_conn)); if (!pc) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "TCP proxy client: accept alloc failed"); return LERR_MEM; } pc->proxy = p; pc->pcb = newpcb; pc->stream_id = ++p->next_stream_id; - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "PROXY ACCEPT pcb=%p sid=%08x", (void*)newpcb, pc->stream_id); + DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "PROXY ACCEPT pcb=%p sid=%08x", (void*)newpcb, pc->stream_id); proxy_flow_init(&pc->flow, p->inst, p->ua, p->via_node_id, ETCP_RT_ID_TCP_PROXY_SERVER, pc->stream_id, tcp_proxy_client_flow_wake, pc); int i; @@ -410,7 +410,7 @@ static err_t tcp_proxy_client_sent_cb(void *arg, struct tcp_pcb *pcb, uint16_t l static void tcp_proxy_client_err_cb(void *arg, err_t err) { struct tcp_proxy_client_conn *pc = (struct tcp_proxy_client_conn *)arg; - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "PROXY ERR_CB pc=%p pcb=%p sid=%08x err=%d rem_closed=%d", + DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "PROXY ERR_CB pc=%p pcb=%p sid=%08x err=%d rem_closed=%d", (void*)pc, (void*)(pc ? pc->pcb : NULL), pc ? pc->stream_id : 0, err, pc ? pc->rem_closed : 0); if (!pc || !pc->proxy || pc->rem_closed) { DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "PROXY tcp_proxy_client_err_cb: pc=%p proxy=%p rem_closed=%d err=%d", (void*)pc, pc ? (void*)pc->proxy : NULL, pc ? pc->rem_closed : 0, err); return; } DEBUG_ERROR(DEBUG_CATEGORY_PROXY, "TCP proxy client: error %d sid=%08x fin_local=%d rem_closed=%d", @@ -504,7 +504,7 @@ static void tcp_proxy_client_conn_free(struct tcp_proxy_client_conn *pc) { if (!pc) return; struct tcp_proxy_client *p = pc->proxy; proxy_flow_destroy(&pc->flow); - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "PROXY FREE pcb=%p sid=%08x", (void*)pc->pcb, pc->stream_id); + DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "PROXY FREE pcb=%p sid=%08x", (void*)pc->pcb, pc->stream_id); uint32_t pending = pc->to_lwip ? queue_entry_count(pc->to_lwip) : 0; DEBUG_INFO(DEBUG_CATEGORY_PROXY, "PROXY FREE sid=%08x total_conns=%d to_lwip_q=%u", pc->stream_id, p->conn_count, pending); @@ -534,7 +534,7 @@ static void tcp_proxy_client_conn_free(struct tcp_proxy_client_conn *pc) { // (в т.ч. молча по истечении TIME_WAIT). Контракт: после вызова pc->pcb == NULL. static void tcp_proxy_client_conn_detach(struct tcp_proxy_client_conn *pc) { if (!pc || !pc->pcb) return; - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "PROXY DETACH pcb=%p state=%s sid=%08x", + DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "PROXY DETACH pcb=%p state=%s sid=%08x", (void*)pc->pcb, tcp_debug_state_str(pc->pcb->state), pc->stream_id); tcp_arg(pc->pcb, NULL); tcp_recv(pc->pcb, NULL); @@ -565,7 +565,7 @@ static void tcp_proxy_client_abort_pcb(struct tcp_proxy_client_conn *pc, const c if (!pc || !pc->pcb) return; struct tcp_pcb* pcb = pc->pcb; if (!tcp_proxy_client_pcb_guard(pc, why)) return; - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "PROXY %s pcb=%p state=%s sid=%08x", + DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "PROXY %s pcb=%p state=%s sid=%08x", why, (void*)pcb, tcp_debug_state_str(pcb->state), pc->stream_id); tcp_proxy_client_conn_detach(pc); tcp_abort(pcb); @@ -611,7 +611,7 @@ static void tcp_proxy_client_handle_close(struct tcp_proxy_client* p, uint32_t s DEBUG_INFO(DEBUG_CATEGORY_PROXY, "PROXY REM_CLOSED sid=%08x fin_local=%d pcb=%p", stream_id, pc->fin_local, (void*)pc->pcb); pc->rem_closed = 1; if (pc->pcb && tcp_proxy_client_pcb_guard(pc, "CLOSE")) { - DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "PROXY CLOSE pcb=%p state=%s sid=%08x", + DEBUG_DEBUG(DEBUG_CATEGORY_PROXY, "PROXY CLOSE pcb=%p state=%s sid=%08x", (void*)pc->pcb, tcp_debug_state_str(pc->pcb->state), stream_id); tcp_arg(pc->pcb, NULL); tcp_recv(pc->pcb, NULL); diff --git a/src/radio/radio_audio.c b/src/radio/radio_audio.c index 93fd6e90..2129fcfd 100644 --- a/src/radio/radio_audio.c +++ b/src/radio/radio_audio.c @@ -107,11 +107,17 @@ static float g_vad_win[RADIO_VAD_WINDOW_16K]; /* накопи static int g_vad_win_len = 0; static struct radio_vad_fsm g_vad_fsm; static uint64_t g_last_rx_tb = 0; /* последний принятый аудио-кадр (busy) */ +static uint64_t g_last_rx_node = 0; /* источник блокировки авто-PTT */ /* TX-статистика (кадрирование/кодирование), выводится раз в секунду пока идёт PTT. */ static uint64_t g_tx_stats_tb = 0; -static uint64_t g_vad_stats_tb = 0; -static float g_vad_peak_probability = 0; /* максимум между логами, чтобы не пропускать короткую речь */ +/* Сводка раз в секунду; TRACE показывает каждое окно, DEBUG — решения и причины отказа. */ +static struct { + uint64_t stats_tb, last_window_tb, total_windows, max_inference_tb, max_gap_tb; + unsigned windows, speech, busy, confirm_resets, errors; + float min_probability, max_probability, max_input_rms, max_model_rms; + int last_busy; +} g_vad_diag; static uint32_t g_tx_frames = 0; /* кадров за интервал */ static uint32_t g_tx_bytes = 0; /* суммарный размер opus за интервал */ static uint32_t g_tx_tail = 0; /* нулевых отсчётов дополнения хвоста за интервал */ @@ -410,7 +416,7 @@ int radio_audio_start(uint64_t group_id, int capture_channels) { g_group_id = group_id; ++g_listen_generation; g_active = 1; - g_last_rx_tb = 0; + g_last_rx_tb = g_last_rx_node = 0; memset(g_sources, 0, sizeof(g_sources)); pthread_mutex_unlock(&g_mtx); DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: listening grp=%016llx", RADIO_AUDIO_ID, (unsigned long long)group_id); @@ -486,17 +492,22 @@ int radio_audio_capture_start(uint64_t group_id, int capture_channels, int vad_e DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: silero_vad_create_default failed — VAD auto-PTT disabled", RADIO_AUDIO_ID); } if (!g_vad_mode && g_vad_compressor) { audio_compressor_destroy(g_vad_compressor); g_vad_compressor = NULL; } - DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: VAD compressor=%s max_gain=%.0fdB shared_with_TX=enabled,target,history,rise,release " - "block=%dms buffering=%dms vad=%d", RADIO_AUDIO_ID, + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "VAD config group=%016llx requested=%d ready=%d ch=%d threshold=%.3f confirm=%dx32ms " + "hangover=%dms busy_hold=%dms prefetch=%dms compressor=%s max_gain=%.0fdB target=%.1fdBFS " + "history=%dms rise=%.1fx release=%.1fx block=%dms buffering=%dms", (unsigned long long)group_id, + vad_enabled, g_vad_mode, capture_channels, g_vad_threshold, RADIO_VAD_CONFIRM_WINDOWS, + g_vad_hangover_ms, RADIO_VAD_BUSY_TB / 10, RADIO_PREFETCH_MS, g_vad_compressor ? "on" : (compressor_enabled ? "unavailable" : "off"), accfg.max_gain_db, - accfg.block_duration_ms, g_vad_compressor ? accfg.block_duration_ms : 0, g_vad_mode); + 20.0f * log10f(accfg.target_level), accfg.lookback_ms, accfg.rise_rate_per_sec, + accfg.release_rate_per_sec, accfg.block_duration_ms, g_vad_compressor ? accfg.block_duration_ms : 0); } g_vad_pcm_frames = 0; g_vad_input_sum_sq = 0; g_vad_input_rms = g_vad_output_rms = 0; if (g_vad_mode) { g_vad_win_len = 0; radio_vad_fsm_reset(&g_vad_fsm); - g_vad_stats_tb = 0; g_vad_peak_probability = 0; + memset(&g_vad_diag, 0, sizeof(g_vad_diag)); + g_vad_diag.last_busy = -1; } g_vad_manual_ptt = g_transmitting = g_tx_pcm_frames = g_vad_decimation = 0; g_vad_sum = 0; g_tx_burst = 0; @@ -518,6 +529,11 @@ int radio_audio_capture_start(uint64_t group_id, int capture_channels, int vad_e void radio_audio_capture_stop(void) { radio_tx_lock(); if (g_encoder) { + if (g_vad_mode) DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "VAD capture stop group=%016llx windows=%llu talking=%d manual=%d " + "transmitting=%d confirm=%d window_tail=%d pcm_tail=%d gain=%.1fdB", + (unsigned long long)g_group_id, (unsigned long long)g_vad_diag.total_windows, + g_vad_fsm.talking, g_vad_manual_ptt, g_transmitting, g_vad_fsm.confirm_count, + g_vad_win_len, g_vad_pcm_frames, audio_compressor_gain_db(g_vad_compressor)); if (g_transmitting) { radio_audio_flush_tx(); radio_audio_post_talk(g_inst ? g_inst->ua : NULL, g_group_id, 0); @@ -612,9 +628,21 @@ static void radio_audio_talk(void* arg) { int current = owner && talk->listen_generation == g_listen_generation && g_active && g_group_id == talk->group_id; pthread_mutex_unlock(&g_mtx); if (talk->begin) { - if (current && get_time_tb() - talk->created_tb <= 2000 && radio_talk_begin(talk->inst, talk->group_id) == 0) { - g_net_generation = talk->generation; g_net_burst = talk->burst; g_net_group = talk->group_id; + uint64_t age_tb = get_time_tb() - talk->created_tb; + if (!current || age_tb > 2000) { + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "VAD/PTT BEGIN discarded group=%016llx generation=%llu burst=%llu " + "owner=%d current=%d queue_age=%.1fms limit=200ms", (unsigned long long)talk->group_id, + (unsigned long long)talk->generation, (unsigned long long)talk->burst, owner, current, age_tb / 10.0); + return; } + int rc = radio_talk_begin(talk->inst, talk->group_id); + if (rc == 0) { + g_net_generation = talk->generation; g_net_burst = talk->burst; g_net_group = talk->group_id; + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "VAD/PTT BEGIN dispatched group=%016llx generation=%llu burst=%llu queue_age=%.1fms", + (unsigned long long)talk->group_id, (unsigned long long)talk->generation, + (unsigned long long)talk->burst, age_tb / 10.0); + } else DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "VAD/PTT BEGIN failed group=%016llx rc=%d queue_age=%.1fms", + (unsigned long long)talk->group_id, rc, age_tb / 10.0); } else if (g_net_generation == talk->generation && g_net_burst == talk->burst && g_net_group == talk->group_id) { if (owner) radio_talk_end(talk->inst, talk->group_id); g_net_generation = g_net_burst = g_net_group = 0; @@ -758,7 +786,7 @@ static void radio_audio_update_transmission(void) { } if (radio_audio_post_talk(g_inst ? g_inst->ua : NULL, g_group_id, desired) != 0) { u_free(prefetch); return; } g_transmitting = desired; - DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: TX %s generation=%llu burst=%llu manual=%d vad=%d", RADIO_AUDIO_ID, + DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "%s: TX %s generation=%llu burst=%llu manual=%d vad=%d", RADIO_AUDIO_ID, desired ? "BEGIN" : "FIN", (unsigned long long)g_tx_generation, (unsigned long long)g_tx_burst, g_vad_manual_ptt, g_vad_fsm.talking); if (desired && g_vad_mode) radio_audio_send_prefetch(prefetch); @@ -767,7 +795,7 @@ static void radio_audio_update_transmission(void) { void radio_audio_capture_discontinuity(uint64_t group_id) { radio_tx_lock(); if (g_active && g_group_id == group_id && g_encoder) { - DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: capture discontinuity tail=%d transmitting=%d vad_tail=%d vad_gain=%.1fdB", + DEBUG_WARN(DEBUG_CATEGORY_DEBUG, "%s: capture discontinuity tail=%d transmitting=%d vad_tail=%d vad_gain=%.1fdB", RADIO_AUDIO_ID, g_tx_pcm_frames, g_transmitting, g_vad_pcm_frames, audio_compressor_gain_db(g_vad_compressor)); radio_audio_flush_tx(); g_vad_win_len = g_vad_decimation = 0; g_vad_sum = 0; @@ -777,7 +805,8 @@ void radio_audio_capture_discontinuity(uint64_t group_id) { g_prefetch_write = g_prefetch_frames = 0; if (g_vad) silero_vad_reset(g_vad); radio_vad_fsm_reset(&g_vad_fsm); - g_vad_stats_tb = 0; g_vad_peak_probability = 0; + memset(&g_vad_diag, 0, sizeof(g_vad_diag)); + g_vad_diag.last_busy = -1; radio_audio_update_transmission(); } radio_tx_unlock(); @@ -791,37 +820,86 @@ static void radio_audio_vad_sample(const int16_t* frame) { g_vad_win[g_vad_win_len++] = g_vad_sum / (3.0f * 32768.0f); g_vad_decimation = 0; g_vad_sum = 0; if (g_vad_win_len != RADIO_VAD_WINDOW_16K) return; - if (!g_vad_compressor) { - double sum_sq = 0; - for (int i = 0; i < RADIO_VAD_WINDOW_16K; ++i) sum_sq += (double)g_vad_win[i] * g_vad_win[i]; - g_vad_input_rms = g_vad_output_rms = sqrtf((float)(sum_sq / RADIO_VAD_WINDOW_16K)); + double sum_sq = 0; + float peak = 0; + for (int i = 0; i < RADIO_VAD_WINDOW_16K; ++i) { + sum_sq += (double)g_vad_win[i] * g_vad_win[i]; + if (fabsf(g_vad_win[i]) > peak) peak = fabsf(g_vad_win[i]); } + float model_rms = sqrtf((float)(sum_sq / RADIO_VAD_WINDOW_16K)); + if (!g_vad_compressor) g_vad_input_rms = g_vad_output_rms = model_rms; float probability = 0; + uint64_t started = get_time_tb(); pthread_mutex_unlock(&g_mtx); int result = silero_vad_process(g_vad, g_vad_win, &probability); pthread_mutex_lock(&g_mtx); - if (result != 0) - DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: VAD process failed", RADIO_AUDIO_ID); g_vad_win_len = 0; uint64_t now = get_time_tb(); + uint64_t inference_tb = now - started; + uint64_t gap_tb = g_vad_diag.last_window_tb ? now - g_vad_diag.last_window_tb : 0; + g_vad_diag.last_window_tb = now; + ++g_vad_diag.total_windows; + ++g_vad_diag.windows; + if (inference_tb > g_vad_diag.max_inference_tb) g_vad_diag.max_inference_tb = inference_tb; + if (gap_tb > g_vad_diag.max_gap_tb) g_vad_diag.max_gap_tb = gap_tb; + if (result != 0) { + ++g_vad_diag.errors; + DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "VAD inference failed group=%016llx window=%llu rc=%d inference=%.1fms", + (unsigned long long)g_group_id, (unsigned long long)g_vad_diag.total_windows, result, inference_tb / 10.0); + } + struct radio_vad_fsm before = g_vad_fsm; int busy = g_last_rx_tb && now - g_last_rx_tb < RADIO_VAD_BUSY_TB; + int64_t rx_age_ms = g_last_rx_tb ? (int64_t)((now - g_last_rx_tb) / 10) : -1; int transition = radio_vad_fsm_update(&g_vad_fsm, probability, g_vad_threshold, RADIO_VAD_CONFIRM_WINDOWS, (uint64_t)g_vad_hangover_ms * 10, busy, now); - if (transition) { - DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: VAD %s probability=%.3f threshold=%.2f busy=%d hangover=%dms", RADIO_AUDIO_ID, - transition > 0 ? "START" : "STOP", probability, g_vad_threshold, busy, g_vad_hangover_ms); - } + uint64_t silence_tb = g_vad_fsm.silence_since_tb ? g_vad_fsm.silence_since_tb : before.silence_since_tb; + double silence_ms = silence_tb ? (now - silence_tb) / 10.0 : 0; + const char* reason = result != 0 ? "inference_error" : busy ? "rx_busy" : transition > 0 ? "confirmed" : + transition < 0 ? "silence_timeout" : g_vad_fsm.talking ? + (probability >= g_vad_threshold ? "speech" : "hangover") : + probability >= g_vad_threshold ? "confirming" : before.confirm_count ? "confirm_reset" : "below_threshold"; radio_audio_update_transmission(); - if (probability > g_vad_peak_probability) g_vad_peak_probability = probability; - if (!g_vad_stats_tb || now - g_vad_stats_tb >= RADIO_AUDIO_STATS_TB) { - DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: VAD probability=%.3f peak=%.3f threshold=%.2f confirm=%d/%d busy=%d manual=%d transmitting=%d " - "compressor=%s input_rms=%.6f output_rms=%.6f gain=%.1fdB clips=%u", - RADIO_AUDIO_ID, probability, g_vad_peak_probability, g_vad_threshold, g_vad_fsm.confirm_count, - RADIO_VAD_CONFIRM_WINDOWS, busy, g_vad_manual_ptt, g_transmitting, g_vad_compressor ? "on" : "off", - g_vad_input_rms, g_vad_output_rms, audio_compressor_gain_db(g_vad_compressor), - audio_compressor_clip_count(g_vad_compressor)); - g_vad_stats_tb = now; - g_vad_peak_probability = 0; + if (transition || busy != g_vad_diag.last_busy || before.confirm_count != g_vad_fsm.confirm_count || + !!before.silence_since_tb != !!g_vad_fsm.silence_since_tb) { + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "VAD decision group=%016llx window=%llu reason=%s probability=%.3f threshold=%.3f " + "talking=%d->%d confirm=%d->%d/%d silence=%.1f/%dms busy=%d rx_node=%016llx rx_age=%lldms " + "manual=%d transmitting=%d input=%.1fdBFS model=%.1fdBFS gain=%.1fdB inference=%.1fms gap=%.1fms", + (unsigned long long)g_group_id, (unsigned long long)g_vad_diag.total_windows, reason, probability, g_vad_threshold, + before.talking, g_vad_fsm.talking, before.confirm_count, g_vad_fsm.confirm_count, RADIO_VAD_CONFIRM_WINDOWS, + silence_ms, g_vad_hangover_ms, busy, (unsigned long long)g_last_rx_node, (long long)rx_age_ms, + g_vad_manual_ptt, g_transmitting, 20.0f * log10f(fmaxf(g_vad_input_rms, 0.000001f)), + 20.0f * log10f(fmaxf(model_rms, 0.000001f)), audio_compressor_gain_db(g_vad_compressor), + inference_tb / 10.0, gap_tb / 10.0); + } + DEBUG_TRACE(DEBUG_CATEGORY_DEBUG, "VAD window=%llu reason=%s probability=%.4f model_rms=%.6f peak=%.6f " + "input_rms=%.6f agc_rms=%.6f confirm=%d talking=%d busy=%d manual=%d tx=%d inference=%.1fms gap=%.1fms", + (unsigned long long)g_vad_diag.total_windows, reason, probability, model_rms, peak, g_vad_input_rms, g_vad_output_rms, + g_vad_fsm.confirm_count, g_vad_fsm.talking, busy, g_vad_manual_ptt, g_transmitting, inference_tb / 10.0, gap_tb / 10.0); + g_vad_diag.last_busy = busy; + if (probability >= g_vad_threshold) ++g_vad_diag.speech; + if (busy) ++g_vad_diag.busy; + if (before.confirm_count && !g_vad_fsm.confirm_count && transition != 1) ++g_vad_diag.confirm_resets; + if (g_vad_diag.windows == 1 || probability < g_vad_diag.min_probability) g_vad_diag.min_probability = probability; + if (probability > g_vad_diag.max_probability) g_vad_diag.max_probability = probability; + if (g_vad_input_rms > g_vad_diag.max_input_rms) g_vad_diag.max_input_rms = g_vad_input_rms; + if (model_rms > g_vad_diag.max_model_rms) g_vad_diag.max_model_rms = model_rms; + if (!g_vad_diag.stats_tb) g_vad_diag.stats_tb = now; + if (now - g_vad_diag.stats_tb >= RADIO_AUDIO_STATS_TB) { + DEBUG_DEBUG(DEBUG_CATEGORY_DEBUG, "VAD summary group=%016llx span=%.1fms windows=%u audio=%ums speech=%u " + "busy=%u confirm_resets=%u errors=%u probability=%.3f..%.3f threshold=%.3f reason=%s " + "input_peak=%.1fdBFS model_peak=%.1fdBFS gain=%.1fdB clips=%u inference_max=%.1fms gap_max=%.1fms " + "manual=%d talking=%d tx=%d tx_pending=%u control_pending=%u dropped=%u", + (unsigned long long)g_group_id, (now - g_vad_diag.stats_tb) / 10.0, g_vad_diag.windows, g_vad_diag.windows * 32, + g_vad_diag.speech, g_vad_diag.busy, g_vad_diag.confirm_resets, g_vad_diag.errors, + g_vad_diag.min_probability, g_vad_diag.max_probability, g_vad_threshold, reason, + 20.0f * log10f(fmaxf(g_vad_diag.max_input_rms, 0.000001f)), + 20.0f * log10f(fmaxf(g_vad_diag.max_model_rms, 0.000001f)), audio_compressor_gain_db(g_vad_compressor), + audio_compressor_clip_count(g_vad_compressor), g_vad_diag.max_inference_tb / 10.0, g_vad_diag.max_gap_tb / 10.0, + g_vad_manual_ptt, g_vad_fsm.talking, g_transmitting, g_tx_pending, g_control_pending, g_tx_dropped); + g_vad_diag.stats_tb = now; + g_vad_diag.windows = g_vad_diag.speech = g_vad_diag.busy = g_vad_diag.confirm_resets = g_vad_diag.errors = 0; + g_vad_diag.min_probability = g_vad_diag.max_probability = g_vad_diag.max_input_rms = g_vad_diag.max_model_rms = 0; + g_vad_diag.max_inference_tb = g_vad_diag.max_gap_tb = 0; } } @@ -838,7 +916,7 @@ static void radio_audio_vad_feed(const int16_t* frame) { g_vad_input_rms = sqrtf((float)(g_vad_input_sum_sq / count)); g_vad_pcm_frames = 0; g_vad_input_sum_sq = 0; if (audio_compressor_process_frame(g_vad_compressor, g_vad_pcm, g_vad_pcm, count) != 0) { - DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: VAD compressor frame failed count=%d — auto-PTT disabled until capture restart", + DEBUG_ERROR(DEBUG_CATEGORY_DEBUG, "%s: VAD compressor frame failed count=%d — auto-PTT disabled until capture restart", RADIO_AUDIO_ID, count); g_vad_mode = 0; g_prefetch_write = g_prefetch_frames = 0; radio_vad_fsm_reset(&g_vad_fsm); @@ -891,7 +969,7 @@ void radio_audio_on_frame(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t now = get_time_tb(); pthread_mutex_lock(&g_mtx); if (g_active && group_id == g_group_id) { - if (len > 0) g_last_rx_tb = now; /* busy-сигнал для VAD: чужой аудио-кадр */ + if (len > 0) { g_last_rx_tb = now; g_last_rx_node = src_node_id; } /* busy-сигнал: чужой аудио-кадр */ struct radio_source* s = radio_src_acquire(src_node_id, stream_id); if (s) { if (len > 0) radio_src_push_frame(s, seq, opus, len, now); diff --git a/tools/chatgui-android/app/build.gradle.kts b/tools/chatgui-android/app/build.gradle.kts index 30dcac7b..fdc24727 100644 --- a/tools/chatgui-android/app/build.gradle.kts +++ b/tools/chatgui-android/app/build.gradle.kts @@ -52,6 +52,7 @@ android { buildFeatures { compose = true + buildConfig = true } // Общие TGS-анимации с desktop-чатгуи, без копирования ресурсов. diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt index 3f20fde0..a9d1a8cf 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt @@ -3,7 +3,6 @@ package com.utun.chat import android.app.Application import android.content.res.Configuration import com.utun.chat.data.AppLanguage -import android.util.Log import android.os.Handler import android.os.Looper import com.utun.chat.data.LogManager @@ -29,7 +28,6 @@ object AppEventHandler : ChatEventCallback { if (type == 12 || type == 13 || type in 34..40 || type == 42 || type == 44) { mainHandler.post { CallController.onEvent(type, data) } } - Log.d("utun-gui", "[EVENT] type=$type len=${data?.size ?: 0}") _events.tryEmit(type to data) } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt index 9ead8876..e3753e98 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt @@ -471,6 +471,7 @@ class MainActivity : AppCompatActivity() { MicrophonePermission.requester = microphoneRequester RadioOverlayService.overlayPermissionRequester = overlayPermissionRequester super.onStart() + RadioAudioEngine.instance().setForeground(true) if (intent.getBooleanExtra(MicrophonePermission.EXTRA_REQUEST, false)) { intent.removeExtra(MicrophonePermission.EXTRA_REQUEST) requestMicrophone("PTT") { @@ -546,6 +547,7 @@ class MainActivity : AppCompatActivity() { } override fun onStop() { + RadioAudioEngine.instance().setForeground(false) super.onStop() if (MicrophonePermission.requester === microphoneRequester) MicrophonePermission.requester = null if (RadioOverlayService.overlayPermissionRequester === overlayPermissionRequester) RadioOverlayService.overlayPermissionRequester = null diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index 23721b92..e5f802ac 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -23,6 +23,16 @@ import java.util.concurrent.ThreadLocalRandom private val Context.dataStore by preferencesDataStore("utun_config") +data class VoiceSettings( + val preset: Int = 1, + val compressorEnabled: Boolean = true, + val maxGainDb: Int = 15, + val lookbackMs: Int = 200, + val lookaheadMs: Int = 100, + val riseRate: Int = 6, + val targetLevelDb: Int = -3 +) + class ConfigProvider(private val context: Context) { private val nodePrivateKey = stringPreferencesKey("node_private_key") private val nodePublicKey = stringPreferencesKey("node_public_key") @@ -44,6 +54,9 @@ class ConfigProvider(private val context: Context) { private val chatCompressorEnabled = intPreferencesKey("chat_compressor_enabled") private val chatCompressorMaxGainDb = intPreferencesKey("chat_compressor_max_gain_db") private val chatCompressorRiseRate = intPreferencesKey("chat_compressor_rise_rate") + private val chatCompressorLookbackMs = intPreferencesKey("chat_compressor_lookback_ms") + private val chatCompressorLookaheadMs = intPreferencesKey("chat_compressor_lookahead_ms") + private val chatCompressorTargetLevelDb = intPreferencesKey("chat_compressor_target_level_db") private val chatMediaDownloadMaxPeers = intPreferencesKey("chat_media_download_max_peers") private val chatStandbyActiveSec = intPreferencesKey("chat_standby_active_sec") private val chatStandbySleepSec = intPreferencesKey("chat_standby_sleep_sec") @@ -142,7 +155,7 @@ class ConfigProvider(private val context: Context) { key == "chat.call_jitter_max_reserve_ms" -> context.dataStore.data.first()[callJitterMaxReserveMs] ?: 1000 key == "chat.compressor_enabled" -> context.dataStore.data.first()[chatCompressorEnabled] ?: 1 key == "chat.compressor_max_gain_db" -> context.dataStore.data.first()[chatCompressorMaxGainDb] ?: 15 - key == "chat.compressor_rise_rate" -> context.dataStore.data.first()[chatCompressorRiseRate] ?: 2 + key == "chat.compressor_rise_rate" -> context.dataStore.data.first()[chatCompressorRiseRate] ?: 6 key == "chat.media_download_max_peers" -> context.dataStore.data.first()[chatMediaDownloadMaxPeers] ?: 3 key == "chat.standby_active_sec" -> context.dataStore.data.first()[chatStandbyActiveSec] ?: 2 key == "chat.standby_sleep_sec" -> context.dataStore.data.first()[chatStandbySleepSec] ?: 60 @@ -184,7 +197,7 @@ class ConfigProvider(private val context: Context) { "chat.call_jitter_max_reserve_ms" -> prefs[callJitterMaxReserveMs] = (value.toIntOrNull() ?: 1000).coerceIn(500, 2000) "chat.compressor_enabled" -> prefs[chatCompressorEnabled] = value.toIntOrNull() ?: 1 "chat.compressor_max_gain_db" -> prefs[chatCompressorMaxGainDb] = value.toIntOrNull() ?: 15 - "chat.compressor_rise_rate" -> prefs[chatCompressorRiseRate] = value.toIntOrNull() ?: 2 + "chat.compressor_rise_rate" -> prefs[chatCompressorRiseRate] = value.toIntOrNull() ?: 6 "chat.media_download_max_peers" -> prefs[chatMediaDownloadMaxPeers] = value.toIntOrNull() ?: 3 "chat.standby_active_sec" -> prefs[chatStandbyActiveSec] = value.toIntOrNull() ?: 2 "chat.standby_sleep_sec" -> prefs[chatStandbySleepSec] = value.toIntOrNull() ?: 60 @@ -204,6 +217,35 @@ class ConfigProvider(private val context: Context) { } } + fun getVoiceSettings(): VoiceSettings = runBlocking { + val prefs = context.dataStore.data.first() + val defaults = VoiceSettings() + val settings = VoiceSettings( + preset = prefs[chatOpusCodecPreset] ?: defaults.preset, + compressorEnabled = (prefs[chatCompressorEnabled] ?: 1) != 0, + maxGainDb = prefs[chatCompressorMaxGainDb] ?: defaults.maxGainDb, + lookbackMs = prefs[chatCompressorLookbackMs] ?: defaults.lookbackMs, + lookaheadMs = prefs[chatCompressorLookaheadMs] ?: defaults.lookaheadMs, + riseRate = prefs[chatCompressorRiseRate] ?: defaults.riseRate, + targetLevelDb = prefs[chatCompressorTargetLevelDb] ?: defaults.targetLevelDb + ) + LogManager.addLog("INFO", "AudioSettings", "Loaded: $settings") + settings + } + + suspend fun saveVoiceSettings(settings: VoiceSettings) { + context.dataStore.edit { prefs -> + prefs[chatOpusCodecPreset] = settings.preset + prefs[chatCompressorEnabled] = if (settings.compressorEnabled) 1 else 0 + prefs[chatCompressorMaxGainDb] = settings.maxGainDb + prefs[chatCompressorLookbackMs] = settings.lookbackMs + prefs[chatCompressorLookaheadMs] = settings.lookaheadMs + prefs[chatCompressorRiseRate] = settings.riseRate + prefs[chatCompressorTargetLevelDb] = settings.targetLevelDb + } + LogManager.addLog("INFO", "AudioSettings", "Saved: $settings") + } + suspend fun generateKeys() { /* Keys are generated in C (instance_lite via ensure_keys). * They are saved back via NativeLib.start() → nativeGetGeneratedKeys(). */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 4026a08b..52569573 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -16,11 +16,23 @@ object NativeLib { /** Save Kotlin callbacks to C globals (log, config). Must be called before start(). */ fun init(dbPath: String, controlPort: Int): Boolean { - return nativeInit( + val provider = ChatApplication.instance.configProvider + val settings = provider.getVoiceSettings() + val ok = nativeInit( dbPath, controlPort, - ChatApplication.instance.configProvider, + provider, LogManager ) + if (!ok) { + LogManager.addLog("ERROR", "AudioSettings", "Native initialization failed; voice settings not applied") + return false + } + nativeVoiceSetPreset(settings.preset) + nativeVoiceSetCompressor(settings.compressorEnabled) + nativeVoiceSetCompressorConfig(settings.maxGainDb, settings.lookbackMs, settings.lookaheadMs, + settings.riseRate.toFloat(), settings.targetLevelDb.toFloat()) + LogManager.addLog("INFO", "AudioSettings", "Applied at initialization: $settings") + return true } /** Start full uTun instance from INI config text */ diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt index e99eb511..6f077eba 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt @@ -43,7 +43,7 @@ class RadioAudioEngine { const val PLAY_CHANNELS = 2 const val RELEASE_HOLD_MS = 250L - const val VAD_ACTIVE_MS = 15 * 60 * 1000L + const val VAD_BACKGROUND_MS = 15 * 60 * 1000L /** Процессный синглтон: доступен из Activity и оверлей-сервиса. */ @Volatile private var inst: RadioAudioEngine? = null @@ -76,8 +76,7 @@ class RadioAudioEngine { @Volatile private var talkRequestNs = 0L @Volatile private var generation = 0L private var audioGroup = 0L - @Volatile private var vadDeadline = 0L - private var vadConfigured = false + private val vadWindow = RadioVadWindow(VAD_BACKGROUND_MS) private var screenReceiver: BroadcastReceiver? = null @Volatile private var screenInteractive = false private var configuredInput = INPUT_AUTO @@ -102,8 +101,8 @@ class RadioAudioEngine { private val releaseTalk = Runnable { finishTalk() } private val vadTimeout = Runnable { io.execute { - if (active && SystemClock.elapsedRealtime() >= vadDeadline) { - LogManager.addLog("INFO", "RadioAudio", "VAD window expired (15 minutes)") + if (active && vadWindow.deadlineMs > 0L && !vadWindow.isAllowed(SystemClock.elapsedRealtime())) { + LogManager.addLog("INFO", "debug", "VAD background window expired (15 minutes) group=$audioGroup") updateCapture() } } @@ -142,7 +141,7 @@ class RadioAudioEngine { if (!active || generation != version) return@execute releaseLocked() audioGroup = group - vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS + vadWindow.restart(SystemClock.elapsedRealtime()) router.setAutoHeadset(true) if (active && generation == version) { try { setupAudio() } catch (e: Exception) { @@ -164,17 +163,29 @@ class RadioAudioEngine { io.execute { releaseLocked() } } - /** Смена настроек перезапускает только захват, сохраняя приём и окно VAD. */ - fun reconfigure() { + /** Смена настроек сохраняет окно VAD; явное включение открывает новое. */ + fun reconfigure(restartVad: Boolean = false) { val version = generation io.execute { if (!active || generation != version || audioTrack == null) return@execute stopCapture() loadCaptureSettings() + if (restartVad) vadWindow.restart(SystemClock.elapsedRealtime()) updateCapture() } } + /** Видимое приложение сбрасывает фоновое окно VAD и отключает отсчёт. */ + fun setForeground(foreground: Boolean) { + val nowMs = SystemClock.elapsedRealtime() + io.execute { + vadWindow.setForeground(foreground, nowMs) + LogManager.addLog("DEBUG", "debug", "VAD application foreground=$foreground group=$audioGroup " + + "backgroundLeftMs=${vadWindow.timeLeftMs(nowMs)}") + if (active) updateCapture() + } + } + /* Приём создаётся независимо от микрофона (только io-поток). */ private fun setupAudio() { loadCaptureSettings() @@ -356,12 +367,12 @@ class RadioAudioEngine { io.execute { if (!active || generation != version) return@execute if (intent.action == Intent.ACTION_SCREEN_OFF) { - vadDeadline = 0L - LogManager.addLog("INFO", "RadioAudio", "screen off: VAD window closed") + vadWindow.close() + LogManager.addLog("INFO", "debug", "VAD screen off: window closed group=$audioGroup") } if (intent.action == Intent.ACTION_SCREEN_ON) { - vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS - LogManager.addLog("INFO", "RadioAudio", "screen on: new VAD window (15 minutes)") + vadWindow.restart(SystemClock.elapsedRealtime()) + LogManager.addLog("INFO", "debug", "VAD screen on: new background window (15 minutes) group=$audioGroup") } updateCapture() } @@ -379,14 +390,24 @@ class RadioAudioEngine { screenInteractive = screenOn() } - /** Выбор захвата: PTT либо включённый VAD в пределах окна при горящем экране. */ + /** VAD без ограничения в приложении; после сворачивания — полные 15 минут при горящем экране. */ private fun updateCapture() { mainHandler.removeCallbacks(vadTimeout) val configured = configuredVad - if (configured && !vadConfigured) vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS - vadConfigured = configured - val wantVad = active && screenInteractive && SystemClock.elapsedRealtime() < vadDeadline && configured - if (wantVad) mainHandler.postDelayed(vadTimeout, (vadDeadline - SystemClock.elapsedRealtime()).coerceAtLeast(1L)) + val now = SystemClock.elapsedRealtime() + vadWindow.setEnabled(configured, now) + val wantVad = active && screenInteractive && vadWindow.isAllowed(now) && configured + val reason = when { + !active -> "radio_off" + !configured -> "vad_disabled" + !screenInteractive -> "screen_off" + !vadWindow.isAllowed(now) -> "window_expired" + else -> "vad_allowed" + } + LogManager.addLog("DEBUG", "debug", "VAD capture gate group=$audioGroup reason=$reason requested=$configured " + + "screen=$screenInteractive foreground=${vadWindow.foreground} backgroundLeftMs=${vadWindow.timeLeftMs(now)} manual=$requestedTalk " + + "capture=$captureRunning nativeVad=$vadMode playback=${audioTrack != null}") + if (wantVad && !vadWindow.foreground) mainHandler.postDelayed(vadTimeout, vadWindow.timeLeftMs(now).coerceAtLeast(1L)) if (!active || (!requestedTalk && !wantVad)) { stopCapture() return @@ -411,7 +432,7 @@ class RadioAudioEngine { private fun startCapture(vad: Boolean): Boolean { if (!MicrophonePermission.granted()) { - LogManager.addLog("WARN", "RadioAudio", "Capture blocked: RECORD_AUDIO not granted group=$audioGroup vad=$vad") + LogManager.addLog("WARN", "debug", "VAD/PTT capture blocked: RECORD_AUDIO not granted group=$audioGroup vad=$vad") return false } val startNs = SystemClock.elapsedRealtimeNanos() @@ -425,7 +446,7 @@ class RadioAudioEngine { } vadMode = NativeLib.radioVadMode() if (vad && !vadMode) { - LogManager.addLog("ERROR", "RadioAudio", "VAD unavailable: capture cancelled") + LogManager.addLog("ERROR", "debug", "VAD unavailable: capture cancelled group=$audioGroup") NativeLib.radioCaptureStop(); rec.release() return false } @@ -465,7 +486,7 @@ class RadioAudioEngine { }.apply { name = "radio-capture"; priority = Thread.MAX_PRIORITY; start() } - LogManager.addLog("INFO", "RadioAudio", + LogManager.addLog("INFO", "debug", "capture started group=$audioGroup ch=$channels vad=$vadMode " + "createMs=${(createdNs - startNs) / 1_000_000} prepareMs=${(preparedNs - createdNs) / 1_000_000} " + "startMs=${(recordingNs - preparedNs) / 1_000_000} totalMs=${(recordingNs - startNs) / 1_000_000}") @@ -485,7 +506,7 @@ class RadioAudioEngine { rec.release() audioRecord = null NativeLib.radioCaptureStop() - LogManager.addLog("INFO", "RadioAudio", "capture released group=$audioGroup") + LogManager.addLog("INFO", "debug", "VAD/PTT capture released group=$audioGroup") } vadMode = false talking = false @@ -495,6 +516,7 @@ class RadioAudioEngine { private fun releaseLocked() { mainHandler.removeCallbacks(vadTimeout) + vadWindow.setEnabled(false, SystemClock.elapsedRealtime()) screenReceiver?.let { ChatApplication.instance.unregisterReceiver(it) } screenReceiver = null stopCapture() @@ -573,6 +595,16 @@ class RadioAudioEngine { val buf = ShortArray(frame) var frameN = 0L var reportedRequestNs = 0L + var statsMs = SystemClock.elapsedRealtime() + var statsFrames = 0 + var fedFrames = 0 + var skippedFrames = 0 + var failedFrames = 0 + var minRmsDb = 0 + var maxRmsDb = -120 + var maxReadNs = 0L + var maxFeedNs = 0L + var previousFeedAllowed: Boolean? = null while (captureRunning) { if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) { if (captureRunning) { @@ -580,6 +612,7 @@ class RadioAudioEngine { } break } + val readStartedNs = SystemClock.elapsedRealtimeNanos() var read = 0 while (read < frame) { if (!captureRunning) return @@ -592,22 +625,45 @@ class RadioAudioEngine { } read += n } - /* диагностика крос-девайс входа/выхода: куда реально роутится захват + уровень сигнала */ - if (frameN < 10 || frameN % 100L == 0L) { - LogManager.addLog("DEBUG", "RadioAudio", - "cap routed=${rec.routedDevice?.type ?: "-"} frame=$frameN rmsDb=${rmsDb(buf)} input=$configuredInput route=${route()}") - } + maxReadNs = maxOf(maxReadNs, SystemClock.elapsedRealtimeNanos() - readStartedNs) + val inputDb = rmsDb(buf) + minRmsDb = minOf(minRmsDb, inputDb) + maxRmsDb = maxOf(maxRmsDb, inputDb) + statsFrames++ frameN++ - val vadAllowed = vadMode && screenInteractive && SystemClock.elapsedRealtime() < vadDeadline - if (active && groupId == group && captureRunning && ((talking && requestedTalk) || vadAllowed)) { + val vadAllowed = vadMode && screenInteractive && vadWindow.isAllowed(SystemClock.elapsedRealtime()) + val feedAllowed = active && groupId == group && captureRunning && ((talking && requestedTalk) || vadAllowed) + if (previousFeedAllowed != feedAllowed) { + LogManager.addLog("DEBUG", "debug", "VAD PCM gate group=$group frame=$frameN allowed=$feedAllowed " + + "active=$active currentGroup=$groupId capture=$captureRunning manual=$requestedTalk talking=$talking " + + "nativeVad=$vadMode screen=$screenInteractive backgroundDeadlineMs=${vadWindow.deadlineMs}") + previousFeedAllowed = feedAllowed + } + if (feedAllowed) { val requestNs = talkRequestNs + val feedStartedNs = SystemClock.elapsedRealtimeNanos() val fed = NativeLib.radioAudioFeed(group, buf) - if (!fed) LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false") + maxFeedNs = maxOf(maxFeedNs, SystemClock.elapsedRealtimeNanos() - feedStartedNs) + if (fed) fedFrames++ else { + failedFrames++ + if (failedFrames == 1) LogManager.addLog("WARN", "debug", "VAD/PTT PCM rejected group=$group frame=$frameN") + } if (fed && requestedTalk && requestNs != reportedRequestNs) { reportedRequestNs = requestNs LogManager.addLog("INFO", "RadioAudio", "PTT first frame queued elapsedMs=${(SystemClock.elapsedRealtimeNanos() - requestNs) / 1_000_000}") } if (captureRunning) _talkingFlow.value = requestedTalk || NativeLib.radioTransmitting() + } else skippedFrames++ + val nowMs = SystemClock.elapsedRealtime() + if (nowMs - statsMs >= 1000L) { + LogManager.addLog("DEBUG", "debug", "VAD capture summary group=$group spanMs=${nowMs - statsMs} " + + "frames=$statsFrames audioMs=${statsFrames * FRAME_MS} fed=$fedFrames skipped=$skippedFrames rejected=$failedFrames " + + "rmsDb=$minRmsDb..$maxRmsDb readMaxMs=${maxReadNs / 1_000_000.0} feedMaxMs=${maxFeedNs / 1_000_000.0} " + + "source=${rec.audioSource} channels=$ch routed=${rec.routedDevice?.type ?: "-"} input=$configuredInput " + + "route=${route()} vad=$vadMode screen=$screenInteractive manual=$requestedTalk tx=${_talkingFlow.value}") + statsMs = nowMs + statsFrames = 0; fedFrames = 0; skippedFrames = 0; failedFrames = 0 + minRmsDb = 0; maxRmsDb = -120; maxReadNs = 0L; maxFeedNs = 0L } } LogManager.addLog("DEBUG", "RadioAudio", "capture loop done") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioVadWindow.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioVadWindow.kt new file mode 100644 index 00000000..8483d666 --- /dev/null +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioVadWindow.kt @@ -0,0 +1,47 @@ +package com.utun.chat.data + +/** Окно VAD расходуется только в фоне; все изменения выполняются в radio-audio-io. */ +class RadioVadWindow(private val durationMs: Long) { + @Volatile var deadlineMs = 0L + private set + var foreground = false + private set + private var enabled = false + private var remainingMs = durationMs + + fun setForeground(value: Boolean, nowMs: Long) { + if (foreground == value) return + if (value) remainingMs = durationMs + foreground = value + updateDeadline(nowMs) + } + + fun setEnabled(value: Boolean, nowMs: Long) { + if (enabled == value) return + enabled = value + if (value) restart(nowMs) else close() + } + + fun restart(nowMs: Long) { + remainingMs = durationMs + updateDeadline(nowMs) + } + + fun close() { + remainingMs = 0L + deadlineMs = 0L + } + + fun isAllowed(nowMs: Long): Boolean = nowMs < deadlineMs + + fun timeLeftMs(nowMs: Long): Long = + if (deadlineMs == Long.MAX_VALUE) remainingMs else (deadlineMs - nowMs).coerceAtLeast(0L) + + private fun updateDeadline(nowMs: Long) { + deadlineMs = when { + !enabled -> 0L + foreground -> Long.MAX_VALUE + else -> nowMs + remainingMs + } + } +} diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt index 11223c48..db77f306 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt @@ -1,6 +1,7 @@ package com.utun.chat.ui.screens import com.utun.chat.R +import com.utun.chat.BuildConfig import androidx.compose.ui.res.stringResource import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -28,6 +29,7 @@ import androidx.compose.ui.unit.sp import com.utun.chat.data.Channel import com.utun.chat.data.DmConversation import com.utun.chat.data.NotificationSound +import com.utun.chat.data.LogManager import com.utun.chat.ChatApplication import kotlinx.coroutines.launch @@ -53,7 +55,10 @@ fun ChannelListScreen( var optionsChannel by remember { mutableStateOf(null) } var deleteConfirmChannel by remember { mutableStateOf(null) } - LaunchedEffect(Unit) { onRefresh() } + LaunchedEffect(Unit) { + LogManager.addLog("INFO", "UI", "Channel list: version=${BuildConfig.VERSION_NAME} code=${BuildConfig.VERSION_CODE}") + onRefresh() + } Scaffold( topBar = { @@ -72,8 +77,16 @@ fun ChannelListScreen( ) }, floatingActionButton = { - FloatingActionButton(onClick = { showCreateDialog = true }) { - Icon(Icons.Default.Add, stringResource(R.string.new_channel)) + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = "v${BuildConfig.VERSION_NAME}", + modifier = Modifier.padding(end = 16.dp), + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + FloatingActionButton(onClick = { showCreateDialog = true }) { + Icon(Icons.Default.Add, stringResource(R.string.new_channel)) + } } } ) { padding -> diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt index 0aa7f899..4b7d584f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt @@ -260,7 +260,7 @@ private fun RadioSourceBar(viewModel: ChatViewModel) { val provider = ChatApplication.instance.configProvider val scope = rememberCoroutineScope() val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay()) - val vadEnabled by provider.radioVadEnabled.collectAsState(initial = provider.getInt("radio.vad_enabled") != 0) + val vadActive by RadioAudioEngine.instance().vadActiveFlow.collectAsState() val volumePttEnabled by provider.radioVolumePttEnabled.collectAsState(initial = provider.getRadioVolumePtt()) val overlayShown by RadioOverlayService.isShown.collectAsState() val overlayStatus by RadioOverlayService.status.collectAsState() @@ -305,9 +305,9 @@ private fun RadioSourceBar(viewModel: ChatViewModel) { volumePttEnabled, volumeKeys = true) { viewModel.setRadioVolumePtt(!volumePttEnabled) } - RadioControlButton(null, stringResource(if (vadEnabled) R.string.disable_vad else R.string.enable_vad), - vadEnabled, text = stringResource(R.string.vad_button)) { - viewModel.setRadioVad(!vadEnabled) + RadioControlButton(null, stringResource(if (vadActive) R.string.disable_vad else R.string.enable_vad), + vadActive, text = stringResource(R.string.vad_button)) { + viewModel.setRadioVad(!vadActive) } RadioControlButton(null, stringResource(R.string.floating_ptt), overlayShown, floating = true) { scope.launch { diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index 3661e89e..aa821561 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -32,6 +32,7 @@ import com.utun.chat.data.RadioAudioEngine import com.utun.chat.data.LogManager import com.utun.chat.data.ServerEntry import com.utun.chat.data.LocalSocketState +import com.utun.chat.data.VoiceSettings import com.utun.chat.headless.HeadlessService import com.utun.chat.ui.components.BatteryOptimizationSetting import com.utun.chat.ui.components.LanguageSetting @@ -42,6 +43,7 @@ import android.os.Build import android.provider.Settings import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.CancellationException import kotlinx.coroutines.delay @@ -49,6 +51,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import java.util.UUID +import java.io.IOException private val LEVELS = listOf("NONE", "ERROR", "WARN", "INFO", "DEBUG", "TRACE") private val CATEGORIES = listOf( @@ -773,28 +776,31 @@ private fun AudioSettingsTab(vm: ChatViewModel) { /* Codec preset */ val presets = listOf(stringResource(R.string.codec_low), stringResource(R.string.codec_standard), stringResource(R.string.codec_high)) - var preset by remember { mutableStateOf(vm.getVoicePreset()) } + val savedVoiceSettings = remember { provider.getVoiceSettings() } + var preset by remember { mutableStateOf(savedVoiceSettings.preset) } var presetExpanded by remember { mutableStateOf(false) } var jitterReserveMs by remember { mutableStateOf(provider.getInt("chat.call_jitter_max_reserve_ms")) } - /* Compressor params with defaults from desktop */ - var compressorOn by remember { mutableStateOf(vm.getVoiceCompressor()) } - var maxGainDb by remember { mutableStateOf(15) } - var lookbackMs by remember { mutableStateOf(200) } - var lookaheadMs by remember { mutableStateOf(100) } - var riseRate by remember { mutableStateOf(2) } - var targetLevelDb by remember { mutableStateOf(-12) } + /* Все параметры восстанавливаются из одного снимка DataStore. */ + var compressorOn by remember { mutableStateOf(savedVoiceSettings.compressorEnabled) } + var maxGainDb by remember { mutableStateOf(savedVoiceSettings.maxGainDb) } + var lookbackMs by remember { mutableStateOf(savedVoiceSettings.lookbackMs) } + var lookaheadMs by remember { mutableStateOf(savedVoiceSettings.lookaheadMs) } + var riseRate by remember { mutableStateOf(savedVoiceSettings.riseRate) } + var targetLevelDb by remember { mutableStateOf(savedVoiceSettings.targetLevelDb) } val apply = { vm.setVoicePreset(preset) vm.setVoiceCompressor(compressorOn) vm.setCompressorConfig(maxGainDb, lookbackMs, lookaheadMs, riseRate.toFloat(), targetLevelDb.toFloat()) - scope.launch { - provider.setValue("chat.opus_codec_preset", preset.toString()) - provider.setValue("chat.compressor_enabled", if (compressorOn) "1" else "0") - provider.setValue("chat.compressor_max_gain_db", maxGainDb.toString()) - provider.setValue("chat.compressor_rise_rate", riseRate.toString()) + val settings = VoiceSettings(preset, compressorOn, maxGainDb, lookbackMs, lookaheadMs, riseRate, targetLevelDb) + vm.viewModelScope.launch { + try { + provider.saveVoiceSettings(settings) + } catch (e: IOException) { + LogManager.addLog("ERROR", "AudioSettings", "Save failed: $settings: $e") + } } NativeLib.setChatSetting("opus_codec_preset", preset.toString()) NativeLib.setChatSetting("compressor_enabled", if (compressorOn) "1" else "0") @@ -915,15 +921,15 @@ private fun AudioSettingsTab(vm: ChatViewModel) { } LabeledSlider(stringResource(R.string.max_gain), maxGainDb.toFloat(), 0f..30f, 30, "${maxGainDb} dB", - { maxGainDb = it.toInt(); apply() }) + { maxGainDb = it.toInt() }, { apply() }) LabeledSlider(stringResource(R.string.lookback_time), lookbackMs.toFloat(), 50f..500f, 45, stringResource(R.string.milliseconds, lookbackMs), - { lookbackMs = it.toInt(); apply() }) + { lookbackMs = it.toInt() }, { apply() }) LabeledSlider(stringResource(R.string.lookahead_time), lookaheadMs.toFloat(), 20f..500f, 48, stringResource(R.string.milliseconds, lookaheadMs), - { lookaheadMs = it.toInt(); apply() }) + { lookaheadMs = it.toInt() }, { apply() }) LabeledSlider(stringResource(R.string.rise_rate), riseRate.toFloat(), 2f..10f, 8, "${riseRate}x", - { riseRate = it.toInt(); apply() }) + { riseRate = it.toInt() }, { apply() }) LabeledSlider(stringResource(R.string.target_level), targetLevelDb.toFloat(), -40f..0f, 40, "${targetLevelDb} dBFS", - { targetLevelDb = it.toInt(); apply() }) + { targetLevelDb = it.toInt() }, { apply() }) } } diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index e39be563..afbda3e0 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -677,7 +677,7 @@ class ChatViewModel : ViewModel() { NativeLib.setChatSetting("radio_vad_enabled", if (enabled) "1" else "0") LogManager.addLog("INFO", "RadioAudio", "Panel VAD enabled=$enabled channel=$radioChannelId") if (enabled) RadioOverlayService.requestPermissions() else RadioOverlayService.sync(ctx) - radioAudio.reconfigure() + radioAudio.reconfigure(restartVad = enabled) } } diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index 2ab9b615..7fbe003c 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -209,7 +209,7 @@ "VAD" "VAD активен: микрофон включён" "VAD неактивен" - "Работает 15 минут при включённом экране. При следующем включении экрана запускается автоматически." + "При включённом экране работает без ограничения в приложении. Возврат в приложение сбрасывает таймер: после сворачивания доступны полные 15 минут. При включении экрана окно начинается заново." "Порог речи:" "Задержка после тишины:" "Макс. усиление перед VAD:" diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index d138186a..c984ce7e 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -209,7 +209,7 @@ "VAD" "VAD active: microphone is on" "VAD inactive" - "Runs for 15 minutes while the screen is on. Starts automatically when the screen turns on again." + "While the screen is on, runs without a time limit in the app. Returning to the app resets the timer: every background period gets a full 15 minutes. Turning the screen on starts a new window." "Speech threshold:" "Delay after silence:" "Max gain before VAD:" diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 3a52c016..b64ad03e 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -83,18 +83,7 @@ static void bridge_log(int level, const char* fmt, ...) { fprintf(stderr, "%s\n", fmt_buf); #endif - if (g_on_log) { - const char* cat; - switch (level) { - case BLEV_TRACE: cat = "TRACE"; break; - case BLEV_DEBUG: cat = "DEBUG"; break; - case BLEV_INFO: cat = "INFO"; break; - case BLEV_WARN: cat = "WARN"; break; - case BLEV_ERROR: cat = "ERROR"; break; - default: cat = "?"; break; - } - g_on_log(level, cat, fmt_buf); - } + if (g_on_log) g_on_log(level, "JNI", fmt_buf); } static void bridge_debug_hook(int level, const char* category, const char* message) { @@ -1658,7 +1647,6 @@ static void jni_on_event(int type, const uint8_t* data, int len) { } void instance_event_handler(int type, const uint8_t* data, int len) { - bridge_log(BLEV_DEBUG, "[EVT_DIAG] event type=%d len=%d", type, len); jni_on_event(type, data, len); } diff --git a/tools/chatgui-android/libutun_lite/instance_lite.c b/tools/chatgui-android/libutun_lite/instance_lite.c index d744f0fe..ac8a94cb 100644 --- a/tools/chatgui-android/libutun_lite/instance_lite.c +++ b/tools/chatgui-android/libutun_lite/instance_lite.c @@ -171,9 +171,6 @@ static void nodeinfo_event_cb(struct TOPO_GROUP* group, struct TOPO_GROUP_NODE* uint8_t hc = 0; topo_node_best_hop_list(nq, &hc, NULL); uint16_t bgp_nodes = (hc > 0) ? (uint16_t)(hc - 1) : 0; - DEBUG_INFO(DEBUG_CATEGORY_DEBUG, "nodeinfo_cb: nid=%016llx pres=%02x up=%02x rtt=%u(0x%04X) hc=%u bgp_nodes=%u paths=%zu", - (unsigned long long)nq->node_id, nq->conn_presence, nq->conn_up, - best_rtt, best_rtt, hc, bgp_nodes, nq->paths ? queue_entry_count(nq->paths) : 0); uint8_t data[16]; memcpy(data, &nq->node_id, 8); data[8] = nq->conn_presence; diff --git a/tools/chatgui-android/libutun_lite/standby.c b/tools/chatgui-android/libutun_lite/standby.c index fe2c4f5d..592c1c8a 100644 --- a/tools/chatgui-android/libutun_lite/standby.c +++ b/tools/chatgui-android/libutun_lite/standby.c @@ -418,7 +418,7 @@ void standby_notify_network_activity(void) { void standby_log_rx(const char* kind, const char* who) { if (!g_enabled || g_phase != PHASE_SLEEP) return; uint64_t slept = get_time_tb() - g_phase_start_tb; - DEBUG_INFO(DEBUG_CATEGORY_DEBUG, + DEBUG_DEBUG(DEBUG_CATEGORY_KEEPALIVE, "standby: rx in SLEEP: %s from %s, slept %d.%ds", kind ? kind : "?", who ? who : "?", (int)(slept / STANDBY_TB_PER_SEC), (int)((slept % STANDBY_TB_PER_SEC) / 1000)); diff --git a/tools/chatgui-android/libutun_lite/voice_recorder.c b/tools/chatgui-android/libutun_lite/voice_recorder.c index 42cec5a4..ab45ffe1 100644 --- a/tools/chatgui-android/libutun_lite/voice_recorder.c +++ b/tools/chatgui-android/libutun_lite/voice_recorder.c @@ -76,8 +76,8 @@ int voice_recorder_init(const char* db_path) { cfg.lookback_ms = 200; cfg.lookahead_ms = 100; cfg.max_gain_db = 15.0f; - cfg.rise_rate_per_sec = 2.0f; - cfg.target_level = 0.25f; /* -12 dBFS — едино с рацией и UI */ + cfg.rise_rate_per_sec = 6.0f; + cfg.target_level = powf(10.0f, -3.0f / 20.0f); /* -3 dBFS — едино с Android UI */ audio_compressor_configure(g_rec->compressor, &cfg); } diff --git a/tools/chatgui-android/tests/test_radio_vad_window.kt b/tools/chatgui-android/tests/test_radio_vad_window.kt new file mode 100644 index 00000000..f8152aa4 --- /dev/null +++ b/tools/chatgui-android/tests/test_radio_vad_window.kt @@ -0,0 +1,47 @@ +import com.utun.chat.data.RadioVadWindow + +fun main() { + val minute = 60_000L + val duration = 15 * minute + val window = RadioVadWindow(duration) + window.setForeground(true, 0) + window.setEnabled(true, 0) + check(window.isAllowed(120 * minute)) { "Foreground VAD expired" } + check(window.timeLeftMs(120 * minute) == duration) { "Foreground consumed background time" } + + window.setForeground(false, 120 * minute) + check(window.deadlineMs == 135 * minute) + window.setForeground(true, 125 * minute) + check(window.timeLeftMs(300 * minute) == duration) { "Returning to the app did not reset the timer" } + check(window.isAllowed(300 * minute)) + window.setForeground(false, 300 * minute) + window.setEnabled(true, 301 * minute) // Перенастройка не продлевает окно. + window.setForeground(false, 302 * minute) + check(window.deadlineMs == 315 * minute) { "New background period did not get the full timeout" } + check(window.isAllowed(315 * minute - 1)) + check(!window.isAllowed(315 * minute)) { "Background timer did not expire" } + + window.setForeground(true, 316 * minute) + check(window.isAllowed(500 * minute)) { "Returning to the app did not resume VAD" } + check(window.timeLeftMs(500 * minute) == duration) + window.setForeground(false, 500 * minute) + check(window.deadlineMs == 515 * minute) + window.close() // Погасший экран закрывает окно сразу. + check(!window.isAllowed(501 * minute)) + window.restart(502 * minute) + check(window.deadlineMs == 517 * minute) + + window.setEnabled(false, 503 * minute) + window.setForeground(true, 504 * minute) + check(!window.isAllowed(504 * minute)) { "Disabled VAD resumed automatically" } + window.setEnabled(true, 505 * minute) + check(window.isAllowed(1000 * minute)) + + val backgroundStart = RadioVadWindow(duration) + backgroundStart.setEnabled(true, minute) + check(backgroundStart.deadlineMs == 16 * minute) + check(!backgroundStart.isAllowed(16 * minute)) + backgroundStart.restart(17 * minute) // Явное повторное включение после истечения. + check(backgroundStart.deadlineMs == 32 * minute) + println("PASS: foreground unlimited and resets timer; full background timeout; expiry; screen; enable/reconfigure") +} diff --git a/tools/chatgui/tests/test_voice_tx.cpp b/tools/chatgui/tests/test_voice_tx.cpp index 47ca8b8b..92d495d3 100644 --- a/tools/chatgui/tests/test_voice_tx.cpp +++ b/tools/chatgui/tests/test_voice_tx.cpp @@ -245,6 +245,50 @@ static void test_prefetch_deferred(UTUN_INSTANCE* instance) { debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_NONE); } +static std::vector diagnostic_messages; +static void capture_diagnostic(int, const char* category, const char* message) { + if (category && !strcmp(category, "debug")) diagnostic_messages.emplace_back(message); +} + +/* Проверяем наблюдаемые причины отказа и старта на настоящем PCM/TX-пути. */ +static void test_vad_diagnostics(UTUN_INSTANCE* instance) { + debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_TRACE); + debug_set_log_hook(capture_diagnostic); + diagnostic_messages.clear(); events.clear(); packets.clear(); radio_compressor_enabled = 0; + REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, 1, 1) == 0); + std::array pcm{}; + for (size_t i = 0; i < pcm.size(); ++i) pcm[i] = (int16_t)(1000 * sin(i * 0.1)); + probability = 0.9f; REQUIRE(radio_audio_feed_pcm(7, pcm.data(), pcm.size()) == 0); + REQUIRE(!radio_audio_transmitting()); + probability = 0.1f; REQUIRE(radio_audio_feed_pcm(7, pcm.data(), pcm.size()) == 0); + REQUIRE(!radio_audio_transmitting()); + probability = 0.9f; + for (int i = 0; i < 2; ++i) REQUIRE(radio_audio_feed_pcm(7, pcm.data(), pcm.size()) == 0); + REQUIRE(radio_audio_transmitting()); uasync_poll(instance->ua, 0); + probability = 0.1f; + for (int i = 0; i < 2; ++i) REQUIRE(radio_audio_feed_pcm(7, pcm.data(), pcm.size()) == 0); + REQUIRE(!radio_audio_transmitting()); uasync_poll(instance->ua, 0); + REQUIRE(!packets.empty()); + radio_audio_on_frame(instance, 7, 0x1234, 1, 0, 0, packets.front().data(), packets.front().size(), nullptr); + probability = 0.9f; + for (int i = 0; i < 2; ++i) REQUIRE(radio_audio_feed_pcm(7, pcm.data(), pcm.size()) == 0); + REQUIRE(!radio_audio_transmitting()); + radio_audio_talk_begin(7); REQUIRE(radio_audio_feed_pcm(7, pcm.data(), pcm.size()) == 0); + REQUIRE(radio_audio_transmitting()); + radio_audio_talk_end(7); radio_audio_capture_discontinuity(7); + radio_audio_stop(); uasync_poll(instance->ua, 0); + auto contains = [](const char* text) { + return std::any_of(diagnostic_messages.begin(), diagnostic_messages.end(), + [text](const std::string& message) { return message.find(text) != std::string::npos; }); + }; + for (const char* text : {"VAD config", "reason=confirming", "reason=confirm_reset", "reason=confirmed", + "reason=silence_timeout", "reason=rx_busy", "rx_node=0000000000001234", "manual=1", + "BEGIN dispatched", "capture discontinuity", "VAD capture stop", "model_rms="}) REQUIRE(contains(text)); + debug_set_log_hook(nullptr); + debug_set_category_level(DEBUG_CATEGORY_DEBUG, DEBUG_LEVEL_NONE); + puts("voice TX: VAD debug logs identify confirmation reset, speech start/stop, RX blocking and manual override PASS"); +} + int main() { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); UTUN_INSTANCE instance{}; instance.ua = uasync_create(); REQUIRE(instance.ua); @@ -321,6 +365,7 @@ int main() { test_prefetch(&instance, 1, 1); test_prefetch(&instance, 2, 1); test_prefetch_deferred(&instance); + test_vad_diagnostics(&instance); for (int channels : {1, 2}) for (int gain : {6, 30}) test_vad_compressor(&instance, channels, gain); // Старый FIN не обращается к новому ядру, даже когда allocator повторно использовал тот же адрес. REQUIRE(radio_audio_start(7, 1) == 0);