From 4d25fde40080c4fb55583b23703a8bbc8b507924 Mon Sep 17 00:00:00 2001
From: Dimitris Papaioannou <dimtpap@protonmail.com>
Date: Sun, 26 Jun 2022 17:14:24 +0300
Subject: [PATCH] linux-pipewire: Add PipeWire audio captures

---
 plugins/linux-pipewire/CMakeLists.txt         |   6 +-
 plugins/linux-pipewire/data/locale/en-US.ini  |   6 +
 plugins/linux-pipewire/linux-pipewire.c       |   5 +
 .../pipewire-audio-capture-app.c              | 918 ++++++++++++++++++
 .../pipewire-audio-capture-device.c           | 520 ++++++++++
 plugins/linux-pipewire/pipewire-audio.c       | 574 +++++++++++
 plugins/linux-pipewire/pipewire-audio.h       | 155 +++
 7 files changed, 2183 insertions(+), 1 deletion(-)
 create mode 100644 plugins/linux-pipewire/pipewire-audio-capture-app.c
 create mode 100644 plugins/linux-pipewire/pipewire-audio-capture-device.c
 create mode 100644 plugins/linux-pipewire/pipewire-audio.c
 create mode 100644 plugins/linux-pipewire/pipewire-audio.h

diff --git a/plugins/linux-pipewire/CMakeLists.txt b/plugins/linux-pipewire/CMakeLists.txt
index 31a55e68b60d7..1e23a3163d26b 100644
--- a/plugins/linux-pipewire/CMakeLists.txt
+++ b/plugins/linux-pipewire/CMakeLists.txt
@@ -38,7 +38,11 @@ target_sources(
           portal.c
           portal.h
           screencast-portal.c
-          screencast-portal.h)
+          screencast-portal.h
+          pipewire-audio.c
+          pipewire-audio.h
+          pipewire-audio-capture-device.c
+          pipewire-audio-capture-app.c)
 
 target_link_libraries(
   linux-pipewire PRIVATE OBS::libobs OBS::obsglad PipeWire::PipeWire GIO::GIO
diff --git a/plugins/linux-pipewire/data/locale/en-US.ini b/plugins/linux-pipewire/data/locale/en-US.ini
index a9e222a995686..edde250b57c76 100644
--- a/plugins/linux-pipewire/data/locale/en-US.ini
+++ b/plugins/linux-pipewire/data/locale/en-US.ini
@@ -3,3 +3,9 @@ PipeWireSelectMonitor="Select Monitor"
 PipeWireSelectWindow="Select Window"
 PipeWireWindowCapture="Window Capture (PipeWire)"
 ShowCursor="Show Cursor"
+PipeWireAudioCaptureInput="Audio Input Capture (PipeWire)"
+PipeWireAudioCaptureOutput="Audio Output Capture (PipeWire)"
+PipeWireAudioCaptureApplication="Application Audio Capture (PipeWire)"
+Device="Device"
+Application="Application"
+ExceptApp="Capture all apps except selected"
diff --git a/plugins/linux-pipewire/linux-pipewire.c b/plugins/linux-pipewire/linux-pipewire.c
index 798ae2fe8ca9f..253256dfd469f 100644
--- a/plugins/linux-pipewire/linux-pipewire.c
+++ b/plugins/linux-pipewire/linux-pipewire.c
@@ -2,6 +2,7 @@
  *
  * Copyright 2021 columbarius <co1umbarius@protonmail.com>
  * Copyright 2021 Georges Basile Stavracas Neto <georges.stavracas@gmail.com>
+ * Copyright 2022 Dimitris Papaioannou <dimtpap@protonmail.com>
  *
  * This program is free software: you can redistribute it and/or modify
  * it under the terms of the GNU General Public License as published by
@@ -24,6 +25,7 @@
 
 #include <pipewire/pipewire.h>
 #include "screencast-portal.h"
+#include "pipewire-audio.h"
 
 OBS_DECLARE_MODULE()
 OBS_MODULE_USE_DEFAULT_LOCALE("linux-pipewire", "en-US")
@@ -38,6 +40,9 @@ bool obs_module_load(void)
 
 	screencast_portal_load();
 
+	pipewire_audio_capture_load();
+	pipewire_audio_capture_app_load();
+
 	return true;
 }
 
diff --git a/plugins/linux-pipewire/pipewire-audio-capture-app.c b/plugins/linux-pipewire/pipewire-audio-capture-app.c
new file mode 100644
index 0000000000000..461d2c8f55f26
--- /dev/null
+++ b/plugins/linux-pipewire/pipewire-audio-capture-app.c
@@ -0,0 +1,918 @@
+/* pipewire-audio-capture-app.c
+ *
+ * Copyright 2022 Dimitris Papaioannou <dimtpap@protonmail.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#include "pipewire-audio.h"
+
+#include <util/dstr.h>
+
+/* Source for capturing applciation audio using PipeWire */
+
+struct target_node_port {
+	const char *channel;
+	uint32_t id;
+
+	struct obs_pw_audio_proxied_object obj;
+};
+
+struct target_node {
+	const char *name;
+	const char *app_name;
+	const char *binary;
+	uint32_t id;
+	struct spa_list ports;
+	uint32_t *p_n_targets;
+
+	struct spa_hook node_listener;
+
+	struct obs_pw_audio_proxied_object obj;
+};
+
+struct system_sink {
+	const char *name;
+	uint32_t id;
+
+	struct obs_pw_audio_proxied_object obj;
+};
+
+struct capture_sink_link {
+	uint32_t id;
+
+	struct obs_pw_audio_proxied_object obj;
+};
+
+struct capture_sink_port {
+	const char *channel;
+	uint32_t id;
+};
+
+/** This source basically works like this:
+    - Keep track of output streams and their ports, system sinks and the default sink
+
+    - Keep track of the channels of the default system sink and create a new virtual sink,
+      destroying the previously made one, with the same channels, then connect the stream to it
+
+    - Connect any registered or new stream ports to the sink
+*/
+struct obs_pw_audio_capture_app {
+	struct obs_pw_audio_instance pw;
+
+	/** The app capture sink automatically mixes
+	  * the audio of all the app streams */
+	struct {
+		struct pw_proxy *proxy;
+		struct spa_hook proxy_listener;
+		bool autoconnect_targets;
+		uint32_t id;
+		uint32_t channels;
+		struct dstr position;
+		DARRAY(struct capture_sink_port) ports;
+
+		/* Links between app streams and the capture sink */
+		struct spa_list links;
+	} sink;
+
+	/** Need the default system sink to create
+	  * the app capture sink with the same audio channels */
+	struct spa_list system_sinks;
+	struct {
+		struct obs_pw_audio_default_node_metadata metadata;
+		struct pw_proxy *proxy;
+		struct spa_hook node_listener;
+		struct spa_hook proxy_listener;
+	} default_sink;
+
+	struct spa_list targets;
+	uint32_t n_targets;
+
+	struct dstr target;
+	bool except_app;
+};
+
+/* System sinks */
+static void system_sink_destroy_cb(void *data)
+{
+	struct system_sink *s = data;
+	bfree((void *)s->name);
+}
+
+static void register_system_sink(struct obs_pw_audio_capture_app *pwac,
+				 uint32_t global_id, const char *name)
+{
+	struct pw_proxy *sink_proxy =
+		pw_registry_bind(pwac->pw.registry, global_id,
+				 PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, 0);
+	if (!sink_proxy) {
+		return;
+	}
+
+	struct system_sink *sink = bmalloc(sizeof(struct system_sink));
+	sink->name = bstrdup(name);
+	sink->id = global_id;
+
+	obs_pw_audio_proxied_object_init(&sink->obj, sink_proxy,
+					 &pwac->system_sinks, NULL,
+					 system_sink_destroy_cb, sink);
+}
+/* ------------------------------------------------- */
+
+/* Target nodes and ports */
+static void port_destroy_cb(void *data)
+{
+	struct target_node_port *p = data;
+	bfree((void *)p->channel);
+}
+
+static void node_destroy_cb(void *data)
+{
+	struct target_node *node = data;
+
+	spa_hook_remove(&node->node_listener);
+
+	struct target_node_port *p, *tp;
+	spa_list_for_each_safe(p, tp, &node->ports, obj.link)
+	{
+		pw_proxy_destroy(p->obj.proxy);
+	}
+
+	(*node->p_n_targets)--;
+
+	bfree((void *)node->binary);
+	bfree((void *)node->app_name);
+	bfree((void *)node->name);
+}
+
+static struct target_node_port *node_register_port(struct target_node *node,
+						   uint32_t global_id,
+						   struct pw_registry *registry,
+						   const char *channel)
+{
+	struct pw_proxy *port_proxy = pw_registry_bind(registry, global_id,
+						       PW_TYPE_INTERFACE_Port,
+						       PW_VERSION_PORT, 0);
+	if (!port_proxy) {
+		return NULL;
+	}
+
+	struct target_node_port *port =
+		bmalloc(sizeof(struct target_node_port));
+	port->channel = bstrdup(channel);
+	port->id = global_id;
+
+	obs_pw_audio_proxied_object_init(&port->obj, port_proxy, &node->ports,
+					 NULL, port_destroy_cb, port);
+
+	return port;
+}
+
+static void on_node_info_cb(void *data, const struct pw_node_info *info)
+{
+	if ((info->change_mask & PW_NODE_CHANGE_MASK_PROPS) == 0 ||
+	    !info->props || !info->props->n_items) {
+		return;
+	}
+
+	const char *binary =
+		spa_dict_lookup(info->props, PW_KEY_APP_PROCESS_BINARY);
+	if (!binary) {
+		return;
+	}
+
+	struct target_node *node = data;
+	bfree((void *)node->binary);
+	node->binary = bstrdup(binary);
+}
+
+static const struct pw_node_events node_events = {
+	PW_VERSION_NODE_EVENTS,
+	.info = on_node_info_cb,
+};
+
+static void register_target_node(struct obs_pw_audio_capture_app *pwac,
+				 uint32_t global_id, const char *app_name,
+				 const char *name)
+{
+	struct pw_proxy *node_proxy =
+		pw_registry_bind(pwac->pw.registry, global_id,
+				 PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, 0);
+	if (!node_proxy) {
+		return;
+	}
+
+	struct target_node *node = bmalloc(sizeof(struct target_node));
+	node->name = bstrdup(name);
+	node->app_name = bstrdup(app_name);
+	node->binary = NULL;
+	node->id = global_id;
+	node->p_n_targets = &pwac->n_targets;
+	spa_list_init(&node->ports);
+
+	pwac->n_targets++;
+
+	obs_pw_audio_proxied_object_init(&node->obj, node_proxy, &pwac->targets,
+					 NULL, node_destroy_cb, node);
+	pw_proxy_add_object_listener(node_proxy, &node->node_listener,
+				     &node_events, node);
+}
+
+static bool node_is_targeted(struct obs_pw_audio_capture_app *pwac,
+			     struct target_node *node)
+{
+	if (dstr_is_empty(&pwac->target)) {
+		return false;
+	}
+
+	return (dstr_cmpi(&pwac->target, node->binary) == 0 ||
+		dstr_cmpi(&pwac->target, node->app_name) == 0 ||
+		dstr_cmpi(&pwac->target, node->name) == 0) ^
+	       pwac->except_app;
+}
+/* ------------------------------------------------- */
+
+/* App streams <-> Capture sink links */
+static void link_bound_cb(void *data, uint32_t global_id)
+{
+	struct capture_sink_link *link = data;
+	link->id = global_id;
+}
+
+static void link_destroy_cb(void *data)
+{
+	struct capture_sink_link *link = data;
+	blog(LOG_DEBUG, "[pipewire] Link %u destroyed", link->id);
+}
+
+static void link_port_to_sink(struct obs_pw_audio_capture_app *pwac,
+			      struct target_node_port *port, uint32_t node_id)
+{
+	blog(LOG_DEBUG,
+	     "[pipewire] Connecting port %u of node %u to app capture sink",
+	     port->id, node_id);
+
+	uint32_t p = 0;
+	if (pwac->sink.channels == 1 && /* Mono capture sink */
+	    pwac->sink.ports.num >= 1) {
+		p = pwac->sink.ports.array[0].id;
+	} else {
+		for (size_t i = 0; i < pwac->sink.ports.num; i++) {
+			if (astrcmpi(pwac->sink.ports.array[i].channel,
+				     port->channel) == 0) {
+				p = pwac->sink.ports.array[i].id;
+				break;
+			}
+		}
+	}
+
+	if (!p) {
+		blog(LOG_WARNING,
+		     "[pipewire] Could not connect port %u of node %u to app capture sink. No port of app capture sink has channel %s",
+		     port->id, node_id, port->channel);
+		return;
+	}
+
+	struct pw_properties *link_props =
+		pw_properties_new(PW_KEY_OBJECT_LINGER, "false",
+				  PW_KEY_FACTORY_NAME, "link-factory", NULL);
+
+	pw_properties_setf(link_props, PW_KEY_LINK_OUTPUT_NODE, "%u", node_id);
+	pw_properties_setf(link_props, PW_KEY_LINK_OUTPUT_PORT, "%u", port->id);
+
+	pw_properties_setf(link_props, PW_KEY_LINK_INPUT_NODE, "%u",
+			   pwac->sink.id);
+	pw_properties_setf(link_props, PW_KEY_LINK_INPUT_PORT, "%u", p);
+
+	struct pw_proxy *link_proxy = pw_core_create_object(
+		pwac->pw.core, "link-factory", PW_TYPE_INTERFACE_Link,
+		PW_VERSION_LINK, &link_props->dict, 0);
+
+	obs_pw_audio_instance_sync(&pwac->pw);
+
+	pw_properties_free(link_props);
+
+	if (!link_proxy) {
+		blog(LOG_WARNING,
+		     "[pipewire] Could not connect port %u of node %u to app capture sink",
+		     port->id, node_id);
+		return;
+	}
+
+	struct capture_sink_link *link =
+		bmalloc(sizeof(struct capture_sink_link));
+	link->id = SPA_ID_INVALID;
+
+	obs_pw_audio_proxied_object_init(&link->obj, link_proxy,
+					 &pwac->sink.links, link_bound_cb,
+					 link_destroy_cb, link);
+}
+
+static void link_node_to_sink(struct obs_pw_audio_capture_app *pwac,
+			      struct target_node *node)
+{
+	struct target_node_port *p;
+	spa_list_for_each(p, &node->ports, obj.link)
+	{
+		link_port_to_sink(pwac, p, node->id);
+	}
+}
+/* ------------------------------------------------- */
+
+/* App capture sink */
+
+/** The app capture sink is created when there
+  * is info about the system's default sink.
+  * See the on_metadata and on_default_sink callbacks */
+
+static void on_sink_proxy_bound_cb(void *data, uint32_t global_id)
+{
+	struct obs_pw_audio_capture_app *pwac = data;
+	pwac->sink.id = global_id;
+	da_init(pwac->sink.ports);
+}
+
+static void on_sink_proxy_removed_cb(void *data)
+{
+	struct obs_pw_audio_capture_app *pwac = data;
+	blog(LOG_WARNING,
+	     "[pipewire] App capture sink %u has been destroyed by the PipeWire remote",
+	     pwac->sink.id);
+	pw_proxy_destroy(pwac->sink.proxy);
+}
+
+static void on_sink_proxy_destroy_cb(void *data)
+{
+	struct obs_pw_audio_capture_app *pwac = data;
+
+	spa_hook_remove(&pwac->sink.proxy_listener);
+	spa_zero(pwac->sink.proxy_listener);
+
+	for (size_t i = 0; i < pwac->sink.ports.num; i++) {
+		struct capture_sink_port *p = &pwac->sink.ports.array[i];
+		bfree((void *)p->channel);
+	}
+	da_free(pwac->sink.ports);
+
+	pwac->sink.channels = 0;
+	dstr_free(&pwac->sink.position);
+
+	pwac->sink.autoconnect_targets = false;
+	pwac->sink.proxy = NULL;
+
+	blog(LOG_DEBUG, "[pipewire] App capture sink %u destroyed",
+	     pwac->sink.id);
+
+	pwac->sink.id = SPA_ID_INVALID;
+}
+
+static void on_sink_proxy_error_cb(void *data, int seq, int res,
+				   const char *message)
+{
+	UNUSED_PARAMETER(data);
+	blog(LOG_ERROR, "[pipewire] App capture sink error: seq:%d res:%d :%s",
+	     seq, res, message);
+}
+
+static const struct pw_proxy_events sink_proxy_events = {
+	PW_VERSION_PROXY_EVENTS,
+	.bound = on_sink_proxy_bound_cb,
+	.removed = on_sink_proxy_removed_cb,
+	.destroy = on_sink_proxy_destroy_cb,
+	.error = on_sink_proxy_error_cb,
+};
+
+static void register_capture_sink_port(struct obs_pw_audio_capture_app *pwac,
+				       uint32_t global_id, const char *channel)
+{
+	struct capture_sink_port *port = da_push_back_new(pwac->sink.ports);
+	port->channel = bstrdup(channel);
+	port->id = global_id;
+}
+
+static void destroy_sink_links(struct obs_pw_audio_capture_app *pwac)
+{
+	struct capture_sink_link *l, *t;
+	spa_list_for_each_safe(l, t, &pwac->sink.links, obj.link)
+	{
+		pw_proxy_destroy(l->obj.proxy);
+	}
+}
+
+static void connect_targets(struct obs_pw_audio_capture_app *pwac,
+			    const char *target, bool except)
+{
+	pwac->except_app = except;
+
+	if (target) {
+		dstr_copy(&pwac->target, target);
+	}
+
+	if (!pwac->sink.proxy) {
+		return;
+	}
+
+	destroy_sink_links(pwac);
+
+	if (dstr_is_empty(&pwac->target)) {
+		return;
+	}
+
+	struct target_node *n;
+	spa_list_for_each(n, &pwac->targets, obj.link)
+	{
+		if (node_is_targeted(pwac, n)) {
+			link_node_to_sink(pwac, n);
+		}
+	}
+}
+
+static bool make_capture_sink(struct obs_pw_audio_capture_app *pwac,
+			      uint32_t channels, const char *position)
+{
+	struct pw_properties *sink_props = pw_properties_new(
+		PW_KEY_NODE_NAME, "OBS Studio", PW_KEY_NODE_DESCRIPTION,
+		"OBS App Audio Capture Sink", PW_KEY_FACTORY_NAME,
+		"support.null-audio-sink", PW_KEY_MEDIA_CLASS,
+		"Audio/Sink/Virtual", PW_KEY_NODE_VIRTUAL, "true",
+		SPA_KEY_AUDIO_POSITION, position, NULL);
+
+	pw_properties_setf(sink_props, PW_KEY_AUDIO_CHANNELS, "%u", channels);
+
+	pwac->sink.proxy = pw_core_create_object(pwac->pw.core, "adapter",
+						 PW_TYPE_INTERFACE_Node,
+						 PW_VERSION_NODE,
+						 &sink_props->dict, 0);
+
+	obs_pw_audio_instance_sync(&pwac->pw);
+
+	pw_properties_free(sink_props);
+
+	if (!pwac->sink.proxy) {
+		blog(LOG_WARNING,
+		     "[pipewire] Failed to create app capture sink");
+		return false;
+	}
+
+	pwac->sink.channels = channels;
+	dstr_copy(&pwac->sink.position, position);
+
+	pwac->sink.id = SPA_ID_INVALID;
+
+	pw_proxy_add_listener(pwac->sink.proxy, &pwac->sink.proxy_listener,
+			      &sink_proxy_events, pwac);
+
+	while (pwac->sink.id == SPA_ID_INVALID ||
+	       pwac->sink.ports.num != channels) {
+		/* Iterate until the sink is bound and all the ports are registered */
+		pw_loop_iterate(pw_thread_loop_get_loop(pwac->pw.thread_loop),
+				-1);
+	}
+
+	blog(LOG_INFO,
+	     "[pipewire] Created app capture sink %u with %u channels and position %s",
+	     pwac->sink.id, channels, position);
+
+	connect_targets(pwac, NULL, pwac->except_app);
+
+	pwac->sink.autoconnect_targets = true;
+
+	if (obs_pw_audio_stream_connect(&pwac->pw.audio, pwac->sink.id,
+					channels) < 0) {
+		blog(LOG_WARNING,
+		     "[pipewire] Error connecting stream %p to app capture sink %u",
+		     pwac->pw.audio.stream, pwac->sink.id);
+	}
+
+	return true;
+}
+
+static void destroy_capture_sink(struct obs_pw_audio_capture_app *pwac)
+{
+	/* Links are automatically destroyed by PipeWire */
+
+	if (!pwac->sink.proxy) {
+		return;
+	}
+
+	if (pw_stream_get_state(pwac->pw.audio.stream, NULL) !=
+	    PW_STREAM_STATE_UNCONNECTED) {
+		pw_stream_disconnect(pwac->pw.audio.stream);
+	}
+
+	pwac->sink.autoconnect_targets = false;
+	pw_proxy_destroy(pwac->sink.proxy);
+	obs_pw_audio_instance_sync(&pwac->pw);
+}
+/* ------------------------------------------------- */
+
+/* Default system sink */
+static void on_default_sink_info_cb(void *data, const struct pw_node_info *info)
+{
+	if ((info->change_mask & PW_NODE_CHANGE_MASK_PROPS) == 0 ||
+	    !info->props || !info->props->n_items) {
+		return;
+	}
+
+	struct obs_pw_audio_capture_app *pwac = data;
+
+	/** Use stereo if
+	  * - The default sink uses the Pro Audio profile, since all streams will be configured to use stereo
+	  * https://gitlab.freedesktop.org/pipewire/pipewire/-/wikis/FAQ#what-is-the-pro-audio-profile
+	  * - The default sink doesn't have the needed props and there isn't already an app capture sink */
+
+	const char *channels =
+		spa_dict_lookup(info->props, PW_KEY_AUDIO_CHANNELS);
+	const char *position =
+		spa_dict_lookup(info->props, SPA_KEY_AUDIO_POSITION);
+	if (!channels || !position) {
+		if (pwac->sink.proxy) {
+			return;
+		}
+		channels = "2";
+		position = "FL,FR";
+	} else if (astrstri(position, "AUX")) {
+		/* Pro Audio sinks use AUX0,AUX1... and so on as their position (see link above) */
+		channels = "2";
+		position = "FL,FR";
+	}
+
+	uint32_t c = strtoul(channels, NULL, 10);
+	if (!c) {
+		return;
+	}
+
+	/* No need to create a new capture sink if the channels are the same */
+	if (pwac->sink.channels == c && !dstr_is_empty(&pwac->sink.position) &&
+	    dstr_cmp(&pwac->sink.position, position) == 0) {
+		return;
+	}
+
+	destroy_capture_sink(pwac);
+
+	make_capture_sink(pwac, c, position);
+}
+
+static const struct pw_node_events default_sink_events = {
+	PW_VERSION_NODE_EVENTS,
+	.info = on_default_sink_info_cb,
+};
+
+static void on_default_sink_proxy_removed_cb(void *data)
+{
+	struct obs_pw_audio_capture_app *pwac = data;
+	pw_proxy_destroy(pwac->default_sink.proxy);
+}
+
+static void on_default_sink_proxy_destroy_cb(void *data)
+{
+	struct obs_pw_audio_capture_app *pwac = data;
+	spa_hook_remove(&pwac->default_sink.node_listener);
+	spa_zero(pwac->default_sink.node_listener);
+
+	spa_hook_remove(&pwac->default_sink.proxy_listener);
+	spa_zero(pwac->default_sink.proxy_listener);
+
+	pwac->default_sink.proxy = NULL;
+}
+
+static const struct pw_proxy_events default_sink_proxy_events = {
+	PW_VERSION_PROXY_EVENTS,
+	.removed = on_default_sink_proxy_removed_cb,
+	.destroy = on_default_sink_proxy_destroy_cb,
+};
+
+static void default_node_cb(void *data, const char *name)
+{
+	struct obs_pw_audio_capture_app *pwac = data;
+
+	blog(LOG_DEBUG, "[pipewire] New default sink %s", name);
+
+	/* Find the new default sink and bind to it to get its channel info */
+	struct system_sink *t, *s = NULL;
+	spa_list_for_each(t, &pwac->system_sinks, obj.link)
+	{
+		if (strcmp(name, t->name) == 0) {
+			s = t;
+			break;
+		}
+	}
+	if (!s) {
+		return;
+	}
+
+	if (pwac->default_sink.proxy) {
+		pw_proxy_destroy(pwac->default_sink.proxy);
+	}
+
+	pwac->default_sink.proxy = pw_registry_bind(pwac->pw.registry, s->id,
+						    PW_TYPE_INTERFACE_Node,
+						    PW_VERSION_NODE, 0);
+	if (!pwac->default_sink.proxy) {
+		if (!pwac->sink.proxy) {
+			blog(LOG_WARNING,
+			     "[pipewire] Failed to get default sink info, app capture sink defaulting to stereo");
+			make_capture_sink(pwac, 2, "FL,FR");
+		}
+		return;
+	}
+
+	pw_proxy_add_object_listener(pwac->default_sink.proxy,
+				     &pwac->default_sink.node_listener,
+				     &default_sink_events, pwac);
+	pw_proxy_add_listener(pwac->default_sink.proxy,
+			      &pwac->default_sink.proxy_listener,
+			      &default_sink_proxy_events, pwac);
+}
+/* ------------------------------------------------- */
+
+/* Registry */
+static void on_global_cb(void *data, uint32_t id, uint32_t permissions,
+			 const char *type, uint32_t version,
+			 const struct spa_dict *props)
+{
+	UNUSED_PARAMETER(permissions);
+	UNUSED_PARAMETER(version);
+
+	if (!props || !type) {
+		return;
+	}
+
+	struct obs_pw_audio_capture_app *pwac = data;
+
+	if (strcmp(type, PW_TYPE_INTERFACE_Port) == 0) {
+		const char *nid, *dir, *chn;
+		if (!(nid = spa_dict_lookup(props, PW_KEY_NODE_ID)) ||
+		    !(dir = spa_dict_lookup(props, PW_KEY_PORT_DIRECTION)) ||
+		    !(chn = spa_dict_lookup(props, PW_KEY_AUDIO_CHANNEL))) {
+			return;
+		}
+
+		uint32_t node_id = strtoul(nid, NULL, 10);
+
+		if (astrcmpi(dir, "in") == 0 && node_id == pwac->sink.id) {
+			register_capture_sink_port(pwac, id, chn);
+		} else if (astrcmpi(dir, "out") == 0) {
+			/* Possibly a target port */
+			struct target_node *t, *n = NULL;
+			spa_list_for_each(t, &pwac->targets, obj.link)
+			{
+				if (t->id == node_id) {
+					n = t;
+					break;
+				}
+			}
+			if (!n) {
+				return;
+			}
+
+			struct target_node_port *p = node_register_port(
+				n, id, pwac->pw.registry, chn);
+
+			if (p && pwac->sink.autoconnect_targets &&
+			    node_is_targeted(pwac, n)) {
+				link_port_to_sink(pwac, p, n->id);
+			}
+		}
+	} else if (strcmp(type, PW_TYPE_INTERFACE_Node) == 0) {
+		const char *node_name, *media_class;
+		if (!(node_name = spa_dict_lookup(props, PW_KEY_NODE_NAME)) ||
+		    !(media_class =
+			      spa_dict_lookup(props, PW_KEY_MEDIA_CLASS))) {
+			return;
+		}
+
+		if (strcmp(media_class, "Stream/Output/Audio") == 0) {
+			/* Target node */
+			const char *node_app_name =
+				spa_dict_lookup(props, PW_KEY_APP_NAME);
+
+			if (!node_app_name) {
+				node_app_name = node_name;
+			}
+
+			register_target_node(pwac, id, node_app_name,
+					     node_name);
+		} else if (strcmp(media_class, "Audio/Sink") == 0) {
+			register_system_sink(pwac, id, node_name);
+		}
+	} else if (strcmp(type, PW_TYPE_INTERFACE_Metadata) == 0) {
+		const char *name = spa_dict_lookup(props, PW_KEY_METADATA_NAME);
+		if (!name || strcmp(name, "default") != 0) {
+			return;
+		}
+
+		if (!obs_pw_audio_default_node_metadata_listen(
+			    &pwac->default_sink.metadata, &pwac->pw, id, true,
+			    default_node_cb, pwac) &&
+		    !pwac->sink.proxy) {
+			blog(LOG_WARNING,
+			     "[pipewire] Failed to get default metadata, app capture sink defaulting to stereo");
+			make_capture_sink(pwac, 2, "FL,FR");
+		}
+	}
+}
+
+static const struct pw_registry_events registry_events = {
+	PW_VERSION_REGISTRY_EVENTS,
+	.global = on_global_cb,
+};
+/* ------------------------------------------------- */
+
+/* Source */
+static void *pipewire_audio_capture_app_create(obs_data_t *settings,
+					       obs_source_t *source)
+{
+	struct obs_pw_audio_capture_app *pwac =
+		bzalloc(sizeof(struct obs_pw_audio_capture_app));
+
+	if (!obs_pw_audio_instance_init(&pwac->pw, &registry_events, pwac, true,
+					false, source)) {
+		obs_pw_audio_instance_destroy(&pwac->pw);
+
+		bfree(pwac);
+		return NULL;
+	}
+
+	spa_list_init(&pwac->targets);
+	spa_list_init(&pwac->sink.links);
+	spa_list_init(&pwac->system_sinks);
+
+	pwac->sink.id = SPA_ID_INVALID;
+	dstr_init(&pwac->sink.position);
+
+	dstr_init_copy(&pwac->target, obs_data_get_string(settings, "Target"));
+	pwac->except_app = obs_data_get_bool(settings, "ExceptApp");
+
+	obs_pw_audio_instance_sync(&pwac->pw);
+	pw_thread_loop_wait(pwac->pw.thread_loop);
+	pw_thread_loop_unlock(pwac->pw.thread_loop);
+
+	return pwac;
+}
+
+static void pipewire_audio_capture_app_defaults(obs_data_t *settings)
+{
+	obs_data_set_default_bool(settings, "ExceptApp", false);
+}
+
+static int cmp_targets(const void *a, const void *b)
+{
+	const char *a_str = *(char **)a;
+	const char *b_str = *(char **)b;
+	return strcmp(a_str, b_str);
+}
+
+static obs_properties_t *pipewire_audio_capture_app_properties(void *data)
+{
+	struct obs_pw_audio_capture_app *pwac = data;
+
+	obs_properties_t *p = obs_properties_create();
+
+	obs_property_t *prop_targets_list = obs_properties_add_list(
+		p, "Target", obs_module_text("Application"),
+		OBS_COMBO_TYPE_EDITABLE, OBS_COMBO_FORMAT_STRING);
+
+	obs_properties_add_bool(p, "ExceptApp", obs_module_text("ExceptApp"));
+
+	DARRAY(char *) targets_arr;
+	da_init(targets_arr);
+
+	pw_thread_loop_lock(pwac->pw.thread_loop);
+
+	da_reserve(targets_arr, pwac->n_targets);
+
+	struct target_node *node;
+	spa_list_for_each(node, &pwac->targets, obj.link)
+	{
+		da_push_back(targets_arr, node->binary     ? &node->binary
+					  : node->app_name ? &node->app_name
+							   : &node->name);
+	}
+
+	/* Show just one entry per app */
+
+	qsort(targets_arr.array, targets_arr.num, sizeof(char *), cmp_targets);
+
+	for (size_t i = 0; i < targets_arr.num; i++) {
+		if (i == 0 || strcmp(targets_arr.array[i - 1],
+				     targets_arr.array[i]) != 0) {
+			obs_property_list_add_string(
+				prop_targets_list, targets_arr.array[i], NULL);
+		}
+	}
+
+	pw_thread_loop_unlock(pwac->pw.thread_loop);
+
+	da_free(targets_arr);
+
+	return p;
+}
+
+static void pipewire_audio_capture_app_update(void *data, obs_data_t *settings)
+{
+	struct obs_pw_audio_capture_app *pwac = data;
+
+	bool except = obs_data_get_bool(settings, "ExceptApp");
+
+	const char *new_target = obs_data_get_string(settings, "Target");
+
+	pw_thread_loop_lock(pwac->pw.thread_loop);
+
+	if (except == pwac->except_app &&
+	    dstr_cmpi(&pwac->target, new_target) == 0) {
+		pw_thread_loop_unlock(pwac->pw.thread_loop);
+		return;
+	}
+
+	connect_targets(pwac, new_target, except);
+
+	obs_pw_audio_instance_sync(&pwac->pw);
+	pw_thread_loop_wait(pwac->pw.thread_loop);
+	pw_thread_loop_unlock(pwac->pw.thread_loop);
+}
+
+static void pipewire_audio_capture_app_show(void *data)
+{
+	struct obs_pw_audio_capture_app *pwac = data;
+	pw_stream_set_active(pwac->pw.audio.stream, true);
+}
+
+static void pipewire_audio_capture_app_hide(void *data)
+{
+	struct obs_pw_audio_capture_app *pwac = data;
+	pw_stream_set_active(pwac->pw.audio.stream, false);
+}
+
+static void pipewire_audio_capture_app_destroy(void *data)
+{
+	struct obs_pw_audio_capture_app *pwac = data;
+
+	pw_thread_loop_lock(pwac->pw.thread_loop);
+
+	struct target_node *n, *tn;
+	spa_list_for_each_safe(n, tn, &pwac->targets, obj.link)
+	{
+		pw_proxy_destroy(n->obj.proxy);
+	}
+	struct system_sink *s, *ts;
+	spa_list_for_each_safe(s, ts, &pwac->system_sinks, obj.link)
+	{
+		pw_proxy_destroy(s->obj.proxy);
+	}
+
+	destroy_capture_sink(pwac);
+
+	if (pwac->default_sink.proxy) {
+		pw_proxy_destroy(pwac->default_sink.proxy);
+	}
+	if (pwac->default_sink.metadata.proxy) {
+		pw_proxy_destroy(pwac->default_sink.metadata.proxy);
+	}
+
+	obs_pw_audio_instance_destroy(&pwac->pw);
+
+	dstr_free(&pwac->sink.position);
+	dstr_free(&pwac->target);
+
+	bfree(pwac);
+}
+
+static const char *pipewire_audio_capture_app_name(void *data)
+{
+	UNUSED_PARAMETER(data);
+	return obs_module_text("PipeWireAudioCaptureApplication");
+}
+
+void pipewire_audio_capture_app_load(void)
+{
+	const struct obs_source_info pipewire_audio_capture_application = {
+		.id = "pipewire-audio-capture-application",
+		.type = OBS_SOURCE_TYPE_INPUT,
+		.output_flags = OBS_SOURCE_AUDIO | OBS_SOURCE_DO_NOT_DUPLICATE,
+		.get_name = pipewire_audio_capture_app_name,
+		.create = pipewire_audio_capture_app_create,
+		.get_defaults = pipewire_audio_capture_app_defaults,
+		.get_properties = pipewire_audio_capture_app_properties,
+		.update = pipewire_audio_capture_app_update,
+		.show = pipewire_audio_capture_app_show,
+		.hide = pipewire_audio_capture_app_hide,
+		.destroy = pipewire_audio_capture_app_destroy,
+		.icon_type = OBS_ICON_TYPE_PROCESS_AUDIO_OUTPUT,
+	};
+
+	obs_register_source(&pipewire_audio_capture_application);
+}
diff --git a/plugins/linux-pipewire/pipewire-audio-capture-device.c b/plugins/linux-pipewire/pipewire-audio-capture-device.c
new file mode 100644
index 0000000000000..b474ddad39fac
--- /dev/null
+++ b/plugins/linux-pipewire/pipewire-audio-capture-device.c
@@ -0,0 +1,520 @@
+/* pipewire-audio-capture-device.c
+ *
+ * Copyright 2022 Dimitris Papaioannou <dimtpap@protonmail.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#include "pipewire-audio.h"
+
+#include <util/dstr.h>
+
+/* Source for capturing device audio using PipeWire */
+
+enum obs_pw_audio_capture_device_type {
+	PIPEWIRE_AUDIO_CAPTURE_DEVICE_INPUT,
+	PIPEWIRE_AUDIO_CAPTURE_DEVICE_OUTPUT,
+};
+
+struct target_node {
+	const char *friendly_name;
+	const char *name;
+	uint32_t id;
+	uint32_t channels;
+
+	struct spa_hook node_listener;
+
+	struct obs_pw_audio_capture_device *pwac;
+
+	struct obs_pw_audio_proxied_object obj;
+};
+
+struct obs_pw_audio_capture_device {
+	obs_source_t *source;
+
+	enum obs_pw_audio_capture_device_type capture_type;
+
+	struct obs_pw_audio_instance pw;
+
+	struct {
+		struct obs_pw_audio_default_node_metadata metadata;
+		bool autoconnect;
+		uint32_t node_id;
+		struct dstr name;
+	} default_info;
+
+	struct spa_list targets;
+
+	struct dstr target_name;
+	uint32_t connected_id;
+};
+
+static void start_streaming(struct obs_pw_audio_capture_device *pwac,
+			    struct target_node *node)
+{
+	if (!node || !node->channels) {
+		return;
+	}
+
+	dstr_copy(&pwac->target_name, node->name);
+
+	if (pw_stream_get_state(pwac->pw.audio.stream, NULL) !=
+	    PW_STREAM_STATE_UNCONNECTED) {
+		if (node->id == pwac->connected_id) {
+			/* Already connected to this node */
+			return;
+		}
+		pw_stream_disconnect(pwac->pw.audio.stream);
+	}
+
+	if (obs_pw_audio_stream_connect(&pwac->pw.audio, node->id,
+					node->channels) == 0) {
+		pwac->connected_id = node->id;
+		blog(LOG_INFO, "[pipewire] %p streaming from %u",
+		     pwac->pw.audio.stream, node->id);
+	} else {
+		pwac->connected_id = SPA_ID_INVALID;
+		blog(LOG_WARNING, "[pipewire] Error connecting stream %p",
+		     pwac->pw.audio.stream);
+	}
+
+	pw_stream_set_active(pwac->pw.audio.stream,
+			     obs_source_active(pwac->source));
+}
+
+struct target_node *get_node_by_name(struct obs_pw_audio_capture_device *pwac,
+				     const char *name)
+{
+	struct target_node *n;
+	spa_list_for_each(n, &pwac->targets, obj.link)
+	{
+		if (strcmp(n->name, name) == 0) {
+			return n;
+		}
+	}
+	return NULL;
+}
+
+struct target_node *get_node_by_id(struct obs_pw_audio_capture_device *pwac,
+				   uint32_t id)
+{
+	struct target_node *n;
+	spa_list_for_each(n, &pwac->targets, obj.link)
+	{
+		if (n->id == id) {
+			return n;
+		}
+	}
+	return NULL;
+}
+
+/* Target node */
+static void on_node_info_cb(void *data, const struct pw_node_info *info)
+{
+	if ((info->change_mask & PW_NODE_CHANGE_MASK_PROPS) == 0 ||
+	    !info->props || !info->props->n_items) {
+		return;
+	}
+
+	const char *channels =
+		spa_dict_lookup(info->props, PW_KEY_AUDIO_CHANNELS);
+	if (!channels) {
+		return;
+	}
+
+	uint32_t c = strtoul(channels, NULL, 10);
+
+	struct target_node *n = data;
+	if (n->channels == c) {
+		return;
+	}
+	n->channels = c;
+
+	struct obs_pw_audio_capture_device *pwac = n->pwac;
+
+	/** If this is the default device and the stream is not already connected to it
+	  * or the stream is unconnected and this node has the desired target name */
+	if ((pwac->default_info.autoconnect && pwac->connected_id != n->id &&
+	     !dstr_is_empty(&pwac->default_info.name) &&
+	     dstr_cmp(&pwac->default_info.name, n->name) == 0) ||
+	    (pw_stream_get_state(pwac->pw.audio.stream, NULL) ==
+		     PW_STREAM_STATE_UNCONNECTED &&
+	     !dstr_is_empty(&pwac->target_name) &&
+	     dstr_cmp(&pwac->target_name, n->name) == 0)) {
+		start_streaming(pwac, n);
+	}
+}
+
+static const struct pw_node_events node_events = {
+	PW_VERSION_NODE_EVENTS,
+	.info = on_node_info_cb,
+};
+
+static void node_destroy_cb(void *data)
+{
+	struct target_node *n = data;
+
+	spa_hook_remove(&n->node_listener);
+
+	bfree((void *)n->friendly_name);
+	bfree((void *)n->name);
+}
+
+static void register_target_node(struct obs_pw_audio_capture_device *pwac,
+				 const char *friendly_name, const char *name,
+				 uint32_t global_id)
+{
+	struct pw_proxy *node_proxy =
+		pw_registry_bind(pwac->pw.registry, global_id,
+				 PW_TYPE_INTERFACE_Node, PW_VERSION_NODE, 0);
+	if (!node_proxy) {
+		return;
+	}
+
+	struct target_node *n = bmalloc(sizeof(struct target_node));
+	n->friendly_name = bstrdup(friendly_name);
+	n->name = bstrdup(name);
+	n->id = global_id;
+	n->channels = 0;
+	n->pwac = pwac;
+
+	obs_pw_audio_proxied_object_init(&n->obj, node_proxy, &pwac->targets,
+					 NULL, node_destroy_cb, n);
+
+	spa_zero(n->node_listener);
+	pw_proxy_add_object_listener(n->obj.proxy, &n->node_listener,
+				     &node_events, n);
+}
+/* ------------------------------------------------- */
+
+/* Default device metadata */
+static void default_node_cb(void *data, const char *name)
+{
+	struct obs_pw_audio_capture_device *pwac = data;
+
+	blog(LOG_DEBUG, "[pipewire] New default device %s", name);
+
+	dstr_copy(&pwac->default_info.name, name);
+
+	struct target_node *n = get_node_by_name(pwac, name);
+	if (n) {
+		pwac->default_info.node_id = n->id;
+		if (pwac->default_info.autoconnect) {
+			start_streaming(pwac, n);
+		}
+	}
+}
+/* ------------------------------------------------- */
+
+/* Registry */
+static void on_global_cb(void *data, uint32_t id, uint32_t permissions,
+			 const char *type, uint32_t version,
+			 const struct spa_dict *props)
+{
+	UNUSED_PARAMETER(permissions);
+	UNUSED_PARAMETER(version);
+
+	struct obs_pw_audio_capture_device *pwac = data;
+
+	if (!props || !type) {
+		return;
+	}
+
+	if (strcmp(type, PW_TYPE_INTERFACE_Node) == 0) {
+		const char *node_name, *media_class;
+		if (!(node_name = spa_dict_lookup(props, PW_KEY_NODE_NAME)) ||
+		    !(media_class =
+			      spa_dict_lookup(props, PW_KEY_MEDIA_CLASS))) {
+			return;
+		}
+
+		/* Target device */
+		if ((pwac->capture_type ==
+			     PIPEWIRE_AUDIO_CAPTURE_DEVICE_INPUT &&
+		     (strcmp(media_class, "Audio/Source") == 0 ||
+		      strcmp(media_class, "Audio/Source/Virtual") == 0)) ||
+		    (pwac->capture_type ==
+			     PIPEWIRE_AUDIO_CAPTURE_DEVICE_OUTPUT &&
+		     strcmp(media_class, "Audio/Sink") == 0)) {
+			const char *node_friendly_name =
+				spa_dict_lookup(props, PW_KEY_NODE_NICK);
+			if (!node_friendly_name) {
+				node_friendly_name = spa_dict_lookup(
+					props, PW_KEY_NODE_DESCRIPTION);
+				if (!node_friendly_name) {
+					node_friendly_name = node_name;
+				}
+			}
+
+			register_target_node(pwac, node_friendly_name,
+					     node_name, id);
+		}
+	} else if (strcmp(type, PW_TYPE_INTERFACE_Metadata) == 0) {
+		const char *name = spa_dict_lookup(props, PW_KEY_METADATA_NAME);
+		if (!name || strcmp(name, "default") != 0) {
+			return;
+		}
+
+		if (!obs_pw_audio_default_node_metadata_listen(
+			    &pwac->default_info.metadata, &pwac->pw, id,
+			    pwac->capture_type ==
+				    PIPEWIRE_AUDIO_CAPTURE_DEVICE_OUTPUT,
+			    default_node_cb, pwac)) {
+			blog(LOG_WARNING,
+			     "[pipewire] Failed to get default metadata, cannot detect default audio devices");
+		}
+	}
+}
+
+static void on_global_remove_cb(void *data, uint32_t id)
+{
+	struct obs_pw_audio_capture_device *pwac = data;
+
+	if (pwac->default_info.node_id == id) {
+		pwac->default_info.node_id = SPA_ID_INVALID;
+	}
+
+	/** If the node we're connected to is removed,
+	  * try to find one with the same name and connect to it. */
+	if (id == pwac->connected_id) {
+		pwac->connected_id = SPA_ID_INVALID;
+
+		pw_stream_disconnect(pwac->pw.audio.stream);
+
+		if (!pwac->default_info.autoconnect &&
+		    !dstr_is_empty(&pwac->target_name)) {
+			start_streaming(pwac,
+					get_node_by_name(
+						pwac, pwac->target_name.array));
+		}
+	}
+}
+
+static const struct pw_registry_events registry_events = {
+	PW_VERSION_REGISTRY_EVENTS,
+	.global = on_global_cb,
+	.global_remove = on_global_remove_cb,
+};
+/* ------------------------------------------------- */
+
+/* Source */
+static void *pipewire_audio_capture_create(
+	obs_data_t *settings, obs_source_t *source,
+	enum obs_pw_audio_capture_device_type capture_type)
+{
+	struct obs_pw_audio_capture_device *pwac =
+		bzalloc(sizeof(struct obs_pw_audio_capture_device));
+
+	if (!obs_pw_audio_instance_init(
+		    &pwac->pw, &registry_events, pwac,
+		    capture_type == PIPEWIRE_AUDIO_CAPTURE_DEVICE_OUTPUT, true,
+		    source)) {
+		obs_pw_audio_instance_destroy(&pwac->pw);
+
+		bfree(pwac);
+		return NULL;
+	}
+
+	pwac->source = source;
+	pwac->capture_type = capture_type;
+	pwac->default_info.node_id = SPA_ID_INVALID;
+	pwac->connected_id = SPA_ID_INVALID;
+
+	spa_list_init(&pwac->targets);
+
+	if (obs_data_get_int(settings, "TargetId") != PW_ID_ANY) {
+		/** Reset id setting, PipeWire node ids may not persist between sessions.
+		  * Connecting to saved target will happen based on the TargetName setting
+		  * once target has connected */
+		obs_data_set_int(settings, "TargetId", 0);
+	} else {
+		pwac->default_info.autoconnect = true;
+	}
+
+	dstr_init_copy(&pwac->target_name,
+		       obs_data_get_string(settings, "TargetName"));
+
+	obs_pw_audio_instance_sync(&pwac->pw);
+	pw_thread_loop_wait(pwac->pw.thread_loop);
+	pw_thread_loop_unlock(pwac->pw.thread_loop);
+
+	return pwac;
+}
+
+static void *pipewire_audio_capture_input_create(obs_data_t *settings,
+						 obs_source_t *source)
+{
+	return pipewire_audio_capture_create(
+		settings, source, PIPEWIRE_AUDIO_CAPTURE_DEVICE_INPUT);
+}
+
+static void *pipewire_audio_capture_output_create(obs_data_t *settings,
+						  obs_source_t *source)
+{
+	return pipewire_audio_capture_create(
+		settings, source, PIPEWIRE_AUDIO_CAPTURE_DEVICE_OUTPUT);
+}
+
+static void pipewire_audio_capture_defaults(obs_data_t *settings)
+{
+	obs_data_set_default_int(settings, "TargetId", PW_ID_ANY);
+}
+
+static obs_properties_t *pipewire_audio_capture_properties(void *data)
+{
+	struct obs_pw_audio_capture_device *pwac = data;
+
+	obs_properties_t *p = obs_properties_create();
+
+	obs_property_t *prop_targets_list = obs_properties_add_list(
+		p, "TargetId", obs_module_text("Device"), OBS_COMBO_TYPE_LIST,
+		OBS_COMBO_FORMAT_INT);
+
+	obs_property_list_add_int(prop_targets_list, obs_module_text("Default"),
+				  PW_ID_ANY);
+
+	pw_thread_loop_lock(pwac->pw.thread_loop);
+
+	struct target_node *n;
+	spa_list_for_each(n, &pwac->targets, obj.link)
+	{
+		obs_property_list_add_int(prop_targets_list, n->friendly_name,
+					  n->id);
+	}
+
+	pw_thread_loop_unlock(pwac->pw.thread_loop);
+
+	return p;
+}
+
+static void pipewire_audio_capture_update(void *data, obs_data_t *settings)
+{
+	struct obs_pw_audio_capture_device *pwac = data;
+
+	uint32_t new_node_id = obs_data_get_int(settings, "TargetId");
+
+	pw_thread_loop_lock(pwac->pw.thread_loop);
+
+	if (new_node_id == PW_ID_ANY) {
+		pwac->default_info.autoconnect = true;
+
+		if (pwac->default_info.node_id != SPA_ID_INVALID) {
+			start_streaming(
+				pwac,
+				get_node_by_id(pwac,
+					       pwac->default_info.node_id));
+		}
+		goto unlock;
+	}
+
+	pwac->default_info.autoconnect = false;
+
+	struct target_node *new_node = get_node_by_id(pwac, new_node_id);
+	if (!new_node) {
+		goto unlock;
+	}
+
+	start_streaming(pwac, new_node);
+
+	obs_data_set_string(settings, "TargetName", pwac->target_name.array);
+
+unlock:
+	pw_thread_loop_unlock(pwac->pw.thread_loop);
+}
+
+static void pipewire_audio_capture_show(void *data)
+{
+	struct obs_pw_audio_capture_device *pwac = data;
+	pw_stream_set_active(pwac->pw.audio.stream, true);
+}
+
+static void pipewire_audio_capture_hide(void *data)
+{
+	struct obs_pw_audio_capture_device *pwac = data;
+	pw_stream_set_active(pwac->pw.audio.stream, false);
+}
+
+static void pipewire_audio_capture_destroy(void *data)
+{
+	struct obs_pw_audio_capture_device *pwac = data;
+
+	pw_thread_loop_lock(pwac->pw.thread_loop);
+
+	struct target_node *n, *tn;
+	spa_list_for_each_safe(n, tn, &pwac->targets, obj.link)
+	{
+		pw_proxy_destroy(n->obj.proxy);
+	}
+
+	if (pwac->default_info.metadata.proxy) {
+		pw_proxy_destroy(pwac->default_info.metadata.proxy);
+	}
+
+	obs_pw_audio_instance_destroy(&pwac->pw);
+
+	dstr_free(&pwac->default_info.name);
+	dstr_free(&pwac->target_name);
+
+	bfree(pwac);
+}
+
+static const char *pipewire_audio_capture_input_name(void *data)
+{
+	UNUSED_PARAMETER(data);
+	return obs_module_text("PipeWireAudioCaptureInput");
+}
+
+static const char *pipewire_audio_capture_output_name(void *data)
+{
+	UNUSED_PARAMETER(data);
+	return obs_module_text("PipeWireAudioCaptureOutput");
+}
+
+void pipewire_audio_capture_load(void)
+{
+	const struct obs_source_info pipewire_audio_capture_input = {
+		.id = "pipewire-audio-capture-input",
+		.type = OBS_SOURCE_TYPE_INPUT,
+		.output_flags = OBS_SOURCE_AUDIO | OBS_SOURCE_DO_NOT_DUPLICATE,
+		.get_name = pipewire_audio_capture_input_name,
+		.create = pipewire_audio_capture_input_create,
+		.get_defaults = pipewire_audio_capture_defaults,
+		.get_properties = pipewire_audio_capture_properties,
+		.update = pipewire_audio_capture_update,
+		.show = pipewire_audio_capture_show,
+		.hide = pipewire_audio_capture_hide,
+		.destroy = pipewire_audio_capture_destroy,
+		.icon_type = OBS_ICON_TYPE_AUDIO_INPUT,
+	};
+	const struct obs_source_info pipewire_audio_capture_output = {
+		.id = "pipewire-audio-capture-output",
+		.type = OBS_SOURCE_TYPE_INPUT,
+		.output_flags = OBS_SOURCE_AUDIO | OBS_SOURCE_DO_NOT_DUPLICATE |
+				OBS_SOURCE_DO_NOT_SELF_MONITOR,
+		.get_name = pipewire_audio_capture_output_name,
+		.create = pipewire_audio_capture_output_create,
+		.get_defaults = pipewire_audio_capture_defaults,
+		.get_properties = pipewire_audio_capture_properties,
+		.update = pipewire_audio_capture_update,
+		.show = pipewire_audio_capture_show,
+		.hide = pipewire_audio_capture_hide,
+		.destroy = pipewire_audio_capture_destroy,
+		.icon_type = OBS_ICON_TYPE_AUDIO_OUTPUT,
+	};
+
+	obs_register_source(&pipewire_audio_capture_input);
+	obs_register_source(&pipewire_audio_capture_output);
+}
diff --git a/plugins/linux-pipewire/pipewire-audio.c b/plugins/linux-pipewire/pipewire-audio.c
new file mode 100644
index 0000000000000..ea761221ba10f
--- /dev/null
+++ b/plugins/linux-pipewire/pipewire-audio.c
@@ -0,0 +1,574 @@
+/* pipewire-audio.c
+ *
+ * Copyright 2022 Dimitris Papaioannou <dimtpap@protonmail.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+#include "pipewire-audio.h"
+
+#include <util/platform.h>
+
+#include <spa/utils/json.h>
+
+/* Utilities */
+bool json_object_find(const char *obj, const char *key, char *value, size_t len)
+{
+	/* From PipeWire's source */
+
+	struct spa_json it[2];
+	const char *v;
+	char k[128];
+
+	spa_json_init(&it[0], obj, strlen(obj));
+	if (spa_json_enter_object(&it[0], &it[1]) <= 0) {
+		return false;
+	}
+
+	while (spa_json_get_string(&it[1], k, sizeof(k)) > 0) {
+		if (spa_streq(k, key)) {
+			if (spa_json_get_string(&it[1], value, len) > 0) {
+				return true;
+			}
+		} else if (spa_json_next(&it[1], &v) <= 0) {
+			break;
+		}
+	}
+	return false;
+}
+/* ------------------------------------------------- */
+
+/* PipeWire stream wrapper */
+void obs_channels_to_spa_audio_position(enum spa_audio_channel *position,
+					uint32_t channels)
+{
+	switch (channels) {
+	case 1:
+		position[0] = SPA_AUDIO_CHANNEL_MONO;
+		break;
+	case 2:
+		position[0] = SPA_AUDIO_CHANNEL_FL;
+		position[1] = SPA_AUDIO_CHANNEL_FR;
+		break;
+	case 3:
+		position[0] = SPA_AUDIO_CHANNEL_FL;
+		position[1] = SPA_AUDIO_CHANNEL_FR;
+		position[2] = SPA_AUDIO_CHANNEL_LFE;
+		break;
+	case 4:
+		position[0] = SPA_AUDIO_CHANNEL_FL;
+		position[1] = SPA_AUDIO_CHANNEL_FR;
+		position[2] = SPA_AUDIO_CHANNEL_FC;
+		position[3] = SPA_AUDIO_CHANNEL_RC;
+		break;
+	case 5:
+		position[0] = SPA_AUDIO_CHANNEL_FL;
+		position[1] = SPA_AUDIO_CHANNEL_FR;
+		position[2] = SPA_AUDIO_CHANNEL_FC;
+		position[3] = SPA_AUDIO_CHANNEL_LFE;
+		position[4] = SPA_AUDIO_CHANNEL_RC;
+		break;
+	case 6:
+		position[0] = SPA_AUDIO_CHANNEL_FL;
+		position[1] = SPA_AUDIO_CHANNEL_FR;
+		position[2] = SPA_AUDIO_CHANNEL_FC;
+		position[3] = SPA_AUDIO_CHANNEL_LFE;
+		position[4] = SPA_AUDIO_CHANNEL_RL;
+		position[5] = SPA_AUDIO_CHANNEL_RR;
+		break;
+	case 8:
+		position[0] = SPA_AUDIO_CHANNEL_FL;
+		position[1] = SPA_AUDIO_CHANNEL_FR;
+		position[2] = SPA_AUDIO_CHANNEL_FC;
+		position[3] = SPA_AUDIO_CHANNEL_LFE;
+		position[4] = SPA_AUDIO_CHANNEL_RL;
+		position[5] = SPA_AUDIO_CHANNEL_RR;
+		position[6] = SPA_AUDIO_CHANNEL_SL;
+		position[7] = SPA_AUDIO_CHANNEL_SR;
+		break;
+	default:
+		for (size_t i = 0; i < channels; i++) {
+			position[i] = SPA_AUDIO_CHANNEL_UNKNOWN;
+		}
+		break;
+	}
+}
+
+enum audio_format spa_to_obs_audio_format(enum spa_audio_format format)
+{
+	switch (format) {
+	case SPA_AUDIO_FORMAT_U8:
+		return AUDIO_FORMAT_U8BIT;
+	case SPA_AUDIO_FORMAT_S16_LE:
+		return AUDIO_FORMAT_16BIT;
+	case SPA_AUDIO_FORMAT_S32_LE:
+		return AUDIO_FORMAT_32BIT;
+	case SPA_AUDIO_FORMAT_F32_LE:
+		return AUDIO_FORMAT_FLOAT;
+	case SPA_AUDIO_FORMAT_U8P:
+		return AUDIO_FORMAT_U8BIT_PLANAR;
+	case SPA_AUDIO_FORMAT_S16P:
+		return AUDIO_FORMAT_16BIT_PLANAR;
+	case SPA_AUDIO_FORMAT_S32P:
+		return AUDIO_FORMAT_32BIT_PLANAR;
+	case SPA_AUDIO_FORMAT_F32P:
+		return AUDIO_FORMAT_FLOAT_PLANAR;
+	default:
+		return AUDIO_FORMAT_UNKNOWN;
+	}
+}
+
+enum speaker_layout spa_to_obs_speakers(uint32_t channels)
+{
+	switch (channels) {
+	case 1:
+		return SPEAKERS_MONO;
+	case 2:
+		return SPEAKERS_STEREO;
+	case 3:
+		return SPEAKERS_2POINT1;
+	case 4:
+		return SPEAKERS_4POINT0;
+	case 5:
+		return SPEAKERS_4POINT1;
+	case 6:
+		return SPEAKERS_5POINT1;
+	case 8:
+		return SPEAKERS_7POINT1;
+	default:
+		return SPEAKERS_UNKNOWN;
+	}
+}
+
+bool spa_to_obs_pw_audio_info(struct obs_pw_audio_info *info,
+			      const struct spa_pod *param)
+{
+	struct spa_audio_info_raw audio_info;
+
+	if (spa_format_audio_raw_parse(param, &audio_info) < 0) {
+		info->sample_rate = 0;
+		info->format = AUDIO_FORMAT_UNKNOWN;
+		info->speakers = SPEAKERS_UNKNOWN;
+
+		return false;
+	}
+
+	info->sample_rate = audio_info.rate;
+	info->speakers = spa_to_obs_speakers(audio_info.channels);
+	info->format = spa_to_obs_audio_format(audio_info.format);
+
+	return true;
+}
+
+static void on_process_cb(void *data)
+{
+	uint64_t now = os_gettime_ns();
+
+	struct obs_pw_audio_stream *s = data;
+
+	struct pw_buffer *b = pw_stream_dequeue_buffer(s->stream);
+
+	if (!b) {
+		return;
+	}
+
+	struct spa_buffer *buf = b->buffer;
+
+	if (!s->info.sample_rate || buf->datas[0].type != SPA_DATA_MemPtr) {
+		goto queue;
+	}
+
+	struct obs_source_audio out = {
+		.frames = s->pos->clock.duration,
+		.speakers = s->info.speakers,
+		.format = s->info.format,
+		.samples_per_sec = s->info.sample_rate,
+	};
+
+	for (size_t i = 0; i < buf->n_datas && i < 8; i++) {
+		out.data[i] = buf->datas[i].data;
+	}
+
+	if (s->pos && (s->info.sample_rate * s->pos->clock.rate_diff)) {
+		/** Taken from PipeWire's implementation of JACK's jack_get_cycle_times
+		  * (https://gitlab.freedesktop.org/pipewire/pipewire/-/blob/0.3.52/pipewire-jack/src/pipewire-jack.c#L5639)
+		  * which is used in the linux-jack plugin to correctly set the timestamp
+		  * (https://github.com/obsproject/obs-studio/blob/27.2.4/plugins/linux-jack/jack-wrapper.c#L87) */
+
+		float period_usecs =
+			s->pos->clock.duration * (float)SPA_USEC_PER_SEC /
+			(s->info.sample_rate * s->pos->clock.rate_diff);
+
+		out.timestamp = now - (uint64_t)(period_usecs * 1000);
+	} else {
+		out.timestamp = now - audio_frames_to_ns(s->info.sample_rate,
+							 out.frames);
+	}
+
+	obs_source_output_audio(s->output, &out);
+
+queue:
+	pw_stream_queue_buffer(s->stream, b);
+}
+
+static void on_state_changed_cb(void *data, enum pw_stream_state old,
+				enum pw_stream_state state, const char *error)
+{
+	UNUSED_PARAMETER(old);
+
+	struct obs_pw_audio_stream *s = data;
+
+	blog(LOG_DEBUG, "[pipewire] Stream %p state: \"%s\" (error: %s)",
+	     s->stream, pw_stream_state_as_string(state),
+	     error ? error : "none");
+}
+
+static void on_param_changed_cb(void *data, uint32_t id,
+				const struct spa_pod *param)
+{
+	if (!param || id != SPA_PARAM_Format) {
+		return;
+	}
+
+	struct obs_pw_audio_stream *s = data;
+
+	if (!spa_to_obs_pw_audio_info(&s->info, param)) {
+		blog(LOG_WARNING,
+		     "[pipewire] Stream %p failed to parse audio format info",
+		     s->stream);
+	} else {
+		blog(LOG_INFO,
+		     "[pipewire] %p Got format: rate %u - channels %u - format %u",
+		     s->stream, s->info.sample_rate, s->info.speakers,
+		     s->info.format);
+	}
+}
+
+static void on_io_changed_cb(void *data, uint32_t id, void *area, uint32_t size)
+{
+	UNUSED_PARAMETER(size);
+
+	struct obs_pw_audio_stream *s = data;
+
+	if (id == SPA_IO_Position) {
+		s->pos = area;
+	}
+}
+
+static const struct pw_stream_events stream_events = {
+	PW_VERSION_STREAM_EVENTS,
+	.process = on_process_cb,
+	.state_changed = on_state_changed_cb,
+	.param_changed = on_param_changed_cb,
+	.io_changed = on_io_changed_cb,
+};
+
+int obs_pw_audio_stream_connect(struct obs_pw_audio_stream *s,
+				uint32_t target_id, uint32_t audio_channels)
+{
+	enum spa_audio_channel pos[8];
+	obs_channels_to_spa_audio_position(pos, audio_channels);
+
+	uint8_t buffer[2048];
+	struct spa_pod_builder b = SPA_POD_BUILDER_INIT(buffer, sizeof(buffer));
+	const struct spa_pod *params[1];
+
+	params[0] = spa_pod_builder_add_object(
+		&b, SPA_TYPE_OBJECT_Format, SPA_PARAM_EnumFormat,
+		SPA_FORMAT_mediaType, SPA_POD_Id(SPA_MEDIA_TYPE_audio),
+		SPA_FORMAT_mediaSubtype, SPA_POD_Id(SPA_MEDIA_SUBTYPE_raw),
+		SPA_FORMAT_AUDIO_channels, SPA_POD_Int(audio_channels),
+		SPA_FORMAT_AUDIO_position,
+		SPA_POD_Array(sizeof(enum spa_audio_channel), SPA_TYPE_Id,
+			      audio_channels, pos),
+		SPA_FORMAT_AUDIO_format,
+		SPA_POD_CHOICE_ENUM_Id(
+			8, SPA_AUDIO_FORMAT_U8, SPA_AUDIO_FORMAT_S16_LE,
+			SPA_AUDIO_FORMAT_S32_LE, SPA_AUDIO_FORMAT_F32_LE,
+			SPA_AUDIO_FORMAT_U8P, SPA_AUDIO_FORMAT_S16P,
+			SPA_AUDIO_FORMAT_S32P, SPA_AUDIO_FORMAT_F32P));
+
+	return pw_stream_connect(s->stream, PW_DIRECTION_INPUT, target_id,
+				 PW_STREAM_FLAG_AUTOCONNECT |
+					 PW_STREAM_FLAG_MAP_BUFFERS |
+					 PW_STREAM_FLAG_DONT_RECONNECT,
+				 params, 1);
+}
+/* ------------------------------------------------- */
+
+/* Common PipeWire components */
+static void on_core_done_cb(void *data, uint32_t id, int seq)
+{
+	struct obs_pw_audio_instance *pw = data;
+
+	if (id == PW_ID_CORE && pw->seq == seq) {
+		pw_thread_loop_signal(pw->thread_loop, false);
+	}
+}
+
+static void on_core_error_cb(void *data, uint32_t id, int seq, int res,
+			     const char *message)
+{
+	struct obs_pw_audio_instance *pw = data;
+
+	blog(LOG_ERROR, "[pipewire] Error id:%u seq:%d res:%d :%s", id, seq,
+	     res, message);
+
+	pw_thread_loop_signal(pw->thread_loop, false);
+}
+
+static const struct pw_core_events core_events = {
+	PW_VERSION_CORE_EVENTS,
+	.done = on_core_done_cb,
+	.error = on_core_error_cb,
+};
+
+bool obs_pw_audio_instance_init(
+	struct obs_pw_audio_instance *pw,
+	const struct pw_registry_events *registry_events,
+	void *registry_cb_data, bool stream_capture_sink,
+	bool stream_want_driver, obs_source_t *stream_output)
+{
+	pw->thread_loop = pw_thread_loop_new("PipeWire thread loop", NULL);
+	pw->context = pw_context_new(pw_thread_loop_get_loop(pw->thread_loop),
+				     NULL, 0);
+
+	pw_thread_loop_lock(pw->thread_loop);
+
+	if (pw_thread_loop_start(pw->thread_loop) < 0) {
+		blog(LOG_WARNING,
+		     "[pipewire] Error starting threaded mainloop");
+		return false;
+	}
+
+	pw->core = pw_context_connect(pw->context, NULL, 0);
+	if (!pw->core) {
+		blog(LOG_WARNING, "[pipewire] Error creating PipeWire core");
+		return false;
+	}
+
+	pw_core_add_listener(pw->core, &pw->core_listener, &core_events, pw);
+
+	pw->registry = pw_core_get_registry(pw->core, PW_VERSION_REGISTRY, 0);
+	if (!pw->registry) {
+		return false;
+	}
+	pw_registry_add_listener(pw->registry, &pw->registry_listener,
+				 registry_events, registry_cb_data);
+
+	pw->audio.output = stream_output;
+	pw->audio.stream = pw_stream_new(
+		pw->core, "OBS Studio",
+		pw_properties_new(
+			PW_KEY_NODE_NAME, "OBS Studio", PW_KEY_NODE_DESCRIPTION,
+			"OBS Audio Capture", PW_KEY_MEDIA_TYPE, "Audio",
+			PW_KEY_MEDIA_CATEGORY, "Capture", PW_KEY_MEDIA_ROLE,
+			"Production", PW_KEY_NODE_WANT_DRIVER,
+			stream_want_driver ? "true" : "false",
+			PW_KEY_STREAM_CAPTURE_SINK,
+			stream_capture_sink ? "true" : "false", NULL));
+
+	if (!pw->audio.stream) {
+		blog(LOG_WARNING, "[pipewire] Failed to create stream");
+		return false;
+	}
+	blog(LOG_INFO, "[pipewire] Created stream %p", pw->audio.stream);
+
+	pw_stream_add_listener(pw->audio.stream, &pw->audio.stream_listener,
+			       &stream_events, &pw->audio);
+
+	return true;
+}
+
+void obs_pw_audio_instance_destroy(struct obs_pw_audio_instance *pw)
+{
+	if (pw->audio.stream) {
+		spa_hook_remove(&pw->audio.stream_listener);
+		if (pw_stream_get_state(pw->audio.stream, NULL) !=
+		    PW_STREAM_STATE_UNCONNECTED) {
+			pw_stream_disconnect(pw->audio.stream);
+		}
+		pw_stream_destroy(pw->audio.stream);
+	}
+
+	if (pw->registry) {
+		spa_hook_remove(&pw->registry_listener);
+		spa_zero(pw->registry_listener);
+		pw_proxy_destroy((struct pw_proxy *)pw->registry);
+	}
+
+	pw_thread_loop_unlock(pw->thread_loop);
+	pw_thread_loop_stop(pw->thread_loop);
+
+	if (pw->core) {
+		spa_hook_remove(&pw->core_listener);
+		spa_zero(pw->core_listener);
+		pw_core_disconnect(pw->core);
+	}
+
+	if (pw->context) {
+		pw_context_destroy(pw->context);
+	}
+
+	pw_thread_loop_destroy(pw->thread_loop);
+}
+
+void obs_pw_audio_instance_sync(struct obs_pw_audio_instance *pw)
+{
+	pw->seq = pw_core_sync(pw->core, PW_ID_CORE, pw->seq);
+}
+/* ------------------------------------------------- */
+
+/* PipeWire metadata */
+static int on_metadata_property_cb(void *data, uint32_t id, const char *key,
+				   const char *type, const char *value)
+{
+	UNUSED_PARAMETER(type);
+
+	struct obs_pw_audio_default_node_metadata *metadata = data;
+
+	if (metadata->default_node_callback && id == PW_ID_CORE && key &&
+	    value &&
+	    strcmp(key, metadata->wants_sink ? "default.audio.sink"
+					     : "default.audio.source") == 0) {
+		char val[128];
+		if (!json_object_find(value, "name", val, sizeof(val)) ||
+		    !*val) {
+			return 0;
+		}
+
+		metadata->default_node_callback(metadata->data, val);
+	}
+
+	return 0;
+}
+
+static const struct pw_metadata_events metadata_events = {
+	PW_VERSION_METADATA_EVENTS,
+	.property = on_metadata_property_cb,
+};
+
+static void on_metadata_proxy_removed_cb(void *data)
+{
+	struct obs_pw_audio_default_node_metadata *metadata = data;
+	pw_proxy_destroy(metadata->proxy);
+}
+
+static void on_metadata_proxy_destroy_cb(void *data)
+{
+	struct obs_pw_audio_default_node_metadata *metadata = data;
+
+	spa_hook_remove(&metadata->metadata_listener);
+	spa_hook_remove(&metadata->proxy_listener);
+	spa_zero(metadata->metadata_listener);
+	spa_zero(metadata->proxy_listener);
+
+	metadata->proxy = NULL;
+}
+
+static const struct pw_proxy_events metadata_proxy_events = {
+	PW_VERSION_PROXY_EVENTS,
+	.removed = on_metadata_proxy_removed_cb,
+	.destroy = on_metadata_proxy_destroy_cb,
+};
+
+bool obs_pw_audio_default_node_metadata_listen(
+	struct obs_pw_audio_default_node_metadata *metadata,
+	struct obs_pw_audio_instance *pw, uint32_t global_id, bool wants_sink,
+	void (*default_node_callback)(void *data, const char *name), void *data)
+{
+	if (metadata->proxy) {
+		pw_proxy_destroy(metadata->proxy);
+	}
+
+	struct pw_proxy *metadata_proxy = pw_registry_bind(
+		pw->registry, global_id, PW_TYPE_INTERFACE_Metadata,
+		PW_VERSION_METADATA, 0);
+	if (!metadata_proxy) {
+		return false;
+	}
+
+	metadata->proxy = metadata_proxy;
+
+	metadata->wants_sink = wants_sink;
+
+	metadata->default_node_callback = default_node_callback;
+	metadata->data = data;
+
+	pw_proxy_add_object_listener(metadata->proxy,
+				     &metadata->metadata_listener,
+				     &metadata_events, metadata);
+	pw_proxy_add_listener(metadata->proxy, &metadata->proxy_listener,
+			      &metadata_proxy_events, metadata);
+
+	return true;
+}
+/* ------------------------------------------------- */
+
+/* Proxied objects */
+static void on_proxy_bound_cb(void *data, uint32_t global_id)
+{
+	struct obs_pw_audio_proxied_object *obj = data;
+	if (obj->bound_callback) {
+		obj->bound_callback(obj->data, global_id);
+	}
+}
+
+static void on_proxy_removed_cb(void *data)
+{
+	struct obs_pw_audio_proxied_object *obj = data;
+	pw_proxy_destroy(obj->proxy);
+}
+
+static void on_proxy_destroy_cb(void *data)
+{
+	struct obs_pw_audio_proxied_object *obj = data;
+	spa_hook_remove(&obj->proxy_listener);
+
+	spa_list_remove(&obj->link);
+
+	if (obj->destroy_callback) {
+		obj->destroy_callback(obj->data);
+	}
+
+	bfree(obj->data);
+}
+
+static const struct pw_proxy_events proxy_events = {
+	PW_VERSION_PROXY_EVENTS,
+	.bound = on_proxy_bound_cb,
+	.removed = on_proxy_removed_cb,
+	.destroy = on_proxy_destroy_cb,
+};
+
+void obs_pw_audio_proxied_object_init(
+	struct obs_pw_audio_proxied_object *obj, struct pw_proxy *proxy,
+	struct spa_list *list,
+	void (*bound_callback)(void *data, uint32_t global_id),
+	void (*destroy_callback)(void *data), void *data)
+{
+	obj->proxy = proxy;
+	obj->bound_callback = bound_callback;
+	obj->destroy_callback = destroy_callback;
+	obj->data = data;
+
+	spa_list_append(list, &obj->link);
+
+	spa_zero(obj->proxy_listener);
+	pw_proxy_add_listener(obj->proxy, &obj->proxy_listener, &proxy_events,
+			      obj);
+}
+/* ------------------------------------------------- */
diff --git a/plugins/linux-pipewire/pipewire-audio.h b/plugins/linux-pipewire/pipewire-audio.h
new file mode 100644
index 0000000000000..316276df75e54
--- /dev/null
+++ b/plugins/linux-pipewire/pipewire-audio.h
@@ -0,0 +1,155 @@
+/* pipewire-audio.h
+ *
+ * Copyright 2022 Dimitris Papaioannou <dimtpap@protonmail.com>
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ * SPDX-License-Identifier: GPL-2.0-or-later
+ */
+
+/* Stuff used by the PipeWire audio capture sources */
+
+#pragma once
+
+#include <obs-module.h>
+
+#include <pipewire/pipewire.h>
+#include <pipewire/extensions/metadata.h>
+#include <spa/param/audio/format-utils.h>
+
+/* PipeWire Stream wrapper */
+
+/**
+ * Audio metadata
+ */
+struct obs_pw_audio_info {
+	uint32_t sample_rate;
+	enum audio_format format;
+	enum speaker_layout speakers;
+};
+
+/**
+ * PipeWire stream wrapper that outputs to an OBS source
+ */
+struct obs_pw_audio_stream {
+	struct pw_stream *stream;
+	struct spa_hook stream_listener;
+	struct obs_pw_audio_info info;
+	struct spa_io_position *pos;
+
+	obs_source_t *output;
+};
+
+/**
+ * Connect a stream with the default params
+ * @return 0 on success, < 0 on error
+ */
+int obs_pw_audio_stream_connect(struct obs_pw_audio_stream *s,
+				uint32_t target_id, uint32_t channels);
+/* ------------------------------------------------- */
+
+/**
+ * Common PipeWire components
+ */
+struct obs_pw_audio_instance {
+	struct pw_thread_loop *thread_loop;
+	struct pw_context *context;
+
+	struct pw_core *core;
+	struct spa_hook core_listener;
+	int seq;
+
+	struct pw_registry *registry;
+	struct spa_hook registry_listener;
+
+	struct obs_pw_audio_stream audio;
+};
+
+/**
+ * Initialize a PipeWire instance
+ * @warning The thread loop is left locked
+ * @return true on success, false on error
+ */
+bool obs_pw_audio_instance_init(
+	struct obs_pw_audio_instance *pw,
+	const struct pw_registry_events *registry_events,
+	void *registry_cb_data, bool stream_capture_sink,
+	bool stream_want_driver, obs_source_t *stream_output);
+
+/**
+ * Destroy a PipeWire instance
+ * @warning Call with the thread loop locked
+ */
+void obs_pw_audio_instance_destroy(struct obs_pw_audio_instance *pw);
+
+/**
+ * Trigger a PipeWire core sync
+ */
+void obs_pw_audio_instance_sync(struct obs_pw_audio_instance *pw);
+/* ------------------------------------------------- */
+
+/**
+ * PipeWire metadata
+ */
+struct obs_pw_audio_default_node_metadata {
+	struct pw_proxy *proxy;
+	struct spa_hook proxy_listener;
+	struct spa_hook metadata_listener;
+
+	bool wants_sink;
+
+	void (*default_node_callback)(void *data, const char *name);
+	void *data;
+};
+
+/**
+ * Add listeners to the metadata
+ * @return true on success, false on error
+ */
+bool obs_pw_audio_default_node_metadata_listen(
+	struct obs_pw_audio_default_node_metadata *metadata,
+	struct obs_pw_audio_instance *pw, uint32_t global_id, bool wants_sink,
+	void (*default_node_callback)(void *data, const char *name),
+	void *data);
+/* ------------------------------------------------- */
+
+/**
+ * Generic proxy handler for PipeWire objects tracked in lists
+ */
+struct obs_pw_audio_proxied_object {
+	void *data;
+
+	void (*bound_callback)(void *data, uint32_t global_id);
+	void (*destroy_callback)(void *data);
+
+	struct pw_proxy *proxy;
+	struct spa_hook proxy_listener;
+
+	struct spa_list link;
+};
+
+/**
+ * Initialize a proxied object
+ */
+void obs_pw_audio_proxied_object_init(
+	struct obs_pw_audio_proxied_object *obj, struct pw_proxy *proxy,
+	struct spa_list *list,
+	void (*bound_callback)(void *data, uint32_t global_id),
+	void (*destroy_callback)(void *data), void *data);
+/* ------------------------------------------------- */
+
+/* Sources */
+void pipewire_audio_capture_load(void);
+void pipewire_audio_capture_app_load(void);
+/* ------------------------------------------------- */