From 7f3247bfccf4e827fcc5cae59978c223ad5fac28 Mon Sep 17 00:00:00 2001 From: evgeny Date: Fri, 9 Oct 2026 16:11:11 +0200 Subject: [PATCH] radio: add activation controls, live VAD meters and signal diagnostics --- doc/tasks.md | 45 +++ src/radio/radio_audio.c | 44 ++- src/radio/radio_audio.h | 4 + tests/test_silero_vad.c | 134 ++++++++- .../main/java/com/utun/chat/data/NativeLib.kt | 3 +- .../utun/chat/ui/screens/SettingsScreen.kt | 49 +++- .../app/src/main/res/values-ru/strings.xml | 7 + .../app/src/main/res/values/strings.xml | 7 + .../jni_bridge/android_jni_bridge.c | 7 +- tools/chatgui/CMakeLists.txt | 6 + tools/chatgui/src/radio_audio_engine.cpp | 6 +- tools/chatgui/src/radio_audio_engine.h | 6 + tools/chatgui/src/radiopanel.cpp | 107 ++++++- tools/chatgui/src/radiopanel.h | 8 + tools/chatgui/src/radiosettingspage.cpp | 272 ++++++++++-------- tools/chatgui/src/radiosettingspage.h | 37 ++- tools/chatgui/src/settingsdialog.cpp | 9 +- tools/chatgui/src/voice_audio_io.cpp | 86 ++++-- tools/chatgui/tests/test_radio_panel.cpp | 38 ++- tools/chatgui/tests/test_voice_audio_io.cpp | 14 + tools/chatgui/tests/test_voice_tx.cpp | 47 ++- tools/chatgui/translations/chatgui_en.ts | 32 +++ tools/chatgui/translations/chatgui_ru.ts | 32 +++ 23 files changed, 818 insertions(+), 182 deletions(-) diff --git a/doc/tasks.md b/doc/tasks.md index 652a2df7..e76a4d5a 100644 --- a/doc/tasks.md +++ b/doc/tasks.md @@ -3,6 +3,51 @@ Сюда пишется список задач с короткой аннотацией. Если задача большая и имеет ТЗ - то ТЗ оформляется отдельным файлом, а сюда помещается аннотация и ссылка на ТЗ. ## Текущая задача +[+] **Desktop: текущая вероятность речи на кнопке VAD** — 09.10.2026: + Зелёная полоса внизу кнопки отображает 0–100% из того же снимка ядра, что настройки; обновляется до 10Гц. + При недоступной оценке остаётся пустая дорожка, в режиме уровня полоса скрыта. Нажатия и удержание сохранены. + +[ ] **Desktop: провалы вероятности VAD при компрессии** — диагностика 09.10.2026: + Воспроизведение пользователя: длительная гласная, затем временная нечувствительность; ближе к концу лога несколько повторов. + Новый лог 14:32:14–14:34:38: на 14:34:07–18 и 14:34:20–38 все 556 окон с RMS > -30dBFS имеют p < 0.5; + PCM непрерывен, errors=0, RX-gate не мешает, clips=5 не растёт. Сигнал не исчезает, порог остаётся 0.5. + --probe-stream: C-обёртка + реальный AGC + две Silero на одинаковом PCM, отличие — один reset состояния второй модели. + Синтезированный flite ah растянут до 24.576с: без AGC/клиппинга p первой секунды следующей речи 0.027 против 0.674 после reset. + При AGC 1dB (clips=0) — 0.012 против 0.662. Влияние истории модели подтверждено, провал на десятки секунд целиком не воспроизведён. + Лог стенда: /tmp/vad-vowel-context.log; запуск: test_silero_vad lib/silero_vad.onnx --probe-stream /tmp/vad-vowel-context48.pcm 993. + Вход стенда: mono s16le/48кГц, reset_window — индекс окна 512/16кГц; режим проверяет без AGC, 1dB и 15dB. + Автоматические сбросы в рабочем VAD не добавлялись; test_silero_vad PASS. + Уточнение: низкая вероятность держится десятки секунд, затем распознавание восстанавливается; краткий клиппинг это не объясняет. + Локальный лог 14:00:10–14:00:58: 15 сбросов AEC при пропусках референса, 16 clipped samples в VAD; vad DEBUG не был включён. + Добавлены секундные уровни raw/clean/reference и причина bypass/reset AEC; отдельное предупреждение о клиппинге VAD. + Секундный отчёт захвата работает также без PCM: возраст кадра, очередь, максимальная задержка и предупреждение о простое. + TRACE каждого окна содержит DC, число нулей и gain вместе с вероятностью/уровнем/паузой между окнами; видны сбросы и RX-gate. + В локальном build/vibechat.cfg включены vad=trace, aec/radio/call=debug, general=info; после диагностики вернуть обычные уровни. + Стенд --probe повторяет одинаковый PCM без reset: AGC 15/30dB после паузы обрезает пики (468/5625 samples на чистой записи). + Полного провала всей фразы почти до нуля стенд не воспроизвёл; нужен повтор живой речи с новыми логами для связи с AEC/AGC. + Алгоритмы обработки сигнала пока не менялись; исправление причины выбирается по результатам повтора. + +[+] **Регрессия Silero с настоящей моделью доступна в desktop CMake** — 09.10.2026: + test_silero_vad переведён на актуальный radio_auto_fsm (в autotools ONNX здесь выключен, поэтому старый тест не собирался). + Опциональный --probe сравнивает повторения одной речи при разном фоне/усилении и печатает вероятность, gain и clips. + +[+] **Desktop: общий интерфейс настроек активации рации** — 09.10.2026: + Удержание 500мс или правая кнопка на VAD/значке уровня открывает мини-блок; короткое нажатие переключает автопередачу. + В мини-блоке и вкладке используется один редактор: две кнопки режима, оба порога dBFS, порог/усиление VAD и задержка. + Одновременно отображаются RMS, вероятность речи и фактическое усиление VAD; снимок общего ядра обновляется с частотой до 10Гц. + Применение явное, закрытие отменяет несохранённый ввод; новая вкладка прокручивается на небольшом экране. + Проверки: radio_panel (удержание без toggle, повторное открытие, применение, индикаторы), gui_language, audio_recovery, voice_tx — PASS. + +[+] **Android: текущая оценка VAD и постоянные настройки обоих детекторов** — 09.10.2026: + В мини-блоке и вкладке рации одновременно доступны пороги уровня, порог VAD, усиление перед VAD и задержка отключения. + Вероятность речи из работающего Silero выводится в процентах и на шкале с отметкой порога; обновление UI — 100мс. + Рядом с пределом усиления показан фактический gain отдельного компрессора VAD в dB; выключенный отмечен как 0 dB. + Снимок native/JNI помечает оценку недоступной при RX, ошибке inference, разрыве/остановке захвата или возрасте больше 200мс. + При выключенной автоматике или режиме уровня UI предлагает включить VAD для измерения вероятности речи. + Проверки: voice_tx (вероятность, свежесть, ошибки, RX, reset), radio_member_mute, radio_panel, gui_language — PASS. + voice_tx также сравнивает отображаемый gain с эталонным VAD-компрессором для mono/stereo и проверяет выключенный компрессор. + APK arm64-v8a/x86_64 собран; новый индикатор на физическом устройстве пока не проверен. + [+] **Android: автопередача рации по уровню сигнала** — 08.10.2026: режим VAD/уровень, два порога dBFS, живой измеритель из общего radio_audio и prefetch 600мс. Вкладка рации временно держит микрофон для измерения при выключенной автоматике; уход со вкладки/пауза освобождает этот захват. diff --git a/src/radio/radio_audio.c b/src/radio/radio_audio.c index ac94e471..050a495d 100644 --- a/src/radio/radio_audio.c +++ b/src/radio/radio_audio.c @@ -102,6 +102,8 @@ 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 float g_vad_probability; +static uint64_t g_vad_updated_tb; 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кГц */ @@ -131,6 +133,8 @@ static uint64_t g_tx_stats_tb = 0; static struct { uint64_t stats_tb, last_window_tb, total_windows, max_inference_tb, max_gap_tb; unsigned windows, speech, busy, confirm_resets, errors; + unsigned clipped_samples, clipped_frames; + float input_sample_peak, output_sample_peak; float min_probability, max_probability, max_input_rms, max_model_rms; int last_busy; } g_vad_diag; @@ -566,6 +570,7 @@ int radio_audio_capture_start(uint64_t group_id, int capture_channels, int auto_ accfg.release_rate_per_sec, accfg.block_duration_ms, g_vad_compressor ? accfg.block_duration_ms : 0); } radio_audio_level_reset(); + g_vad_probability = 0; g_vad_updated_tb = 0; g_vad_pcm_frames = 0; g_vad_input_sum_sq = 0; g_vad_input_rms = g_vad_output_rms = 0; if (g_auto_mode) { @@ -616,6 +621,7 @@ void radio_audio_capture_stop(void) { if (g_vad) { silero_vad_destroy(g_vad); g_vad = NULL; } g_auto_mode = 0; radio_audio_level_reset(); + g_vad_probability = 0; g_vad_updated_tb = 0; g_manual_ptt = 0; g_vad_win_len = 0; g_vad_pcm_frames = 0; g_vad_input_sum_sq = 0; @@ -900,6 +906,7 @@ static int radio_audio_auto_block(uint64_t now) { (unsigned long long)(g_output_voice_end > g_output_played ? g_output_voice_end - g_output_played : 0), g_manual_ptt, g_transmitting, g_prefetch_frames); g_auto_blocked = busy; + g_vad_updated_tb = 0; 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; @@ -920,6 +927,7 @@ void radio_audio_capture_discontinuity(uint64_t group_id) { 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_updated_tb = 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; @@ -942,10 +950,13 @@ static void radio_audio_vad_sample(const int16_t* frame) { g_vad_win[g_vad_win_len++] = g_vad_sum / (3.0f * 32768.0f); g_vad_decimation = 0; g_vad_sum = 0; if (g_vad_win_len != RADIO_VAD_WINDOW_16K) return; - double sum_sq = 0; + double sum_sq = 0, sum = 0; + unsigned zeros = 0; float peak = 0; for (int i = 0; i < RADIO_VAD_WINDOW_16K; ++i) { sum_sq += (double)g_vad_win[i] * g_vad_win[i]; + sum += g_vad_win[i]; + if (g_vad_win[i] == 0) ++zeros; if (fabsf(g_vad_win[i]) > peak) peak = fabsf(g_vad_win[i]); } float model_rms = sqrtf((float)(sum_sq / RADIO_VAD_WINDOW_16K)); @@ -965,11 +976,13 @@ static void radio_audio_vad_sample(const int16_t* frame) { if (inference_tb > g_vad_diag.max_inference_tb) g_vad_diag.max_inference_tb = inference_tb; if (gap_tb > g_vad_diag.max_gap_tb) g_vad_diag.max_gap_tb = gap_tb; if (result != 0) { + g_vad_updated_tb = 0; ++g_vad_diag.errors; 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_auto_block(now)) return; /* RX мог начаться, пока Silero работал без общего mutex. */ + if (result == 0) { g_vad_probability = probability; g_vad_updated_tb = now; } 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; @@ -995,8 +1008,11 @@ static void radio_audio_vad_sample(const int16_t* frame) { 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, + "dc=%.6f zeros=%u/%d input_rms=%.6f agc_rms=%.6f gain=%.1fdB " + "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, + sum / RADIO_VAD_WINDOW_16K, zeros, RADIO_VAD_WINDOW_16K, g_vad_input_rms, g_vad_output_rms, + audio_compressor_gain_db(g_vad_compressor), 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; @@ -1008,6 +1024,14 @@ static void radio_audio_vad_sample(const int16_t* frame) { if (model_rms > g_vad_diag.max_model_rms) g_vad_diag.max_model_rms = model_rms; if (!g_vad_diag.stats_tb) g_vad_diag.stats_tb = now; if (now - g_vad_diag.stats_tb >= RADIO_AUDIO_STATS_TB) { + if (g_vad_diag.clipped_samples) { + DEBUG_WARN(DEBUG_CATEGORY_VAD, "VAD compressor clipping group=%016llx span=%.1fms samples=%u frames=%u " + "input_peak=%.4f output_peak=%.4f gain=%.1fdB probability=%.3f..%.3f", + (unsigned long long)g_group_id, (now - g_vad_diag.stats_tb) / 10.0, + g_vad_diag.clipped_samples, g_vad_diag.clipped_frames, g_vad_diag.input_sample_peak, + g_vad_diag.output_sample_peak, audio_compressor_gain_db(g_vad_compressor), + g_vad_diag.min_probability, g_vad_diag.max_probability); + } DEBUG_DEBUG(DEBUG_CATEGORY_VAD, "VAD summary group=%016llx span=%.1fms windows=%u audio=%ums speech=%u " "busy=%u confirm_resets=%u errors=%u probability=%.3f..%.3f threshold=%.3f reason=%s " "input_peak=%.1fdBFS model_peak=%.1fdBFS gain=%.1fdB clips=%u inference_max=%.1fms gap_max=%.1fms " @@ -1023,6 +1047,8 @@ static void radio_audio_vad_sample(const int16_t* frame) { g_vad_diag.windows = g_vad_diag.speech = g_vad_diag.busy = g_vad_diag.confirm_resets = g_vad_diag.errors = 0; g_vad_diag.min_probability = g_vad_diag.max_probability = g_vad_diag.max_input_rms = g_vad_diag.max_model_rms = 0; g_vad_diag.max_inference_tb = g_vad_diag.max_gap_tb = 0; + g_vad_diag.clipped_samples = g_vad_diag.clipped_frames = 0; + g_vad_diag.input_sample_peak = g_vad_diag.output_sample_peak = 0; } } @@ -1033,11 +1059,13 @@ static void radio_audio_vad_feed(const int16_t* frame) { for (int ch = 0; ch < g_capture_channels; ++ch) { double sample = (double)frame[ch] / 32768.0; g_vad_input_sum_sq += sample * sample; + if (fabs(sample) > g_vad_diag.input_sample_peak) g_vad_diag.input_sample_peak = (float)fabs(sample); } if (++g_vad_pcm_frames != RADIO_AUDIO_FRAME_SAMPLES) return; int count = RADIO_AUDIO_FRAME_SAMPLES * g_capture_channels; g_vad_input_rms = sqrtf((float)(g_vad_input_sum_sq / count)); g_vad_pcm_frames = 0; g_vad_input_sum_sq = 0; + uint32_t clips_before = audio_compressor_clip_count(g_vad_compressor); 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); @@ -1046,10 +1074,14 @@ static void radio_audio_vad_feed(const int16_t* frame) { radio_audio_update_transmission(); return; } + uint32_t clips = audio_compressor_clip_count(g_vad_compressor) - clips_before; + g_vad_diag.clipped_samples += clips; + if (clips) ++g_vad_diag.clipped_frames; double sum_sq = 0; for (int i = 0; i < count; ++i) { double sample = (double)g_vad_pcm[i] / 32768.0; sum_sq += sample * sample; + if (fabs(sample) > g_vad_diag.output_sample_peak) g_vad_diag.output_sample_peak = (float)fabs(sample); } g_vad_output_rms = sqrtf((float)(sum_sq / count)); for (int i = 0; i < count && !g_auto_blocked; i += g_capture_channels) radio_audio_vad_sample(g_vad_pcm + i); @@ -1092,10 +1124,14 @@ static void radio_audio_level_feed(const int16_t* frame) { 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}; + struct radio_audio_input_status status = {.level_dbfs = g_level_dbfs, .vad_probability = g_vad_probability}; 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); + status.vad_valid = g_active && g_encoder && g_auto_mode == RADIO_AUTO_VAD && !status.blocked && + g_vad_updated_tb && now - g_vad_updated_tb <= 2000; + status.vad_gain_db = audio_compressor_gain_db(g_vad_compressor); + status.vad_compressor_enabled = audio_compressor_is_enabled(g_vad_compressor); pthread_mutex_unlock(&g_mtx); return status; } diff --git a/src/radio/radio_audio.h b/src/radio/radio_audio.h index a75de77d..78324191 100644 --- a/src/radio/radio_audio.h +++ b/src/radio/radio_audio.h @@ -44,6 +44,10 @@ struct radio_audio_input_status { float level_dbfs; /* RMS за 20мс после AEC, до компрессоров; тишина = -120dBFS. */ int valid; /* Есть PCM не старше 200мс. */ int blocked; /* Чужая речь / дренаж вывода / защитная пауза. */ + float vad_probability; /* Вероятность речи 0..1 из последнего успешного окна Silero. */ + int vad_valid; /* VAD работает, эфир свободен, оценка не старше 200мс. */ + float vad_gain_db; /* Фактическое усиление отдельного компрессора VAD; 0 при выключенном. */ + int vad_compressor_enabled; }; /* Любой поток: короткий снимок под mutex, без вызовов GUI из аудиопотока. */ struct radio_audio_input_status radio_audio_get_input_status(void); diff --git a/tests/test_silero_vad.c b/tests/test_silero_vad.c index 0d3f8fd3..be646569 100644 --- a/tests/test_silero_vad.c +++ b/tests/test_silero_vad.c @@ -17,7 +17,7 @@ #include "../lib/silero_vad.h" #include "../lib/audio_compressor.h" #include "../lib/debug_config.h" -#include "../src/radio/radio_vad.h" +#include "../src/radio/radio_auto.h" #define PI 3.14159265358979323846 @@ -198,8 +198,8 @@ static void test_speech(const char* model_path) { if (!vad) return; for (int pass = 0; pass < 2; pass++) { silero_vad_reset(vad); - struct radio_vad_fsm fsm; - radio_vad_fsm_reset(&fsm); + struct radio_auto_fsm fsm; + radio_auto_fsm_reset(&fsm); uint64_t now = 1; float max_prob = 0; int starts = 0, stops = 0; @@ -223,7 +223,7 @@ static void test_speech(const char* model_path) { if (probability > max_prob) max_prob = probability; } now += 320; - int transition = radio_vad_fsm_update(&fsm, probability, 0.5f, 2, 2000, 0, now); + int transition = radio_auto_fsm_update(&fsm, probability, 0.5f, 0.5f, 2, 2000, 0, now); if (transition == 1 && window < SPEECH_WINDOWS) starts++; if (transition == -1 && window >= SPEECH_WINDOWS) stops++; } @@ -260,8 +260,8 @@ static void test_compressed_speech(float amplitude, int max_gain_db) { printf("[silero_vad] independent AGC speech amplitude=%.3f max_gain=%ddB + low noise\n", amplitude, max_gain_db); for (int pass = 0; pass < 2; ++pass) { audio_compressor_reset(ac); silero_vad_reset(vad); - struct radio_vad_fsm fsm; - radio_vad_fsm_reset(&fsm); + struct radio_auto_fsm fsm; + radio_auto_fsm_reset(&fsm); uint32_t noise = 1; float peak = 0, noise_peak = 0; int window_len = 0, window_idx = 0, starts = 0, stops = 0; @@ -293,7 +293,7 @@ static void test_compressed_speech(float amplitude, int max_gain_db) { int speech = end_frame > NOISE_FRAMES && end_frame <= NOISE_FRAMES + SPEECH_SAMPLES * 3; if (speech && probability > peak) peak = probability; if (end_frame <= NOISE_FRAMES && probability > noise_peak) noise_peak = probability; - int transition = radio_vad_fsm_update(&fsm, probability, 0.5f, 2, 2000, 0, (uint64_t)(window_idx + 1) * 320); + int transition = radio_auto_fsm_update(&fsm, probability, 0.5f, 0.5f, 2, 2000, 0, (uint64_t)(window_idx + 1) * 320); if (transition > 0) starts++; if (transition < 0) stops++; window_len = 0; ++window_idx; @@ -309,6 +309,111 @@ static void test_compressed_speech(float amplitude, int max_gain_db) { audio_compressor_destroy(ac); silero_vad_destroy(vad); } +/* Диагностика: один и тот же PCM повторяется без reset, виден эффект истории AGC/Silero. */ +static void probe_repeated_speech(float amplitude, int noise_amplitude, int max_gain_db) { + enum { FRAMES = 153600, REPEATS = 4, SPEECH_START = 61440, SPEECH_FRAMES = 16384 * 3 }; + int16_t speech[16384], pcm[960]; + FILE* file = fopen(TEST_SILERO_SPEECH_PATH, "rb"); + CHECK(file != NULL, "probe fixture open"); if (!file) return; + size_t size = fread(speech, sizeof(*speech), 16384, file); fclose(file); + CHECK(size == 16384, "probe fixture size"); if (size != 16384) return; + struct audio_compressor* ac = audio_compressor_create(); + silero_vad_t* vad = silero_vad_create_default(); + CHECK(ac && vad, "probe create"); + if (!ac || !vad) { audio_compressor_destroy(ac); silero_vad_destroy(vad); return; } + audio_compressor_config_t cfg = {0}; + cfg.sample_rate = 48000; cfg.channels = 1; cfg.block_duration_ms = 20; cfg.lookback_ms = 200; + cfg.max_gain_db = max_gain_db < 0 ? 0 : max_gain_db; cfg.rise_rate_per_sec = 4; cfg.target_level = 0.25f; + CHECK(audio_compressor_configure(ac, &cfg) == 0, "probe configure"); + audio_compressor_set_enabled(ac, max_gain_db >= 0); + float window[512]; int used = 0; + for (int repeat = 0; repeat < REPEATS; ++repeat) { + uint32_t random = 1, clips = audio_compressor_clip_count(ac); + float peak_probability = 0, probability_sum = 0, noise_peak = 0, gain_at_start = 0; + int speech_windows = 0, high_windows = 0; + for (int pos = 0; pos < FRAMES; pos += 960) { + for (int i = 0; i < 960; ++i) { + random = random * 1664525u + 1013904223u; + int value = noise_amplitude * ((int)(random >> 16) - 32768) / 32768; + int offset = pos + i - SPEECH_START; + if (offset >= 0 && offset < SPEECH_FRAMES) value += (int)(speech[offset / 3] * amplitude); + pcm[i] = (int16_t)(value > 32767 ? 32767 : value < -32768 ? -32768 : value); + } + if (pos == SPEECH_START) gain_at_start = audio_compressor_gain_db(ac); + CHECK(audio_compressor_process_frame(ac, pcm, pcm, 960) == 0, "probe process frame"); + for (int i = 0; i < 960; i += 3) { + window[used++] = ((float)pcm[i] + pcm[i + 1] + pcm[i + 2]) / (3 * 32768.0f); + if (used != 512) continue; + used = 0; float probability = 0; + CHECK(silero_vad_process(vad, window, &probability) == 0, "probe VAD"); + int end = pos + i + 3; + if (end > SPEECH_START && end <= SPEECH_START + SPEECH_FRAMES) { + if (probability > peak_probability) peak_probability = probability; + probability_sum += probability; ++speech_windows; + if (probability >= 0.8f) ++high_windows; + } else if (end <= SPEECH_START && probability > noise_peak) noise_peak = probability; + } + } + printf("VAD_PROBE amplitude=%.2f noise=%d gain_limit=%d repeat=%d start_gain=%.1f clips=%u speech_peak=%.4f " + "speech_mean=%.4f high=%d/%d noise_peak=%.4f\n", amplitude, noise_amplitude, max_gain_db, repeat, + gain_at_start, audio_compressor_clip_count(ac) - clips, peak_probability, probability_sum / speech_windows, + high_windows, speech_windows, noise_peak); + } + audio_compressor_destroy(ac); silero_vad_destroy(vad); +} + + +/* PCM mono s16le/48кГц: две модели получают одинаковый выход AGC; сброс отличается только в указанном окне. */ +static void probe_stream(const char* path, unsigned reset_window, int max_gain_db) { + FILE* file = fopen(path, "rb"); + CHECK(file != NULL, "stream open: %s", path); if (!file) return; + struct audio_compressor* ac = audio_compressor_create(); + silero_vad_t* history = silero_vad_create_default(); + silero_vad_t* reset = silero_vad_create_default(); + CHECK(ac && history && reset, "stream create"); + if (!ac || !history || !reset) goto done; + audio_compressor_config_t cfg = {0}; + cfg.sample_rate = 48000; cfg.channels = 1; cfg.block_duration_ms = 20; cfg.lookback_ms = 200; + cfg.max_gain_db = max_gain_db < 0 ? 0 : max_gain_db; cfg.rise_rate_per_sec = 4; cfg.target_level = 0.25f; + int rc = audio_compressor_configure(ac, &cfg); + CHECK(rc == 0, "stream compressor configure"); if (rc != 0) goto done; + audio_compressor_set_enabled(ac, max_gain_db >= 0); + uint8_t bytes[1920]; int16_t pcm[960]; float window[512]; + unsigned used = 0, index = 0; + size_t count; + while ((count = fread(bytes, 1, sizeof(bytes), file)) != 0) { + CHECK(count == sizeof(bytes), "stream requires full 20ms frames, tail=%zu", count); + if (count != sizeof(bytes)) break; + for (int i = 0; i < 960; ++i) { + int value = bytes[2*i] | ((int)bytes[2*i+1] << 8); + pcm[i] = (int16_t)(value >= 32768 ? value - 65536 : value); + } + rc = audio_compressor_process_frame(ac, pcm, pcm, 960); + CHECK(rc == 0, "stream compressor frame"); if (rc != 0) break; + for (int i = 0; i < 960; i += 3) { + window[used++] = ((float)pcm[i] + pcm[i+1] + pcm[i+2]) / (3 * 32768.0f); + if (used != 512) continue; + used = 0; + if (index == reset_window) silero_vad_reset(reset); + float p = 0, q = 0; + int a = silero_vad_process(history, window, &p), b = silero_vad_process(reset, window, &q); + CHECK(a == 0 && b == 0 && isfinite(p) && isfinite(q), "stream inference window=%u", index); + if (a || b) goto done; + if (index < reset_window) CHECK(fabsf(p - q) < 1e-6f, "stream models differ before reset window=%u", index); + double energy = 0; + for (int j = 0; j < 512; ++j) energy += window[j] * window[j]; + printf("VAD_STREAM gain_limit=%d window=%u time_ms=%u reset_window=%u history=%.6f reset=%.6f " + "rms=%.6f gain=%.2f clips=%u\n", max_gain_db, index, index * 32, reset_window, p, q, + sqrt(energy / 512), audio_compressor_gain_db(ac), audio_compressor_clip_count(ac)); + ++index; + } + } + CHECK(!ferror(file), "stream read failed"); + CHECK(index > reset_window, "stream ended before reset window=%u total=%u", reset_window, index); +done: + fclose(file); audio_compressor_destroy(ac); silero_vad_destroy(history); silero_vad_destroy(reset); +} + int main(int argc, char** argv) { const char* model_path = (argc > 1) ? argv[1] : "../lib/silero_vad.onnx"; @@ -318,6 +423,21 @@ int main(int argc, char** argv) { printf("Silero VAD test, model: %s\n", model_path); + if (argc > 2 && !strcmp(argv[2], "--probe")) { + const int gains[] = {-1, 1, 15, 30}, noises[] = {0, 300, 2000}; + for (int a = 0; a < 2; ++a) for (int n = 0; n < 3; ++n) for (int g = 0; g < 4; ++g) + probe_repeated_speech(a ? 1.0f : 0.1f, noises[n], gains[g]); + return g_failures ? 1 : 0; + } + if (argc > 2 && !strcmp(argv[2], "--probe-stream")) { + unsigned reset_window = 0; char extra; + if (argc != 5 || sscanf(argv[4], "%u%c", &reset_window, &extra) != 1 || reset_window > 1000000) { + fprintf(stderr, "Usage: %s model --probe-stream mono48k.s16le reset_window\n", argv[0]); return 1; + } + const int gains[] = {-1, 1, 15}; + for (int i = 0; i < 3; ++i) probe_stream(argv[3], reset_window, gains[i]); + return g_failures ? 1 : 0; + } test_lifecycle_and_errors(model_path); test_silence(model_path); test_pcm16_matches_float(model_path); diff --git a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt index 1f6f679e..ad00ba8a 100644 --- a/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt +++ b/tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt @@ -214,7 +214,8 @@ object NativeLib { fun radioAudioStop() { nativeRadioAudioStop() } fun radioCaptureStart(groupId: Long, channels: Int, mode: Int): Boolean = nativeRadioCaptureStart(groupId, channels, mode) fun radioConfigure(requestId: Long, settings: RadioAutoSettings): Boolean = nativeRadioConfigure(requestId, settings.nativeValues()) - fun radioInputStatus(): FloatArray = nativeRadioInputStatus() // уровень dBFS, valid, RX-блокировка + // dBFS, valid, RX-блокировка, вероятность речи 0..1, vadValid, усиление VAD в dB, компрессор VAD включён. + fun radioInputStatus(): FloatArray = nativeRadioInputStatus() fun radioCaptureStop() { nativeRadioCaptureStop() } fun radioTransmitting(): Boolean = nativeRadioTransmitting() fun radioAutoMode(): Int = nativeRadioAutoMode() 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 d04df3e3..41749098 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 @@ -584,7 +584,7 @@ private fun DeveloperSettingsTab(vm: ChatViewModel, onDirty: (Boolean) -> Unit, dismissButton = { TextButton(onClick = { showClearDb = false }) { Text(stringResource(R.string.cancel)) } }) } -/* Настройка детектора использует тот же native RMS, что и фактическая передача. */ +/* Измерители показывают фактические RMS и вероятность речи из native-детекторов. */ @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun RadioAutomaticSettings(provider: ConfigProvider, compact: Boolean = false) { @@ -596,6 +596,9 @@ internal fun RadioAutomaticSettings(provider: ConfigProvider, compact: Boolean = var saveError by remember { mutableStateOf(false) } var resumed by remember { mutableStateOf(false) } var level by remember { mutableStateOf(null) } + var vadProbability by remember { mutableStateOf(null) } + var vadGain by remember { mutableStateOf(null) } + var vadCompressorEnabled by remember { mutableStateOf(false) } var blocked by remember { mutableStateOf(false) } var active by remember { mutableStateOf(engine.isActive()) } var micPermission by remember { mutableStateOf(MicrophonePermission.granted()) } @@ -607,12 +610,15 @@ internal fun RadioAutomaticSettings(provider: ConfigProvider, compact: Boolean = onPauseOrDispose { resumed = false; engine.setLevelMonitoring(false) } } LaunchedEffect(resumed) { - if (!resumed) { level = null; return@LaunchedEffect } + if (!resumed) { level = null; vadProbability = null; vadGain = null; return@LaunchedEffect } while (true) { val status = NativeLib.radioInputStatus() active = engine.isActive() micPermission = MicrophonePermission.granted() level = if (active && status[1] != 0f) status[0] else null + vadProbability = if (active && status[4] != 0f) status[3] else null + vadGain = if (active && status[4] != 0f) status[5] else null + vadCompressorEnabled = status[6] != 0f blocked = status[2] != 0f delay(100) } @@ -661,19 +667,34 @@ internal fun RadioAutomaticSettings(provider: ConfigProvider, compact: Boolean = Text("−90 dBFS", style = MaterialTheme.typography.bodySmall) Text("0", style = MaterialTheme.typography.bodySmall) } - if (draft.mode == RadioAutoSettings.LEVEL) { - LabeledSlider(stringResource(R.string.radio_level_on), draft.levelOn.toFloat(), -90f..0f, 89, "${draft.levelOn} dBFS", - { if (!saving) draft = draft.copy(levelOn = it.toInt()) }) - LabeledSlider(stringResource(R.string.radio_level_off), draft.levelOff.toFloat(), -90f..0f, 89, "${draft.levelOff} dBFS", - { if (!saving) draft = draft.copy(levelOff = it.toInt()) }) - } else { - LabeledSlider(stringResource(R.string.speech_threshold), draft.vadThreshold.toFloat(), 10f..95f, 84, "${draft.vadThreshold}%", - { if (!saving) draft = draft.copy(vadThreshold = it.toInt()) }) - if (!compact) LabeledSlider(stringResource(R.string.vad_max_gain), draft.vadGain.toFloat(), 1f..60f, 58, "${draft.vadGain} dB", - { if (!saving) draft = draft.copy(vadGain = it.toInt()) }) - if (!compact) Text(stringResource(R.string.vad_compressor_hint), style = MaterialTheme.typography.bodySmall) + LabeledSlider(stringResource(R.string.radio_level_on), draft.levelOn.toFloat(), -90f..0f, 89, "${draft.levelOn} dBFS", + { if (!saving) draft = draft.copy(levelOn = it.toInt()) }) + LabeledSlider(stringResource(R.string.radio_level_off), draft.levelOff.toFloat(), -90f..0f, 89, "${draft.levelOff} dBFS", + { if (!saving) draft = draft.copy(levelOff = it.toInt()) }) + HorizontalDivider() + Text(stringResource(R.string.vad_button), style = MaterialTheme.typography.titleSmall) + Text(vadProbability?.let { stringResource(R.string.radio_vad_current, it * 100f) } ?: stringResource(when { + !saved.enabled || saved.mode != RadioAutoSettings.VAD -> R.string.radio_vad_enable + blocked -> R.string.radio_vad_busy + else -> R.string.radio_vad_unavailable + }), style = MaterialTheme.typography.bodySmall) + Canvas(Modifier.fillMaxWidth().height(20.dp)) { + drawRect(background) + vadProbability?.let { drawRect(Color(0xFF27AE60), size = Size(it.coerceIn(0f, 1f) * size.width, size.height)) } + val thresholdX = draft.vadThreshold / 100f * size.width + drawLine(Color(0xFFE67E22), Offset(thresholdX, 0f), Offset(thresholdX, size.height), 3.dp.toPx()) } - if (!compact) LabeledSlider(stringResource(R.string.silence_delay), draft.hangoverMs.toFloat(), 50f..2000f, 38, + LabeledSlider(stringResource(R.string.speech_threshold), draft.vadThreshold.toFloat(), 10f..95f, 84, "${draft.vadThreshold}%", + { if (!saving) draft = draft.copy(vadThreshold = it.toInt()) }) + Text(when { + vadGain == null -> stringResource(R.string.radio_vad_gain_unavailable) + !vadCompressorEnabled -> stringResource(R.string.radio_vad_compressor_off) + else -> stringResource(R.string.radio_vad_gain_current, vadGain!!) + }, style = MaterialTheme.typography.bodySmall) + LabeledSlider(stringResource(R.string.vad_max_gain), draft.vadGain.toFloat(), 1f..60f, 58, "${draft.vadGain} dB", + { if (!saving) draft = draft.copy(vadGain = it.toInt()) }) + if (!compact) Text(stringResource(R.string.vad_compressor_hint), style = MaterialTheme.typography.bodySmall) + LabeledSlider(stringResource(R.string.silence_delay), draft.hangoverMs.toFloat(), 50f..2000f, 38, stringResource(R.string.milliseconds, draft.hangoverMs), { if (!saving) draft = draft.copy(hangoverMs = it.toInt()) }) if (draft.levelOff >= draft.levelOn) Text(stringResource(R.string.radio_level_invalid), color = MaterialTheme.colorScheme.error) if (saveError) Text(stringResource(R.string.radio_auto_save_failed), color = MaterialTheme.colorScheme.error) diff --git a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml index dafdb770..7b240b26 100644 --- a/tools/chatgui-android/app/src/main/res/values-ru/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values-ru/strings.xml @@ -407,6 +407,13 @@ "Разрешить доступ к микрофону" "Уровень микрофона недоступен" "Текущий уровень: %1$.1f dBFS" + "Вероятность речи VAD: %1$.1f%%" + "Включите автопередачу в режиме VAD для оценки вероятности речи" + "Оценка VAD приостановлена: эфир занят" + "Оценка вероятности речи VAD недоступна" + "Текущее усиление компрессора VAD: %1$+.1f dB" + "Текущее усиление компрессора VAD: —" + "Компрессор VAD выключен (0 dB)" "Порог включения (оранжевый):" "Порог отключения (синий):" "Порог отключения должен быть ниже порога включения" diff --git a/tools/chatgui-android/app/src/main/res/values/strings.xml b/tools/chatgui-android/app/src/main/res/values/strings.xml index 0f2bdc22..92404e8d 100644 --- a/tools/chatgui-android/app/src/main/res/values/strings.xml +++ b/tools/chatgui-android/app/src/main/res/values/strings.xml @@ -406,6 +406,13 @@ "Allow microphone access" "Microphone level unavailable" "Current level: %1$.1f dBFS" + "VAD speech probability: %1$.1f%%" + "Enable automatic transmission in VAD mode to see speech probability" + "VAD measurement paused: channel busy" + "VAD speech probability unavailable" + "Current VAD compressor gain: %1$+.1f dB" + "Current VAD compressor gain: —" + "VAD compressor off (0 dB)" "Start threshold (orange):" "Stop threshold (blue):" "The stop threshold must be lower than the start threshold" diff --git a/tools/chatgui-android/jni_bridge/android_jni_bridge.c b/tools/chatgui-android/jni_bridge/android_jni_bridge.c index f4cba85a..35eee614 100644 --- a/tools/chatgui-android/jni_bridge/android_jni_bridge.c +++ b/tools/chatgui-android/jni_bridge/android_jni_bridge.c @@ -2784,10 +2784,11 @@ JNIEXPORT jboolean JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioConfigur JNIEXPORT jfloatArray JNICALL Java_com_utun_chat_data_NativeLib_nativeRadioInputStatus(JNIEnv* env, jobject thiz) { (void)thiz; struct radio_audio_input_status status = radio_audio_get_input_status(); - jfloat values[] = {status.level_dbfs, (float)status.valid, (float)status.blocked}; - jfloatArray result = (*env)->NewFloatArray(env, 3); + jfloat values[] = {status.level_dbfs, (float)status.valid, (float)status.blocked, status.vad_probability, (float)status.vad_valid, + status.vad_gain_db, (float)status.vad_compressor_enabled}; + jfloatArray result = (*env)->NewFloatArray(env, 7); if (!result) { DEBUG_ERROR(DEBUG_CATEGORY_RADIO, "Android radio input status allocation failed"); return NULL; } - (*env)->SetFloatArrayRegion(env, result, 0, 3, values); + (*env)->SetFloatArrayRegion(env, result, 0, 7, values); return result; } diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 547bdc6c..ff00421d 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -444,6 +444,12 @@ add_executable(test_voice_audio_io tests/test_voice_audio_io.cpp src/voice_audio target_include_directories(test_voice_audio_io PRIVATE ${UTUN_INCLUDE_DIRS}) target_link_libraries(test_voice_audio_io PRIVATE utun pthread) add_test(NAME test_voice_audio_io COMMAND test_voice_audio_io) +if(ONNXRUNTIME_INCLUDE_DIR AND ONNXRUNTIME_LIB) + add_executable(test_silero_vad ../../tests/test_silero_vad.c) + target_compile_definitions(test_silero_vad PRIVATE TEST_SILERO_SPEECH_PATH="${CMAKE_CURRENT_SOURCE_DIR}/../../tests/data/silero_speech.pcm") + target_link_libraries(test_silero_vad PRIVATE utun) + add_test(NAME test_silero_vad COMMAND test_silero_vad "${CMAKE_CURRENT_SOURCE_DIR}/../../lib/silero_vad.onnx") +endif() set_tests_properties(test_audio_event_queue test_voice_audio_io PROPERTIES TIMEOUT 15) if(UNIX AND NOT APPLE) add_executable(test_audio_timing tests/test_audio_timing.c transport/audio_diagnostics.cpp) diff --git a/tools/chatgui/src/radio_audio_engine.cpp b/tools/chatgui/src/radio_audio_engine.cpp index 7f04d345..04732175 100644 --- a/tools/chatgui/src/radio_audio_engine.cpp +++ b/tools/chatgui/src/radio_audio_engine.cpp @@ -271,8 +271,12 @@ void RadioAudioEngine::updateState() { bool capture = m_active && m_capture.initialized() && m_voice.captureRunning() && ma_device_is_started(m_capture.device()); auto input = radio_audio_get_input_status(); bool valid = capture && input.valid; - if (m_inputLevel != input.level_dbfs || m_inputLevelValid != valid) { + bool vadValid = capture && input.vad_valid; + if (m_inputLevel != input.level_dbfs || m_inputLevelValid != valid || m_vadProbability != input.vad_probability || + m_vadValid != vadValid || m_vadGain != input.vad_gain_db || m_vadCompressorEnabled != bool(input.vad_compressor_enabled)) { m_inputLevel = input.level_dbfs; m_inputLevelValid = valid; + m_vadProbability = input.vad_probability; m_vadValid = vadValid; + m_vadGain = input.vad_gain_db; m_vadCompressorEnabled = input.vad_compressor_enabled; emit inputLevelChanged(); } bool automatic = capture && radio_audio_auto_mode() != RADIO_AUTO_OFF; diff --git a/tools/chatgui/src/radio_audio_engine.h b/tools/chatgui/src/radio_audio_engine.h index f9a4153e..9158d909 100644 --- a/tools/chatgui/src/radio_audio_engine.h +++ b/tools/chatgui/src/radio_audio_engine.h @@ -44,6 +44,10 @@ public: int autoMode() const { return m_settings[6]; } float inputLevel() const { return m_inputLevel; } bool inputLevelValid() const { return m_inputLevelValid; } + float vadProbability() const { return m_vadProbability; } + bool vadValid() const { return m_vadValid; } + float vadGain() const { return m_vadGain; } + bool vadCompressorEnabled() const { return m_vadCompressorEnabled; } bool autoBlocked() const { return m_autoBlocked; } bool autoAvailable() const { return m_autoAvailable; } bool captureAvailable() const { return m_captureAvailable; } @@ -80,6 +84,8 @@ private: float m_inputLevel = -120; bool m_inputLevelValid = false, m_autoBlocked = false; + float m_vadProbability = 0, m_vadGain = 0; + bool m_vadValid = false, m_vadCompressorEnabled = 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 634a8861..85c46702 100644 --- a/tools/chatgui/src/radiopanel.cpp +++ b/tools/chatgui/src/radiopanel.cpp @@ -1,6 +1,7 @@ #include #include "radiopanel.h" #include "radio_audio_engine.h" +#include "radiosettingspage.h" #include "sound_manager.h" #include "../transport/gui_bridge.h" extern "C" { @@ -17,7 +18,32 @@ extern "C" { #include #include #include +#include +#include +#include +#include +#include +#include #include +#include + +/* Полоса показывает текущую вероятность речи, не установленный порог срабатывания. */ +class RadioAutoButton final : public QPushButton { +public: + explicit RadioAutoButton(QWidget* parent) : QPushButton(QStringLiteral("VAD"), parent) {} +protected: + void paintEvent(QPaintEvent* event) override { + QPushButton::paintEvent(event); + auto* engine = RadioAudioEngine::instance(); + if (engine->autoMode() != 1) return; + QPainter painter(this); + QRectF track(6, height() - 7, width() - 12, 3); + painter.fillRect(track, isChecked() ? QColor(0, 0, 0, 65) : palette().color(QPalette::Mid)); + if (!engine->vadValid()) return; + track.setWidth(track.width() * std::clamp(engine->vadProbability(), 0.0f, 1.0f)); + painter.fillRect(track, QColor("#4cdb88")); + } +}; RadioPanel::RadioPanel(QWidget* parent) : QWidget(parent) { setObjectName(QStringLiteral("radioPanel")); @@ -43,12 +69,21 @@ RadioPanel::RadioPanel(QWidget* parent) : QWidget(parent) { m_status->setMinimumWidth(0); m_status->setSizePolicy(QSizePolicy::Ignored, QSizePolicy::Preferred); row->addWidget(m_status, 1); - m_auto = new QPushButton(QStringLiteral("VAD"), this); + m_auto = new RadioAutoButton(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->installEventFilter(this); + m_auto->setContextMenuPolicy(Qt::CustomContextMenu); + connect(m_auto, &QWidget::customContextMenuRequested, this, [this]() { showActivationSettings(); }); + m_autoHold.setSingleShot(true); m_autoHold.setInterval(500); + connect(&m_autoHold, &QTimer::timeout, this, [this]() { + if (!m_auto->isDown() || !m_auto->isEnabled()) return; + m_autoLongPressed = true; m_auto->setDown(false); + showActivationSettings(); + }); m_auto->setStyleSheet("QPushButton:checked { background: #3390EC; color: white; }"); row->addWidget(m_auto); m_ptt = new QPushButton(QStringLiteral("PTT"), this); @@ -67,10 +102,13 @@ RadioPanel::RadioPanel(QWidget* parent) : QWidget(parent) { if (!m_pttLatched) engine->talkEnd(); }); connect(m_auto, &QPushButton::clicked, this, [this](bool checked) { + if (m_autoLongPressed) { updateState(); return; } RadioAudioEngine::instance()->applySettings({{QStringLiteral("radio_auto_enabled"), checked ? 1 : 0}}); updateState(); }); connect(engine, &RadioAudioEngine::stateChanged, this, &RadioPanel::updateState); + connect(engine, &RadioAudioEngine::inputLevelChanged, m_auto, [this]() { m_auto->update(); }); + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "RadioPanel: VAD button meter uses live speech probability (0..100%%), refresh <=10Hz"); connect(SoundManager::instance(), &SoundManager::devicesChanged, this, &RadioPanel::updateDevices); updateState(); updateDevices(); @@ -82,6 +120,16 @@ RadioPanel::~RadioPanel() { /* Второе нажатие Qt приходит как DoubleClick: не отдаём его обычному переключателю pressed. */ bool RadioPanel::eventFilter(QObject* watched, QEvent* event) { + if (watched == m_auto) { + if (event->type() == QEvent::MouseButtonPress && static_cast(event)->button() == Qt::LeftButton) { + m_autoLongPressed = false; m_autoHold.start(); + } else if (event->type() == QEvent::MouseButtonRelease && static_cast(event)->button() == Qt::LeftButton) { + m_autoHold.stop(); + if (m_autoLongPressed) { m_autoLongPressed = false; m_auto->setDown(false); return true; } + } else if (event->type() == QEvent::Leave || event->type() == QEvent::Hide || event->type() == QEvent::WindowDeactivate) { + m_autoHold.stop(); + } else if (event->type() == QEvent::KeyPress) m_autoLongPressed = false; + } if (watched == m_ptt && event->type() == QEvent::MouseButtonDblClick && m_ptt->isEnabled()) { auto* mouse = static_cast(event); if (mouse->button() == Qt::LeftButton && m_ptt->rect().contains(mouse->pos())) { @@ -93,6 +141,42 @@ bool RadioPanel::eventFilter(QObject* watched, QEvent* event) { return QWidget::eventFilter(watched, event); } +void RadioPanel::showActivationSettings() { + if (!m_auto->isEnabled()) return; + m_autoHold.stop(); m_auto->setDown(false); + if (!m_activationMenu) { + m_activationMenu = new QMenu(this); + m_activationMenu->setObjectName(QStringLiteral("radioActivationPopup")); + auto* action = new QWidgetAction(m_activationMenu); + auto* contents = new QWidget(m_activationMenu); + auto* layout = new QVBoxLayout(contents); + auto* scroll = new QScrollArea(contents); + scroll->setFrameShape(QFrame::NoFrame); scroll->setWidgetResizable(true); + m_activationSettings = new RadioActivationSettings(true); + scroll->setWidget(m_activationSettings); + int height = QGuiApplication::primaryScreen()->availableGeometry().height(); + int contentHeight = m_activationSettings->heightForWidth(420); + if (contentHeight < 0) contentHeight = m_activationSettings->sizeHint().height(); + scroll->setFixedSize(420, std::min(contentHeight + 24, std::min(480, std::max(160, height - 120)))); + layout->addWidget(scroll); + auto* apply = new QPushButton(QCoreApplication::translate("ChatGui", "Apply"), contents); + apply->setObjectName(QStringLiteral("radioActivationApply")); + layout->addWidget(apply); + connect(apply, &QPushButton::clicked, m_activationSettings, &RadioActivationSettings::apply); + connect(m_activationSettings, &RadioActivationSettings::changed, apply, [this, apply]() { + apply->setEnabled(m_activationSettings->canApply()); + }); + action->setDefaultWidget(contents); m_activationMenu->addAction(action); + connect(m_activationMenu, &QMenu::aboutToHide, this, [this]() { + m_autoLongPressed = false; + DEBUG_DEBUG(DEBUG_CATEGORY_RADIO, "RadioPanel: close activation quick settings"); + }); + } + m_activationSettings->loadSettings(); + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "RadioPanel: open activation quick settings mode=%d", RadioAudioEngine::instance()->autoMode()); + m_activationMenu->popup(m_auto->mapToGlobal(QPoint(0, m_auto->height()))); +} + /* Фиксация владеет только кнопочным PTT; хоткей и автоматическая передача остаются независимыми. */ void RadioPanel::setPttLatched(bool latched, const char* reason) { if (m_pttLatched == latched) return; @@ -109,9 +193,19 @@ void RadioPanel::updateState() { if (m_pttLatched && (!engine->isActive() || !engine->captureAvailable())) setPttLatched(false, engine->isActive() ? "microphone-unavailable" : "radio-stopped"); QSignalBlocker blocker(m_auto); - m_auto->setText(engine->autoMode() == 2 ? QCoreApplication::translate("ChatGui", "Level") : QStringLiteral("VAD")); + m_auto->setText(engine->autoMode() == 2 ? QString() : QStringLiteral("VAD")); + QIcon levelIcon; + if (engine->autoMode() == 2) { + QPixmap pixmap(24, 24); pixmap.fill(Qt::transparent); + QPainter painter(&pixmap); + painter.setPen(QPen(engine->autoEnabled() ? Qt::white : palette().color(QPalette::ButtonText), 3, Qt::SolidLine, Qt::RoundCap)); + painter.drawLine(5, 10, 5, 19); painter.drawLine(12, 4, 12, 19); painter.drawLine(19, 7, 19, 19); + painter.end(); levelIcon = QIcon(pixmap); + } + m_auto->setIcon(levelIcon); m_auto->setIconSize(QSize(24, 24)); m_auto->setChecked(engine->autoEnabled()); m_auto->setEnabled(engine->isActive() && engine->settingsReady() && !engine->settingsBusy()); + if (!engine->isActive() && m_activationMenu) m_activationMenu->hide(); 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.") @@ -137,8 +231,13 @@ void RadioPanel::updateState() { : QCoreApplication::translate("ChatGui", "VAD listening"); m_status->setText(state); m_status->setToolTip(engine->settingsError().isEmpty() ? state : engine->settingsError()); - m_auto->setToolTip(engine->autoEnabled() ? QCoreApplication::translate("ChatGui", "Disable automatic voice transmission") - : QCoreApplication::translate("ChatGui", "Enable automatic voice transmission")); + const QString toggle = engine->autoEnabled() ? QCoreApplication::translate("ChatGui", "Disable automatic voice transmission") + : QCoreApplication::translate("ChatGui", "Enable automatic voice transmission"); + const QString mode = engine->autoMode() == 2 ? QCoreApplication::translate("ChatGui", "By signal level") : QStringLiteral("VAD"); + const QString hint = QCoreApplication::translate("ChatGui", "Hold or right-click to adjust activation mode and thresholds"); + m_auto->setToolTip(mode + "\n" + toggle + "\n" + hint); + m_auto->setAccessibleName(mode + ". " + toggle); m_auto->setAccessibleDescription(hint); + m_auto->update(); } /* Список перечитывается при открытии меню: подключённые устройства видны без перезапуска. */ diff --git a/tools/chatgui/src/radiopanel.h b/tools/chatgui/src/radiopanel.h index 6ab8a73d..d310fb07 100644 --- a/tools/chatgui/src/radiopanel.h +++ b/tools/chatgui/src/radiopanel.h @@ -1,10 +1,13 @@ #pragma once #include +#include class QToolButton; class QPushButton; class QLabel; +class QMenu; +class RadioActivationSettings; /* Верхняя панель активной рации: общие устройства SoundManager, VAD и удерживаемая PTT. * Состояние передачи берётся из ядра; включение VAD хранится в конфигурации приложения. */ @@ -17,6 +20,7 @@ private: bool eventFilter(QObject* watched, QEvent* event) override; void setPttLatched(bool latched, const char* reason); void updateState(); + void showActivationSettings(); void updateDevices(); void populateDevices(QToolButton* button, bool capture); void selectDevice(const QString& id, bool capture); @@ -24,4 +28,8 @@ private: QPushButton *m_auto, *m_ptt; QLabel* m_status; bool m_pttLatched = false; + QTimer m_autoHold; + bool m_autoLongPressed = false; + QMenu* m_activationMenu = nullptr; + RadioActivationSettings* m_activationSettings = nullptr; }; diff --git a/tools/chatgui/src/radiosettingspage.cpp b/tools/chatgui/src/radiosettingspage.cpp index c610e8d4..e7bf0af8 100644 --- a/tools/chatgui/src/radiosettingspage.cpp +++ b/tools/chatgui/src/radiosettingspage.cpp @@ -15,7 +15,7 @@ #include #include #include -#include +#include #include #include #include @@ -29,89 +29,181 @@ public: explicit RadioLevelMeter(QWidget* parent) : QWidget(parent) { setMinimumSize(200, 34); } float level = -120; int on = -35, off = -45; - bool valid = false; + bool valid = false, vad = 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()); }; + auto x = [&](float value) { return bar.left() + int(std::clamp(vad ? value / 100 : (value + 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")); + painter.drawText(QRect(0, 18, width(), 16), Qt::AlignLeft, vad ? QStringLiteral("0%") : QStringLiteral("−90 dBFS")); + painter.drawText(QRect(0, 18, width(), 16), Qt::AlignRight, vad ? QStringLiteral("100%") : QStringLiteral("0")); for (int threshold : {off, on}) { + if (vad && threshold == off) continue; 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); - title->setStyleSheet("font-weight: bold; font-size: 13px;"); - layout->addWidget(title); - m_audioSettings = new QWidget(this); - auto* form = new QFormLayout(m_audioSettings); - form->setContentsMargins(0, 0, 0, 12); - auto add = [&](const QString& label, int min, int max, const QString& suffix) { - auto* spin = new QSpinBox(m_audioSettings); - spin->setRange(min, max); - spin->setSuffix(suffix); - form->addRow(label, spin); +RadioActivationSettings::RadioActivationSettings(bool compact, QWidget* parent) : QWidget(parent), m_compact(compact) { + auto* form = new QFormLayout(this); + form->setContentsMargins(0, 0, 0, 8); + form->setRowWrapPolicy(QFormLayout::WrapLongRows); + form->setFieldGrowthPolicy(QFormLayout::AllNonFixedFieldsGrow); + auto add = [&](const QString& label, const char* name, int min, int max, const QString& suffix) { + auto* spin = new QSpinBox(this); + spin->setObjectName(QLatin1String(name)); spin->setRange(min, max); spin->setSuffix(suffix); + auto* text = new QLabel(label, this); text->setWordWrap(true); + form->addRow(text, spin); + connect(spin, QOverload::of(&QSpinBox::valueChanged), this, &RadioActivationSettings::updateControls); 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(" %")); + auto label = [&](const char* name) { + auto* text = new QLabel(this); text->setObjectName(QLatin1String(name)); text->setWordWrap(true); + form->addRow(text); return text; + }; + auto* modes = new QWidget(this); + auto* row = new QHBoxLayout(modes); row->setContentsMargins(0, 0, 0, 0); row->setSpacing(0); + m_mode = new QButtonGroup(this); + for (int mode : {1, 2}) { + auto* button = new QPushButton(mode == 1 ? QStringLiteral("VAD") : QCoreApplication::translate("ChatGui", "By signal level"), modes); + button->setObjectName(mode == 1 ? QStringLiteral("radioModeVad") : QStringLiteral("radioModeLevel")); + button->setCheckable(true); button->setMinimumHeight(36); + button->setStyleSheet("QPushButton:checked { background: #3390EC; color: white; }"); + m_mode->addButton(button, mode); row->addWidget(button, 1); + connect(button, &QPushButton::clicked, this, [this, mode]() { + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "Radio settings: select mode=%d compact=%d (pending Apply)", mode, m_compact); + updateControls(); + }); + } + m_mode->button(1)->setChecked(true); + form->addRow(QCoreApplication::translate("ChatGui", "Automatic transmission:"), modes); + m_levelText = label("radioLevelText"); + m_levelMeter = new RadioLevelMeter(this); m_levelMeter->setObjectName(QStringLiteral("radioLevelMeter")); + form->addRow(m_levelMeter); + m_levelOn = add(QCoreApplication::translate("ChatGui", "Start threshold (orange):"), "radioLevelOn", -90, 0, QStringLiteral(" dBFS")); + m_levelOff = add(QCoreApplication::translate("ChatGui", "Stop threshold (blue):"), "radioLevelOff", -90, 0, QStringLiteral(" dBFS")); + m_vadText = label("radioVadText"); + m_vadMeter = new RadioLevelMeter(this); m_vadMeter->vad = true; m_vadMeter->setObjectName(QStringLiteral("radioVadMeter")); + form->addRow(m_vadMeter); + m_threshold = add(QCoreApplication::translate("ChatGui", "VAD speech threshold:"), "radioVadThreshold", 10, 95, QStringLiteral(" %")); m_threshold->setToolTip(QCoreApplication::translate("ChatGui", "Speech probability threshold: lower is more sensitive. This is not a volume threshold.")); - m_hangover = add(QCoreApplication::translate("ChatGui", - "Silence before ending transmission:"), 50, 2000, QCoreApplication::translate("ChatGui", - " ms")); - m_compressor = new QCheckBox(QCoreApplication::translate("ChatGui", "Transmission and VAD compressor"), m_audioSettings); - form->addRow(m_compressor); - 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 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); - connect(RadioAudioEngine::instance(), &RadioAudioEngine::stateChanged, this, [this]() { - auto* engine = RadioAudioEngine::instance(); - m_audioSettings->setEnabled(engine->settingsReady() && !engine->settingsBusy()); - if (!m_audioSettingsLoaded && engine->settingsReady()) loadAudioSettings(); + m_gainText = label("radioVadGainText"); + m_vadGain = add(QCoreApplication::translate("ChatGui", "VAD max gain:"), "radioVadGain", 1, 60, QCoreApplication::translate("ChatGui", " dB")); + m_hangover = add(QCoreApplication::translate("ChatGui", "Silence before ending transmission:"), "radioHangover", 50, 2000, + QCoreApplication::translate("ChatGui", " ms")); + if (!compact) { + m_compressor = new QCheckBox(QCoreApplication::translate("ChatGui", "Transmission and VAD compressor"), this); + form->addRow(m_compressor); + connect(m_compressor, &QCheckBox::toggled, this, &RadioActivationSettings::updateControls); + m_txGain = add(QCoreApplication::translate("ChatGui", "Transmission max gain:"), "radioTxGain", 1, 60, + QCoreApplication::translate("ChatGui", " dB")); + label("radioActivationHint")->setText(QCoreApplication::translate("ChatGui", + "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_error = label("radioActivationError"); m_error->setStyleSheet(QStringLiteral("color: #e74c3c;")); + auto* engine = RadioAudioEngine::instance(); + connect(engine, &RadioAudioEngine::stateChanged, this, [this, engine]() { + setEnabled(engine->settingsReady() && !engine->settingsBusy()); + if (!m_loaded && engine->settingsReady()) loadSettings(); + else updateControls(); }); - 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(); + connect(engine, &RadioAudioEngine::inputLevelChanged, this, &RadioActivationSettings::updateMeters); + loadSettings(); +} + +QMap RadioActivationSettings::values() const { + QMap result{{"radio_auto_mode", m_mode->checkedId()}, {"radio_level_on_dbfs", m_levelOn->value()}, + {"radio_level_off_dbfs", m_levelOff->value()}, {"radio_vad_threshold", m_threshold->value()}, + {"radio_auto_hangover_ms", m_hangover->value()}, {"radio_vad_compressor_max_gain_db", m_vadGain->value()}}; + if (!m_compact) { + result.insert(QStringLiteral("radio_compressor_enabled"), m_compressor->isChecked() ? 1 : 0); + result.insert(QStringLiteral("radio_compressor_max_gain_db"), m_txGain->value()); + } + return result; +} + +bool RadioActivationSettings::canApply() const { + auto* engine = RadioAudioEngine::instance(); + if (!m_loaded || !engine->settingsReady() || engine->settingsBusy() || m_levelOff->value() >= m_levelOn->value()) return false; + const auto draft = values(); + for (auto it = draft.cbegin(); it != draft.cend(); ++it) if (engine->setting(it.key()) != it.value()) return true; + return false; +} + +bool RadioActivationSettings::apply() { + DEBUG_INFO(DEBUG_CATEGORY_RADIO, "Radio settings: apply compact=%d mode=%d on=%d off=%d vad=%d gain=%d hangover=%d", + m_compact, m_mode->checkedId(), m_levelOn->value(), m_levelOff->value(), m_threshold->value(), + m_vadGain->value(), m_hangover->value()); + return RadioAudioEngine::instance()->applySettings(values()); +} + +void RadioActivationSettings::loadSettings() { + auto* engine = RadioAudioEngine::instance(); + setEnabled(engine->settingsReady() && !engine->settingsBusy()); + if (!engine->settingsReady()) return; + m_loaded = false; + m_mode->button(engine->autoMode())->setChecked(true); + 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_auto_hangover_ms"))); + m_vadGain->setValue(engine->setting(QStringLiteral("radio_vad_compressor_max_gain_db"))); + if (!m_compact) { + m_compressor->setChecked(engine->setting(QStringLiteral("radio_compressor_enabled")) != 0); + m_txGain->setValue(engine->setting(QStringLiteral("radio_compressor_max_gain_db"))); + } + m_loaded = true; + updateControls(); +} + +void RadioActivationSettings::updateControls() { + if (!m_loaded) return; + QString error = m_levelOff->value() >= m_levelOn->value() + ? QCoreApplication::translate("ChatGui", "The stop threshold must be lower than the start threshold") + : RadioAudioEngine::instance()->settingsError(); + m_error->setText(error); m_error->setVisible(!error.isEmpty()); + m_levelMeter->on = m_levelOn->value(); m_levelMeter->off = m_levelOff->value(); + m_vadMeter->on = m_threshold->value(); + updateMeters(); + emit changed(); +} + +void RadioActivationSettings::updateMeters() { + 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); + m_vadMeter->valid = engine->vadValid(); m_vadMeter->level = engine->vadProbability() * 100; m_vadMeter->update(); + if (engine->vadValid()) text = QCoreApplication::translate("ChatGui", "VAD speech probability: %1%").arg(engine->vadProbability() * 100, 0, 'f', 1); + else if (!engine->autoEnabled() || engine->autoMode() != 1) + text = QCoreApplication::translate("ChatGui", "Enable automatic transmission in VAD mode to see speech probability"); + else if (engine->autoBlocked()) text = QCoreApplication::translate("ChatGui", "VAD measurement paused: channel busy"); + else text = QCoreApplication::translate("ChatGui", "VAD speech probability unavailable"); + m_vadText->setText(text); m_vadMeter->setAccessibleName(text); + if (!engine->vadValid()) text = QCoreApplication::translate("ChatGui", "Current VAD compressor gain: —"); + else if (!engine->vadCompressorEnabled()) text = QCoreApplication::translate("ChatGui", "VAD compressor off (0 dB)"); + else text = QCoreApplication::translate("ChatGui", "Current VAD compressor gain: %1 dB") + .arg((engine->vadGain() >= 0 ? QStringLiteral("+") : QString()) + QString::number(engine->vadGain(), 'f', 1)); + m_gainText->setText(text); +} + +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); + title->setStyleSheet("font-weight: bold; font-size: 13px;"); + layout->addWidget(title); + m_activation = new RadioActivationSettings(false, this); + layout->addWidget(m_activation); 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 " @@ -267,7 +359,7 @@ void RadioSettingsPage::updateLabel(int index) { } void RadioSettingsPage::loadFromDb() { - loadAudioSettings(); + m_activation->loadSettings(); if (m_portalStatus) { updatePortalLabels(); return; } finishCapture(false); QStringList values; @@ -286,37 +378,13 @@ 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_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; + if (!m_activation->apply()) return false; if (m_portalStatus) return true; finishCapture(false); PttHotkeyManager::instance()->setHotkeys(hotkeys()); return true; } -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_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() { auto* manager = PttHotkeyManager::instance(); const auto descriptions = manager->portalDescriptions(); @@ -329,25 +397,3 @@ 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 2b6867bd..654f9988 100644 --- a/tools/chatgui/src/radiosettingspage.h +++ b/tools/chatgui/src/radiosettingspage.h @@ -3,6 +3,7 @@ #include "ptt_key_chord.h" #include #include +#include class DbManager; class QPushButton; @@ -11,9 +12,31 @@ class QHideEvent; class QShowEvent; class QSpinBox; class QCheckBox; -class QComboBox; +class QButtonGroup; class RadioLevelMeter; +/* Общий редактор вкладки и мини-блока; метрики не перезаписывают редактируемые настройки. */ +class RadioActivationSettings : public QWidget { + Q_OBJECT +public: + explicit RadioActivationSettings(bool compact, QWidget* parent = nullptr); + void loadSettings(); + bool apply(); + bool canApply() const; +signals: + void changed(); +private: + QMap values() const; + void updateControls(); + void updateMeters(); + bool m_compact, m_loaded = false; + QButtonGroup* m_mode; + QSpinBox *m_threshold, *m_hangover, *m_txGain = nullptr, *m_vadGain, *m_levelOn, *m_levelOff; + QCheckBox* m_compressor = nullptr; + RadioLevelMeter *m_levelMeter, *m_vadMeter; + QLabel *m_levelText, *m_vadText, *m_gainText, *m_error; +}; + // Запись глобальных сочетаний PTT, включая отдельные левые/правые модификаторы. class RadioSettingsPage : public QWidget { Q_OBJECT @@ -31,17 +54,7 @@ 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; + RadioActivationSettings* m_activation; QLabel* m_portalStatus = nullptr; void startCapture(int index); void finishCapture(bool save); diff --git a/tools/chatgui/src/settingsdialog.cpp b/tools/chatgui/src/settingsdialog.cpp index 33e87387..d50d3534 100644 --- a/tools/chatgui/src/settingsdialog.cpp +++ b/tools/chatgui/src/settingsdialog.cpp @@ -34,6 +34,7 @@ extern "C" { #include #include #include +#include SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNode* node, AudioRecorder* recorder, QWidget* parent) @@ -210,8 +211,12 @@ SettingsDialog::SettingsDialog(const QString& configPath, DbManager* db, UtunNod m_audioDevicePage->loadFromDb(); m_pages->addWidget(m_audioDevicePage); - m_radioPage = new RadioSettingsPage(db, m_pages); - m_pages->addWidget(m_radioPage); + auto* radioScroll = new QScrollArea(m_pages); + radioScroll->setWidgetResizable(true); + radioScroll->setFrameShape(QFrame::NoFrame); + m_radioPage = new RadioSettingsPage(db, radioScroll); + radioScroll->setWidget(m_radioPage); + m_pages->addWidget(radioScroll); rightLayout->addWidget(m_pages, 1); // --- Bottom buttons --- diff --git a/tools/chatgui/src/voice_audio_io.cpp b/tools/chatgui/src/voice_audio_io.cpp index f89ddca7..71f77443 100644 --- a/tools/chatgui/src/voice_audio_io.cpp +++ b/tools/chatgui/src/voice_audio_io.cpp @@ -205,10 +205,15 @@ void VoiceAudioIo::captureLoop() { int16_t raw[1920]{}, cleaned[1920]{}, referencePcm[1920]{}; uint8_t muteMask[960]{}; unsigned filled = 0; - int64_t frameTime = 0, lastReport = voice_time_us(); + int64_t frameTime = 0, lastReport = voice_time_us(), lastCapture = lastReport, maxQueueAge = 0; uint64_t expectedSequence = 0, missingReference = 0, reportedReferenceDrops = referenceDrops, captureRoute = 0; int64_t expectedCaptureTime = 0; bool referenceAvailable = false; + const char* referenceReason = "empty_history"; + double referenceGapUs = 0; + double rawEnergy = 0, cleanEnergy = 0, referenceEnergy = 0; + float rawPeak = 0, cleanPeak = 0, referencePeak = 0; + unsigned signalFrames = 0, aecFrames = 0; bool muted = false; auto applyMarker = [&](EventKind kind, bool value) { if (kind == EventKind::Mute) muted = value; @@ -238,6 +243,7 @@ void VoiceAudioIo::captureLoop() { } }; auto alignReference = [&]() { + referenceReason = "empty_history"; referenceGapUs = 0; if (!historyCount) return false; size_t index = 0; for (unsigned i = 0; i < 960; ++i) { @@ -251,7 +257,11 @@ void VoiceAudioIo::captureLoop() { if (measured > interval * 0.95 && measured < interval * 1.05) interval = measured; } double position = (time - block.time) / interval; - if (position < 0 || position >= block.frames) return false; + if (position < 0 || position >= block.frames) { + referenceReason = position < 0 ? "history_too_new" : index + 1 == historyCount ? "render_not_ready" : "render_gap"; + referenceGapUs = position < 0 ? -position * interval : (position - block.frames) * interval; + return false; + } unsigned sample = (unsigned)position; double fraction = position - sample; for (unsigned channel = 0; channel < 2; ++channel) { @@ -260,6 +270,7 @@ void VoiceAudioIo::captureLoop() { referencePcm[2*i+channel] = (int16_t)(first + (second - first) * fraction); } } + referenceReason = "aligned"; return true; }; auto deliver = [&](unsigned frames) { @@ -274,8 +285,42 @@ void VoiceAudioIo::captureLoop() { if (m_kind == Kind::Call && frames == 960) call_audio_feed_prepared_pcm(m_session, cleaned, muteMask); filled = 0; markerCount = 0; }; + // Отчёт работает и при отсутствии PCM, иначе длительный провал захвата невидим. + auto report = [&]() { + int64_t now = voice_time_us(); + if (now - lastReport >= 1000000) { + DEBUG_DEBUG(DEBUG_CATEGORY_AEC, "voice-io: capture health session=%llu span=%.1fms pcm_age=%.1fms " + "queue_age_max=%.1fms queue=%zu pending=%u sequence=%llu", + (unsigned long long)m_session, (now - lastReport) / 1000.0, (now - lastCapture) / 1000.0, + maxQueueAge / 1000.0, m_captureEvents.size(), filled, (unsigned long long)expectedSequence); + if (now - lastCapture >= 1000000) + DEBUG_WARN(DEBUG_CATEGORY_CALL, "voice-io: capture stalled session=%llu no_pcm=%.1fms queue=%zu", + (unsigned long long)m_session, (now - lastCapture) / 1000.0, m_captureEvents.size()); + maxQueueAge = 0; + if (signalFrames) { + auto db = [](double power) { return 10 * std::log10(std::max(power, 1e-12)); }; + DEBUG_DEBUG(DEBUG_CATEGORY_AEC, "voice-io: signal session=%llu frames=%u aec_frames=%u raw=%.1fdBFS clean=%.1fdBFS " + "reference=%.1fdBFS peaks=%.4f/%.4f/%.4f reference_state=%s gap=%.1fus", + (unsigned long long)m_session, signalFrames, aecFrames, db(rawEnergy / (signalFrames * 960 * m_channels)), + db(cleanEnergy / (signalFrames * 960 * m_channels)), db(aecFrames ? referenceEnergy / (aecFrames * 1920) : 0), + rawPeak, cleanPeak, referencePeak, referenceReason, referenceGapUs); + signalFrames = aecFrames = 0; rawEnergy = cleanEnergy = referenceEnergy = 0; + rawPeak = cleanPeak = referencePeak = 0; + } + uint64_t captureDrops = m_captureDropped.exchange(0), refDrops = m_referenceDropped.load(); + uint64_t underruns = m_playbackUnderruns.exchange(0); + if (captureDrops || refDrops != reportedReferenceDrops || missingReference || underruns) + DEBUG_WARN(DEBUG_CATEGORY_AEC, "voice-io: session=%llu capture_drop=%llu reference_drop=%llu missing_ref=%llu output_missing=%llu", + (unsigned long long)m_session, (unsigned long long)captureDrops, (unsigned long long)refDrops, + (unsigned long long)missingReference, (unsigned long long)underruns); + else DEBUG_DEBUG(DEBUG_CATEGORY_AEC, "voice-io: session=%llu reference history=%zu capture_queue=%zu", + (unsigned long long)m_session, historyCount, m_captureEvents.size()); + missingReference = 0; reportedReferenceDrops = refDrops; lastReport = now; + } + }; bool stopping = false; while (m_capturing && !stopping) { + report(); consumeReference(); Event event{}; if (!m_captureEvents.pop(event)) { @@ -289,9 +334,12 @@ void VoiceAudioIo::captureLoop() { else { DEBUG_ERROR(DEBUG_CATEGORY_CALL, "voice-io: too many controls within one capture frame"); stopping = true; } continue; } - if (voice_time_us() - event.enqueued > 100000) { + int64_t received = voice_time_us(); + maxQueueAge = std::max(maxQueueAge, received - event.enqueued); + if (received - event.enqueued > 100000) { m_captureDropped.fetch_add(event.frames); continue; } + lastCapture = received; bool clockJump = expectedCaptureTime && std::abs(event.time - expectedCaptureTime) > 20000; if (event.sequence != expectedSequence || (captureRoute && event.route != captureRoute) || clockJump) { DEBUG_WARN(DEBUG_CATEGORY_CALL, "voice-io: capture discontinuity expected=%llu actual=%llu pending=%u route=%016llx->%016llx", @@ -315,28 +363,34 @@ void VoiceAudioIo::captureLoop() { memcpy(cleaned, raw, 960 * m_channels * sizeof(int16_t)); consumeReference(); bool available = aec && alignReference(); + if (!aec) referenceReason = "disabled"; if (available) { speex_aec_feed_playback(aec, referencePcm, 1920); speex_aec_process_capture(aec, raw, 960 * m_channels, cleaned); } else if (aec) { ++missingReference; - if (referenceAvailable) speex_aec_reset(aec); + if (referenceAvailable) { + DEBUG_DEBUG(DEBUG_CATEGORY_AEC, "voice-io: AEC bypass/reset session=%llu reason=%s gap=%.1fus history=%zu capture_time=%lld", + (unsigned long long)m_session, referenceReason, referenceGapUs, historyCount, (long long)frameTime); + speex_aec_reset(aec); + } + } + if (debug_should_output(DEBUG_LEVEL_DEBUG, DEBUG_CATEGORY_AEC)) { + ++signalFrames; if (available) ++aecFrames; + for (int i = 0; i < 960 * m_channels; ++i) { + float in = raw[i] / 32768.0f, out = cleaned[i] / 32768.0f; + rawEnergy += in * in; cleanEnergy += out * out; + rawPeak = std::max(rawPeak, std::abs(in)); cleanPeak = std::max(cleanPeak, std::abs(out)); + } + if (available) for (int i = 0; i < 1920; ++i) { + float ref = referencePcm[i] / 32768.0f; + referenceEnergy += ref * ref; referencePeak = std::max(referencePeak, std::abs(ref)); + } } referenceAvailable = available; deliver(960); } - int64_t now = voice_time_us(); - if (now - lastReport >= 1000000) { - uint64_t captureDrops = m_captureDropped.exchange(0), refDrops = m_referenceDropped.load(); - uint64_t underruns = m_playbackUnderruns.exchange(0); - if (captureDrops || refDrops != reportedReferenceDrops || missingReference || underruns) - DEBUG_WARN(DEBUG_CATEGORY_AEC, "voice-io: session=%llu capture_drop=%llu reference_drop=%llu missing_ref=%llu output_missing=%llu", - (unsigned long long)m_session, (unsigned long long)captureDrops, (unsigned long long)refDrops, - (unsigned long long)missingReference, (unsigned long long)underruns); - else DEBUG_DEBUG(DEBUG_CATEGORY_AEC, "voice-io: session=%llu aligned history=%zu capture_queue=%zu", - (unsigned long long)m_session, historyCount, m_captureEvents.size()); - missingReference = 0; reportedReferenceDrops = refDrops; lastReport = now; - } + } if (filled) { DEBUG_WARN(DEBUG_CATEGORY_CALL, "voice-io: capture tail=%u frames bypasses AEC; call tail discarded, radio tail preserved", filled); diff --git a/tools/chatgui/tests/test_radio_panel.cpp b/tools/chatgui/tests/test_radio_panel.cpp index 61d16b3e..85a7c302 100644 --- a/tools/chatgui/tests/test_radio_panel.cpp +++ b/tools/chatgui/tests/test_radio_panel.cpp @@ -1,7 +1,6 @@ /* Настоящие 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" @@ -124,12 +123,42 @@ int main(int argc, char** argv) { REQUIRE(engine->settingsBusy() && !vad->isEnabled()); await([&]() { return !engine->settingsBusy() && engine->autoAvailable(); }); REQUIRE(vad->isChecked() && outputs == oldOutputs && inputs == oldInputs + 1); + // Удержание открывает редактор без переключения автоматики и перезапуска захвата. + oldInputs = inputs; + QTest::mousePress(vad, Qt::LeftButton); pump(550); + auto* popup = panel.findChild("radioActivationPopup"); + REQUIRE(popup && popup->isVisible() && engine->autoEnabled() && inputs == oldInputs); + QTest::mouseRelease(vad, Qt::LeftButton); + REQUIRE(popup->isVisible() && engine->autoEnabled() && !engine->settingsBusy()); + auto* quickVad = popup->findChild("radioModeVad"); + auto* quickLevel = popup->findChild("radioModeLevel"); + auto* quickThreshold = popup->findChild("radioVadThreshold"); + auto* quickOn = popup->findChild("radioLevelOn"); + auto* quickApply = popup->findChild("radioActivationApply"); + auto* quickProbability = popup->findChild("radioVadText"); + auto* quickGain = popup->findChild("radioVadGainText"); + REQUIRE(quickVad && quickLevel && quickThreshold && quickOn && quickApply && quickProbability && quickGain); + quickLevel->click(); REQUIRE(quickOn->isEnabled() && quickThreshold->isEnabled() && engine->autoMode() == 1); + quickVad->click(); quickThreshold->setValue(60); REQUIRE(quickApply->isEnabled()); + quickApply->click(); await([&] { return !engine->settingsBusy() && engine->vadValid(); }); + REQUIRE(engine->setting("radio_vad_threshold") == 60 && engine->autoEnabled() && !quickApply->isEnabled()); + probability = 0.25f; + await([&] { return quickProbability->text().contains("25.0%") && quickGain->text().contains("dB"); }); + REQUIRE(engine->vadCompressorEnabled() && engine->vadGain() > 0); + REQUIRE(popup->grab().save("/tmp/desktop-radio-activation-popup.png")); + quickThreshold->setValue(70); + QTest::keyClick(popup, Qt::Key_Escape); REQUIRE(!popup->isVisible()); + QMetaObject::invokeMethod(vad, "customContextMenuRequested", Q_ARG(QPoint, QPoint())); + REQUIRE(popup->isVisible() && quickThreshold->value() == 60); + popup->hide(); probability = 0; + qInfo("radio panel: hold/context popup, no toggle, direct modes, both thresholds, apply/cancel, live probability/gain PASS"); pump(750); // Полное кольцо prefetch до начала речи. int previousPackets = packets; probability = 0.9f; await([&]() { return engine->isTransmitting() && begins == 1 && packets >= previousPackets + 30; }); REQUIRE(!engine->isTalking() && ptt->property("transmitting").toBool() && begins == 1); REQUIRE(selfSpeaking()); // VAD включает собственный динамик без нажатой PTT. + REQUIRE(panel.grab().save("/tmp/desktop-radio-vad-meter.png")); REQUIRE(packets >= previousPackets + 30); int beforeManual = begins; QTest::mousePress(ptt, Qt::LeftButton); @@ -210,17 +239,18 @@ int main(int argc, char** argv) { RadioSettingsPage settings(nullptr); settings.resize(620, 780); settings.show(); - auto* mode = settings.findChild("radioAutoMode"); + auto* mode = settings.findChild("radioModeLevel"); 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); + mode->click(); 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->autoMode() == 2 && !vad->icon().isNull() && vad->text().isEmpty() && radio_audio_auto_mode() == RADIO_AUTO_LEVEL); + REQUIRE(settings.findChild("radioVadThreshold")->isEnabled() && on->isEnabled()); REQUIRE(!engine->isTransmitting() && status->text() == "Waiting for signal"); REQUIRE(levelText->text().contains("dBFS") && engine->inputLevel() == -120); // Silero недоступен; уровень работает и сохраняется вместе с парой порогов. diff --git a/tools/chatgui/tests/test_voice_audio_io.cpp b/tools/chatgui/tests/test_voice_audio_io.cpp index f7af6719..7ee4ef0d 100644 --- a/tools/chatgui/tests/test_voice_audio_io.cpp +++ b/tools/chatgui/tests/test_voice_audio_io.cpp @@ -14,6 +14,7 @@ extern "C" { #include #include #include +#include #define REQUIRE(x) do { if (!(x)) { fprintf(stderr, "voice-io:%d %s\n", __LINE__, #x); abort(); } } while (0) static std::mutex mutex; static std::vector capture, transmitted; @@ -21,6 +22,10 @@ static std::vector masks; static bool talking; static std::atomic pulls{0}, blocks{0}; static std::atomic stall{false}, stalled{false}; +static std::atomic idleReported{false}; +static void capture_diagnostic(int, const char*, const char* message) { + if (std::strstr(message, "voice-io: capture stalled")) idleReported = true; +} static std::atomic output_position{0}, output_delay{0}; extern "C" void radio_audio_playback_progress(uint64_t, uint64_t position, uint64_t delay) { output_delay = delay; output_position = position; @@ -58,6 +63,15 @@ int main() { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); VoiceAudioIo voice; std::array pcm{}; pcm.fill(1234); + // Отсутствие PCM должно попадать в лог без необходимости ждать следующего кадра. + debug_set_log_hook(capture_diagnostic); + REQUIRE(voice.start(VoiceAudioIo::Kind::Radio, 7) && voice.startCapture(1, false)); + await([] { return idleReported.load(); }); + voice.capture(pcm.data(), 960, 0); + await([] { return blocks.load() == 1; }); + voice.stop(); debug_set_log_hook(nullptr); + REQUIRE(capture.size() == 960 && capture.front() == 1234 && capture.back() == 1234); + reset(); pulls = 0; output_position = 0; // Выдача RX worker не подтверждает вывод; частичный callback сохраняет позицию и backend delay. REQUIRE(voice.start(VoiceAudioIo::Kind::Radio, 7)); await([] { return pulls.load() >= 2; }); diff --git a/tools/chatgui/tests/test_voice_tx.cpp b/tools/chatgui/tests/test_voice_tx.cpp index d09c2840..74eda223 100644 --- a/tools/chatgui/tests/test_voice_tx.cpp +++ b/tools/chatgui/tests/test_voice_tx.cpp @@ -26,6 +26,7 @@ static std::vector events; static std::vector> packets; static float probability; static int vad_windows; +static int vad_result; static int radio_compressor_enabled; static int auto_hangover_ms; static bool observe_vad; @@ -40,7 +41,7 @@ extern "C" void __wrap_silero_vad_reset(silero_vad_t*) {} extern "C" int __wrap_silero_vad_process(silero_vad_t*, const float* samples, float* result) { if (stall_vad) { vad_stalled = true; while (stall_vad) std::this_thread::sleep_for(std::chrono::milliseconds(1)); } if (observe_vad) vad_input.insert(vad_input.end(), samples, samples + SILERO_VAD_WINDOW_SAMPLES); - ++vad_windows; *result = probability; return 0; + ++vad_windows; *result = probability; return vad_result; } extern "C" int __wrap_radio_talk_begin(UTUN_INSTANCE*, uint64_t) { events.push_back('B'); return 0; } extern "C" int __wrap_radio_talk_end(UTUN_INSTANCE*, uint64_t) { events.push_back('F'); return 0; } @@ -97,6 +98,8 @@ static void check_vad_pcm(const int16_t* pcm, int frames, int channels, int max_ expected.resize(expected.size() / 512 * 512); REQUIRE(vad_input == expected); REQUIRE(audio_compressor_gain_db(ac) <= max_gain + 0.001f); + auto status = radio_audio_get_input_status(); + REQUIRE(status.vad_compressor_enabled && std::abs(status.vad_gain_db - audio_compressor_gain_db(ac)) < 0.0001f); audio_compressor_destroy(ac); } @@ -275,6 +278,7 @@ static void test_vad_diagnostics(UTUN_INSTANCE* instance) { REQUIRE(!radio_audio_transmitting()); uasync_poll(instance->ua, 0); REQUIRE(!packets.empty()); radio_audio_on_frame(instance, 7, 0x1234, 1, 0, 0, packets.front().data(), packets.front().size(), nullptr); + REQUIRE(radio_audio_get_input_status().blocked && !radio_audio_get_input_status().vad_valid); probability = 0.9f; for (int i = 0; i < 2; ++i) REQUIRE(radio_audio_feed_pcm(7, pcm.data(), pcm.size()) == 0); REQUIRE(!radio_audio_transmitting()); @@ -289,6 +293,16 @@ static void test_vad_diagnostics(UTUN_INSTANCE* instance) { for (const char* text : {"VAD config", "reason=confirming", "reason=confirm_reset", "reason=confirmed", "reason=silence_timeout", "reason=rx_busy", "rx_node=0000000000001234", "manual=1", "BEGIN dispatched", "capture discontinuity", "VAD capture stop", "model_rms="}) REQUIRE(contains(text)); + // Клиппинг должен быть явно виден в диагностике ветки VAD, отдельно от компрессора передачи. + radio_compressor_enabled = 1; probability = 0; test_time = 100000; + REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, 1, RADIO_AUTO_VAD) == 0); + pcm.fill(0); + for (int i = 0; i < 2; ++i) REQUIRE(radio_audio_feed_pcm(7, pcm.data(), pcm.size()) == 0); + REQUIRE(contains("dc=0.000000 zeros=512/512")); + test_time += 10001; pcm.fill(30000); + for (int i = 0; i < 2; ++i) REQUIRE(radio_audio_feed_pcm(7, pcm.data(), pcm.size()) == 0); + REQUIRE(contains("VAD compressor clipping") && contains("input_peak=")); + radio_audio_stop(); uasync_poll(instance->ua, 0); test_time = 0; radio_compressor_enabled = 0; debug_set_log_hook(nullptr); debug_set_category_level(DEBUG_CATEGORY_VAD, DEBUG_LEVEL_NONE); debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_NONE); @@ -432,6 +446,36 @@ static void test_level_detector(UTUN_INSTANCE* instance, int channels, int compr channels, compressor); } +/* UI получает именно результат Silero; устаревшее значение не выглядит свежей тишиной. */ +static void test_vad_input_status(UTUN_INSTANCE* instance) { + radio_compressor_enabled = 0; test_time = 100000; + REQUIRE(radio_audio_start(7, 0) == 0 && radio_audio_capture_start(7, 1, RADIO_AUTO_VAD) == 0); + REQUIRE(!radio_audio_get_input_status().vad_valid); + std::array pcm{}; + auto feed = [&] { REQUIRE(radio_audio_feed_pcm(7, pcm.data(), pcm.size()) == 0); }; + probability = 0.25f; feed(); + auto status = radio_audio_get_input_status(); + REQUIRE(status.valid && status.vad_valid && status.vad_probability == probability); + REQUIRE(!status.vad_compressor_enabled && status.vad_gain_db == 0); + test_time += 2000; REQUIRE(radio_audio_get_input_status().vad_valid); + test_time += 1; REQUIRE(!radio_audio_get_input_status().vad_valid); + probability = 0; feed(); + status = radio_audio_get_input_status(); REQUIRE(status.vad_valid && status.vad_probability == 0); + radio_audio_capture_discontinuity(7); REQUIRE(!radio_audio_get_input_status().vad_valid); + probability = 0.4f; feed(); REQUIRE(radio_audio_get_input_status().vad_valid); + vad_result = -1; feed(); REQUIRE(!radio_audio_get_input_status().vad_valid); + vad_result = 0; feed(); + status = radio_audio_get_input_status(); REQUIRE(status.vad_valid && status.vad_probability == probability); + radio_audio_capture_stop(); REQUIRE(!radio_audio_get_input_status().vad_valid); + for (int mode : {RADIO_AUTO_OFF, RADIO_AUTO_LEVEL, RADIO_AUTO_VAD}) { + REQUIRE(radio_audio_capture_start(7, 1, mode) == 0 && !radio_audio_get_input_status().vad_valid); + feed(); REQUIRE(!!radio_audio_get_input_status().vad_valid == (mode == RADIO_AUTO_VAD)); + radio_audio_capture_stop(); + } + radio_audio_stop(); uasync_poll(instance->ua, 0); test_time = 0; + puts("voice TX: VAD probability snapshot, zero, freshness, inference failure, discontinuity, stop and mode switch PASS"); +} + int main() { debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); UTUN_INSTANCE instance{}; instance.ua = uasync_create(); REQUIRE(instance.ua); @@ -513,6 +557,7 @@ int main() { } test_prefetch_deferred(&instance); test_vad_diagnostics(&instance); + test_vad_input_status(&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); diff --git a/tools/chatgui/translations/chatgui_en.ts b/tools/chatgui/translations/chatgui_en.ts index ca97ab25..7ddbe6f6 100644 --- a/tools/chatgui/translations/chatgui_en.ts +++ b/tools/chatgui/translations/chatgui_en.ts @@ -2215,5 +2215,37 @@ Directories: %5 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. + + VAD speech probability: %1% + VAD speech probability: %1% + + + Enable automatic transmission in VAD mode to see speech probability + Enable automatic transmission in VAD mode to see speech probability + + + VAD measurement paused: channel busy + VAD measurement paused: channel busy + + + VAD speech probability unavailable + VAD speech probability unavailable + + + Current VAD compressor gain: — + Current VAD compressor gain: — + + + VAD compressor off (0 dB) + VAD compressor off (0 dB) + + + Current VAD compressor gain: %1 dB + Current VAD compressor gain: %1 dB + + + Hold or right-click to adjust activation mode and thresholds + Hold or right-click to adjust activation mode and thresholds + diff --git a/tools/chatgui/translations/chatgui_ru.ts b/tools/chatgui/translations/chatgui_ru.ts index 2c57baf3..4e944820 100644 --- a/tools/chatgui/translations/chatgui_ru.ts +++ b/tools/chatgui/translations/chatgui_ru.ts @@ -2221,5 +2221,37 @@ Directories: %5 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 отдельный предел усиления. + + VAD speech probability: %1% + Вероятность речи VAD: %1% + + + Enable automatic transmission in VAD mode to see speech probability + Включите автопередачу в режиме VAD для оценки вероятности речи + + + VAD measurement paused: channel busy + Оценка VAD приостановлена: эфир занят + + + VAD speech probability unavailable + Оценка вероятности речи VAD недоступна + + + Current VAD compressor gain: — + Текущее усиление компрессора VAD: — + + + VAD compressor off (0 dB) + Компрессор VAD выключен (0 dB) + + + Current VAD compressor gain: %1 dB + Текущее усиление компрессора VAD: %1 dB + + + Hold or right-click to adjust activation mode and thresholds + Удерживайте или нажмите правой кнопкой для настройки режима и порогов +