Browse Source

Improve Android radio capture lifecycle, VAD and PTT controls

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

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

190
src/radio/radio_audio.c

@ -48,7 +48,6 @@
/* ── VAD авто-PTT (отдельный режим, ручной PTT не меняется) ── */
#define RADIO_VAD_CONFIRM_WINDOWS 2 /* окон (32мс) выше порога для старта (~64мс) */
#define RADIO_VAD_BUSY_TB 2000 /* 200мс: канал занят после последнего RX-кадра */
#define RADIO_VAD_MAX_PREROLL_FRAMES 25 /* максимум pre-roll 500мс @ 20мс */
struct radio_pending {
uint8_t used;
@ -88,15 +87,10 @@ static uint64_t g_last_stats_tb = 0;
static int g_vad_mode = 0; /* режим включён для текущей группы */
static silero_vad_t* g_vad = NULL; /* детектор (16кГц), создаётся в start */
static float g_vad_threshold = 0.5f;
static int g_vad_pre_roll_ms = 200;
static int g_vad_hangover_ms = 200;
static int g_vad_manual_ptt = 0; /* PTT override: зажата — VAD не вмешивается */
static float g_vad_win[RADIO_VAD_WINDOW_16K]; /* накопитель окон 16кГц */
static int g_vad_win_len = 0;
static int16_t* g_vad_preroll = NULL; /* кольцевой pre-roll (сырой PCM) */
static int g_vad_preroll_cap = 0; /* ёмкость в сэмплах (interleaved) */
static int g_vad_preroll_len = 0; /* заполнено сэмплов */
static int g_vad_preroll_pos = 0; /* позиция записи */
static struct radio_vad_fsm g_vad_fsm;
static uint64_t g_last_rx_tb = 0; /* последний принятый аудио-кадр (busy) */
@ -262,6 +256,8 @@ static struct radio_source* radio_src_acquire(uint64_t src, uint16_t stream) {
return NULL;
}
static void radio_audio_post_talk(struct UASYNC* ua, uint64_t group_id, void (*fn)(void*));
/* ── lifecycle ── */
int radio_audio_init(struct UTUN_INSTANCE* inst) {
@ -281,13 +277,35 @@ void radio_audio_destroy(struct UTUN_INSTANCE* inst) {
}
int radio_audio_start(uint64_t group_id, int capture_channels) {
if (group_id == 0) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: start bad group_id=0", RADIO_AUDIO_ID); return -1; }
if (capture_channels != 1 && capture_channels != 2) capture_channels = 1;
if (!group_id) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: start bad group_id=0", RADIO_AUDIO_ID); return -1; }
pthread_mutex_lock(&g_mtx);
if (g_active && g_group_id == group_id) { pthread_mutex_unlock(&g_mtx); return 0; }
if (g_active) { pthread_mutex_unlock(&g_mtx); radio_audio_stop(); pthread_mutex_lock(&g_mtx); }
pthread_mutex_unlock(&g_mtx);
radio_audio_stop();
pthread_mutex_lock(&g_mtx);
g_group_id = group_id;
g_active = 1;
g_last_rx_tb = 0;
memset(g_sources, 0, sizeof(g_sources));
pthread_mutex_unlock(&g_mtx);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: listening grp=%016llx", RADIO_AUDIO_ID, (unsigned long long)group_id);
if (!capture_channels) return 0;
int rc = radio_audio_capture_start(group_id, capture_channels, chat_setting_get_int(g_inst, "radio_vad_enabled", 0));
if (rc != 0) radio_audio_stop();
return rc;
}
int radio_audio_capture_start(uint64_t group_id, int capture_channels, int vad_enabled) {
if (capture_channels != 1 && capture_channels != 2) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: capture invalid channels=%d", RADIO_AUDIO_ID, capture_channels);
return -1;
}
pthread_mutex_lock(&g_mtx);
if (!g_active || g_group_id != group_id || g_encoder) {
pthread_mutex_unlock(&g_mtx);
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: capture start invalid state grp=%016llx", RADIO_AUDIO_ID, (unsigned long long)group_id);
return -1;
}
opus_codec_encoder_t* enc = opus_codec_encoder_create(RADIO_AUDIO_SAMPLE_RATE, capture_channels);
if (!enc) { pthread_mutex_unlock(&g_mtx); DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encoder create failed", RADIO_AUDIO_ID); return -1; }
int opus_mode = chat_setting_get_int(g_inst, "radio_opus_mode", 0);
@ -295,8 +313,7 @@ int radio_audio_start(uint64_t group_id, int capture_channels) {
opus_codec_encoder_application_set(enc, opus_mode ? OPUS_CODEC_APP_AUDIO : OPUS_CODEC_APP_VOIP);
opus_codec_encoder_bitrate_set(enc, opus_bitrate_kbps * 1000);
/* компрессор микрофона (AGC): конфиг из [chatserver], lookahead=0 — без задержки и хвоста.
* gain_smoothed не сбрасывается между PTT — адаптивная громкость запоминается. */
/* Компрессор TX: lookahead=0, без задержки и хвоста. */
struct audio_compressor* ac = audio_compressor_create();
if (ac) {
audio_compressor_config_t accfg = {0};
@ -312,59 +329,54 @@ int radio_audio_start(uint64_t group_id, int capture_channels) {
audio_compressor_set_enabled(ac, chat_setting_get_int(g_inst, "radio_compressor_enabled", 1));
}
/* VAD авто-PTT: отдельный режим (ручной PTT не меняется). Компрессор в VAD-режиме
* байпасится — pre-roll передаётся как сырой PCM, кодируемый по срабатыванию. */
g_vad_mode = chat_setting_get_int(g_inst, "radio_vad_enabled", 0) ? 1 : 0;
if (!ac) DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: compressor unavailable", RADIO_AUDIO_ID);
/* VAD анализирует текущие кадры без истории захвата. */
g_vad_mode = vad_enabled ? 1 : 0;
if (g_vad_mode) {
g_vad_threshold = (float)chat_setting_get_int(g_inst, "radio_vad_threshold", 50) / 100.0f;
g_vad_pre_roll_ms = chat_setting_get_int(g_inst, "radio_vad_pre_roll_ms", 200);
g_vad_hangover_ms = chat_setting_get_int(g_inst, "radio_vad_hangover_ms", 200);
g_vad = silero_vad_create_default();
if (!g_vad) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: silero_vad_create_default failed — VAD auto-PTT disabled", RADIO_AUDIO_ID);
g_vad_mode = 0;
} else {
int frames = g_vad_pre_roll_ms / RADIO_AUDIO_FRAME_MS;
if (frames < 1) frames = 1;
if (frames > RADIO_VAD_MAX_PREROLL_FRAMES) frames = RADIO_VAD_MAX_PREROLL_FRAMES;
g_vad_preroll_cap = frames * RADIO_AUDIO_FRAME_SAMPLES * capture_channels;
g_vad_preroll = (int16_t*)u_malloc((size_t)g_vad_preroll_cap * sizeof(int16_t));
if (!g_vad_preroll) {
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: pre-roll alloc failed — VAD auto-PTT disabled", RADIO_AUDIO_ID);
silero_vad_destroy(g_vad); g_vad = NULL;
g_vad_mode = 0; g_vad_preroll_cap = 0;
}
}
}
if (g_vad_mode) {
g_vad_preroll_len = 0;
g_vad_preroll_pos = 0;
g_vad_win_len = 0;
radio_vad_fsm_reset(&g_vad_fsm);
}
g_vad_manual_ptt = 0;
g_last_rx_tb = 0;
g_encoder = enc;
g_compressor = ac;
g_capture_channels = capture_channels;
g_group_id = group_id;
g_active = 1;
memset(g_sources, 0, sizeof(g_sources));
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: capture started grp=%016llx ch=%d opus=%s bitrate=%dkbps compressor=%s vad=%d threshold=%.2f hangover=%dms",
RADIO_AUDIO_ID, (unsigned long long)group_id, capture_channels, opus_mode ? "AUDIO" : "VOIP", opus_bitrate_kbps,
ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable", g_vad_mode, g_vad_threshold, g_vad_hangover_ms);
pthread_mutex_unlock(&g_mtx);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: started grp=%016llx opus_mode=%s bitrate=%dkbps capture_ch=%d compressor=%s vad=%s", RADIO_AUDIO_ID,
(unsigned long long)group_id, opus_mode ? "AUDIO" : "VOIP", opus_bitrate_kbps, capture_channels,
ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable",
g_vad_mode ? "on" : "off");
return 0;
}
void radio_audio_capture_stop(void) {
pthread_mutex_lock(&g_mtx);
if (g_encoder) {
if (g_vad_manual_ptt || g_vad_fsm.talking)
radio_audio_post_talk(g_inst ? g_inst->ua : NULL, g_group_id, radio_talk_end_trampoline);
opus_codec_encoder_destroy(g_encoder); g_encoder = NULL;
if (g_compressor) { audio_compressor_destroy(g_compressor); g_compressor = NULL; }
if (g_vad) { silero_vad_destroy(g_vad); g_vad = NULL; }
g_vad_mode = 0;
g_vad_manual_ptt = 0;
g_vad_win_len = 0;
radio_vad_fsm_reset(&g_vad_fsm);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: capture stopped", RADIO_AUDIO_ID);
}
pthread_mutex_unlock(&g_mtx);
}
void radio_audio_stop(void) {
opus_codec_encoder_t* enc = NULL;
struct audio_compressor* ac = NULL;
silero_vad_t* vad = NULL;
int16_t* preroll = NULL;
radio_audio_capture_stop();
int was_active = 0;
pthread_mutex_lock(&g_mtx);
@ -372,27 +384,12 @@ void radio_audio_stop(void) {
was_active = 1;
g_active = 0;
g_group_id = 0;
enc = g_encoder; g_encoder = NULL;
ac = g_compressor; g_compressor = NULL;
vad = g_vad; g_vad = NULL;
preroll = g_vad_preroll; g_vad_preroll = NULL;
g_vad_mode = 0;
g_vad_manual_ptt = 0;
g_vad_preroll_cap = 0;
g_vad_preroll_len = 0;
g_vad_preroll_pos = 0;
g_vad_win_len = 0;
radio_vad_fsm_reset(&g_vad_fsm);
for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; i++)
if (g_sources[i].used) radio_src_free(&g_sources[i]);
}
pthread_mutex_unlock(&g_mtx);
if (was_active) {
if (enc) opus_codec_encoder_destroy(enc);
if (ac) audio_compressor_destroy(ac);
if (vad) silero_vad_destroy(vad);
if (preroll) u_free(preroll);
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: stopped", RADIO_AUDIO_ID);
}
}
@ -413,11 +410,19 @@ int radio_audio_vad_mode(void) {
return m;
}
int radio_audio_transmitting(void) {
pthread_mutex_lock(&g_mtx);
int talking = g_active && (g_vad_manual_ptt || g_vad_fsm.talking);
pthread_mutex_unlock(&g_mtx);
return talking;
}
/* ── PTT (GUI → uasync) ── */
static void radio_audio_post_talk(struct UASYNC* ua, uint64_t group_id, void (*fn)(void*)) {
if (!ua) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: talk post without uasync", RADIO_AUDIO_ID); return; }
struct radio_talk_arg* a = (struct radio_talk_arg*)u_malloc(sizeof(*a));
if (!a) return;
if (!a) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: talk post allocation failed", RADIO_AUDIO_ID); return; }
a->inst = g_inst;
a->group_id = group_id;
uasync_post(ua, fn, a);
@ -426,22 +431,33 @@ static void radio_audio_post_talk(struct UASYNC* ua, uint64_t group_id, void (*f
void radio_audio_talk_begin(uint64_t group_id) {
if (!g_inst || !g_inst->ua) return;
pthread_mutex_lock(&g_mtx);
if (!g_active || group_id != g_group_id || !g_encoder) {
pthread_mutex_unlock(&g_mtx);
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: talk_begin without capture grp=%016llx", RADIO_AUDIO_ID, (unsigned long long)group_id);
return;
}
g_vad_manual_ptt = 1;
if (g_vad_fsm.talking) {
/* VAD вела передачу — закрываем её FIN, затем открываем ручной burst */
radio_audio_post_talk(g_inst->ua, group_id, radio_talk_end_trampoline);
radio_vad_fsm_reset(&g_vad_fsm);
}
pthread_mutex_unlock(&g_mtx);
radio_audio_post_talk(g_inst->ua, group_id, radio_talk_begin_trampoline);
pthread_mutex_unlock(&g_mtx);
}
void radio_audio_talk_end(uint64_t group_id) {
if (!g_inst || !g_inst->ua) return;
pthread_mutex_lock(&g_mtx);
if (!g_active || group_id != g_group_id || !g_vad_manual_ptt) {
pthread_mutex_unlock(&g_mtx);
DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: talk_end without manual transmission grp=%016llx", RADIO_AUDIO_ID,
(unsigned long long)group_id);
return;
}
g_vad_manual_ptt = 0;
pthread_mutex_unlock(&g_mtx);
radio_audio_post_talk(g_inst->ua, group_id, radio_talk_end_trampoline);
pthread_mutex_unlock(&g_mtx);
}
/* ── VAD авто-PTT: helpers (под g_mtx) ── */
@ -450,12 +466,12 @@ void radio_audio_talk_end(uint64_t group_id) {
* Вызывается при удержании g_mtx (использует g_encoder/g_capture_channels). */
static void radio_audio_encode_post(uint64_t group_id, const int16_t* frame,
struct UASYNC* ua, struct UTUN_INSTANCE* inst) {
if (!ua || !inst) return;
if (!ua || !inst) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: encode without instance", RADIO_AUDIO_ID); return; }
uint8_t opus[RADIO_MAX_OPUS];
int l = opus_codec_encode(g_encoder, frame, RADIO_AUDIO_FRAME_SAMPLES, opus, (int)sizeof(opus));
if (l <= 0) return;
if (l <= 0) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: opus encode failed rc=%d", RADIO_AUDIO_ID, l); return; }
struct radio_media_arg* a = (struct radio_media_arg*)u_malloc(sizeof(*a));
if (!a) return;
if (!a) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: media allocation failed", RADIO_AUDIO_ID); return; }
a->inst = inst;
a->group_id = group_id;
a->len = (uint16_t)l;
@ -463,34 +479,6 @@ static void radio_audio_encode_post(uint64_t group_id, const int16_t* frame,
uasync_post(ua, radio_talk_send_trampoline, a);
}
/* Записать кадр сырого PCM в кольцевой pre-roll (старые сэмплы вытесняются). */
static void radio_vad_preroll_put(const int16_t* pcm, int count) {
if (!g_vad_preroll || count <= 0 || g_vad_preroll_cap <= 0) return;
if (count > g_vad_preroll_cap) { pcm += (count - g_vad_preroll_cap); count = g_vad_preroll_cap; }
for (int i = 0; i < count; i++) {
g_vad_preroll[g_vad_preroll_pos] = pcm[i];
g_vad_preroll_pos = (g_vad_preroll_pos + 1) % g_vad_preroll_cap;
if (g_vad_preroll_len < g_vad_preroll_cap) g_vad_preroll_len++;
}
}
/* Выдать все полные кадры из pre-roll (oldest→newest) через encode_post, затем очистить. */
static void radio_vad_preroll_flush(uint64_t group_id, struct UASYNC* ua, struct UTUN_INSTANCE* inst) {
if (!g_vad_preroll || g_vad_preroll_cap <= 0) return;
int frame_total = RADIO_AUDIO_FRAME_SAMPLES * g_capture_channels;
if (frame_total <= 0) return;
int n_frames = g_vad_preroll_len / frame_total;
int16_t tmp[RADIO_AUDIO_FRAME_SAMPLES * 2]; /* frame_total макс. 1920 */
for (int f = 0; f < n_frames; f++) {
int start = g_vad_preroll_pos - g_vad_preroll_len + f * frame_total;
start = ((start % g_vad_preroll_cap) + g_vad_preroll_cap) % g_vad_preroll_cap;
for (int i = 0; i < frame_total; i++)
tmp[i] = g_vad_preroll[(start + i) % g_vad_preroll_cap];
radio_audio_encode_post(group_id, tmp, ua, inst);
}
g_vad_preroll_len = 0;
}
/* VAD-ветка feed_pcm: ресемпл → Silero → фильтр → старт/стоп/передача (держит g_mtx). */
static int radio_vad_feed(uint64_t group_id, const int16_t* pcm, int count) {
float out16k[RADIO_VAD_FRAME_SAMPLES_16K];
@ -506,16 +494,12 @@ static int radio_vad_feed(uint64_t group_id, const int16_t* pcm, int count) {
inst = g_inst;
if (g_vad_manual_ptt) {
/* PTT override: ведём pre-roll и просто передаём кадр (VAD не вмешивается) */
radio_vad_preroll_put(pcm, count);
/* Ручной PTT: передаём текущий кадр, VAD не вмешивается. */
radio_audio_encode_post(group_id, pcm, ua, inst);
pthread_mutex_unlock(&g_mtx);
return 0;
}
radio_vad_preroll_put(pcm, count);
int started_this_frame = 0;
radio_vad_resample(pcm, g_capture_channels, out16k);
int off = 0;
while (off < RADIO_VAD_FRAME_SAMPLES_16K) {
@ -526,10 +510,18 @@ static int radio_vad_feed(uint64_t group_id, const int16_t* pcm, int count) {
off += take;
if (g_vad_win_len == RADIO_VAD_WINDOW_16K) {
float prob = 0.0f;
if (silero_vad_process(g_vad, g_vad_win, &prob) != 0) prob = 0.0f;
if (silero_vad_process(g_vad, g_vad_win, &prob) != 0) {
DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: VAD processing failed", RADIO_AUDIO_ID);
prob = 0.0f;
}
g_vad_win_len = 0;
uint64_t now = get_time_tb();
int busy = (g_last_rx_tb != 0 && (now - g_last_rx_tb) < RADIO_VAD_BUSY_TB) ? 1 : 0;
if (!g_tx_stats_tb || now - g_tx_stats_tb >= RADIO_AUDIO_STATS_TB) {
DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: vad prob=%.3f threshold=%.2f busy=%d talking=%d confirm=%d",
RADIO_AUDIO_ID, prob, g_vad_threshold, busy, g_vad_fsm.talking, g_vad_fsm.confirm_count);
g_tx_stats_tb = now;
}
int action = radio_vad_fsm_update(&g_vad_fsm, prob, g_vad_threshold,
RADIO_VAD_CONFIRM_WINDOWS,
(uint64_t)g_vad_hangover_ms * 10, busy, now);
@ -537,8 +529,6 @@ static int radio_vad_feed(uint64_t group_id, const int16_t* pcm, int count) {
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: vad START prob=%.2f grp=%016llx",
RADIO_AUDIO_ID, prob, (unsigned long long)group_id);
radio_audio_post_talk(ua, group_id, radio_talk_begin_trampoline);
radio_vad_preroll_flush(group_id, ua, inst); /* флашит ring (включая текущий кадр) */
started_this_frame = 1;
} else if (action == -1) {
DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: vad STOP grp=%016llx",
RADIO_AUDIO_ID, (unsigned long long)group_id);
@ -547,7 +537,7 @@ static int radio_vad_feed(uint64_t group_id, const int16_t* pcm, int count) {
}
}
if (g_vad_fsm.talking && !started_this_frame) {
if (g_vad_fsm.talking) {
radio_audio_encode_post(group_id, pcm, ua, inst);
}

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

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

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

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 mutedChannelsKey = stringSetPreferencesKey("muted_channels")
private val callAudioRouteKey = stringPreferencesKey("call_audio_route")
private val radioVadEnabledKey = intPreferencesKey("radio_vad_enabled")
private val radioVadThresholdKey = intPreferencesKey("radio_vad_threshold")
private val radioVadHangoverMsKey = intPreferencesKey("radio_vad_hangover_ms")
private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt")
private val radioOverlayKey = booleanPreferencesKey("radio_overlay")
private val radioMicSourceKey = intPreferencesKey("radio_mic_source")
@ -141,6 +144,9 @@ class ConfigProvider(private val context: Context) {
key == "chat.standby_min_sleep_sec" -> context.dataStore.data.first()[chatStandbyMinSleepSec] ?: 15
key == "chat.standby_keepalive_ms" -> context.dataStore.data.first()[chatStandbyKeepaliveMs] ?: 30000
key == "chat.group_autoconnect" -> context.dataStore.data.first()[chatGroupAutoconnect] ?: 1
key == "radio.vad_enabled" -> context.dataStore.data.first()[radioVadEnabledKey] ?: 0
key == "radio.vad_threshold" -> context.dataStore.data.first()[radioVadThresholdKey] ?: 50
key == "radio.vad_hangover_ms" -> context.dataStore.data.first()[radioVadHangoverMsKey] ?: 200
key == "radio.opus_mode" -> context.dataStore.data.first()[radioOpusModeKey] ?: 0
key == "radio.opus_bitrate" -> context.dataStore.data.first()[radioOpusBitrateKey] ?: 32
key == "radio.capture_stereo" -> context.dataStore.data.first()[radioCaptureStereoKey] ?: 0
@ -178,6 +184,9 @@ class ConfigProvider(private val context: Context) {
"chat.standby_min_sleep_sec" -> prefs[chatStandbyMinSleepSec] = value.toIntOrNull() ?: 15
"chat.standby_keepalive_ms" -> prefs[chatStandbyKeepaliveMs] = value.toIntOrNull() ?: 30000
"chat.group_autoconnect" -> prefs[chatGroupAutoconnect] = value.toIntOrNull() ?: 1
"radio.vad_enabled" -> prefs[radioVadEnabledKey] = value.toIntOrNull() ?: 0
"radio.vad_threshold" -> prefs[radioVadThresholdKey] = value.toIntOrNull() ?: 50
"radio.vad_hangover_ms" -> prefs[radioVadHangoverMsKey] = value.toIntOrNull() ?: 200
"radio.opus_mode" -> prefs[radioOpusModeKey] = value.toIntOrNull() ?: 0
"radio.opus_bitrate" -> prefs[radioOpusBitrateKey] = value.toIntOrNull() ?: 32
"radio.capture_stereo" -> prefs[radioCaptureStereoKey] = value.toIntOrNull() ?: 0
@ -302,6 +311,8 @@ class ConfigProvider(private val context: Context) {
context.dataStore.edit { prefs -> prefs[radioVolumePttKey] = enabled }
}
val radioOverlayEnabled: Flow<Boolean> = context.dataStore.data.map { it[radioOverlayKey] ?: false }
@Volatile private var radioOverlayCached = false
@Volatile private var radioOverlayLoaded = false
@ -511,6 +522,9 @@ class ConfigProvider(private val context: Context) {
appendLine("storage_autoload_maxsize_mb=$chatAutoloadMaxMb")
appendLine("storage_maxsize_gb=$chatMaxGb")
appendLine("opus_codec_preset=$chatOpusPreset")
appendLine("radio_vad_enabled=${getInt("radio.vad_enabled")}")
appendLine("radio_vad_threshold=${getInt("radio.vad_threshold")}")
appendLine("radio_vad_hangover_ms=${getInt("radio.vad_hangover_ms")}")
appendLine("radio_opus_mode=$radioOpusMode")
appendLine("radio_opus_bitrate=$radioOpusBitrate")
appendLine("radio_capture_stereo=$radioCaptureStereo")

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 radioAudioPull(groupId: Long, out: ShortArray): Int = nativeRadioAudioPull(groupId, out)
fun radioAudioStop() { nativeRadioAudioStop() }
fun radioCaptureStart(groupId: Long, channels: Int, vad: Boolean): Boolean = nativeRadioCaptureStart(groupId, channels, vad)
fun radioCaptureStop() { nativeRadioCaptureStop() }
fun radioTransmitting(): Boolean = nativeRadioTransmitting()
fun radioVadMode(): Boolean = nativeRadioVadMode()
fun radioTalkBegin(groupId: Long) { nativeRadioTalkBegin(groupId) }
fun radioTalkEnd(groupId: Long) { nativeRadioTalkEnd(groupId) }
@ -277,6 +280,9 @@ object NativeLib {
private external fun nativeRadioAudioFeed(groupId: Long, samples: ShortArray): Boolean
private external fun nativeRadioAudioPull(groupId: Long, out: ShortArray): Int
private external fun nativeRadioAudioStop()
private external fun nativeRadioCaptureStart(groupId: Long, channels: Int, vad: Boolean): Boolean
private external fun nativeRadioCaptureStop()
private external fun nativeRadioTransmitting(): Boolean
private external fun nativeRadioVadMode(): Boolean
private external fun nativeRadioTalkBegin(groupId: Long)
private external fun nativeRadioTalkEnd(groupId: Long)

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

@ -1,5 +1,8 @@
package com.utun.chat.data
import android.content.BroadcastReceiver
import android.content.Intent
import android.content.IntentFilter
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioDeviceInfo
@ -11,6 +14,8 @@ import android.media.MediaRecorder
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.PowerManager
import android.os.SystemClock
import com.utun.chat.ChatApplication
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -20,7 +25,7 @@ import java.util.concurrent.Executors
* Полудуплексный аудио-движок рации (аналог CallAudioEngine, но с PTT).
*
* Потоки:
* - capture-поток: AudioRecord.read(960) → NativeLib.radioAudioFeed (только при зажатом PTT)
* - capture-поток: AudioRecord.read(960) → NativeLib.radioAudioFeed (PTT либо активный VAD)
* - play-поток: NativeLib.radioAudioPull (C: per-source jitter → декод → микшер) → AudioTrack.write
*
* Воспроизведение тянет микшированный PCM всех говорящих из единого голосового стека
@ -36,8 +41,8 @@ class RadioAudioEngine {
/** Воспроизведение всегда стерео (моно-пиры апмиксуются Opus-декодером). */
const val PLAY_CHANNELS = 2
/** Хвост передачи после отпускания PTT: продолжаем слать голос, затем FIN. */
const val RELEASE_HOLD_MS = 200L
const val RELEASE_HOLD_MS = 250L
const val VAD_ACTIVE_MS = 15 * 60 * 1000L
/** Процессный синглтон: доступен из Activity и оверлей-сервиса. */
@Volatile private var inst: RadioAudioEngine? = null
@ -58,13 +63,26 @@ class RadioAudioEngine {
private var playThread: Thread? = null
private var prevMode: Int = AudioManager.MODE_NORMAL
private var prevSpeaker: Boolean = false
private var audioModeOwned = false
@Volatile private var active = false
@Volatile private var groupId = 0L
@Volatile private var talking = false
@Volatile private var micDead = false
@Volatile private var captureRunning = false
@Volatile private var playbackRunning = false
@Volatile private var requestedTalk = false
@Volatile private var talkRequestNs = 0L
@Volatile private var generation = 0L
private var audioGroup = 0L
@Volatile private var vadDeadline = 0L
private var vadConfigured = false
private var screenReceiver: BroadcastReceiver? = null
@Volatile private var screenInteractive = false
private var configuredInput = INPUT_AUTO
private var configuredMicSource = 0
private var configuredStereo = false
private var configuredVad = false
@Volatile private var vadMode = false /* VAD авто-PTT: фидим PCM постоянно */
@Volatile private var captureChannels = 1 /* 1=моно, 2=стерео — по настройке захвата */
/** Маршрутизация аудио-выхода рации (динамик ↔ гарнитура), как в звонке. */
val router = CallAudioRouter()
@ -77,13 +95,23 @@ class RadioAudioEngine {
Thread(r, "radio-audio-io").apply { isDaemon = true }
}
/* Отложенный FIN после релиза PTT (все talkBegin/talkEnd зовутся с main-потока). */
/* Таймер окна VAD; управление ресурсами всегда на io-потоке. */
private val mainHandler = Handler(Looper.getMainLooper())
private val releaseRunnable = Runnable { finishTalk() }
private val releaseTalk = Runnable { finishTalk() }
private val vadTimeout = Runnable {
io.execute {
if (active && SystemClock.elapsedRealtime() >= vadDeadline) {
LogManager.addLog("INFO", "RadioAudio", "VAD window expired (15 minutes)")
updateCapture()
}
}
}
private val _vadActiveFlow = MutableStateFlow(false)
val vadActiveFlow: StateFlow<Boolean> = _vadActiveFlow
fun isActive(): Boolean = active
fun isTalking(): Boolean = talking
fun isTalking(): Boolean = _talkingFlow.value
/** Текущий маршрут выхода рации (SPEAKER/HEADSET). */
fun route(): AudioRoute = router.route
@ -95,61 +123,63 @@ class RadioAudioEngine {
fun headsetAvailable(): Boolean = router.headsetAvailable
/** Ручной выбор выхода (отключает автоследование за гарнитурой). */
fun setRoute(r: AudioRoute) = router.setRoute(r)
fun setRoute(r: AudioRoute, preferWired: Boolean = false) = router.setRoute(r, preferWired)
/** Начать прослушивание канала (переключатель рации вкл).
* Тяжёлая инициализация аудио выполняется на io-потоке, не блокируя main. */
fun start(group: Long) {
if (active && groupId == group) return
mainHandler.removeCallbacks(releaseTalk)
val version = ++generation
active = true
groupId = group
talking = false
io.execute { startBlocking(group) }
requestedTalk = false
_talkingFlow.value = false
io.execute {
if (!active || generation != version) return@execute
releaseLocked()
audioGroup = group
vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS
router.setAutoHeadset(true)
if (active && generation == version) {
try { setupAudio() } catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "audio setup failed: ${e.message}")
releaseLocked()
if (generation == version) active = false
}
}
}
}
fun stop() {
if (!active) return
mainHandler.removeCallbacks(releaseTalk)
active = false
talking = false
requestedTalk = false
_talkingFlow.value = false
mainHandler.removeCallbacks(releaseRunnable)
io.execute { stopBlocking() }
}
/* ── io-поток (сериализовано): setup/teardown аудио ── */
private fun startBlocking(group: Long) {
if (!active || groupId != group) return
/* Переключение группы: гасим старое аудио (active=false даёт старым
* capture/play-потокам выйти до release, иначе UAF AudioTrack в playLoop). */
active = false
releaseLocked(false)
active = true
router.setAutoHeadset(true) /* дефолт «авто» только на свежий старт/смену группы */
setupAudio()
++generation
io.execute { releaseLocked() }
}
/* Полный перезапуск аудио на том же канале — применение новых настроек на лету. */
/** Смена настроек перезапускает только захват, сохраняя приём и окно VAD. */
fun reconfigure() {
val g = groupId
if (g == 0L) return
val version = generation
io.execute {
if (groupId != g) return@execute
if (talking) finishTalk()
if (active) { active = false; releaseLocked(false) }
active = true
setupAudio()
if (!active || generation != version || audioTrack == null) return@execute
stopCapture()
loadCaptureSettings()
updateCapture()
}
}
/* Инициализация C-движка + AudioRecord/AudioTrack + потоков (только io-поток). */
/* Приём создаётся независимо от микрофона (только io-поток). */
private fun setupAudio() {
val group = groupId
loadCaptureSettings()
val group = audioGroup
val ctx = ChatApplication.instance
val am = ctx.getSystemService(Context.AUDIO_SERVICE) as AudioManager
prevMode = am.mode
prevSpeaker = am.isSpeakerphoneOn
audioModeOwned = true
try {
am.mode = AudioManager.MODE_IN_COMMUNICATION
} catch (e: Exception) {
@ -160,24 +190,12 @@ class RadioAudioEngine {
router.fallbackRoute = AudioRoute.SPEAKER
router.start()
// ── AudioRecord (захват) с откатом стерео→моно ──
val (rec, capCh) = createAudioRecord()
if (rec == null) {
router.stop(); restoreAudioMode(); active = false; return
}
captureChannels = capCh
if (!NativeLib.radioAudioStart(group, capCh)) {
rec.release()
if (!NativeLib.radioAudioStart(group, 0)) {
router.stop(); restoreAudioMode(); active = false
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group ch=$capCh")
LogManager.addLog("ERROR", "RadioAudio", "native radioAudioStart failed group=$group")
return
}
/* VAD авто-PTT: сервисный слой решил, что режим включён — фидим PCM постоянно. */
vadMode = NativeLib.radioVadMode()
if (vadMode) LogManager.addLog("INFO", "RadioAudio", "VAD auto-PTT enabled group=$group")
// ── AudioTrack (воспроизведение, всегда стерео) ──
val trackMin = AudioTrack.getMinBufferSize(
SAMPLE_RATE, AudioFormat.CHANNEL_OUT_STEREO, AudioFormat.ENCODING_PCM_16BIT)
@ -193,13 +211,10 @@ class RadioAudioEngine {
trackBuf, AudioTrack.MODE_STREAM, AudioManager.AUDIO_SESSION_ID_GENERATE)
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "AudioTrack create failed: ${e.message}")
rec.release()
NativeLib.radioAudioStop(); router.stop(); restoreAudioMode(); active = false; return
}
audioRecord = rec
audioTrack = track
micDead = false
/* Прямое роутирование трека на выбранное устройство (обход глючной глобальной
* маршрутизации), как в CallAudioEngine. */
@ -211,27 +226,33 @@ class RadioAudioEngine {
if (ChatApplication.instance.configProvider.getRadioInputSource() == INPUT_AUTO) reconfigure()
}
rec.startRecording()
track.play()
captureThread = Thread { captureLoop() }.apply {
priority = Thread.MAX_PRIORITY; start()
}
playbackRunning = true
playThread = Thread { playLoop() }.apply {
priority = Thread.MAX_PRIORITY; start()
}
LogManager.addLog("INFO", "RadioAudio", "started group=$group captureCh=$captureChannels trackBuf=$trackBuf")
registerScreenReceiver()
updateCapture()
LogManager.addLog("INFO", "RadioAudio", "listening group=$group trackBuf=$trackBuf")
}
/* Настройки загружаются при включении/перенастройке, без DataStore в пути PTT. */
private fun loadCaptureSettings() {
val provider = ChatApplication.instance.configProvider
configuredInput = provider.getRadioInputSource()
configuredMicSource = provider.getRadioMicSource()
configuredStereo = provider.getRadioCaptureStereo()
configuredVad = provider.getInt("radio.vad_enabled") != 0
}
/* Создать AudioRecord с маршрутизацией входного устройства: встроенный микрофон
* (стерео + предобработка из настроек), проводная/BT-гарнитура (моно, MIC),
* авто — системный дефолт. Возвращает (record, каналы). */
private fun createAudioRecord(): Pair<AudioRecord?, Int> {
val ctx = ChatApplication.instance
val inputSource = ctx.configProvider.getRadioInputSource()
val pref = ctx.configProvider.getRadioMicSource()
val wantStereo = ctx.configProvider.getRadioCaptureStereo()
val inputSource = configuredInput
val pref = configuredMicSource
val wantStereo = configuredStereo
/* гарнитурный вход: моно + VOICE_COMMUNICATION (звонковый тракт «запитывает»
* микрофон гарнитуры; AudioSource.MIC на ней даёт тишину). AUTO: следуем за
@ -272,6 +293,10 @@ class RadioAudioEngine {
for (mask in masks) {
val channels = if (mask == AudioFormat.CHANNEL_IN_STEREO) 2 else 1
val recMin = AudioRecord.getMinBufferSize(SAMPLE_RATE, mask, AudioFormat.ENCODING_PCM_16BIT)
if (recMin <= 0) {
LogManager.addLog("WARN", "RadioAudio", "unsupported capture format ch=$channels minBuffer=$recMin")
continue
}
val bufSize = (recMin * 2).coerceAtLeast(FRAME_SAMPLES * channels * 4)
for (src in sources) {
val rec = tryCreateRecord(src, bufSize, mask)
@ -298,7 +323,7 @@ class RadioAudioEngine {
LogManager.addLog("ERROR", "RadioAudio", "setPreferredDevice(record) threw: ${e.message}")
false
}
LogManager.addLog("WARN", "RadioAudio", "record input -> dev=${dev.type} setPreferredDevice=$ok")
LogManager.addLog(if (ok) "DEBUG" else "WARN", "RadioAudio", "record input -> dev=${dev.type} setPreferredDevice=$ok")
}
private fun tryCreateRecord(source: Int, bufferSize: Int, channelMask: Int): AudioRecord? {
@ -316,89 +341,224 @@ class RadioAudioEngine {
return rec
}
private fun stopBlocking() {
releaseLocked(true)
private fun screenOn(): Boolean =
(ChatApplication.instance.getSystemService(Context.POWER_SERVICE) as PowerManager).isInteractive
private fun registerScreenReceiver() {
val receiver = object : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
screenInteractive = intent.action == Intent.ACTION_SCREEN_ON
val version = generation
io.execute {
if (!active || generation != version) return@execute
if (intent.action == Intent.ACTION_SCREEN_OFF) {
vadDeadline = 0L
LogManager.addLog("INFO", "RadioAudio", "screen off: VAD window closed")
}
if (intent.action == Intent.ACTION_SCREEN_ON) {
vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS
LogManager.addLog("INFO", "RadioAudio", "screen on: new VAD window (15 minutes)")
}
updateCapture()
}
}
}
val filter = IntentFilter().apply {
addAction(Intent.ACTION_SCREEN_ON)
addAction(Intent.ACTION_SCREEN_OFF)
}
val ctx = ChatApplication.instance
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU)
ctx.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED)
else ctx.registerReceiver(receiver, filter)
screenReceiver = receiver
screenInteractive = screenOn()
}
/* Освобождение аудио-ресурсов (только io-поток): join потоков → release → restore. */
private fun releaseLocked(resetGroup: Boolean) {
captureThread?.join(1000); captureThread = null
playThread?.join(1000); playThread = null
/** Выбор захвата: PTT либо включённый VAD в пределах окна при горящем экране. */
private fun updateCapture() {
mainHandler.removeCallbacks(vadTimeout)
val configured = configuredVad
if (configured && !vadConfigured) vadDeadline = SystemClock.elapsedRealtime() + VAD_ACTIVE_MS
vadConfigured = configured
val wantVad = active && screenInteractive && SystemClock.elapsedRealtime() < vadDeadline && configured
if (wantVad) mainHandler.postDelayed(vadTimeout, (vadDeadline - SystemClock.elapsedRealtime()).coerceAtLeast(1L))
if (!active || (!requestedTalk && !wantVad)) {
stopCapture()
return
}
if (audioRecord != null && vadMode != wantVad) stopCapture()
if (audioTrack == null) return
if (audioRecord == null && !startCapture(wantVad)) {
requestedTalk = false
_talkingFlow.value = false
return
}
if (requestedTalk && !talking) {
NativeLib.radioTalkBegin(audioGroup)
talking = true
_talkingFlow.value = true
} else if (!requestedTalk && talking) {
NativeLib.radioTalkEnd(audioGroup)
talking = false
_talkingFlow.value = false
}
}
audioRecord?.apply { try { stop() } catch (_: Exception) {}; release() }
audioRecord = null
audioTrack?.apply { try { stop() } catch (_: Exception) {}; release() }
audioTrack = null
private fun startCapture(vad: Boolean): Boolean {
val startNs = SystemClock.elapsedRealtimeNanos()
val (rec, channels) = createAudioRecord()
if (rec == null) return false
val createdNs = SystemClock.elapsedRealtimeNanos()
if (!NativeLib.radioCaptureStart(audioGroup, channels, vad)) {
LogManager.addLog("ERROR", "RadioAudio", "native capture start failed group=$audioGroup ch=$channels vad=$vad")
rec.release()
return false
}
vadMode = NativeLib.radioVadMode()
if (vad && !vadMode) {
LogManager.addLog("ERROR", "RadioAudio", "VAD unavailable: capture cancelled")
NativeLib.radioCaptureStop(); rec.release()
return false
}
val preparedNs = SystemClock.elapsedRealtimeNanos()
try {
rec.startRecording()
if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) error("recording did not start")
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioAudio", "capture start failed: ${e.message}")
NativeLib.radioCaptureStop(); rec.release()
vadMode = false
return false
}
val recordingNs = SystemClock.elapsedRealtimeNanos()
audioRecord = rec
captureRunning = true
_vadActiveFlow.value = vadMode
// BEGIN должен попасть в uasync раньше первого кадра ручной передачи.
if (requestedTalk) {
NativeLib.radioTalkBegin(audioGroup)
talking = true
_talkingFlow.value = true
}
val captureGroup = audioGroup
captureThread = Thread {
try { captureLoop(rec, captureGroup, channels) }
catch (e: Exception) { LogManager.addLog("ERROR", "RadioAudio", "capture failed: ${e.message}") }
finally {
if (captureRunning) io.execute {
if (audioRecord === rec) {
requestedTalk = false
stopCapture()
LogManager.addLog("WARN", "RadioAudio", "capture lost: resources released")
}
}
}
}.apply {
name = "radio-capture"; priority = Thread.MAX_PRIORITY; start()
}
LogManager.addLog("INFO", "RadioAudio",
"capture started group=$audioGroup ch=$channels vad=$vadMode " +
"createMs=${(createdNs - startNs) / 1_000_000} prepareMs=${(preparedNs - createdNs) / 1_000_000} " +
"startMs=${(recordingNs - preparedNs) / 1_000_000} totalMs=${(recordingNs - startNs) / 1_000_000}")
return true
}
private fun stopCapture() {
captureRunning = false
val rec = audioRecord
if (rec != null) {
try { rec.stop() } catch (e: Exception) {
LogManager.addLog("WARN", "RadioAudio", "capture stop failed: ${e.message}")
}
// stop разблокирует read; release только после выхода потока.
captureThread?.join()
captureThread = null
rec.release()
audioRecord = null
NativeLib.radioCaptureStop()
LogManager.addLog("INFO", "RadioAudio", "capture released group=$audioGroup")
}
vadMode = false
talking = false
_talkingFlow.value = requestedTalk
_vadActiveFlow.value = false
}
private fun releaseLocked() {
mainHandler.removeCallbacks(vadTimeout)
screenReceiver?.let { ChatApplication.instance.unregisterReceiver(it) }
screenReceiver = null
stopCapture()
playbackRunning = false
audioTrack?.let { track ->
try { track.stop() } catch (e: Exception) {
LogManager.addLog("WARN", "RadioAudio", "playback stop failed: ${e.message}")
}
playThread?.interrupt()
playThread?.join()
playThread = null
track.release()
}
audioTrack = null
NativeLib.radioAudioStop()
router.onOutputDevice = null
router.onInputDevice = null
router.stop()
restoreAudioMode()
if (resetGroup) {
groupId = 0L
LogManager.addLog("INFO", "RadioAudio", "stopped")
}
}
/** PTT зажата — начинаем передачу. */
fun talkBegin() {
if (!active) return
mainHandler.removeCallbacks(releaseRunnable)
if (talking) return
if (micDead) {
LogManager.addLog("WARN", "RadioAudio", "talkBegin but mic is dead (background capture stopped)")
}
talking = true
mainHandler.removeCallbacks(releaseTalk)
if (requestedTalk) return
talkRequestNs = SystemClock.elapsedRealtimeNanos()
requestedTalk = true
_talkingFlow.value = true
NativeLib.radioTalkBegin(groupId)
val version = generation
LogManager.addLog("DEBUG", "RadioAudio", "PTT requested group=$groupId")
io.execute {
if (active && generation == version) {
LogManager.addLog("DEBUG", "RadioAudio", "PTT io queueMs=${(SystemClock.elapsedRealtimeNanos() - talkRequestNs) / 1_000_000}")
updateCapture()
}
}
}
/** PTT отпущена — добиваем хвост RELEASE_HOLD_MS, затем завершаем (FIN). */
/** Отпускание: 250мс передачи, повторное касание отменяет завершение. */
fun talkEnd() {
if (!active || !talking) return
mainHandler.removeCallbacks(releaseRunnable)
mainHandler.postDelayed(releaseRunnable, RELEASE_HOLD_MS)
LogManager.addLog("DEBUG", "RadioAudio", "talk release scheduled +${RELEASE_HOLD_MS}ms")
}
/** Немедленное завершение передачи без хвоста (drag кнопки рации). */
fun talkEndImmediate() {
if (!active || !talking) return
mainHandler.removeCallbacks(releaseRunnable)
finishTalk()
if (!active || !requestedTalk) return
mainHandler.removeCallbacks(releaseTalk)
mainHandler.postDelayed(releaseTalk, RELEASE_HOLD_MS)
LogManager.addLog("DEBUG", "RadioAudio", "PTT release scheduled +${RELEASE_HOLD_MS}ms")
}
private fun finishTalk() {
if (!talking) return
talking = false
if (!requestedTalk) return
requestedTalk = false
_talkingFlow.value = false
NativeLib.radioTalkEnd(groupId)
LogManager.addLog("DEBUG", "RadioAudio", "talk finished (FIN)")
val version = generation
io.execute { if (active && generation == version) updateCapture() }
}
private fun captureLoop() {
val ch = captureChannels
private fun captureLoop(rec: AudioRecord, group: Long, ch: Int) {
val frame = FRAME_SAMPLES * ch
val buf = ShortArray(frame)
var frameN = 0L
while (active) {
val rec = audioRecord ?: break
var reportedRequestNs = 0L
while (captureRunning) {
if (rec.recordingState != AudioRecord.RECORDSTATE_RECORDING) {
if (active) {
micDead = true
if (captureRunning) {
LogManager.addLog("WARN", "RadioAudio", "capture loop: recordingState=${rec.recordingState}, mic stopped")
}
break
}
var read = 0
while (read < frame) {
if (!active) return
if (!captureRunning) return
val n = rec.read(buf, read, frame - read)
if (n <= 0) {
if (active) {
micDead = true
if (captureRunning) {
LogManager.addLog("WARN", "RadioAudio", "capture loop: read()=$n, mic lost")
}
return
@ -408,11 +568,19 @@ class RadioAudioEngine {
/* диагностика крос-девайс входа/выхода: куда реально роутится захват + уровень сигнала */
if (frameN < 10 || frameN % 100L == 0L) {
LogManager.addLog("DEBUG", "RadioAudio",
"cap routed=${rec.routedDevice?.type ?: "-"} frame=$frameN rmsDb=${rmsDb(buf)} input=${inputSource()} route=${route()}")
"cap routed=${rec.routedDevice?.type ?: "-"} frame=$frameN rmsDb=${rmsDb(buf)} input=$configuredInput route=${route()}")
}
frameN++
if ((talking || vadMode) && !NativeLib.radioAudioFeed(groupId, buf)) {
LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false")
val vadAllowed = vadMode && screenInteractive && SystemClock.elapsedRealtime() < vadDeadline
if (active && groupId == group && captureRunning && ((talking && requestedTalk) || vadAllowed)) {
val requestNs = talkRequestNs
val fed = NativeLib.radioAudioFeed(group, buf)
if (!fed) LogManager.addLog("WARN", "RadioAudio", "radioAudioFeed returned false")
if (fed && requestedTalk && requestNs != reportedRequestNs) {
reportedRequestNs = requestNs
LogManager.addLog("INFO", "RadioAudio", "PTT first frame queued elapsedMs=${(SystemClock.elapsedRealtimeNanos() - requestNs) / 1_000_000}")
}
if (captureRunning) _talkingFlow.value = requestedTalk || NativeLib.radioTransmitting()
}
}
LogManager.addLog("DEBUG", "RadioAudio", "capture loop done")
@ -432,9 +600,9 @@ class RadioAudioEngine {
val silence = ShortArray(frame)
val frameNs = FRAME_MS * 1_000_000L /* 20мс в наносекундах */
var nextNs = System.nanoTime()
while (active) {
while (playbackRunning) {
val track = audioTrack ?: return
val n = NativeLib.radioAudioPull(groupId, buf)
val n = NativeLib.radioAudioPull(audioGroup, buf)
if (n > 0) track.write(buf, 0, n)
if (n < frame) track.write(silence, 0, frame - n)
@ -452,6 +620,8 @@ class RadioAudioEngine {
}
private fun restoreAudioMode() {
if (!audioModeOwned) return
audioModeOwned = false
try {
val am = ChatApplication.instance.getSystemService(Context.AUDIO_SERVICE) as AudioManager
am.isSpeakerphoneOn = prevSpeaker

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

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
import android.content.ContentResolver
import android.content.Intent
import android.provider.Settings
import android.net.Uri
import android.os.SystemClock
import android.provider.OpenableColumns
import android.view.ViewConfiguration
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.foundation.Canvas
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.horizontalScroll
@ -16,21 +20,34 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.AutoMode
import androidx.compose.material.icons.filled.Bluetooth
import androidx.compose.material.icons.filled.HeadsetMic
import androidx.compose.material.icons.automirrored.filled.VolumeUp
import androidx.compose.material.icons.filled.Link
import androidx.compose.material.icons.filled.People
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.layout.onGloballyPositioned
import androidx.compose.ui.layout.positionInWindow
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.utun.chat.ChatApplication
import com.utun.chat.data.LogManager
import com.utun.chat.data.AudioRoute
import com.utun.chat.data.Channel
import com.utun.chat.data.Message
@ -44,7 +61,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@ -70,6 +86,8 @@ fun ChatScreen(
val recordingDurationMs by viewModel.recordingDurationMs.collectAsState()
var recordingLevel by remember { mutableStateOf(0f) }
val context = LocalContext.current
val view = LocalView.current
val pttCenterOffset = with(LocalDensity.current) { 60.dp.toPx() }
val scope = rememberCoroutineScope()
/* рация */
@ -173,7 +191,16 @@ fun ChatScreen(
)
}
}
Box(modifier = Modifier.weight(1f).fillMaxWidth()) {
Box(modifier = Modifier.weight(1f).fillMaxWidth().onGloballyPositioned { coordinates ->
val windowPosition = coordinates.positionInWindow()
val screenOrigin = IntArray(2)
val windowOrigin = IntArray(2)
view.getLocationOnScreen(screenOrigin)
view.getLocationInWindow(windowOrigin)
RadioOverlayService.setDefaultCenter(
(windowPosition.x + coordinates.size.width / 2f + screenOrigin[0] - windowOrigin[0]).toInt(),
(windowPosition.y + pttCenterOffset + screenOrigin[1] - windowOrigin[1]).toInt())
}) {
LazyColumn(
modifier = Modifier.fillMaxSize(),
state = listState,
@ -279,9 +306,7 @@ fun ChatScreen(
}
}
/* Круглая PTT-кнопка рации: удержание > PTT_HOLD_MS — говорим, двойной тап —
* фиксирует передачу (latch) без удержания, следующий тап/двойной тап — отбой. */
private const val PTT_HOLD_MS = 250L
/* Передача сразу при касании; двойной тап фиксирует, следующий тап завершает. */
@Composable
private fun RadioPttButton(
@ -290,7 +315,7 @@ private fun RadioPttButton(
onTalkBegin: () -> Unit,
onTalkEnd: () -> Unit
) {
val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong()
val doubleTapTimeout = ViewConfiguration.getDoubleTapTimeout().toLong().coerceAtMost(RadioAudioEngine.RELEASE_HOLD_MS)
var latched by remember { mutableStateOf(false) }
var lastTapUp by remember { mutableStateOf(0L) }
@ -298,51 +323,46 @@ private fun RadioPttButton(
modifier = modifier
.size(96.dp)
.pointerInput(Unit) {
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
val downId = down.id
down.consume()
try {
awaitPointerEventScope {
while (true) {
val down = awaitFirstDown(requireUnconsumed = false)
val downId = down.id
down.consume()
val now = SystemClock.uptimeMillis()
val isSecondTap = lastTapUp != 0L && (now - lastTapUp) <= doubleTapTimeout
val now = SystemClock.uptimeMillis()
val isSecondTap = lastTapUp != 0L && (now - lastTapUp) <= doubleTapTimeout
/* быстрое отпускание (до PTT_HOLD_MS) — это тап */
var tapped = false
withTimeoutOrNull(PTT_HOLD_MS) {
while (true) {
val ev = awaitPointerEvent()
val c = ev.changes.firstOrNull { it.id == downId } ?: return@withTimeoutOrNull
if (!c.pressed) { tapped = true; return@withTimeoutOrNull }
c.consume()
}
}
if (tapped) {
if (latched) {
val wasLatched = latched
if (wasLatched) {
latched = false
lastTapUp = 0L
onTalkEnd()
} else if (isSecondTap) {
latched = true
lastTapUp = 0L
onTalkBegin()
} else {
lastTapUp = SystemClock.uptimeMillis()
}
continue
}
} else onTalkBegin()
/* удержание — говорим, пока не отпустят */
onTalkBegin()
while (true) {
val ev = awaitPointerEvent()
val c = ev.changes.firstOrNull { it.id == downId } ?: break
c.consume()
if (!c.pressed) break
var released = false
try {
while (true) {
val ev = awaitPointerEvent()
val c = ev.changes.firstOrNull { it.id == downId } ?: break
c.consume()
if (!c.pressed) { released = true; break }
}
val upTime = SystemClock.uptimeMillis()
val tapped = released && upTime - now <= RadioAudioEngine.RELEASE_HOLD_MS
if (!wasLatched && tapped && isSecondTap) {
latched = true
lastTapUp = 0L
} else lastTapUp = if (!wasLatched && tapped) upTime else 0L
} finally {
if (!wasLatched && !latched) onTalkEnd()
}
}
onTalkEnd()
}
} finally {
latched = false
lastTapUp = 0L
onTalkEnd()
}
},
shape = CircleShape,
@ -355,7 +375,7 @@ private fun RadioPttButton(
}
}
/* Строка аудиоисточников рации: вход (авто/встроенный/проводная/беспроводная) + выход. */
/* Единый выбор входа/выхода и переключатель плавающей PTT-кнопки. */
@Composable
private fun RadioSourceBar(viewModel: ChatViewModel) {
val radioAuto by viewModel.radioAuto.collectAsState()
@ -363,61 +383,125 @@ private fun RadioSourceBar(viewModel: ChatViewModel) {
val wiredInputAvailable by viewModel.wiredInputAvailable.collectAsState()
val btInputAvailable by viewModel.btInputAvailable.collectAsState()
val radioRoute by viewModel.radioRoute.collectAsState()
val radioHeadsetAvailable by viewModel.radioHeadsetAvailable.collectAsState()
val context = LocalContext.current
val provider = ChatApplication.instance.configProvider
val scope = rememberCoroutineScope()
val overlayEnabled by provider.radioOverlayEnabled.collectAsState(initial = provider.getRadioOverlay())
val overlayPermission = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) {
if (RadioOverlayService.canDrawOverlays(context)) {
scope.launch {
provider.setRadioOverlay(true)
RadioOverlayService.sync(context)
LogManager.addLog("INFO", "RadioUI", "floating PTT enabled after permission grant")
}
} else LogManager.addLog("WARN", "RadioUI", "floating PTT permission not granted")
}
Surface(color = Color(0xFF0F1B22)) {
Row(
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(horizontal = 8.dp, vertical = 4.dp),
horizontalArrangement = Arrangement.spacedBy(6.dp),
modifier = Modifier.fillMaxWidth().heightIn(min = 72.dp).padding(horizontal = 12.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
RadioSourceGroupLabel("Вход")
RadioSourceChip("Авто", radioAuto, { viewModel.setRadioAuto() })
RadioSourceChip("Встроенный", radioInputSource == RadioAudioEngine.INPUT_BUILTIN,
{ viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN) })
if (wiredInputAvailable) {
RadioSourceChip("Проводная", radioInputSource == RadioAudioEngine.INPUT_WIRED,
{ viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED) })
}
if (btInputAvailable) {
RadioSourceChip("Беспроводная", radioInputSource == RadioAudioEngine.INPUT_BT,
{ viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT) })
Row(
modifier = Modifier.weight(1f).horizontalScroll(rememberScrollState()),
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically
) {
val currentDeviceIcon = if (radioRoute == AudioRoute.HEADSET) Icons.Default.HeadsetMic else Icons.AutoMirrored.Filled.VolumeUp
RadioControlButton(Icons.Default.AutoMode, "Авто: ${if (radioRoute == AudioRoute.HEADSET) "гарнитура" else "динамик"}",
radioAuto, centerIcon = currentDeviceIcon) {
viewModel.setRadioAuto()
}
RadioControlButton(Icons.AutoMirrored.Filled.VolumeUp, "Микрофон и динамик телефона",
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BUILTIN && radioRoute == AudioRoute.SPEAKER) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BUILTIN)
}
if (wiredInputAvailable) {
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук проводной гарнитуры",
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_WIRED && radioRoute == AudioRoute.HEADSET) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_WIRED)
}
}
if (btInputAvailable) {
RadioControlButton(Icons.Default.HeadsetMic, "Микрофон и звук Bluetooth-гарнитуры",
!radioAuto && radioInputSource == RadioAudioEngine.INPUT_BT && radioRoute == AudioRoute.HEADSET, bluetooth = true) {
viewModel.setRadioInputSource(RadioAudioEngine.INPUT_BT)
}
}
}
RadioSourceGroupLabel("Выход")
RadioSourceChip("Динамик", radioRoute == AudioRoute.SPEAKER,
{ viewModel.setRadioRoute(AudioRoute.SPEAKER) })
if (radioHeadsetAvailable) {
RadioSourceChip("Гарнитура", radioRoute == AudioRoute.HEADSET,
{ viewModel.setRadioRoute(AudioRoute.HEADSET) })
VerticalDivider(modifier = Modifier.height(32.dp), color = Color(0xFF344550))
RadioControlButton(null, "Плавающая PTT-кнопка", overlayEnabled, floating = true) {
if (!overlayEnabled && !RadioOverlayService.canDrawOverlays(context)) {
try {
overlayPermission.launch(Intent(Settings.ACTION_MANAGE_OVERLAY_PERMISSION,
Uri.parse("package:${context.packageName}")))
} catch (e: Exception) {
LogManager.addLog("ERROR", "RadioUI", "open overlay permission failed: ${e.message}")
}
} else scope.launch {
provider.setRadioOverlay(!overlayEnabled)
RadioOverlayService.sync(context)
LogManager.addLog("INFO", "RadioUI", "floating PTT enabled=${provider.getRadioOverlay()}")
}
}
}
}
}
@Composable
private fun RadioSourceChip(
label: String,
active: Boolean,
onClick: () -> Unit
private fun RadioControlButton(
icon: ImageVector?, label: String, active: Boolean,
centerIcon: ImageVector? = null, bluetooth: Boolean = false, floating: Boolean = false, onClick: () -> Unit
) {
Surface(
modifier = Modifier.clickable(onClick = onClick),
shape = RoundedCornerShape(16.dp),
color = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D)
IconToggleButton(
checked = active,
onCheckedChange = { onClick() },
modifier = Modifier.size(48.dp),
colors = IconButtonDefaults.iconToggleButtonColors(
containerColor = Color(0xFF24323D), contentColor = Color(0xFF9BA6AD),
checkedContainerColor = Color(0xFF1F6FEB), checkedContentColor = Color.White)
) {
Text(
label,
color = if (active) Color.White else Color(0xFF9BA6AD),
fontSize = 12.sp,
modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp)
)
if (floating) {
RadioFloatingIcon(label)
return@IconToggleButton
}
val background = if (active) Color(0xFF1F6FEB) else Color(0xFF24323D)
Box(modifier = Modifier.size(34.dp), contentAlignment = Alignment.Center) {
Icon(requireNotNull(icon), contentDescription = label, modifier = Modifier.size(if (centerIcon != null) 32.dp else 28.dp))
if (centerIcon != null) {
Box(Modifier.size(20.dp).background(background, CircleShape), contentAlignment = Alignment.Center) {
Icon(centerIcon, contentDescription = null, modifier = Modifier.size(15.dp))
}
}
if (bluetooth) {
Box(Modifier.align(Alignment.BottomEnd).size(16.dp).background(background, CircleShape),
contentAlignment = Alignment.Center) {
Icon(Icons.Default.Bluetooth, contentDescription = null, modifier = Modifier.size(14.dp))
}
}
}
}
}
/* Кружок и четыре стрелки наружу — плавающая/перемещаемая PTT-кнопка. */
@Composable
private fun RadioSourceGroupLabel(text: String) {
Text(text, color = Color(0xFF5A6A75), fontSize = 11.sp, fontWeight = FontWeight.Bold)
private fun RadioFloatingIcon(label: String) {
val color = LocalContentColor.current
Canvas(Modifier.size(28.dp).semantics { contentDescription = label }) {
val c = center
val radius = 4.dp.toPx()
val stroke = 1.7.dp.toPx()
val start = 6.dp.toPx()
val end = 13.dp.toPx()
val head = 3.dp.toPx()
drawCircle(color, radius, c, style = Stroke(stroke))
for (direction in listOf(Offset(1f, 0f), Offset(-1f, 0f), Offset(0f, 1f), Offset(0f, -1f))) {
val tip = c + direction * end
val perpendicular = Offset(-direction.y, direction.x)
drawLine(color, c + direction * start, tip, strokeWidth = stroke)
drawLine(color, tip, tip - direction * head + perpendicular * head, strokeWidth = stroke)
drawLine(color, tip, tip - direction * head - perpendicular * head, strokeWidth = stroke)
}
}
}

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",
"socket", "control", "dump", "traffic", "debug", "general",
"nat", "keepalive", "media", "etcp_route", "etcp_dump",
"chat_sync", "member_sync", "proxy", "radio"
"chat_sync", "member_sync", "proxy", "radio", "vad"
)
private val PROTOCOLS = listOf("tcp", "udp")
@ -481,6 +481,45 @@ private fun RadioSettingsTab() {
modifier = Modifier.padding(16.dp).verticalScroll(rememberScrollState()),
verticalArrangement = Arrangement.spacedBy(12.dp)
) {
var vadEnabled by remember { mutableStateOf(provider.getInt("radio.vad_enabled") != 0) }
var vadThreshold by remember { mutableStateOf(provider.getInt("radio.vad_threshold")) }
var vadHangover by remember { mutableStateOf(provider.getInt("radio.vad_hangover_ms")) }
val vadActive by RadioAudioEngine.instance().vadActiveFlow.collectAsState()
Text("Автоматическая передача по голосу (VAD)", style = MaterialTheme.typography.titleMedium)
Row(verticalAlignment = Alignment.CenterVertically) {
Text("Включить VAD", modifier = Modifier.weight(1f))
Switch(checked = vadEnabled, onCheckedChange = { enabled ->
vadEnabled = enabled
scope.launch {
provider.setValue("radio.vad_enabled", if (enabled) "1" else "0")
NativeLib.setChatSetting("radio_vad_enabled", if (enabled) "1" else "0")
RadioOverlayService.sync(ChatApplication.instance)
RadioAudioEngine.instance().reconfigure()
}
})
}
Text(if (vadActive) "VAD активен: микрофон включён" else "VAD неактивен",
style = MaterialTheme.typography.bodySmall)
Text("Работает 15 минут при включённом экране. При следующем включении экрана запускается автоматически.",
style = MaterialTheme.typography.bodySmall)
LabeledSlider("Порог речи:", vadThreshold.toFloat(), 10f..95f, 84, "$vadThreshold%",
{ vadThreshold = it.toInt() }, onValueChangeFinished = {
scope.launch {
provider.setValue("radio.vad_threshold", vadThreshold.toString())
NativeLib.setChatSetting("radio_vad_threshold", vadThreshold.toString())
RadioAudioEngine.instance().reconfigure()
}
})
LabeledSlider("Задержка после тишины:", vadHangover.toFloat(), 50f..2000f, 38, "$vadHangover мс",
{ vadHangover = it.toInt() }, onValueChangeFinished = {
scope.launch {
provider.setValue("radio.vad_hangover_ms", vadHangover.toString())
NativeLib.setChatSetting("radio_vad_hangover_ms", vadHangover.toString())
RadioAudioEngine.instance().reconfigure()
}
})
HorizontalDivider(Modifier.padding(vertical = 4.dp))
/* ── Volume-кнопки PTT ── */
var volumePtt by remember { mutableStateOf(provider.getRadioVolumePtt()) }
Row(verticalAlignment = Alignment.CenterVertically) {
@ -522,11 +561,10 @@ private fun RadioSettingsTab() {
}
Switch(checked = overlayEnabled && canOverlay, enabled = canOverlay, onCheckedChange = {
overlayEnabled = it
scope.launch { provider.setRadioOverlay(it) }
if (it && RadioAudioEngine.instance().isActive())
RadioOverlayService.start(ChatApplication.instance)
else
RadioOverlayService.stop(ChatApplication.instance)
scope.launch {
provider.setRadioOverlay(it)
RadioOverlayService.sync(ChatApplication.instance)
}
})
}
@ -547,8 +585,10 @@ private fun RadioSettingsTab() {
micSources.forEachIndexed { i, n ->
DropdownMenuItem(text = { Text(n) }, onClick = {
micSource = i
scope.launch { provider.setRadioMicSource(i) }
RadioAudioEngine.instance().reconfigure()
scope.launch {
provider.setRadioMicSource(i)
RadioAudioEngine.instance().reconfigure()
}
micExpanded = false
})
}
@ -567,9 +607,11 @@ private fun RadioSettingsTab() {
}
Switch(checked = captureStereo, onCheckedChange = {
captureStereo = it
scope.launch { provider.setRadioCaptureStereo(it) }
NativeLib.setChatSetting("radio_capture_stereo", if (it) "1" else "0")
RadioAudioEngine.instance().reconfigure()
scope.launch {
provider.setRadioCaptureStereo(it)
NativeLib.setChatSetting("radio_capture_stereo", if (it) "1" else "0")
RadioAudioEngine.instance().reconfigure()
}
})
}

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

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

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;
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioCaptureStart(
JNIEnv* env, jobject thiz, jlong groupId, jint channels, jboolean vad) {
(void)env; (void)thiz;
return radio_audio_capture_start((uint64_t)groupId, (int)channels, vad ? 1 : 0) == 0 ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioCaptureStop(JNIEnv* env, jobject thiz) {
(void)env; (void)thiz;
radio_audio_capture_stop();
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioFeed(
JNIEnv* env, jobject thiz, jlong groupId, jshortArray samples) {
(void)thiz;
@ -2461,6 +2472,11 @@ JNIEXPORT void JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioAudioStop(
utun_bridge_radio_audio_stop();
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioTransmitting(JNIEnv* env, jobject thiz) {
(void)env; (void)thiz;
return radio_audio_transmitting() ? JNI_TRUE : JNI_FALSE;
}
JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioVadMode(
JNIEnv* env, jobject thiz) {
(void)env; (void)thiz;

34
tools/chatgui/tests/test_radio_audio.cpp

@ -101,6 +101,40 @@ static int pull_max_abs(int npulls) {
int main() {
printf("=== test_radio_audio ===\n"); fflush(stdout);
TEST("RX-only: захват выключен, приём декодируется"); {
int rc = radio_audio_start(GID, 0);
push_const(SRC_A, 1, 10000, 8);
int mx = pull_max_abs(16);
int active = radio_audio_active();
int talking = radio_audio_transmitting();
radio_audio_stop();
if (rc == 0 && active && !talking && mx > 1000) OK();
else FAIL("rc=%d active=%d talking=%d max=%d", rc, active, talking, mx);
}
TEST("TX stop/start mono/stereo сохраняет накопленный RX PCM"); {
const int npulls = 16;
int freqs[6] = {300, 1000, 2000, 3000, 4000, 5000};
uint8_t pkt[6][256];
int lens[6], seqs[6] = {0, 1, 2, 3, 4, 5};
int16_t ref[npulls * 960], result[npulls * 960];
int ok = 0;
if (encode_frames(freqs, 6, pkt, lens)) {
run_burst(pkt, lens, seqs, seqs, 6, ref, npulls);
ok = radio_audio_start(GID, 0) == 0;
feed_frames(pkt, lens, seqs, seqs, 6);
for (int ch = 1; ch <= 2; ch++) {
ok = (radio_audio_capture_start(GID, ch, 0) == 0) && ok;
radio_audio_capture_stop();
ok = radio_audio_active() && !radio_audio_transmitting() && ok;
}
for (int i = 0; i < npulls; i++) radio_audio_pull_pcm(GID, result + i * 960, 960);
ok = memcmp(ref, result, sizeof(ref)) == 0 && ok;
radio_audio_stop();
}
if (ok) OK(); else FAIL("TX lifecycle изменил RX или не сменил формат захвата");
}
TEST("single-source: decode через jitter-буфер выдаёт ненулевой PCM"); {
radio_audio_start(GID, 1);
push_const(SRC_A, 1, 10000, 8);

Loading…
Cancel
Save