Add sound capture

This commit is contained in:
2026-07-20 10:57:34 +05:00
parent 07bd12ee9a
commit eb13c87dd3
14 changed files with 539 additions and 11 deletions

View File

@@ -349,6 +349,7 @@ private:
header.height = static_cast<uint32_t>(renderHeight_);
header.fps = 30;
header.zoom_percent = static_cast<uint32_t>(zoomPercent_);
header.capture_audio = 0;
header.transparent = 0;
header.hardware_acceleration = 1;
header.url_length = static_cast<uint32_t>(url.size());

View File

@@ -1,6 +1,8 @@
#include <obs/obs-module.h>
#include <obs/obs-interaction.h>
#include <obs/util/platform.h>
#include <pulse/error.h>
#include <pulse/simple.h>
#include <errno.h>
#include <fcntl.h>
@@ -21,6 +23,7 @@
#include <unistd.h>
#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,

View File

@@ -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 {

174
src/pulse-capture.c Normal file
View 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
View 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);

View File

@@ -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");