Browse Source

Improve Android VAD lifecycle, audio settings and diagnostics

master
evgeny 1 week ago
parent
commit
123b47c612
  1. 31
      doc/tasks.md
  2. 2
      src/call/call.c
  3. 8
      src/lwip_tcp/lwip_tcp.c
  4. 12
      src/proxy/tcp_proxy_client.c
  5. 146
      src/radio/radio_audio.c
  6. 1
      tools/chatgui-android/app/build.gradle.kts
  7. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/ChatApplication.kt
  8. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/MainActivity.kt
  9. 46
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  10. 16
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  11. 114
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  12. 47
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioVadWindow.kt
  13. 19
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChannelListScreen.kt
  14. 8
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  15. 42
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  16. 2
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  17. 2
      tools/chatgui-android/app/src/main/res/values-ru/strings.xml
  18. 2
      tools/chatgui-android/app/src/main/res/values/strings.xml
  19. 14
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  20. 3
      tools/chatgui-android/libutun_lite/instance_lite.c
  21. 2
      tools/chatgui-android/libutun_lite/standby.c
  22. 4
      tools/chatgui-android/libutun_lite/voice_recorder.c
  23. 47
      tools/chatgui-android/tests/test_radio_vad_window.kt
  24. 45
      tools/chatgui/tests/test_voice_tx.cpp

31
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 очередь на суперузле с проверкой подписанной роли.

2
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);

8
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;
}

12
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);

146
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);

1
tools/chatgui-android/app/build.gradle.kts

@ -52,6 +52,7 @@ android {
buildFeatures {
compose = true
buildConfig = true
}
// Общие TGS-анимации с desktop-чатгуи, без копирования ресурсов.

2
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)
}

2
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

46
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(). */

16
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 */

114
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")

47
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
}
}
}

19
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<Channel?>(null) }
var deleteConfirmChannel by remember { mutableStateOf<Channel?>(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 ->

8
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 {

42
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() })
}
}

2
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)
}
}

2
tools/chatgui-android/app/src/main/res/values-ru/strings.xml

@ -209,7 +209,7 @@
<string name="vad_button">"VAD"</string>
<string name="vad_active">"VAD активен: микрофон включён"</string>
<string name="vad_inactive">"VAD неактивен"</string>
<string name="vad_screen_hint">"Работает 15 минут при включённом экране. При следующем включении экрана запускается автоматически."</string>
<string name="vad_screen_hint">"При включённом экране работает без ограничения в приложении. Возврат в приложение сбрасывает таймер: после сворачивания доступны полные 15 минут. При включении экрана окно начинается заново."</string>
<string name="speech_threshold">"Порог речи:"</string>
<string name="silence_delay">"Задержка после тишины:"</string>
<string name="vad_max_gain">"Макс. усиление перед VAD:"</string>

2
tools/chatgui-android/app/src/main/res/values/strings.xml

@ -209,7 +209,7 @@
<string name="vad_button">"VAD"</string>
<string name="vad_active">"VAD active: microphone is on"</string>
<string name="vad_inactive">"VAD inactive"</string>
<string name="vad_screen_hint">"Runs for 15 minutes while the screen is on. Starts automatically when the screen turns on again."</string>
<string name="vad_screen_hint">"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."</string>
<string name="speech_threshold">"Speech threshold:"</string>
<string name="silence_delay">"Delay after silence:"</string>
<string name="vad_max_gain">"Max gain before VAD:"</string>

14
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);
}

3
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;

2
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));

4
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);
}

47
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")
}

45
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<std::string> 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<int16_t, 1536> 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);

Loading…
Cancel
Save