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

This commit is contained in:
evgeny committed 2026-10-09 16:11:11 +02:00
1 parent fddbba217d
commit 7f3247bfcc
23 files changed
+818 -182

No files matched your search

+34 -4
View File
@@ -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,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; });
+46 -1
View File
@@ -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);