diff --git a/doc/tasks.md b/doc/tasks.md index bf1cfb5d..7658891d 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -35,12 +35,17 @@ 512, фильтр-гистерезис + машина состояний (`src/radio/radio_vad.h`, чистый, юнит-тестируемый): старт при подтверждении 2 окон (~64мс) выше порога `radio_vad_threshold`, стоп при тишине `radio_vad_hangover_ms` (200мс) или чужом - разговоре (busy = RX-кадр <200мс). Pre-roll `radio_vad_pre_roll_ms` (200мс) — - кольцевой буфер сырого PCM, флашится при срабатывании («передаём за 200мс до»). + разговоре (busy = RX-кадр <200мс). История захвата (pre-roll) удалена; передаётся текущий PCM. Компрессор в VAD-режиме байпасится. Desktop `radio_audio_engine.cpp` и Android `RadioAudioEngine.kt` фидят PCM постоянно в VAD-режиме; JNI `radioVadMode()`. Тест `test_radio_vad` (ресемплер + FSM). Android: onnxruntime AAR 1.23.2 в `jniLibs/` + `cpp/onnxruntime/include` + `-DHAVE_SILERO_VAD` в CMake. + Android: настройки VAD (включение, порог, задержка после тишины); микрофон в PTT + создаётся только для передачи. PTT запускается при касании, после отпускания + передаёт ещё 250мс; повторное касание отменяет завершение (двойной тап без FIN). + VAD работает 15 минут при горящем экране; + SCREEN_OFF освобождает захват, SCREEN_ON автоматически открывает новое окно. + RX остаётся активен; TX освобождается отдельно через `radio_audio_capture_stop()`. [+] **chatgui (Qt) не собирался: C++ void* в utun_node.cpp** — сделано. Причина: `proxy_protocol.h` (C-заголовок, включается в C++ `utun_node.cpp`) делал diff --git a/src/chat/chat_setting.c b/src/chat/chat_setting.c index ced5fe7a..fc1f523a 100644 --- a/src/chat/chat_setting.c +++ b/src/chat/chat_setting.c @@ -44,7 +44,6 @@ static const struct chat_setting_def g_setting_defs[] = { {"radio_compressor_max_gain_db",CHAT_SETTING_INT, 15, 1, 100}, {"radio_vad_enabled", CHAT_SETTING_BOOL, 0, 0, 1}, {"radio_vad_threshold", CHAT_SETTING_INT, 50, 10, 95}, /* порог вероятности речи, % */ - {"radio_vad_pre_roll_ms", CHAT_SETTING_INT, 200, 0, 500}, /* pre-roll перед срабатыванием */ {"radio_vad_hangover_ms", CHAT_SETTING_INT, 200, 50, 2000},/* тишина до авто-отбоя */ {"media_download_max_peers", CHAT_SETTING_INT, 3, 1, 16}, {"storage_backfill_days", CHAT_SETTING_INT, 7, 1, 365}, diff --git a/src/radio/radio_audio.c b/src/radio/radio_audio.c index d0b55432..94e175ba 100644 --- a/src/radio/radio_audio.c +++ b/src/radio/radio_audio.c @@ -48,7 +48,6 @@ /* ── VAD авто-PTT (отдельный режим, ручной PTT не меняется) ── */ #define RADIO_VAD_CONFIRM_WINDOWS 2 /* окон (32мс) выше порога для старта (~64мс) */ #define RADIO_VAD_BUSY_TB 2000 /* 200мс: канал занят после последнего RX-кадра */ -#define RADIO_VAD_MAX_PREROLL_FRAMES 25 /* максимум pre-roll 500мс @ 20мс */ struct radio_pending { uint8_t used; @@ -88,15 +87,10 @@ static uint64_t g_last_stats_tb = 0; static int g_vad_mode = 0; /* режим включён для текущей группы */ static silero_vad_t* g_vad = NULL; /* детектор (16кГц), создаётся в start */ static float g_vad_threshold = 0.5f; -static int g_vad_pre_roll_ms = 200; static int g_vad_hangover_ms = 200; static int g_vad_manual_ptt = 0; /* PTT override: зажата — VAD не вмешивается */ static float g_vad_win[RADIO_VAD_WINDOW_16K]; /* накопитель окон 16кГц */ static int g_vad_win_len = 0; -static int16_t* g_vad_preroll = NULL; /* кольцевой pre-roll (сырой PCM) */ -static int g_vad_preroll_cap = 0; /* ёмкость в сэмплах (interleaved) */ -static int g_vad_preroll_len = 0; /* заполнено сэмплов */ -static int g_vad_preroll_pos = 0; /* позиция записи */ static struct radio_vad_fsm g_vad_fsm; static uint64_t g_last_rx_tb = 0; /* последний принятый аудио-кадр (busy) */ @@ -262,6 +256,8 @@ static struct radio_source* radio_src_acquire(uint64_t src, uint16_t stream) { return NULL; } +static void radio_audio_post_talk(struct UASYNC* ua, uint64_t group_id, void (*fn)(void*)); + /* ── lifecycle ── */ int radio_audio_init(struct UTUN_INSTANCE* inst) { @@ -281,13 +277,35 @@ void radio_audio_destroy(struct UTUN_INSTANCE* inst) { } int radio_audio_start(uint64_t group_id, int capture_channels) { - if (group_id == 0) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: start bad group_id=0", RADIO_AUDIO_ID); return -1; } - if (capture_channels != 1 && capture_channels != 2) capture_channels = 1; - + if (!group_id) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: start bad group_id=0", RADIO_AUDIO_ID); return -1; } pthread_mutex_lock(&g_mtx); if (g_active && g_group_id == group_id) { pthread_mutex_unlock(&g_mtx); return 0; } - if (g_active) { pthread_mutex_unlock(&g_mtx); radio_audio_stop(); pthread_mutex_lock(&g_mtx); } + pthread_mutex_unlock(&g_mtx); + radio_audio_stop(); + pthread_mutex_lock(&g_mtx); + g_group_id = group_id; + g_active = 1; + g_last_rx_tb = 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); + if (!capture_channels) return 0; + int rc = radio_audio_capture_start(group_id, capture_channels, chat_setting_get_int(g_inst, "radio_vad_enabled", 0)); + if (rc != 0) radio_audio_stop(); + return rc; +} +int radio_audio_capture_start(uint64_t group_id, int capture_channels, int vad_enabled) { + if (capture_channels != 1 && capture_channels != 2) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: capture invalid channels=%d", RADIO_AUDIO_ID, capture_channels); + return -1; + } + pthread_mutex_lock(&g_mtx); + if (!g_active || g_group_id != group_id || g_encoder) { + pthread_mutex_unlock(&g_mtx); + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: capture start invalid state grp=%016llx", RADIO_AUDIO_ID, (unsigned long long)group_id); + return -1; + } opus_codec_encoder_t* enc = opus_codec_encoder_create(RADIO_AUDIO_SAMPLE_RATE, capture_channels); if (!enc) { pthread_mutex_unlock(&g_mtx); DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encoder create failed", RADIO_AUDIO_ID); return -1; } int opus_mode = chat_setting_get_int(g_inst, "radio_opus_mode", 0); @@ -295,8 +313,7 @@ int radio_audio_start(uint64_t group_id, int capture_channels) { opus_codec_encoder_application_set(enc, opus_mode ? OPUS_CODEC_APP_AUDIO : OPUS_CODEC_APP_VOIP); opus_codec_encoder_bitrate_set(enc, opus_bitrate_kbps * 1000); - /* компрессор микрофона (AGC): конфиг из [chatserver], lookahead=0 — без задержки и хвоста. - * gain_smoothed не сбрасывается между PTT — адаптивная громкость запоминается. */ + /* Компрессор TX: lookahead=0, без задержки и хвоста. */ struct audio_compressor* ac = audio_compressor_create(); if (ac) { audio_compressor_config_t accfg = {0}; @@ -312,59 +329,54 @@ int radio_audio_start(uint64_t group_id, int capture_channels) { audio_compressor_set_enabled(ac, chat_setting_get_int(g_inst, "radio_compressor_enabled", 1)); } - /* VAD авто-PTT: отдельный режим (ручной PTT не меняется). Компрессор в VAD-режиме - * байпасится — pre-roll передаётся как сырой PCM, кодируемый по срабатыванию. */ - g_vad_mode = chat_setting_get_int(g_inst, "radio_vad_enabled", 0) ? 1 : 0; + if (!ac) DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: compressor unavailable", RADIO_AUDIO_ID); + + /* VAD анализирует текущие кадры без истории захвата. */ + g_vad_mode = vad_enabled ? 1 : 0; if (g_vad_mode) { g_vad_threshold = (float)chat_setting_get_int(g_inst, "radio_vad_threshold", 50) / 100.0f; - g_vad_pre_roll_ms = chat_setting_get_int(g_inst, "radio_vad_pre_roll_ms", 200); g_vad_hangover_ms = chat_setting_get_int(g_inst, "radio_vad_hangover_ms", 200); g_vad = silero_vad_create_default(); if (!g_vad) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: silero_vad_create_default failed — VAD auto-PTT disabled", RADIO_AUDIO_ID); g_vad_mode = 0; - } else { - int frames = g_vad_pre_roll_ms / RADIO_AUDIO_FRAME_MS; - if (frames < 1) frames = 1; - if (frames > RADIO_VAD_MAX_PREROLL_FRAMES) frames = RADIO_VAD_MAX_PREROLL_FRAMES; - g_vad_preroll_cap = frames * RADIO_AUDIO_FRAME_SAMPLES * capture_channels; - g_vad_preroll = (int16_t*)u_malloc((size_t)g_vad_preroll_cap * sizeof(int16_t)); - if (!g_vad_preroll) { - DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: pre-roll alloc failed — VAD auto-PTT disabled", RADIO_AUDIO_ID); - silero_vad_destroy(g_vad); g_vad = NULL; - g_vad_mode = 0; g_vad_preroll_cap = 0; - } } } if (g_vad_mode) { - g_vad_preroll_len = 0; - g_vad_preroll_pos = 0; g_vad_win_len = 0; radio_vad_fsm_reset(&g_vad_fsm); } g_vad_manual_ptt = 0; - g_last_rx_tb = 0; g_encoder = enc; g_compressor = ac; g_capture_channels = capture_channels; - g_group_id = group_id; - g_active = 1; - memset(g_sources, 0, sizeof(g_sources)); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: capture started grp=%016llx ch=%d opus=%s bitrate=%dkbps compressor=%s vad=%d threshold=%.2f hangover=%dms", + RADIO_AUDIO_ID, (unsigned long long)group_id, capture_channels, opus_mode ? "AUDIO" : "VOIP", opus_bitrate_kbps, + ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable", g_vad_mode, g_vad_threshold, g_vad_hangover_ms); pthread_mutex_unlock(&g_mtx); - - DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: started grp=%016llx opus_mode=%s bitrate=%dkbps capture_ch=%d compressor=%s vad=%s", RADIO_AUDIO_ID, - (unsigned long long)group_id, opus_mode ? "AUDIO" : "VOIP", opus_bitrate_kbps, capture_channels, - ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable", - g_vad_mode ? "on" : "off"); return 0; } +void radio_audio_capture_stop(void) { + pthread_mutex_lock(&g_mtx); + if (g_encoder) { + if (g_vad_manual_ptt || g_vad_fsm.talking) + radio_audio_post_talk(g_inst ? g_inst->ua : NULL, g_group_id, radio_talk_end_trampoline); + opus_codec_encoder_destroy(g_encoder); g_encoder = NULL; + if (g_compressor) { audio_compressor_destroy(g_compressor); g_compressor = NULL; } + if (g_vad) { silero_vad_destroy(g_vad); g_vad = NULL; } + g_vad_mode = 0; + g_vad_manual_ptt = 0; + g_vad_win_len = 0; + radio_vad_fsm_reset(&g_vad_fsm); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: capture stopped", RADIO_AUDIO_ID); + } + pthread_mutex_unlock(&g_mtx); +} + void radio_audio_stop(void) { - opus_codec_encoder_t* enc = NULL; - struct audio_compressor* ac = NULL; - silero_vad_t* vad = NULL; - int16_t* preroll = NULL; + radio_audio_capture_stop(); int was_active = 0; pthread_mutex_lock(&g_mtx); @@ -372,27 +384,12 @@ void radio_audio_stop(void) { was_active = 1; g_active = 0; g_group_id = 0; - enc = g_encoder; g_encoder = NULL; - ac = g_compressor; g_compressor = NULL; - vad = g_vad; g_vad = NULL; - preroll = g_vad_preroll; g_vad_preroll = NULL; - g_vad_mode = 0; - g_vad_manual_ptt = 0; - g_vad_preroll_cap = 0; - g_vad_preroll_len = 0; - g_vad_preroll_pos = 0; - g_vad_win_len = 0; - radio_vad_fsm_reset(&g_vad_fsm); for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++) if (g_sources[i].used) radio_src_free(&g_sources[i]); } pthread_mutex_unlock(&g_mtx); if (was_active) { - if (enc) opus_codec_encoder_destroy(enc); - if (ac) audio_compressor_destroy(ac); - if (vad) silero_vad_destroy(vad); - if (preroll) u_free(preroll); DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: stopped", RADIO_AUDIO_ID); } } @@ -413,11 +410,19 @@ int radio_audio_vad_mode(void) { return m; } +int radio_audio_transmitting(void) { + pthread_mutex_lock(&g_mtx); + int talking = g_active && (g_vad_manual_ptt || g_vad_fsm.talking); + pthread_mutex_unlock(&g_mtx); + return talking; +} + /* ── PTT (GUI → uasync) ── */ static void radio_audio_post_talk(struct UASYNC* ua, uint64_t group_id, void (*fn)(void*)) { + if (!ua) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: talk post without uasync", RADIO_AUDIO_ID); return; } struct radio_talk_arg* a = (struct radio_talk_arg*)u_malloc(sizeof(*a)); - if (!a) return; + if (!a) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: talk post allocation failed", RADIO_AUDIO_ID); return; } a->inst = g_inst; a->group_id = group_id; uasync_post(ua, fn, a); @@ -426,22 +431,33 @@ static void radio_audio_post_talk(struct UASYNC* ua, uint64_t group_id, void (*f void radio_audio_talk_begin(uint64_t group_id) { if (!g_inst || !g_inst->ua) return; pthread_mutex_lock(&g_mtx); + if (!g_active || group_id != g_group_id || !g_encoder) { + pthread_mutex_unlock(&g_mtx); + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: talk_begin without capture grp=%016llx", RADIO_AUDIO_ID, (unsigned long long)group_id); + return; + } g_vad_manual_ptt = 1; if (g_vad_fsm.talking) { /* VAD вела передачу — закрываем её FIN, затем открываем ручной burst */ radio_audio_post_talk(g_inst->ua, group_id, radio_talk_end_trampoline); radio_vad_fsm_reset(&g_vad_fsm); } - pthread_mutex_unlock(&g_mtx); radio_audio_post_talk(g_inst->ua, group_id, radio_talk_begin_trampoline); + pthread_mutex_unlock(&g_mtx); } void radio_audio_talk_end(uint64_t group_id) { if (!g_inst || !g_inst->ua) return; pthread_mutex_lock(&g_mtx); + if (!g_active || group_id != g_group_id || !g_vad_manual_ptt) { + pthread_mutex_unlock(&g_mtx); + DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: talk_end without manual transmission grp=%016llx", RADIO_AUDIO_ID, + (unsigned long long)group_id); + return; + } g_vad_manual_ptt = 0; - pthread_mutex_unlock(&g_mtx); radio_audio_post_talk(g_inst->ua, group_id, radio_talk_end_trampoline); + pthread_mutex_unlock(&g_mtx); } /* ── VAD авто-PTT: helpers (под g_mtx) ── */ @@ -450,12 +466,12 @@ void radio_audio_talk_end(uint64_t group_id) { * Вызывается при удержании g_mtx (использует g_encoder/g_capture_channels). */ static void radio_audio_encode_post(uint64_t group_id, const int16_t* frame, struct UASYNC* ua, struct UTUN_INSTANCE* inst) { - if (!ua || !inst) return; + if (!ua || !inst) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encode without instance", RADIO_AUDIO_ID); return; } uint8_t opus[RADIO_MAX_OPUS]; int l = opus_codec_encode(g_encoder, frame, RADIO_AUDIO_FRAME_SAMPLES, opus, (int)sizeof(opus)); - if (l <= 0) return; + if (l <= 0) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: opus encode failed rc=%d", RADIO_AUDIO_ID, l); return; } struct radio_media_arg* a = (struct radio_media_arg*)u_malloc(sizeof(*a)); - if (!a) return; + if (!a) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: media allocation failed", RADIO_AUDIO_ID); return; } a->inst = inst; a->group_id = group_id; a->len = (uint16_t)l; @@ -463,34 +479,6 @@ static void radio_audio_encode_post(uint64_t group_id, const int16_t* frame, uasync_post(ua, radio_talk_send_trampoline, a); } -/* Записать кадр сырого PCM в кольцевой pre-roll (старые сэмплы вытесняются). */ -static void radio_vad_preroll_put(const int16_t* pcm, int count) { - if (!g_vad_preroll || count <= 0 || g_vad_preroll_cap <= 0) return; - if (count > g_vad_preroll_cap) { pcm += (count - g_vad_preroll_cap); count = g_vad_preroll_cap; } - for (int i = 0; i < count; i++) { - g_vad_preroll[g_vad_preroll_pos] = pcm[i]; - g_vad_preroll_pos = (g_vad_preroll_pos + 1) % g_vad_preroll_cap; - if (g_vad_preroll_len < g_vad_preroll_cap) g_vad_preroll_len++; - } -} - -/* Выдать все полные кадры из pre-roll (oldest→newest) через encode_post, затем очистить. */ -static void radio_vad_preroll_flush(uint64_t group_id, struct UASYNC* ua, struct UTUN_INSTANCE* inst) { - if (!g_vad_preroll || g_vad_preroll_cap <= 0) return; - int frame_total = RADIO_AUDIO_FRAME_SAMPLES * g_capture_channels; - if (frame_total <= 0) return; - int n_frames = g_vad_preroll_len / frame_total; - int16_t tmp[RADIO_AUDIO_FRAME_SAMPLES * 2]; /* frame_total макс. 1920 */ - for (int f = 0; f < n_frames; f++) { - int start = g_vad_preroll_pos - g_vad_preroll_len + f * frame_total; - start = ((start % g_vad_preroll_cap) + g_vad_preroll_cap) % g_vad_preroll_cap; - for (int i = 0; i < frame_total; i++) - tmp[i] = g_vad_preroll[(start + i) % g_vad_preroll_cap]; - radio_audio_encode_post(group_id, tmp, ua, inst); - } - g_vad_preroll_len = 0; -} - /* VAD-ветка feed_pcm: ресемпл → Silero → фильтр → старт/стоп/передача (держит g_mtx). */ static int radio_vad_feed(uint64_t group_id, const int16_t* pcm, int count) { float out16k[RADIO_VAD_FRAME_SAMPLES_16K]; @@ -506,16 +494,12 @@ static int radio_vad_feed(uint64_t group_id, const int16_t* pcm, int count) { inst = g_inst; if (g_vad_manual_ptt) { - /* PTT override: ведём pre-roll и просто передаём кадр (VAD не вмешивается) */ - radio_vad_preroll_put(pcm, count); + /* Ручной PTT: передаём текущий кадр, VAD не вмешивается. */ radio_audio_encode_post(group_id, pcm, ua, inst); pthread_mutex_unlock(&g_mtx); return 0; } - radio_vad_preroll_put(pcm, count); - - int started_this_frame = 0; radio_vad_resample(pcm, g_capture_channels, out16k); int off = 0; while (off < RADIO_VAD_FRAME_SAMPLES_16K) { @@ -526,10 +510,18 @@ static int radio_vad_feed(uint64_t group_id, const int16_t* pcm, int count) { off += take; if (g_vad_win_len == RADIO_VAD_WINDOW_16K) { float prob = 0.0f; - if (silero_vad_process(g_vad, g_vad_win, &prob) != 0) prob = 0.0f; + if (silero_vad_process(g_vad, g_vad_win, &prob) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: VAD processing failed", RADIO_AUDIO_ID); + prob = 0.0f; + } g_vad_win_len = 0; uint64_t now = get_time_tb(); int busy = (g_last_rx_tb != 0 && (now - g_last_rx_tb) < RADIO_VAD_BUSY_TB) ? 1 : 0; + if (!g_tx_stats_tb || now - g_tx_stats_tb >= RADIO_AUDIO_STATS_TB) { + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: vad prob=%.3f threshold=%.2f busy=%d talking=%d confirm=%d", + RADIO_AUDIO_ID, prob, g_vad_threshold, busy, g_vad_fsm.talking, g_vad_fsm.confirm_count); + g_tx_stats_tb = now; + } int action = radio_vad_fsm_update(&g_vad_fsm, prob, g_vad_threshold, RADIO_VAD_CONFIRM_WINDOWS, (uint64_t)g_vad_hangover_ms * 10, busy, now); @@ -537,8 +529,6 @@ static int radio_vad_feed(uint64_t group_id, const int16_t* pcm, int count) { DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: vad START prob=%.2f grp=%016llx", RADIO_AUDIO_ID, prob, (unsigned long long)group_id); radio_audio_post_talk(ua, group_id, radio_talk_begin_trampoline); - radio_vad_preroll_flush(group_id, ua, inst); /* флашит ring (включая текущий кадр) */ - started_this_frame = 1; } else if (action == -1) { DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: vad STOP grp=%016llx", RADIO_AUDIO_ID, (unsigned long long)group_id); @@ -547,7 +537,7 @@ static int radio_vad_feed(uint64_t group_id, const int16_t* pcm, int count) { } } - if (g_vad_fsm.talking && !started_this_frame) { + if (g_vad_fsm.talking) { radio_audio_encode_post(group_id, pcm, ua, inst); } diff --git a/src/radio/radio_audio.h b/src/radio/radio_audio.h index 84126b29..91d557eb 100644 --- a/src/radio/radio_audio.h +++ b/src/radio/radio_audio.h @@ -33,10 +33,16 @@ int radio_audio_init(struct UTUN_INSTANCE* inst); void radio_audio_destroy(struct UTUN_INSTANCE* inst); /* GUI-поток: начать прослушивание канала (encoder + реестр источников). + * capture_channels=0 — только RX; 1/2 сохраняют запуск RX+TX для desktop. * capture_channels — каналы захвата (1=моно, 2=стерео): задаёт GUI по фактической * возможности устройства (стерео недоступно → откат на моно). RX всегда стерео. */ int radio_audio_start(uint64_t group_id, int capture_channels); +/* Независимый TX: start(..., 0) включает только RX, захват готовится по фактическому формату микрофона. + * capture_stop отправляет FIN активной передачи и освобождает TX, сохраняя RX. */ +int radio_audio_capture_start(uint64_t group_id, int capture_channels, int vad_enabled); +void radio_audio_capture_stop(void); + /* GUI-поток: прекратить прослушивание (освободить всё). */ void radio_audio_stop(void); @@ -58,6 +64,9 @@ int radio_audio_active(void); * и не гасить захват по PTT; ручной PTT остаётся override). */ int radio_audio_vad_mode(void); +/* Текущее состояние передачи: ручной PTT либо сработавший VAD. */ +int radio_audio_transmitting(void); + /* uasync-поток: принятый Opus-кадр источника (декодируем + кладём в ring). */ void radio_audio_on_frame(struct UTUN_INSTANCE* inst, uint64_t group_id, uint64_t src_node_id, uint16_t stream_id, uint16_t seq, diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt index 3a872703..588183bb 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt @@ -78,6 +78,7 @@ class CallAudioRouter { private val am: AudioManager = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager + private var preferWiredHeadset = false private var started = false private var deviceCallback: AudioDeviceCallback? = null private var btHeadset: BluetoothHeadset? = null @@ -121,13 +122,15 @@ class CallAudioRouter { LogManager.addLog("INFO", "CallAudioRouter", "stopped") } - fun setRoute(r: AudioRoute) { + fun setRoute(r: AudioRoute, preferWired: Boolean = false) { if (r == AudioRoute.HEADSET && !headsetAvailable) { LogManager.addLog("WARN", "CallAudioRouter", "setRoute HEADSET rejected: no headset available") return } autoHeadset = false - if (route == r) return + val preferenceChanged = preferWiredHeadset != preferWired + preferWiredHeadset = preferWired + if (route == r && !preferenceChanged) return route = r preferredRoute = r applyRoute(r) @@ -143,7 +146,10 @@ class CallAudioRouter { /** Включить/выключить автоследование за гарнитурой (рация включает, звонок — нет). */ fun setAutoHeadset(enabled: Boolean) { + val resetPreference = enabled && preferWiredHeadset autoHeadset = enabled + if (enabled) preferWiredHeadset = false + if (resetPreference && started && route == AudioRoute.HEADSET) applyRoute(route) LogManager.addLog("INFO", "CallAudioRouter", "autoHeadset -> $enabled") } @@ -183,7 +189,9 @@ class CallAudioRouter { } AudioRoute.HEADSET -> { am.isSpeakerphoneOn = false - val bt = findBtScoDevice() + val wired = findWiredDevice() + val useWired = preferWiredHeadset && wired != null + val bt = if (useWired) null else findBtScoDevice() if (bt != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { am.setCommunicationDevice(bt) @@ -192,12 +200,13 @@ class CallAudioRouter { } dev = bt inDev = btInputDevice() - } else if (isBtHeadsetProfileConnected()) { + } else if (!useWired && isBtHeadsetProfileConnected()) { /* классический BT: SCO ещё не поднят — запускаем и ждём * AudioDeviceCallback, который пере-применит маршрут */ startBluetoothSco() } else { - val wired = findWiredDevice() + stopBluetoothSco() + am.isBluetoothScoOn = false if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && wired != null) { am.setCommunicationDevice(wired) } else { @@ -214,7 +223,8 @@ class CallAudioRouter { currentInputDevice = inDev onInputDevice?.invoke(inDev) LogManager.addLog("INFO", "CallAudioRouter", - "route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} inDev=${inDev?.type ?: "-"} mode=${am.mode} speaker=${am.isSpeakerphoneOn}") + "route -> $r dev=${dev?.type}/${dev?.productName ?: "-"} inDev=${inDev?.type ?: "-"} " + + "preferWired=$preferWiredHeadset mode=${am.mode} speaker=${am.isSpeakerphoneOn}") } catch (e: Exception) { LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute $r failed: ${e.message}") } 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 e40fd01c..d477777e 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 @@ -54,6 +54,9 @@ class ConfigProvider(private val context: Context) { private val soundEnabledKey = booleanPreferencesKey("sound_enabled") private val mutedChannelsKey = stringSetPreferencesKey("muted_channels") private val callAudioRouteKey = stringPreferencesKey("call_audio_route") + private val radioVadEnabledKey = intPreferencesKey("radio_vad_enabled") + private val radioVadThresholdKey = intPreferencesKey("radio_vad_threshold") + private val radioVadHangoverMsKey = intPreferencesKey("radio_vad_hangover_ms") private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt") private val radioOverlayKey = booleanPreferencesKey("radio_overlay") private val radioMicSourceKey = intPreferencesKey("radio_mic_source") @@ -141,6 +144,9 @@ class ConfigProvider(private val context: Context) { key == "chat.standby_min_sleep_sec" -> context.dataStore.data.first()[chatStandbyMinSleepSec] ?: 15 key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000 key == "chat.group_autoconnect" -> context.dataStore.data.first()[chatGroupAutoconnect] ?: 1 + key == "radio.vad_enabled" -> context.dataStore.data.first()[radioVadEnabledKey] ?: 0 + key == "radio.vad_threshold" -> context.dataStore.data.first()[radioVadThresholdKey] ?: 50 + key == "radio.vad_hangover_ms" -> context.dataStore.data.first()[radioVadHangoverMsKey] ?: 200 key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0 key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32 key == "radio.capture_stereo" -> context.dataStore.data.first()[radioCaptureStereoKey] ?: 0 @@ -178,6 +184,9 @@ class ConfigProvider(private val context: Context) { "chat.standby_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15 "chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000 "chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1 + "radio.vad_enabled" -> prefs[radioVadEnabledKey] = value.toIntOrNull() ?: 0 + "radio.vad_threshold" -> prefs[radioVadThresholdKey] = value.toIntOrNull() ?: 50 + "radio.vad_hangover_ms" -> prefs[radioVadHangoverMsKey] = value.toIntOrNull() ?: 200 "radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0 "radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32 "radio.capture_stereo" -> prefs[radioCaptureStereoKey] = value.toIntOrNull() ?: 0 @@ -302,6 +311,8 @@ class ConfigProvider(private val context: Context) { context.dataStore.edit { prefs -> prefs[radioVolumePttKey] = enabled } } + val radioOverlayEnabled: Flow = context.dataStore.data.map { it[radioOverlayKey] ?: false } + @Volatile private var radioOverlayCached = false @Volatile private var radioOverlayLoaded = false @@ -511,6 +522,9 @@ class ConfigProvider(private val context: Context) { appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb") appendLine("storage_maxsize_gb=$chatMaxGb") appendLine("opus_codec_preset=$chatOpusPreset") + appendLine("radio_vad_enabled=${getInt("radio.vad_enabled")}") + appendLine("radio_vad_threshold=${getInt("radio.vad_threshold")}") + appendLine("radio_vad_hangover_ms=${getInt("radio.vad_hangover_ms")}") appendLine("radio_opus_mode=$radioOpusMode") appendLine("radio_opus_bitrate=$radioOpusBitrate") appendLine("radio_capture_stereo=$radioCaptureStereo") 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 5407756b..8e43d670 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 @@ -167,6 +167,9 @@ object NativeLib { fun radioAudioFeed(groupId: Long, samples: ShortArray): Boolean = nativeRadioAudioFeed(groupId, samples) fun radioAudioPull(groupId: Long, out: ShortArray): Int = nativeRadioAudioPull(groupId, out) fun radioAudioStop() { nativeRadioAudioStop() } + fun radioCaptureStart(groupId: Long, channels: Int, vad: Boolean): Boolean = nativeRadioCaptureStart(groupId, channels, vad) + fun radioCaptureStop() { nativeRadioCaptureStop() } + fun radioTransmitting(): Boolean = nativeRadioTransmitting() fun radioVadMode(): Boolean = nativeRadioVadMode() fun radioTalkBegin(groupId: Long) { nativeRadioTalkBegin(groupId) } fun radioTalkEnd(groupId: Long) { nativeRadioTalkEnd(groupId) } @@ -277,6 +280,9 @@ object NativeLib { private external fun nativeRadioAudioFeed(groupId: Long, samples: ShortArray): Boolean private external fun nativeRadioAudioPull(groupId: Long, out: ShortArray): Int private external fun nativeRadioAudioStop() + private external fun nativeRadioCaptureStart(groupId: Long, channels: Int, vad: Boolean): Boolean + private external fun nativeRadioCaptureStop() + private external fun nativeRadioTransmitting(): Boolean private external fun nativeRadioVadMode(): Boolean private external fun nativeRadioTalkBegin(groupId: Long) private external fun nativeRadioTalkEnd(groupId: Long) 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 abe854f7..d67851fb 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 @@ -1,5 +1,8 @@ package com.utun.chat.data +import android.content.BroadcastReceiver +import android.content.Intent +import android.content.IntentFilter import android.content.Context import android.media.AudioAttributes import android.media.AudioDeviceInfo @@ -11,6 +14,8 @@ import android.media.MediaRecorder import android.os.Build import android.os.Handler import android.os.Looper +import android.os.PowerManager +import android.os.SystemClock import com.utun.chat.ChatApplication import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -20,7 +25,7 @@ import java.util.concurrent.Executors * Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). * * Потоки: - * - capture-поток: AudioRecord.read(960) → NativeLib.radioAudioFeed (только при зажатом PTT) + * - capture-поток: AudioRecord.read(960) → NativeLib.radioAudioFeed (PTT либо активный VAD) * - play-поток: NativeLib.radioAudioPull (C: per-source jitter → декод → микшер) → AudioTrack.write * * Воспроизведение тянет микшированный PCM всех говорящих из единого голосового стека @@ -36,8 +41,8 @@ class RadioAudioEngine { /** Воспроизведение всегда стерео (моно-пиры апмиксуются Opus-декодером). */ const val PLAY_CHANNELS = 2 - /** Хвост передачи после отпускания PTT: продолжаем слать голос, затем FIN. */ - const val RELEASE_HOLD_MS = 200L + const val RELEASE_HOLD_MS = 250L + const val VAD_ACTIVE_MS = 15 * 60 * 1000L /** Процессный синглтон: доступен из Activity и оверлей-сервиса. */ @Volatile private var inst: RadioAudioEngine? = null @@ -58,13 +63,26 @@ class RadioAudioEngine { private var playThread: Thread? = null private var prevMode: Int = AudioManager.MODE_NORMAL private var prevSpeaker: Boolean = false + private var audioModeOwned = false @Volatile private var active = false @Volatile private var groupId = 0L @Volatile private var talking = false - @Volatile private var micDead = false + @Volatile private var captureRunning = false + @Volatile private var playbackRunning = false + @Volatile private var requestedTalk = false + @Volatile private var talkRequestNs = 0L + @Volatile private var generation = 0L + private var audioGroup = 0L + @Volatile private var vadDeadline = 0L + private var vadConfigured = false + private var screenReceiver: BroadcastReceiver? = null + @Volatile private var screenInteractive = false + private var configuredInput = INPUT_AUTO + private var configuredMicSource = 0 + private var configuredStereo = false + private var configuredVad = false @Volatile private var vadMode = false /* VAD авто-PTT: фидим PCM постоянно */ - @Volatile private var captureChannels = 1 /* 1=моно, 2=стерео — по настройке захвата */ /** Маршрутизация аудио-выхода рации (динамик ↔ гарнитура), как в звонке. */ val router = CallAudioRouter() @@ -77,13 +95,23 @@ class RadioAudioEngine { Thread(r, "radio-audio-io").apply { isDaemon = true } } - /* Отложенный FIN после релиза PTT (все talkBegin/talkEnd зовутся с main-потока). */ + /* Таймер окна VAD; управление ресурсами всегда на io-потоке. */ private val mainHandler = Handler(Looper.getMainLooper()) - private val releaseRunnable = Runnable { finishTalk() } + 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)") + updateCapture() + } + } + } + private val _vadActiveFlow = MutableStateFlow(false) + val vadActiveFlow: StateFlow = _vadActiveFlow fun isActive(): Boolean = active - fun isTalking(): Boolean = talking + fun isTalking(): Boolean = _talkingFlow.value /** Текущий маршрут выхода рации (SPEAKER/HEADSET). */ fun route(): AudioRoute = router.route @@ -95,61 +123,63 @@ class RadioAudioEngine { fun headsetAvailable(): Boolean = router.headsetAvailable /** Ручной выбор выхода (отключает автоследование за гарнитурой). */ - fun setRoute(r: AudioRoute) = router.setRoute(r) + fun setRoute(r: AudioRoute, preferWired: Boolean = false) = router.setRoute(r, preferWired) /** Начать прослушивание канала (переключатель рации вкл). * Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */ fun start(group: Long) { if (active && groupId == group) return + mainHandler.removeCallbacks(releaseTalk) + val version = ++generation active = true groupId = group - talking = false - io.execute { startBlocking(group) } + requestedTalk = false + _talkingFlow.value = false + io.execute { + if (!active || generation != version) return@execute + releaseLocked() + audioGroup = group + vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS + router.setAutoHeadset(true) + if (active && generation == version) { + try { setupAudio() } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioAudio", "audio setup failed: ${e.message}") + releaseLocked() + if (generation == version) active = false + } + } + } } fun stop() { - if (!active) return + mainHandler.removeCallbacks(releaseTalk) active = false - talking = false + requestedTalk = false _talkingFlow.value = false - mainHandler.removeCallbacks(releaseRunnable) - io.execute { stopBlocking() } - } - - /* ── io-поток (сериализовано): setup/teardown аудио ── */ - - private fun startBlocking(group: Long) { - if (!active || groupId != group) return - - /* Переключение группы: гасим старое аудио (active=false даёт старым - * capture/play-потокам выйти до release, иначе UAF AudioTrack в playLoop). */ - active = false - releaseLocked(false) - active = true - router.setAutoHeadset(true) /* дефолт «авто» только на свежий старт/смену группы */ - setupAudio() + ++generation + io.execute { releaseLocked() } } - /* Полный перезапуск аудио на том же канале — применение новых настроек на лету. */ + /** Смена настроек перезапускает только захват, сохраняя приём и окно VAD. */ fun reconfigure() { - val g = groupId - if (g == 0L) return + val version = generation io.execute { - if (groupId != g) return@execute - if (talking) finishTalk() - if (active) { active = false; releaseLocked(false) } - active = true - setupAudio() + if (!active || generation != version || audioTrack == null) return@execute + stopCapture() + loadCaptureSettings() + updateCapture() } } - /* Инициализация C-движка + AudioRecord/AudioTrack + потоков (только io-поток). */ + /* Приём создаётся независимо от микрофона (только io-поток). */ private fun setupAudio() { - val group = groupId + loadCaptureSettings() + val group = audioGroup val ctx = ChatApplication.instance val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager prevMode = am.mode prevSpeaker = am.isSpeakerphoneOn + audioModeOwned = true try { am.mode = AudioManager.MODE_IN_COMMUNICATION } catch (e: Exception) { @@ -160,24 +190,12 @@ class RadioAudioEngine { router.fallbackRoute = AudioRoute.SPEAKER router.start() - // ── AudioRecord (захват) с откатом стерео→моно ── - val (rec, capCh) = createAudioRecord() - if (rec == null) { - router.stop(); restoreAudioMode(); active = false; return - } - captureChannels = capCh - - if (!NativeLib.radioAudioStart(group, capCh)) { - rec.release() + if (!NativeLib.radioAudioStart(group, 0)) { router.stop(); restoreAudioMode(); active = false - LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group ch=$capCh") + LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group") return } - /* VAD авто-PTT: сервисный слой решил, что режим включён — фидим PCM постоянно. */ - vadMode = NativeLib.radioVadMode() - if (vadMode) LogManager.addLog("INFO", "RadioAudio", "VAD auto-PTT enabled group=$group") - // ── AudioTrack (воспроизведение, всегда стерео) ── val trackMin = AudioTrack.getMinBufferSize( SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT) @@ -193,13 +211,10 @@ class RadioAudioEngine { trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE) } catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}") - rec.release() NativeLib.radioAudioStop(); router.stop(); restoreAudioMode(); active = false; return } - audioRecord = rec audioTrack = track - micDead = false /* Прямое роутирование трека на выбранное устройство (обход глючной глобальной * маршрутизации), как в CallAudioEngine. */ @@ -211,27 +226,33 @@ class RadioAudioEngine { if (ChatApplication.instance.configProvider.getRadioInputSource() == INPUT_AUTO) reconfigure() } - rec.startRecording() track.play() - - captureThread = Thread { captureLoop() }.apply { - priority = Thread.MAX_PRIORITY; start() - } + playbackRunning = true playThread = Thread { playLoop() }.apply { priority = Thread.MAX_PRIORITY; start() } - LogManager.addLog("INFO", "RadioAudio", "started group=$group captureCh=$captureChannels trackBuf=$trackBuf") + registerScreenReceiver() + updateCapture() + LogManager.addLog("INFO", "RadioAudio", "listening group=$group trackBuf=$trackBuf") + } + + /* Настройки загружаются при включении/перенастройке, без DataStore в пути PTT. */ + private fun loadCaptureSettings() { + val provider = ChatApplication.instance.configProvider + configuredInput = provider.getRadioInputSource() + configuredMicSource = provider.getRadioMicSource() + configuredStereo = provider.getRadioCaptureStereo() + configuredVad = provider.getInt("radio.vad_enabled") != 0 } /* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон * (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC), * авто — системный дефолт. Возвращает (record, каналы). */ private fun createAudioRecord(): Pair { - val ctx = ChatApplication.instance - val inputSource = ctx.configProvider.getRadioInputSource() - val pref = ctx.configProvider.getRadioMicSource() - val wantStereo = ctx.configProvider.getRadioCaptureStereo() + val inputSource = configuredInput + val pref = configuredMicSource + val wantStereo = configuredStereo /* гарнитурный вход: моно + VOICE_COMMUNICATION (звонковый тракт «запитывает» * микрофон гарнитуры; AudioSource.MIC на ней даёт тишину). AUTO: следуем за @@ -272,6 +293,10 @@ class RadioAudioEngine { for (mask in masks) { val channels = if (mask == AudioFormat.CHANNEL_IN_STEREO) 2 else 1 val recMin = AudioRecord.getMinBufferSize(SAMPLE_RATE, mask, AudioFormat.ENCODING_PCM_16BIT) + if (recMin <= 0) { + LogManager.addLog("WARN", "RadioAudio", "unsupported capture format ch=$channels minBuffer=$recMin") + continue + } val bufSize = (recMin * 2).coerceAtLeast(FRAME_SAMPLES * channels * 4) for (src in sources) { val rec = tryCreateRecord(src, bufSize, mask) @@ -298,7 +323,7 @@ class RadioAudioEngine { LogManager.addLog("ERROR", "RadioAudio", "setPreferredDevice(record) threw: ${e.message}") false } - LogManager.addLog("WARN", "RadioAudio", "record input -> dev=${dev.type} setPreferredDevice=$ok") + LogManager.addLog(if (ok) "DEBUG" else "WARN", "RadioAudio", "record input -> dev=${dev.type} setPreferredDevice=$ok") } private fun tryCreateRecord(source: Int, bufferSize: Int, channelMask: Int): AudioRecord? { @@ -316,89 +341,224 @@ class RadioAudioEngine { return rec } - private fun stopBlocking() { - releaseLocked(true) + private fun screenOn(): Boolean = + (ChatApplication.instance.getSystemService(Context.POWER_SERVICE) as PowerManager).isInteractive + + private fun registerScreenReceiver() { + val receiver = object : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + screenInteractive = intent.action == Intent.ACTION_SCREEN_ON + val version = generation + 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") + } + if (intent.action == Intent.ACTION_SCREEN_ON) { + vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS + LogManager.addLog("INFO", "RadioAudio", "screen on: new VAD window (15 minutes)") + } + updateCapture() + } + } + } + val filter = IntentFilter().apply { + addAction(Intent.ACTION_SCREEN_ON) + addAction(Intent.ACTION_SCREEN_OFF) + } + val ctx = ChatApplication.instance + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) + ctx.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED) + else ctx.registerReceiver(receiver, filter) + screenReceiver = receiver + screenInteractive = screenOn() } - /* Освобождение аудио-ресурсов (только io-поток): join потоков → release → restore. */ - private fun releaseLocked(resetGroup: Boolean) { - captureThread?.join(1000); captureThread = null - playThread?.join(1000); playThread = null + /** Выбор захвата: PTT либо включённый VAD в пределах окна при горящем экране. */ + 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)) + if (!active || (!requestedTalk && !wantVad)) { + stopCapture() + return + } + if (audioRecord != null && vadMode != wantVad) stopCapture() + if (audioTrack == null) return + if (audioRecord == null && !startCapture(wantVad)) { + requestedTalk = false + _talkingFlow.value = false + return + } + if (requestedTalk && !talking) { + NativeLib.radioTalkBegin(audioGroup) + talking = true + _talkingFlow.value = true + } else if (!requestedTalk && talking) { + NativeLib.radioTalkEnd(audioGroup) + talking = false + _talkingFlow.value = false + } + } - audioRecord?.apply { try { stop() } catch (_: Exception) {}; release() } - audioRecord = null - audioTrack?.apply { try { stop() } catch (_: Exception) {}; release() } - audioTrack = null + private fun startCapture(vad: Boolean): Boolean { + val startNs = SystemClock.elapsedRealtimeNanos() + val (rec, channels) = createAudioRecord() + if (rec == null) return false + val createdNs = SystemClock.elapsedRealtimeNanos() + if (!NativeLib.radioCaptureStart(audioGroup, channels, vad)) { + LogManager.addLog("ERROR", "RadioAudio", "native capture start failed group=$audioGroup ch=$channels vad=$vad") + rec.release() + return false + } + vadMode = NativeLib.radioVadMode() + if (vad && !vadMode) { + LogManager.addLog("ERROR", "RadioAudio", "VAD unavailable: capture cancelled") + NativeLib.radioCaptureStop(); rec.release() + return false + } + val preparedNs = SystemClock.elapsedRealtimeNanos() + try { + rec.startRecording() + if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) error("recording did not start") + } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioAudio", "capture start failed: ${e.message}") + NativeLib.radioCaptureStop(); rec.release() + vadMode = false + return false + } + val recordingNs = SystemClock.elapsedRealtimeNanos() + audioRecord = rec + captureRunning = true + _vadActiveFlow.value = vadMode + // BEGIN должен попасть в uasync раньше первого кадра ручной передачи. + if (requestedTalk) { + NativeLib.radioTalkBegin(audioGroup) + talking = true + _talkingFlow.value = true + } + val captureGroup = audioGroup + captureThread = Thread { + try { captureLoop(rec, captureGroup, channels) } + catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "capture failed: ${e.message}") } + finally { + if (captureRunning) io.execute { + if (audioRecord === rec) { + requestedTalk = false + stopCapture() + LogManager.addLog("WARN", "RadioAudio", "capture lost: resources released") + } + } + } + }.apply { + name = "radio-capture"; priority = Thread.MAX_PRIORITY; start() + } + LogManager.addLog("INFO", "RadioAudio", + "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}") + return true + } + private fun stopCapture() { + captureRunning = false + val rec = audioRecord + if (rec != null) { + try { rec.stop() } catch (e: Exception) { + LogManager.addLog("WARN", "RadioAudio", "capture stop failed: ${e.message}") + } + // stop разблокирует read; release только после выхода потока. + captureThread?.join() + captureThread = null + rec.release() + audioRecord = null + NativeLib.radioCaptureStop() + LogManager.addLog("INFO", "RadioAudio", "capture released group=$audioGroup") + } + vadMode = false + talking = false + _talkingFlow.value = requestedTalk + _vadActiveFlow.value = false + } + + private fun releaseLocked() { + mainHandler.removeCallbacks(vadTimeout) + screenReceiver?.let { ChatApplication.instance.unregisterReceiver(it) } + screenReceiver = null + stopCapture() + playbackRunning = false + audioTrack?.let { track -> + try { track.stop() } catch (e: Exception) { + LogManager.addLog("WARN", "RadioAudio", "playback stop failed: ${e.message}") + } + playThread?.interrupt() + playThread?.join() + playThread = null + track.release() + } + audioTrack = null NativeLib.radioAudioStop() router.onOutputDevice = null router.onInputDevice = null router.stop() restoreAudioMode() - - if (resetGroup) { - groupId = 0L - LogManager.addLog("INFO", "RadioAudio", "stopped") - } } - /** PTT зажата — начинаем передачу. */ fun talkBegin() { if (!active) return - mainHandler.removeCallbacks(releaseRunnable) - if (talking) return - if (micDead) { - LogManager.addLog("WARN", "RadioAudio", "talkBegin but mic is dead (background capture stopped)") - } - talking = true + mainHandler.removeCallbacks(releaseTalk) + if (requestedTalk) return + talkRequestNs = SystemClock.elapsedRealtimeNanos() + requestedTalk = true _talkingFlow.value = true - NativeLib.radioTalkBegin(groupId) + val version = generation + LogManager.addLog("DEBUG", "RadioAudio", "PTT requested group=$groupId") + io.execute { + if (active && generation == version) { + LogManager.addLog("DEBUG", "RadioAudio", "PTT io queueMs=${(SystemClock.elapsedRealtimeNanos() - talkRequestNs) / 1_000_000}") + updateCapture() + } + } } - /** PTT отпущена — добиваем хвост RELEASE_HOLD_MS, затем завершаем (FIN). */ + /** Отпускание: 250мс передачи, повторное касание отменяет завершение. */ fun talkEnd() { - if (!active || !talking) return - mainHandler.removeCallbacks(releaseRunnable) - mainHandler.postDelayed(releaseRunnable, RELEASE_HOLD_MS) - LogManager.addLog("DEBUG", "RadioAudio", "talk release scheduled +${RELEASE_HOLD_MS}ms") - } - - /** Немедленное завершение передачи без хвоста (drag кнопки рации). */ - fun talkEndImmediate() { - if (!active || !talking) return - mainHandler.removeCallbacks(releaseRunnable) - finishTalk() + if (!active || !requestedTalk) return + mainHandler.removeCallbacks(releaseTalk) + mainHandler.postDelayed(releaseTalk, RELEASE_HOLD_MS) + LogManager.addLog("DEBUG", "RadioAudio", "PTT release scheduled +${RELEASE_HOLD_MS}ms") } private fun finishTalk() { - if (!talking) return - talking = false + if (!requestedTalk) return + requestedTalk = false _talkingFlow.value = false - NativeLib.radioTalkEnd(groupId) - LogManager.addLog("DEBUG", "RadioAudio", "talk finished (FIN)") + val version = generation + io.execute { if (active && generation == version) updateCapture() } } - private fun captureLoop() { - val ch = captureChannels + private fun captureLoop(rec: AudioRecord, group: Long, ch: Int) { val frame = FRAME_SAMPLES * ch val buf = ShortArray(frame) var frameN = 0L - while (active) { - val rec = audioRecord ?: break + var reportedRequestNs = 0L + while (captureRunning) { if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) { - if (active) { - micDead = true + if (captureRunning) { LogManager.addLog("WARN", "RadioAudio", "capture loop: recordingState=${rec.recordingState}, mic stopped") } break } var read = 0 while (read < frame) { - if (!active) return + if (!captureRunning) return val n = rec.read(buf, read, frame - read) if (n <= 0) { - if (active) { - micDead = true + if (captureRunning) { LogManager.addLog("WARN", "RadioAudio", "capture loop: read()=$n, mic lost") } return @@ -408,11 +568,19 @@ class RadioAudioEngine { /* диагностика крос-девайс входа/выхода: куда реально роутится захват + уровень сигнала */ if (frameN < 10 || frameN % 100L == 0L) { LogManager.addLog("DEBUG", "RadioAudio", - "cap routed=${rec.routedDevice?.type ?: "-"} frame=$frameN rmsDb=${rmsDb(buf)} input=${inputSource()} route=${route()}") + "cap routed=${rec.routedDevice?.type ?: "-"} frame=$frameN rmsDb=${rmsDb(buf)} input=$configuredInput route=${route()}") } frameN++ - if ((talking || vadMode) && !NativeLib.radioAudioFeed(groupId, buf)) { - LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false") + val vadAllowed = vadMode && screenInteractive && SystemClock.elapsedRealtime() < vadDeadline + if (active && groupId == group && captureRunning && ((talking && requestedTalk) || vadAllowed)) { + val requestNs = talkRequestNs + val fed = NativeLib.radioAudioFeed(group, buf) + if (!fed) LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false") + 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() } } LogManager.addLog("DEBUG", "RadioAudio", "capture loop done") @@ -432,9 +600,9 @@ class RadioAudioEngine { val silence = ShortArray(frame) val frameNs = FRAME_MS * 1_000_000L /* 20мс в наносекундах */ var nextNs = System.nanoTime() - while (active) { + while (playbackRunning) { val track = audioTrack ?: return - val n = NativeLib.radioAudioPull(groupId, buf) + val n = NativeLib.radioAudioPull(audioGroup, buf) if (n > 0) track.write(buf, 0, n) if (n < frame) track.write(silence, 0, frame - n) @@ -452,6 +620,8 @@ class RadioAudioEngine { } private fun restoreAudioMode() { + if (!audioModeOwned) return + audioModeOwned = false try { val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager am.isSpeakerphoneOn = prevSpeaker diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt index 5b1cfbfa..fdbaa8cf 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt @@ -9,6 +9,7 @@ import android.content.Intent import android.content.pm.PackageManager import android.content.pm.ServiceInfo import android.graphics.PixelFormat +import android.graphics.Point import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.IBinder @@ -24,16 +25,24 @@ import android.widget.TextView import androidx.core.app.NotificationCompat import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat +import androidx.core.view.doOnLayout +import com.utun.chat.ChatApplication import kotlin.math.abs +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow /** * Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop. - * Удержание > PTT_HOLD_MS активирует передачу (RadioAudioEngine.talkBegin/talkEnd), - * отпускание — завершает. Двойной тап фиксирует передачу (latch) без удержания, + * Касание сразу активирует передачу (RadioAudioEngine.talkBegin/talkEnd), + * отпускание — завершает с хвостом 250мс. Двойной тап фиксирует передачу (latch) без удержания, * повторный двойной тап или drag — отбой. Работает пока включена опция * «PTT поверх приложений» и разрешено «поверх других приложений» (SYSTEM_ALERT_WINDOW). + * При включённом VAD сервис сохраняет доступ к микрофону без плавающей кнопки. */ class RadioOverlayService : Service() { @@ -43,7 +52,16 @@ class RadioOverlayService : Service() { const val COLOR_IDLE = 0xFF81C784.toInt() const val COLOR_TALKING = 0xFFE53935.toInt() const val DRAG_LOCK_MS = 300L - const val PTT_HOLD_MS = 250L + + private var defaultCenter: Point? = null + private var currentService: RadioOverlayService? = null + + // Координаты центра обычной PTT в пикселях экрана; обновляются из layout чата. + fun setDefaultCenter(x: Int, y: Int) { + if (defaultCenter?.x == x && defaultCenter?.y == y) return + defaultCenter = Point(x, y) + currentService?.placeAtDefaultCenter() + } private val _isShown = MutableStateFlow(false) val isShown: StateFlow = _isShown @@ -51,12 +69,20 @@ class RadioOverlayService : Service() { /* Гонка start/stop: stop() до вызова startForeground() роняет FGS. * Пока сервис стартует (starting) — взводим pendingStop, а onCreate * после startForeground() сам вызовет stopSelf(). */ + @Volatile private var running = false @Volatile private var starting = false @Volatile private var pendingStop = false fun canDrawOverlays(ctx: Context): Boolean = Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx) + fun sync(ctx: Context) { + val provider = ChatApplication.instance.configProvider + if (RadioAudioEngine.instance().isActive() && + (provider.getRadioOverlay() || provider.getInt("radio.vad_enabled") != 0)) start(ctx) + else stop(ctx) + } + fun start(ctx: Context) { LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.start isShown=${_isShown.value} t=${System.currentTimeMillis()}") pendingStop = false @@ -66,13 +92,16 @@ class RadioOverlayService : Service() { LogManager.addLog("ERROR", "RadioOverlay", "FOREGROUND_SERVICE_MICROPHONE not granted — background mic will be muted") return } - if (starting || _isShown.value) return - starting = true + if (starting) return + starting = !running val i = Intent(ctx, RadioOverlayService::class.java) - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - ctx.startForegroundService(i) - else - ctx.startService(i) + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i) + else ctx.startService(i) + } catch (e: Exception) { + starting = false + LogManager.addLog("ERROR", "RadioOverlay", "service start failed: ${e.message}") + } } fun stop(ctx: Context) { @@ -89,6 +118,9 @@ class RadioOverlayService : Service() { private var overlayView: View? = null private var params: WindowManager.LayoutParams? = null private var talking = false + private var positionDragged = false + private var positioningPending = false + private val serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) override fun onBind(intent: Intent?): IBinder? = null @@ -103,14 +135,12 @@ class RadioOverlayService : Service() { startForeground(NOTIF_ID, buildNotification()) } + running = true + currentService = this windowManager = getSystemService(WINDOW_SERVICE) as WindowManager - if (!canDrawOverlays(this)) { - LogManager.addLog("WARN", "RadioOverlay", "SYSTEM_ALERT_WINDOW not granted — stopping") - starting = false - stopSelf() - return + serviceScope.launch { + RadioAudioEngine.instance().talkingFlow.collect { updateButtonColor() } } - addOverlay() starting = false if (pendingStop) { pendingStop = false @@ -118,6 +148,25 @@ class RadioOverlayService : Service() { } } + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (pendingStop || !RadioAudioEngine.instance().isActive()) { + stopSelf() + return START_NOT_STICKY + } + val provider = ChatApplication.instance.configProvider + val wantOverlay = provider.getRadioOverlay() && canDrawOverlays(this) + if (wantOverlay && overlayView == null) addOverlay() + if (!wantOverlay && overlayView != null) { + endTalk() + windowManager?.removeView(overlayView) + overlayView = null + _isShown.value = false + } + if (!wantOverlay && provider.getInt("radio.vad_enabled") == 0) stopSelf() + LogManager.addLog("INFO", "RadioOverlay", "service active overlay=$wantOverlay vad=${provider.getInt("radio.vad_enabled")}") + return START_NOT_STICKY + } + private fun createChannel() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val ch = NotificationChannel(CHANNEL_ID, "Рация PTT", NotificationManager.IMPORTANCE_LOW).apply { @@ -130,7 +179,7 @@ class RadioOverlayService : Service() { private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID) .setSmallIcon(android.R.drawable.ic_btn_speak_now) .setContentTitle("Рация активна") - .setContentText("PTT-кнопка поверх приложений") + .setContentText("Рация: PTT / автоматическая передача по голосу") .setOngoing(true) .build() @@ -146,7 +195,7 @@ class RadioOverlayService : Service() { } } val touchSlop = (ViewConfiguration.get(this).scaledTouchSlop * 2.5f).toInt() - val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong() + val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong().coerceAtMost(RadioAudioEngine.RELEASE_HOLD_MS) val lp = WindowManager.LayoutParams( WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT, @@ -158,8 +207,9 @@ class RadioOverlayService : Service() { PixelFormat.TRANSLUCENT ).apply { width = dp(67); height = dp(67) - gravity = Gravity.TOP or Gravity.START - x = 16; y = 200 + gravity = Gravity.TOP or Gravity.LEFT + x = defaultCenter?.let { it.x - width / 2 } ?: ((resources.displayMetrics.widthPixels - width) / 2) + y = defaultCenter?.let { it.y - height / 2 } ?: dp(200) } v.setOnTouchListener(object : View.OnTouchListener { private var startX = 0; private var startY = 0 @@ -168,7 +218,8 @@ class RadioOverlayService : Service() { private var dragAllowed = true private var latched = false private var lastTapUpTime = 0L - private val talkBeginRunnable = Runnable { beginTalk() } + private var wasLatched = false + private var secondTap = false override fun onTouch(view: View, event: MotionEvent): Boolean { when (event.action) { @@ -179,8 +230,13 @@ class RadioOverlayService : Service() { touchY = event.rawY dragging = false dragAllowed = true - view.removeCallbacks(talkBeginRunnable) - view.postDelayed(talkBeginRunnable, PTT_HOLD_MS) + wasLatched = latched + secondTap = lastTapUpTime != 0L && event.eventTime - lastTapUpTime <= doubleTapTimeout + if (wasLatched) { + latched = false + lastTapUpTime = 0L + endTalk() + } else beginTalk() return true } MotionEvent.ACTION_MOVE -> { @@ -193,9 +249,10 @@ class RadioOverlayService : Service() { dragAllowed = false } else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) { dragging = true - view.removeCallbacks(talkBeginRunnable) + positionDragged = true latched = false - endTalkImmediate() + lastTapUpTime = 0L + endTalk() lp.x = startX + (event.rawX - touchX).toInt() lp.y = startY + (event.rawY - touchY).toInt() wm.updateViewLayout(v, lp) @@ -204,26 +261,20 @@ class RadioOverlayService : Service() { return true } MotionEvent.ACTION_UP -> { - view.removeCallbacks(talkBeginRunnable) - val now = SystemClock.uptimeMillis() - val isDouble = lastTapUpTime != 0L && (now - lastTapUpTime) <= doubleTapTimeout - if (latched) { - latched = false - lastTapUpTime = 0L - endTalk() - } else if (isDouble) { + val tapped = !dragging && event.eventTime - event.downTime <= RadioAudioEngine.RELEASE_HOLD_MS + if (!wasLatched && tapped && secondTap) { latched = true lastTapUpTime = 0L - beginTalk() } else { - lastTapUpTime = now + lastTapUpTime = if (!wasLatched && tapped) event.eventTime else 0L endTalk() } return true } MotionEvent.ACTION_CANCEL -> { - view.removeCallbacks(talkBeginRunnable) - if (!latched) endTalk() + latched = false + lastTapUpTime = 0L + endTalk() return true } } @@ -233,46 +284,73 @@ class RadioOverlayService : Service() { wm.addView(v, lp) overlayView = v params = lp + positionDragged = false + placeAtDefaultCenter() _isShown.value = true LogManager.addLog("INFO", "RadioOverlay", "overlay shown") } + /* Поправка по фактическим координатам окна учитывает системные отступы оверлея. */ + private fun placeAtDefaultCenter() { + val v = overlayView ?: return + if (positionDragged) return + if (!v.isLaidOut) { + v.doOnLayout { placeAtDefaultCenter() } + return + } + if (positioningPending) return + positioningPending = true + v.postOnAnimation { + positioningPending = false + val target = defaultCenter ?: return@postOnAnimation + val lp = params ?: return@postOnAnimation + if (overlayView !== v || positionDragged) return@postOnAnimation + val actual = IntArray(2) + v.getLocationOnScreen(actual) + val dx = target.x - actual[0] - v.width / 2 + val dy = target.y - actual[1] - v.height / 2 + if (dx == 0 && dy == 0) return@postOnAnimation + lp.x += dx + lp.y += dy + try { + windowManager?.updateViewLayout(v, lp) + LogManager.addLog("DEBUG", "RadioOverlay", "default position center=${target.x},${target.y} x=${lp.x} y=${lp.y}") + } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioOverlay", "default position failed: ${e.message}") + } + } + } + private fun beginTalk() { if (talking) return talking = true - updateButtonColor() RadioAudioEngine.instance().talkBegin() + updateButtonColor() } private fun endTalk() { - endTalkInternal(immediate = false) - } - - private fun endTalkImmediate() { - endTalkInternal(immediate = true) - } - - private fun endTalkInternal(immediate: Boolean) { if (!talking) return talking = false + RadioAudioEngine.instance().talkEnd() updateButtonColor() - if (immediate) RadioAudioEngine.instance().talkEndImmediate() - else RadioAudioEngine.instance().talkEnd() } private fun updateButtonColor() { (overlayView?.background as? GradientDrawable)?.setColor( - if (talking) COLOR_TALKING else COLOR_IDLE + if (RadioAudioEngine.instance().talkingFlow.value) COLOR_TALKING else COLOR_IDLE ) } private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt() override fun onDestroy() { + serviceScope.cancel() endTalk() overlayView?.let { windowManager?.removeView(it) } overlayView = null starting = false + running = false + if (currentService === this) currentService = null _isShown.value = false LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed") super.onDestroy() 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 2525f957..7cf74c01 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 @@ -1,12 +1,16 @@ package com.utun.chat.ui.screens import android.content.ContentResolver +import android.content.Intent +import android.provider.Settings import android.net.Uri import android.os.SystemClock import android.provider.OpenableColumns import android.view.ViewConfiguration import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.horizontalScroll @@ -16,21 +20,34 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.AutoMode +import androidx.compose.material.icons.filled.Bluetooth +import androidx.compose.material.icons.filled.HeadsetMic +import androidx.compose.material.icons.automirrored.filled.VolumeUp import androidx.compose.material.icons.filled.Link import androidx.compose.material.icons.filled.People import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.layout.onGloballyPositioned +import androidx.compose.ui.layout.positionInWindow +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.graphics.Color -import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.utun.chat.ChatApplication +import com.utun.chat.data.LogManager import com.utun.chat.data.AudioRoute import com.utun.chat.data.Channel import com.utun.chat.data.Message @@ -44,7 +61,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import kotlinx.coroutines.withTimeoutOrNull @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -70,6 +86,8 @@ fun ChatScreen( val recordingDurationMs by viewModel.recordingDurationMs.collectAsState() var recordingLevel by remember { mutableStateOf(0f) } val context = LocalContext.current + val view = LocalView.current + val pttCenterOffset = with(LocalDensity.current) { 60.dp.toPx() } val scope = rememberCoroutineScope() /* рация */ @@ -173,7 +191,16 @@ fun ChatScreen( ) } } - Box(modifier = Modifier.weight(1f).fillMaxWidth()) { + Box(modifier = Modifier.weight(1f).fillMaxWidth().onGloballyPositioned { coordinates -> + val windowPosition = coordinates.positionInWindow() + val screenOrigin = IntArray(2) + val windowOrigin = IntArray(2) + view.getLocationOnScreen(screenOrigin) + view.getLocationInWindow(windowOrigin) + RadioOverlayService.setDefaultCenter( + (windowPosition.x + coordinates.size.width / 2f + screenOrigin[0] - windowOrigin[0]).toInt(), + (windowPosition.y + pttCenterOffset + screenOrigin[1] - windowOrigin[1]).toInt()) + }) { LazyColumn( modifier = Modifier.fillMaxSize(), state = listState, @@ -279,9 +306,7 @@ fun ChatScreen( } } -/* Круглая PTT-кнопка рации: удержание > PTT_HOLD_MS — говорим, двойной тап — - * фиксирует передачу (latch) без удержания, следующий тап/двойной тап — отбой. */ -private const val PTT_HOLD_MS = 250L +/* Передача сразу при касании; двойной тап фиксирует, следующий тап завершает. */ @Composable private fun RadioPttButton( @@ -290,7 +315,7 @@ private fun RadioPttButton( onTalkBegin: () -> Unit, onTalkEnd: () -> Unit ) { - val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong() + val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong().coerceAtMost(RadioAudioEngine.RELEASE_HOLD_MS) var latched by remember { mutableStateOf(false) } var lastTapUp by remember { mutableStateOf(0L) } @@ -298,51 +323,46 @@ private fun RadioPttButton( modifier = modifier .size(96.dp) .pointerInput(Unit) { - awaitPointerEventScope { - while (true) { - val down = awaitFirstDown(requireUnconsumed = false) - val downId = down.id - down.consume() + try { + awaitPointerEventScope { + while (true) { + val down = awaitFirstDown(requireUnconsumed = false) + val downId = down.id + down.consume() - val now = SystemClock.uptimeMillis() - val isSecondTap = lastTapUp != 0L && (now - lastTapUp) <= doubleTapTimeout + val now = SystemClock.uptimeMillis() + val isSecondTap = lastTapUp != 0L && (now - lastTapUp) <= doubleTapTimeout - /* быстрое отпускание (до PTT_HOLD_MS) — это тап */ - var tapped = false - withTimeoutOrNull(PTT_HOLD_MS) { - while (true) { - val ev = awaitPointerEvent() - val c = ev.changes.firstOrNull { it.id == downId } ?: return@withTimeoutOrNull - if (!c.pressed) { tapped = true; return@withTimeoutOrNull } - c.consume() - } - } - - if (tapped) { - if (latched) { + val wasLatched = latched + if (wasLatched) { latched = false lastTapUp = 0L onTalkEnd() - } else if (isSecondTap) { - latched = true - lastTapUp = 0L - onTalkBegin() - } else { - lastTapUp = SystemClock.uptimeMillis() - } - continue - } + } else onTalkBegin() - /* удержание — говорим, пока не отпустят */ - onTalkBegin() - while (true) { - val ev = awaitPointerEvent() - val c = ev.changes.firstOrNull { it.id == downId } ?: break - c.consume() - if (!c.pressed) break + var released = false + try { + while (true) { + val ev = awaitPointerEvent() + val c = ev.changes.firstOrNull { it.id == downId } ?: break + c.consume() + if (!c.pressed) { released = true; break } + } + val upTime = SystemClock.uptimeMillis() + val tapped = released && upTime - now <= RadioAudioEngine.RELEASE_HOLD_MS + if (!wasLatched && tapped && isSecondTap) { + latched = true + lastTapUp = 0L + } else lastTapUp = if (!wasLatched && tapped) upTime else 0L + } finally { + if (!wasLatched && !latched) onTalkEnd() + } } - onTalkEnd() } + } finally { + latched = false + lastTapUp = 0L + onTalkEnd() } }, shape = CircleShape, @@ -355,7 +375,7 @@ private fun RadioPttButton( } } -/* Строка аудиоисточников рации: вход (авто/встроенный/проводная/беспроводная) + выход. */ +/* Единый выбор входа/выхода и переключатель плавающей PTT-кнопки. */ @Composable private fun RadioSourceBar(viewModel: ChatViewModel) { val radioAuto by viewModel.radioAuto.collectAsState() @@ -363,61 +383,125 @@ private fun RadioSourceBar(viewModel: ChatViewModel) { val wiredInputAvailable by viewModel.wiredInputAvailable.collectAsState() val btInputAvailable by viewModel.btInputAvailable.collectAsState() val radioRoute by viewModel.radioRoute.collectAsState() - val radioHeadsetAvailable by viewModel.radioHeadsetAvailable.collectAsState() + val context = LocalContext.current + val provider = ChatApplication.instance.configProvider + val scope = rememberCoroutineScope() + val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay()) + val overlayPermission = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { + if (RadioOverlayService.canDrawOverlays(context)) { + scope.launch { + provider.setRadioOverlay(true) + RadioOverlayService.sync(context) + LogManager.addLog("INFO", "RadioUI", "floating PTT enabled after permission grant") + } + } else LogManager.addLog("WARN", "RadioUI", "floating PTT permission not granted") + } Surface(color = Color(0xFF0F1B22)) { Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - .padding(horizontal = 8.dp, vertical = 4.dp), - horizontalArrangement = Arrangement.spacedBy(6.dp), + modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 12.dp, vertical = 12.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically ) { - RadioSourceGroupLabel("Вход") - RadioSourceChip("Авто", radioAuto, { viewModel.setRadioAuto() }) - RadioSourceChip("Встроенный", radioInputSource == RadioAudioEngine.INPUT_BUILTIN, - { viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN) }) - if (wiredInputAvailable) { - RadioSourceChip("Проводная", radioInputSource == RadioAudioEngine.INPUT_WIRED, - { viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED) }) - } - if (btInputAvailable) { - RadioSourceChip("Беспроводная", radioInputSource == RadioAudioEngine.INPUT_BT, - { viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT) }) + Row( + modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically + ) { + val currentDeviceIcon = if (radioRoute == AudioRoute.HEADSET) Icons.Default.HeadsetMic else Icons.AutoMirrored.Filled.VolumeUp + RadioControlButton(Icons.Default.AutoMode, "Авто: ${if (radioRoute == AudioRoute.HEADSET) "гарнитура" else "динамик"}", + radioAuto, centerIcon = currentDeviceIcon) { + viewModel.setRadioAuto() + } + RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, "Микрофон и динамик телефона", + !radioAuto && radioInputSource == RadioAudioEngine.INPUT_BUILTIN && radioRoute == AudioRoute.SPEAKER) { + viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN) + } + if (wiredInputAvailable) { + RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук проводной гарнитуры", + !radioAuto && radioInputSource == RadioAudioEngine.INPUT_WIRED && radioRoute == AudioRoute.HEADSET) { + viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED) + } + } + if (btInputAvailable) { + RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук Bluetooth-гарнитуры", + !radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) { + viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT) + } + } } - RadioSourceGroupLabel("Выход") - RadioSourceChip("Динамик", radioRoute == AudioRoute.SPEAKER, - { viewModel.setRadioRoute(AudioRoute.SPEAKER) }) - if (radioHeadsetAvailable) { - RadioSourceChip("Гарнитура", radioRoute == AudioRoute.HEADSET, - { viewModel.setRadioRoute(AudioRoute.HEADSET) }) + VerticalDivider(modifier = Modifier.height(32.dp), color = Color(0xFF344550)) + RadioControlButton(null, "Плавающая PTT-кнопка", overlayEnabled, floating = true) { + if (!overlayEnabled && !RadioOverlayService.canDrawOverlays(context)) { + try { + overlayPermission.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION, + Uri.parse("package:${context.packageName}"))) + } catch (e: Exception) { + LogManager.addLog("ERROR", "RadioUI", "open overlay permission failed: ${e.message}") + } + } else scope.launch { + provider.setRadioOverlay(!overlayEnabled) + RadioOverlayService.sync(context) + LogManager.addLog("INFO", "RadioUI", "floating PTT enabled=${provider.getRadioOverlay()}") + } } } } } @Composable -private fun RadioSourceChip( - label: String, - active: Boolean, - onClick: () -> Unit +private fun RadioControlButton( + icon: ImageVector?, label: String, active: Boolean, + centerIcon: ImageVector? = null, bluetooth: Boolean = false, floating: Boolean = false, onClick: () -> Unit ) { - Surface( - modifier = Modifier.clickable(onClick = onClick), - shape = RoundedCornerShape(16.dp), - color = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D) + IconToggleButton( + checked = active, + onCheckedChange = { onClick() }, + modifier = Modifier.size(48.dp), + colors = IconButtonDefaults.iconToggleButtonColors( + containerColor = Color(0xFF24323D), contentColor = Color(0xFF9BA6AD), + checkedContainerColor = Color(0xFF1F6FEB), checkedContentColor = Color.White) ) { - Text( - label, - color = if (active) Color.White else Color(0xFF9BA6AD), - fontSize = 12.sp, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) - ) + if (floating) { + RadioFloatingIcon(label) + return@IconToggleButton + } + val background = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D) + Box(modifier = Modifier.size(34.dp), contentAlignment = Alignment.Center) { + Icon(requireNotNull(icon), contentDescription = label, modifier = Modifier.size(if (centerIcon != null) 32.dp else 28.dp)) + if (centerIcon != null) { + Box(Modifier.size(20.dp).background(background, CircleShape), contentAlignment = Alignment.Center) { + Icon(centerIcon, contentDescription = null, modifier = Modifier.size(15.dp)) + } + } + if (bluetooth) { + Box(Modifier.align(Alignment.BottomEnd).size(16.dp).background(background, CircleShape), + contentAlignment = Alignment.Center) { + Icon(Icons.Default.Bluetooth, contentDescription = null, modifier = Modifier.size(14.dp)) + } + } + } } } +/* Кружок и четыре стрелки наружу — плавающая/перемещаемая PTT-кнопка. */ @Composable -private fun RadioSourceGroupLabel(text: String) { - Text(text, color = Color(0xFF5A6A75), fontSize = 11.sp, fontWeight = FontWeight.Bold) +private fun RadioFloatingIcon(label: String) { + val color = LocalContentColor.current + Canvas(Modifier.size(28.dp).semantics { contentDescription = label }) { + val c = center + val radius = 4.dp.toPx() + val stroke = 1.7.dp.toPx() + val start = 6.dp.toPx() + val end = 13.dp.toPx() + val head = 3.dp.toPx() + drawCircle(color, radius, c, style = Stroke(stroke)) + for (direction in listOf(Offset(1f, 0f), Offset(-1f, 0f), Offset(0f, 1f), Offset(0f, -1f))) { + val tip = c + direction * end + val perpendicular = Offset(-direction.y, direction.x) + drawLine(color, c + direction * start, tip, strokeWidth = stroke) + drawLine(color, tip, tip - direction * head + perpendicular * head, strokeWidth = stroke) + drawLine(color, tip, tip - direction * head - perpendicular * head, strokeWidth = stroke) + } + } } 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 ba7ed682..9959b36f 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 @@ -46,7 +46,7 @@ private val CATEGORIES = listOf( "config", "tun", "routing", "timers", "bgp", "chat", "socket", "control", "dump", "traffic", "debug", "general", "nat", "keepalive", "media", "etcp_route", "etcp_dump", - "chat_sync", "member_sync", "proxy", "radio" + "chat_sync", "member_sync", "proxy", "radio", "vad" ) private val PROTOCOLS = listOf("tcp", "udp") @@ -481,6 +481,45 @@ private fun RadioSettingsTab() { modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()), verticalArrangement = Arrangement.spacedBy(12.dp) ) { + var vadEnabled by remember { mutableStateOf(provider.getInt("radio.vad_enabled") != 0) } + var vadThreshold by remember { mutableStateOf(provider.getInt("radio.vad_threshold")) } + var vadHangover by remember { mutableStateOf(provider.getInt("radio.vad_hangover_ms")) } + val vadActive by RadioAudioEngine.instance().vadActiveFlow.collectAsState() + Text("Автоматическая передача по голосу (VAD)", style = MaterialTheme.typography.titleMedium) + Row(verticalAlignment = Alignment.CenterVertically) { + Text("Включить VAD", modifier = Modifier.weight(1f)) + Switch(checked = vadEnabled, onCheckedChange = { enabled -> + vadEnabled = enabled + scope.launch { + provider.setValue("radio.vad_enabled", if (enabled) "1" else "0") + NativeLib.setChatSetting("radio_vad_enabled", if (enabled) "1" else "0") + RadioOverlayService.sync(ChatApplication.instance) + RadioAudioEngine.instance().reconfigure() + } + }) + } + Text(if (vadActive) "VAD активен: микрофон включён" else "VAD неактивен", + style = MaterialTheme.typography.bodySmall) + Text("Работает 15 минут при включённом экране. При следующем включении экрана запускается автоматически.", + style = MaterialTheme.typography.bodySmall) + LabeledSlider("Порог речи:", vadThreshold.toFloat(), 10f..95f, 84, "$vadThreshold%", + { vadThreshold = it.toInt() }, onValueChangeFinished = { + scope.launch { + provider.setValue("radio.vad_threshold", vadThreshold.toString()) + NativeLib.setChatSetting("radio_vad_threshold", vadThreshold.toString()) + RadioAudioEngine.instance().reconfigure() + } + }) + LabeledSlider("Задержка после тишины:", vadHangover.toFloat(), 50f..2000f, 38, "$vadHangover мс", + { vadHangover = it.toInt() }, onValueChangeFinished = { + scope.launch { + provider.setValue("radio.vad_hangover_ms", vadHangover.toString()) + NativeLib.setChatSetting("radio_vad_hangover_ms", vadHangover.toString()) + RadioAudioEngine.instance().reconfigure() + } + }) + HorizontalDivider(Modifier.padding(vertical = 4.dp)) + /* ── Volume-кнопки PTT ── */ var volumePtt by remember { mutableStateOf(provider.getRadioVolumePtt()) } Row(verticalAlignment = Alignment.CenterVertically) { @@ -522,11 +561,10 @@ private fun RadioSettingsTab() { } Switch(checked = overlayEnabled && canOverlay, enabled = canOverlay, onCheckedChange = { overlayEnabled = it - scope.launch { provider.setRadioOverlay(it) } - if (it && RadioAudioEngine.instance().isActive()) - RadioOverlayService.start(ChatApplication.instance) - else - RadioOverlayService.stop(ChatApplication.instance) + scope.launch { + provider.setRadioOverlay(it) + RadioOverlayService.sync(ChatApplication.instance) + } }) } @@ -547,8 +585,10 @@ private fun RadioSettingsTab() { micSources.forEachIndexed { i, n -> DropdownMenuItem(text = { Text(n) }, onClick = { micSource = i - scope.launch { provider.setRadioMicSource(i) } - RadioAudioEngine.instance().reconfigure() + scope.launch { + provider.setRadioMicSource(i) + RadioAudioEngine.instance().reconfigure() + } micExpanded = false }) } @@ -567,9 +607,11 @@ private fun RadioSettingsTab() { } Switch(checked = captureStereo, onCheckedChange = { captureStereo = it - scope.launch { provider.setRadioCaptureStereo(it) } - NativeLib.setChatSetting("radio_capture_stereo", if (it) "1" else "0") - RadioAudioEngine.instance().reconfigure() + scope.launch { + provider.setRadioCaptureStereo(it) + NativeLib.setChatSetting("radio_capture_stereo", if (it) "1" else "0") + RadioAudioEngine.instance().reconfigure() + } }) } 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 3b408ece..c95770a0 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 @@ -898,7 +898,7 @@ class ChatViewModel : ViewModel() { else -> null } if (linkedRoute != null) { - radioAudio.setRoute(linkedRoute) + radioAudio.setRoute(linkedRoute, preferWired = clamped == RadioAudioEngine.INPUT_WIRED) _radioRoute.value = radioAudio.route() } viewModelScope.launch { @@ -949,10 +949,7 @@ class ChatViewModel : ViewModel() { LogManager.addLog("DEBUG", "RADIO_DBG", "syncRadioOverlay radioOn=$radioOn channel=$radioChannelId overlayEnabled=$overlayEnabled " + "isShown=${RadioOverlayService.isShown.value} t=${System.currentTimeMillis()}") - if (radioOn && overlayEnabled) - RadioOverlayService.start(ctx) - else - RadioOverlayService.stop(ctx) + RadioOverlayService.sync(ctx) } fun radioName(nodeId: Long): String = diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index cfb63024..eddebe82 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -2444,6 +2444,17 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioSta return (utun_bridge_radio_audio_start((uint64_t)groupId, (int)channels) == 0) ? JNI_TRUE : JNI_FALSE; } +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioCaptureStart( + JNIEnv* env, jobject thiz, jlong groupId, jint channels, jboolean vad) { + (void)env; (void)thiz; + return radio_audio_capture_start((uint64_t)groupId, (int)channels, vad ? 1 : 0) == 0 ? JNI_TRUE : JNI_FALSE; +} + +JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioCaptureStop(JNIEnv* env, jobject thiz) { + (void)env; (void)thiz; + radio_audio_capture_stop(); +} + JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioFeed( JNIEnv* env, jobject thiz, jlong groupId, jshortArray samples) { (void)thiz; @@ -2461,6 +2472,11 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioStop( utun_bridge_radio_audio_stop(); } +JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioTransmitting(JNIEnv* env, jobject thiz) { + (void)env; (void)thiz; + return radio_audio_transmitting() ? JNI_TRUE : JNI_FALSE; +} + JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioVadMode( JNIEnv* env, jobject thiz) { (void)env; (void)thiz; diff --git a/tools/chatgui/tests/test_radio_audio.cpp b/tools/chatgui/tests/test_radio_audio.cpp index 02df6c84..f949fc82 100644 --- a/tools/chatgui/tests/test_radio_audio.cpp +++ b/tools/chatgui/tests/test_radio_audio.cpp @@ -101,6 +101,40 @@ static int pull_max_abs(int npulls) { int main() { printf("=== test_radio_audio ===\n"); fflush(stdout); + TEST("RX-only: захват выключен, приём декодируется"); { + int rc = radio_audio_start(GID, 0); + push_const(SRC_A, 1, 10000, 8); + int mx = pull_max_abs(16); + int active = radio_audio_active(); + int talking = radio_audio_transmitting(); + radio_audio_stop(); + if (rc == 0 && active && !talking && mx > 1000) OK(); + else FAIL("rc=%d active=%d talking=%d max=%d", rc, active, talking, mx); + } + + TEST("TX stop/start mono/stereo сохраняет накопленный RX PCM"); { + const int npulls = 16; + int freqs[6] = {300, 1000, 2000, 3000, 4000, 5000}; + uint8_t pkt[6][256]; + int lens[6], seqs[6] = {0, 1, 2, 3, 4, 5}; + int16_t ref[npulls * 960], result[npulls * 960]; + int ok = 0; + if (encode_frames(freqs, 6, pkt, lens)) { + run_burst(pkt, lens, seqs, seqs, 6, ref, npulls); + ok = radio_audio_start(GID, 0) == 0; + feed_frames(pkt, lens, seqs, seqs, 6); + for (int ch = 1; ch <= 2; ch++) { + ok = (radio_audio_capture_start(GID, ch, 0) == 0) && ok; + radio_audio_capture_stop(); + ok = radio_audio_active() && !radio_audio_transmitting() && ok; + } + for (int i = 0; i < npulls; i++) radio_audio_pull_pcm(GID, result + i * 960, 960); + ok = memcmp(ref, result, sizeof(ref)) == 0 && ok; + radio_audio_stop(); + } + if (ok) OK(); else FAIL("TX lifecycle изменил RX или не сменил формат захвата"); + } + TEST("single-source: decode через jitter-буфер выдаёт ненулевой PCM"); { radio_audio_start(GID, 1); push_const(SRC_A, 1, 10000, 8);