Browse Source

radio: add activation controls, live VAD meters and signal diagnostics

master
evgeny 4 days ago
parent
commit
7f3247bfcc
  1. 45
      doc/tasks.md
  2. 44
      src/radio/radio_audio.c
  3. 4
      src/radio/radio_audio.h
  4. 134
      tests/test_silero_vad.c
  5. 3
      tools/chatgui-android/app/src/main/java/com/utun/chat/data/NativeLib.kt
  6. 49
      tools/chatgui-android/app/src/main/java/com/utun/chat/ui/screens/SettingsScreen.kt
  7. 7
      tools/chatgui-android/app/src/main/res/values-ru/strings.xml
  8. 7
      tools/chatgui-android/app/src/main/res/values/strings.xml
  9. 7
      tools/chatgui-android/jni_bridge/android_jni_bridge.c
  10. 6
      tools/chatgui/CMakeLists.txt
  11. 6
      tools/chatgui/src/radio_audio_engine.cpp
  12. 6
      tools/chatgui/src/radio_audio_engine.h
  13. 107
      tools/chatgui/src/radiopanel.cpp
  14. 8
      tools/chatgui/src/radiopanel.h
  15. 272
      tools/chatgui/src/radiosettingspage.cpp
  16. 37
      tools/chatgui/src/radiosettingspage.h
  17. 9
      tools/chatgui/src/settingsdialog.cpp
  18. 86
      tools/chatgui/src/voice_audio_io.cpp
  19. 38
      tools/chatgui/tests/test_radio_panel.cpp
  20. 14
      tools/chatgui/tests/test_voice_audio_io.cpp
  21. 47
      tools/chatgui/tests/test_voice_tx.cpp
  22. 32
      tools/chatgui/translations/chatgui_en.ts
  23. 32
      tools/chatgui/translations/chatgui_ru.ts

45
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мс.
Вкладка рации временно держит микрофон для измерения при выключенной автоматике; уход со вкладки/пауза освобождает этот захват.

44
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;
}

4
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);

134
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);

3
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()

49
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<Float?>(null) }
var vadProbability by remember { mutableStateOf<Float?>(null) }
var vadGain by remember { mutableStateOf<Float?>(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)

7
tools/chatgui-android/app/src/main/res/values-ru/strings.xml

@ -407,6 +407,13 @@
<string name="radio_level_allow_mic">"Разрешить доступ к микрофону"</string>
<string name="radio_level_unavailable">"Уровень микрофона недоступен"</string>
<string name="radio_level_current">"Текущий уровень: %1$.1f dBFS"</string>
<string name="radio_vad_current">"Вероятность речи VAD: %1$.1f%%"</string>
<string name="radio_vad_enable">"Включите автопередачу в режиме VAD для оценки вероятности речи"</string>
<string name="radio_vad_busy">"Оценка VAD приостановлена: эфир занят"</string>
<string name="radio_vad_unavailable">"Оценка вероятности речи VAD недоступна"</string>
<string name="radio_vad_gain_current">"Текущее усиление компрессора VAD: %1$+.1f dB"</string>
<string name="radio_vad_gain_unavailable">"Текущее усиление компрессора VAD: —"</string>
<string name="radio_vad_compressor_off">"Компрессор VAD выключен (0 dB)"</string>
<string name="radio_level_on">"Порог включения (оранжевый):"</string>
<string name="radio_level_off">"Порог отключения (синий):"</string>
<string name="radio_level_invalid">"Порог отключения должен быть ниже порога включения"</string>

7
tools/chatgui-android/app/src/main/res/values/strings.xml

@ -406,6 +406,13 @@
<string name="radio_level_allow_mic">"Allow microphone access"</string>
<string name="radio_level_unavailable">"Microphone level unavailable"</string>
<string name="radio_level_current">"Current level: %1$.1f dBFS"</string>
<string name="radio_vad_current">"VAD speech probability: %1$.1f%%"</string>
<string name="radio_vad_enable">"Enable automatic transmission in VAD mode to see speech probability"</string>
<string name="radio_vad_busy">"VAD measurement paused: channel busy"</string>
<string name="radio_vad_unavailable">"VAD speech probability unavailable"</string>
<string name="radio_vad_gain_current">"Current VAD compressor gain: %1$+.1f dB"</string>
<string name="radio_vad_gain_unavailable">"Current VAD compressor gain: —"</string>
<string name="radio_vad_compressor_off">"VAD compressor off (0 dB)"</string>
<string name="radio_level_on">"Start threshold (orange):"</string>
<string name="radio_level_off">"Stop threshold (blue):"</string>
<string name="radio_level_invalid">"The stop threshold must be lower than the start threshold"</string>

7
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;
}

6
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)

6
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;

6
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<bool> m_active{false};
std::atomic<bool> m_talking{false};

107
tools/chatgui/src/radiopanel.cpp

@ -1,6 +1,7 @@
#include <QCoreApplication>
#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 <QSignalBlocker>
#include <QStyle>
#include <QMouseEvent>
#include <QWidgetAction>
#include <QScrollArea>
#include <QVBoxLayout>
#include <QPainter>
#include <QScreen>
#include <QGuiApplication>
#include <cstring>
#include <algorithm>
/* Полоса показывает текущую вероятность речи, не установленный порог срабатывания. */
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<QMouseEvent*>(event)->button() == Qt::LeftButton) {
m_autoLongPressed = false; m_autoHold.start();
} else if (event->type() == QEvent::MouseButtonRelease && static_cast<QMouseEvent*>(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<QMouseEvent*>(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();
}
/* Список перечитывается при открытии меню: подключённые устройства видны без перезапуска. */

8
tools/chatgui/src/radiopanel.h

@ -1,10 +1,13 @@
#pragma once
#include <QWidget>
#include <QTimer>
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;
};

272
tools/chatgui/src/radiosettingspage.cpp

@ -15,7 +15,7 @@
#include <QFormLayout>
#include <QSpinBox>
#include <QCheckBox>
#include <QComboBox>
#include <QButtonGroup>
#include <QPainter>
#include <algorithm>
#include <QKeyEvent>
@ -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<int>::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<int>::of(&QComboBox::currentIndexChanged), this, [this]() { updateAudioControls(); });
for (auto* spin : {m_levelOn, m_levelOff})
connect(spin, QOverload<int>::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<QString, int> RadioActivationSettings::values() const {
QMap<QString, int> 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);
}

37
tools/chatgui/src/radiosettingspage.h

@ -3,6 +3,7 @@
#include "ptt_key_chord.h"
#include <QWidget>
#include <QVector>
#include <QMap>
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<QString, int> 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);

9
tools/chatgui/src/settingsdialog.cpp

@ -34,6 +34,7 @@ extern "C" {
#include <QTextStream>
#include <QMessageBox>
#include <QFileDialog>
#include <QScrollArea>
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 ---

86
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);

38
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 <QComboBox>
#include <QSpinBox>
#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<QMenu*>("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<QPushButton*>("radioModeVad");
auto* quickLevel = popup->findChild<QPushButton*>("radioModeLevel");
auto* quickThreshold = popup->findChild<QSpinBox*>("radioVadThreshold");
auto* quickOn = popup->findChild<QSpinBox*>("radioLevelOn");
auto* quickApply = popup->findChild<QPushButton*>("radioActivationApply");
auto* quickProbability = popup->findChild<QLabel*>("radioVadText");
auto* quickGain = popup->findChild<QLabel*>("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<QComboBox*>("radioAutoMode");
auto* mode = settings.findChild<QPushButton*>("radioModeLevel");
auto* on = settings.findChild<QSpinBox*>("radioLevelOn");
auto* off = settings.findChild<QSpinBox*>("radioLevelOff");
auto* levelText = settings.findChild<QLabel*>("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<QSpinBox*>("radioVadThreshold")->isEnabled() && on->isEnabled());
REQUIRE(!engine->isTransmitting() && status->text() == "Waiting for signal");
REQUIRE(levelText->text().contains("dBFS") && engine->inputLevel() == -120);
// Silero недоступен; уровень работает и сохраняется вместе с парой порогов.

14
tools/chatgui/tests/test_voice_audio_io.cpp

@ -14,6 +14,7 @@ extern "C" {
#include <cstdio>
#include <cstdlib>
#include <algorithm>
#include <cstring>
#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<int16_t> capture, transmitted;
@ -21,6 +22,10 @@ static std::vector<uint8_t> masks;
static bool talking;
static std::atomic<int> pulls{0}, blocks{0};
static std::atomic<bool> stall{false}, stalled{false};
static std::atomic<bool> 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<uint64_t> 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<int16_t, 960> 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; });

47
tools/chatgui/tests/test_voice_tx.cpp

@ -26,6 +26,7 @@ static std::vector<char> events;
static std::vector<std::vector<uint8_t>> 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<int16_t, 1536> 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);

32
tools/chatgui/translations/chatgui_en.ts

@ -2215,5 +2215,37 @@ Directories: %5</translation>
<source>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.</source>
<translation>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.</translation>
</message>
<message>
<source>VAD speech probability: %1%</source>
<translation>VAD speech probability: %1%</translation>
</message>
<message>
<source>Enable automatic transmission in VAD mode to see speech probability</source>
<translation>Enable automatic transmission in VAD mode to see speech probability</translation>
</message>
<message>
<source>VAD measurement paused: channel busy</source>
<translation>VAD measurement paused: channel busy</translation>
</message>
<message>
<source>VAD speech probability unavailable</source>
<translation>VAD speech probability unavailable</translation>
</message>
<message>
<source>Current VAD compressor gain: —</source>
<translation>Current VAD compressor gain: —</translation>
</message>
<message>
<source>VAD compressor off (0 dB)</source>
<translation>VAD compressor off (0 dB)</translation>
</message>
<message>
<source>Current VAD compressor gain: %1 dB</source>
<translation>Current VAD compressor gain: %1 dB</translation>
</message>
<message>
<source>Hold or right-click to adjust activation mode and thresholds</source>
<translation>Hold or right-click to adjust activation mode and thresholds</translation>
</message>
</context>
</TS>

32
tools/chatgui/translations/chatgui_ru.ts

@ -2221,5 +2221,37 @@ Directories: %5</source>
<source>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.</source>
<translation>Включайте автоматическую передачу кнопкой на панели рации. В обоих режимах передаётся до 0,6 с звука перед срабатыванием. Уровень измеряется после эхоподавления, до компрессора. Для включения нужны 40 мс на пороге включения или выше; передача заканчивается после заданной паузы ниже порога отключения. У компрессора VAD отдельный предел усиления.</translation>
</message>
<message>
<source>VAD speech probability: %1%</source>
<translation>Вероятность речи VAD: %1%</translation>
</message>
<message>
<source>Enable automatic transmission in VAD mode to see speech probability</source>
<translation>Включите автопередачу в режиме VAD для оценки вероятности речи</translation>
</message>
<message>
<source>VAD measurement paused: channel busy</source>
<translation>Оценка VAD приостановлена: эфир занят</translation>
</message>
<message>
<source>VAD speech probability unavailable</source>
<translation>Оценка вероятности речи VAD недоступна</translation>
</message>
<message>
<source>Current VAD compressor gain: —</source>
<translation>Текущее усиление компрессора VAD: —</translation>
</message>
<message>
<source>VAD compressor off (0 dB)</source>
<translation>Компрессор VAD выключен (0 dB)</translation>
</message>
<message>
<source>Current VAD compressor gain: %1 dB</source>
<translation>Текущее усиление компрессора VAD: %1 dB</translation>
</message>
<message>
<source>Hold or right-click to adjust activation mode and thresholds</source>
<translation>Удерживайте или нажмите правой кнопкой для настройки режима и порогов</translation>
</message>
</context>
</TS>

Loading…
Cancel
Save