Browse Source

Improve Android radio capture lifecycle, VAD and PTT controls

master
evgeny 2 days ago
parent
commit
0d44ada922
  1. 9
      doc/tasks.md
  2. 1
      src/chat/chat_setting.c
  3. 190
      src/radio/radio_audio.c
  4. 9
      src/radio/radio_audio.h
  5. 22
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt
  6. 14
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt
  7. 6
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  8. 410
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt
  9. 174
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioOverlayService.kt
  10. 254
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt
  11. 64
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  12. 7
      tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt
  13. 16
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  14. 34
      tools/chatgui/tests/test_radio_audio.cpp

9
doc/tasks.md

@ -35,12 +35,17 @@
512, фильтр-гистерезис + машина состояний (`src/radio/radio_vad.h`, чистый, 512, фильтр-гистерезис + машина состояний (`src/radio/radio_vad.h`, чистый,
юнит-тестируемый): старт при подтверждении 2 окон (~64мс) выше порога юнит-тестируемый): старт при подтверждении 2 окон (~64мс) выше порога
`radio_vad_threshold`, стоп при тишине `radio_vad_hangover_ms` (200мс) или чужом `radio_vad_threshold`, стоп при тишине `radio_vad_hangover_ms` (200мс) или чужом
разговоре (busy = RX-кадр <200мс). Pre-roll `radio_vad_pre_roll_ms` (200мс) — разговоре (busy = RX-кадр <200мс). История захвата (pre-roll) удалена; передаётся текущий PCM.
кольцевой буфер сырого PCM, флашится при срабатывании («передаём за 200мс до»).
Компрессор в VAD-режиме байпасится. Desktop `radio_audio_engine.cpp` и Android Компрессор в VAD-режиме байпасится. Desktop `radio_audio_engine.cpp` и Android
`RadioAudioEngine.kt` фидят PCM постоянно в VAD-режиме; JNI `radioVadMode()`. `RadioAudioEngine.kt` фидят PCM постоянно в VAD-режиме; JNI `radioVadMode()`.
Тест `test_radio_vad` (ресемплер + FSM). Android: onnxruntime AAR 1.23.2 в Тест `test_radio_vad` (ресемплер + FSM). Android: onnxruntime AAR 1.23.2 в
`jniLibs/` + `cpp/onnxruntime/include` + `-DHAVE_SILERO_VAD` в CMake. `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** — сделано. Причина: [+] **chatgui (Qt) не собирался: C++ void* в utun_node.cpp** — сделано. Причина:
`proxy_protocol.h` (C-заголовок, включается в C++ `utun_node.cpp`) делал `proxy_protocol.h` (C-заголовок, включается в C++ `utun_node.cpp`) делал

1
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_compressor_max_gain_db",CHAT_SETTING_INT, 15, 1, 100},
{"radio_vad_enabled", CHAT_SETTING_BOOL, 0, 0, 1}, {"radio_vad_enabled", CHAT_SETTING_BOOL, 0, 0, 1},
{"radio_vad_threshold", CHAT_SETTING_INT, 50, 10, 95}, /* порог вероятности речи, % */ {"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},/* тишина до авто-отбоя */ {"radio_vad_hangover_ms", CHAT_SETTING_INT, 200, 50, 2000},/* тишина до авто-отбоя */
{"media_download_max_peers", CHAT_SETTING_INT, 3, 1, 16}, {"media_download_max_peers", CHAT_SETTING_INT, 3, 1, 16},
{"storage_backfill_days", CHAT_SETTING_INT, 7, 1, 365}, {"storage_backfill_days", CHAT_SETTING_INT, 7, 1, 365},

190
src/radio/radio_audio.c

@ -48,7 +48,6 @@
/* ── VAD авто-PTT (отдельный режим, ручной PTT не меняется) ── */ /* ── VAD авто-PTT (отдельный режим, ручной PTT не меняется) ── */
#define RADIO_VAD_CONFIRM_WINDOWS 2 /* окон (32мс) выше порога для старта (~64мс) */ #define RADIO_VAD_CONFIRM_WINDOWS 2 /* окон (32мс) выше порога для старта (~64мс) */
#define RADIO_VAD_BUSY_TB 2000 /* 200мс: канал занят после последнего RX-кадра */ #define RADIO_VAD_BUSY_TB 2000 /* 200мс: канал занят после последнего RX-кадра */
#define RADIO_VAD_MAX_PREROLL_FRAMES 25 /* максимум pre-roll 500мс @ 20мс */
struct radio_pending { struct radio_pending {
uint8_t used; uint8_t used;
@ -88,15 +87,10 @@ static uint64_t g_last_stats_tb = 0;
static int g_vad_mode = 0; /* режим включён для текущей группы */ static int g_vad_mode = 0; /* режим включён для текущей группы */
static silero_vad_t* g_vad = NULL; /* детектор (16кГц), создаётся в start */ static silero_vad_t* g_vad = NULL; /* детектор (16кГц), создаётся в start */
static float g_vad_threshold = 0.5f; 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_hangover_ms = 200;
static int g_vad_manual_ptt = 0; /* PTT override: зажата — VAD не вмешивается */ static int g_vad_manual_ptt = 0; /* PTT override: зажата — VAD не вмешивается */
static float g_vad_win[RADIO_VAD_WINDOW_16K]; /* накопитель окон 16кГц */ static float g_vad_win[RADIO_VAD_WINDOW_16K]; /* накопитель окон 16кГц */
static int g_vad_win_len = 0; 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 struct radio_vad_fsm g_vad_fsm;
static uint64_t g_last_rx_tb = 0; /* последний принятый аудио-кадр (busy) */ 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; return NULL;
} }
static void radio_audio_post_talk(struct UASYNC* ua, uint64_t group_id, void (*fn)(void*));
/* ── lifecycle ── */ /* ── lifecycle ── */
int radio_audio_init(struct UTUN_INSTANCE* inst) { 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) { 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 (!group_id) { 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;
pthread_mutex_lock(&g_mtx); pthread_mutex_lock(&g_mtx);
if (g_active && g_group_id == group_id) { pthread_mutex_unlock(&g_mtx); return 0; } 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); 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; } 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); 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_application_set(enc, opus_mode ? OPUS_CODEC_APP_AUDIO : OPUS_CODEC_APP_VOIP);
opus_codec_encoder_bitrate_set(enc, opus_bitrate_kbps * 1000); opus_codec_encoder_bitrate_set(enc, opus_bitrate_kbps * 1000);
/* компрессор микрофона (AGC): конфиг из [chatserver], lookahead=0 — без задержки и хвоста. /* Компрессор TX: lookahead=0, без задержки и хвоста. */
* gain_smoothed не сбрасывается между PTT — адаптивная громкость запоминается. */
struct audio_compressor* ac = audio_compressor_create(); struct audio_compressor* ac = audio_compressor_create();
if (ac) { if (ac) {
audio_compressor_config_t accfg = {0}; 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)); audio_compressor_set_enabled(ac, chat_setting_get_int(g_inst, "radio_compressor_enabled", 1));
} }
/* VAD авто-PTT: отдельный режим (ручной PTT не меняется). Компрессор в VAD-режиме if (!ac) DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: compressor unavailable", RADIO_AUDIO_ID);
* байпасится — pre-roll передаётся как сырой PCM, кодируемый по срабатыванию. */
g_vad_mode = chat_setting_get_int(g_inst, "radio_vad_enabled", 0) ? 1 : 0; /* VAD анализирует текущие кадры без истории захвата. */
g_vad_mode = vad_enabled ? 1 : 0;
if (g_vad_mode) { if (g_vad_mode) {
g_vad_threshold = (float)chat_setting_get_int(g_inst, "radio_vad_threshold", 50) / 100.0f; 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_hangover_ms = chat_setting_get_int(g_inst, "radio_vad_hangover_ms", 200);
g_vad = silero_vad_create_default(); g_vad = silero_vad_create_default();
if (!g_vad) { if (!g_vad) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: silero_vad_create_default failed — VAD auto-PTT disabled", RADIO_AUDIO_ID); DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: silero_vad_create_default failed — VAD auto-PTT disabled", RADIO_AUDIO_ID);
g_vad_mode = 0; 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) { if (g_vad_mode) {
g_vad_preroll_len = 0;
g_vad_preroll_pos = 0;
g_vad_win_len = 0; g_vad_win_len = 0;
radio_vad_fsm_reset(&g_vad_fsm); radio_vad_fsm_reset(&g_vad_fsm);
} }
g_vad_manual_ptt = 0; g_vad_manual_ptt = 0;
g_last_rx_tb = 0;
g_encoder = enc; g_encoder = enc;
g_compressor = ac; g_compressor = ac;
g_capture_channels = capture_channels; g_capture_channels = capture_channels;
g_group_id = group_id; DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: capture started grp=%016llx ch=%d opus=%s bitrate=%dkbps compressor=%s vad=%d threshold=%.2f hangover=%dms",
g_active = 1; RADIO_AUDIO_ID, (unsigned long long)group_id, capture_channels, opus_mode ? "AUDIO" : "VOIP", opus_bitrate_kbps,
memset(g_sources, 0, sizeof(g_sources)); ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable", g_vad_mode, g_vad_threshold, g_vad_hangover_ms);
pthread_mutex_unlock(&g_mtx); 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; 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) { void radio_audio_stop(void) {
opus_codec_encoder_t* enc = NULL; radio_audio_capture_stop();
struct audio_compressor* ac = NULL;
silero_vad_t* vad = NULL;
int16_t* preroll = NULL;
int was_active = 0; int was_active = 0;
pthread_mutex_lock(&g_mtx); pthread_mutex_lock(&g_mtx);
@ -372,27 +384,12 @@ void radio_audio_stop(void) {
was_active = 1; was_active = 1;
g_active = 0; g_active = 0;
g_group_id = 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++) for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++)
if (g_sources[i].used) radio_src_free(&g_sources[i]); if (g_sources[i].used) radio_src_free(&g_sources[i]);
} }
pthread_mutex_unlock(&g_mtx); pthread_mutex_unlock(&g_mtx);
if (was_active) { 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); DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: stopped", RADIO_AUDIO_ID);
} }
} }
@ -413,11 +410,19 @@ int radio_audio_vad_mode(void) {
return m; 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) ── */ /* ── PTT (GUI → uasync) ── */
static void radio_audio_post_talk(struct UASYNC* ua, uint64_t group_id, void (*fn)(void*)) { 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)); 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->inst = g_inst;
a->group_id = group_id; a->group_id = group_id;
uasync_post(ua, fn, a); 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) { void radio_audio_talk_begin(uint64_t group_id) {
if (!g_inst || !g_inst->ua) return; if (!g_inst || !g_inst->ua) return;
pthread_mutex_lock(&g_mtx); 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; g_vad_manual_ptt = 1;
if (g_vad_fsm.talking) { if (g_vad_fsm.talking) {
/* VAD вела передачу — закрываем её FIN, затем открываем ручной burst */ /* VAD вела передачу — закрываем её FIN, затем открываем ручной burst */
radio_audio_post_talk(g_inst->ua, group_id, radio_talk_end_trampoline); radio_audio_post_talk(g_inst->ua, group_id, radio_talk_end_trampoline);
radio_vad_fsm_reset(&g_vad_fsm); 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); 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) { void radio_audio_talk_end(uint64_t group_id) {
if (!g_inst || !g_inst->ua) return; if (!g_inst || !g_inst->ua) return;
pthread_mutex_lock(&g_mtx); 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; g_vad_manual_ptt = 0;
pthread_mutex_unlock(&g_mtx);
radio_audio_post_talk(g_inst->ua, group_id, radio_talk_end_trampoline); radio_audio_post_talk(g_inst->ua, group_id, radio_talk_end_trampoline);
pthread_mutex_unlock(&g_mtx);
} }
/* ── VAD авто-PTT: helpers (под 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). */ * Вызывается при удержании g_mtx (использует g_encoder/g_capture_channels). */
static void radio_audio_encode_post(uint64_t group_id, const int16_t* frame, static void radio_audio_encode_post(uint64_t group_id, const int16_t* frame,
struct UASYNC* ua, struct UTUN_INSTANCE* inst) { 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]; uint8_t opus[RADIO_MAX_OPUS];
int l = opus_codec_encode(g_encoder, frame, RADIO_AUDIO_FRAME_SAMPLES, opus, (int)sizeof(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)); 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->inst = inst;
a->group_id = group_id; a->group_id = group_id;
a->len = (uint16_t)l; 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); 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). */ /* VAD-ветка feed_pcm: ресемпл → Silero → фильтр → старт/стоп/передача (держит g_mtx). */
static int radio_vad_feed(uint64_t group_id, const int16_t* pcm, int count) { static int radio_vad_feed(uint64_t group_id, const int16_t* pcm, int count) {
float out16k[RADIO_VAD_FRAME_SAMPLES_16K]; 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; inst = g_inst;
if (g_vad_manual_ptt) { if (g_vad_manual_ptt) {
/* PTT override: ведём pre-roll и просто передаём кадр (VAD не вмешивается) */ /* Ручной PTT: передаём текущий кадр, VAD не вмешивается. */
radio_vad_preroll_put(pcm, count);
radio_audio_encode_post(group_id, pcm, ua, inst); radio_audio_encode_post(group_id, pcm, ua, inst);
pthread_mutex_unlock(&g_mtx); pthread_mutex_unlock(&g_mtx);
return 0; return 0;
} }
radio_vad_preroll_put(pcm, count);
int started_this_frame = 0;
radio_vad_resample(pcm, g_capture_channels, out16k); radio_vad_resample(pcm, g_capture_channels, out16k);
int off = 0; int off = 0;
while (off < RADIO_VAD_FRAME_SAMPLES_16K) { 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; off += take;
if (g_vad_win_len == RADIO_VAD_WINDOW_16K) { if (g_vad_win_len == RADIO_VAD_WINDOW_16K) {
float prob = 0.0f; 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; g_vad_win_len = 0;
uint64_t now = get_time_tb(); uint64_t now = get_time_tb();
int busy = (g_last_rx_tb != 0 && (now - g_last_rx_tb) < RADIO_VAD_BUSY_TB) ? 1 : 0; 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, int action = radio_vad_fsm_update(&g_vad_fsm, prob, g_vad_threshold,
RADIO_VAD_CONFIRM_WINDOWS, RADIO_VAD_CONFIRM_WINDOWS,
(uint64_t)g_vad_hangover_ms * 10, busy, now); (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", DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: vad START prob=%.2f grp=%016llx",
RADIO_AUDIO_ID, prob, (unsigned long long)group_id); RADIO_AUDIO_ID, prob, (unsigned long long)group_id);
radio_audio_post_talk(ua, group_id, radio_talk_begin_trampoline); 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) { } else if (action == -1) {
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: vad STOP grp=%016llx", DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: vad STOP grp=%016llx",
RADIO_AUDIO_ID, (unsigned long long)group_id); 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); radio_audio_encode_post(group_id, pcm, ua, inst);
} }

9
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); void radio_audio_destroy(struct UTUN_INSTANCE* inst);
/* GUI-поток: начать прослушивание канала (encoder + реестр источников). /* GUI-поток: начать прослушивание канала (encoder + реестр источников).
* capture_channels=0 — только RX; 1/2 сохраняют запуск RX+TX для desktop.
* capture_channels — каналы захвата (1=моно, 2=стерео): задаёт GUI по фактической * capture_channels — каналы захвата (1=моно, 2=стерео): задаёт GUI по фактической
* возможности устройства (стерео недоступно → откат на моно). RX всегда стерео. */ * возможности устройства (стерео недоступно → откат на моно). RX всегда стерео. */
int radio_audio_start(uint64_t group_id, int capture_channels); 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-поток: прекратить прослушивание (освободить всё). */ /* GUI-поток: прекратить прослушивание (освободить всё). */
void radio_audio_stop(void); void radio_audio_stop(void);
@ -58,6 +64,9 @@ int radio_audio_active(void);
* и не гасить захват по PTT; ручной PTT остаётся override). */ * и не гасить захват по PTT; ручной PTT остаётся override). */
int radio_audio_vad_mode(void); int radio_audio_vad_mode(void);
/* Текущее состояние передачи: ручной PTT либо сработавший VAD. */
int radio_audio_transmitting(void);
/* uasync-поток: принятый Opus-кадр источника (декодируем + кладём в ring). */ /* uasync-поток: принятый Opus-кадр источника (декодируем + кладём в ring). */
void radio_audio_on_frame(struct UTUN_INSTANCE* inst, uint64_t group_id, 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, uint64_t src_node_id, uint16_t stream_id, uint16_t seq,

22
tools/chatgui-android/app/src/main/java/com/utun/chat/data/CallAudioRouter.kt

@ -78,6 +78,7 @@ class CallAudioRouter {
private val am: AudioManager = private val am: AudioManager =
ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
private var preferWiredHeadset = false
private var started = false private var started = false
private var deviceCallback: AudioDeviceCallback? = null private var deviceCallback: AudioDeviceCallback? = null
private var btHeadset: BluetoothHeadset? = null private var btHeadset: BluetoothHeadset? = null
@ -121,13 +122,15 @@ class CallAudioRouter {
LogManager.addLog("INFO", "CallAudioRouter", "stopped") LogManager.addLog("INFO", "CallAudioRouter", "stopped")
} }
fun setRoute(r: AudioRoute) { fun setRoute(r: AudioRoute, preferWired: Boolean = false) {
if (r == AudioRoute.HEADSET && !headsetAvailable) { if (r == AudioRoute.HEADSET && !headsetAvailable) {
LogManager.addLog("WARN", "CallAudioRouter", "setRoute HEADSET rejected: no headset available") LogManager.addLog("WARN", "CallAudioRouter", "setRoute HEADSET rejected: no headset available")
return return
} }
autoHeadset = false autoHeadset = false
if (route == r) return val preferenceChanged = preferWiredHeadset != preferWired
preferWiredHeadset = preferWired
if (route == r && !preferenceChanged) return
route = r route = r
preferredRoute = r preferredRoute = r
applyRoute(r) applyRoute(r)
@ -143,7 +146,10 @@ class CallAudioRouter {
/** Включить/выключить автоследование за гарнитурой (рация включает, звонок — нет). */ /** Включить/выключить автоследование за гарнитурой (рация включает, звонок — нет). */
fun setAutoHeadset(enabled: Boolean) { fun setAutoHeadset(enabled: Boolean) {
val resetPreference = enabled && preferWiredHeadset
autoHeadset = enabled autoHeadset = enabled
if (enabled) preferWiredHeadset = false
if (resetPreference && started && route == AudioRoute.HEADSET) applyRoute(route)
LogManager.addLog("INFO", "CallAudioRouter", "autoHeadset -> $enabled") LogManager.addLog("INFO", "CallAudioRouter", "autoHeadset -> $enabled")
} }
@ -183,7 +189,9 @@ class CallAudioRouter {
} }
AudioRoute.HEADSET -> { AudioRoute.HEADSET -> {
am.isSpeakerphoneOn = false 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 (bt != null) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
am.setCommunicationDevice(bt) am.setCommunicationDevice(bt)
@ -192,12 +200,13 @@ class CallAudioRouter {
} }
dev = bt dev = bt
inDev = btInputDevice() inDev = btInputDevice()
} else if (isBtHeadsetProfileConnected()) { } else if (!useWired && isBtHeadsetProfileConnected()) {
/* классический BT: SCO ещё не поднят — запускаем и ждём /* классический BT: SCO ещё не поднят — запускаем и ждём
* AudioDeviceCallback, который пере-применит маршрут */ * AudioDeviceCallback, который пере-применит маршрут */
startBluetoothSco() startBluetoothSco()
} else { } else {
val wired = findWiredDevice() stopBluetoothSco()
am.isBluetoothScoOn = false
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && wired != null) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && wired != null) {
am.setCommunicationDevice(wired) am.setCommunicationDevice(wired)
} else { } else {
@ -214,7 +223,8 @@ class CallAudioRouter {
currentInputDevice = inDev currentInputDevice = inDev
onInputDevice?.invoke(inDev) onInputDevice?.invoke(inDev)
LogManager.addLog("INFO", "CallAudioRouter", 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) { } catch (e: Exception) {
LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute $r failed: ${e.message}") LogManager.addLog("ERROR", "CallAudioRouter", "applyRoute $r failed: ${e.message}")
} }

14
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 soundEnabledKey = booleanPreferencesKey("sound_enabled")
private val mutedChannelsKey = stringSetPreferencesKey("muted_channels") private val mutedChannelsKey = stringSetPreferencesKey("muted_channels")
private val callAudioRouteKey = stringPreferencesKey("call_audio_route") 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 radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt")
private val radioOverlayKey = booleanPreferencesKey("radio_overlay") private val radioOverlayKey = booleanPreferencesKey("radio_overlay")
private val radioMicSourceKey = intPreferencesKey("radio_mic_source") 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_min_sleep_sec" -> context.dataStore.data.first()[chatStandbyMinSleepSec] ?: 15
key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000 key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000
key == "chat.group_autoconnect" -> context.dataStore.data.first()[chatGroupAutoconnect] ?: 1 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_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0
key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32 key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32
key == "radio.capture_stereo" -> context.dataStore.data.first()[radioCaptureStereoKey] ?: 0 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_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15
"chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000 "chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000
"chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1 "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_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0
"radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32 "radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32
"radio.capture_stereo" -> prefs[radioCaptureStereoKey] = value.toIntOrNull() ?: 0 "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 } context.dataStore.edit { prefs -> prefs[radioVolumePttKey] = enabled }
} }
val radioOverlayEnabled: Flow<Boolean> = context.dataStore.data.map { it[radioOverlayKey] ?: false }
@Volatile private var radioOverlayCached = false @Volatile private var radioOverlayCached = false
@Volatile private var radioOverlayLoaded = false @Volatile private var radioOverlayLoaded = false
@ -511,6 +522,9 @@ class ConfigProvider(private val context: Context) {
appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb") appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb")
appendLine("storage_maxsize_gb=$chatMaxGb") appendLine("storage_maxsize_gb=$chatMaxGb")
appendLine("opus_codec_preset=$chatOpusPreset") 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_mode=$radioOpusMode")
appendLine("radio_opus_bitrate=$radioOpusBitrate") appendLine("radio_opus_bitrate=$radioOpusBitrate")
appendLine("radio_capture_stereo=$radioCaptureStereo") appendLine("radio_capture_stereo=$radioCaptureStereo")

6
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 radioAudioFeed(groupId: Long, samples: ShortArray): Boolean = nativeRadioAudioFeed(groupId, samples)
fun radioAudioPull(groupId: Long, out: ShortArray): Int = nativeRadioAudioPull(groupId, out) fun radioAudioPull(groupId: Long, out: ShortArray): Int = nativeRadioAudioPull(groupId, out)
fun radioAudioStop() { nativeRadioAudioStop() } 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 radioVadMode(): Boolean = nativeRadioVadMode()
fun radioTalkBegin(groupId: Long) { nativeRadioTalkBegin(groupId) } fun radioTalkBegin(groupId: Long) { nativeRadioTalkBegin(groupId) }
fun radioTalkEnd(groupId: Long) { nativeRadioTalkEnd(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 nativeRadioAudioFeed(groupId: Long, samples: ShortArray): Boolean
private external fun nativeRadioAudioPull(groupId: Long, out: ShortArray): Int private external fun nativeRadioAudioPull(groupId: Long, out: ShortArray): Int
private external fun nativeRadioAudioStop() 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 nativeRadioVadMode(): Boolean
private external fun nativeRadioTalkBegin(groupId: Long) private external fun nativeRadioTalkBegin(groupId: Long)
private external fun nativeRadioTalkEnd(groupId: Long) private external fun nativeRadioTalkEnd(groupId: Long)

410
tools/chatgui-android/app/src/main/java/com/utun/chat/data/RadioAudioEngine.kt

@ -1,5 +1,8 @@
package com.utun.chat.data package com.utun.chat.data
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
import android.content.Context import android.content.Context
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioDeviceInfo import android.media.AudioDeviceInfo
@ -11,6 +14,8 @@ import android.media.MediaRecorder
import android.os.Build import android.os.Build
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import com.utun.chat.ChatApplication import com.utun.chat.ChatApplication
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
@ -20,7 +25,7 @@ import java.util.concurrent.Executors
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT). * Полудуплексный аудио-движок рации (аналог 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 * - play-поток: NativeLib.radioAudioPull (C: per-source jitter → декод → микшер) → AudioTrack.write
* *
* Воспроизведение тянет микшированный PCM всех говорящих из единого голосового стека * Воспроизведение тянет микшированный PCM всех говорящих из единого голосового стека
@ -36,8 +41,8 @@ class RadioAudioEngine {
/** Воспроизведение всегда стерео (моно-пиры апмиксуются Opus-декодером). */ /** Воспроизведение всегда стерео (моно-пиры апмиксуются Opus-декодером). */
const val PLAY_CHANNELS = 2 const val PLAY_CHANNELS = 2
/** Хвост передачи после отпускания PTT: продолжаем слать голос, затем FIN. */ const val RELEASE_HOLD_MS = 250L
const val RELEASE_HOLD_MS = 200L const val VAD_ACTIVE_MS = 15 * 60 * 1000L
/** Процессный синглтон: доступен из Activity и оверлей-сервиса. */ /** Процессный синглтон: доступен из Activity и оверлей-сервиса. */
@Volatile private var inst: RadioAudioEngine? = null @Volatile private var inst: RadioAudioEngine? = null
@ -58,13 +63,26 @@ class RadioAudioEngine {
private var playThread: Thread? = null private var playThread: Thread? = null
private var prevMode: Int = AudioManager.MODE_NORMAL private var prevMode: Int = AudioManager.MODE_NORMAL
private var prevSpeaker: Boolean = false private var prevSpeaker: Boolean = false
private var audioModeOwned = false
@Volatile private var active = false @Volatile private var active = false
@Volatile private var groupId = 0L @Volatile private var groupId = 0L
@Volatile private var talking = false @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 vadMode = false /* VAD авто-PTT: фидим PCM постоянно */
@Volatile private var captureChannels = 1 /* 1=моно, 2=стерео — по настройке захвата */
/** Маршрутизация аудио-выхода рации (динамик ↔ гарнитура), как в звонке. */ /** Маршрутизация аудио-выхода рации (динамик ↔ гарнитура), как в звонке. */
val router = CallAudioRouter() val router = CallAudioRouter()
@ -77,13 +95,23 @@ class RadioAudioEngine {
Thread(r, "radio-audio-io").apply { isDaemon = true } Thread(r, "radio-audio-io").apply { isDaemon = true }
} }
/* Отложенный FIN после релиза PTT (все talkBegin/talkEnd зовутся с main-потока). */ /* Таймер окна VAD; управление ресурсами всегда на io-потоке. */
private val mainHandler = Handler(Looper.getMainLooper()) 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<Boolean> = _vadActiveFlow
fun isActive(): Boolean = active fun isActive(): Boolean = active
fun isTalking(): Boolean = talking fun isTalking(): Boolean = _talkingFlow.value
/** Текущий маршрут выхода рации (SPEAKER/HEADSET). */ /** Текущий маршрут выхода рации (SPEAKER/HEADSET). */
fun route(): AudioRoute = router.route fun route(): AudioRoute = router.route
@ -95,61 +123,63 @@ class RadioAudioEngine {
fun headsetAvailable(): Boolean = router.headsetAvailable 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. */ * Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */
fun start(group: Long) { fun start(group: Long) {
if (active && groupId == group) return if (active && groupId == group) return
mainHandler.removeCallbacks(releaseTalk)
val version = ++generation
active = true active = true
groupId = group groupId = group
talking = false requestedTalk = false
io.execute { startBlocking(group) } _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() { fun stop() {
if (!active) return mainHandler.removeCallbacks(releaseTalk)
active = false active = false
talking = false requestedTalk = false
_talkingFlow.value = false _talkingFlow.value = false
mainHandler.removeCallbacks(releaseRunnable) ++generation
io.execute { stopBlocking() } io.execute { releaseLocked() }
}
/* ── 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()
} }
/* Полный перезапуск аудио на том же канале — применение новых настроек на лету. */ /** Смена настроек перезапускает только захват, сохраняя приём и окно VAD. */
fun reconfigure() { fun reconfigure() {
val g = groupId val version = generation
if (g == 0L) return
io.execute { io.execute {
if (groupId != g) return@execute if (!active || generation != version || audioTrack == null) return@execute
if (talking) finishTalk() stopCapture()
if (active) { active = false; releaseLocked(false) } loadCaptureSettings()
active = true updateCapture()
setupAudio()
} }
} }
/* Инициализация C-движка + AudioRecord/AudioTrack + потоков (только io-поток). */ /* Приём создаётся независимо от микрофона (только io-поток). */
private fun setupAudio() { private fun setupAudio() {
val group = groupId loadCaptureSettings()
val group = audioGroup
val ctx = ChatApplication.instance val ctx = ChatApplication.instance
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
prevMode = am.mode prevMode = am.mode
prevSpeaker = am.isSpeakerphoneOn prevSpeaker = am.isSpeakerphoneOn
audioModeOwned = true
try { try {
am.mode = AudioManager.MODE_IN_COMMUNICATION am.mode = AudioManager.MODE_IN_COMMUNICATION
} catch (e: Exception) { } catch (e: Exception) {
@ -160,24 +190,12 @@ class RadioAudioEngine {
router.fallbackRoute = AudioRoute.SPEAKER router.fallbackRoute = AudioRoute.SPEAKER
router.start() router.start()
// ── AudioRecord (захват) с откатом стерео→моно ── if (!NativeLib.radioAudioStart(group, 0)) {
val (rec, capCh) = createAudioRecord()
if (rec == null) {
router.stop(); restoreAudioMode(); active = false; return
}
captureChannels = capCh
if (!NativeLib.radioAudioStart(group, capCh)) {
rec.release()
router.stop(); restoreAudioMode(); active = false 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 return
} }
/* VAD авто-PTT: сервисный слой решил, что режим включён — фидим PCM постоянно. */
vadMode = NativeLib.radioVadMode()
if (vadMode) LogManager.addLog("INFO", "RadioAudio", "VAD auto-PTT enabled group=$group")
// ── AudioTrack (воспроизведение, всегда стерео) ── // ── AudioTrack (воспроизведение, всегда стерео) ──
val trackMin = AudioTrack.getMinBufferSize( val trackMin = AudioTrack.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT) 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) trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
} catch (e: Exception) { } catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}") LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}")
rec.release()
NativeLib.radioAudioStop(); router.stop(); restoreAudioMode(); active = false; return NativeLib.radioAudioStop(); router.stop(); restoreAudioMode(); active = false; return
} }
audioRecord = rec
audioTrack = track audioTrack = track
micDead = false
/* Прямое роутирование трека на выбранное устройство (обход глючной глобальной /* Прямое роутирование трека на выбранное устройство (обход глючной глобальной
* маршрутизации), как в CallAudioEngine. */ * маршрутизации), как в CallAudioEngine. */
@ -211,27 +226,33 @@ class RadioAudioEngine {
if (ChatApplication.instance.configProvider.getRadioInputSource() == INPUT_AUTO) reconfigure() if (ChatApplication.instance.configProvider.getRadioInputSource() == INPUT_AUTO) reconfigure()
} }
rec.startRecording()
track.play() track.play()
playbackRunning = true
captureThread = Thread { captureLoop() }.apply {
priority = Thread.MAX_PRIORITY; start()
}
playThread = Thread { playLoop() }.apply { playThread = Thread { playLoop() }.apply {
priority = Thread.MAX_PRIORITY; start() 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 с маршрутизацией входного устройства: встроенный микрофон /* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон
* (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC), * (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC),
* авто — системный дефолт. Возвращает (record, каналы). */ * авто — системный дефолт. Возвращает (record, каналы). */
private fun createAudioRecord(): Pair<AudioRecord?, Int> { private fun createAudioRecord(): Pair<AudioRecord?, Int> {
val ctx = ChatApplication.instance val inputSource = configuredInput
val inputSource = ctx.configProvider.getRadioInputSource() val pref = configuredMicSource
val pref = ctx.configProvider.getRadioMicSource() val wantStereo = configuredStereo
val wantStereo = ctx.configProvider.getRadioCaptureStereo()
/* гарнитурный вход: моно + VOICE_COMMUNICATION (звонковый тракт «запитывает» /* гарнитурный вход: моно + VOICE_COMMUNICATION (звонковый тракт «запитывает»
* микрофон гарнитуры; AudioSource.MIC на ней даёт тишину). AUTO: следуем за * микрофон гарнитуры; AudioSource.MIC на ней даёт тишину). AUTO: следуем за
@ -272,6 +293,10 @@ class RadioAudioEngine {
for (mask in masks) { for (mask in masks) {
val channels = if (mask == AudioFormat.CHANNEL_IN_STEREO) 2 else 1 val channels = if (mask == AudioFormat.CHANNEL_IN_STEREO) 2 else 1
val recMin = AudioRecord.getMinBufferSize(SAMPLE_RATE, mask, AudioFormat.ENCODING_PCM_16BIT) 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) val bufSize = (recMin * 2).coerceAtLeast(FRAME_SAMPLES * channels * 4)
for (src in sources) { for (src in sources) {
val rec = tryCreateRecord(src, bufSize, mask) val rec = tryCreateRecord(src, bufSize, mask)
@ -298,7 +323,7 @@ class RadioAudioEngine {
LogManager.addLog("ERROR", "RadioAudio", "setPreferredDevice(record) threw: ${e.message}") LogManager.addLog("ERROR", "RadioAudio", "setPreferredDevice(record) threw: ${e.message}")
false 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? { private fun tryCreateRecord(source: Int, bufferSize: Int, channelMask: Int): AudioRecord? {
@ -316,89 +341,224 @@ class RadioAudioEngine {
return rec return rec
} }
private fun stopBlocking() { private fun screenOn(): Boolean =
releaseLocked(true) (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. */ /** Выбор захвата: PTT либо включённый VAD в пределах окна при горящем экране. */
private fun releaseLocked(resetGroup: Boolean) { private fun updateCapture() {
captureThread?.join(1000); captureThread = null mainHandler.removeCallbacks(vadTimeout)
playThread?.join(1000); playThread = null 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() } private fun startCapture(vad: Boolean): Boolean {
audioRecord = null val startNs = SystemClock.elapsedRealtimeNanos()
audioTrack?.apply { try { stop() } catch (_: Exception) {}; release() } val (rec, channels) = createAudioRecord()
audioTrack = null 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() NativeLib.radioAudioStop()
router.onOutputDevice = null router.onOutputDevice = null
router.onInputDevice = null router.onInputDevice = null
router.stop() router.stop()
restoreAudioMode() restoreAudioMode()
if (resetGroup) {
groupId = 0L
LogManager.addLog("INFO", "RadioAudio", "stopped")
}
} }
/** PTT зажата — начинаем передачу. */
fun talkBegin() { fun talkBegin() {
if (!active) return if (!active) return
mainHandler.removeCallbacks(releaseRunnable) mainHandler.removeCallbacks(releaseTalk)
if (talking) return if (requestedTalk) return
if (micDead) { talkRequestNs = SystemClock.elapsedRealtimeNanos()
LogManager.addLog("WARN", "RadioAudio", "talkBegin but mic is dead (background capture stopped)") requestedTalk = true
}
talking = true
_talkingFlow.value = 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() { fun talkEnd() {
if (!active || !talking) return if (!active || !requestedTalk) return
mainHandler.removeCallbacks(releaseRunnable) mainHandler.removeCallbacks(releaseTalk)
mainHandler.postDelayed(releaseRunnable, RELEASE_HOLD_MS) mainHandler.postDelayed(releaseTalk, RELEASE_HOLD_MS)
LogManager.addLog("DEBUG", "RadioAudio", "talk release scheduled +${RELEASE_HOLD_MS}ms") LogManager.addLog("DEBUG", "RadioAudio", "PTT release scheduled +${RELEASE_HOLD_MS}ms")
}
/** Немедленное завершение передачи без хвоста (drag кнопки рации). */
fun talkEndImmediate() {
if (!active || !talking) return
mainHandler.removeCallbacks(releaseRunnable)
finishTalk()
} }
private fun finishTalk() { private fun finishTalk() {
if (!talking) return if (!requestedTalk) return
talking = false requestedTalk = false
_talkingFlow.value = false _talkingFlow.value = false
NativeLib.radioTalkEnd(groupId) val version = generation
LogManager.addLog("DEBUG", "RadioAudio", "talk finished (FIN)") io.execute { if (active && generation == version) updateCapture() }
} }
private fun captureLoop() { private fun captureLoop(rec: AudioRecord, group: Long, ch: Int) {
val ch = captureChannels
val frame = FRAME_SAMPLES * ch val frame = FRAME_SAMPLES * ch
val buf = ShortArray(frame) val buf = ShortArray(frame)
var frameN = 0L var frameN = 0L
while (active) { var reportedRequestNs = 0L
val rec = audioRecord ?: break while (captureRunning) {
if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) { if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
if (active) { if (captureRunning) {
micDead = true
LogManager.addLog("WARN", "RadioAudio", "capture loop: recordingState=${rec.recordingState}, mic stopped") LogManager.addLog("WARN", "RadioAudio", "capture loop: recordingState=${rec.recordingState}, mic stopped")
} }
break break
} }
var read = 0 var read = 0
while (read < frame) { while (read < frame) {
if (!active) return if (!captureRunning) return
val n = rec.read(buf, read, frame - read) val n = rec.read(buf, read, frame - read)
if (n <= 0) { if (n <= 0) {
if (active) { if (captureRunning) {
micDead = true
LogManager.addLog("WARN", "RadioAudio", "capture loop: read()=$n, mic lost") LogManager.addLog("WARN", "RadioAudio", "capture loop: read()=$n, mic lost")
} }
return return
@ -408,11 +568,19 @@ class RadioAudioEngine {
/* диагностика крос-девайс входа/выхода: куда реально роутится захват + уровень сигнала */ /* диагностика крос-девайс входа/выхода: куда реально роутится захват + уровень сигнала */
if (frameN < 10 || frameN % 100L == 0L) { if (frameN < 10 || frameN % 100L == 0L) {
LogManager.addLog("DEBUG", "RadioAudio", 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++ frameN++
if ((talking || vadMode) && !NativeLib.radioAudioFeed(groupId, buf)) { val vadAllowed = vadMode && screenInteractive && SystemClock.elapsedRealtime() < vadDeadline
LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false") 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") LogManager.addLog("DEBUG", "RadioAudio", "capture loop done")
@ -432,9 +600,9 @@ class RadioAudioEngine {
val silence = ShortArray(frame) val silence = ShortArray(frame)
val frameNs = FRAME_MS * 1_000_000L /* 20мс в наносекундах */ val frameNs = FRAME_MS * 1_000_000L /* 20мс в наносекундах */
var nextNs = System.nanoTime() var nextNs = System.nanoTime()
while (active) { while (playbackRunning) {
val track = audioTrack ?: return 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 > 0) track.write(buf, 0, n)
if (n < frame) track.write(silence, 0, frame - n) if (n < frame) track.write(silence, 0, frame - n)
@ -452,6 +620,8 @@ class RadioAudioEngine {
} }
private fun restoreAudioMode() { private fun restoreAudioMode() {
if (!audioModeOwned) return
audioModeOwned = false
try { try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.isSpeakerphoneOn = prevSpeaker am.isSpeakerphoneOn = prevSpeaker

174
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.PackageManager
import android.content.pm.ServiceInfo import android.content.pm.ServiceInfo
import android.graphics.PixelFormat import android.graphics.PixelFormat
import android.graphics.Point
import android.graphics.drawable.GradientDrawable import android.graphics.drawable.GradientDrawable
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
@ -24,16 +25,24 @@ import android.widget.TextView
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.view.doOnLayout
import com.utun.chat.ChatApplication
import kotlin.math.abs 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.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
/** /**
* Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop. * Оверлей PTT поверх других приложений: плавающая круглая кнопка, drag-and-drop.
* Удержание > PTT_HOLD_MS активирует передачу (RadioAudioEngine.talkBegin/talkEnd), * Касание сразу активирует передачу (RadioAudioEngine.talkBegin/talkEnd),
* отпускание — завершает. Двойной тап фиксирует передачу (latch) без удержания, * отпускание — завершает с хвостом 250мс. Двойной тап фиксирует передачу (latch) без удержания,
* повторный двойной тап или drag — отбой. Работает пока включена опция * повторный двойной тап или drag — отбой. Работает пока включена опция
* «PTT поверх приложений» и разрешено «поверх других приложений» (SYSTEM_ALERT_WINDOW). * «PTT поверх приложений» и разрешено «поверх других приложений» (SYSTEM_ALERT_WINDOW).
* При включённом VAD сервис сохраняет доступ к микрофону без плавающей кнопки.
*/ */
class RadioOverlayService : Service() { class RadioOverlayService : Service() {
@ -43,7 +52,16 @@ class RadioOverlayService : Service() {
const val COLOR_IDLE = 0xFF81C784.toInt() const val COLOR_IDLE = 0xFF81C784.toInt()
const val COLOR_TALKING = 0xFFE53935.toInt() const val COLOR_TALKING = 0xFFE53935.toInt()
const val DRAG_LOCK_MS = 300L 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) private val _isShown = MutableStateFlow(false)
val isShown: StateFlow<Boolean> = _isShown val isShown: StateFlow<Boolean> = _isShown
@ -51,12 +69,20 @@ class RadioOverlayService : Service() {
/* Гонка start/stop: stop() до вызова startForeground() роняет FGS. /* Гонка start/stop: stop() до вызова startForeground() роняет FGS.
* Пока сервис стартует (starting) — взводим pendingStop, а onCreate * Пока сервис стартует (starting) — взводим pendingStop, а onCreate
* после startForeground() сам вызовет stopSelf(). */ * после startForeground() сам вызовет stopSelf(). */
@Volatile private var running = false
@Volatile private var starting = false @Volatile private var starting = false
@Volatile private var pendingStop = false @Volatile private var pendingStop = false
fun canDrawOverlays(ctx: Context): Boolean = fun canDrawOverlays(ctx: Context): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.M || Settings.canDrawOverlays(ctx) 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) { fun start(ctx: Context) {
LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.start isShown=${_isShown.value} t=${System.currentTimeMillis()}") LogManager.addLog("DEBUG", "RADIO_DBG", "RadioOverlayService.start isShown=${_isShown.value} t=${System.currentTimeMillis()}")
pendingStop = false pendingStop = false
@ -66,13 +92,16 @@ class RadioOverlayService : Service() {
LogManager.addLog("ERROR", "RadioOverlay", "FOREGROUND_SERVICE_MICROPHONE not granted — background mic will be muted") LogManager.addLog("ERROR", "RadioOverlay", "FOREGROUND_SERVICE_MICROPHONE not granted — background mic will be muted")
return return
} }
if (starting || _isShown.value) return if (starting) return
starting = true starting = !running
val i = Intent(ctx, RadioOverlayService::class.java) val i = Intent(ctx, RadioOverlayService::class.java)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) try {
ctx.startForegroundService(i) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) ctx.startForegroundService(i)
else else ctx.startService(i)
ctx.startService(i) } catch (e: Exception) {
starting = false
LogManager.addLog("ERROR", "RadioOverlay", "service start failed: ${e.message}")
}
} }
fun stop(ctx: Context) { fun stop(ctx: Context) {
@ -89,6 +118,9 @@ class RadioOverlayService : Service() {
private var overlayView: View? = null private var overlayView: View? = null
private var params: WindowManager.LayoutParams? = null private var params: WindowManager.LayoutParams? = null
private var talking = false 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 override fun onBind(intent: Intent?): IBinder? = null
@ -103,14 +135,12 @@ class RadioOverlayService : Service() {
startForeground(NOTIF_ID, buildNotification()) startForeground(NOTIF_ID, buildNotification())
} }
running = true
currentService = this
windowManager = getSystemService(WINDOW_SERVICE) as WindowManager windowManager = getSystemService(WINDOW_SERVICE) as WindowManager
if (!canDrawOverlays(this)) { serviceScope.launch {
LogManager.addLog("WARN", "RadioOverlay", "SYSTEM_ALERT_WINDOW not granted — stopping") RadioAudioEngine.instance().talkingFlow.collect { updateButtonColor() }
starting = false
stopSelf()
return
} }
addOverlay()
starting = false starting = false
if (pendingStop) { if (pendingStop) {
pendingStop = false 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() { private fun createChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(CHANNEL_ID, "Рация PTT", NotificationManager.IMPORTANCE_LOW).apply { 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) private fun buildNotification() = NotificationCompat.Builder(this, CHANNEL_ID)
.setSmallIcon(android.R.drawable.ic_btn_speak_now) .setSmallIcon(android.R.drawable.ic_btn_speak_now)
.setContentTitle("Рация активна") .setContentTitle("Рация активна")
.setContentText("PTT-кнопка поверх приложений") .setContentText("Рация: PTT / автоматическая передача по голосу")
.setOngoing(true) .setOngoing(true)
.build() .build()
@ -146,7 +195,7 @@ class RadioOverlayService : Service() {
} }
} }
val touchSlop = (ViewConfiguration.get(this).scaledTouchSlop * 2.5f).toInt() 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( val lp = WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT, WindowManager.LayoutParams.WRAP_CONTENT,
@ -158,8 +207,9 @@ class RadioOverlayService : Service() {
PixelFormat.TRANSLUCENT PixelFormat.TRANSLUCENT
).apply { ).apply {
width = dp(67); height = dp(67) width = dp(67); height = dp(67)
gravity = Gravity.TOP or Gravity.START gravity = Gravity.TOP or Gravity.LEFT
x = 16; y = 200 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 { v.setOnTouchListener(object : View.OnTouchListener {
private var startX = 0; private var startY = 0 private var startX = 0; private var startY = 0
@ -168,7 +218,8 @@ class RadioOverlayService : Service() {
private var dragAllowed = true private var dragAllowed = true
private var latched = false private var latched = false
private var lastTapUpTime = 0L 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 { override fun onTouch(view: View, event: MotionEvent): Boolean {
when (event.action) { when (event.action) {
@ -179,8 +230,13 @@ class RadioOverlayService : Service() {
touchY = event.rawY touchY = event.rawY
dragging = false dragging = false
dragAllowed = true dragAllowed = true
view.removeCallbacks(talkBeginRunnable) wasLatched = latched
view.postDelayed(talkBeginRunnable, PTT_HOLD_MS) secondTap = lastTapUpTime != 0L && event.eventTime - lastTapUpTime <= doubleTapTimeout
if (wasLatched) {
latched = false
lastTapUpTime = 0L
endTalk()
} else beginTalk()
return true return true
} }
MotionEvent.ACTION_MOVE -> { MotionEvent.ACTION_MOVE -> {
@ -193,9 +249,10 @@ class RadioOverlayService : Service() {
dragAllowed = false dragAllowed = false
} else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) { } else if (abs(event.rawX - touchX) > touchSlop || abs(event.rawY - touchY) > touchSlop) {
dragging = true dragging = true
view.removeCallbacks(talkBeginRunnable) positionDragged = true
latched = false latched = false
endTalkImmediate() lastTapUpTime = 0L
endTalk()
lp.x = startX + (event.rawX - touchX).toInt() lp.x = startX + (event.rawX - touchX).toInt()
lp.y = startY + (event.rawY - touchY).toInt() lp.y = startY + (event.rawY - touchY).toInt()
wm.updateViewLayout(v, lp) wm.updateViewLayout(v, lp)
@ -204,26 +261,20 @@ class RadioOverlayService : Service() {
return true return true
} }
MotionEvent.ACTION_UP -> { MotionEvent.ACTION_UP -> {
view.removeCallbacks(talkBeginRunnable) val tapped = !dragging && event.eventTime - event.downTime <= RadioAudioEngine.RELEASE_HOLD_MS
val now = SystemClock.uptimeMillis() if (!wasLatched && tapped && secondTap) {
val isDouble = lastTapUpTime != 0L && (now - lastTapUpTime) <= doubleTapTimeout
if (latched) {
latched = false
lastTapUpTime = 0L
endTalk()
} else if (isDouble) {
latched = true latched = true
lastTapUpTime = 0L lastTapUpTime = 0L
beginTalk()
} else { } else {
lastTapUpTime = now lastTapUpTime = if (!wasLatched && tapped) event.eventTime else 0L
endTalk() endTalk()
} }
return true return true
} }
MotionEvent.ACTION_CANCEL -> { MotionEvent.ACTION_CANCEL -> {
view.removeCallbacks(talkBeginRunnable) latched = false
if (!latched) endTalk() lastTapUpTime = 0L
endTalk()
return true return true
} }
} }
@ -233,46 +284,73 @@ class RadioOverlayService : Service() {
wm.addView(v, lp) wm.addView(v, lp)
overlayView = v overlayView = v
params = lp params = lp
positionDragged = false
placeAtDefaultCenter()
_isShown.value = true _isShown.value = true
LogManager.addLog("INFO", "RadioOverlay", "overlay shown") 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() { private fun beginTalk() {
if (talking) return if (talking) return
talking = true talking = true
updateButtonColor()
RadioAudioEngine.instance().talkBegin() RadioAudioEngine.instance().talkBegin()
updateButtonColor()
} }
private fun endTalk() { private fun endTalk() {
endTalkInternal(immediate = false)
}
private fun endTalkImmediate() {
endTalkInternal(immediate = true)
}
private fun endTalkInternal(immediate: Boolean) {
if (!talking) return if (!talking) return
talking = false talking = false
RadioAudioEngine.instance().talkEnd()
updateButtonColor() updateButtonColor()
if (immediate) RadioAudioEngine.instance().talkEndImmediate()
else RadioAudioEngine.instance().talkEnd()
} }
private fun updateButtonColor() { private fun updateButtonColor() {
(overlayView?.background as? GradientDrawable)?.setColor( (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() private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
override fun onDestroy() { override fun onDestroy() {
serviceScope.cancel()
endTalk() endTalk()
overlayView?.let { windowManager?.removeView(it) } overlayView?.let { windowManager?.removeView(it) }
overlayView = null overlayView = null
starting = false starting = false
running = false
if (currentService === this) currentService = null
_isShown.value = false _isShown.value = false
LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed") LogManager.addLog("INFO", "RadioOverlay", "overlay destroyed")
super.onDestroy() super.onDestroy()

254
tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/ChatScreen.kt

@ -1,12 +1,16 @@
package com.utun.chat.ui.screens package com.utun.chat.ui.screens
import android.content.ContentResolver import android.content.ContentResolver
import android.content.Intent
import android.provider.Settings
import android.net.Uri import android.net.Uri
import android.os.SystemClock import android.os.SystemClock
import android.provider.OpenableColumns import android.provider.OpenableColumns
import android.view.ViewConfiguration import android.view.ViewConfiguration
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts 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.clickable
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.horizontalScroll 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.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack 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.Link
import androidx.compose.material.icons.filled.People import androidx.compose.material.icons.filled.People
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment 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.Modifier
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color 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.platform.LocalContext
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp 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.AudioRoute
import com.utun.chat.data.Channel import com.utun.chat.data.Channel
import com.utun.chat.data.Message import com.utun.chat.data.Message
@ -44,7 +61,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
@ -70,6 +86,8 @@ fun ChatScreen(
val recordingDurationMs by viewModel.recordingDurationMs.collectAsState() val recordingDurationMs by viewModel.recordingDurationMs.collectAsState()
var recordingLevel by remember { mutableStateOf(0f) } var recordingLevel by remember { mutableStateOf(0f) }
val context = LocalContext.current val context = LocalContext.current
val view = LocalView.current
val pttCenterOffset = with(LocalDensity.current) { 60.dp.toPx() }
val scope = rememberCoroutineScope() 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( LazyColumn(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
state = listState, state = listState,
@ -279,9 +306,7 @@ fun ChatScreen(
} }
} }
/* Круглая PTT-кнопка рации: удержание > PTT_HOLD_MS — говорим, двойной тап — /* Передача сразу при касании; двойной тап фиксирует, следующий тап завершает. */
* фиксирует передачу (latch) без удержания, следующий тап/двойной тап — отбой. */
private const val PTT_HOLD_MS = 250L
@Composable @Composable
private fun RadioPttButton( private fun RadioPttButton(
@ -290,7 +315,7 @@ private fun RadioPttButton(
onTalkBegin: () -> Unit, onTalkBegin: () -> Unit,
onTalkEnd: () -> 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 latched by remember { mutableStateOf(false) }
var lastTapUp by remember { mutableStateOf(0L) } var lastTapUp by remember { mutableStateOf(0L) }
@ -298,51 +323,46 @@ private fun RadioPttButton(
modifier = modifier modifier = modifier
.size(96.dp) .size(96.dp)
.pointerInput(Unit) { .pointerInput(Unit) {
awaitPointerEventScope { try {
while (true) { awaitPointerEventScope {
val down = awaitFirstDown(requireUnconsumed = false) while (true) {
val downId = down.id val down = awaitFirstDown(requireUnconsumed = false)
down.consume() val downId = down.id
down.consume()
val now = SystemClock.uptimeMillis() val now = SystemClock.uptimeMillis()
val isSecondTap = lastTapUp != 0L && (now - lastTapUp) <= doubleTapTimeout val isSecondTap = lastTapUp != 0L && (now - lastTapUp) <= doubleTapTimeout
/* быстрое отпускание (до PTT_HOLD_MS) — это тап */ val wasLatched = latched
var tapped = false if (wasLatched) {
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) {
latched = false latched = false
lastTapUp = 0L lastTapUp = 0L
onTalkEnd() onTalkEnd()
} else if (isSecondTap) { } else onTalkBegin()
latched = true
lastTapUp = 0L
onTalkBegin()
} else {
lastTapUp = SystemClock.uptimeMillis()
}
continue
}
/* удержание — говорим, пока не отпустят */ var released = false
onTalkBegin() try {
while (true) { while (true) {
val ev = awaitPointerEvent() val ev = awaitPointerEvent()
val c = ev.changes.firstOrNull { it.id == downId } ?: break val c = ev.changes.firstOrNull { it.id == downId } ?: break
c.consume() c.consume()
if (!c.pressed) break 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, shape = CircleShape,
@ -355,7 +375,7 @@ private fun RadioPttButton(
} }
} }
/* Строка аудиоисточников рации: вход (авто/встроенный/проводная/беспроводная) + выход. */ /* Единый выбор входа/выхода и переключатель плавающей PTT-кнопки. */
@Composable @Composable
private fun RadioSourceBar(viewModel: ChatViewModel) { private fun RadioSourceBar(viewModel: ChatViewModel) {
val radioAuto by viewModel.radioAuto.collectAsState() val radioAuto by viewModel.radioAuto.collectAsState()
@ -363,61 +383,125 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
val wiredInputAvailable by viewModel.wiredInputAvailable.collectAsState() val wiredInputAvailable by viewModel.wiredInputAvailable.collectAsState()
val btInputAvailable by viewModel.btInputAvailable.collectAsState() val btInputAvailable by viewModel.btInputAvailable.collectAsState()
val radioRoute by viewModel.radioRoute.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)) { Surface(color = Color(0xFF0F1B22)) {
Row( Row(
modifier = Modifier modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 12.dp, vertical = 12.dp),
.fillMaxWidth() horizontalArrangement = Arrangement.spacedBy(8.dp),
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
RadioSourceGroupLabel("Вход") Row(
RadioSourceChip("Авто", radioAuto, { viewModel.setRadioAuto() }) modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()),
RadioSourceChip("Встроенный", radioInputSource == RadioAudioEngine.INPUT_BUILTIN, horizontalArrangement = Arrangement.spacedBy(8.dp),
{ viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN) }) verticalAlignment = Alignment.CenterVertically
if (wiredInputAvailable) { ) {
RadioSourceChip("Проводная", radioInputSource == RadioAudioEngine.INPUT_WIRED, val currentDeviceIcon = if (radioRoute == AudioRoute.HEADSET) Icons.Default.HeadsetMic else Icons.AutoMirrored.Filled.VolumeUp
{ viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED) }) RadioControlButton(Icons.Default.AutoMode, "Авто: ${if (radioRoute == AudioRoute.HEADSET) "гарнитура" else "динамик"}",
} radioAuto, centerIcon = currentDeviceIcon) {
if (btInputAvailable) { viewModel.setRadioAuto()
RadioSourceChip("Беспроводная", radioInputSource == RadioAudioEngine.INPUT_BT, }
{ viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT) }) 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("Выход") VerticalDivider(modifier = Modifier.height(32.dp), color = Color(0xFF344550))
RadioSourceChip("Динамик", radioRoute == AudioRoute.SPEAKER, RadioControlButton(null, "Плавающая PTT-кнопка", overlayEnabled, floating = true) {
{ viewModel.setRadioRoute(AudioRoute.SPEAKER) }) if (!overlayEnabled && !RadioOverlayService.canDrawOverlays(context)) {
if (radioHeadsetAvailable) { try {
RadioSourceChip("Гарнитура", radioRoute == AudioRoute.HEADSET, overlayPermission.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
{ viewModel.setRadioRoute(AudioRoute.HEADSET) }) 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 @Composable
private fun RadioSourceChip( private fun RadioControlButton(
label: String, icon: ImageVector?, label: String, active: Boolean,
active: Boolean, centerIcon: ImageVector? = null, bluetooth: Boolean = false, floating: Boolean = false, onClick: () -> Unit
onClick: () -> Unit
) { ) {
Surface( IconToggleButton(
modifier = Modifier.clickable(onClick = onClick), checked = active,
shape = RoundedCornerShape(16.dp), onCheckedChange = { onClick() },
color = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D) modifier = Modifier.size(48.dp),
colors = IconButtonDefaults.iconToggleButtonColors(
containerColor = Color(0xFF24323D), contentColor = Color(0xFF9BA6AD),
checkedContainerColor = Color(0xFF1F6FEB), checkedContentColor = Color.White)
) { ) {
Text( if (floating) {
label, RadioFloatingIcon(label)
color = if (active) Color.White else Color(0xFF9BA6AD), return@IconToggleButton
fontSize = 12.sp, }
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp) 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 @Composable
private fun RadioSourceGroupLabel(text: String) { private fun RadioFloatingIcon(label: String) {
Text(text, color = Color(0xFF5A6A75), fontSize = 11.sp, fontWeight = FontWeight.Bold) 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)
}
}
} }

64
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", "config", "tun", "routing", "timers", "bgp", "chat",
"socket", "control", "dump", "traffic", "debug", "general", "socket", "control", "dump", "traffic", "debug", "general",
"nat", "keepalive", "media", "etcp_route", "etcp_dump", "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") private val PROTOCOLS = listOf("tcp", "udp")
@ -481,6 +481,45 @@ private fun RadioSettingsTab() {
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()), modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp) 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 ── */ /* ── Volume-кнопки PTT ── */
var volumePtt by remember { mutableStateOf(provider.getRadioVolumePtt()) } var volumePtt by remember { mutableStateOf(provider.getRadioVolumePtt()) }
Row(verticalAlignment = Alignment.CenterVertically) { Row(verticalAlignment = Alignment.CenterVertically) {
@ -522,11 +561,10 @@ private fun RadioSettingsTab() {
} }
Switch(checked = overlayEnabled && canOverlay, enabled = canOverlay, onCheckedChange = { Switch(checked = overlayEnabled && canOverlay, enabled = canOverlay, onCheckedChange = {
overlayEnabled = it overlayEnabled = it
scope.launch { provider.setRadioOverlay(it) } scope.launch {
if (it && RadioAudioEngine.instance().isActive()) provider.setRadioOverlay(it)
RadioOverlayService.start(ChatApplication.instance) RadioOverlayService.sync(ChatApplication.instance)
else }
RadioOverlayService.stop(ChatApplication.instance)
}) })
} }
@ -547,8 +585,10 @@ private fun RadioSettingsTab() {
micSources.forEachIndexed { i, n -> micSources.forEachIndexed { i, n ->
DropdownMenuItem(text = { Text(n) }, onClick = { DropdownMenuItem(text = { Text(n) }, onClick = {
micSource = i micSource = i
scope.launch { provider.setRadioMicSource(i) } scope.launch {
RadioAudioEngine.instance().reconfigure() provider.setRadioMicSource(i)
RadioAudioEngine.instance().reconfigure()
}
micExpanded = false micExpanded = false
}) })
} }
@ -567,9 +607,11 @@ private fun RadioSettingsTab() {
} }
Switch(checked = captureStereo, onCheckedChange = { Switch(checked = captureStereo, onCheckedChange = {
captureStereo = it captureStereo = it
scope.launch { provider.setRadioCaptureStereo(it) } scope.launch {
NativeLib.setChatSetting("radio_capture_stereo", if (it) "1" else "0") provider.setRadioCaptureStereo(it)
RadioAudioEngine.instance().reconfigure() NativeLib.setChatSetting("radio_capture_stereo", if (it) "1" else "0")
RadioAudioEngine.instance().reconfigure()
}
}) })
} }

7
tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt

@ -898,7 +898,7 @@ class ChatViewModel : ViewModel() {
else -> null else -> null
} }
if (linkedRoute != null) { if (linkedRoute != null) {
radioAudio.setRoute(linkedRoute) radioAudio.setRoute(linkedRoute, preferWired = clamped == RadioAudioEngine.INPUT_WIRED)
_radioRoute.value = radioAudio.route() _radioRoute.value = radioAudio.route()
} }
viewModelScope.launch { viewModelScope.launch {
@ -949,10 +949,7 @@ class ChatViewModel : ViewModel() {
LogManager.addLog("DEBUG", "RADIO_DBG", LogManager.addLog("DEBUG", "RADIO_DBG",
"syncRadioOverlay radioOn=$radioOn channel=$radioChannelId overlayEnabled=$overlayEnabled " + "syncRadioOverlay radioOn=$radioOn channel=$radioChannelId overlayEnabled=$overlayEnabled " +
"isShown=${RadioOverlayService.isShown.value} t=${System.currentTimeMillis()}") "isShown=${RadioOverlayService.isShown.value} t=${System.currentTimeMillis()}")
if (radioOn && overlayEnabled) RadioOverlayService.sync(ctx)
RadioOverlayService.start(ctx)
else
RadioOverlayService.stop(ctx)
} }
fun radioName(nodeId: Long): String = fun radioName(nodeId: Long): String =

16
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; 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( JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioFeed(
JNIEnv* env, jobject thiz, jlong groupId, jshortArray samples) { JNIEnv* env, jobject thiz, jlong groupId, jshortArray samples) {
(void)thiz; (void)thiz;
@ -2461,6 +2472,11 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioStop(
utun_bridge_radio_audio_stop(); 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( JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioVadMode(
JNIEnv* env, jobject thiz) { JNIEnv* env, jobject thiz) {
(void)env; (void)thiz; (void)env; (void)thiz;

34
tools/chatgui/tests/test_radio_audio.cpp

@ -101,6 +101,40 @@ static int pull_max_abs(int npulls) {
int main() { int main() {
printf("=== test_radio_audio ===\n"); fflush(stdout); 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"); { TEST("single-source: decode через jitter-буфер выдаёт ненулевой PCM"); {
radio_audio_start(GID, 1); radio_audio_start(GID, 1);
push_const(SRC_A, 1, 10000, 8); push_const(SRC_A, 1, 10000, 8);

Loading…
Cancel
Save