diff --git a/doc/tasks.md b/doc/tasks.md index 6ad80f42..d3098ffa 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -3,6 +3,16 @@ Сюда пишется список задач с короткой аннотацией. Если задача большая и имеет ТЗ - то ТЗ оформляется отдельным файлом, а сюда помещается аннотация и ссылка на ТЗ. ## Текущая задача +[+] **ChatGUI: автопередача рации по уровню сигнала** — 08.10.2026: + выбор VAD/уровень, RMS 20мс после AEC до компрессоров, два порога dBFS (-35/-45 по умолчанию), + подтверждение 40мс и общая задержка отключения. Измеритель работает при включённой рации даже без автоматики. + Общие FSM, prefetch 600мс, приоритет PTT и RX-защита; уровень работает без Silero. + INI: radio_auto_enabled, radio_auto_mode (1=VAD, 2=уровень), radio_auto_hangover_ms, + radio_level_on_dbfs/radio_level_off_dbfs. Пара проверяется перед сохранением; ядро отключает автоматику + при некорректной паре из INI. Общие настройки/API переименованы в Android без изменения его VAD-интерфейса. + Проверки: FSM, voice_tx (PCM/Opus/prefetch/RX/AGC), radio_panel (настройки/индикатор/INI), + radio_member_mute, voice_audio_io, audio_recovery, gui_language — PASS; ChatGUI собран. + [+] **Индикация говорящих в списке участников Android** — 07.10.2026: динамик без волн — молчит, с волнами — говорит; красное перечёркивание сохраняется при mute, включая речь. Отдельный набор говорящих активной рации переживает обновления участников и просмотр другого канала; @@ -402,12 +412,12 @@ Сборка: `--with-silero-vad` (autotools), `-DHAVE_SILERO_VAD` + libonnxruntime (chatgui CMake, Android NDK). `make check`: 111/0/1. -[+] **Авто-PTT рации по VAD** — сделано. Отдельный режим `radio_vad_enabled` (ручной +[+] **Авто-PTT рации по VAD** — сделано. Отдельный режим `radio_auto_enabled` (ручной PTT не меняется, при зажатой кнопке — override). Сервисный слой `radio_audio.c`: ресемплер 48k→16k моно (3-tap box + 3:1, стерео микшируется), накопитель окон - 512, фильтр-гистерезис + машина состояний (`src/radio/radio_vad.h`, чистый, + 512, фильтр-гистерезис + машина состояний (`src/radio/radio_auto.h`, чистый, юнит-тестируемый): старт при подтверждении 2 окон (~64мс) выше порога - `radio_vad_threshold`, стоп при тишине `radio_vad_hangover_ms` (200мс) или чужом + `radio_vad_threshold`, стоп при тишине `radio_auto_hangover_ms` (200мс) или чужом разговоре (busy = RX-кадр <200мс). Перед BEGIN сохраняется до 600мс непереданного PCM; prefetch отправляется одной задачей перед live без повторов и задержки на стороне отправителя. VAD использует отдельный компрессор с собственной историей усиления. Desktop `radio_audio_engine.cpp` и Android diff --git a/src/chat/chat_setting.c b/src/chat/chat_setting.c index 387ea690..fca49dee 100644 --- a/src/chat/chat_setting.c +++ b/src/chat/chat_setting.c @@ -42,10 +42,13 @@ static const struct chat_setting_def g_setting_defs[] = { {"compressor_rise_rate", CHAT_SETTING_INT, 2, 1, 100}, {"radio_compressor_enabled", CHAT_SETTING_BOOL, 1, 0, 1}, {"radio_compressor_max_gain_db",CHAT_SETTING_INT, 15, 1, 60}, - {"radio_vad_enabled", CHAT_SETTING_BOOL, 0, 0, 1}, + {"radio_auto_mode", CHAT_SETTING_INT, 1, 1, 2}, /* 1=VAD, 2=уровень */ + {"radio_level_on_dbfs", CHAT_SETTING_INT, -35, -90, 0}, + {"radio_level_off_dbfs", CHAT_SETTING_INT, -45, -90, 0}, + {"radio_auto_enabled", CHAT_SETTING_BOOL, 0, 0, 1}, {"radio_vad_compressor_max_gain_db", CHAT_SETTING_INT, 15, 1, 60}, {"radio_vad_threshold", CHAT_SETTING_INT, 50, 10, 95}, /* порог вероятности речи, % */ - {"radio_vad_hangover_ms", CHAT_SETTING_INT, 200, 50, 2000},/* тишина до авто-отбоя */ + {"radio_auto_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}, {"dm_media_ttl_sec", CHAT_SETTING_INT, 604800, 1, 31536000}, /* TTL custody M: 7 суток */ diff --git a/src/radio/radio_audio.c b/src/radio/radio_audio.c index 0c5e4643..ac94e471 100644 --- a/src/radio/radio_audio.c +++ b/src/radio/radio_audio.c @@ -15,6 +15,7 @@ #include "radio.h" #include "radio_proto.h" #include "radio_vad.h" +#include "radio_auto.h" #include "radio_jitter.h" #include "../chat/chat_setting.h" #include "../utun_instance.h" @@ -46,9 +47,9 @@ #define RADIO_AUDIO_MIN_TEMPO 0.75 /* темп при пустом буфере (замедление) */ #define RADIO_AUDIO_MAX_TEMPO 1.6 /* темп при глубоком буфере (догон) */ -/* ── VAD авто-PTT (отдельный режим, ручной PTT не меняется) ── */ +/* ── Автопередача VAD/уровень, ручной PTT имеет приоритет ── */ #define RADIO_VAD_CONFIRM_WINDOWS 2 /* окон (32мс) выше порога для старта (~64мс) */ -#define RADIO_VAD_BUSY_TB 2000 /* 200мс после завершения чужой речи и её вывода */ +#define RADIO_AUTO_BUSY_TB 2000 /* 200мс после завершения чужой речи и её вывода */ #define RADIO_PREFETCH_MS 600 /* PCM только до начала передачи */ #define RADIO_PREFETCH_FRAMES (RADIO_AUDIO_SAMPLE_RATE * RADIO_PREFETCH_MS / 1000) #define RADIO_PREFETCH_PACKETS (RADIO_PREFETCH_FRAMES / RADIO_AUDIO_FRAME_SAMPLES) @@ -92,8 +93,8 @@ static struct radio_source g_sources[RADIO_AUDIO_MAX_SOURCES]; static int32_t g_mix[RADIO_AUDIO_MIX_SAMPLES]; static uint64_t g_last_stats_tb = 0; -/* ── VAD авто-PTT: состояние (под g_mtx) ── */ -static int g_vad_mode = 0; /* режим включён для текущей группы */ +/* ── Автопередача: общее состояние и ресурсы VAD (под g_mtx) ── */ +static int g_auto_mode = 0; /* режим включён для текущей группы */ static silero_vad_t* g_vad = NULL; /* детектор (16кГц), создаётся в start */ static struct audio_compressor* g_vad_compressor = NULL; /* отдельная история и gain, только для детектора */ static int16_t g_vad_pcm[RADIO_AUDIO_MIX_SAMPLES]; /* копия 20мс PCM до TX-компрессора */ @@ -101,16 +102,28 @@ static int g_vad_pcm_frames = 0; static double g_vad_input_sum_sq = 0; static float g_vad_input_rms = 0, g_vad_output_rms = 0; static float g_vad_threshold = 0.5f; -static int g_vad_hangover_ms = 200; -static int g_vad_manual_ptt = 0; /* PTT override: зажата — VAD не вмешивается */ +static int g_auto_hangover_ms = 200; +static int g_manual_ptt = 0; /* PTT override: зажата — детектор не вмешивается */ static float g_vad_win[RADIO_VAD_WINDOW_16K]; /* накопитель окон 16кГц */ static int g_vad_win_len = 0; -static struct radio_vad_fsm g_vad_fsm; +static struct radio_auto_fsm g_auto_fsm; static uint64_t g_last_rx_tb = 0; /* последний принятый аудио-кадр (busy) */ static uint64_t g_last_rx_node = 0; /* источник блокировки авто-PTT */ static uint64_t g_rx_guard_until = 0; static uint64_t g_output_position = 0, g_output_voice_end = 0, g_output_played = 0; -static int g_output_tracked = 0, g_vad_blocked = 0; +static int g_output_tracked = 0, g_auto_blocked = 0; + +/* Измеряем после AEC, до обоих компрессоров. Часы уровня — число принятых PCM-фреймов. */ +static double g_level_sum_sq; +static int g_level_frames; +static uint64_t g_level_position, g_level_updated_tb, g_level_stats_tb; +static float g_level_dbfs = -120, g_level_on = -35, g_level_off = -45; + +static void radio_audio_level_reset(void) { + g_level_sum_sq = 0; g_level_frames = 0; + g_level_position = g_level_updated_tb = g_level_stats_tb = 0; + g_level_dbfs = -120; +} /* TX-статистика (кадрирование/кодирование), выводится раз в секунду пока идёт PTT. */ static uint64_t g_tx_stats_tb = 0; @@ -453,17 +466,22 @@ int radio_audio_start(uint64_t group_id, int capture_channels) { g_active = 1; g_last_rx_tb = g_last_rx_node = 0; g_rx_guard_until = g_output_position = g_output_voice_end = g_output_played = 0; - g_output_tracked = g_vad_blocked = 0; + g_output_tracked = g_auto_blocked = 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)); + int rc = radio_audio_capture_start(group_id, capture_channels, chat_setting_get_int(g_inst, "radio_auto_enabled", 0) + ? chat_setting_get_int(g_inst, "radio_auto_mode", RADIO_AUTO_VAD) : RADIO_AUTO_OFF); if (rc != 0) radio_audio_stop(); return rc; } -int radio_audio_capture_start(uint64_t group_id, int capture_channels, int vad_enabled) { +int radio_audio_capture_start(uint64_t group_id, int capture_channels, int auto_mode) { + if (auto_mode < RADIO_AUTO_OFF || auto_mode > RADIO_AUTO_LEVEL) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: invalid auto mode=%d", RADIO_AUDIO_ID, auto_mode); + return -1; + } if (capture_channels != 1 && capture_channels != 2) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: capture invalid channels=%d", RADIO_AUDIO_ID, capture_channels); return -1; @@ -508,10 +526,19 @@ int radio_audio_capture_start(uint64_t group_id, int capture_channels, int vad_e accfg.rise_rate_per_sec, accfg.release_rate_per_sec); /* VAD анализирует текущие кадры; prefetch сохраняет предшествующий непереданный PCM. */ - g_vad_mode = 0; - if (vad_enabled) { + g_auto_mode = 0; + g_auto_hangover_ms = chat_setting_get_int(g_inst, "radio_auto_hangover_ms", 200); + g_level_on = chat_setting_get_int(g_inst, "radio_level_on_dbfs", -35); + g_level_off = chat_setting_get_int(g_inst, "radio_level_off_dbfs", -45); + if (auto_mode == RADIO_AUTO_LEVEL) { + if (g_level_off < g_level_on) g_auto_mode = RADIO_AUTO_LEVEL; + else DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: invalid level thresholds on=%.1f off=%.1f — auto-PTT disabled", + RADIO_AUDIO_ID, g_level_on, g_level_off); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: level config ready=%d on=%.1fdBFS off=%.1fdBFS confirm=2x20ms hangover=%dms prefetch=%dms", + RADIO_AUDIO_ID, g_auto_mode != 0, g_level_on, g_level_off, g_auto_hangover_ms, RADIO_PREFETCH_MS); + } + if (auto_mode == RADIO_AUTO_VAD) { g_vad_threshold = (float)chat_setting_get_int(g_inst, "radio_vad_threshold", 50) / 100.0f; - g_vad_hangover_ms = chat_setting_get_int(g_inst, "radio_vad_hangover_ms", 200); accfg.max_gain_db = (float)chat_setting_get_int(g_inst, "radio_vad_compressor_max_gain_db", 15); if (compressor_enabled) { g_vad_compressor = audio_compressor_create(); @@ -523,31 +550,32 @@ int radio_audio_capture_start(uint64_t group_id, int capture_channels, int vad_e pthread_mutex_unlock(&g_mtx); silero_vad_t* vad = silero_vad_create_default(); pthread_mutex_lock(&g_mtx); - g_vad = vad; g_vad_mode = vad != NULL; + g_vad = vad; g_auto_mode = vad ? RADIO_AUTO_VAD : RADIO_AUTO_OFF; } else DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "%s: VAD compressor create failed — auto-PTT disabled", RADIO_AUDIO_ID); if (!g_vad && (!compressor_enabled || g_vad_compressor)) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: silero_vad_create_default failed — VAD auto-PTT disabled", RADIO_AUDIO_ID); } - if (!g_vad_mode && g_vad_compressor) { audio_compressor_destroy(g_vad_compressor); g_vad_compressor = NULL; } + if (!g_auto_mode && g_vad_compressor) { audio_compressor_destroy(g_vad_compressor); g_vad_compressor = NULL; } DEBUG_INFO(DEBUG_CATEGORY_VAD, "VAD config group=%016llx requested=%d ready=%d ch=%d threshold=%.3f confirm=%dx32ms " "hangover=%dms busy_hold=%dms prefetch=%dms compressor=%s max_gain=%.0fdB target=%.1fdBFS " "history=%dms rise=%.1fx release=%.1fx block=%dms buffering=%dms", (unsigned long long)group_id, - vad_enabled, g_vad_mode, capture_channels, g_vad_threshold, RADIO_VAD_CONFIRM_WINDOWS, - g_vad_hangover_ms, RADIO_VAD_BUSY_TB / 10, RADIO_PREFETCH_MS, + auto_mode, g_auto_mode, capture_channels, g_vad_threshold, RADIO_VAD_CONFIRM_WINDOWS, + g_auto_hangover_ms, RADIO_AUTO_BUSY_TB / 10, RADIO_PREFETCH_MS, g_vad_compressor ? "on" : (compressor_enabled ? "unavailable" : "off"), accfg.max_gain_db, 20.0f * log10f(accfg.target_level), accfg.lookback_ms, accfg.rise_rate_per_sec, accfg.release_rate_per_sec, accfg.block_duration_ms, g_vad_compressor ? accfg.block_duration_ms : 0); } + radio_audio_level_reset(); g_vad_pcm_frames = 0; g_vad_input_sum_sq = 0; g_vad_input_rms = g_vad_output_rms = 0; - if (g_vad_mode) { + if (g_auto_mode) { g_vad_win_len = 0; - radio_vad_fsm_reset(&g_vad_fsm); + radio_auto_fsm_reset(&g_auto_fsm); memset(&g_vad_diag, 0, sizeof(g_vad_diag)); g_vad_diag.last_busy = -1; } - g_vad_manual_ptt = g_transmitting = g_tx_pcm_frames = g_vad_decimation = 0; - g_vad_blocked = 0; + g_manual_ptt = g_transmitting = g_tx_pcm_frames = g_vad_decimation = 0; + g_auto_blocked = 0; g_vad_sum = 0; g_tx_burst = 0; g_prefetch_write = g_prefetch_frames = g_tx_warned = 0; @@ -556,10 +584,10 @@ int radio_audio_capture_start(uint64_t group_id, int capture_channels, int vad_e g_compressor = ac; g_capture_channels = capture_channels; DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: capture started grp=%016llx ch=%d opus=%s bitrate=%dkbps compressor=%s " - "vad=%d threshold=%.2f hangover=%dms prefetch=%dms", + "auto_mode=%d threshold=%.2f hangover=%dms prefetch=%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, g_vad_mode ? RADIO_PREFETCH_MS : 0); + ac ? (audio_compressor_is_enabled(ac) ? "on" : "off") : "unavailable", g_auto_mode, g_vad_threshold, + g_auto_hangover_ms, g_auto_mode ? RADIO_PREFETCH_MS : 0); radio_tx_unlock(); return 0; } @@ -567,10 +595,10 @@ int radio_audio_capture_start(uint64_t group_id, int capture_channels, int vad_e void radio_audio_capture_stop(void) { radio_tx_lock(); if (g_encoder) { - if (g_vad_mode) DEBUG_INFO(DEBUG_CATEGORY_VAD, "VAD capture stop group=%016llx windows=%llu talking=%d manual=%d " + if (g_auto_mode == RADIO_AUTO_VAD) DEBUG_INFO(DEBUG_CATEGORY_VAD, "VAD capture stop group=%016llx windows=%llu talking=%d manual=%d " "transmitting=%d confirm=%d window_tail=%d pcm_tail=%d gain=%.1fdB", (unsigned long long)g_group_id, (unsigned long long)g_vad_diag.total_windows, - g_vad_fsm.talking, g_vad_manual_ptt, g_transmitting, g_vad_fsm.confirm_count, + g_auto_fsm.talking, g_manual_ptt, g_transmitting, g_auto_fsm.confirm_count, g_vad_win_len, g_vad_pcm_frames, audio_compressor_gain_db(g_vad_compressor)); if (g_transmitting) { radio_audio_flush_tx(); @@ -586,13 +614,14 @@ void radio_audio_capture_stop(void) { audio_compressor_destroy(g_vad_compressor); g_vad_compressor = NULL; } if (g_vad) { silero_vad_destroy(g_vad); g_vad = NULL; } - g_vad_mode = 0; - g_vad_manual_ptt = 0; + g_auto_mode = 0; + radio_audio_level_reset(); + g_manual_ptt = 0; g_vad_win_len = 0; g_vad_pcm_frames = 0; g_vad_input_sum_sq = 0; g_vad_input_rms = g_vad_output_rms = 0; g_prefetch_write = g_prefetch_frames = 0; - radio_vad_fsm_reset(&g_vad_fsm); + radio_auto_fsm_reset(&g_auto_fsm); DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: capture stopped", RADIO_AUDIO_ID); } radio_tx_unlock(); @@ -622,7 +651,7 @@ void radio_audio_reset_playback(uint64_t group_id) { if (g_active && g_group_id == group_id) { for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; ++i) radio_src_free(&g_sources[i]); g_output_position = g_output_voice_end = g_output_played = 0; - radio_audio_rx_guard(get_time_tb() + RADIO_VAD_BUSY_TB); + radio_audio_rx_guard(get_time_tb() + RADIO_AUTO_BUSY_TB); DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: playback reset group=%016llx", RADIO_AUDIO_ID, (unsigned long long)group_id); } pthread_mutex_unlock(&g_mtx); @@ -639,10 +668,10 @@ void radio_audio_playback_progress(uint64_t group_id, uint64_t position, uint64_ (unsigned long long)group_id); g_output_tracked = 1; if (g_output_voice_end > g_output_played && position >= g_output_voice_end) { - radio_audio_rx_guard(get_time_tb() + delay_tb + RADIO_VAD_BUSY_TB); + radio_audio_rx_guard(get_time_tb() + delay_tb + RADIO_AUTO_BUSY_TB); int sources = 0; for (int i = 0; i < RADIO_AUDIO_MAX_SOURCES; ++i) sources += g_sources[i].used != 0; - if (!sources) DEBUG_DEBUG(DEBUG_CATEGORY_VAD, "VAD output drained group=%016llx position=%llu delay=%.1fms guard=200ms", + if (!sources) DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "Auto output drained group=%016llx position=%llu delay=%.1fms guard=200ms", (unsigned long long)group_id, (unsigned long long)position, delay_tb / 10.0); } g_output_played = position; @@ -659,10 +688,10 @@ int radio_audio_active(void) { return a; } -int radio_audio_vad_mode(void) { +int radio_audio_auto_mode(void) { int m = 0; pthread_mutex_lock(&g_mtx); - m = g_vad_mode; + m = g_auto_mode; pthread_mutex_unlock(&g_mtx); return m; } @@ -750,7 +779,7 @@ void radio_audio_talk_begin(uint64_t group_id) { 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_manual_ptt = 1; radio_audio_update_transmission(); radio_tx_unlock(); } @@ -758,18 +787,18 @@ void radio_audio_talk_begin(uint64_t group_id) { void radio_audio_talk_end(uint64_t group_id) { if (!g_inst || !g_inst->ua) return; radio_tx_lock(); - if (!g_active || group_id != g_group_id || !g_vad_manual_ptt) { + if (!g_active || group_id != g_group_id || !g_manual_ptt) { radio_tx_unlock(); 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_manual_ptt = 0; radio_audio_update_transmission(); radio_tx_unlock(); } -/* ── VAD авто-PTT: helpers (под g_mtx) ── */ +/* ── Автопередача: helpers (под g_mtx) ── */ /* Один 20мс кадр, общий путь AGC/Opus для prefetch и live; под g_mtx. */ static int radio_audio_encode_frame(const int16_t* frame, uint8_t* opus) { @@ -833,19 +862,19 @@ static void radio_audio_send_prefetch(struct radio_audio_tx* task) { frames, frames * 1000 / RADIO_AUDIO_SAMPLE_RATE, packets, g_tx_pcm_frames, g_capture_channels); } -/* Ручной PTT имеет приоритет; смена manual/VAD при открытой серии не создаёт второй BEGIN. */ +/* Ручной PTT имеет приоритет; смена manual/auto при открытой серии не создаёт второй BEGIN. */ static void radio_audio_update_transmission(void) { - int busy = g_vad_mode && radio_audio_rx_busy(get_time_tb()); + int busy = g_auto_mode && radio_audio_rx_busy(get_time_tb()); if (busy) { - radio_vad_fsm_reset(&g_vad_fsm); + radio_auto_fsm_reset(&g_auto_fsm); g_prefetch_write = g_prefetch_frames = 0; } - int desired = g_vad_manual_ptt || (g_vad_mode && g_vad_fsm.talking && !busy); + int desired = g_manual_ptt || (g_auto_mode && g_auto_fsm.talking && !busy); if (desired == g_transmitting) return; struct radio_audio_tx* prefetch = NULL; if (!desired) radio_audio_flush_tx(); else { - int packets = g_vad_mode ? g_prefetch_frames / RADIO_AUDIO_FRAME_SAMPLES : 0; + int packets = g_auto_mode ? g_prefetch_frames / RADIO_AUDIO_FRAME_SAMPLES : 0; if (packets) { prefetch = radio_audio_tx_alloc(g_inst, packets); if (!prefetch) return; @@ -854,29 +883,30 @@ static void radio_audio_update_transmission(void) { } if (radio_audio_post_talk(g_inst ? g_inst->ua : NULL, g_group_id, desired) != 0) { u_free(prefetch); return; } g_transmitting = desired; - DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: TX %s generation=%llu burst=%llu manual=%d vad=%d", RADIO_AUDIO_ID, + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: TX %s generation=%llu burst=%llu manual=%d auto=%d mode=%d", RADIO_AUDIO_ID, desired ? "BEGIN" : "FIN", (unsigned long long)g_tx_generation, (unsigned long long)g_tx_burst, - g_vad_manual_ptt, g_vad_fsm.talking); - if (desired && g_vad_mode) radio_audio_send_prefetch(prefetch); + g_manual_ptt, g_auto_fsm.talking, g_auto_mode); + if (desired && g_auto_mode) radio_audio_send_prefetch(prefetch); } /* Только TX-writer: RX лишь публикует занятость, не трогая Silero во время inference без g_mtx. */ -static int radio_audio_vad_block(uint64_t now) { +static int radio_audio_auto_block(uint64_t now) { int busy = radio_audio_rx_busy(now); - if (busy != g_vad_blocked) { - DEBUG_DEBUG(DEBUG_CATEGORY_VAD, "VAD gate group=%016llx reason=%s rx_node=%016llx guard_left=%.1fms " + if (busy != g_auto_blocked) { + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "Auto gate group=%016llx reason=%s rx_node=%016llx guard_left=%.1fms " "output_pending=%llu manual=%d transmitting=%d prefetch=%d", (unsigned long long)g_group_id, busy ? "rx_busy" : "rx_released", (unsigned long long)g_last_rx_node, g_rx_guard_until > now ? (g_rx_guard_until - now) / 10.0 : 0.0, (unsigned long long)(g_output_voice_end > g_output_played ? g_output_voice_end - g_output_played : 0), - g_vad_manual_ptt, g_transmitting, g_prefetch_frames); - g_vad_blocked = busy; + g_manual_ptt, g_transmitting, g_prefetch_frames); + g_auto_blocked = busy; + g_level_frames = 0; g_level_sum_sq = 0; g_vad_win_len = g_vad_decimation = 0; g_vad_sum = 0; g_vad_pcm_frames = 0; g_vad_input_sum_sq = 0; g_vad_input_rms = g_vad_output_rms = 0; if (g_vad_compressor) audio_compressor_reset(g_vad_compressor); if (g_vad) silero_vad_reset(g_vad); - radio_vad_fsm_reset(&g_vad_fsm); + radio_auto_fsm_reset(&g_auto_fsm); g_prefetch_write = g_prefetch_frames = 0; radio_audio_update_transmission(); } @@ -889,13 +919,14 @@ void radio_audio_capture_discontinuity(uint64_t group_id) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: capture discontinuity tail=%d transmitting=%d vad_tail=%d vad_gain=%.1fdB", RADIO_AUDIO_ID, g_tx_pcm_frames, g_transmitting, g_vad_pcm_frames, audio_compressor_gain_db(g_vad_compressor)); radio_audio_flush_tx(); + radio_audio_level_reset(); g_vad_win_len = g_vad_decimation = 0; g_vad_sum = 0; g_vad_pcm_frames = 0; g_vad_input_sum_sq = 0; g_vad_input_rms = g_vad_output_rms = 0; if (g_vad_compressor) audio_compressor_reset(g_vad_compressor); g_prefetch_write = g_prefetch_frames = 0; if (g_vad) silero_vad_reset(g_vad); - radio_vad_fsm_reset(&g_vad_fsm); + radio_auto_fsm_reset(&g_auto_fsm); memset(&g_vad_diag, 0, sizeof(g_vad_diag)); g_vad_diag.last_busy = -1; radio_audio_update_transmission(); @@ -938,39 +969,39 @@ static void radio_audio_vad_sample(const int16_t* frame) { DEBUG_ERROR(DEBUG_CATEGORY_VAD, "VAD inference failed group=%016llx window=%llu rc=%d inference=%.1fms", (unsigned long long)g_group_id, (unsigned long long)g_vad_diag.total_windows, result, inference_tb / 10.0); } - if (radio_audio_vad_block(now)) return; /* RX мог начаться, пока Silero работал без общего mutex. */ - struct radio_vad_fsm before = g_vad_fsm; + if (radio_audio_auto_block(now)) return; /* RX мог начаться, пока Silero работал без общего mutex. */ + struct radio_auto_fsm before = g_auto_fsm; int busy = 0; int64_t rx_age_ms = g_last_rx_tb ? (int64_t)((now - g_last_rx_tb) / 10) : -1; - int transition = radio_vad_fsm_update(&g_vad_fsm, probability, g_vad_threshold, RADIO_VAD_CONFIRM_WINDOWS, - (uint64_t)g_vad_hangover_ms * 10, busy, now); - uint64_t silence_tb = g_vad_fsm.silence_since_tb ? g_vad_fsm.silence_since_tb : before.silence_since_tb; + int transition = radio_auto_fsm_update(&g_auto_fsm, probability, g_vad_threshold, g_vad_threshold, RADIO_VAD_CONFIRM_WINDOWS, + (uint64_t)g_auto_hangover_ms * 10, busy, now); + uint64_t silence_tb = g_auto_fsm.silence_since_tb ? g_auto_fsm.silence_since_tb : before.silence_since_tb; double silence_ms = silence_tb ? (now - silence_tb) / 10.0 : 0; const char* reason = result != 0 ? "inference_error" : busy ? "rx_busy" : transition > 0 ? "confirmed" : - transition < 0 ? "silence_timeout" : g_vad_fsm.talking ? + transition < 0 ? "silence_timeout" : g_auto_fsm.talking ? (probability >= g_vad_threshold ? "speech" : "hangover") : probability >= g_vad_threshold ? "confirming" : before.confirm_count ? "confirm_reset" : "below_threshold"; radio_audio_update_transmission(); - if (transition || busy != g_vad_diag.last_busy || before.confirm_count != g_vad_fsm.confirm_count || - !!before.silence_since_tb != !!g_vad_fsm.silence_since_tb) { + if (transition || busy != g_vad_diag.last_busy || before.confirm_count != g_auto_fsm.confirm_count || + !!before.silence_since_tb != !!g_auto_fsm.silence_since_tb) { DEBUG_DEBUG(DEBUG_CATEGORY_VAD, "VAD decision group=%016llx window=%llu reason=%s probability=%.3f threshold=%.3f " "talking=%d->%d confirm=%d->%d/%d silence=%.1f/%dms busy=%d rx_node=%016llx rx_age=%lldms " "manual=%d transmitting=%d input=%.1fdBFS model=%.1fdBFS gain=%.1fdB inference=%.1fms gap=%.1fms", (unsigned long long)g_group_id, (unsigned long long)g_vad_diag.total_windows, reason, probability, g_vad_threshold, - before.talking, g_vad_fsm.talking, before.confirm_count, g_vad_fsm.confirm_count, RADIO_VAD_CONFIRM_WINDOWS, - silence_ms, g_vad_hangover_ms, busy, (unsigned long long)g_last_rx_node, (long long)rx_age_ms, - g_vad_manual_ptt, g_transmitting, 20.0f * log10f(fmaxf(g_vad_input_rms, 0.000001f)), + before.talking, g_auto_fsm.talking, before.confirm_count, g_auto_fsm.confirm_count, RADIO_VAD_CONFIRM_WINDOWS, + silence_ms, g_auto_hangover_ms, busy, (unsigned long long)g_last_rx_node, (long long)rx_age_ms, + g_manual_ptt, g_transmitting, 20.0f * log10f(fmaxf(g_vad_input_rms, 0.000001f)), 20.0f * log10f(fmaxf(model_rms, 0.000001f)), audio_compressor_gain_db(g_vad_compressor), inference_tb / 10.0, gap_tb / 10.0); } DEBUG_TRACE(DEBUG_CATEGORY_VAD, "VAD window=%llu reason=%s probability=%.4f model_rms=%.6f peak=%.6f " "input_rms=%.6f agc_rms=%.6f confirm=%d talking=%d busy=%d manual=%d tx=%d inference=%.1fms gap=%.1fms", (unsigned long long)g_vad_diag.total_windows, reason, probability, model_rms, peak, g_vad_input_rms, g_vad_output_rms, - g_vad_fsm.confirm_count, g_vad_fsm.talking, busy, g_vad_manual_ptt, g_transmitting, inference_tb / 10.0, gap_tb / 10.0); + g_auto_fsm.confirm_count, g_auto_fsm.talking, busy, g_manual_ptt, g_transmitting, inference_tb / 10.0, gap_tb / 10.0); g_vad_diag.last_busy = busy; if (probability >= g_vad_threshold) ++g_vad_diag.speech; if (busy) ++g_vad_diag.busy; - if (before.confirm_count && !g_vad_fsm.confirm_count && transition != 1) ++g_vad_diag.confirm_resets; + if (before.confirm_count && !g_auto_fsm.confirm_count && transition != 1) ++g_vad_diag.confirm_resets; if (g_vad_diag.windows == 1 || probability < g_vad_diag.min_probability) g_vad_diag.min_probability = probability; if (probability > g_vad_diag.max_probability) g_vad_diag.max_probability = probability; if (g_vad_input_rms > g_vad_diag.max_input_rms) g_vad_diag.max_input_rms = g_vad_input_rms; @@ -987,7 +1018,7 @@ static void radio_audio_vad_sample(const int16_t* frame) { 20.0f * log10f(fmaxf(g_vad_diag.max_input_rms, 0.000001f)), 20.0f * log10f(fmaxf(g_vad_diag.max_model_rms, 0.000001f)), audio_compressor_gain_db(g_vad_compressor), audio_compressor_clip_count(g_vad_compressor), g_vad_diag.max_inference_tb / 10.0, g_vad_diag.max_gap_tb / 10.0, - g_vad_manual_ptt, g_vad_fsm.talking, g_transmitting, g_tx_pending, g_control_pending, g_tx_dropped); + g_manual_ptt, g_auto_fsm.talking, g_transmitting, g_tx_pending, g_control_pending, g_tx_dropped); g_vad_diag.stats_tb = now; g_vad_diag.windows = g_vad_diag.speech = g_vad_diag.busy = g_vad_diag.confirm_resets = g_vad_diag.errors = 0; g_vad_diag.min_probability = g_vad_diag.max_probability = g_vad_diag.max_input_rms = g_vad_diag.max_model_rms = 0; @@ -1010,8 +1041,8 @@ static void radio_audio_vad_feed(const int16_t* frame) { if (audio_compressor_process_frame(g_vad_compressor, g_vad_pcm, g_vad_pcm, count) != 0) { DEBUG_ERROR(DEBUG_CATEGORY_VAD, "%s: VAD compressor frame failed count=%d — auto-PTT disabled until capture restart", RADIO_AUDIO_ID, count); - g_vad_mode = 0; g_prefetch_write = g_prefetch_frames = 0; - radio_vad_fsm_reset(&g_vad_fsm); + g_auto_mode = 0; g_prefetch_write = g_prefetch_frames = 0; + radio_auto_fsm_reset(&g_auto_fsm); radio_audio_update_transmission(); return; } @@ -1021,7 +1052,52 @@ static void radio_audio_vad_feed(const int16_t* frame) { sum_sq += sample * sample; } g_vad_output_rms = sqrtf((float)(sum_sq / count)); - for (int i = 0; i < count && !g_vad_blocked; i += g_capture_channels) radio_audio_vad_sample(g_vad_pcm + i); + for (int i = 0; i < count && !g_auto_blocked; i += g_capture_channels) radio_audio_vad_sample(g_vad_pcm + i); +} + +/* Окна не зависят от разбиения callback; стерео измеряется по энергии, без микширования L/R. */ +static void radio_audio_level_feed(const int16_t* frame) { + for (int ch = 0; ch < g_capture_channels; ++ch) { + double sample = frame[ch] / 32768.0; + g_level_sum_sq += sample * sample; + } + if (++g_level_frames != RADIO_AUDIO_FRAME_SAMPLES) return; + double power = g_level_sum_sq / (RADIO_AUDIO_FRAME_SAMPLES * g_capture_channels); + g_level_dbfs = (float)(10.0 * log10(fmax(power, 1e-12))); + g_level_frames = 0; g_level_sum_sq = 0; + g_level_position += RADIO_AUDIO_FRAME_MS * 10; + g_level_updated_tb = get_time_tb(); + if (g_auto_mode != RADIO_AUTO_LEVEL || g_auto_blocked) return; + struct radio_auto_fsm before = g_auto_fsm; + int transition = radio_auto_fsm_update(&g_auto_fsm, g_level_dbfs, g_level_on, g_level_off, 2, + (uint64_t)g_auto_hangover_ms * 10, 0, g_level_position); + const char* reason = transition > 0 ? "confirmed" : transition < 0 ? "silence_timeout" : + g_auto_fsm.silence_since_tb ? "hangover" : g_auto_fsm.talking ? "holding" : + g_auto_fsm.confirm_count ? "confirming" : "below_threshold"; + if (transition) { + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "%s: level decision=%s level=%.1fdBFS on=%.1f off=%.1f hangover=%dms manual=%d", + RADIO_AUDIO_ID, reason, g_level_dbfs, g_level_on, g_level_off, g_auto_hangover_ms, g_manual_ptt); + } + if (before.confirm_count != g_auto_fsm.confirm_count || !!before.silence_since_tb != !!g_auto_fsm.silence_since_tb || + g_level_updated_tb - g_level_stats_tb >= RADIO_AUDIO_STATS_TB) { + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: level reason=%s value=%.1fdBFS on=%.1f off=%.1f confirm=%d/2 auto=%d manual=%d", + RADIO_AUDIO_ID, reason, g_level_dbfs, g_level_on, g_level_off, g_auto_fsm.confirm_count, + g_auto_fsm.talking, g_manual_ptt); + g_level_stats_tb = g_level_updated_tb; + } + DEBUG_TRACE(DEBUG_CATEGORY_RADIO, "%s: level window value=%.1fdBFS reason=%s position=%llu", + RADIO_AUDIO_ID, g_level_dbfs, reason, (unsigned long long)g_level_position); + radio_audio_update_transmission(); +} + +struct radio_audio_input_status radio_audio_get_input_status(void) { + pthread_mutex_lock(&g_mtx); + struct radio_audio_input_status status = {g_level_dbfs, 0, 0}; + uint64_t now = get_time_tb(); + status.valid = g_active && g_encoder && g_level_updated_tb && now - g_level_updated_tb <= 2000; + status.blocked = g_active && radio_audio_rx_busy(now); + pthread_mutex_unlock(&g_mtx); + return status; } /* Очищенные interleaved отсчёты произвольной длины; границы PTT ставит единственный TX-writer. */ @@ -1032,12 +1108,13 @@ int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count) { DEBUG_WARN(DEBUG_CATEGORY_RADIO, "%s: invalid capture frame count=%d", RADIO_AUDIO_ID, count); return -1; } - if (g_vad_mode) radio_audio_vad_block(get_time_tb()); + if (g_auto_mode) radio_audio_auto_block(get_time_tb()); radio_audio_update_transmission(); // Повторить BEGIN после освобождения bounded control-очереди. for (int offset = 0; offset < count; offset += g_capture_channels) { - if (g_vad_mode && !g_vad_blocked) radio_audio_vad_feed(pcm + offset); + radio_audio_level_feed(pcm + offset); + if (g_auto_mode == RADIO_AUTO_VAD && !g_auto_blocked) radio_audio_vad_feed(pcm + offset); if (!g_transmitting) { - if (g_vad_mode && !g_vad_blocked) { + if (g_auto_mode && !g_auto_blocked) { memcpy(g_prefetch_pcm + g_prefetch_write * g_capture_channels, pcm + offset, g_capture_channels * sizeof(int16_t)); g_prefetch_write = (g_prefetch_write + 1) % RADIO_PREFETCH_FRAMES; if (g_prefetch_frames < RADIO_PREFETCH_FRAMES) ++g_prefetch_frames; @@ -1077,7 +1154,7 @@ void radio_audio_on_frame(struct UTUN_INSTANCE* inst, uint64_t group_id, } } else { /* Переполнение таблицы не должно разрешать VAD поверх отброшенной передачи. */ - radio_audio_rx_guard(now + RADIO_AUDIO_SOURCE_TIMEOUT_TB + RADIO_VAD_BUSY_TB); + radio_audio_rx_guard(now + RADIO_AUDIO_SOURCE_TIMEOUT_TB + RADIO_AUTO_BUSY_TB); } } pthread_mutex_unlock(&g_mtx); @@ -1120,7 +1197,7 @@ int radio_audio_pull_pcm(uint64_t group_id, int16_t* out, int max_samples) { radio_src_flush_pending(s); } if (!s->rj) { - if (s->ending) { radio_src_free(s); radio_audio_rx_guard(now + RADIO_VAD_BUSY_TB); } + if (s->ending) { radio_src_free(s); radio_audio_rx_guard(now + RADIO_AUTO_BUSY_TB); } continue; } /* ending: снять pre-roll (если ещё не снят) и дренировать накопленный остаток. */ @@ -1138,7 +1215,7 @@ int radio_audio_pull_pcm(uint64_t group_id, int16_t* out, int max_samples) { if (n > 0) { uint64_t end = g_output_position + (uint64_t)(n + 1) / RADIO_AUDIO_MIX_CHANNELS; if (end > g_output_voice_end) g_output_voice_end = end; - if (!g_output_tracked) radio_audio_rx_guard(now + (uint64_t)n * 10000 / (48000 * 2) + RADIO_VAD_BUSY_TB); + if (!g_output_tracked) radio_audio_rx_guard(now + (uint64_t)n * 10000 / (48000 * 2) + RADIO_AUTO_BUSY_TB); } if (s->ending && n == 0) { uint32_t dropped = 0, underruns = 0; @@ -1146,7 +1223,7 @@ int radio_audio_pull_pcm(uint64_t group_id, int16_t* out, int max_samples) { DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "%s: source ended src=%016llx dropped=%u underruns=%u", RADIO_AUDIO_ID, (unsigned long long)s->src_node_id, dropped, underruns); radio_src_free(s); - radio_audio_rx_guard(now + RADIO_VAD_BUSY_TB); + radio_audio_rx_guard(now + RADIO_AUTO_BUSY_TB); } } g_output_position += (uint64_t)max_samples / RADIO_AUDIO_MIX_CHANNELS; diff --git a/src/radio/radio_audio.h b/src/radio/radio_audio.h index 9bd77e83..a75de77d 100644 --- a/src/radio/radio_audio.h +++ b/src/radio/radio_audio.h @@ -38,11 +38,21 @@ void radio_audio_destroy(struct UTUN_INSTANCE* inst); * возможности устройства (стерео недоступно → откат на моно). RX всегда стерео. */ int radio_audio_start(uint64_t group_id, int capture_channels); +enum radio_auto_mode { RADIO_AUTO_OFF = 0, RADIO_AUTO_VAD = 1, RADIO_AUTO_LEVEL = 2 }; + +struct radio_audio_input_status { + float level_dbfs; /* RMS за 20мс после AEC, до компрессоров; тишина = -120dBFS. */ + int valid; /* Есть PCM не старше 200мс. */ + int blocked; /* Чужая речь / дренаж вывода / защитная пауза. */ +}; +/* Любой поток: короткий снимок под mutex, без вызовов GUI из аудиопотока. */ +struct radio_audio_input_status radio_audio_get_input_status(void); + /* Независимый TX: start(..., 0) включает только RX, захват готовится по фактическому формату микрофона. * capture_stop отправляет FIN активной передачи и освобождает TX, сохраняя RX. */ -int radio_audio_capture_start(uint64_t group_id, int capture_channels, int vad_enabled); +int radio_audio_capture_start(uint64_t group_id, int capture_channels, int auto_mode); void radio_audio_capture_stop(void); -/* Единственный TX-writer: завершить старый PCM-хвост, сбросить VAD после потери захвата. */ +/* Единственный TX-writer: завершить старый PCM-хвост, сбросить детектор после потери захвата. */ void radio_audio_capture_discontinuity(uint64_t group_id); /* GUI-поток: прекратить прослушивание (освободить всё). */ @@ -57,8 +67,8 @@ void radio_audio_talk_end(uint64_t group_id); /* Единственный TX-writer: очищенные interleaved отсчёты произвольной длины (кратно числу каналов). * VAD использует свою копию PCM и отдельный AGC с общими настройками TX, кроме radio_vad_compressor_max_gain_db. * Включённый AGC VAD накапливает 20мс; выключенный пропускает PCM сразу. TX не использует обработанную VAD-копию. - * При включённом VAD до BEGIN хранится до 600мс непереданного PCM и отправляется перед live. - * Один автомат manual/VAD; хвост каждой серии дополняется тишиной до FIN. */ + * При включённой автоматике до BEGIN хранится до 600мс непереданного PCM и отправляется перед live. + * Один автомат manual/auto; хвост каждой серии дополняется тишиной до FIN. */ int radio_audio_feed_pcm(uint64_t group_id, const int16_t* pcm, int count); /* аудио-поток: выдать до max_samples микшированного PCM (источники микшируются, @@ -69,17 +79,17 @@ int radio_audio_pull_pcm(uint64_t group_id, int16_t* out, int max_samples); * position — число стерео PCM-фреймов из pull, дошедших до устройства (не число int16). * delay_tb — оставшееся время до их физического воспроизведения, 0 при подтверждении playback head. * Позиции начинаются с нуля после start/reset_playback; устаревшие callbacks отсекает владелец I/O. - * VAD ждёт вывода последнего голоса и ещё 200мс. Без отчётов учитывается только выход pull. */ + * Автопередача ждёт вывода последнего голоса и ещё 200мс. Без отчётов учитывается только выход pull. */ void radio_audio_playback_progress(uint64_t group_id, uint64_t position, uint64_t delay_tb); /* 1 = аудио-движок активен (радио канала включено). */ int radio_audio_active(void); -/* 1 = включён VAD-авто-PTT для текущей группы (движки должны фидить PCM постоянно - * и не гасить захват по PTT; ручной PTT остаётся override). */ -int radio_audio_vad_mode(void); +/* Фактический RADIO_AUTO_OFF/VAD/LEVEL; ручной PTT имеет приоритет. + * Оба детектора требуют непрерывный capture, общий prefetch 600мс и RX-защиту. */ +int radio_audio_auto_mode(void); -/* Текущее состояние передачи: ручной PTT либо сработавший VAD. */ +/* Текущее состояние передачи: ручной PTT либо сработавший детектор. */ int radio_audio_transmitting(void); /* uasync-поток: копируем Opus-кадр в jitter источника; декодирование/растяжение — на pull в RX-потоке. */ diff --git a/src/radio/radio_auto.h b/src/radio/radio_auto.h new file mode 100644 index 00000000..03eb9fc4 --- /dev/null +++ b/src/radio/radio_auto.h @@ -0,0 +1,60 @@ +// Общий автомат автопередачи VAD/уровень, без I/O. +#ifndef RADIO_AUTO_H +#define RADIO_AUTO_H +#include + +/* ── машина состояний авто-PTT ── */ + +struct radio_auto_fsm { + int talking; /* идёт автоматическая передача */ + int confirm_count; /* подряд окон с value >= start_threshold */ + uint64_t silence_since_tb; /* начало текущей тишины (0 — не в тишине) */ +}; + +static inline void radio_auto_fsm_reset(struct radio_auto_fsm* f) { + f->talking = 0; + f->confirm_count = 0; + f->silence_since_tb = 0; +} + +/* Общее решение VAD/уровня. VAD передаёт одинаковые пороги вероятности. + * Подтверждение старта — confirm_windows последовательных окон >= start_threshold. + * Передача заканчивается после hangover_tb ниже stop_threshold; busy гасит её сразу. + * now — монотонное время в 0.1мс; для уровня это позиция в захваченном PCM. + * Возврат: 1 = START, -1 = STOP, 0 = без изменений. */ +static inline int radio_auto_fsm_update(struct radio_auto_fsm* f, float value, float start_threshold, float stop_threshold, + int confirm_windows, uint64_t hangover_tb, + int busy, uint64_t now) { + if (busy) { + /* чужой разговор: старт запрещён, свою передачу гасим сразу */ + f->confirm_count = 0; + f->silence_since_tb = 0; + if (f->talking) { f->talking = 0; return -1; } + return 0; + } + if (value >= (f->talking ? stop_threshold : start_threshold)) { + f->silence_since_tb = 0; + if (!f->talking) { + f->confirm_count++; + if (f->confirm_count >= confirm_windows) { + f->confirm_count = 0; + f->talking = 1; + return 1; /* старт: подтверждение набрано */ + } + } + return 0; + } + /* value ниже порога текущего состояния */ + f->confirm_count = 0; + if (f->talking) { + if (f->silence_since_tb == 0) f->silence_since_tb = now; + else if (now - f->silence_since_tb >= hangover_tb) { + f->talking = 0; + f->silence_since_tb = 0; + return -1; /* стоп: hangover истёк */ + } + } + return 0; +} + +#endif diff --git a/src/radio/radio_vad.h b/src/radio/radio_vad.h index 3cd658dc..386a9664 100644 --- a/src/radio/radio_vad.h +++ b/src/radio/radio_vad.h @@ -2,10 +2,9 @@ // // Содержит: // - ресемплер 48кГц→16кГц моно (3-tap box + децимация 3:1, стерео микшируется); -// - фильтр-гистерезис вероятности речи и машину состояний «передаю/молчу». +// Машина состояний автопередачи — в radio_auto.h. // -// Всё — static inline, без состояния (кроме struct radio_vad_fsm, который владеет -// вызывающий). Используется сервисным слоем radio_audio.c и тестом test_radio_vad. +// Используется сервисным слоем radio_audio.c и тестом test_radio_vad. // // Время — в timebase-единицах (0.1мс), как get_time_tb() в ядре. #ifndef RADIO_VAD_H @@ -35,61 +34,4 @@ static inline void radio_vad_resample(const int16_t* pcm, int channels, float* o } } -/* ── машина состояний авто-PTT ── */ - -struct radio_vad_fsm { - int talking; /* идёт VAD-передача */ - int confirm_count; /* подряд окон с prob >= threshold */ - uint64_t silence_since_tb; /* начало текущей тишины (0 — не в тишине) */ -}; - -static inline void radio_vad_fsm_reset(struct radio_vad_fsm* f) { - f->talking = 0; - f->confirm_count = 0; - f->silence_since_tb = 0; -} - -/* Обработать очередную вероятность речи (окно 32мс). - * prob — вероятность [0..1]; - * threshold — порог срабатывания [0..1]; - * confirm_windows — сколько окон подряд выше порога нужно для старта; - * hangover_tb — сколько тишины (tb) держать передачу перед стопом; - * busy — 1 если сейчас говорит кто-то другой (канал занят); - * now — текущее время (tb). - * Возврат: 1 = начать передачу, -1 = закончить передачу, 0 = без изменений. */ -static inline int radio_vad_fsm_update(struct radio_vad_fsm* f, float prob, float threshold, - int confirm_windows, uint64_t hangover_tb, - int busy, uint64_t now) { - if (busy) { - /* чужой разговор: старт запрещён, свою передачу гасим сразу */ - f->confirm_count = 0; - f->silence_since_tb = 0; - if (f->talking) { f->talking = 0; return -1; } - return 0; - } - if (prob >= threshold) { - f->silence_since_tb = 0; - if (!f->talking) { - f->confirm_count++; - if (f->confirm_count >= confirm_windows) { - f->confirm_count = 0; - f->talking = 1; - return 1; /* старт: подтверждение набрано */ - } - } - return 0; - } - /* prob < threshold */ - f->confirm_count = 0; - if (f->talking) { - if (f->silence_since_tb == 0) f->silence_since_tb = now; - else if (now - f->silence_since_tb >= hangover_tb) { - f->talking = 0; - f->silence_since_tb = 0; - return -1; /* стоп: hangover истёк */ - } - } - return 0; -} - #endif /* RADIO_VAD_H */ diff --git a/tests/test_radio_vad.c b/tests/test_radio_vad.c index 31be0cdf..75886003 100644 --- a/tests/test_radio_vad.c +++ b/tests/test_radio_vad.c @@ -7,6 +7,7 @@ #include #include "../src/radio/radio_vad.h" +#include "../src/radio/radio_auto.h" #define PI 3.14159265358979323846 @@ -70,36 +71,36 @@ static void test_resample(void) { static void test_fsm(void) { printf("[radio_vad] fsm (confirm / hangover / busy)\n"); - struct radio_vad_fsm f; + struct radio_auto_fsm f; const float thr = 0.5f; const int confirm = 2; const uint64_t hangover = 2000; /* 200мс */ uint64_t now = 0; /* подтверждение: 2 окна выше порога → старт */ - radio_vad_fsm_reset(&f); + radio_auto_fsm_reset(&f); now += 320; - CHECK(radio_vad_fsm_update(&f, 0.9f, thr, confirm, hangover, 0, now) == 0, "confirm#1 должен молчать"); + CHECK(radio_auto_fsm_update(&f, 0.9f, thr, thr, confirm, hangover, 0, now) == 0, "confirm#1 должен молчать"); now += 320; - CHECK(radio_vad_fsm_update(&f, 0.9f, thr, confirm, hangover, 0, now) == 1, "confirm#2 должен дать START"); + CHECK(radio_auto_fsm_update(&f, 0.9f, thr, thr, confirm, hangover, 0, now) == 1, "confirm#2 должен дать START"); CHECK(f.talking == 1, "после старта talking=1"); /* одиночный пик не запускает передачу */ - radio_vad_fsm_reset(&f); + radio_auto_fsm_reset(&f); now += 320; - CHECK(radio_vad_fsm_update(&f, 0.9f, thr, confirm, hangover, 0, now) == 0, "одиночный пик не стартует"); + CHECK(radio_auto_fsm_update(&f, 0.9f, thr, thr, confirm, hangover, 0, now) == 0, "одиночный пик не стартует"); now += 320; - CHECK(radio_vad_fsm_update(&f, 0.1f, thr, confirm, hangover, 0, now) == 0, "сброс подтверждения"); + CHECK(radio_auto_fsm_update(&f, 0.1f, thr, thr, confirm, hangover, 0, now) == 0, "сброс подтверждения"); /* hangover: после старта тишина ~200мс → STOP */ - radio_vad_fsm_reset(&f); - now += 320; radio_vad_fsm_update(&f, 0.9f, thr, confirm, hangover, 0, now); - now += 320; CHECK(radio_vad_fsm_update(&f, 0.9f, thr, confirm, hangover, 0, now) == 1, "старт"); + radio_auto_fsm_reset(&f); + now += 320; radio_auto_fsm_update(&f, 0.9f, thr, thr, confirm, hangover, 0, now); + now += 320; CHECK(radio_auto_fsm_update(&f, 0.9f, thr, thr, confirm, hangover, 0, now) == 1, "старт"); { int stops = 0; for (int i = 0; i < 10; i++) { now += 320; - int a = radio_vad_fsm_update(&f, 0.1f, thr, confirm, hangover, 0, now); + int a = radio_auto_fsm_update(&f, 0.1f, thr, thr, confirm, hangover, 0, now); if (a == -1) { stops++; break; } CHECK(a == 0, "hangover: до ~200мс не стопим"); } @@ -108,25 +109,45 @@ static void test_fsm(void) { } /* busy: чужой разговор гасит нашу передачу сразу */ - radio_vad_fsm_reset(&f); - now += 320; radio_vad_fsm_update(&f, 0.9f, thr, confirm, hangover, 0, now); - now += 320; radio_vad_fsm_update(&f, 0.9f, thr, confirm, hangover, 0, now); + radio_auto_fsm_reset(&f); + now += 320; radio_auto_fsm_update(&f, 0.9f, thr, thr, confirm, hangover, 0, now); + now += 320; radio_auto_fsm_update(&f, 0.9f, thr, thr, confirm, hangover, 0, now); CHECK(f.talking == 1, "старт перед busy"); now += 320; - CHECK(radio_vad_fsm_update(&f, 0.9f, thr, confirm, hangover, 1, now) == -1, "busy должен дать STOP"); + CHECK(radio_auto_fsm_update(&f, 0.9f, thr, thr, confirm, hangover, 1, now) == -1, "busy должен дать STOP"); /* busy блокирует старт */ - radio_vad_fsm_reset(&f); + radio_auto_fsm_reset(&f); for (int i = 0; i < 5; i++) { now += 320; - CHECK(radio_vad_fsm_update(&f, 0.9f, thr, confirm, hangover, 1, now) == 0, "busy блокирует старт"); + CHECK(radio_auto_fsm_update(&f, 0.9f, thr, thr, confirm, hangover, 1, now) == 0, "busy блокирует старт"); } CHECK(f.talking == 0, "busy: не стартуем"); } +/* Точные границы двух порогов, сброс тишины внутри зоны гистерезиса. */ +static void test_level_fsm(void) { + struct radio_auto_fsm f = {0}; + CHECK(!radio_auto_fsm_update(&f, -40, -35, -45, 2, 2000, 0, 200), "между порогами старт запрещён"); + CHECK(!radio_auto_fsm_update(&f, -35, -35, -45, 2, 2000, 0, 400), "первое окно подтверждения"); + CHECK(!radio_auto_fsm_update(&f, -36, -35, -45, 2, 2000, 0, 600), "сброс одиночного пика"); + CHECK(!f.confirm_count, "подтверждение сброшено"); + CHECK(!radio_auto_fsm_update(&f, -35, -35, -45, 2, 2000, 0, 800), "новое подтверждение"); + CHECK(radio_auto_fsm_update(&f, -35, -35, -45, 2, 2000, 0, 1000) == 1, "равенство порогу включает"); + CHECK(!radio_auto_fsm_update(&f, -45, -35, -45, 2, 2000, 0, 1200) && !f.silence_since_tb, "равенство нижнему удерживает"); + CHECK(!radio_auto_fsm_update(&f, -46, -35, -45, 2, 2000, 0, 1400), "начало тишины"); + CHECK(!radio_auto_fsm_update(&f, -46, -35, -45, 2, 2000, 0, 3399) && f.talking, "до задержки не выключаем"); + CHECK(!radio_auto_fsm_update(&f, -40, -35, -45, 2, 2000, 0, 3400) && !f.silence_since_tb, "зона удержания отменяет тишину"); + CHECK(!radio_auto_fsm_update(&f, -46, -35, -45, 2, 2000, 0, 3600), "новая тишина"); + CHECK(radio_auto_fsm_update(&f, -46, -35, -45, 2, 2000, 0, 5600) == -1, "точное окончание задержки"); + CHECK(!radio_auto_fsm_update(&f, -40, -35, -45, 2, 2000, 0, 5800) && !f.talking, "после стопа зона удержания не запускает"); + CHECK(!radio_auto_fsm_update(&f, 0, -35, -45, 2, 2000, 1, 6000) && !f.confirm_count, "RX блокирует уровень"); +} + int main(void) { test_resample(); test_fsm(); + test_level_fsm(); if (g_failures == 0) { printf("TEST PASSED\n"); return 0; } printf("TEST FAILED (%d failures)\n", g_failures); diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt index e5f802ac..f88a9d4a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/ConfigProvider.kt @@ -70,9 +70,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 radioVadEnabledKey = intPreferencesKey("radio_auto_enabled") private val radioVadThresholdKey = intPreferencesKey("radio_vad_threshold") - private val radioVadHangoverMsKey = intPreferencesKey("radio_vad_hangover_ms") + private val radioVadHangoverMsKey = intPreferencesKey("radio_auto_hangover_ms") private val radioVadCompressorMaxGainDbKey = intPreferencesKey("radio_vad_compressor_max_gain_db") private val radioVolumePttKey = booleanPreferencesKey("radio_volume_ptt") private val radioOverlayKey = booleanPreferencesKey("radio_overlay") @@ -609,9 +609,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_auto_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_auto_hangover_ms=${getInt("radio.vad_hangover_ms")}") appendLine("radio_vad_compressor_max_gain_db=${getInt("radio.vad_compressor_max_gain_db")}") appendLine("radio_opus_mode=$radioOpusMode") appendLine("radio_opus_bitrate=$radioOpusBitrate") diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt index d7668881..7a8e492f 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt @@ -611,7 +611,7 @@ private fun RadioSettingsTab() { { vadHangover = it.toInt() }, onValueChangeFinished = { scope.launch { provider.setValue("radio.vad_hangover_ms", vadHangover.toString()) - NativeLib.setChatSetting("radio_vad_hangover_ms", vadHangover.toString()) + NativeLib.setChatSetting("radio_auto_hangover_ms", vadHangover.toString()) RadioAudioEngine.instance().reconfigure() } }) diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt index e07b0d6b..a62bd6bc 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/viewmodel/ChatViewModel.kt @@ -945,7 +945,7 @@ class ChatViewModel : ViewModel() { viewModelScope.launch { val ctx = ChatApplication.instance ctx.configProvider.setValue("radio.vad_enabled", if (enabled) "1" else "0") - NativeLib.setChatSetting("radio_vad_enabled", if (enabled) "1" else "0") + NativeLib.setChatSetting("radio_auto_enabled", if (enabled) "1" else "0") LogManager.addLog("INFO", "RadioAudio", "Panel VAD enabled=$enabled channel=$radioChannelId") if (enabled) RadioOverlayService.requestPermissions() else RadioOverlayService.sync(ctx) radioAudio.reconfigure(restartVad = enabled) diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index 9996c57f..34e62151 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -1639,7 +1639,7 @@ int utun_bridge_radio_audio_pull(uint64_t group_id, int16_t* out, int max_sample } int utun_bridge_radio_vad_mode(void) { - return radio_audio_vad_mode(); + return radio_audio_auto_mode(); } void utun_bridge_radio_talk_begin(uint64_t group_id) { diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index d83b9b8c..547bdc6c 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -502,16 +502,17 @@ if(CMAKE_SYSTEM_NAME STREQUAL "Linux") add_test(NAME test_audio_recovery COMMAND test_audio_recovery) set_tests_properties(test_audio_recovery PROPERTIES TIMEOUT 15) add_executable(test_radio_panel tests/test_radio_panel.cpp src/radiopanel.cpp src/radio_audio_engine.cpp + src/radiosettingspage.cpp src/ptt_hotkey_manager.cpp src/sound_manager.cpp src/voice_audio_io.cpp src/audio_device.cpp transport/miniaudio_impl.c transport/audio_diagnostics.cpp transport/config_updater.cpp ${RADIO_MEMBER_TEST_SOURCES}) target_include_directories(test_radio_panel PRIVATE ${UTUN_INCLUDE_DIRS}) - target_link_libraries(test_radio_panel PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun_voice utun pthread dl) + target_link_libraries(test_radio_panel PRIVATE ${QT_LIBS} ${QT_TEST_LIB} utun_voice utun pthread dl ${X11_LIBRARIES}) target_link_options(test_radio_panel PRIVATE "-Wl,--wrap=ma_context_init" "-Wl,--wrap=ma_device_start" "-Wl,--wrap=radio_talk_begin" "-Wl,--wrap=radio_talk_end" "-Wl,--wrap=radio_talk_send" "-Wl,--wrap=silero_vad_create_default" "-Wl,--wrap=silero_vad_destroy" "-Wl,--wrap=silero_vad_reset" "-Wl,--wrap=silero_vad_process") add_test(NAME test_radio_panel COMMAND test_radio_panel) - set_tests_properties(test_radio_panel PROPERTIES TIMEOUT 15 ENVIRONMENT "QT_QPA_PLATFORM=offscreen") + set_tests_properties(test_radio_panel PROPERTIES TIMEOUT 25 ENVIRONMENT "QT_QPA_PLATFORM=offscreen") add_executable(test_ptt_portal tests/test_ptt_portal.cpp src/ptt_portal.cpp) target_link_libraries(test_ptt_portal PRIVATE ${QT_DBUS} ${QT_TEST_LIB} utun pthread) find_program(DBUS_RUN_SESSION dbus-run-session) diff --git a/tools/chatgui/src/radio_audio_engine.cpp b/tools/chatgui/src/radio_audio_engine.cpp index 3a503c7f..7f04d345 100644 --- a/tools/chatgui/src/radio_audio_engine.cpp +++ b/tools/chatgui/src/radio_audio_engine.cpp @@ -23,14 +23,15 @@ extern "C" { #include namespace { -constexpr const char* settingKeys[] = {"radio_vad_enabled", "radio_vad_threshold", "radio_vad_hangover_ms", - "radio_compressor_enabled", "radio_compressor_max_gain_db", "radio_vad_compressor_max_gain_db"}; -constexpr int settingMin[] = {0, 10, 50, 0, 1, 1}, settingMax[] = {1, 95, 2000, 1, 60, 60}; +constexpr const char* settingKeys[] = {"radio_auto_enabled", "radio_vad_threshold", "radio_auto_hangover_ms", + "radio_compressor_enabled", "radio_compressor_max_gain_db", "radio_vad_compressor_max_gain_db", + "radio_auto_mode", "radio_level_on_dbfs", "radio_level_off_dbfs"}; +constexpr int settingMin[] = {0, 10, 50, 0, 1, 1, 1, -90, -90}, settingMax[] = {1, 95, 2000, 1, 60, 60, 2, 0, 0}; struct SettingsRequest { posted_task task; // Первое поле: очередь освобождает весь запрос, включая отмену при shutdown. RadioAudioEngine* engine; UTUN_INSTANCE* inst; - std::array values; + std::array values; bool apply; }; } @@ -100,6 +101,12 @@ bool RadioAudioEngine::applySettings(const QMap& values) { } next[index] = it.value(); } + if (next[8] >= next[7]) { + m_settingsError = QCoreApplication::translate("ChatGui", "The stop threshold must be lower than the start threshold"); + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: invalid threshold pair on=%d off=%d", next[7], next[8]); + emit stateChanged(); + return false; + } if (next == m_settings && !m_active) return true; if (next != m_settings && !m_configPath.isEmpty()) { QFile input(m_configPath); @@ -128,7 +135,7 @@ bool RadioAudioEngine::applySettings(const QMap& values) { } /* Возврат в GUI происходит после применения всей группы настроек в uasync. */ -void RadioAudioEngine::postSettings(const std::array& values, bool apply) { +void RadioAudioEngine::postSettings(const std::array& values, bool apply) { auto* inst = gui_bridge_get_inst(); void* memory = inst && gui_bridge_is_uasync_ready() ? u_malloc(sizeof(SettingsRequest)) : nullptr; if (!memory) { @@ -165,9 +172,14 @@ void RadioAudioEngine::postSettings(const std::array& values, bool apply engine->m_settings = snapshot; engine->m_settingsReady = true; engine->m_settingsBusy = false; + if (snapshot[8] >= snapshot[7]) { + engine->m_settingsError = QCoreApplication::translate("ChatGui", "The stop threshold must be lower than the start threshold"); + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: loaded invalid threshold pair on=%d off=%d", snapshot[7], snapshot[8]); + } if (!success) engine->m_settingsError = QCoreApplication::translate("ChatGui", "The core rejected radio settings"); - DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: settings applied=%d vad=%d threshold=%d hangover=%dms TX_AGC=%d " - "TX_gain=%ddB VAD_gain=%ddB", success, snapshot[0], snapshot[1], snapshot[2], snapshot[3], snapshot[4], snapshot[5]); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: settings applied=%d auto=%d threshold=%d hangover=%dms TX_AGC=%d " + "TX_gain=%ddB VAD_gain=%ddB mode=%d level_on=%d level_off=%d", success, snapshot[0], snapshot[1], snapshot[2], + snapshot[3], snapshot[4], snapshot[5], snapshot[6], snapshot[7], snapshot[8]); if ((changed || apply) && engine->m_active) engine->reconfigureCapture(); emit engine->stateChanged(); }, Qt::QueuedConnection); @@ -177,8 +189,8 @@ void RadioAudioEngine::postSettings(const std::array& values, bool apply } void RadioAudioEngine::reconfigureCapture() { - DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: reconfigure capture group=%016llx manual=%d vad=%d", - (unsigned long long)m_groupId.load(), m_talking.load(), vadEnabled()); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: reconfigure capture group=%016llx manual=%d auto=%d", + (unsigned long long)m_groupId.load(), m_talking.load(), autoEnabled()); m_capture.close(); m_voice.stopCapture(); radio_audio_capture_stop(); @@ -226,8 +238,8 @@ void RadioAudioEngine::openCapture() { m_captureChannels = channels; auto* inst = gui_bridge_get_inst(); - if (radio_audio_capture_start(m_groupId.load(), channels, vadEnabled()) != 0) { - DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: core capture start failed ch=%d vad=%d", channels, vadEnabled()); + if (radio_audio_capture_start(m_groupId.load(), channels, autoEnabled() ? autoMode() : RADIO_AUTO_OFF) != 0) { + DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "RadioAudio: core capture start failed ch=%d mode=%d", channels, autoEnabled() ? autoMode() : RADIO_AUTO_OFF); m_capture.close(); m_capture.retryLater(); return; } if (!m_voice.startCapture(channels, chat_setting_get_int(inst, "aec_enabled", 1) != 0)) { @@ -257,12 +269,19 @@ void RadioAudioEngine::recoverDevices() { /* Снимок фактического TX включает ручной PTT и VAD; уведомляем GUI только при изменении. */ void RadioAudioEngine::updateState() { bool capture = m_active && m_capture.initialized() && m_voice.captureRunning() && ma_device_is_started(m_capture.device()); - bool vad = capture && radio_audio_vad_mode(); + auto input = radio_audio_get_input_status(); + bool valid = capture && input.valid; + if (m_inputLevel != input.level_dbfs || m_inputLevelValid != valid) { + m_inputLevel = input.level_dbfs; m_inputLevelValid = valid; + emit inputLevelChanged(); + } + bool automatic = capture && radio_audio_auto_mode() != RADIO_AUTO_OFF; + bool blocked = capture && input.blocked; bool transmitting = m_active && radio_audio_transmitting(); - if (capture == m_captureAvailable && vad == m_vadAvailable && transmitting == m_transmitting) return; - m_captureAvailable = capture; m_vadAvailable = vad; m_transmitting = transmitting; - DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: state group=%016llx capture=%d vad_requested=%d vad_ready=%d TX=%d manual=%d", - (unsigned long long)m_groupId.load(), capture, vadEnabled(), vad, transmitting, m_talking.load()); + if (capture == m_captureAvailable && automatic == m_autoAvailable && transmitting == m_transmitting && blocked == m_autoBlocked) return; + m_captureAvailable = capture; m_autoAvailable = automatic; m_transmitting = transmitting; m_autoBlocked = blocked; + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioAudio: state group=%016llx capture=%d auto_requested=%d mode=%d ready=%d blocked=%d TX=%d manual=%d", + (unsigned long long)m_groupId.load(), capture, autoEnabled(), autoMode(), automatic, blocked, transmitting, m_talking.load()); emit stateChanged(); } diff --git a/tools/chatgui/src/radio_audio_engine.h b/tools/chatgui/src/radio_audio_engine.h index 73b25566..f9a4153e 100644 --- a/tools/chatgui/src/radio_audio_engine.h +++ b/tools/chatgui/src/radio_audio_engine.h @@ -40,8 +40,12 @@ public: int setting(const QString& name) const; bool settingsReady() const { return m_settingsReady; } bool settingsBusy() const { return m_settingsBusy; } - bool vadEnabled() const { return m_settings[0] != 0; } - bool vadAvailable() const { return m_vadAvailable; } + bool autoEnabled() const { return m_settings[0] != 0; } + int autoMode() const { return m_settings[6]; } + float inputLevel() const { return m_inputLevel; } + bool inputLevelValid() const { return m_inputLevelValid; } + bool autoBlocked() const { return m_autoBlocked; } + bool autoAvailable() const { return m_autoAvailable; } bool captureAvailable() const { return m_captureAvailable; } bool isTransmitting() const { return m_transmitting; } QString settingsError() const { return m_settingsError; } @@ -55,6 +59,7 @@ public: signals: void talkingChanged(bool talking); // GUI-поток, суммарное состояние кнопки и хоткея + void inputLevelChanged(); // снимок измерителя, не чаще 10Гц void stateChanged(); // фактический TX, доступность capture/VAD и настройки; GUI-поток private: @@ -66,12 +71,15 @@ private: void openCapture(); void updateState(); void reconfigureCapture(); - void postSettings(const std::array& values, bool apply); - std::array m_settings{0, 50, 200, 1, 15, 15}; + void postSettings(const std::array& values, bool apply); + std::array m_settings{0, 50, 200, 1, 15, 15, 1, -35, -45}; QString m_configPath; QString m_settingsError; bool m_settingsReady = false, m_settingsBusy = false; - bool m_transmitting = false, m_vadAvailable = false, m_captureAvailable = false; + bool m_transmitting = false, m_autoAvailable = false, m_captureAvailable = false; + + float m_inputLevel = -120; + bool m_inputLevelValid = false, m_autoBlocked = false; std::atomic m_active{false}; std::atomic m_talking{false}; diff --git a/tools/chatgui/src/radiopanel.cpp b/tools/chatgui/src/radiopanel.cpp index a4c9cdf5..634a8861 100644 --- a/tools/chatgui/src/radiopanel.cpp +++ b/tools/chatgui/src/radiopanel.cpp @@ -43,13 +43,14 @@ RadioPanel::RadioPanel(QWidget* parent) : QWidget(parent) { m_status->setMinimumWidth(0); m_status->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); row->addWidget(m_status, 1); - m_vad = new QPushButton(QStringLiteral("VAD"), this); - m_vad->setObjectName(QStringLiteral("radioVadButton")); - m_vad->setAccessibleName(QCoreApplication::translate("ChatGui", "Automatic voice transmission")); - m_vad->setCheckable(true); - m_vad->setFixedSize(52, 34); - m_vad->setStyleSheet("QPushButton:checked { background: #3390EC; color: white; }"); - row->addWidget(m_vad); + m_auto = new QPushButton(QStringLiteral("VAD"), this); + m_auto->setObjectName(QStringLiteral("radioAutoButton")); + m_auto->setAccessibleName(QCoreApplication::translate("ChatGui", "Automatic voice transmission")); + m_auto->setCheckable(true); + m_auto->setMinimumWidth(72); + m_auto->setFixedHeight(34); + m_auto->setStyleSheet("QPushButton:checked { background: #3390EC; color: white; }"); + row->addWidget(m_auto); m_ptt = new QPushButton(QStringLiteral("PTT"), this); m_ptt->setObjectName(QStringLiteral("radioPttButton")); m_ptt->setAccessibleName(QCoreApplication::translate("ChatGui", "Hold to talk")); @@ -65,8 +66,8 @@ RadioPanel::RadioPanel(QWidget* parent) : QWidget(parent) { connect(m_ptt, &QPushButton::released, this, [this, engine]() { if (!m_pttLatched) engine->talkEnd(); }); - connect(m_vad, &QPushButton::clicked, this, [this](bool checked) { - RadioAudioEngine::instance()->applySettings({{QStringLiteral("radio_vad_enabled"), checked ? 1 : 0}}); + connect(m_auto, &QPushButton::clicked, this, [this](bool checked) { + RadioAudioEngine::instance()->applySettings({{QStringLiteral("radio_auto_enabled"), checked ? 1 : 0}}); updateState(); }); connect(engine, &RadioAudioEngine::stateChanged, this, &RadioPanel::updateState); @@ -107,9 +108,10 @@ void RadioPanel::updateState() { auto* engine = RadioAudioEngine::instance(); if (m_pttLatched && (!engine->isActive() || !engine->captureAvailable())) setPttLatched(false, engine->isActive() ? "microphone-unavailable" : "radio-stopped"); - QSignalBlocker blocker(m_vad); - m_vad->setChecked(engine->vadEnabled()); - m_vad->setEnabled(engine->isActive() && engine->settingsReady() && !engine->settingsBusy()); + QSignalBlocker blocker(m_auto); + m_auto->setText(engine->autoMode() == 2 ? QCoreApplication::translate("ChatGui", "Level") : QStringLiteral("VAD")); + m_auto->setChecked(engine->autoEnabled()); + m_auto->setEnabled(engine->isActive() && engine->settingsReady() && !engine->settingsBusy()); m_ptt->setEnabled(engine->isActive() && engine->captureAvailable()); m_ptt->setText(m_pttLatched ? QStringLiteral("PTT \U0001F512") : QStringLiteral("PTT")); m_ptt->setToolTip(m_pttLatched ? QCoreApplication::translate("ChatGui", "PTT locked. Click to release.") @@ -127,12 +129,15 @@ void RadioPanel::updateState() { if (!engine->settingsError().isEmpty()) state = QCoreApplication::translate("ChatGui", "Settings error"); else if (engine->settingsBusy()) state = QCoreApplication::translate("ChatGui", "Applying settings…"); else if (!engine->captureAvailable()) state = QCoreApplication::translate("ChatGui", "Microphone unavailable"); - else if (engine->vadEnabled() && !engine->vadAvailable()) state = QCoreApplication::translate("ChatGui", "VAD unavailable"); + else if (engine->autoEnabled() && !engine->autoAvailable()) state = QCoreApplication::translate("ChatGui", "Automatic transmission unavailable"); else if (transmitting) state = QCoreApplication::translate("ChatGui", "Transmitting"); - else state = engine->vadEnabled() ? QCoreApplication::translate("ChatGui", "VAD listening") : QCoreApplication::translate("ChatGui", "Receiving"); + else if (engine->autoEnabled() && engine->autoBlocked()) state = QCoreApplication::translate("ChatGui", "Channel busy"); + else if (!engine->autoEnabled()) state = QCoreApplication::translate("ChatGui", "Receiving"); + else state = engine->autoMode() == 2 ? QCoreApplication::translate("ChatGui", "Waiting for signal") + : QCoreApplication::translate("ChatGui", "VAD listening"); m_status->setText(state); m_status->setToolTip(engine->settingsError().isEmpty() ? state : engine->settingsError()); - m_vad->setToolTip(engine->vadEnabled() ? QCoreApplication::translate("ChatGui", "Disable automatic voice transmission") + m_auto->setToolTip(engine->autoEnabled() ? QCoreApplication::translate("ChatGui", "Disable automatic voice transmission") : QCoreApplication::translate("ChatGui", "Enable automatic voice transmission")); } diff --git a/tools/chatgui/src/radiopanel.h b/tools/chatgui/src/radiopanel.h index a7d15077..6ab8a73d 100644 --- a/tools/chatgui/src/radiopanel.h +++ b/tools/chatgui/src/radiopanel.h @@ -21,7 +21,7 @@ private: void populateDevices(QToolButton* button, bool capture); void selectDevice(const QString& id, bool capture); QToolButton *m_input, *m_output; - QPushButton *m_vad, *m_ptt; + QPushButton *m_auto, *m_ptt; QLabel* m_status; bool m_pttLatched = false; }; diff --git a/tools/chatgui/src/radiosettingspage.cpp b/tools/chatgui/src/radiosettingspage.cpp index a372e926..c610e8d4 100644 --- a/tools/chatgui/src/radiosettingspage.cpp +++ b/tools/chatgui/src/radiosettingspage.cpp @@ -15,11 +15,38 @@ #include #include #include +#include +#include +#include #include #ifdef Q_OS_WIN #include #endif +/* Шкала показывает именно RMS детектора; отметки соответствуют редактируемым порогам. */ +class RadioLevelMeter : public QWidget { +public: + explicit RadioLevelMeter(QWidget* parent) : QWidget(parent) { setMinimumSize(200, 34); } + float level = -120; + int on = -35, off = -45; + bool valid = false; +protected: + void paintEvent(QPaintEvent*) override { + QPainter painter(this); + QRect bar(1, 1, width() - 2, 16); + painter.fillRect(bar, palette().color(QPalette::Mid)); + auto x = [&](float db) { return bar.left() + int(std::clamp((db + 90) / 90, 0.0f, 1.0f) * bar.width()); }; + if (valid) painter.fillRect(QRect(bar.left(), bar.top(), x(level) - bar.left(), bar.height()), QColor("#27ae60")); + painter.setPen(palette().color(QPalette::Text)); + painter.drawText(QRect(0, 18, width(), 16), Qt::AlignLeft, QStringLiteral("−90 dBFS")); + painter.drawText(QRect(0, 18, width(), 16), Qt::AlignRight, QStringLiteral("0")); + for (int threshold : {off, on}) { + painter.setPen(QPen(threshold == on ? QColor("#e67e22") : QColor("#3390ec"), 2)); + painter.drawLine(x(threshold), 0, x(threshold), 18); + } + } +}; + RadioSettingsPage::RadioSettingsPage(DbManager* db, QWidget* parent) : QWidget(parent), m_db(db) { auto* layout = new QVBoxLayout(this); auto* title = new QLabel(QCoreApplication::translate("ChatGui", "Radio (PTT)"), this); @@ -35,6 +62,27 @@ RadioSettingsPage::RadioSettingsPage(DbManager* db, QWidget* parent) : QWidget(p form->addRow(label, spin); return spin; }; + m_mode = new QComboBox(m_audioSettings); + m_mode->setObjectName(QStringLiteral("radioAutoMode")); + m_mode->addItem(QStringLiteral("VAD"), 1); + m_mode->addItem(QCoreApplication::translate("ChatGui", "By signal level"), 2); + form->addRow(QCoreApplication::translate("ChatGui", "Automatic transmission:"), m_mode); + m_levelMeter = new RadioLevelMeter(m_audioSettings); + m_levelMeter->setObjectName(QStringLiteral("radioLevelMeter")); + form->addRow(QCoreApplication::translate("ChatGui", "Microphone level:"), m_levelMeter); + m_levelText = new QLabel(m_audioSettings); + m_levelText->setObjectName(QStringLiteral("radioLevelText")); + m_levelText->setWordWrap(true); + form->addRow(m_levelText); + m_levelOn = add(QCoreApplication::translate("ChatGui", "Start threshold (orange):"), -90, 0, QStringLiteral(" dBFS")); + m_levelOff = add(QCoreApplication::translate("ChatGui", "Stop threshold (blue):"), -90, 0, QStringLiteral(" dBFS")); + m_levelOn->setObjectName(QStringLiteral("radioLevelOn")); + m_levelOff->setObjectName(QStringLiteral("radioLevelOff")); + m_thresholdError = new QLabel(m_audioSettings); + m_thresholdError->setWordWrap(true); + m_thresholdError->setStyleSheet(QStringLiteral("color: #e74c3c;")); + m_thresholdError->setText(QCoreApplication::translate("ChatGui", "The stop threshold must be lower than the start threshold")); + form->addRow(m_thresholdError); m_threshold = add(QCoreApplication::translate("ChatGui", "VAD speech threshold:"), 10, 95, QStringLiteral(" %")); m_threshold->setToolTip(QCoreApplication::translate("ChatGui", "Speech probability threshold: lower is more sensitive. This is not a volume threshold.")); @@ -46,9 +94,9 @@ RadioSettingsPage::RadioSettingsPage(DbManager* db, QWidget* parent) : QWidget(p m_txGain = add(QCoreApplication::translate("ChatGui", "Transmission max gain:"), 1, 60, QCoreApplication::translate("ChatGui", " dB")); m_vadGain = add(QCoreApplication::translate("ChatGui", "VAD max gain:"), 1, 60, QCoreApplication::translate("ChatGui", " dB")); auto* explanation = new QLabel(QCoreApplication::translate("ChatGui", - "Enable VAD using the button on the radio panel. Speech prebuffer is 0.6 s. The VAD " - "compressor has a separate maximum gain; other parameters are shared with the transmission " - "compressor. Set the gain rise rate in Audio."), m_audioSettings); + "Enable automatic transmission using the radio panel button. Both modes include 0.6 s of prebuffer. " + "Signal level is measured after echo cancellation, before compression. Start requires 40 ms above the start threshold; " + "transmission ends after the configured silence below the stop threshold. The VAD compressor has a separate maximum gain."), m_audioSettings); explanation->setWordWrap(true); form->addRow(explanation); layout->addWidget(m_audioSettings); @@ -57,7 +105,13 @@ RadioSettingsPage::RadioSettingsPage(DbManager* db, QWidget* parent) : QWidget(p m_audioSettings->setEnabled(engine->settingsReady() && !engine->settingsBusy()); if (!m_audioSettingsLoaded && engine->settingsReady()) loadAudioSettings(); }); + connect(m_mode, QOverload::of(&QComboBox::currentIndexChanged), this, [this]() { updateAudioControls(); }); + for (auto* spin : {m_levelOn, m_levelOff}) + connect(spin, QOverload::of(&QSpinBox::valueChanged), this, [this]() { updateAudioControls(); }); + connect(RadioAudioEngine::instance(), &RadioAudioEngine::inputLevelChanged, this, &RadioSettingsPage::updateLevel); + connect(RadioAudioEngine::instance(), &RadioAudioEngine::stateChanged, this, &RadioSettingsPage::updateLevel); loadAudioSettings(); + updateAudioControls(); if (PttHotkeyManager::usesPortal()) { auto* hint = new QLabel(QCoreApplication::translate("ChatGui", "Global shortcuts work from any application. Assign a regular key with Ctrl, Shift, Alt or " @@ -233,8 +287,11 @@ QStringList RadioSettingsPage::hotkeys() const { bool RadioSettingsPage::apply() { if (!RadioAudioEngine::instance()->applySettings({ + {QStringLiteral("radio_auto_mode"), m_mode->currentData().toInt()}, + {QStringLiteral("radio_level_on_dbfs"), m_levelOn->value()}, + {QStringLiteral("radio_level_off_dbfs"), m_levelOff->value()}, {QStringLiteral("radio_vad_threshold"), m_threshold->value()}, - {QStringLiteral("radio_vad_hangover_ms"), m_hangover->value()}, + {QStringLiteral("radio_auto_hangover_ms"), m_hangover->value()}, {QStringLiteral("radio_compressor_enabled"), m_compressor->isChecked() ? 1 : 0}, {QStringLiteral("radio_compressor_max_gain_db"), m_txGain->value()}, {QStringLiteral("radio_vad_compressor_max_gain_db"), m_vadGain->value()}})) return false; @@ -248,12 +305,16 @@ void RadioSettingsPage::loadAudioSettings() { auto* engine = RadioAudioEngine::instance(); m_audioSettings->setEnabled(engine->settingsReady() && !engine->settingsBusy()); if (!engine->settingsReady()) return; + m_mode->setCurrentIndex(m_mode->findData(engine->autoMode())); + m_levelOn->setValue(engine->setting(QStringLiteral("radio_level_on_dbfs"))); + m_levelOff->setValue(engine->setting(QStringLiteral("radio_level_off_dbfs"))); m_threshold->setValue(engine->setting(QStringLiteral("radio_vad_threshold"))); - m_hangover->setValue(engine->setting(QStringLiteral("radio_vad_hangover_ms"))); + m_hangover->setValue(engine->setting(QStringLiteral("radio_auto_hangover_ms"))); m_compressor->setChecked(engine->setting(QStringLiteral("radio_compressor_enabled")) != 0); m_txGain->setValue(engine->setting(QStringLiteral("radio_compressor_max_gain_db"))); m_vadGain->setValue(engine->setting(QStringLiteral("radio_vad_compressor_max_gain_db"))); m_audioSettingsLoaded = true; + updateAudioControls(); } void RadioSettingsPage::updatePortalLabels() { @@ -268,3 +329,25 @@ void RadioSettingsPage::updatePortalLabels() { m_portalStatus->setText(manager->portalStatus()); m_buttons[0]->setEnabled(!manager->portalBusy()); } + +void RadioSettingsPage::updateAudioControls() { + bool level = m_mode->currentData().toInt() == 2; + m_levelOn->setEnabled(level); m_levelOff->setEnabled(level); + m_threshold->setEnabled(!level); m_vadGain->setEnabled(!level); + m_thresholdError->setVisible(m_levelOff->value() >= m_levelOn->value()); + m_levelMeter->on = m_levelOn->value(); m_levelMeter->off = m_levelOff->value(); + updateLevel(); +} + +void RadioSettingsPage::updateLevel() { + auto* engine = RadioAudioEngine::instance(); + m_levelMeter->valid = engine->inputLevelValid(); + m_levelMeter->level = engine->inputLevel(); + m_levelMeter->update(); + QString text; + if (!engine->isActive()) text = QCoreApplication::translate("ChatGui", "Enable radio to measure the microphone level"); + else if (!engine->inputLevelValid()) text = QCoreApplication::translate("ChatGui", "Microphone level unavailable"); + else text = QCoreApplication::translate("ChatGui", "Current level: %1 dBFS").arg(engine->inputLevel(), 0, 'f', 1); + m_levelText->setText(text); + m_levelMeter->setAccessibleName(text); +} diff --git a/tools/chatgui/src/radiosettingspage.h b/tools/chatgui/src/radiosettingspage.h index 07a4901f..2b6867bd 100644 --- a/tools/chatgui/src/radiosettingspage.h +++ b/tools/chatgui/src/radiosettingspage.h @@ -11,6 +11,8 @@ class QHideEvent; class QShowEvent; class QSpinBox; class QCheckBox; +class QComboBox; +class RadioLevelMeter; // Запись глобальных сочетаний PTT, включая отдельные левые/правые модификаторы. class RadioSettingsPage : public QWidget { @@ -30,9 +32,15 @@ protected: private: void updatePortalLabels(); void loadAudioSettings(); + void updateAudioControls(); + void updateLevel(); QWidget* m_audioSettings = nullptr; QSpinBox *m_threshold, *m_hangover, *m_txGain, *m_vadGain; QCheckBox* m_compressor; + QComboBox* m_mode; + QSpinBox *m_levelOn, *m_levelOff; + RadioLevelMeter* m_levelMeter; + QLabel *m_levelText, *m_thresholdError; bool m_audioSettingsLoaded = false; QLabel* m_portalStatus = nullptr; void startCapture(int index); diff --git a/tools/chatgui/tests/test_radio_member_mute.cpp b/tools/chatgui/tests/test_radio_member_mute.cpp index 383607dd..ac005dd4 100644 --- a/tools/chatgui/tests/test_radio_member_mute.cpp +++ b/tools/chatgui/tests/test_radio_member_mute.cpp @@ -168,7 +168,7 @@ int main() { frames(inst, gid, SOURCE_B); REQUIRE(audible(pull(gid))); #ifdef UTUN_TEST_WRAP_VAD radio_audio_stop(); REQUIRE(radio_audio_start(gid, 0) == 0 && radio_audio_capture_start(gid, 1, 1) == 0); - REQUIRE(radio_audio_vad_mode()); + REQUIRE(radio_audio_auto_mode()); int16_t speech[1920]; for (int16_t& sample : speech) sample = 7000; frames(inst, gid, SOURCE_A); REQUIRE(!audible(pull(gid))); for (int i = 0; i < 6; ++i) REQUIRE(radio_audio_feed_pcm(gid, speech, 1920) == 0); diff --git a/tools/chatgui/tests/test_radio_panel.cpp b/tools/chatgui/tests/test_radio_panel.cpp index 19666719..61d16b3e 100644 --- a/tools/chatgui/tests/test_radio_panel.cpp +++ b/tools/chatgui/tests/test_radio_panel.cpp @@ -1,5 +1,8 @@ /* Настоящие Qt-кнопки, null audio backend и общий radio_audio; подменяем только вероятность VAD и сетевой выход. */ #include "../src/radiopanel.h" +#include "../src/radiosettingspage.h" +#include +#include #include "../src/radio_audio_engine.h" #include "../src/sound_manager.h" #include "../src/memberlistmodel.h" @@ -82,7 +85,7 @@ int main(int argc, char** argv) { debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_INFO); instance.ua = uasync_create(); REQUIRE(instance.ua && radio_audio_init(&instance) == 0); - REQUIRE(chat_setting_set(&instance, "radio_vad_enabled", "0") == 0); + REQUIRE(chat_setting_set(&instance, "radio_auto_enabled", "0") == 0); auto* manager = SoundManager::instance(); REQUIRE(manager->init()); QTemporaryDir directory; @@ -90,7 +93,7 @@ int main(int argc, char** argv) { QString configPath = directory.filePath("radio.cfg"); QFile config(configPath); REQUIRE(config.open(QIODevice::WriteOnly)); - REQUIRE(config.write("[chatserver]\nradio_vad_enabled=0\nradio_opus_bitrate=32\n") > 0); + REQUIRE(config.write("[chatserver]\nradio_auto_enabled=0\nradio_opus_bitrate=32\n") > 0); config.close(); auto* engine = RadioAudioEngine::instance(); MemberListModel members; @@ -104,11 +107,11 @@ int main(int argc, char** argv) { auto selfSpeaking = [&] { return members.index(0, 0).data(MemberListModel::RadioSpeakingRole).toBool(); }; engine->loadSettings(configPath); await([&]() { return engine->settingsReady() && !engine->settingsBusy(); }); - REQUIRE(!engine->vadEnabled() && engine->setting("radio_vad_compressor_max_gain_db") == 15); + REQUIRE(!engine->autoEnabled() && engine->setting("radio_vad_compressor_max_gain_db") == 15); RadioPanel panel; panel.resize(580, 50); panel.show(); - auto* vad = panel.findChild("radioVadButton"); + auto* vad = panel.findChild("radioAutoButton"); auto* ptt = panel.findChild("radioPttButton"); auto* status = panel.findChild("radioStatus"); REQUIRE(vad && ptt && status && !vad->isEnabled()); @@ -119,7 +122,7 @@ int main(int argc, char** argv) { int oldOutputs = outputs, oldInputs = inputs; vad->click(); REQUIRE(engine->settingsBusy() && !vad->isEnabled()); - await([&]() { return !engine->settingsBusy() && engine->vadAvailable(); }); + await([&]() { return !engine->settingsBusy() && engine->autoAvailable(); }); REQUIRE(vad->isChecked() && outputs == oldOutputs && inputs == oldInputs + 1); pump(750); // Полное кольцо prefetch до начала речи. int previousPackets = packets; @@ -148,8 +151,8 @@ int main(int argc, char** argv) { REQUIRE(selfSpeaking()); oldOutputs = outputs; oldInputs = inputs; vad->click(); - await([&]() { return !engine->settingsBusy() && !engine->vadEnabled() && engine->isTransmitting(); }); - REQUIRE(engine->isTalking() && !engine->vadAvailable() && outputs == oldOutputs && inputs == oldInputs + 1); + await([&]() { return !engine->settingsBusy() && !engine->autoEnabled() && engine->isTransmitting(); }); + REQUIRE(engine->isTalking() && !engine->autoAvailable() && outputs == oldOutputs && inputs == oldInputs + 1); engine->setHotkeyTalking(false); await([&]() { return !engine->isTransmitting(); }); REQUIRE(!selfSpeaking()); @@ -186,7 +189,7 @@ int main(int argc, char** argv) { QTest::mouseRelease(ptt, Qt::LeftButton); REQUIRE(!engine->isTalking()); qInfo("radio panel: double-click latch, click release, independent hotkey and channel reset PASS"); - REQUIRE(engine->applySettings({{"radio_vad_threshold", 65}, {"radio_vad_hangover_ms", 400}, + REQUIRE(engine->applySettings({{"radio_vad_threshold", 65}, {"radio_auto_hangover_ms", 400}, {"radio_vad_compressor_max_gain_db", 30}, {"radio_compressor_max_gain_db", 6}})); await([&]() { return !engine->settingsBusy(); }); REQUIRE(engine->setting("radio_vad_compressor_max_gain_db") == 30 && engine->setting("radio_compressor_max_gain_db") == 6); @@ -196,7 +199,7 @@ int main(int argc, char** argv) { detectorAvailable = false; vad->click(); await([&]() { return !engine->settingsBusy(); }); - REQUIRE(vad->isChecked() && engine->captureAvailable() && !engine->vadAvailable() && status->text() == "VAD unavailable"); + REQUIRE(vad->isChecked() && engine->captureAvailable() && !engine->autoAvailable() && status->text() == "Automatic transmission unavailable"); QTest::mousePress(ptt, Qt::LeftButton); await([&]() { return engine->isTransmitting(); }); REQUIRE(selfSpeaking()); @@ -205,14 +208,50 @@ int main(int argc, char** argv) { REQUIRE(!selfSpeaking()); qInfo("radio panel: capture-only restart preserves output/manual owner; VAD failure leaves manual PTT working PASS"); + RadioSettingsPage settings(nullptr); + settings.resize(620, 780); settings.show(); + auto* mode = settings.findChild("radioAutoMode"); + auto* on = settings.findChild("radioLevelOn"); + auto* off = settings.findChild("radioLevelOff"); + auto* levelText = settings.findChild("radioLevelText"); + REQUIRE(mode && on && off && levelText); + mode->setCurrentIndex(1); on->setValue(-50); off->setValue(-40); + REQUIRE(!settings.apply() && !engine->settingsBusy() && engine->autoMode() == 1); + off->setValue(-60); + REQUIRE(settings.apply()); + await([&]() { return !engine->settingsBusy() && engine->autoAvailable() && engine->inputLevelValid(); }); + REQUIRE(engine->autoMode() == 2 && vad->text() == "Level" && radio_audio_auto_mode() == RADIO_AUTO_LEVEL); + REQUIRE(!engine->isTransmitting() && status->text() == "Waiting for signal"); + REQUIRE(levelText->text().contains("dBFS") && engine->inputLevel() == -120); + // Silero недоступен; уровень работает и сохраняется вместе с парой порогов. + REQUIRE(config.open(QIODevice::ReadOnly)); + QByteArray levelConfig = config.readAll(); config.close(); + REQUIRE(levelConfig.contains("radio_auto_mode=2") && levelConfig.contains("radio_level_on_dbfs=-50") && + levelConfig.contains("radio_level_off_dbfs=-60")); + auto* parsedLevel = parse_config(configPath.toUtf8().constData()); + REQUIRE(parsedLevel && chat_setting_get_int_state(&parsedLevel->global.chat_settings, "radio_auto_mode", 0) == 2 && + chat_setting_get_int_state(&parsedLevel->global.chat_settings, "radio_level_on_dbfs", 0) == -50 && + chat_setting_get_int_state(&parsedLevel->global.chat_settings, "radio_level_off_dbfs", 0) == -60); + free_config(parsedLevel); + REQUIRE(settings.grab().save("/tmp/desktop-radio-level-settings.png")); + vad->click(); + await([&]() { return !engine->settingsBusy() && !engine->autoEnabled() && engine->inputLevelValid(); }); + REQUIRE(!engine->autoAvailable() && levelText->text().contains("dBFS")); + engine->stop(); + REQUIRE(!engine->inputLevelValid() && levelText->text().contains("Enable radio")); + settings.hide(); + REQUIRE(engine->applySettings({{"radio_auto_enabled", 1}, {"radio_auto_mode", 1}})); + await([&]() { return !engine->settingsBusy(); }); + qInfo("radio panel: level mode without Silero, live meter, inactive meter, threshold pair validation and saved settings PASS"); + engine->stop(); REQUIRE(!engine->isTransmitting() && !engine->captureAvailable() && !vad->isEnabled()); REQUIRE(!selfSpeaking()); REQUIRE(config.open(QIODevice::ReadOnly)); QByteArray saved = config.readAll(); config.close(); - REQUIRE(saved.contains("radio_vad_enabled=1") && saved.contains("radio_vad_threshold=65") && - saved.contains("radio_vad_hangover_ms=400") && saved.contains("radio_vad_compressor_max_gain_db=30") && + REQUIRE(saved.contains("radio_auto_enabled=1") && saved.contains("radio_vad_threshold=65") && + saved.contains("radio_auto_hangover_ms=400") && saved.contains("radio_vad_compressor_max_gain_db=30") && saved.contains("radio_opus_bitrate=32")); // Как при рестарте приложения: runtime-настройки заново читаются парсером из сохранённого INI. auto* parsed = parse_config(configPath.toUtf8().constData()); @@ -221,35 +260,35 @@ int main(int argc, char** argv) { free_config(parsed); engine->loadSettings(configPath); await([&]() { return !engine->settingsBusy(); }); - REQUIRE(engine->vadEnabled() && engine->setting("radio_vad_threshold") == 65); + REQUIRE(engine->autoEnabled() && engine->setting("radio_vad_threshold") == 65); detectorAvailable = true; REQUIRE(engine->start(8) == 0); - await([&]() { return engine->vadAvailable(); }); + await([&]() { return engine->autoAvailable(); }); REQUIRE(engine->groupId() == 8 && vad->isChecked()); engine->stop(); failCapture = true; REQUIRE(engine->start(9) == 0); REQUIRE(!engine->captureAvailable() && vad->isEnabled() && !ptt->isEnabled()); vad->click(); - await([&]() { return !engine->settingsBusy() && !engine->vadEnabled(); }); + await([&]() { return !engine->settingsBusy() && !engine->autoEnabled(); }); REQUIRE(!engine->captureAvailable()); failCapture = false; await([&]() { return engine->captureAvailable(); }); vad->click(); - await([&]() { return !engine->settingsBusy() && engine->vadAvailable(); }); + await([&]() { return !engine->settingsBusy() && engine->autoAvailable(); }); REQUIRE(vad->isChecked() && ptt->isEnabled()); qInfo("radio panel: unavailable microphone permits VAD settings; capture recovery restores controls PASS"); engine->stop(); engine->loadSettings(directory.filePath("missing.cfg")); await([&]() { return !engine->settingsBusy(); }); - REQUIRE(!engine->applySettings({{"radio_vad_enabled", 0}}) && engine->vadEnabled() && !engine->settingsError().isEmpty()); + REQUIRE(!engine->applySettings({{"radio_auto_enabled", 0}}) && engine->autoEnabled() && !engine->settingsError().isEmpty()); for (auto* button : panel.findChildren()) { REQUIRE(QMetaObject::invokeMethod(button->menu(), "aboutToShow", Qt::DirectConnection)); REQUIRE(!button->menu()->actions().isEmpty()); } engine->loadSettings(configPath); await([&]() { return !engine->settingsBusy(); }); - REQUIRE(engine->start(10) == 0 && engine->vadAvailable()); + REQUIRE(engine->start(10) == 0 && engine->autoAvailable()); pump(100); REQUIRE(panel.grab().save("/tmp/desktop-radio-panel.png")); engine->stop(); diff --git a/tools/chatgui/tests/test_voice_tx.cpp b/tools/chatgui/tests/test_voice_tx.cpp index 557ca0db..d09c2840 100644 --- a/tools/chatgui/tests/test_voice_tx.cpp +++ b/tools/chatgui/tests/test_voice_tx.cpp @@ -27,6 +27,7 @@ static std::vector> packets; static float probability; static int vad_windows; static int radio_compressor_enabled; +static int auto_hangover_ms; static bool observe_vad; static std::vector vad_input; static std::atomic stall_vad{false}, vad_stalled{false}; @@ -53,7 +54,7 @@ extern "C" int __real_chat_setting_get_int(UTUN_INSTANCE*, const char*, int); extern "C" int __wrap_chat_setting_get_int(UTUN_INSTANCE* inst, const char* key, int fallback) { if (!strcmp(key, "radio_compressor_enabled")) return radio_compressor_enabled; if (!strcmp(key, "call_compressor_enabled")) return 0; - if (!strcmp(key, "radio_vad_hangover_ms")) return 0; + if (!strcmp(key, "radio_auto_hangover_ms")) return auto_hangover_ms; return __real_chat_setting_get_int(inst, key, fallback); } static void check_packet(opus_codec_encoder_t* encoder, const int16_t* pcm, size_t index, @@ -147,13 +148,13 @@ static void test_vad_compressor(UTUN_INSTANCE* instance, int channels, int max_g } /* Полные 600мс кольца, граница VAD внутри кадра и live должны совпасть с непрерывным Opus-потоком. */ -static void test_prefetch(UTUN_INSTANCE* instance, int channels, int compressor_enabled = 0) { +static void test_prefetch(UTUN_INSTANCE* instance, int channels, int compressor_enabled = 0, int mode = RADIO_AUTO_VAD) { debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_INFO); packets.clear(); events.clear(); probability = 0; radio_compressor_enabled = compressor_enabled; - REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, channels, 1) == 0); + REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, channels, mode) == 0); const int idle = 33 * 1536, confirm = 2 * 1536, live = 959; std::vector pcm((idle + confirm + live) * channels); - for (size_t i = 0; i < pcm.size(); ++i) pcm[i] = (int16_t)(10000 * sin(i * 0.07)); + for (size_t i = 0; i < pcm.size(); ++i) pcm[i] = (int16_t)((mode == RADIO_AUTO_LEVEL && i < size_t(idle * channels) ? 100 : 10000) * sin(i * 0.07)); REQUIRE(radio_audio_feed_pcm(7, pcm.data(), idle * channels) == 0); REQUIRE(!radio_audio_transmitting() && !instance->ua->posted_tasks_head); probability = 0.9f; @@ -180,7 +181,7 @@ static void test_prefetch(UTUN_INSTANCE* instance, int channels, int compressor_ // Разрыв очищает историю; короткий prefetch не дополняется тишиной на границе с live. packets.clear(); events.clear(); probability = 0; - REQUIRE(radio_audio_capture_start(7, channels, 1) == 0); + REQUIRE(radio_audio_capture_start(7, channels, mode) == 0); REQUIRE(radio_audio_feed_pcm(7, pcm.data(), 100 * channels) == 0); radio_audio_capture_discontinuity(7); REQUIRE(radio_audio_feed_pcm(7, pcm.data() + 100 * channels, 240 * channels) == 0); @@ -295,7 +296,7 @@ static void test_vad_diagnostics(UTUN_INSTANCE* instance) { } /* Настоящие RX/jitter/TX, управляемые часы: FIN не означает окончание слышимого голоса. */ -static void test_vad_receive_guard(UTUN_INSTANCE* instance) { +static void test_vad_receive_guard(UTUN_INSTANCE* instance, int mode = RADIO_AUTO_VAD) { debug_set_category_level(DEBUG_CATEGORY_VAD, DEBUG_LEVEL_DEBUG); debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_DEBUG); test_time = __real_get_time_tb(); @@ -316,7 +317,7 @@ static void test_vad_receive_guard(UTUN_INSTANCE* instance) { }; auto start = [&] { events.clear(); packets.clear(); pulled = 0; probability = 0.9f; - REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, 1, 1) == 0); + REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, 1, mode) == 0); radio_audio_playback_progress(7, 0, 0); }; start(); @@ -354,25 +355,83 @@ static void test_vad_receive_guard(UTUN_INSTANCE* instance) { test_time += 1; feed(); REQUIRE(radio_audio_transmitting()); radio_audio_stop(); uasync_poll(instance->ua, 0); - start(); - REQUIRE(radio_audio_feed_pcm(7, speech.data(), 1536) == 0); - stall_vad = true; vad_stalled = false; - std::thread tx([&] { REQUIRE(radio_audio_feed_pcm(7, speech.data(), 1536) == 0); }); - for (int i = 0; i < 200 && !vad_stalled; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(1)); - REQUIRE(vad_stalled); receive(42, 0); stall_vad = false; tx.join(); - REQUIRE(!radio_audio_transmitting()); // RX во время второго inference отменяет готовый START. - radio_audio_reset_playback(7); - test_time += 1999; feed(); REQUIRE(!radio_audio_transmitting()); - test_time += 1; feed(); REQUIRE(radio_audio_transmitting()); - radio_audio_stop(); uasync_poll(instance->ua, 0); - start(); feed(); REQUIRE(radio_audio_transmitting()); // Новая сессия не наследует RX-запрет. - radio_audio_stop(); uasync_poll(instance->ua, 0); + if (mode == RADIO_AUTO_VAD) { + start(); + REQUIRE(radio_audio_feed_pcm(7, speech.data(), 1536) == 0); + stall_vad = true; vad_stalled = false; + std::thread tx([&] { REQUIRE(radio_audio_feed_pcm(7, speech.data(), 1536) == 0); }); + for (int i = 0; i < 200 && !vad_stalled; ++i) std::this_thread::sleep_for(std::chrono::milliseconds(1)); + REQUIRE(vad_stalled); receive(42, 0); stall_vad = false; tx.join(); + REQUIRE(!radio_audio_transmitting()); // RX во время второго inference отменяет готовый START. + radio_audio_reset_playback(7); + test_time += 1999; feed(); REQUIRE(!radio_audio_transmitting()); + test_time += 1; feed(); REQUIRE(radio_audio_transmitting()); + radio_audio_stop(); uasync_poll(instance->ua, 0); + start(); feed(); REQUIRE(radio_audio_transmitting()); // Новая сессия не наследует RX-запрет. + radio_audio_stop(); uasync_poll(instance->ua, 0); + } test_time = 0; debug_set_category_level(DEBUG_CATEGORY_VAD, DEBUG_LEVEL_NONE); debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_NONE); puts("voice TX: RX/FIN/drain/device delay + exact 200ms, multiple speakers, timeout, prefetch, manual PTT, inference race PASS"); } +static void test_level_detector(UTUN_INSTANCE* instance, int channels, int compressor) { + radio_compressor_enabled = compressor; auto_hangover_ms = 200; + packets.clear(); events.clear(); + int windows = vad_windows; + REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, channels, RADIO_AUTO_LEVEL) == 0); + REQUIRE(radio_audio_auto_mode() == RADIO_AUTO_LEVEL && !radio_audio_get_input_status().valid); + auto feed = [&](int amplitude, int frames = 960) { + std::vector pcm(frames * channels); + for (int i = 0; i < frames; ++i) for (int ch = 0; ch < channels; ++ch) + pcm[i * channels + ch] = ch ? -amplitude : amplitude; // Противофаза не уничтожает RMS. + // Неровные чанки проверяют независимость от границ callback. + for (int i = 0; i < frames; i += 137) { + REQUIRE(radio_audio_feed_pcm(7, pcm.data() + i * channels, std::min(137, frames - i) * channels) == 0); + uasync_poll(instance->ua, 0); + } + }; + feed(328); REQUIRE(!radio_audio_transmitting()); + auto input = radio_audio_get_input_status(); + REQUIRE(input.valid && std::abs(input.level_dbfs + 39.99f) < 0.05f); + feed(1200); REQUIRE(!radio_audio_transmitting()); + feed(328); REQUIRE(!radio_audio_transmitting()); // Пик отменён. + feed(1200); REQUIRE(!radio_audio_transmitting()); + feed(1200); REQUIRE(radio_audio_transmitting()); + feed(328); REQUIRE(radio_audio_transmitting()); // Между порогами удерживаем. + for (int i = 0; i < 10; ++i) feed(100); + REQUIRE(radio_audio_transmitting()); + feed(328); REQUIRE(radio_audio_transmitting()); // Отмена почти истёкшей задержки. + for (int i = 0; i < 11; ++i) feed(100); + REQUIRE(!radio_audio_transmitting()); + feed(328); REQUIRE(!radio_audio_transmitting()); + radio_audio_talk_begin(7); feed(100); REQUIRE(radio_audio_transmitting()); + radio_audio_talk_end(7); REQUIRE(!radio_audio_transmitting()); + feed(1200); radio_audio_capture_discontinuity(7); + REQUIRE(!radio_audio_get_input_status().valid); + feed(1200); REQUIRE(!radio_audio_transmitting()); + feed(1200); REQUIRE(radio_audio_transmitting()); + radio_audio_capture_stop(); uasync_poll(instance->ua, 0); + REQUIRE(!radio_audio_get_input_status().valid && vad_windows == windows); + REQUIRE(radio_audio_capture_start(7, channels, RADIO_AUTO_OFF) == 0); + feed(1200); feed(1200); REQUIRE(!radio_audio_transmitting() && radio_audio_get_input_status().valid); + test_time = __real_get_time_tb() + 2001; + REQUIRE(!radio_audio_get_input_status().valid); test_time = 0; + radio_audio_stop(); uasync_poll(instance->ua, 0); + REQUIRE(chat_setting_set(instance, "radio_level_on_dbfs", "-60") == 0); + REQUIRE(chat_setting_set(instance, "radio_level_off_dbfs", "-50") == 0); + REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, channels, RADIO_AUTO_LEVEL) == 0); + REQUIRE(radio_audio_auto_mode() == RADIO_AUTO_OFF); // Некорректный INI не включает автоматическую передачу. + radio_audio_talk_begin(7); feed(1200); REQUIRE(radio_audio_transmitting()); + radio_audio_stop(); uasync_poll(instance->ua, 0); + REQUIRE(chat_setting_set(instance, "radio_level_on_dbfs", "-35") == 0); + REQUIRE(chat_setting_set(instance, "radio_level_off_dbfs", "-45") == 0); + auto_hangover_ms = 0; radio_compressor_enabled = 0; + printf("voice TX: level hysteresis, hangover reset, RMS, stereo phase, gain independence, chunking, PTT, restart ch=%d AGC=%d PASS\n", + channels, compressor); +} + int main() { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); UTUN_INSTANCE instance{}; instance.ua = uasync_create(); REQUIRE(instance.ua); @@ -421,7 +480,7 @@ int main() { REQUIRE(pending <= 24); // 16 control + 8 media, FIN каждой принятой серии уже зарезервирован. uasync_poll(instance.ua, 0); packets.clear(); events.clear(); radio_audio_capture_stop(); - REQUIRE(radio_audio_capture_start(7, 1, 1) == 0 && radio_audio_vad_mode()); + REQUIRE(radio_audio_capture_start(7, 1, 1) == 0 && radio_audio_auto_mode()); auto feed = [&](int frames) { while (frames) { int count = std::min(frames, 1920); REQUIRE(radio_audio_feed_pcm(7, input.data(), count) == 0); frames -= count; } }; @@ -448,9 +507,14 @@ int main() { test_prefetch(&instance, 2); test_prefetch(&instance, 1, 1); test_prefetch(&instance, 2, 1); + for (int channels : {1, 2}) for (int compressor : {0, 1}) { + test_prefetch(&instance, channels, compressor, RADIO_AUTO_LEVEL); + test_level_detector(&instance, channels, compressor); + } test_prefetch_deferred(&instance); test_vad_diagnostics(&instance); test_vad_receive_guard(&instance); + test_vad_receive_guard(&instance, RADIO_AUTO_LEVEL); for (int channels : {1, 2}) for (int gain : {6, 30}) test_vad_compressor(&instance, channels, gain); // Старый FIN не обращается к новому ядру, даже когда allocator повторно использовал тот же адрес. REQUIRE(radio_audio_start(7, 1) == 0); diff --git a/tools/chatgui/translations/chatgui_en.ts b/tools/chatgui/translations/chatgui_en.ts index deb7332d..ca97ab25 100644 --- a/tools/chatgui/translations/chatgui_en.ts +++ b/tools/chatgui/translations/chatgui_en.ts @@ -2159,5 +2159,61 @@ Directories: %5 Original message is not available yetOriginal message is not available yet Could not send reply. Please try again.Could not send reply. Please try again. Voice messageVoice message + + Level + Level + + + By signal level + By signal level + + + Automatic transmission: + Automatic transmission: + + + Microphone level: + Microphone level: + + + Start threshold (orange): + Start threshold (orange): + + + Stop threshold (blue): + Stop threshold (blue): + + + The stop threshold must be lower than the start threshold + The stop threshold must be lower than the start threshold + + + Enable radio to measure the microphone level + Enable radio to measure the microphone level + + + Microphone level unavailable + Microphone level unavailable + + + Current level: %1 dBFS + Current level: %1 dBFS + + + Automatic transmission unavailable + Automatic transmission unavailable + + + Channel busy + Channel busy + + + Waiting for signal + Waiting for signal + + + Enable automatic transmission using the radio panel button. Both modes include 0.6 s of prebuffer. Signal level is measured after echo cancellation, before compression. Start requires 40 ms above the start threshold; transmission ends after the configured silence below the stop threshold. The VAD compressor has a separate maximum gain. + Enable automatic transmission using the radio panel button. Both modes include 0.6 s of prebuffer. Signal level is measured after echo cancellation, before compression. Start requires 40 ms above the start threshold; transmission ends after the configured silence below the stop threshold. The VAD compressor has a separate maximum gain. + diff --git a/tools/chatgui/translations/chatgui_ru.ts b/tools/chatgui/translations/chatgui_ru.ts index 398cebbd..2c57baf3 100644 --- a/tools/chatgui/translations/chatgui_ru.ts +++ b/tools/chatgui/translations/chatgui_ru.ts @@ -2165,5 +2165,61 @@ Directories: %5 Original message is not available yetИсходное сообщение пока недоступно Could not send reply. Please try again.Не удалось отправить ответ. Повторите попытку. Voice messageГолосовое сообщение + + Level + Уровень + + + By signal level + По уровню сигнала + + + Automatic transmission: + Автоматическая передача: + + + Microphone level: + Уровень микрофона: + + + Start threshold (orange): + Порог включения (оранжевый): + + + Stop threshold (blue): + Порог отключения (синий): + + + The stop threshold must be lower than the start threshold + Порог отключения должен быть ниже порога включения + + + Enable radio to measure the microphone level + Включите рацию для измерения уровня микрофона + + + Microphone level unavailable + Уровень микрофона недоступен + + + Current level: %1 dBFS + Текущий уровень: %1 dBFS + + + Automatic transmission unavailable + Автоматическая передача недоступна + + + Channel busy + Эфир занят + + + Waiting for signal + Ожидание сигнала + + + Enable automatic transmission using the radio panel button. Both modes include 0.6 s of prebuffer. Signal level is measured after echo cancellation, before compression. Start requires 40 ms above the start threshold; transmission ends after the configured silence below the stop threshold. The VAD compressor has a separate maximum gain. + Включайте автоматическую передачу кнопкой на панели рации. В обоих режимах передаётся до 0,6 с звука перед срабатыванием. Уровень измеряется после эхоподавления, до компрессора. Для включения нужны 40 мс на пороге включения или выше; передача заканчивается после заданной паузы ниже порога отключения. У компрессора VAD отдельный предел усиления. +