Add sound capture
This commit is contained in:
@@ -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(GTK REQUIRED IMPORTED_TARGET gtk+-3.0)
|
||||||
pkg_check_modules(WEBKIT REQUIRED IMPORTED_TARGET webkit2gtk-4.1)
|
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(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
|
add_library(obs-webkit-browser MODULE
|
||||||
src/plugin.c
|
src/plugin.c
|
||||||
src/docks.cpp
|
src/docks.cpp
|
||||||
src/protocol.h
|
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 c_std_11)
|
||||||
target_compile_features(obs-webkit-browser PRIVATE cxx_std_17)
|
target_compile_features(obs-webkit-browser PRIVATE cxx_std_17)
|
||||||
@@ -29,6 +32,7 @@ target_link_libraries(obs-webkit-browser PRIVATE
|
|||||||
obs
|
obs
|
||||||
obs-frontend-api
|
obs-frontend-api
|
||||||
PkgConfig::QT_WIDGETS
|
PkgConfig::QT_WIDGETS
|
||||||
|
PkgConfig::PULSE
|
||||||
Threads::Threads
|
Threads::Threads
|
||||||
)
|
)
|
||||||
set_target_properties(obs-webkit-browser PROPERTIES PREFIX "")
|
set_target_properties(obs-webkit-browser PROPERTIES PREFIX "")
|
||||||
@@ -106,4 +110,9 @@ if(BUILD_TESTING)
|
|||||||
$<TARGET_FILE:obs-webkit-browser> "${CMAKE_CURRENT_SOURCE_DIR}/data"
|
$<TARGET_FILE:obs-webkit-browser> "${CMAKE_CURRENT_SOURCE_DIR}/data"
|
||||||
$<TARGET_FILE:obs-webkit-renderer>)
|
$<TARGET_FILE:obs-webkit-renderer>)
|
||||||
set_tests_properties(module-load-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
|
set_tests_properties(module-load-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
|
||||||
|
|
||||||
|
add_test(NAME module-audio-test COMMAND module-load-test
|
||||||
|
$<TARGET_FILE:obs-webkit-browser> "${CMAKE_CURRENT_SOURCE_DIR}/data"
|
||||||
|
$<TARGET_FILE:obs-webkit-renderer> audio-test)
|
||||||
|
set_tests_properties(module-audio-test PROPERTIES TIMEOUT 20 SKIP_RETURN_CODE 77)
|
||||||
endif()
|
endif()
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ DMA-BUF. Кадры передаются в OBS напрямую как BGRA, б
|
|||||||
- прозрачный фон с alpha-каналом;
|
- прозрачный фон с alpha-каналом;
|
||||||
- пользовательский CSS;
|
- пользовательский CSS;
|
||||||
- масштаб страницы 25–500% с сохранением в настройках источника или web-панели;
|
- масштаб страницы 25–500% с сохранением в настройках источника или web-панели;
|
||||||
|
- опциональный захват звука каждого browser source в отдельную полосу Audio Mixer OBS;
|
||||||
- размер 2–8192 px и 1–60 FPS;
|
- размер 2–8192 px и 1–60 FPS;
|
||||||
- аппаратный WebKit compositor через DRM/DMA-BUF (Mesa `radeonsi` на AMD);
|
- аппаратный WebKit compositor через DRM/DMA-BUF (Mesa `radeonsi` на AMD);
|
||||||
- прямой XComposite/XShm-захват скомпозированного GPU-кадра с alpha;
|
- прямой XComposite/XShm-захват скомпозированного GPU-кадра с alpha;
|
||||||
@@ -27,7 +28,11 @@ DMA-BUF. Кадры передаются в OBS напрямую как BGRA, б
|
|||||||
страницы одного origin взаимодействуют штатными `storage`-событиями и
|
страницы одного origin взаимодействуют штатными `storage`-событиями и
|
||||||
`BroadcastChannel`, как вкладки одного браузера.
|
`BroadcastChannel`, как вкладки одного браузера.
|
||||||
|
|
||||||
Звук страницы намеренно выключен: добавьте в OBS отдельный Application Audio Capture.
|
Захват звука по умолчанию выключен. Включите «Захватывать звук браузера» в
|
||||||
|
настройках нужного источника; его громкость появится отдельной полосой в Audio Mixer OBS.
|
||||||
|
Для захвата требуется работающий PulseAudio-совместимый сервер (в том числе
|
||||||
|
PipeWire Pulse) и GStreamer `pulsesink`; скрипт установки зависимостей добавляет
|
||||||
|
необходимый пакет `gst-plugins-good`.
|
||||||
Все WebView одной сессии OBS работают в общем renderer-процессе и одном
|
Все WebView одной сессии OBS работают в общем renderer-процессе и одном
|
||||||
`WebKitWebContext`; маленькие процессы-посредники сохраняют прежнюю независимую
|
`WebKitWebContext`; маленькие процессы-посредники сохраняют прежнюю независимую
|
||||||
жизнь каждого source/dock и изоляцию от OBS. Профиль хранится в стандартных каталогах
|
жизнь каждого source/dock и изоляцию от OBS. Профиль хранится в стандартных каталогах
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Width="Width"
|
|||||||
Height="Height"
|
Height="Height"
|
||||||
FPS="Frames per second"
|
FPS="Frames per second"
|
||||||
PageZoom="Page zoom (%)"
|
PageZoom="Page zoom (%)"
|
||||||
|
CaptureAudio="Capture browser audio"
|
||||||
Transparent="Transparent background"
|
Transparent="Transparent background"
|
||||||
CustomCSS="Custom CSS"
|
CustomCSS="Custom CSS"
|
||||||
ShutdownWhenHidden="Shut down renderer when source is hidden"
|
ShutdownWhenHidden="Shut down renderer when source is hidden"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ Width="Ширина"
|
|||||||
Height="Высота"
|
Height="Высота"
|
||||||
FPS="Кадров в секунду"
|
FPS="Кадров в секунду"
|
||||||
PageZoom="Масштаб страницы (%)"
|
PageZoom="Масштаб страницы (%)"
|
||||||
|
CaptureAudio="Захватывать звук браузера"
|
||||||
Transparent="Прозрачный фон"
|
Transparent="Прозрачный фон"
|
||||||
CustomCSS="Пользовательский CSS"
|
CustomCSS="Пользовательский CSS"
|
||||||
ShutdownWhenHidden="Останавливать рендерер, когда источник скрыт"
|
ShutdownWhenHidden="Останавливать рендерер, когда источник скрыт"
|
||||||
|
|||||||
@@ -18,5 +18,7 @@ $ROOT apk add \
|
|||||||
qt6-qtbase-dev \
|
qt6-qtbase-dev \
|
||||||
gtk+3.0 gtk+3.0-dev \
|
gtk+3.0 gtk+3.0-dev \
|
||||||
webkit2gtk-4.1 webkit2gtk-4.1-dev \
|
webkit2gtk-4.1 webkit2gtk-4.1-dev \
|
||||||
|
gst-plugins-good \
|
||||||
|
pulseaudio-dev \
|
||||||
libx11-dev libxcomposite-dev libxdamage-dev libxext-dev \
|
libx11-dev libxcomposite-dev libxdamage-dev libxext-dev \
|
||||||
mesa-dri-gallium
|
mesa-dri-gallium
|
||||||
|
|||||||
@@ -349,6 +349,7 @@ private:
|
|||||||
header.height = static_cast<uint32_t>(renderHeight_);
|
header.height = static_cast<uint32_t>(renderHeight_);
|
||||||
header.fps = 30;
|
header.fps = 30;
|
||||||
header.zoom_percent = static_cast<uint32_t>(zoomPercent_);
|
header.zoom_percent = static_cast<uint32_t>(zoomPercent_);
|
||||||
|
header.capture_audio = 0;
|
||||||
header.transparent = 0;
|
header.transparent = 0;
|
||||||
header.hardware_acceleration = 1;
|
header.hardware_acceleration = 1;
|
||||||
header.url_length = static_cast<uint32_t>(url.size());
|
header.url_length = static_cast<uint32_t>(url.size());
|
||||||
|
|||||||
91
src/plugin.c
91
src/plugin.c
@@ -1,6 +1,8 @@
|
|||||||
#include <obs/obs-module.h>
|
#include <obs/obs-module.h>
|
||||||
#include <obs/obs-interaction.h>
|
#include <obs/obs-interaction.h>
|
||||||
#include <obs/util/platform.h>
|
#include <obs/util/platform.h>
|
||||||
|
#include <pulse/error.h>
|
||||||
|
#include <pulse/simple.h>
|
||||||
|
|
||||||
#include <errno.h>
|
#include <errno.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
@@ -21,6 +23,7 @@
|
|||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
#include "protocol.h"
|
#include "protocol.h"
|
||||||
|
#include "pulse-capture.h"
|
||||||
|
|
||||||
OBS_DECLARE_MODULE()
|
OBS_DECLARE_MODULE()
|
||||||
OBS_MODULE_USE_DEFAULT_LOCALE("obs-webkit-browser", "en-US")
|
OBS_MODULE_USE_DEFAULT_LOCALE("obs-webkit-browser", "en-US")
|
||||||
@@ -51,8 +54,11 @@ struct renderer_process {
|
|||||||
size_t shared_size;
|
size_t shared_size;
|
||||||
pthread_t frame_thread;
|
pthread_t frame_thread;
|
||||||
pthread_t log_thread;
|
pthread_t log_thread;
|
||||||
|
pthread_t audio_thread;
|
||||||
bool frame_thread_started;
|
bool frame_thread_started;
|
||||||
bool log_thread_started;
|
bool log_thread_started;
|
||||||
|
bool audio_thread_started;
|
||||||
|
struct owb_pulse_sink pulse_sink;
|
||||||
atomic_bool stopping;
|
atomic_bool stopping;
|
||||||
uint32_t width;
|
uint32_t width;
|
||||||
uint32_t height;
|
uint32_t height;
|
||||||
@@ -69,6 +75,7 @@ struct browser_source {
|
|||||||
uint32_t height;
|
uint32_t height;
|
||||||
uint32_t fps;
|
uint32_t fps;
|
||||||
uint32_t zoom_percent;
|
uint32_t zoom_percent;
|
||||||
|
bool capture_audio;
|
||||||
bool transparent;
|
bool transparent;
|
||||||
bool shutdown_when_hidden;
|
bool shutdown_when_hidden;
|
||||||
bool visible;
|
bool visible;
|
||||||
@@ -196,6 +203,54 @@ static void *log_reader(void *opaque)
|
|||||||
return NULL;
|
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)
|
static void terminate_renderer_group(pid_t pid)
|
||||||
{
|
{
|
||||||
if (pid <= 0)
|
if (pid <= 0)
|
||||||
@@ -227,6 +282,8 @@ static void stop_renderer_locked(struct browser_source *browser)
|
|||||||
renderer->control_fd = -1;
|
renderer->control_fd = -1;
|
||||||
}
|
}
|
||||||
terminate_renderer_group(renderer->pid);
|
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) {
|
if (renderer->frame_fd >= 0) {
|
||||||
close(renderer->frame_fd);
|
close(renderer->frame_fd);
|
||||||
renderer->frame_fd = -1;
|
renderer->frame_fd = -1;
|
||||||
@@ -237,6 +294,8 @@ static void stop_renderer_locked(struct browser_source *browser)
|
|||||||
pthread_join(renderer->log_thread, NULL);
|
pthread_join(renderer->log_thread, NULL);
|
||||||
else if (renderer->log_fd >= 0)
|
else if (renderer->log_fd >= 0)
|
||||||
close(renderer->log_fd);
|
close(renderer->log_fd);
|
||||||
|
if (renderer->audio_thread_started)
|
||||||
|
pthread_join(renderer->audio_thread, NULL);
|
||||||
if (renderer->shared && renderer->shared != MAP_FAILED)
|
if (renderer->shared && renderer->shared != MAP_FAILED)
|
||||||
munmap(renderer->shared, renderer->shared_size);
|
munmap(renderer->shared, renderer->shared_size);
|
||||||
if (renderer->shared_fd >= 0)
|
if (renderer->shared_fd >= 0)
|
||||||
@@ -257,11 +316,14 @@ static bool send_initial_config(struct browser_source *browser, struct renderer_
|
|||||||
.height = browser->height,
|
.height = browser->height,
|
||||||
.fps = browser->fps,
|
.fps = browser->fps,
|
||||||
.zoom_percent = browser->zoom_percent,
|
.zoom_percent = browser->zoom_percent,
|
||||||
|
.capture_audio = browser->capture_audio,
|
||||||
.transparent = browser->transparent,
|
.transparent = browser->transparent,
|
||||||
.hardware_acceleration = true,
|
.hardware_acceleration = true,
|
||||||
.url_length = (uint32_t)url_length,
|
.url_length = (uint32_t)url_length,
|
||||||
.css_length = (uint32_t)css_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)) &&
|
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->url, url_length) &&
|
||||||
write_all_socket(renderer->control_fd, browser->css, css_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 frames[2] = {-1, -1};
|
||||||
int logs[2] = {-1, -1};
|
int logs[2] = {-1, -1};
|
||||||
int shared_fd = -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;
|
struct owb_shared_frames *shared = MAP_FAILED;
|
||||||
size_t shared_size = owb_shared_size(browser->width, browser->height);
|
size_t shared_size = owb_shared_size(browser->width, browser->height);
|
||||||
shared_fd = memfd_create("obs-webkit-browser-frames", MFD_CLOEXEC);
|
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_fd = shared_fd;
|
||||||
renderer->shared = shared;
|
renderer->shared = shared;
|
||||||
renderer->shared_size = shared_size;
|
renderer->shared_size = shared_size;
|
||||||
|
renderer->pulse_sink = pulse_sink;
|
||||||
|
pulse_sink.module_index = UINT32_MAX;
|
||||||
renderer->width = browser->width;
|
renderer->width = browser->width;
|
||||||
renderer->height = browser->height;
|
renderer->height = browser->height;
|
||||||
renderer->owner = browser;
|
renderer->owner = browser;
|
||||||
@@ -377,6 +451,7 @@ static bool start_renderer_locked(struct browser_source *browser)
|
|||||||
close(renderer->log_fd);
|
close(renderer->log_fd);
|
||||||
munmap(renderer->shared, renderer->shared_size);
|
munmap(renderer->shared, renderer->shared_size);
|
||||||
close(renderer->shared_fd);
|
close(renderer->shared_fd);
|
||||||
|
owb_pulse_sink_destroy(&renderer->pulse_sink);
|
||||||
free(renderer);
|
free(renderer);
|
||||||
goto fail;
|
goto fail;
|
||||||
}
|
}
|
||||||
@@ -384,12 +459,21 @@ static bool start_renderer_locked(struct browser_source *browser)
|
|||||||
renderer->frame_thread_started = true;
|
renderer->frame_thread_started = true;
|
||||||
if (pthread_create(&renderer->log_thread, NULL, log_reader, renderer) == 0)
|
if (pthread_create(&renderer->log_thread, NULL, log_reader, renderer) == 0)
|
||||||
renderer->log_thread_started = true;
|
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) {
|
if (!renderer->frame_thread_started) {
|
||||||
blog(LOG_ERROR, "[obs-webkit-browser] Cannot start frame reader thread");
|
blog(LOG_ERROR, "[obs-webkit-browser] Cannot start frame reader thread");
|
||||||
browser->renderer = renderer;
|
browser->renderer = renderer;
|
||||||
stop_renderer_locked(browser);
|
stop_renderer_locked(browser);
|
||||||
goto fail;
|
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;
|
browser->renderer = renderer;
|
||||||
blog(LOG_INFO, "[obs-webkit-browser] Started %ux%u@%u renderer", browser->width,
|
blog(LOG_INFO, "[obs-webkit-browser] Started %ux%u@%u renderer", browser->width,
|
||||||
browser->height, browser->fps);
|
browser->height, browser->fps);
|
||||||
@@ -410,6 +494,7 @@ fail:
|
|||||||
munmap(shared, shared_size);
|
munmap(shared, shared_size);
|
||||||
if (shared_fd >= 0)
|
if (shared_fd >= 0)
|
||||||
close(shared_fd);
|
close(shared_fd);
|
||||||
|
owb_pulse_sink_destroy(&pulse_sink);
|
||||||
return false;
|
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->height = setting_u32(settings, "height", 2, 8192);
|
||||||
browser->fps = setting_u32(settings, "fps", 1, 60);
|
browser->fps = setting_u32(settings, "fps", 1, 60);
|
||||||
browser->zoom_percent = setting_u32(settings, "zoom", 25, 500);
|
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->transparent = obs_data_get_bool(settings, "transparent");
|
||||||
browser->shutdown_when_hidden = obs_data_get_bool(settings, "shutdown_when_hidden");
|
browser->shutdown_when_hidden = obs_data_get_bool(settings, "shutdown_when_hidden");
|
||||||
stop_renderer_locked(browser);
|
stop_renderer_locked(browser);
|
||||||
start_renderer_locked(browser);
|
start_renderer_locked(browser);
|
||||||
pthread_mutex_unlock(&browser->mutex);
|
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)
|
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, "height", 720);
|
||||||
obs_data_set_default_int(settings, "fps", 30);
|
obs_data_set_default_int(settings, "fps", 30);
|
||||||
obs_data_set_default_int(settings, "zoom", 100);
|
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, "transparent", true);
|
||||||
obs_data_set_default_bool(settings, "shutdown_when_hidden", false);
|
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, "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(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_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_bool(props, "transparent", obs_module_text("Transparent"));
|
||||||
obs_properties_add_text(props, "css", obs_module_text("CustomCSS"), OBS_TEXT_MULTILINE);
|
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"));
|
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",
|
.id = "webkit_browser_source",
|
||||||
.type = OBS_SOURCE_TYPE_INPUT,
|
.type = OBS_SOURCE_TYPE_INPUT,
|
||||||
.icon_type = OBS_ICON_TYPE_BROWSER,
|
.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,
|
.get_name = browser_name,
|
||||||
.create = browser_create,
|
.create = browser_create,
|
||||||
.destroy = browser_destroy,
|
.destroy = browser_destroy,
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
|
|
||||||
#define OWB_CONFIG_MAGIC 0x4f574243u /* OWBC */
|
#define OWB_CONFIG_MAGIC 0x4f574243u /* OWBC */
|
||||||
#define OWB_COMMAND_MAGIC 0x4f574245u /* OWBE */
|
#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_MAX_STRING (16u * 1024u * 1024u)
|
||||||
|
#define OWB_AUDIO_SINK_NAME_SIZE 64u
|
||||||
#define OWB_SHARED_MAGIC 0x4f574246u /* OWBF */
|
#define OWB_SHARED_MAGIC 0x4f574246u /* OWBF */
|
||||||
#define OWB_SHARED_FRAME_FD 198
|
#define OWB_SHARED_FRAME_FD 198
|
||||||
#define OWB_SHARED_FRAME_SLOTS 2u
|
#define OWB_SHARED_FRAME_SLOTS 2u
|
||||||
@@ -18,10 +19,12 @@ struct owb_config_header {
|
|||||||
uint32_t height;
|
uint32_t height;
|
||||||
uint32_t fps;
|
uint32_t fps;
|
||||||
uint32_t zoom_percent;
|
uint32_t zoom_percent;
|
||||||
|
uint32_t capture_audio;
|
||||||
uint32_t transparent;
|
uint32_t transparent;
|
||||||
uint32_t hardware_acceleration;
|
uint32_t hardware_acceleration;
|
||||||
uint32_t url_length;
|
uint32_t url_length;
|
||||||
uint32_t css_length;
|
uint32_t css_length;
|
||||||
|
char audio_sink[OWB_AUDIO_SINK_NAME_SIZE];
|
||||||
};
|
};
|
||||||
|
|
||||||
struct owb_shared_frames {
|
struct owb_shared_frames {
|
||||||
|
|||||||
174
src/pulse-capture.c
Normal file
174
src/pulse-capture.c
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
#include "pulse-capture.h"
|
||||||
|
|
||||||
|
#include <pulse/error.h>
|
||||||
|
#include <pulse/introspect.h>
|
||||||
|
#include <pulse/mainloop.h>
|
||||||
|
#include <pulse/pulseaudio.h>
|
||||||
|
|
||||||
|
#include <stdatomic.h>
|
||||||
|
#include <stdio.h>
|
||||||
|
#include <string.h>
|
||||||
|
#include <time.h>
|
||||||
|
#include <unistd.h>
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
19
src/pulse-capture.h
Normal file
19
src/pulse-capture.h
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
#pragma once
|
||||||
|
|
||||||
|
#include <stdbool.h>
|
||||||
|
#include <stdint.h>
|
||||||
|
|
||||||
|
#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);
|
||||||
|
|
||||||
@@ -36,7 +36,9 @@ struct config {
|
|||||||
uint32_t height;
|
uint32_t height;
|
||||||
uint32_t fps;
|
uint32_t fps;
|
||||||
uint32_t zoom_percent;
|
uint32_t zoom_percent;
|
||||||
|
bool capture_audio;
|
||||||
bool transparent;
|
bool transparent;
|
||||||
|
char audio_sink[OWB_AUDIO_SINK_NAME_SIZE];
|
||||||
char *url;
|
char *url;
|
||||||
char *css;
|
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.version != OWB_PROTOCOL_VERSION || header.width < 2 || header.height < 2 ||
|
||||||
header.width > 8192 || header.height > 8192 || header.fps < 1 || header.fps > 60 ||
|
header.width > 8192 || header.height > 8192 || header.fps < 1 || header.fps > 60 ||
|
||||||
header.zoom_percent < 25 || header.zoom_percent > 500 ||
|
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)
|
header.url_length > OWB_MAX_STRING || header.css_length > OWB_MAX_STRING)
|
||||||
return false;
|
return false;
|
||||||
config->width = header.width;
|
config->width = header.width;
|
||||||
config->height = header.height;
|
config->height = header.height;
|
||||||
config->fps = header.fps;
|
config->fps = header.fps;
|
||||||
config->zoom_percent = header.zoom_percent;
|
config->zoom_percent = header.zoom_percent;
|
||||||
|
config->capture_audio = header.capture_audio != 0;
|
||||||
config->transparent = header.transparent != 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
|
/* The protocol retains this legacy flag, but this renderer always enables the
|
||||||
* WebKit compositor. */
|
* WebKit compositor. */
|
||||||
(void)header.hardware_acceleration;
|
(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_content_manager_add_style_sheet(manager, sheet);
|
||||||
webkit_user_style_sheet_unref(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(
|
renderer->web_view = WEBKIT_WEB_VIEW(g_object_new(
|
||||||
WEBKIT_TYPE_WEB_VIEW, "web-context", shared_context,
|
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_signal_connect(renderer->web_view, "load-failed", G_CALLBACK(load_failed), renderer);
|
||||||
g_object_unref(manager);
|
g_object_unref(manager);
|
||||||
WebKitSettings *settings = webkit_web_view_get_settings(renderer->web_view);
|
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_HARDWARE_ACCELERATION_POLICY_ALWAYS);
|
||||||
webkit_web_view_set_zoom_level(renderer->web_view,
|
webkit_web_view_set_zoom_level(renderer->web_view,
|
||||||
(double)renderer->config.zoom_percent / 100.0);
|
(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};
|
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);
|
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,
|
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);
|
G_IO_IN | G_IO_HUP | G_IO_ERR | G_IO_NVAL, client_ready, renderer);
|
||||||
char *uri = normalize_uri(renderer->config.url);
|
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);
|
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);
|
g_free(uri);
|
||||||
if (pthread_create(&renderer->capture_thread, NULL, capture_loop, renderer) != 0) {
|
if (pthread_create(&renderer->capture_thread, NULL, capture_loop, renderer) != 0) {
|
||||||
renderer_log(renderer, "Cannot start GPU capture thread\n");
|
renderer_log(renderer, "Cannot start GPU capture thread\n");
|
||||||
|
|||||||
@@ -1,17 +1,158 @@
|
|||||||
#include <obs/obs.h>
|
#include <obs/obs.h>
|
||||||
|
|
||||||
|
#include <arpa/inet.h>
|
||||||
|
#include <errno.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <stdatomic.h>
|
||||||
|
#include <stdint.h>
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
|
#include <sys/socket.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
|
#include <sys/types.h>
|
||||||
|
#include <sys/wait.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
||||||
|
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[] =
|
||||||
|
"<!doctype html><style>html,body{margin:0;width:100%;height:100%;background:#123}</style>"
|
||||||
|
"<audio id='tone' autoplay loop src='/tone.wav'></audio>"
|
||||||
|
"<script>tone.play().catch(e=>console.error('audio play failed',e))</script>";
|
||||||
|
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)
|
int main(int argc, char **argv)
|
||||||
{
|
{
|
||||||
if (argc != 4) {
|
if (argc < 4 || argc > 5) {
|
||||||
fprintf(stderr, "usage: %s plugin.so data-directory renderer\n", argv[0]);
|
fprintf(stderr, "usage: %s plugin.so data-directory renderer [audio-test]\n", argv[0]);
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
bool audio_test = argc == 5 && strcmp(argv[4], "audio-test") == 0;
|
||||||
char temporary[] = "/tmp/obs-webkit-module-test-XXXXXX";
|
char temporary[] = "/tmp/obs-webkit-module-test-XXXXXX";
|
||||||
if (!mkdtemp(temporary)) {
|
if (!mkdtemp(temporary)) {
|
||||||
perror("mkdtemp");
|
perror("mkdtemp");
|
||||||
@@ -34,26 +175,43 @@ int main(int argc, char **argv)
|
|||||||
rmdir(temporary);
|
rmdir(temporary);
|
||||||
return 77;
|
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;
|
obs_module_t *module = NULL;
|
||||||
int result = obs_open_module(&module, argv[1], temporary);
|
int result = obs_open_module(&module, argv[1], temporary);
|
||||||
if (result != MODULE_SUCCESS || !obs_init_module(module)) {
|
if (result != MODULE_SUCCESS || !obs_init_module(module)) {
|
||||||
fprintf(stderr, "module load failed: %d\n", result);
|
fprintf(stderr, "module load failed: %d\n", result);
|
||||||
|
if (audio_server > 0) {
|
||||||
|
kill(audio_server, SIGTERM);
|
||||||
|
waitpid(audio_server, NULL, 0);
|
||||||
|
}
|
||||||
obs_shutdown();
|
obs_shutdown();
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
const char *name = obs_source_get_display_name("webkit_browser_source");
|
const char *name = obs_source_get_display_name("webkit_browser_source");
|
||||||
uint32_t flags = obs_get_source_output_flags("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) &&
|
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)
|
if (!valid)
|
||||||
fprintf(stderr, "source registration is incomplete (name=%s, flags=0x%x)\n",
|
fprintf(stderr, "source registration is incomplete (name=%s, flags=0x%x)\n",
|
||||||
name ? name : "(null)", flags);
|
name ? name : "(null)", flags);
|
||||||
obs_data_t *settings = obs_data_create();
|
obs_data_t *settings = obs_data_create();
|
||||||
obs_data_set_string(settings, "url", "data:text/html,<body style='background:red'></body>");
|
obs_data_set_string(settings, "url", audio_test ? audio_url :
|
||||||
|
"data:text/html,<body style='background:red'></body>");
|
||||||
obs_data_set_int(settings, "width", 64);
|
obs_data_set_int(settings, "width", 64);
|
||||||
obs_data_set_int(settings, "height", 64);
|
obs_data_set_int(settings, "height", 64);
|
||||||
obs_data_set_int(settings, "fps", 10);
|
obs_data_set_int(settings, "fps", 10);
|
||||||
obs_data_set_int(settings, "zoom", 175);
|
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, "transparent", true);
|
||||||
obs_data_set_bool(settings, "hardware_acceleration", false);
|
obs_data_set_bool(settings, "hardware_acceleration", false);
|
||||||
obs_source_t *source = obs_source_create_private("webkit_browser_source", "smoke", settings);
|
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_properties_t *properties = source ? obs_source_properties(source) : NULL;
|
||||||
obs_property_t *zoom = properties ? obs_properties_get(properties, "zoom") : 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 ||
|
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_type(zoom) != OBS_NUMBER_SLIDER || obs_property_int_min(zoom) != 25 ||
|
||||||
obs_property_int_max(zoom) != 500 || obs_property_int_step(zoom) != 5) {
|
obs_property_int_max(zoom) != 500 || obs_property_int_step(zoom) != 5) {
|
||||||
fprintf(stderr, "page zoom property is missing or invalid\n");
|
fprintf(stderr, "page zoom property is missing or invalid\n");
|
||||||
valid = false;
|
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)
|
if (properties)
|
||||||
obs_properties_destroy(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) {
|
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_source_release(source);
|
||||||
}
|
}
|
||||||
obs_shutdown();
|
obs_shutdown();
|
||||||
unlink(renderer_link);
|
unlink(renderer_link);
|
||||||
unlink(locale_link);
|
unlink(locale_link);
|
||||||
rmdir(temporary);
|
rmdir(temporary);
|
||||||
|
if (audio_server > 0) {
|
||||||
|
kill(audio_server, SIGTERM);
|
||||||
|
waitpid(audio_server, NULL, 0);
|
||||||
|
}
|
||||||
return valid ? 0 : 1;
|
return valid ? 0 : 1;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
|
|
||||||
int main(void)
|
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, zoom_percent) == 20);
|
||||||
|
assert(offsetof(struct owb_config_header, audio_sink) == 44);
|
||||||
assert(sizeof(struct owb_command) == 100);
|
assert(sizeof(struct owb_command) == 100);
|
||||||
assert(offsetof(struct owb_shared_frames, pixels) == 40);
|
assert(offsetof(struct owb_shared_frames, pixels) == 40);
|
||||||
assert(owb_shared_size(2, 2) == 72);
|
assert(owb_shared_size(2, 2) == 72);
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ int main(int argc, char **argv)
|
|||||||
"background:rgb(241,23,17)}";
|
"background:rgb(241,23,17)}";
|
||||||
const char *url = external_url ? argv[3] : internal_url;
|
const char *url = external_url ? argv[3] : internal_url;
|
||||||
const char *css = external_url ? "" : zoom ? zoom_css : opaque ? opaque_css : transparent_css;
|
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 = {
|
struct owb_config_header config = {
|
||||||
.magic = OWB_CONFIG_MAGIC,
|
.magic = OWB_CONFIG_MAGIC,
|
||||||
.version = OWB_PROTOCOL_VERSION,
|
.version = OWB_PROTOCOL_VERSION,
|
||||||
@@ -121,12 +122,15 @@ int main(int argc, char **argv)
|
|||||||
.height = height,
|
.height = height,
|
||||||
.fps = 10,
|
.fps = 10,
|
||||||
.zoom_percent = zoom ? 200 : 100,
|
.zoom_percent = zoom ? 200 : 100,
|
||||||
|
.capture_audio = audio_sink && *audio_sink,
|
||||||
.transparent = opaque ? 0 : 1,
|
.transparent = opaque ? 0 : 1,
|
||||||
/* GPU rendering must still produce an invisible captured frame. */
|
/* GPU rendering must still produce an invisible captured frame. */
|
||||||
.hardware_acceleration = 1,
|
.hardware_acceleration = 1,
|
||||||
.url_length = (uint32_t)strlen(url),
|
.url_length = (uint32_t)strlen(url),
|
||||||
.css_length = (uint32_t)strlen(css),
|
.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)) &&
|
bool sent = write_exact(control[0], &config, sizeof(config)) &&
|
||||||
write_exact(control[0], url, strlen(url)) &&
|
write_exact(control[0], url, strlen(url)) &&
|
||||||
write_exact(control[0], css, strlen(css));
|
write_exact(control[0], css, strlen(css));
|
||||||
|
|||||||
Reference in New Issue
Block a user