diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index e6a5ca27..ebdcc20b 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -638,7 +638,10 @@ endif() if(CMAKE_SYSTEM_NAME STREQUAL "Linux") target_compile_definitions(test_stream_publish PRIVATE TEST_NULL_AUDIO) target_link_options(test_stream_publish PRIVATE "-Wl,--wrap=ma_context_init" "-Wl,--wrap=ma_device_init" - "-Wl,--wrap=opus_codec_encode") + "-Wl,--wrap=ma_device_uninit" "-Wl,--wrap=opus_codec_encode") + if(FFMPEG_FOUND) + target_link_options(test_stream_publish PRIVATE "-Wl,--wrap=avcodec_open2") + endif() endif() add_test(NAME test_stream_publish COMMAND test_stream_publish) set_tests_properties(test_stream_publish PROPERTIES TIMEOUT 60 ENVIRONMENT "QT_QPA_PLATFORM=offscreen") diff --git a/tools/chatgui/tests/test_stream_publish.cpp b/tools/chatgui/tests/test_stream_publish.cpp index 811ed619..c215f115 100644 --- a/tools/chatgui/tests/test_stream_publish.cpp +++ b/tools/chatgui/tests/test_stream_publish.cpp @@ -7,6 +7,7 @@ #include #include #include +#include #include #include #include @@ -53,8 +54,25 @@ extern "C" { #ifdef TEST_NULL_AUDIO static ma_device_data_proc captureCallback; static std::atomic failAudioEncoding{false}; +static std::atomic suppressMicrophone{false}, silentMicrophone{false}, slowCaptureClose{false}; +static std::atomic captureOpens{0}, audioEncodes{0}; +#ifdef HAVE_FFMPEG +static std::atomic softwareOpens{0}, hardwareOpens{0}; +static std::atomic failHardware{false}; +extern "C" int __real_avcodec_open2(AVCodecContext*, const AVCodec*, AVDictionary**); +extern "C" int __wrap_avcodec_open2(AVCodecContext* context, const AVCodec* codec, AVDictionary** options) { + if (av_codec_is_encoder(codec)) { + QByteArray name(codec->name); + bool hardware = name.endsWith("_nvenc") || name.endsWith("_qsv") || name.endsWith("_amf") || name.endsWith("_vaapi"); + if (hardware) { hardwareOpens++; if (failHardware) return AVERROR(ENODEV); } + else softwareOpens++; + } + return __real_avcodec_open2(context, codec, options); +} +#endif extern "C" int __real_opus_codec_encode(opus_codec_encoder_t*, const int16_t*, int, uint8_t*, int); extern "C" int __wrap_opus_codec_encode(opus_codec_encoder_t* encoder, const int16_t* pcm, int frames, uint8_t* data, int capacity) { + audioEncodes++; return failAudioEncoding.exchange(false) ? -1 : __real_opus_codec_encode(encoder, pcm, frames, data, capacity); } extern "C" ma_result __real_ma_context_init(const ma_backend*, ma_uint32, const ma_context_config*, ma_context*); @@ -63,21 +81,29 @@ extern "C" ma_result __wrap_ma_context_init(const ma_backend*, ma_uint32, const ma_backend backend = ma_backend_null; return __real_ma_context_init(&backend, 1, nullptr, context); } static void injectMicrophone(ma_device* device, void* out, const void*, ma_uint32 frames) { + if (suppressMicrophone) return; static uint64_t position = 0; std::vector pcm(frames * device->capture.channels); for (unsigned i = 0; i < frames; i++, position++) { pcm[i * device->capture.channels] = (int16_t)(1200 * std::sin(2 * 3.141592653589793 * 440 * position / 48000)); if (device->capture.channels == 2) pcm[i * 2 + 1] = (int16_t)(3600 * std::sin(2 * 3.141592653589793 * 880 * position / 48000)); } + if (silentMicrophone) std::fill(pcm.begin(), pcm.end(), 0); captureCallback(device, out, pcm.data(), frames); } extern "C" ma_result __wrap_ma_device_init(ma_context* context, const ma_device_config* config, ma_device* device) { auto copy = *config; if (copy.deviceType == ma_device_type_capture && copy.capture.format == ma_format_s16) { + captureOpens++; captureCallback = copy.dataCallback; copy.dataCallback = injectMicrophone; } return __real_ma_device_init(context, ©, device); } +extern "C" void __real_ma_device_uninit(ma_device*); +extern "C" void __wrap_ma_device_uninit(ma_device* device) { + if (device->type == ma_device_type_capture && slowCaptureClose) std::this_thread::sleep_for(std::chrono::milliseconds(700)); + __real_ma_device_uninit(device); +} #endif class StreamPublishTest : public QObject { @@ -256,6 +282,8 @@ private slots: #ifdef HAVE_STREAM_CAPTURE if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run with X11 screen capture enabled"); StreamPublishEngine engine; QTRY_VERIFY_WITH_TIMEOUT(!engine.codecs().isEmpty(), 15000); + connect(&engine, &StreamPublishEngine::softwareEncodingRequested, &engine, + [&engine](uint64_t generation) { engine.answerSoftwareEncoding(generation, true); }); QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred); StreamPublishConfig config; config.audio = false; config.nativeSize = true; config.automaticBitrate = true; config.video.width = 640; config.video.height = 360; config.video.fps = 15; @@ -466,6 +494,92 @@ private slots: QCOMPARE(status.total_bytes, status.video_bytes + status.audio_bytes + status.stream_bytes + status.transport_bytes + status.inflight_bytes); gui_stream_publish_stop(id); QTRY_VERIFY_WITH_TIMEOUT(ended(id), 4000); } + void cpuConsent_data() { + QTest::addColumn("allow"); + QTest::newRow("decline") << false; + QTest::newRow("allow") << true; + } + void cpuConsent() { +#if defined(HAVE_STREAM_CAPTURE) && defined(TEST_NULL_AUDIO) && defined(HAVE_FFMPEG) + if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run under isolated Xvfb"); + QFETCH(bool, allow); + failHardware = true; softwareOpens = 0; audioEncodes = 0; + auto reset = qScopeGuard([] { failHardware = false; }); + StreamPublishEngine engine; StreamPublishWindow controls(&engine, group); controls.show(); + QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred); + QSignalSpy requests(&engine, &StreamPublishEngine::softwareEncodingRequested); + StreamPublishConfig config; config.audio = false; config.video.width = 320; config.video.height = 180; config.video.fps = 15; + for (const auto& source : engine.sources()) if (source.kind == StreamVideoSource::Screen) { config.source = source; break; } + QVERIFY(!config.source.id.isEmpty()); QCOMPARE(softwareOpens.load(), 0u); + QVERIFY(engine.start(group, config)); QTRY_COMPARE(requests.size(), 1); + uint64_t generation = requests.front().front().toULongLong(); + auto* question = controls.findChild("streamCpuConfirmation"); QVERIFY(question); + QCOMPARE(question->defaultButton(), question->button(QMessageBox::No)); + QTest::qWait(allow ? 8200 : 150); // Время ответа человека не является timeout первого кадра. + QCOMPARE(softwareOpens.load(), 0u); QCOMPARE(audioEncodes.load(), 0u); QVERIFY(errors.isEmpty()); + engine.answerSoftwareEncoding(generation + 1, true); QCOMPARE(engine.softwareEncodingRequest(), generation); + question->done(allow ? QMessageBox::Yes : QMessageBox::No); + if (allow) { + QTRY_VERIFY(softwareOpens.load() > 0); QTRY_VERIFY(engine.previewFrame().isValid()); + QVERIFY(errors.isEmpty()); engine.stop(); + } else { + QTRY_VERIFY(!engine.running()); QCOMPARE(errors.size(), 1); QCOMPARE(softwareOpens.load(), 0u); + } + QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 4000); + // Согласие не сохраняется между трансляциями. + unsigned before = softwareOpens.load(); + QVERIFY(engine.start(group, config)); QTRY_COMPARE(requests.size(), 2); + QCOMPARE(softwareOpens.load(), before); engine.stop(); + engine.answerSoftwareEncoding(generation, true); QCOMPARE(softwareOpens.load(), before); + QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 4000); +#else + QSKIP("Capture and encoder instrumentation unavailable"); +#endif + } + void microphoneFailure_data() { + QTest::addColumn("initialFailure"); + QTest::newRow("no-first-frame") << true; + QTest::newRow("stops-after-silence") << false; + } + void microphoneFailure() { +#if defined(HAVE_STREAM_CAPTURE) && defined(TEST_NULL_AUDIO) && defined(HAVE_FFMPEG) + if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Run under isolated Xvfb"); + QFETCH(bool, initialFailure); + auto* sound = SoundManager::instance(); QVERIFY(sound->init()); + failHardware = true; suppressMicrophone = initialFailure; silentMicrophone = true; slowCaptureClose = true; + softwareOpens = 0; hardwareOpens = 0; audioEncodes = 0; captureOpens = 0; + auto reset = qScopeGuard([sound] { + failHardware = suppressMicrophone = silentMicrophone = slowCaptureClose = false; sound->shutdown(); + }); + StreamPublishEngine engine; StreamPublishWindow controls(&engine, group); + QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred); + QSignalSpy requests(&engine, &StreamPublishEngine::softwareEncodingRequested); + StreamPublishConfig config; config.video.width = 320; config.video.height = 180; config.video.fps = 15; + for (const auto& source : engine.sources()) if (source.kind == StreamVideoSource::Screen) { config.source = source; break; } + QVERIFY(engine.start(group, config)); + if (!initialFailure) { + QTRY_COMPARE(requests.size(), 1); QCOMPARE(softwareOpens.load(), 0u); QCOMPARE(audioEncodes.load(), 0u); + engine.answerSoftwareEncoding(requests.front().front().toULongLong(), true); + QTRY_VERIFY(audioEncodes.load() > 0); QVERIFY(errors.isEmpty()); suppressMicrophone = true; + } + QElapsedTimer clock; clock.start(); qint64 previous = clock.elapsed(), maxGap = 0; + QTimer heartbeat; heartbeat.setInterval(10); + connect(&heartbeat, &QTimer::timeout, this, [&] { + auto now = clock.elapsed(); maxGap = std::max(maxGap, now - previous); previous = now; + }); + heartbeat.start(); QTRY_VERIFY_WITH_TIMEOUT(!engine.running(), 2500); + QCOMPARE(errors.size(), 1); QVERIFY(errors.front().front().toString().contains("microphone")); + QVERIFY(engine.active()); // Фоновое закрытие ещё идёт, GUI продолжает обрабатывать события. + QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 2500); heartbeat.stop(); QVERIFY2(maxGap < 400, qPrintable(QString::number(maxGap))); + QCOMPARE(captureOpens.load(), 1u); + bool shown = false; + for (auto* label : controls.findChildren()) shown |= label->text().contains("microphone is not delivering audio"); + QVERIFY(shown); + if (initialFailure) { QVERIFY(requests.isEmpty()); QCOMPARE(hardwareOpens.load(), 0u); QCOMPARE(softwareOpens.load(), 0u); QCOMPARE(audioEncodes.load(), 0u); } +#else + QSKIP("Capture and encoder instrumentation unavailable"); +#endif + } void screenAndWindowCapture() { #if defined(HAVE_STREAM_CAPTURE) && defined(TEST_NULL_AUDIO) if (!qEnvironmentVariableIsSet("UTUN_TEST_SCREEN_CAPTURE")) QSKIP("Real screen/window capture runs separately under Xvfb"); @@ -473,6 +587,8 @@ private slots: QLabel pattern("Desktop capture test"); pattern.setWindowTitle("UTUN capture fixture"); pattern.resize(320, 180); pattern.setStyleSheet("background: #c04020; color: white; font-size: 20px;"); pattern.show(); QTest::qWait(100); StreamPublishEngine engine; QTRY_VERIFY_WITH_TIMEOUT(!engine.codecs().isEmpty(), 15000); + connect(&engine, &StreamPublishEngine::softwareEncodingRequested, &engine, + [&engine](uint64_t generation) { engine.answerSoftwareEncoding(generation, true); }); StreamPublishConfig config; config.video.width = 640; config.video.height = 360; config.video.fps = 15; for (const auto& source : engine.sources()) if (source.kind == StreamVideoSource::Screen) { config.source = source; break; } QVERIFY(!config.source.id.isEmpty());