From eb13c87dd303e2255add3540f34a40f439f529d2 Mon Sep 17 00:00:00 2001 From: bitheaven Date: Mon, 20 Jul 2026 10:57:34 +0500 Subject: [PATCH] Add sound capture --- CMakeLists.txt | 9 ++ README.md | 7 +- data/locale/en-US.ini | 1 + data/locale/ru-RU.ini | 1 + scripts/install-deps-alpine.sh | 2 + src/docks.cpp | 1 + src/plugin.c | 91 ++++++++++++++- src/protocol.h | 5 +- src/pulse-capture.c | 174 +++++++++++++++++++++++++++++ src/pulse-capture.h | 19 ++++ src/renderer.c | 37 ++++++- tests/module-load-test.c | 196 ++++++++++++++++++++++++++++++++- tests/protocol-test.c | 3 +- tests/renderer-smoke-test.c | 4 + 14 files changed, 539 insertions(+), 11 deletions(-) create mode 100644 src/pulse-capture.c create mode 100644 src/pulse-capture.h diff --git a/CMakeLists.txt b/CMakeLists.txt index e26e6bd..fad161c 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -14,11 +14,14 @@ pkg_check_modules(GLIB REQUIRED IMPORTED_TARGET glib-2.0) pkg_check_modules(GTK REQUIRED IMPORTED_TARGET gtk+-3.0) pkg_check_modules(WEBKIT REQUIRED IMPORTED_TARGET webkit2gtk-4.1) pkg_check_modules(X11_CAPTURE REQUIRED IMPORTED_TARGET x11 xcomposite xdamage xext) +pkg_check_modules(PULSE REQUIRED IMPORTED_TARGET libpulse libpulse-simple) add_library(obs-webkit-browser MODULE src/plugin.c src/docks.cpp src/protocol.h + src/pulse-capture.c + src/pulse-capture.h ) target_compile_features(obs-webkit-browser PRIVATE c_std_11) target_compile_features(obs-webkit-browser PRIVATE cxx_std_17) @@ -29,6 +32,7 @@ target_link_libraries(obs-webkit-browser PRIVATE obs obs-frontend-api PkgConfig::QT_WIDGETS + PkgConfig::PULSE Threads::Threads ) set_target_properties(obs-webkit-browser PROPERTIES PREFIX "") @@ -106,4 +110,9 @@ if(BUILD_TESTING) $ "${CMAKE_CURRENT_SOURCE_DIR}/data" $) set_tests_properties(module-load-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77) + + add_test(NAME module-audio-test COMMAND module-load-test + $ "${CMAKE_CURRENT_SOURCE_DIR}/data" + $ audio-test) + set_tests_properties(module-audio-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77) endif() diff --git a/README.md b/README.md index 16e85be..d96081c 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,7 @@ DMA-BUF. Кадры передаются в OBS напрямую как BGRA, б - прозрачный фон с alpha-каналом; - пользовательский CSS; - масштаб страницы 25–500% с сохранением в настройках источника или web-панели; +- опциональный захват звука каждого browser source в отдельную полосу Audio Mixer OBS; - размер 2–8192 px и 1–60 FPS; - аппаратный WebKit compositor через DRM/DMA-BUF (Mesa `radeonsi` на AMD); - прямой XComposite/XShm-захват скомпозированного GPU-кадра с alpha; @@ -27,7 +28,11 @@ DMA-BUF. Кадры передаются в OBS напрямую как BGRA, б страницы одного origin взаимодействуют штатными `storage`-событиями и `BroadcastChannel`, как вкладки одного браузера. -Звук страницы намеренно выключен: добавьте в OBS отдельный Application Audio Capture. +Захват звука по умолчанию выключен. Включите «Захватывать звук браузера» в +настройках нужного источника; его громкость появится отдельной полосой в Audio Mixer OBS. +Для захвата требуется работающий PulseAudio-совместимый сервер (в том числе +PipeWire Pulse) и GStreamer `pulsesink`; скрипт установки зависимостей добавляет +необходимый пакет `gst-plugins-good`. Все WebView одной сессии OBS работают в общем renderer-процессе и одном `WebKitWebContext`; маленькие процессы-посредники сохраняют прежнюю независимую жизнь каждого source/dock и изоляцию от OBS. Профиль хранится в стандартных каталогах diff --git a/data/locale/en-US.ini b/data/locale/en-US.ini index 4952fe5..18d0ecd 100644 --- a/data/locale/en-US.ini +++ b/data/locale/en-US.ini @@ -14,6 +14,7 @@ Width="Width" Height="Height" FPS="Frames per second" PageZoom="Page zoom (%)" +CaptureAudio="Capture browser audio" Transparent="Transparent background" CustomCSS="Custom CSS" ShutdownWhenHidden="Shut down renderer when source is hidden" diff --git a/data/locale/ru-RU.ini b/data/locale/ru-RU.ini index 5d96cbe..3bd2c7f 100644 --- a/data/locale/ru-RU.ini +++ b/data/locale/ru-RU.ini @@ -14,6 +14,7 @@ Width="Ширина" Height="Высота" FPS="Кадров в секунду" PageZoom="Масштаб страницы (%)" +CaptureAudio="Захватывать звук браузера" Transparent="Прозрачный фон" CustomCSS="Пользовательский CSS" ShutdownWhenHidden="Останавливать рендерер, когда источник скрыт" diff --git a/scripts/install-deps-alpine.sh b/scripts/install-deps-alpine.sh index d4a5966..a57c9ea 100755 --- a/scripts/install-deps-alpine.sh +++ b/scripts/install-deps-alpine.sh @@ -18,5 +18,7 @@ $ROOT apk add \ qt6-qtbase-dev \ gtk+3.0 gtk+3.0-dev \ webkit2gtk-4.1 webkit2gtk-4.1-dev \ + gst-plugins-good \ + pulseaudio-dev \ libx11-dev libxcomposite-dev libxdamage-dev libxext-dev \ mesa-dri-gallium diff --git a/src/docks.cpp b/src/docks.cpp index 4712502..441cd0d 100644 --- a/src/docks.cpp +++ b/src/docks.cpp @@ -349,6 +349,7 @@ private: header.height = static_cast(renderHeight_); header.fps = 30; header.zoom_percent = static_cast(zoomPercent_); + header.capture_audio = 0; header.transparent = 0; header.hardware_acceleration = 1; header.url_length = static_cast(url.size()); diff --git a/src/plugin.c b/src/plugin.c index 577b22c..293eebc 100644 --- a/src/plugin.c +++ b/src/plugin.c @@ -1,6 +1,8 @@ #include #include #include +#include +#include #include #include @@ -21,6 +23,7 @@ #include #include "protocol.h" +#include "pulse-capture.h" OBS_DECLARE_MODULE() OBS_MODULE_USE_DEFAULT_LOCALE("obs-webkit-browser", "en-US") @@ -51,8 +54,11 @@ struct renderer_process { size_t shared_size; pthread_t frame_thread; pthread_t log_thread; + pthread_t audio_thread; bool frame_thread_started; bool log_thread_started; + bool audio_thread_started; + struct owb_pulse_sink pulse_sink; atomic_bool stopping; uint32_t width; uint32_t height; @@ -69,6 +75,7 @@ struct browser_source { uint32_t height; uint32_t fps; uint32_t zoom_percent; + bool capture_audio; bool transparent; bool shutdown_when_hidden; bool visible; @@ -196,6 +203,54 @@ static void *log_reader(void *opaque) return NULL; } +static void *audio_reader(void *opaque) +{ + struct renderer_process *renderer = opaque; + char monitor[OWB_AUDIO_SINK_NAME_SIZE + 16]; + snprintf(monitor, sizeof(monitor), "%s.monitor", renderer->pulse_sink.name); + pa_sample_spec specification = { + .format = PA_SAMPLE_FLOAT32NE, + .rate = OWB_AUDIO_SAMPLE_RATE, + .channels = OWB_AUDIO_CHANNELS, + }; + pa_buffer_attr buffering = { + .maxlength = (uint32_t)-1, + .tlength = (uint32_t)-1, + .prebuf = (uint32_t)-1, + .minreq = (uint32_t)-1, + .fragsize = OWB_AUDIO_BLOCK_FRAMES * OWB_AUDIO_CHANNELS * sizeof(float), + }; + int pulse_error = 0; + pa_simple *stream = pa_simple_new(NULL, "OBS WebKit Browser", PA_STREAM_RECORD, + monitor, "Browser source audio", &specification, NULL, + &buffering, &pulse_error); + if (!stream) { + blog(LOG_ERROR, "[obs-webkit-browser] Cannot capture browser audio: %s", + pa_strerror(pulse_error)); + return NULL; + } + + float samples[OWB_AUDIO_BLOCK_FRAMES * OWB_AUDIO_CHANNELS]; + while (!atomic_load(&renderer->stopping)) { + if (pa_simple_read(stream, samples, sizeof(samples), &pulse_error) < 0) + break; + uint64_t duration = (uint64_t)OWB_AUDIO_BLOCK_FRAMES * 1000000000u / + OWB_AUDIO_SAMPLE_RATE; + uint64_t now = os_gettime_ns(); + struct obs_source_audio audio = { + .data = {(const uint8_t *)samples}, + .frames = OWB_AUDIO_BLOCK_FRAMES, + .speakers = SPEAKERS_STEREO, + .format = AUDIO_FORMAT_FLOAT, + .samples_per_sec = OWB_AUDIO_SAMPLE_RATE, + .timestamp = now > duration ? now - duration : now, + }; + obs_source_output_audio(renderer->owner->source, &audio); + } + pa_simple_free(stream); + return NULL; +} + static void terminate_renderer_group(pid_t pid) { if (pid <= 0) @@ -227,6 +282,8 @@ static void stop_renderer_locked(struct browser_source *browser) renderer->control_fd = -1; } terminate_renderer_group(renderer->pid); + if (renderer->pulse_sink.module_index != UINT32_MAX) + owb_pulse_sink_destroy(&renderer->pulse_sink); if (renderer->frame_fd >= 0) { close(renderer->frame_fd); renderer->frame_fd = -1; @@ -237,6 +294,8 @@ static void stop_renderer_locked(struct browser_source *browser) pthread_join(renderer->log_thread, NULL); else if (renderer->log_fd >= 0) close(renderer->log_fd); + if (renderer->audio_thread_started) + pthread_join(renderer->audio_thread, NULL); if (renderer->shared && renderer->shared != MAP_FAILED) munmap(renderer->shared, renderer->shared_size); if (renderer->shared_fd >= 0) @@ -257,11 +316,14 @@ static bool send_initial_config(struct browser_source *browser, struct renderer_ .height = browser->height, .fps = browser->fps, .zoom_percent = browser->zoom_percent, + .capture_audio = browser->capture_audio, .transparent = browser->transparent, .hardware_acceleration = true, .url_length = (uint32_t)url_length, .css_length = (uint32_t)css_length, }; + if (browser->capture_audio) + snprintf(header.audio_sink, sizeof(header.audio_sink), "%s", renderer->pulse_sink.name); return write_all_socket(renderer->control_fd, &header, sizeof(header)) && write_all_socket(renderer->control_fd, browser->url, url_length) && write_all_socket(renderer->control_fd, browser->css, css_length); @@ -287,6 +349,16 @@ static bool start_renderer_locked(struct browser_source *browser) int frames[2] = {-1, -1}; int logs[2] = {-1, -1}; int shared_fd = -1; + struct owb_pulse_sink pulse_sink = {.module_index = UINT32_MAX}; + if (browser->capture_audio) { + char pulse_error[256]; + if (!owb_pulse_sink_create(&pulse_sink, pulse_error, sizeof(pulse_error))) { + blog(LOG_ERROR, "[obs-webkit-browser] Cannot create browser audio sink: %s", + pulse_error); + bfree(helper); + return false; + } + } struct owb_shared_frames *shared = MAP_FAILED; size_t shared_size = owb_shared_size(browser->width, browser->height); shared_fd = memfd_create("obs-webkit-browser-frames", MFD_CLOEXEC); @@ -361,6 +433,8 @@ static bool start_renderer_locked(struct browser_source *browser) renderer->shared_fd = shared_fd; renderer->shared = shared; renderer->shared_size = shared_size; + renderer->pulse_sink = pulse_sink; + pulse_sink.module_index = UINT32_MAX; renderer->width = browser->width; renderer->height = browser->height; renderer->owner = browser; @@ -377,6 +451,7 @@ static bool start_renderer_locked(struct browser_source *browser) close(renderer->log_fd); munmap(renderer->shared, renderer->shared_size); close(renderer->shared_fd); + owb_pulse_sink_destroy(&renderer->pulse_sink); free(renderer); goto fail; } @@ -384,12 +459,21 @@ static bool start_renderer_locked(struct browser_source *browser) renderer->frame_thread_started = true; if (pthread_create(&renderer->log_thread, NULL, log_reader, renderer) == 0) renderer->log_thread_started = true; + if (browser->capture_audio && + pthread_create(&renderer->audio_thread, NULL, audio_reader, renderer) == 0) + renderer->audio_thread_started = true; if (!renderer->frame_thread_started) { blog(LOG_ERROR, "[obs-webkit-browser] Cannot start frame reader thread"); browser->renderer = renderer; stop_renderer_locked(browser); goto fail; } + if (browser->capture_audio && !renderer->audio_thread_started) { + blog(LOG_ERROR, "[obs-webkit-browser] Cannot start browser audio reader thread"); + browser->renderer = renderer; + stop_renderer_locked(browser); + goto fail; + } browser->renderer = renderer; blog(LOG_INFO, "[obs-webkit-browser] Started %ux%u@%u renderer", browser->width, browser->height, browser->fps); @@ -410,6 +494,7 @@ fail: munmap(shared, shared_size); if (shared_fd >= 0) close(shared_fd); + owb_pulse_sink_destroy(&pulse_sink); return false; } @@ -437,11 +522,13 @@ static void browser_update(void *data, obs_data_t *settings) browser->height = setting_u32(settings, "height", 2, 8192); browser->fps = setting_u32(settings, "fps", 1, 60); browser->zoom_percent = setting_u32(settings, "zoom", 25, 500); + browser->capture_audio = obs_data_get_bool(settings, "capture_audio"); browser->transparent = obs_data_get_bool(settings, "transparent"); browser->shutdown_when_hidden = obs_data_get_bool(settings, "shutdown_when_hidden"); stop_renderer_locked(browser); start_renderer_locked(browser); pthread_mutex_unlock(&browser->mutex); + obs_source_set_audio_active(browser->source, browser->capture_audio); } static void *browser_create(obs_data_t *settings, obs_source_t *source) @@ -483,6 +570,7 @@ static void browser_defaults(obs_data_t *settings) obs_data_set_default_int(settings, "height", 720); obs_data_set_default_int(settings, "fps", 30); obs_data_set_default_int(settings, "zoom", 100); + obs_data_set_default_bool(settings, "capture_audio", false); obs_data_set_default_bool(settings, "transparent", true); obs_data_set_default_bool(settings, "shutdown_when_hidden", false); } @@ -503,6 +591,7 @@ static obs_properties_t *browser_properties(void *data) obs_properties_add_int(props, "height", obs_module_text("Height"), 2, 8192, 1); obs_properties_add_int(props, "fps", obs_module_text("FPS"), 1, 60, 1); obs_properties_add_int_slider(props, "zoom", obs_module_text("PageZoom"), 25, 500, 5); + obs_properties_add_bool(props, "capture_audio", obs_module_text("CaptureAudio")); obs_properties_add_bool(props, "transparent", obs_module_text("Transparent")); obs_properties_add_text(props, "css", obs_module_text("CustomCSS"), OBS_TEXT_MULTILINE); obs_properties_add_bool(props, "shutdown_when_hidden", obs_module_text("ShutdownWhenHidden")); @@ -595,7 +684,7 @@ static struct obs_source_info browser_info = { .id = "webkit_browser_source", .type = OBS_SOURCE_TYPE_INPUT, .icon_type = OBS_ICON_TYPE_BROWSER, - .output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_INTERACTION, + .output_flags = OBS_SOURCE_ASYNC_VIDEO | OBS_SOURCE_AUDIO | OBS_SOURCE_INTERACTION, .get_name = browser_name, .create = browser_create, .destroy = browser_destroy, diff --git a/src/protocol.h b/src/protocol.h index f65aad6..b3adc28 100644 --- a/src/protocol.h +++ b/src/protocol.h @@ -5,8 +5,9 @@ #define OWB_CONFIG_MAGIC 0x4f574243u /* OWBC */ #define OWB_COMMAND_MAGIC 0x4f574245u /* OWBE */ -#define OWB_PROTOCOL_VERSION 3u +#define OWB_PROTOCOL_VERSION 4u #define OWB_MAX_STRING (16u * 1024u * 1024u) +#define OWB_AUDIO_SINK_NAME_SIZE 64u #define OWB_SHARED_MAGIC 0x4f574246u /* OWBF */ #define OWB_SHARED_FRAME_FD 198 #define OWB_SHARED_FRAME_SLOTS 2u @@ -18,10 +19,12 @@ struct owb_config_header { uint32_t height; uint32_t fps; uint32_t zoom_percent; + uint32_t capture_audio; uint32_t transparent; uint32_t hardware_acceleration; uint32_t url_length; uint32_t css_length; + char audio_sink[OWB_AUDIO_SINK_NAME_SIZE]; }; struct owb_shared_frames { diff --git a/src/pulse-capture.c b/src/pulse-capture.c new file mode 100644 index 0000000..baf9794 --- /dev/null +++ b/src/pulse-capture.c @@ -0,0 +1,174 @@ +#include "pulse-capture.h" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +struct pulse_connection { + pa_mainloop *mainloop; + pa_context *context; +}; + +struct index_result { + bool complete; + uint32_t index; +}; + +struct success_result { + bool complete; + bool success; +}; + +static atomic_uint sink_serial = 1; + +static uint64_t monotonic_milliseconds(void) +{ + struct timespec now; + clock_gettime(CLOCK_MONOTONIC, &now); + return (uint64_t)now.tv_sec * 1000u + (uint64_t)now.tv_nsec / 1000000u; +} + +static bool pulse_iterate_until(struct pulse_connection *connection, bool *complete, + uint64_t deadline) +{ + while (!*complete && monotonic_milliseconds() < deadline) { + int result = 0; + if (pa_mainloop_iterate(connection->mainloop, 0, &result) < 0) + return false; + pa_context_state_t state = pa_context_get_state(connection->context); + if (!PA_CONTEXT_IS_GOOD(state)) + return false; + struct timespec pause = {.tv_nsec = 10000000L}; + nanosleep(&pause, NULL); + } + return *complete; +} + +static bool pulse_connection_open(struct pulse_connection *connection, char *error, + size_t error_size) +{ + memset(connection, 0, sizeof(*connection)); + connection->mainloop = pa_mainloop_new(); + if (!connection->mainloop) + goto fail; + connection->context = pa_context_new(pa_mainloop_get_api(connection->mainloop), + "OBS WebKit Browser"); + if (!connection->context || pa_context_connect(connection->context, NULL, + PA_CONTEXT_NOFLAGS, NULL) < 0) + goto fail; + + uint64_t deadline = monotonic_milliseconds() + 3000; + while (monotonic_milliseconds() < deadline) { + pa_context_state_t state = pa_context_get_state(connection->context); + if (state == PA_CONTEXT_READY) + return true; + if (!PA_CONTEXT_IS_GOOD(state)) + break; + int result = 0; + if (pa_mainloop_iterate(connection->mainloop, 0, &result) < 0) + break; + struct timespec pause = {.tv_nsec = 10000000L}; + nanosleep(&pause, NULL); + } + +fail: + if (error && error_size) { + int code = connection->context ? pa_context_errno(connection->context) : PA_ERR_INTERNAL; + snprintf(error, error_size, "%s", pa_strerror(code)); + } + if (connection->context) { + pa_context_disconnect(connection->context); + pa_context_unref(connection->context); + } + if (connection->mainloop) + pa_mainloop_free(connection->mainloop); + memset(connection, 0, sizeof(*connection)); + return false; +} + +static void pulse_connection_close(struct pulse_connection *connection) +{ + if (connection->context) { + pa_context_disconnect(connection->context); + pa_context_unref(connection->context); + } + if (connection->mainloop) + pa_mainloop_free(connection->mainloop); +} + +static void module_loaded(pa_context *context, uint32_t index, void *opaque) +{ + (void)context; + struct index_result *result = opaque; + result->index = index; + result->complete = true; +} + +static void module_unloaded(pa_context *context, int success, void *opaque) +{ + (void)context; + struct success_result *result = opaque; + result->success = success != 0; + result->complete = true; +} + +bool owb_pulse_sink_create(struct owb_pulse_sink *sink, char *error, size_t error_size) +{ + memset(sink, 0, sizeof(*sink)); + sink->module_index = PA_INVALID_INDEX; + unsigned serial = atomic_fetch_add(&sink_serial, 1); + snprintf(sink->name, sizeof(sink->name), "owb_%ld_%u", (long)getpid(), serial); + + struct pulse_connection connection; + if (!pulse_connection_open(&connection, error, error_size)) + return false; + char arguments[256]; + snprintf(arguments, sizeof(arguments), + "sink_name=%s rate=%u channels=%u sink_properties=device.description=OBS_WebKit_Browser", + sink->name, OWB_AUDIO_SAMPLE_RATE, OWB_AUDIO_CHANNELS); + struct index_result result = {.index = PA_INVALID_INDEX}; + pa_operation *operation = pa_context_load_module(connection.context, "module-null-sink", + arguments, module_loaded, &result); + bool okay = operation && pulse_iterate_until(&connection, &result.complete, + monotonic_milliseconds() + 3000) && + result.index != PA_INVALID_INDEX; + if (operation) + pa_operation_unref(operation); + if (!okay && error && error_size) + snprintf(error, error_size, "%s", pa_strerror(pa_context_errno(connection.context))); + pulse_connection_close(&connection); + if (!okay) { + sink->name[0] = '\0'; + return false; + } + sink->module_index = result.index; + return true; +} + +void owb_pulse_sink_destroy(struct owb_pulse_sink *sink) +{ + if (!sink || sink->module_index == PA_INVALID_INDEX) + return; + struct pulse_connection connection; + if (pulse_connection_open(&connection, NULL, 0)) { + struct success_result result = {0}; + pa_operation *operation = pa_context_unload_module(connection.context, + sink->module_index, + module_unloaded, &result); + if (operation) { + pulse_iterate_until(&connection, &result.complete, + monotonic_milliseconds() + 3000); + pa_operation_unref(operation); + } + pulse_connection_close(&connection); + } + sink->module_index = PA_INVALID_INDEX; + sink->name[0] = '\0'; +} diff --git a/src/pulse-capture.h b/src/pulse-capture.h new file mode 100644 index 0000000..caa994c --- /dev/null +++ b/src/pulse-capture.h @@ -0,0 +1,19 @@ +#pragma once + +#include +#include + +#include "protocol.h" + +#define OWB_AUDIO_SAMPLE_RATE 48000u +#define OWB_AUDIO_CHANNELS 2u +#define OWB_AUDIO_BLOCK_FRAMES 480u + +struct owb_pulse_sink { + uint32_t module_index; + char name[OWB_AUDIO_SINK_NAME_SIZE]; +}; + +bool owb_pulse_sink_create(struct owb_pulse_sink *sink, char *error, size_t error_size); +void owb_pulse_sink_destroy(struct owb_pulse_sink *sink); + diff --git a/src/renderer.c b/src/renderer.c index e5e39a0..a16fd43 100644 --- a/src/renderer.c +++ b/src/renderer.c @@ -36,7 +36,9 @@ struct config { uint32_t height; uint32_t fps; uint32_t zoom_percent; + bool capture_audio; bool transparent; + char audio_sink[OWB_AUDIO_SINK_NAME_SIZE]; char *url; char *css; }; @@ -109,13 +111,19 @@ static bool read_config(int fd, struct config *config) header.version != OWB_PROTOCOL_VERSION || header.width < 2 || header.height < 2 || header.width > 8192 || header.height > 8192 || header.fps < 1 || header.fps > 60 || header.zoom_percent < 25 || header.zoom_percent > 500 || + header.capture_audio > 1 || + (header.capture_audio && + (!header.audio_sink[0] || + !memchr(header.audio_sink, '\0', sizeof(header.audio_sink)))) || header.url_length > OWB_MAX_STRING || header.css_length > OWB_MAX_STRING) return false; config->width = header.width; config->height = header.height; config->fps = header.fps; config->zoom_percent = header.zoom_percent; + config->capture_audio = header.capture_audio != 0; config->transparent = header.transparent != 0; + memcpy(config->audio_sink, header.audio_sink, sizeof(config->audio_sink)); /* The protocol retains this legacy flag, but this renderer always enables the * WebKit compositor. */ (void)header.hardware_acceleration; @@ -699,9 +707,12 @@ static struct renderer *create_renderer(int client_fd, const int descriptors[4]) webkit_user_content_manager_add_style_sheet(manager, sheet); webkit_user_style_sheet_unref(sheet); } + WebKitWebsitePolicies *policies = webkit_website_policies_new_with_policies( + "autoplay", WEBKIT_AUTOPLAY_ALLOW, NULL); renderer->web_view = WEBKIT_WEB_VIEW(g_object_new( WEBKIT_TYPE_WEB_VIEW, "web-context", shared_context, - "user-content-manager", manager, NULL)); + "user-content-manager", manager, "website-policies", policies, NULL)); + g_object_unref(policies); g_signal_connect(renderer->web_view, "load-failed", G_CALLBACK(load_failed), renderer); g_object_unref(manager); WebKitSettings *settings = webkit_web_view_get_settings(renderer->web_view); @@ -714,7 +725,7 @@ static struct renderer *create_renderer(int client_fd, const int descriptors[4]) WEBKIT_HARDWARE_ACCELERATION_POLICY_ALWAYS); webkit_web_view_set_zoom_level(renderer->web_view, (double)renderer->config.zoom_percent / 100.0); - webkit_web_view_set_is_muted(renderer->web_view, TRUE); + webkit_web_view_set_is_muted(renderer->web_view, !renderer->config.capture_audio); GdkRGBA background = renderer->config.transparent ? (GdkRGBA){0, 0, 0, 0} : (GdkRGBA){1, 1, 1, 1}; webkit_web_view_set_background_color(renderer->web_view, &background); @@ -769,7 +780,29 @@ static struct renderer *create_renderer(int client_fd, const int descriptors[4]) renderer->client_source = g_unix_fd_add(renderer->client_fd, G_IO_IN | G_IO_HUP | G_IO_ERR | G_IO_NVAL, client_ready, renderer); char *uri = normalize_uri(renderer->config.url); + char *previous_pulse_sink = g_strdup(g_getenv("PULSE_SINK")); + char *previous_feature_rank = g_strdup(g_getenv("GST_PLUGIN_FEATURE_RANK")); + if (renderer->config.capture_audio) { + g_setenv("PULSE_SINK", renderer->config.audio_sink, TRUE); + char *audio_feature_rank = previous_feature_rank && *previous_feature_rank + ? g_strdup_printf("pipewiresink:NONE,pulsesink:MAX,%s", previous_feature_rank) + : g_strdup("pipewiresink:NONE,pulsesink:MAX"); + g_setenv("GST_PLUGIN_FEATURE_RANK", audio_feature_rank, TRUE); + g_free(audio_feature_rank); + } webkit_web_view_load_uri(renderer->web_view, uri); + if (previous_pulse_sink) { + g_setenv("PULSE_SINK", previous_pulse_sink, TRUE); + g_free(previous_pulse_sink); + } else { + g_unsetenv("PULSE_SINK"); + } + if (previous_feature_rank) { + g_setenv("GST_PLUGIN_FEATURE_RANK", previous_feature_rank, TRUE); + g_free(previous_feature_rank); + } else { + g_unsetenv("GST_PLUGIN_FEATURE_RANK"); + } g_free(uri); if (pthread_create(&renderer->capture_thread, NULL, capture_loop, renderer) != 0) { renderer_log(renderer, "Cannot start GPU capture thread\n"); diff --git a/tests/module-load-test.c b/tests/module-load-test.c index b7543f6..96b6aee 100644 --- a/tests/module-load-test.c +++ b/tests/module-load-test.c @@ -1,17 +1,158 @@ #include +#include +#include +#include +#include +#include #include #include #include +#include #include +#include +#include #include +struct audio_probe { + atomic_uint blocks; + atomic_bool signal; +}; + +static bool write_all(int fd, const void *data, size_t size) +{ + const uint8_t *cursor = data; + while (size) { + ssize_t written = write(fd, cursor, size); + if (written < 0 && errno == EINTR) + continue; + if (written <= 0) + return false; + cursor += (size_t)written; + size -= (size_t)written; + } + return true; +} + +static void write_u32_le(uint8_t *destination, uint32_t value) +{ + for (unsigned index = 0; index < 4; index++) + destination[index] = (uint8_t)(value >> (index * 8)); +} + +static void write_u16_le(uint8_t *destination, uint16_t value) +{ + destination[0] = (uint8_t)value; + destination[1] = (uint8_t)(value >> 8); +} + +static void serve_audio(int server) +{ + static const char page[] = + "" + "" + ""; + const uint32_t frames = 48000; + const uint32_t data_size = frames * 2 * sizeof(int16_t); + uint8_t wav_header[44] = {0}; + memcpy(wav_header, "RIFF", 4); + write_u32_le(wav_header + 4, 36 + data_size); + memcpy(wav_header + 8, "WAVEfmt ", 8); + write_u32_le(wav_header + 16, 16); + write_u16_le(wav_header + 20, 1); + write_u16_le(wav_header + 22, 2); + write_u32_le(wav_header + 24, 48000); + write_u32_le(wav_header + 28, 48000 * 2 * sizeof(int16_t)); + write_u16_le(wav_header + 32, 2 * sizeof(int16_t)); + write_u16_le(wav_header + 34, 16); + memcpy(wav_header + 36, "data", 4); + write_u32_le(wav_header + 40, data_size); + int16_t *samples = malloc(data_size); + if (!samples) + _exit(2); + for (uint32_t frame = 0; frame < frames; frame++) { + int16_t value = (frame / 55) % 2 ? 10000 : -10000; + samples[frame * 2] = value; + samples[frame * 2 + 1] = value; + } + + for (;;) { + int client = accept(server, NULL, NULL); + if (client < 0 && errno == EINTR) + continue; + if (client < 0) + _exit(0); + char request[2048] = {0}; + ssize_t count = read(client, request, sizeof(request) - 1); + bool wav = count > 0 && strstr(request, "GET /tone.wav "); + char response[512]; + size_t body_size = wav ? sizeof(wav_header) + data_size : sizeof(page) - 1; + int length = snprintf(response, sizeof(response), + "HTTP/1.1 200 OK\r\nContent-Type: %s\r\nCache-Control: no-store\r\n" + "Connection: close\r\nContent-Length: %zu\r\n\r\n", + wav ? "audio/wav" : "text/html", body_size); + write_all(client, response, (size_t)length); + if (wav) { + write_all(client, wav_header, sizeof(wav_header)); + write_all(client, samples, data_size); + } else { + write_all(client, page, sizeof(page) - 1); + } + close(client); + } +} + +static pid_t start_audio_server(char *url, size_t url_size) +{ + int server = socket(AF_INET, SOCK_STREAM, 0); + if (server < 0) + return -1; + struct sockaddr_in address = {.sin_family = AF_INET, .sin_addr.s_addr = htonl(INADDR_LOOPBACK)}; + if (bind(server, (struct sockaddr *)&address, sizeof(address)) != 0 || listen(server, 8) != 0) { + close(server); + return -1; + } + socklen_t size = sizeof(address); + if (getsockname(server, (struct sockaddr *)&address, &size) != 0) { + close(server); + return -1; + } + pid_t child = fork(); + if (child == 0) + serve_audio(server); + close(server); + if (child < 0) + return -1; + snprintf(url, url_size, "http://127.0.0.1:%u/", ntohs(address.sin_port)); + return child; +} + +static void audio_captured(void *opaque, obs_source_t *source, + const struct audio_data *audio, bool muted) +{ + (void)source; + (void)muted; + struct audio_probe *probe = opaque; + atomic_fetch_add(&probe->blocks, 1); + if (!audio->data[0]) + return; + const float *samples = (const float *)audio->data[0]; + for (uint32_t index = 0; index < audio->frames; index++) { + float value = samples[index]; + if (value > 0.01f || value < -0.01f) { + atomic_store(&probe->signal, true); + break; + } + } +} + int main(int argc, char **argv) { - if (argc != 4) { - fprintf(stderr, "usage: %s plugin.so data-directory renderer\n", argv[0]); + if (argc < 4 || argc > 5) { + fprintf(stderr, "usage: %s plugin.so data-directory renderer [audio-test]\n", argv[0]); return 2; } + bool audio_test = argc == 5 && strcmp(argv[4], "audio-test") == 0; char temporary[] = "/tmp/obs-webkit-module-test-XXXXXX"; if (!mkdtemp(temporary)) { perror("mkdtemp"); @@ -34,26 +175,43 @@ int main(int argc, char **argv) rmdir(temporary); return 77; } + struct obs_audio_info audio_info = {.samples_per_sec = 48000, .speakers = SPEAKERS_STEREO}; + if (!obs_reset_audio(&audio_info)) { + obs_shutdown(); + return 77; + } + char audio_url[128] = {0}; + pid_t audio_server = audio_test ? start_audio_server(audio_url, sizeof(audio_url)) : -1; + if (audio_test && audio_server < 0) { + obs_shutdown(); + return 77; + } obs_module_t *module = NULL; int result = obs_open_module(&module, argv[1], temporary); if (result != MODULE_SUCCESS || !obs_init_module(module)) { fprintf(stderr, "module load failed: %d\n", result); + if (audio_server > 0) { + kill(audio_server, SIGTERM); + waitpid(audio_server, NULL, 0); + } obs_shutdown(); return 1; } const char *name = obs_source_get_display_name("webkit_browser_source"); uint32_t flags = obs_get_source_output_flags("webkit_browser_source"); bool valid = name && strstr(name, "WebKit") && (flags & OBS_SOURCE_ASYNC_VIDEO) && - (flags & OBS_SOURCE_INTERACTION); + (flags & OBS_SOURCE_AUDIO) && (flags & OBS_SOURCE_INTERACTION); if (!valid) fprintf(stderr, "source registration is incomplete (name=%s, flags=0x%x)\n", name ? name : "(null)", flags); obs_data_t *settings = obs_data_create(); - obs_data_set_string(settings, "url", "data:text/html,"); + obs_data_set_string(settings, "url", audio_test ? audio_url : + "data:text/html,"); obs_data_set_int(settings, "width", 64); obs_data_set_int(settings, "height", 64); obs_data_set_int(settings, "fps", 10); obs_data_set_int(settings, "zoom", 175); + obs_data_set_bool(settings, "capture_audio", audio_test); obs_data_set_bool(settings, "transparent", true); obs_data_set_bool(settings, "hardware_acceleration", false); obs_source_t *source = obs_source_create_private("webkit_browser_source", "smoke", settings); @@ -64,21 +222,49 @@ int main(int argc, char **argv) } obs_properties_t *properties = source ? obs_source_properties(source) : NULL; obs_property_t *zoom = properties ? obs_properties_get(properties, "zoom") : NULL; + obs_property_t *capture_audio_property = + properties ? obs_properties_get(properties, "capture_audio") : NULL; if (!zoom || obs_property_get_type(zoom) != OBS_PROPERTY_INT || obs_property_int_type(zoom) != OBS_NUMBER_SLIDER || obs_property_int_min(zoom) != 25 || obs_property_int_max(zoom) != 500 || obs_property_int_step(zoom) != 5) { fprintf(stderr, "page zoom property is missing or invalid\n"); valid = false; } + if (!capture_audio_property || obs_property_get_type(capture_audio_property) != OBS_PROPERTY_BOOL || + !source || + obs_source_audio_active(source) != audio_test) { + fprintf(stderr, "browser audio capture property or inactive mixer state is invalid\n"); + valid = false; + } if (properties) obs_properties_destroy(properties); + struct audio_probe probe; + atomic_init(&probe.blocks, 0); + atomic_init(&probe.signal, false); + if (source && audio_test) + obs_source_add_audio_capture_callback(source, audio_captured, &probe); if (source) { - usleep(500000); + unsigned attempts = audio_test ? 100 : 5; + for (unsigned attempt = 0; attempt < attempts && + (!audio_test || !atomic_load(&probe.signal)); attempt++) + usleep(100000); + if (audio_test) { + obs_source_remove_audio_capture_callback(source, audio_captured, &probe); + if (!atomic_load(&probe.signal)) { + fprintf(stderr, "browser audio did not reach the OBS source (blocks=%u)\n", + atomic_load(&probe.blocks)); + valid = false; + } + } obs_source_release(source); } obs_shutdown(); unlink(renderer_link); unlink(locale_link); rmdir(temporary); + if (audio_server > 0) { + kill(audio_server, SIGTERM); + waitpid(audio_server, NULL, 0); + } return valid ? 0 : 1; } diff --git a/tests/protocol-test.c b/tests/protocol-test.c index 4f34072..cf388cb 100644 --- a/tests/protocol-test.c +++ b/tests/protocol-test.c @@ -5,8 +5,9 @@ int main(void) { - assert(sizeof(struct owb_config_header) == 40); + assert(sizeof(struct owb_config_header) == 108); assert(offsetof(struct owb_config_header, zoom_percent) == 20); + assert(offsetof(struct owb_config_header, audio_sink) == 44); assert(sizeof(struct owb_command) == 100); assert(offsetof(struct owb_shared_frames, pixels) == 40); assert(owb_shared_size(2, 2) == 72); diff --git a/tests/renderer-smoke-test.c b/tests/renderer-smoke-test.c index 979d370..9eb39a2 100644 --- a/tests/renderer-smoke-test.c +++ b/tests/renderer-smoke-test.c @@ -114,6 +114,7 @@ int main(int argc, char **argv) "background:rgb(241,23,17)}"; const char *url = external_url ? argv[3] : internal_url; const char *css = external_url ? "" : zoom ? zoom_css : opaque ? opaque_css : transparent_css; + const char *audio_sink = getenv("OWB_TEST_AUDIO_SINK"); struct owb_config_header config = { .magic = OWB_CONFIG_MAGIC, .version = OWB_PROTOCOL_VERSION, @@ -121,12 +122,15 @@ int main(int argc, char **argv) .height = height, .fps = 10, .zoom_percent = zoom ? 200 : 100, + .capture_audio = audio_sink && *audio_sink, .transparent = opaque ? 0 : 1, /* GPU rendering must still produce an invisible captured frame. */ .hardware_acceleration = 1, .url_length = (uint32_t)strlen(url), .css_length = (uint32_t)strlen(css), }; + if (audio_sink) + snprintf(config.audio_sink, sizeof(config.audio_sink), "%s", audio_sink); bool sent = write_exact(control[0], &config, sizeof(config)) && write_exact(control[0], url, strlen(url)) && write_exact(control[0], css, strlen(css));