|
|
|
|
@ -7,6 +7,7 @@
|
|
|
|
|
#include <QCheckBox> |
|
|
|
|
#include <QButtonGroup> |
|
|
|
|
#include <QPushButton> |
|
|
|
|
#include <QMessageBox> |
|
|
|
|
#include <QJsonDocument> |
|
|
|
|
#include <QJsonObject> |
|
|
|
|
#include <QScreen> |
|
|
|
|
@ -53,8 +54,25 @@ extern "C" {
|
|
|
|
|
#ifdef TEST_NULL_AUDIO |
|
|
|
|
static ma_device_data_proc captureCallback; |
|
|
|
|
static std::atomic<bool> failAudioEncoding{false}; |
|
|
|
|
static std::atomic<bool> suppressMicrophone{false}, silentMicrophone{false}, slowCaptureClose{false}; |
|
|
|
|
static std::atomic<unsigned> captureOpens{0}, audioEncodes{0}; |
|
|
|
|
#ifdef HAVE_FFMPEG |
|
|
|
|
static std::atomic<unsigned> softwareOpens{0}, hardwareOpens{0}; |
|
|
|
|
static std::atomic<bool> 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<int16_t> 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<bool>("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<QMessageBox*>("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<bool>("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<QLabel*>()) 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()); |
|
|
|
|
|