/* Настоящие Qt-кнопки, null audio backend и общий radio_audio; подменяем только вероятность VAD и сетевой выход. */ #include "../src/radiopanel.h" #include "../src/radio_audio_engine.h" #include "../src/sound_manager.h" #include #include #include #include #include #include #include #include #include #include extern "C" { #include "utun_instance.h" #include "radio/radio_audio.h" #include "radio/radio.h" #include "chat/chat_setting.h" #include "config_parser.h" #include "u_async.h" #include "debug_config.h" #include "silero_vad.h" #include "mem.h" } #define REQUIRE(condition) do { if (!(condition)) qFatal("radio panel line %d: %s", __LINE__, #condition); } while (0) static UTUN_INSTANCE instance{}; static std::atomic probability{0}; static bool detectorAvailable = true; static bool failCapture; static int begins, ends, packets, inputs, outputs; extern "C" UTUN_INSTANCE* gui_bridge_get_inst() { return &instance; } extern "C" int gui_bridge_is_uasync_ready() { return instance.ua != nullptr; } extern "C" void gui_bridge_post_uasync_fn(void (*fn)(void*), void* arg) { uasync_post(instance.ua, fn, arg); } extern "C" int __wrap_radio_talk_begin(UTUN_INSTANCE*, uint64_t) { ++begins; return 0; } extern "C" int __wrap_radio_talk_end(UTUN_INSTANCE*, uint64_t) { ++ends; return 0; } extern "C" int __wrap_radio_talk_send(UTUN_INSTANCE*, uint64_t, const uint8_t*, int) { ++packets; return 0; } extern "C" silero_vad_t* __wrap_silero_vad_create_default() { return detectorAvailable ? reinterpret_cast(1) : nullptr; } extern "C" void __wrap_silero_vad_destroy(silero_vad_t*) {} extern "C" void __wrap_silero_vad_reset(silero_vad_t*) {} extern "C" int __wrap_silero_vad_process(silero_vad_t*, const float*, float* result) { *result = probability.load(); return 0; } extern "C" ma_result __real_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context*); extern "C" ma_result __real_ma_device_start(ma_device*); extern "C" ma_result __wrap_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context* context) { ma_backend backend = ma_backend_null; return __real_ma_context_init(&backend, 1, nullptr, context); } extern "C" ma_result __wrap_ma_device_start(ma_device* device) { if (device->type == ma_device_type_capture) ++inputs; else ++outputs; if (failCapture && device->type == ma_device_type_capture) return MA_ERROR; return __real_ma_device_start(device); } static void pump(int milliseconds) { QElapsedTimer timer; timer.start(); while (timer.elapsed() < milliseconds) { uasync_poll(instance.ua, 10); QApplication::processEvents(); } } template static void await(Predicate ready) { QElapsedTimer timer; timer.start(); while (!ready() && timer.elapsed() < 2500) pump(10); REQUIRE(ready()); } int main(int argc, char** argv) { QApplication app(argc, argv); debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_RADIO, DEBUG_LEVEL_INFO); instance.ua = uasync_create(); REQUIRE(instance.ua && radio_audio_init(&instance) == 0); REQUIRE(chat_setting_set(&instance, "radio_vad_enabled", "0") == 0); auto* manager = SoundManager::instance(); REQUIRE(manager->init()); QTemporaryDir directory; REQUIRE(directory.isValid()); QString configPath = directory.filePath("radio.cfg"); QFile config(configPath); REQUIRE(config.open(QIODevice::WriteOnly)); REQUIRE(config.write("[chatserver]\nradio_vad_enabled=0\nradio_opus_bitrate=32\n") > 0); config.close(); auto* engine = RadioAudioEngine::instance(); engine->loadSettings(configPath); await([&]() { return engine->settingsReady() && !engine->settingsBusy(); }); REQUIRE(!engine->vadEnabled() && engine->setting("radio_vad_compressor_max_gain_db") == 15); RadioPanel panel; panel.resize(580, 50); panel.show(); auto* vad = panel.findChild("radioVadButton"); auto* ptt = panel.findChild("radioPttButton"); auto* status = panel.findChild("radioStatus"); REQUIRE(vad && ptt && status && !vad->isEnabled()); REQUIRE(engine->start(7) == 0); await([&]() { return engine->captureAvailable() && ptt->isEnabled(); }); REQUIRE(!vad->isChecked() && vad->isEnabled()); REQUIRE(vad->geometry().right() < ptt->geometry().left()); int oldOutputs = outputs, oldInputs = inputs; vad->click(); REQUIRE(engine->settingsBusy() && !vad->isEnabled()); await([&]() { return !engine->settingsBusy() && engine->vadAvailable(); }); REQUIRE(vad->isChecked() && outputs == oldOutputs && inputs == oldInputs + 1); 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(packets >= previousPackets + 30); int beforeManual = begins; QTest::mousePress(ptt, Qt::LeftButton); REQUIRE(engine->isTalking()); engine->setHotkeyTalking(true); QTest::mouseRelease(ptt, Qt::LeftButton); REQUIRE(engine->isTalking()); engine->setHotkeyTalking(false); pump(100); REQUIRE(!engine->isTalking() && engine->isTransmitting() && begins == beforeManual); probability = 0; await([&]() { return !engine->isTransmitting() && ends == 1; }); REQUIRE(!ptt->property("transmitting").toBool() && ends == 1); qInfo("radio panel: automatic TX, 600ms prefetch, actual UI state and manual/hotkey handover PASS"); engine->setHotkeyTalking(true); await([&]() { return engine->isTransmitting(); }); oldOutputs = outputs; oldInputs = inputs; vad->click(); await([&]() { return !engine->settingsBusy() && !engine->vadEnabled() && engine->isTransmitting(); }); REQUIRE(engine->isTalking() && !engine->vadAvailable() && outputs == oldOutputs && inputs == oldInputs + 1); engine->setHotkeyTalking(false); await([&]() { return !engine->isTransmitting(); }); REQUIRE(engine->applySettings({{"radio_vad_threshold", 65}, {"radio_vad_hangover_ms", 400}, {"radio_vad_compressor_max_gain_db", 30}, {"radio_compressor_max_gain_db", 6}})); await([&]() { return !engine->settingsBusy(); }); REQUIRE(engine->setting("radio_vad_compressor_max_gain_db") == 30 && engine->setting("radio_compressor_max_gain_db") == 6); REQUIRE(!engine->applySettings({{"radio_vad_compressor_max_gain_db", 61}})); REQUIRE(engine->setting("radio_vad_compressor_max_gain_db") == 30); detectorAvailable = false; vad->click(); await([&]() { return !engine->settingsBusy(); }); REQUIRE(vad->isChecked() && engine->captureAvailable() && !engine->vadAvailable() && status->text() == "VAD недоступен"); QTest::mousePress(ptt, Qt::LeftButton); await([&]() { return engine->isTransmitting(); }); QTest::mouseRelease(ptt, Qt::LeftButton); await([&]() { return !engine->isTransmitting(); }); qInfo("radio panel: capture-only restart preserves output/manual owner; VAD failure leaves manual PTT working PASS"); engine->stop(); REQUIRE(!engine->isTransmitting() && !engine->captureAvailable() && !vad->isEnabled()); REQUIRE(config.open(QIODevice::ReadOnly)); QByteArray saved = config.readAll(); config.close(); REQUIRE(saved.contains("radio_vad_enabled=1") && saved.contains("radio_vad_threshold=65") && saved.contains("radio_vad_hangover_ms=400") && saved.contains("radio_vad_compressor_max_gain_db=30") && saved.contains("radio_opus_bitrate=32")); // Как при рестарте приложения: runtime-настройки заново читаются парсером из сохранённого INI. auto* parsed = parse_config(configPath.toUtf8().constData()); REQUIRE(parsed); instance.chat_settings = parsed->global.chat_settings; free_config(parsed); engine->loadSettings(configPath); await([&]() { return !engine->settingsBusy(); }); REQUIRE(engine->vadEnabled() && engine->setting("radio_vad_threshold") == 65); detectorAvailable = true; REQUIRE(engine->start(8) == 0); await([&]() { return engine->vadAvailable(); }); REQUIRE(engine->groupId() == 8 && vad->isChecked()); engine->stop(); failCapture = true; REQUIRE(engine->start(9) == 0); REQUIRE(!engine->captureAvailable() && vad->isEnabled() && !ptt->isEnabled()); vad->click(); await([&]() { return !engine->settingsBusy() && !engine->vadEnabled(); }); REQUIRE(!engine->captureAvailable()); failCapture = false; await([&]() { return engine->captureAvailable(); }); vad->click(); await([&]() { return !engine->settingsBusy() && engine->vadAvailable(); }); REQUIRE(vad->isChecked() && ptt->isEnabled()); qInfo("radio panel: unavailable microphone permits VAD settings; capture recovery restores controls PASS"); engine->stop(); engine->loadSettings(directory.filePath("missing.cfg")); await([&]() { return !engine->settingsBusy(); }); REQUIRE(!engine->applySettings({{"radio_vad_enabled", 0}}) && engine->vadEnabled() && !engine->settingsError().isEmpty()); for (auto* button : panel.findChildren()) { REQUIRE(QMetaObject::invokeMethod(button->menu(), "aboutToShow", Qt::DirectConnection)); REQUIRE(!button->menu()->actions().isEmpty()); } engine->loadSettings(configPath); await([&]() { return !engine->settingsBusy(); }); REQUIRE(engine->start(10) == 0 && engine->vadAvailable()); pump(100); REQUIRE(panel.grab().save("/tmp/desktop-radio-panel.png")); engine->stop(); radio_audio_destroy(&instance); uasync_destroy(instance.ua, 0); instance.ua = nullptr; manager->shutdown(); size_t allocated = u_get_allocated_count(); instance.ua = uasync_create(); REQUIRE(instance.ua); engine->loadSettings(configPath); REQUIRE(engine->settingsBusy() && instance.ua->posted_tasks_head); uasync_destroy(instance.ua, 0); instance.ua = nullptr; REQUIRE(u_get_allocated_count() == allocated); qInfo("radio panel: pending settings request freed on core shutdown PASS"); qInfo("radio panel: saved settings, channel change, device menus, range validation and config failure PASS"); return 0; }