radio: add activation controls, live VAD meters and signal diagnostics
This commit is contained in:
1 parent
fddbba217d
commit
7f3247bfcc
23 files changed
+818
-182
No files matched your search
@@ -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; });
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user