From 240568870a5ea897dd475ceb2e95df1a7c852c57 Mon Sep 17 00:00:00 2001 From: evgeny Date: Wed, 14 Oct 2026 10:19:44 +0200 Subject: [PATCH] Test desktop codec round trips and real screen publication --- tools/chatgui/CMakeLists.txt | 15 ++ tools/chatgui/tests/test_stream_publish.cpp | 243 ++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100644 tools/chatgui/tests/test_stream_publish.cpp diff --git a/tools/chatgui/CMakeLists.txt b/tools/chatgui/CMakeLists.txt index 51435d22..5b5a9f4b 100644 --- a/tools/chatgui/CMakeLists.txt +++ b/tools/chatgui/CMakeLists.txt @@ -541,6 +541,21 @@ target_link_libraries(test_stream_audio PRIVATE utun_voice utun pthread ${CMAKE_ add_test(NAME test_stream_audio COMMAND test_stream_audio) set_tests_properties(test_stream_audio PROPERTIES TIMEOUT 30) +add_executable(test_stream_publish tests/test_stream_publish.cpp transport/stream_bridge.cpp + src/sound_manager.cpp src/voice_audio_io.cpp src/audio_device.cpp transport/miniaudio_impl.c transport/audio_diagnostics.cpp) +target_link_libraries(test_stream_publish PRIVATE chatgui_stream_publish ${QT_TEST_LIB} utun_voice pthread ${CMAKE_DL_LIBS}) +if(FFMPEG_FOUND) + target_compile_definitions(test_stream_publish PRIVATE HAVE_FFMPEG) + target_include_directories(test_stream_publish PRIVATE ${FFMPEG_INCLUDE_DIRS}) + target_link_libraries(test_stream_publish PRIVATE ${FFMPEG_TARGET} ${FFMPEG_LIBRARIES}) +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") +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") + target_link_libraries(test_ptt_key_chord PRIVATE ${QT_CORE}) add_test(NAME test_ptt_key_chord COMMAND test_ptt_key_chord) diff --git a/tools/chatgui/tests/test_stream_publish.cpp b/tools/chatgui/tests/test_stream_publish.cpp new file mode 100644 index 00000000..96ae4c97 --- /dev/null +++ b/tools/chatgui/tests/test_stream_publish.cpp @@ -0,0 +1,243 @@ +/* Настоящие FFmpeg/Opus/uAV2/RS и ядро. X11 capture дополнительно запускается под Xvfb. + * Null miniaudio подаёт два разных тона вместо физического микрофона, не подменяя encoder/публикацию. */ +#include +#include +#include +#include +#include +#include +#include +#include "../src/stream_video_encoder.h" +#include "../src/stream_publish_engine.h" +#include "../src/stream_publish_window.h" +#include "../src/sound_manager.h" +#include "../transport/stream_bridge.h" +extern "C" { +#include "utun_instance.h" +#include "u_async.h" +#include "chat/chat_core.h" +#include "stream/stream.h" +#include "debug_config.h" +#include "mem.h" +#include "sqlite3.h" +#include "opus_codec.h" +#ifdef HAVE_FFMPEG +#include +#include +#endif +} +#ifdef HAVE_STREAM_CAPTURE +#include +#endif + +#ifdef TEST_NULL_AUDIO +static ma_device_data_proc captureCallback; +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_init(ma_context*, const ma_device_config*, 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); +} +static void injectMicrophone(ma_device* device, void* out, const void*, ma_uint32 frames) { + 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)); + } + 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) { + captureCallback = copy.dataCallback; copy.dataCallback = injectMicrophone; + } + return __real_ma_device_init(context, ©, device); +} +#endif + +class StreamPublishTest : public QObject { + Q_OBJECT + UASYNC* ua = nullptr; + UTUN_INSTANCE* inst = nullptr; + QTimer poll; + uint64_t group = 0; + size_t allocations = 0; + static QByteArray keyframe(int codec, int width = 160, int height = 90, uint64_t pts = 100000) { + StreamVideoEncoder encoder; StreamVideoConfig config; config.width = width; config.height = height; config.codec = codec; + if (!encoder.open(config)) return {}; + QImage image(width, height, QImage::Format_RGB32); image.fill(QColor(45, 130, 220)); + QList output; + if (!encoder.encode(image, pts, true, output) || output.isEmpty() || !output.front().key) return {}; + return output.front().data; + } + bool record(uint64_t id, StreamRecordKind kind, StreamRecord& out) { + QElapsedTimer elapsed; elapsed.start(); + while (elapsed.elapsed() < 2500) { + if (gui_stream_pull(id, out, 0, 60000000, 60000000) && out.kind == kind) return true; + QTest::qWait(1); + } + return false; + } + bool live(uint64_t id) { + stream_publisher_status status; + return gui_stream_publish_status(id, status) && status.state == STREAM_PUBLISHER_LIVE && status.epoch; + } + bool ended(uint64_t id) { + stream_publisher_status status; + return gui_stream_publish_status(id, status) && status.state == STREAM_PUBLISHER_ENDED; + } +private slots: + void initTestCase() { + debug_config_init(); debug_set_level(DEBUG_LEVEL_WARN); debug_set_category_level(DEBUG_CATEGORY_STREAM, DEBUG_LEVEL_DEBUG); + debug_set_category_level(DEBUG_CATEGORY_VIDEO, DEBUG_LEVEL_DEBUG); + allocations = u_get_allocated_count(); ua = uasync_create(); QVERIFY(ua); + inst = utun_instance_create_from_str(ua, + "[global]\nauto_sockets=no\n[chatserver]\ngroup_autoconnect=0\nstorage_autoload=0\nmember_files_enabled=0\n"); + QVERIFY(inst); QCOMPARE(utun_core_start(inst), 0); QCOMPARE(chat_service_start(inst), 0); + chat_core_create_channel_auto(inst, "Desktop publisher test"); sqlite3_stmt* statement = nullptr; + QCOMPARE(sqlite3_prepare_v2(chat_core_get_db(inst), "SELECT channel_id FROM channels LIMIT 1", -1, &statement, nullptr), SQLITE_OK); + QCOMPARE(sqlite3_step(statement), SQLITE_ROW); group = QString::fromUtf8((const char*)sqlite3_column_text(statement, 0)).toULongLong(); + sqlite3_finalize(statement); QVERIFY(group); QVERIFY(gui_stream_attach(inst)); uasync_mark_running(ua); + connect(&poll, &QTimer::timeout, this, [this] { uasync_poll(ua, 0); }); poll.start(1); + } + void exclusiveBroadcastMicrophone() { + AudioCaptureLease call, radio, recorder, publisher(AudioCaptureLease::Mode::Exclusive); + QVERIFY(call.acquire()); QVERIFY(radio.acquire()); QVERIFY(recorder.acquire()); QVERIFY(!publisher.acquire()); + call.release(); radio.release(); QVERIFY(!publisher.acquire()); recorder.release(); + QVERIFY(publisher.acquire()); QVERIFY(!call.acquire()); QVERIFY(!radio.acquire()); QVERIFY(!recorder.acquire()); + QVERIFY(publisher.acquire()); publisher.release(); QVERIFY(call.acquire()); + } + void encoderRoundTrip_data() { + QTest::addColumn("codec"); + for (int codec : StreamVideoEncoder::available()) QTest::newRow(qPrintable(StreamVideoEncoder::codecName(codec))) << codec; + } + void encoderRoundTrip() { +#ifdef HAVE_FFMPEG + QFETCH(int, codec); auto encoded = keyframe(codec); QVERIFY(!encoded.isEmpty()); + const uint8_t *config = nullptr, *frame = nullptr; size_t configLength = 0, frameLength = 0; + QCOMPARE(stream_media_key_parse((const uint8_t*)encoded.constData(), encoded.size(), &config, &configLength, &frame, &frameLength), STREAM_OK); + const char* names[] = {"", "h264", "hevc", "vp9", "av1", "vvc"}; + const AVCodecDescriptor* descriptor = avcodec_descriptor_get_by_name(names[codec]); QVERIFY(descriptor); + const AVCodec* decoder = avcodec_find_decoder(descriptor->id); QVERIFY(decoder); + AVCodecContext* context = avcodec_alloc_context3(decoder); QVERIFY(context); + if (configLength) { + context->extradata = (uint8_t*)av_mallocz(configLength + AV_INPUT_BUFFER_PADDING_SIZE); QVERIFY(context->extradata); + memcpy(context->extradata, config, configLength); context->extradata_size = (int)configLength; + } + QCOMPARE(avcodec_open2(context, decoder, nullptr), 0); + AVPacket* packet = av_packet_alloc(); AVFrame* decoded = av_frame_alloc(); QVERIFY(packet && decoded); + QCOMPARE(av_new_packet(packet, (int)frameLength), 0); memcpy(packet->data, frame, frameLength); + QCOMPARE(avcodec_send_packet(context, packet), 0); QCOMPARE(avcodec_receive_frame(context, decoded), 0); + QCOMPARE(decoded->width, 160); QCOMPARE(decoded->height, 90); + av_frame_free(&decoded); av_packet_free(&packet); avcodec_free_context(&context); +#else + QSKIP("FFmpeg unavailable"); +#endif + } + void publishRevisionAndFinish() { + uint64_t id = 101, viewer = 201; + stream_media_format format{160, 90, 0, STREAM_MEDIA_H264, 0, 1}; + QVERIFY(gui_stream_publish_open(id, group, format)); QTRY_VERIFY(live(id)); + stream_publisher_status status; QVERIFY(gui_stream_publish_status(id, status)); uint64_t epoch = status.epoch; + QVERIFY(gui_stream_open(viewer, group, inst->node_id, epoch)); StreamRecord out; QVERIFY(record(viewer, StreamRecordKind::Opened, out)); + auto first = keyframe(STREAM_MEDIA_H264); QVERIFY(!first.isEmpty()); + QCOMPARE(gui_stream_publish_feed(id, true, 100000, true, first, &format), 1); + QVERIFY(record(viewer, StreamRecordKind::Video, out)); QCOMPARE(out.format.revision, 1u); QCOMPARE(out.data, first); + format.width = 128; format.height = 80; format.rotation = 270; format.revision = 2; + QCOMPARE(gui_stream_publish_feed(id, true, 200000, false, QByteArray("P"), &format), 0); + auto second = keyframe(STREAM_MEDIA_H264, 128, 80, 200000); QVERIFY(!second.isEmpty()); + QCOMPARE(gui_stream_publish_feed(id, true, 200000, true, second, &format), 1); + QVERIFY(record(viewer, StreamRecordKind::Video, out)); QCOMPARE(out.format.revision, 2u); QCOMPARE(out.format.rotation, (uint16_t)270); + QVERIFY(gui_stream_publish_status(id, status)); QCOMPARE(status.epoch, epoch); + gui_stream_publish_stop(id); QVERIFY(record(viewer, StreamRecordKind::End, out)); QTRY_VERIFY_WITH_TIMEOUT(ended(id), 4000); + QCOMPARE(gui_stream_publish_feed(id, true, 300000, true, second, &format), -1); gui_stream_stop(viewer); + } + void overloadRequiresKey() { + uint64_t id = 102; stream_media_format format{160, 90, 0, STREAM_MEDIA_H264, 0, 1}; + QVERIFY(gui_stream_publish_open(id, group, format)); QTRY_VERIFY(live(id)); + QByteArray large(1200000, '\0'); large[4] = '\1'; + QCOMPARE(gui_stream_publish_feed(id, true, 100000, true, large, &format), 1); + QCOMPARE(gui_stream_publish_feed(id, true, 200000, false, large, &format), 0); + stream_publisher_status status; QVERIFY(gui_stream_publish_status(id, status)); QVERIFY(status.need_key); + QCOMPARE(gui_stream_publish_feed(id, true, 300000, false, QByteArray("P"), &format), 0); + auto key = keyframe(STREAM_MEDIA_H264); QVERIFY(!key.isEmpty()); + QCOMPARE(gui_stream_publish_feed(id, true, 400000, true, key, &format), 1); + QVERIFY(gui_stream_publish_status(id, status)); QVERIFY(!status.need_key); + QTest::qWait(150); QVERIFY(gui_stream_publish_status(id, status)); QVERIFY(status.sample_us); + 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 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"); + auto* sound = SoundManager::instance(); QVERIFY(sound->init()); + 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); + StreamPublishConfig config; config.video.width = 640; config.video.height = 360; config.video.fps = 15; + config.source = {StreamVideoSource::Screen, "Virtual screen", QGuiApplication::primaryScreen()->name().toUtf8(), {}}; + QSignalSpy errors(&engine, &StreamPublishEngine::errorOccurred); + QVERIFY(engine.start(group, config)); + QTRY_VERIFY_WITH_TIMEOUT(engine.config().video.codec == STREAM_MEDIA_H264, 10000); + char* sources = nullptr; uint64_t epoch = 0; + QElapsedTimer wait; wait.start(); + while (!epoch && wait.elapsed() < 5000) { + stream_member_state member{}; + if (stream_member_load(inst, group, inst->node_id, &member, nullptr) == STREAM_OK && member.published) epoch = member.source.source_epoch; + QTest::qWait(1); + } + Q_UNUSED(sources); QVERIFY(epoch); QVERIFY(gui_stream_open(202, group, inst->node_id, epoch)); + StreamRecord out; QVERIFY(record(202, StreamRecordKind::Opened, out)); QVERIFY(record(202, StreamRecordKind::Video, out)); + uint64_t first = out.pts; unsigned audio = 0, video = 0; uint64_t latest = first, left = 0, right = 0; + auto* decoder = opus_codec_decoder_create(48000, 2); QVERIFY(decoder); + QElapsedTimer elapsed; elapsed.start(); + while (elapsed.elapsed() < 1800) { + if (gui_stream_pull(202, out, 0, 60000000, 60000000)) { + if (out.kind == StreamRecordKind::Video) { video++; latest = out.pts; } + if (out.kind == StreamRecordKind::Audio) { + audio++; int16_t pcm[1920]; QCOMPARE(opus_codec_decode(decoder, (const uint8_t*)out.data.constData(), out.data.size(), pcm, 960), 960); + for (int i = 0; i < 960; i++) { left += (int64_t)pcm[2 * i] * pcm[2 * i]; right += (int64_t)pcm[2 * i + 1] * pcm[2 * i + 1]; } + } + } + QTest::qWait(1); + } + QVERIFY(video >= 10); QVERIFY(audio >= 40); QVERIFY(latest > first + 1000000); QVERIFY(left && right > left * 3); + opus_codec_decoder_destroy(decoder); + auto next = config; next.video.bitrate = 1000000; next.video.rotation = 90; + QVERIFY(engine.reconfigure(next)); bool revised = false; + elapsed.restart(); while (!revised && elapsed.elapsed() < 5000) { + if (gui_stream_pull(202, out, 0, 60000000, 60000000) && out.kind == StreamRecordKind::Video && out.format.revision >= 2) revised = true; + QTest::qWait(1); + } + QVERIFY(revised); QCOMPARE(out.format.rotation, (uint16_t)90); +#if QT_VERSION >= QT_VERSION_CHECK(6, 10, 0) + next.source = {StreamVideoSource::Window, pattern.windowTitle(), {}, QVariant::fromValue(QCapturableWindow(pattern.windowHandle()))}; + QVERIFY(engine.reconfigure(next)); bool window = false; + elapsed.restart(); while (!window && elapsed.elapsed() < 5000) { + if (gui_stream_pull(202, out, 0, 60000000, 60000000) && out.kind == StreamRecordKind::Video && out.format.revision >= 3) window = true; + QTest::qWait(1); + } + QVERIFY(window); +#endif + QVERIFY(errors.isEmpty()); engine.stop(); QTRY_VERIFY_WITH_TIMEOUT(!engine.active(), 4000); + gui_stream_stop(202); sound->shutdown(); +#else + QSKIP("Qt screen capture or null audio unavailable"); +#endif + } + void concurrentDetach() { + uint64_t id = 103; stream_media_format format{160, 90, 0, STREAM_MEDIA_H264, 0, 1}; + QVERIFY(gui_stream_publish_open(id, group, format)); QTRY_VERIFY(live(id)); auto key = keyframe(STREAM_MEDIA_H264); QVERIFY(!key.isEmpty()); + std::atomic ready{false}; std::thread producer([&] { + ready = true; for (uint64_t pts = 1; gui_stream_publish_feed(id, true, pts, true, key, &format) >= 0; pts++) {} + }); + while (!ready) std::this_thread::yield(); gui_stream_detach(inst); producer.join(); + QVERIFY(!gui_stream_ready()); QVERIFY(gui_stream_attach(inst)); + } + void cleanupTestCase() { + poll.stop(); gui_stream_detach(inst); uasync_mark_stopped(ua); utun_instance_destroy(inst); uasync_destroy(ua); + QCOMPARE(u_get_allocated_count(), allocations); + } +}; +QTEST_MAIN(StreamPublishTest) +#include "test_stream_publish.moc"