From 9911c8532eb9072d93eb39b02aa94699f0a735a1 Mon Sep 17 00:00:00 2001
From: Julian Bouzas <julian.bouzas@collabora.com>
Date: Tue, 21 Mar 2023 09:18:30 -0400
Subject: [PATCH 01/11] m-default-nodes: remove echo-cancel configuration

This will be possible to do with the new module-filters-api.
---
 modules/module-default-nodes.c               | 75 --------------------
 src/config/main.lua.d/40-device-defaults.lua |  9 ---
 2 files changed, 84 deletions(-)

diff --git a/modules/module-default-nodes.c b/modules/module-default-nodes.c
index aaf29389..3bb93f00 100644
--- a/modules/module-default-nodes.c
+++ b/modules/module-default-nodes.c
@@ -16,18 +16,12 @@
 #define NAME "default-nodes"
 #define DEFAULT_SAVE_INTERVAL_MS 1000
 #define DEFAULT_USE_PERSISTENT_STORAGE TRUE
-#define DEFAULT_AUTO_ECHO_CANCEL TRUE
-#define DEFAULT_ECHO_CANCEL_SINK_NAME "echo-cancel-sink"
-#define DEFAULT_ECHO_CANCEL_SOURCE_NAME "echo-cancel-source"
 #define N_PREV_CONFIGS 16
 
 enum {
   PROP_0,
   PROP_SAVE_INTERVAL_MS,
   PROP_USE_PERSISTENT_STORAGE,
-  PROP_AUTO_ECHO_CANCEL,
-  PROP_ECHO_CANCEL_SINK_NAME,
-  PROP_ECHO_CANCEL_SOURCE_NAME,
 };
 
 typedef struct _WpDefaultNode WpDefaultNode;
@@ -51,8 +45,6 @@ struct _WpDefaultNodes
   /* properties */
   guint save_interval_ms;
   gboolean use_persistent_storage;
-  gboolean auto_echo_cancel;
-  gchar *echo_cancel_names[2];
 };
 
 G_DECLARE_FINAL_TYPE (WpDefaultNodes, wp_default_nodes,
@@ -243,21 +235,6 @@ node_has_available_routes (WpDefaultNodes * self, WpNode *node)
   return FALSE;
 }
 
-static gboolean
-is_echo_cancel_node (WpDefaultNodes * self, WpNode *node, WpDirection direction)
-{
-  const gchar *name = wp_pipewire_object_get_property (
-      WP_PIPEWIRE_OBJECT (node), PW_KEY_NODE_NAME);
-  const gchar *virtual_str = wp_pipewire_object_get_property (
-      WP_PIPEWIRE_OBJECT (node), PW_KEY_NODE_VIRTUAL);
-  gboolean virtual = virtual_str && pw_properties_parse_bool (virtual_str);
-
-  if (!name || !virtual)
-    return FALSE;
-
-  return g_strcmp0 (name, self->echo_cancel_names[direction]) == 0;
-}
-
 static WpNode *
 find_best_media_class_node (WpDefaultNodes * self, const gchar *media_class,
     const WpDefaultNode *def, WpDirection direction, gint *priority)
@@ -291,9 +268,6 @@ find_best_media_class_node (WpDefaultNodes * self, const gchar *media_class,
       if (!node_has_available_routes (self, node))
         continue;
 
-      if (self->auto_echo_cancel && is_echo_cancel_node (self, node, direction))
-        prio += 10000;
-
       if (name && def->config_value && g_strcmp0 (name, def->config_value) == 0) {
         prio += 20000 * (N_PREV_CONFIGS + 1);
       } else if (name) {
@@ -597,41 +571,18 @@ wp_default_nodes_set_property (GObject * object, guint property_id,
   case PROP_USE_PERSISTENT_STORAGE:
     self->use_persistent_storage =  g_value_get_boolean (value);
     break;
-  case PROP_AUTO_ECHO_CANCEL:
-    self->auto_echo_cancel = g_value_get_boolean (value);
-    break;
-  case PROP_ECHO_CANCEL_SINK_NAME:
-    g_clear_pointer (&self->echo_cancel_names[WP_DIRECTION_INPUT], g_free);
-    self->echo_cancel_names[WP_DIRECTION_INPUT] = g_value_dup_string (value);
-    break;
-  case PROP_ECHO_CANCEL_SOURCE_NAME:
-    g_clear_pointer (&self->echo_cancel_names[WP_DIRECTION_OUTPUT], g_free);
-    self->echo_cancel_names[WP_DIRECTION_OUTPUT] = g_value_dup_string (value);
-    break;
   default:
     G_OBJECT_WARN_INVALID_PROPERTY_ID (object, property_id, pspec);
     break;
   }
 }
 
-static void
-wp_default_nodes_finalize (GObject * object)
-{
-  WpDefaultNodes * self = WP_DEFAULT_NODES (object);
-
-  g_clear_pointer (&self->echo_cancel_names[WP_DIRECTION_INPUT], g_free);
-  g_clear_pointer (&self->echo_cancel_names[WP_DIRECTION_OUTPUT], g_free);
-
-  G_OBJECT_CLASS (wp_default_nodes_parent_class)->finalize (object);
-}
-
 static void
 wp_default_nodes_class_init (WpDefaultNodesClass * klass)
 {
   GObjectClass *object_class = (GObjectClass *) klass;
   WpPluginClass *plugin_class = (WpPluginClass *) klass;
 
-  object_class->finalize = wp_default_nodes_finalize;
   object_class->set_property = wp_default_nodes_set_property;
 
   plugin_class->enable = wp_default_nodes_enable;
@@ -646,21 +597,6 @@ wp_default_nodes_class_init (WpDefaultNodesClass * klass)
       g_param_spec_boolean ("use-persistent-storage", "use-persistent-storage",
           "use-persistent-storage", DEFAULT_USE_PERSISTENT_STORAGE,
           G_PARAM_WRITABLE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS));
-
-  g_object_class_install_property (object_class, PROP_AUTO_ECHO_CANCEL,
-      g_param_spec_boolean ("auto-echo-cancel", "auto-echo-cancel",
-          "auto-echo-cancel", DEFAULT_AUTO_ECHO_CANCEL,
-          G_PARAM_WRITABLE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS));
-
-  g_object_class_install_property (object_class, PROP_ECHO_CANCEL_SINK_NAME,
-      g_param_spec_string ("echo-cancel-sink-name", "echo-cancel-sink-name",
-          "echo-cancel-sink-name", DEFAULT_ECHO_CANCEL_SINK_NAME,
-          G_PARAM_WRITABLE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS));
-
-  g_object_class_install_property (object_class, PROP_ECHO_CANCEL_SOURCE_NAME,
-      g_param_spec_string ("echo-cancel-source-name", "echo-cancel-source-name",
-          "echo-cancel-source-name", DEFAULT_ECHO_CANCEL_SOURCE_NAME,
-          G_PARAM_WRITABLE | G_PARAM_CONSTRUCT_ONLY | G_PARAM_STATIC_STRINGS));
 }
 
 WP_PLUGIN_EXPORT gboolean
@@ -668,19 +604,11 @@ wireplumber__module_init (WpCore * core, GVariant * args, GError ** error)
 {
   guint save_interval_ms = DEFAULT_SAVE_INTERVAL_MS;
   gboolean use_persistent_storage = DEFAULT_USE_PERSISTENT_STORAGE;
-  gboolean auto_echo_cancel = DEFAULT_AUTO_ECHO_CANCEL;
-  const gchar *echo_cancel_sink_name = DEFAULT_ECHO_CANCEL_SINK_NAME;
-  const gchar *echo_cancel_source_name = DEFAULT_ECHO_CANCEL_SOURCE_NAME;
 
   if (args) {
     g_variant_lookup (args, "save-interval-ms", "u", &save_interval_ms);
     g_variant_lookup (args, "use-persistent-storage", "b",
         &use_persistent_storage);
-    g_variant_lookup (args, "auto-echo-cancel", "&s", &auto_echo_cancel);
-    g_variant_lookup (args, "echo-cancel-sink-name", "&s",
-        &echo_cancel_sink_name);
-    g_variant_lookup (args, "echo-cancel-source-name", "&s",
-        &echo_cancel_source_name);
   }
 
   wp_plugin_register (g_object_new (wp_default_nodes_get_type (),
@@ -688,9 +616,6 @@ wireplumber__module_init (WpCore * core, GVariant * args, GError ** error)
           "core", core,
           "save-interval-ms", save_interval_ms,
           "use-persistent-storage", use_persistent_storage,
-          "auto-echo-cancel", auto_echo_cancel,
-          "echo-cancel-sink-name", echo_cancel_sink_name,
-          "echo-cancel-source-name", echo_cancel_source_name,
           NULL));
   return TRUE;
 }
diff --git a/src/config/main.lua.d/40-device-defaults.lua b/src/config/main.lua.d/40-device-defaults.lua
index 19202914..91c4e189 100644
--- a/src/config/main.lua.d/40-device-defaults.lua
+++ b/src/config/main.lua.d/40-device-defaults.lua
@@ -10,15 +10,6 @@ device_defaults.properties = {
   -- the default volumes to apply to ACP device nodes, in the linear scale
   --["default-volume"] = 0.064,
   --["default-input-volume"] = 1.0,
-
-  -- Whether to auto-switch to echo cancel sink and source nodes or not
-  ["auto-echo-cancel"] = true,
-
-  -- Sets the default echo-cancel-sink node name to automatically switch to
-  ["echo-cancel-sink-name"] = "echo-cancel-sink",
-
-  -- Sets the default echo-cancel-source node name to automatically switch to
-  ["echo-cancel-source-name"] = "echo-cancel-source",
 }
 
 -- Sets persistent device profiles that should never change when wireplumber is
-- 
2.42.0


From a9b6f3bcb4c525c1e257873875feb6223fa6b660 Mon Sep 17 00:00:00 2001
From: Julian Bouzas <julian.bouzas@collabora.com>
Date: Fri, 17 Mar 2023 11:06:11 -0400
Subject: [PATCH 02/11] modules: add new module-filters-api to enable smart
 filter policy

This module provides an API to link filter nodes using the logic configured in
'policy.lua.d/30-filters-config.lua'. This configuration file allows grouping
filters together (sorted by priority) to use a common target node. A node is
considered a filter node if it has the node.link-group property.

This is disabled by default. You can enable smart filters policy in the
'10-default-policy.lya' configuration file.
---
 modules/meson.build                           |  11 +
 modules/module-filters-api.c                  | 905 ++++++++++++++++++
 modules/module-lua-scripting/api/json.c       |   2 +-
 src/config/policy.lua.d/10-default-policy.lua |   9 +
 src/config/policy.lua.d/30-filters-config.lua |  73 ++
 src/scripts/filters-metadata.lua              |  39 +
 src/scripts/policy-node.lua                   | 120 ++-
 7 files changed, 1156 insertions(+), 3 deletions(-)
 create mode 100644 modules/module-filters-api.c
 create mode 100644 src/config/policy.lua.d/30-filters-config.lua
 create mode 100644 src/scripts/filters-metadata.lua

diff --git a/modules/meson.build b/modules/meson.build
index 4930bfae..4a337017 100644
--- a/modules/meson.build
+++ b/modules/meson.build
@@ -159,6 +159,17 @@ shared_library(
   dependencies : [wp_dep, pipewire_dep],
 )
 
+shared_library(
+  'wireplumber-module-filters-api',
+  [
+    'module-filters-api.c',
+  ],
+  c_args : [common_c_args, '-DG_LOG_DOMAIN="m-filters-api"'],
+  install : true,
+  install_dir : wireplumber_module_dir,
+  dependencies : [wp_dep, pipewire_dep],
+)
+
 if libsystemd_dep.found() or libelogind_dep.found()
   shared_library(
     'wireplumber-module-logind',
diff --git a/modules/module-filters-api.c b/modules/module-filters-api.c
new file mode 100644
index 00000000..32c67c85
--- /dev/null
+++ b/modules/module-filters-api.c
@@ -0,0 +1,905 @@
+/* WirePlumber
+ *
+ * Copyright © 2023 Collabora Ltd.
+ *    @author Julian Bouzas <julian.bouzas@collabora.com>
+ *
+ * SPDX-License-Identifier: MIT
+ */
+
+#include <pipewire/keys.h>
+
+#include <wp/wp.h>
+
+struct _WpFiltersApi
+{
+  WpPlugin parent;
+
+  WpObjectManager *metadata_om;
+  WpObjectManager *stream_nodes_om;
+  WpObjectManager *nodes_om;
+  WpObjectManager *filter_nodes_om;
+  guint n_playback_stream_nodes;
+  guint n_capture_stream_nodes;
+  GList *filters[2];
+  GHashTable *targets;
+};
+
+enum {
+  ACTION_IS_FILTER_ENABLED,
+  ACTION_GET_FILTER_TARGET,
+  ACTION_GET_FILTER_FROM_TARGET,
+  ACTION_GET_DEFAULT_FILTER,
+  SIGNAL_CHANGED,
+  N_SIGNALS
+};
+
+static guint signals[N_SIGNALS] = {0};
+
+G_DECLARE_FINAL_TYPE (WpFiltersApi, wp_filters_api, WP, FILTERS_API, WpPlugin)
+G_DEFINE_TYPE (WpFiltersApi, wp_filters_api, WP_TYPE_PLUGIN)
+
+struct _Filter {
+  gchar *link_group;
+  WpDirection direction;
+  WpNode *node;
+  WpNode *stream;
+  gchar *target;
+  gboolean enabled;
+  gint priority;
+};
+typedef struct _Filter Filter;
+
+static guint
+get_filter_priority (const gchar *link_group)
+{
+  if (strstr (link_group, "loopback"))
+    return 300;
+  if (strstr (link_group, "filter-chain"))
+    return 200;
+  /* By default echo-cancel is the lowest priority to properly cancel audio */
+  if (strstr (link_group, "echo-cancel"))
+    return 0;
+  return 100;
+}
+
+static Filter *
+filter_new (const gchar *link_group, WpDirection dir, gboolean is_stream,
+    WpNode *node)
+{
+  Filter *f = g_malloc0 (sizeof (Filter));
+  f->link_group = g_strdup (link_group);
+  f->direction = dir;
+  f->node = is_stream ? NULL : g_object_ref (node);
+  f->stream = is_stream ? g_object_ref (node) : NULL;
+  f->target = NULL;
+  f->enabled = TRUE;
+  f->priority = get_filter_priority (link_group);
+  return f;
+}
+
+static void
+filter_free (Filter *f)
+{
+  g_clear_pointer (&f->link_group, g_free);
+  g_clear_pointer (&f->target, g_free);
+  g_clear_object (&f->node);
+  g_clear_object (&f->stream);
+  g_free (f);
+}
+
+static gint
+filter_equal_func (const Filter *f, const gchar *link_group)
+{
+  return g_str_equal (f->link_group, link_group) ? 0 : 1;
+}
+
+static gint
+filter_compare_func (const Filter *a, const Filter *b)
+{
+  gint diff = a->priority - b->priority;
+  if (diff != 0)
+    return diff;
+  return g_strcmp0 (a->link_group, b->link_group);
+}
+
+static void
+wp_filters_api_init (WpFiltersApi * self)
+{
+}
+
+static gboolean
+wp_filters_api_is_filter_enabled (WpFiltersApi * self, const gchar *direction,
+    const gchar *link_group)
+{
+  WpDirection dir = WP_DIRECTION_INPUT;
+  GList *filters;
+  Filter *found = NULL;
+
+  g_return_val_if_fail (direction, FALSE);
+  g_return_val_if_fail (link_group, FALSE);
+
+  /* Get the filters for the given direction */
+  if (g_str_equal (direction, "output") || g_str_equal (direction, "Output"))
+    dir = WP_DIRECTION_OUTPUT;
+  filters = self->filters[dir];
+
+  /* Find the filter in the filters list */
+  filters = g_list_find_custom (filters, link_group,
+      (GCompareFunc) filter_equal_func);
+  if (!filters)
+    return FALSE;
+
+  found = filters->data;
+  return found->enabled;
+}
+
+static gint
+wp_filters_api_get_filter_target (WpFiltersApi * self, const gchar *direction,
+    const gchar *link_group)
+{
+  WpDirection dir = WP_DIRECTION_INPUT;
+  GList *filters;
+  Filter *found;
+
+  g_return_val_if_fail (direction, -1);
+  g_return_val_if_fail (link_group, -1);
+
+  /* Get the filters for the given direction */
+  if (g_str_equal (direction, "output") || g_str_equal (direction, "Output"))
+    dir = WP_DIRECTION_OUTPUT;
+  filters = self->filters[dir];
+
+  /* Find the filter in the filters list */
+  filters = g_list_find_custom (filters, link_group,
+      (GCompareFunc) filter_equal_func);
+  if (!filters)
+    return -1;
+  found = filters->data;
+  if (!found->enabled)
+    return -1;
+
+  /* Return the previous filter with matching target that is enabled */
+  while ((filters = g_list_previous (filters))) {
+    Filter *prev = (Filter *) filters->data;
+    if ((prev->target == found->target ||
+        (prev->target && found->target &&
+        g_str_equal (prev->target, found->target))) &&
+        prev->enabled)
+      return wp_proxy_get_bound_id (WP_PROXY (prev->node));
+  }
+
+  /* Find the target */
+  if (found->target) {
+    WpNode *node = g_hash_table_lookup (self->targets, found->target);
+    if (node)
+      return wp_proxy_get_bound_id (WP_PROXY (node));
+  }
+
+  return -1;
+}
+
+static gint
+wp_filters_api_get_filter_from_target (WpFiltersApi * self,
+    const gchar *direction, gint target_id)
+{
+  WpDirection dir = WP_DIRECTION_INPUT;
+  GList *filters;
+  gboolean found = FALSE;
+  const gchar *target = NULL;
+  gint res = target_id;
+
+  g_return_val_if_fail (direction, res);
+
+  /* Get the filters for the given direction */
+  if (g_str_equal (direction, "output") || g_str_equal (direction, "Output"))
+    dir = WP_DIRECTION_OUTPUT;
+  filters = self->filters[dir];
+
+  /* Find the first target matching target_id */
+  while (filters) {
+    Filter *f = (Filter *) filters->data;
+    gint f_target_id = wp_filters_api_get_filter_target (self, direction,
+        f->link_group);
+    if (f_target_id == target_id && f->enabled) {
+      target = f->target;
+      found = TRUE;
+      break;
+    }
+
+    /* Advance */
+    filters = g_list_next (filters);
+  }
+
+  /* Just return if target was not found */
+  if (!found)
+    return res;
+
+  /* Get the last filter node ID of the target found */
+  filters = self->filters[dir];
+  while (filters) {
+    Filter *f = (Filter *) filters->data;
+    if ((f->target == target ||
+        (f->target && target && g_str_equal (f->target, target))) &&
+        f->enabled)
+      res = wp_proxy_get_bound_id (WP_PROXY (f->node));
+
+    /* Advance */
+    filters = g_list_next (filters);
+  }
+
+  return res;
+}
+
+static gint
+wp_filters_api_get_default_filter (WpFiltersApi * self, const gchar *direction)
+{
+  WpDirection dir = WP_DIRECTION_INPUT;
+  GList *filters;
+
+  g_return_val_if_fail (direction, -1);
+
+  /* Get the filters for the given direction */
+  if (g_str_equal (direction, "output") || g_str_equal (direction, "Output"))
+    dir = WP_DIRECTION_OUTPUT;
+  filters = self->filters[dir];
+
+  /* The default filter is the highest priority filter without target, this is
+   * the first filer that is enabled because the list is sorted by priority */
+  while (filters) {
+    Filter *f = (Filter *) filters->data;
+    if (f->enabled && !f->target)
+      return wp_proxy_get_bound_id (WP_PROXY (f->node));
+
+    /* Advance */
+    filters = g_list_next (filters);
+  }
+
+  return -1;
+}
+
+static void
+sync_changed (WpCore * core, GAsyncResult * res, WpFiltersApi * self)
+{
+  g_autoptr (GError) error = NULL;
+
+  if (!wp_core_sync_finish (core, res, &error)) {
+    wp_warning_object (self, "core sync error: %s", error->message);
+    return;
+  }
+
+  g_signal_emit (self, signals[SIGNAL_CHANGED], 0);
+}
+
+static WpNode *
+find_target_node (WpFiltersApi *self, WpSpaJson *props_json)
+{
+  g_auto (GValue) item = G_VALUE_INIT;
+  g_autoptr (WpIterator) it = NULL;
+  g_autoptr (WpObjectInterest) interest = NULL;
+
+  /* Make sure the properties are a JSON object */
+  if (!props_json || !wp_spa_json_is_object (props_json)) {
+    wp_warning_object (self, "Target properties must be a JSON object");
+    return NULL;
+  }
+
+  /* Create the object intereset with the target properties */
+  interest = wp_object_interest_new (WP_TYPE_NODE, NULL);
+  it = wp_spa_json_new_iterator (props_json);
+  for (; wp_iterator_next (it, &item); g_value_unset (&item)) {
+    WpSpaJson *j = g_value_get_boxed (&item);
+    g_autofree gchar *key = NULL;
+    WpSpaJson *value_json;
+    g_autofree gchar *value = NULL;
+
+    key = wp_spa_json_parse_string (j);
+    g_value_unset (&item);
+    if (!wp_iterator_next (it, &item)) {
+      wp_warning_object (self,
+        "Could not get valid key-value pairs from target properties");
+      break;
+    }
+    value_json = g_value_get_boxed (&item);
+    value = wp_spa_json_parse_string (value_json);
+    if (!value) {
+      wp_warning_object (self,
+        "Could not get '%s' value from target properties", key);
+      break;
+    }
+
+    wp_object_interest_add_constraint (interest, WP_CONSTRAINT_TYPE_PW_PROPERTY,
+        key, WP_CONSTRAINT_VERB_MATCHES, g_variant_new_string (value));
+  }
+
+  return wp_object_manager_lookup_full (self->nodes_om,
+      wp_object_interest_ref (interest));
+}
+
+static gboolean
+reevaluate_targets (WpFiltersApi *self)
+{
+  g_autoptr (WpMetadata) m = NULL;
+  const gchar *json_str;
+  g_autoptr (WpSpaJson) json = NULL;
+  g_auto (GValue) item = G_VALUE_INIT;
+  g_autoptr (WpIterator) it = NULL;
+  gboolean changed = FALSE;
+
+  g_hash_table_remove_all (self->targets);
+
+  /* Make sure the metadata exists */
+  m = wp_object_manager_lookup (self->metadata_om, WP_TYPE_METADATA, NULL);
+  if (!m)
+    return FALSE;
+
+  /* Don't update anything if the metadata value is not set */
+  json_str = wp_metadata_find (m, 0, "filters.configured.targets", NULL);
+  if (!json_str)
+    return FALSE;
+
+  /* Make sure the metadata value is an object */
+  json = wp_spa_json_new_from_string (json_str);
+  if (!json || !wp_spa_json_is_object (json)) {
+    wp_warning_object (self,
+        "ignoring metadata value as it is not a JSON object: %s", json_str);
+    return FALSE;
+  }
+
+  /* Find the target node for each target, and add it to the hash table */
+  it = wp_spa_json_new_iterator (json);
+  for (; wp_iterator_next (it, &item); g_value_unset (&item)) {
+    WpSpaJson *j = g_value_get_boxed (&item);
+    g_autofree gchar *key = NULL;
+    WpSpaJson *props;
+    g_autoptr (WpNode) target = NULL;
+    WpNode *curr_target;
+
+    key = wp_spa_json_parse_string (j);
+    g_value_unset (&item);
+    if (!wp_iterator_next (it, &item)) {
+      wp_warning_object (self,
+        "Could not get valid key-value pairs from target object");
+      break;
+    }
+    props = g_value_get_boxed (&item);
+
+    /* Get current target */
+    curr_target = g_hash_table_lookup (self->targets, key);
+
+    /* Find the node and insert it into the table if found */
+    target = find_target_node (self, props);
+    if (target) {
+      /* Check if the target changed */
+      if (curr_target) {
+        guint32 target_bound_id = wp_proxy_get_bound_id (WP_PROXY (target));
+        guint32 curr_bound_id = wp_proxy_get_bound_id (WP_PROXY (curr_target));
+        if (target_bound_id != curr_bound_id)
+          changed = TRUE;
+      }
+
+      g_hash_table_insert (self->targets, g_strdup (key),
+          g_steal_pointer (&target));
+    } else {
+      if (curr_target)
+        changed = TRUE;
+    }
+  }
+
+  return changed;
+}
+
+static gboolean
+update_values_from_metadata (WpFiltersApi * self, Filter *f)
+{
+  g_autoptr (WpMetadata) m = NULL;
+  const gchar *f_stream_name;
+  const gchar *f_node_name;
+  const gchar *json_str;
+  g_autoptr (WpSpaJson) json = NULL;
+  g_auto (GValue) item = G_VALUE_INIT;
+  g_autoptr (WpIterator) it = NULL;
+  gboolean changed = FALSE;
+
+  /* Make sure the metadata exists */
+  m = wp_object_manager_lookup (self->metadata_om, WP_TYPE_METADATA, NULL);
+  if (!m)
+    return FALSE;
+
+  /* Make sure both the stream and node are available */
+  if (!f->stream || !f->node)
+    return FALSE;
+  f_stream_name = wp_pipewire_object_get_property (
+      WP_PIPEWIRE_OBJECT (f->stream), PW_KEY_NODE_NAME);
+  f_node_name = wp_pipewire_object_get_property (
+      WP_PIPEWIRE_OBJECT (f->node), PW_KEY_NODE_NAME);
+
+  /* Don't update anything if the metadata value is not set */
+  json_str = wp_metadata_find (m, 0, "filters.configured.filters", NULL);
+  if (!json_str)
+    return FALSE;
+
+  /* Make sure the metadata value is an array */
+  json = wp_spa_json_new_from_string (json_str);
+  if (!json || !wp_spa_json_is_array (json)) {
+    wp_warning_object (self,
+        "ignoring metadata value as it is not a JSON array: %s", json_str);
+    return FALSE;
+  }
+
+  /* Find the filter values in the metadata */
+  it = wp_spa_json_new_iterator (json);
+  for (; wp_iterator_next (it, &item); g_value_unset (&item)) {
+    WpSpaJson *j = g_value_get_boxed (&item);
+    g_autofree gchar *stream_name = NULL;
+    g_autofree gchar *node_name = NULL;
+    g_autofree gchar *direction = NULL;
+    g_autofree gchar *target = NULL;
+    g_autofree gchar *mode = NULL;
+    WpDirection dir = WP_DIRECTION_INPUT;
+    gint priority;
+
+    if (!j || !wp_spa_json_is_object (j))
+      continue;
+
+    /* Parse mandatory fields */
+    if (!wp_spa_json_object_get (j, "stream-name", "s", &stream_name,
+        "node-name", "s", &node_name, "direction", "s", &direction, NULL)) {
+      g_autofree gchar *str = wp_spa_json_to_string (j);
+      wp_warning_object (self,
+          "failed to parse stream-name, node-name and direction in filter: %s",
+          str);
+      continue;
+    }
+
+    /* Make sure direction is valid */
+    if (g_str_equal (direction, "input")) {
+      dir = WP_DIRECTION_INPUT;
+    } else if (g_str_equal (direction, "output")) {
+      dir = WP_DIRECTION_OUTPUT;
+    } else {
+      g_autofree gchar *str = wp_spa_json_to_string (j);
+      wp_warning_object (self,
+          "direction %s is not valid for filter: %s", direction, str);
+    }
+
+    /* Find first filter matching stream-name, node-name and direction */
+    if (g_str_equal (f_stream_name, stream_name) &&
+        g_str_equal (f_node_name, node_name) &&
+        f->direction == dir) {
+
+      /* Update target */
+      if (wp_spa_json_object_get (j, "target", "s", &target, NULL)) {
+        if (!f->target || !g_str_equal (f->target, target)) {
+          g_clear_pointer (&f->target, g_free);
+          f->target = g_strdup (target);
+          changed = TRUE;
+        }
+      } else {
+        if (f->target) {
+          g_clear_pointer (&f->target, g_free);
+          changed = TRUE;
+        }
+      }
+
+      /* Update mode */
+      if (wp_spa_json_object_get (j, "mode", "s", &mode, NULL)) {
+        if (g_str_equal (mode, "always")) {
+          if (!f->enabled) {
+            f->enabled = TRUE;
+            changed = TRUE;
+          }
+        } else if (g_str_equal (mode, "never")) {
+          if (f->enabled) {
+            f->enabled = FALSE;
+            changed = TRUE;
+          }
+        } else if (g_str_equal (mode, "playback-only")) {
+          if (f->enabled != (self->n_playback_stream_nodes > 0)) {
+            f->enabled = self->n_playback_stream_nodes > 0;
+            changed = TRUE;
+          }
+        } else if (g_str_equal (mode, "capture-only")) {
+          if (f->enabled != (self->n_capture_stream_nodes > 0)) {
+            f->enabled = self->n_capture_stream_nodes > 0;
+            changed = TRUE;
+          }
+        } else {
+          wp_warning_object (self,
+              "The '%s' value is not a valid for the 'mode' filter field",
+              mode);
+        }
+      }
+
+      /* Update priority */
+      if (wp_spa_json_object_get (j, "priority", "i", &priority, NULL)) {
+        if (f->priority != priority) {
+          f->priority = priority;
+          changed = TRUE;
+        }
+      }
+      break;
+    }
+  }
+
+  return changed;
+}
+
+static gboolean
+reevaluate_filters (WpFiltersApi *self, WpDirection direction)
+{
+  GList *filters;
+  gboolean changed = FALSE;
+
+  /* Update filter values */
+  filters = self->filters[direction];
+  while (filters) {
+    Filter *f = (Filter *) filters->data;
+    if (update_values_from_metadata (self, f))
+      changed = TRUE;
+    filters = g_list_next (filters);
+  }
+
+  /* Sort filters if changed */
+  if (changed)
+    self->filters[direction] = g_list_sort (self->filters[direction],
+        (GCompareFunc) filter_compare_func);
+
+  return changed;
+}
+
+static void
+schedule_changed (WpFiltersApi * self)
+{
+  g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self));
+  g_return_if_fail (core);
+
+  wp_core_sync_closure (core, NULL, g_cclosure_new_object (
+      G_CALLBACK (sync_changed), G_OBJECT (self)));
+}
+
+static void
+on_stream_node_added (WpObjectManager *om, WpPipewireObject *proxy, gpointer d)
+{
+  WpFiltersApi * self = WP_FILTERS_API (d);
+  const gchar* media_class = wp_pipewire_object_get_property (proxy,
+      PW_KEY_MEDIA_CLASS);
+
+  if (g_str_equal (media_class, "Stream/Output/Audio"))
+    self->n_playback_stream_nodes++;
+  else if (g_str_equal (media_class, "Stream/Input/Audio"))
+    self->n_capture_stream_nodes++;
+}
+
+static void
+on_stream_node_removed (WpObjectManager *om, WpPipewireObject *proxy, gpointer d)
+{
+  WpFiltersApi * self = WP_FILTERS_API (d);
+  const gchar* media_class = wp_pipewire_object_get_property (proxy,
+      PW_KEY_MEDIA_CLASS);
+
+  if (g_str_equal (media_class, "Stream/Output/Audio") &&
+      self->n_playback_stream_nodes > 0)
+    self->n_playback_stream_nodes--;
+  else if (g_str_equal (media_class, "Stream/Input/Audio") &&
+      self->n_capture_stream_nodes > 0)
+    self->n_capture_stream_nodes--;
+}
+
+static void
+on_stream_nodes_changed (WpObjectManager *om, gpointer d)
+{
+  WpFiltersApi * self = WP_FILTERS_API (d);
+  gboolean changed = FALSE;
+
+  /* Reevaluate everything */
+  for (guint i = 0; i < 2; i++)
+    if (reevaluate_filters (self, i))
+      changed = TRUE;
+
+  if (changed)
+    schedule_changed (self);
+}
+
+static void
+on_node_added (WpObjectManager *om, WpPipewireObject *proxy, gpointer d)
+{
+  WpFiltersApi * self = WP_FILTERS_API (d);
+  reevaluate_targets (self);
+}
+
+static void
+on_node_removed (WpObjectManager *om, WpPipewireObject *proxy, gpointer d)
+{
+  WpFiltersApi * self = WP_FILTERS_API (d);
+  reevaluate_targets (self);
+}
+
+static void
+on_filter_node_added (WpObjectManager *om, WpPipewireObject *proxy, gpointer d)
+{
+  WpFiltersApi * self = WP_FILTERS_API (d);
+  const gchar *key;
+  WpDirection dir;
+  gboolean is_stream;
+  GList *found;
+
+  /* Get direction */
+  key = wp_pipewire_object_get_property (proxy, PW_KEY_MEDIA_CLASS);
+  if (!key)
+    return;
+
+  if (g_str_equal (key, "Audio/Sink") ||
+      g_str_equal (key, "Stream/Output/Audio")) {
+    dir = WP_DIRECTION_INPUT;
+  } else if (g_str_equal (key, "Audio/Source") ||
+      g_str_equal (key, "Stream/Input/Audio")) {
+    dir = WP_DIRECTION_OUTPUT;
+  } else {
+    wp_debug_object (self, "ignoring node with media class: %s", key);
+    return;
+  }
+
+  /* Check whether the proxy is a stream or not */
+  is_stream = FALSE;
+  if (g_str_equal (key, "Stream/Output/Audio") ||
+      g_str_equal (key, "Stream/Input/Audio"))
+    is_stream = TRUE;
+
+  /* We use the link group as filter name */
+  key = wp_pipewire_object_get_property (proxy, PW_KEY_NODE_LINK_GROUP);
+  if (!key) {
+    wp_debug_object (self, "ignoring node without link group");
+    return;
+  }
+
+  /* Check if the filter already exists, and add it if it does not exist */
+  found = g_list_find_custom (self->filters[dir], key,
+      (GCompareFunc) filter_equal_func);
+  if (!found) {
+    Filter *f = filter_new (key, dir, is_stream, WP_NODE (proxy));
+    update_values_from_metadata (self, f);
+    self->filters[dir] = g_list_insert_sorted (self->filters[dir],
+        f, (GCompareFunc) filter_compare_func);
+  } else {
+    Filter *f = found->data;
+    if (is_stream) {
+      g_clear_object (&f->stream);
+      f->stream = g_object_ref (WP_NODE (proxy));
+    } else {
+      g_clear_object (&f->node);
+      f->node = g_object_ref (WP_NODE (proxy));
+    }
+    update_values_from_metadata (self, f);
+    self->filters[dir] = g_list_sort (self->filters[dir],
+        (GCompareFunc) filter_compare_func);
+  }
+}
+
+static void
+on_filter_node_removed (WpObjectManager *om, WpPipewireObject *proxy,
+    gpointer d)
+{
+  WpFiltersApi * self = WP_FILTERS_API (d);
+
+  const gchar *key;
+  WpDirection dir;
+  GList *found;
+
+  /* Get direction */
+  key = wp_pipewire_object_get_property (proxy, PW_KEY_MEDIA_CLASS);
+  if (!key)
+    return;
+
+  if (g_str_equal (key, "Audio/Sink") ||
+      g_str_equal (key, "Stream/Output/Audio")) {
+    dir = WP_DIRECTION_INPUT;
+  } else if (g_str_equal (key, "Audio/Source") ||
+      g_str_equal (key, "Stream/Input/Audio")) {
+    dir = WP_DIRECTION_OUTPUT;
+  } else {
+    wp_debug_object (self, "ignoring node with media class: %s", key);
+    return;
+  }
+
+  /* We use the link group as filter name */
+  key = wp_pipewire_object_get_property (proxy, PW_KEY_NODE_LINK_GROUP);
+  if (!key) {
+    wp_debug_object (self, "ignoring node without link group");
+    return;
+  }
+
+  /* Find and remove the filter */
+  found = g_list_find_custom (self->filters[dir], key,
+      (GCompareFunc) filter_equal_func);
+  if (found) {
+    self->filters[dir] = g_list_remove (self->filters[dir], found->data);
+  }
+}
+
+static void
+on_metadata_changed (WpMetadata *m, guint32 subject,
+    const gchar *key, const gchar *type, const gchar *value, gpointer d)
+{
+  WpFiltersApi * self = WP_FILTERS_API (d);
+  gboolean changed = FALSE;
+
+  /* Reevaluate everything */
+  if (reevaluate_targets (self))
+    changed = TRUE;
+  for (guint i = 0; i < 2; i++)
+    if (reevaluate_filters (self, i))
+      changed = TRUE;
+
+  if (changed)
+    schedule_changed (self);
+}
+
+static void
+on_metadata_added (WpObjectManager *om, WpMetadata *metadata, gpointer d)
+{
+  WpFiltersApi * self = WP_FILTERS_API (d);
+  gboolean changed = FALSE;
+
+  /* Handle the changed signal */
+  g_signal_connect_object (metadata, "changed",
+      G_CALLBACK (on_metadata_changed), self, 0);
+
+  /* Reevaluate everything */
+  if (reevaluate_targets (self))
+    changed = TRUE;
+  for (guint i = 0; i < 2; i++)
+    if (reevaluate_filters (self, i))
+      changed = TRUE;
+
+  if (changed)
+    schedule_changed (self);
+}
+
+static void
+on_metadata_installed (WpObjectManager * om, WpFiltersApi * self)
+{
+  g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self));
+
+  /* Create the stream nodes object manager */
+  self->stream_nodes_om = wp_object_manager_new ();
+  wp_object_manager_add_interest (self->stream_nodes_om, WP_TYPE_NODE,
+      WP_CONSTRAINT_TYPE_PW_PROPERTY, PW_KEY_MEDIA_CLASS, "#s", "Stream/*/Audio",
+      WP_CONSTRAINT_TYPE_PW_PROPERTY, PW_KEY_NODE_LINK_GROUP, "-",
+      NULL);
+  wp_object_manager_request_object_features (self->stream_nodes_om,
+      WP_TYPE_NODE, WP_OBJECT_FEATURES_ALL);
+  g_signal_connect_object (self->stream_nodes_om, "object-added",
+      G_CALLBACK (on_stream_node_added), self, 0);
+  g_signal_connect_object (self->stream_nodes_om, "object-removed",
+      G_CALLBACK (on_stream_node_removed), self, 0);
+  g_signal_connect_object (self->stream_nodes_om, "objects-changed",
+      G_CALLBACK (on_stream_nodes_changed), self, 0);
+  wp_core_install_object_manager (core, self->stream_nodes_om);
+
+  /* Create the nodes object manager */
+  self->nodes_om = wp_object_manager_new ();
+  wp_object_manager_add_interest (self->nodes_om, WP_TYPE_NODE,
+      WP_CONSTRAINT_TYPE_PW_PROPERTY, PW_KEY_MEDIA_CLASS, "#s", "Audio/*",
+      WP_CONSTRAINT_TYPE_PW_PROPERTY, PW_KEY_NODE_LINK_GROUP, "-",
+      NULL);
+  wp_object_manager_request_object_features (self->nodes_om,
+      WP_TYPE_NODE, WP_OBJECT_FEATURES_ALL);
+  g_signal_connect_object (self->nodes_om, "object-added",
+      G_CALLBACK (on_node_added), self, 0);
+  g_signal_connect_object (self->nodes_om, "object-removed",
+      G_CALLBACK (on_node_removed), self, 0);
+  g_signal_connect_object (self->nodes_om, "objects-changed",
+      G_CALLBACK (schedule_changed), self, G_CONNECT_SWAPPED);
+  wp_core_install_object_manager (core, self->nodes_om);
+
+  /* Create the filter nodes object manager */
+  self->filter_nodes_om = wp_object_manager_new ();
+  wp_object_manager_add_interest (self->filter_nodes_om, WP_TYPE_NODE,
+      WP_CONSTRAINT_TYPE_PW_PROPERTY, PW_KEY_NODE_LINK_GROUP, "+",
+      NULL);
+  wp_object_manager_request_object_features (self->filter_nodes_om,
+      WP_TYPE_NODE, WP_OBJECT_FEATURES_ALL);
+  g_signal_connect_object (self->filter_nodes_om, "object-added",
+      G_CALLBACK (on_filter_node_added), self, 0);
+  g_signal_connect_object (self->filter_nodes_om, "object-removed",
+      G_CALLBACK (on_filter_node_removed), self, 0);
+  g_signal_connect_object (self->filter_nodes_om, "objects-changed",
+      G_CALLBACK (schedule_changed), self, G_CONNECT_SWAPPED);
+  wp_core_install_object_manager (core, self->filter_nodes_om);
+
+  wp_object_update_features (WP_OBJECT (self), WP_PLUGIN_FEATURE_ENABLED, 0);
+}
+
+static void
+wp_filters_api_enable (WpPlugin * plugin, WpTransition * transition)
+{
+  WpFiltersApi * self = WP_FILTERS_API (plugin);
+  g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self));
+
+  self->targets = g_hash_table_new_full (g_str_hash, g_str_equal, g_free,
+      g_object_unref);
+
+  /* Create the metadata object manager */
+  self->metadata_om = wp_object_manager_new ();
+  wp_object_manager_add_interest (self->metadata_om, WP_TYPE_METADATA,
+      WP_CONSTRAINT_TYPE_PW_GLOBAL_PROPERTY, "metadata.name", "=s", "filters",
+      NULL);
+  wp_object_manager_request_object_features (self->metadata_om,
+      WP_TYPE_METADATA, WP_OBJECT_FEATURES_ALL);
+  g_signal_connect_object (self->metadata_om, "object-added",
+      G_CALLBACK (on_metadata_added), self, 0);
+  g_signal_connect_object (self->metadata_om, "installed",
+      G_CALLBACK (on_metadata_installed), self, 0);
+  wp_core_install_object_manager (core, self->metadata_om);
+}
+
+static void
+wp_filters_api_disable (WpPlugin * plugin)
+{
+  WpFiltersApi * self = WP_FILTERS_API (plugin);
+
+  for (guint i = 0; i < 2; i++) {
+    if (self->filters[i]) {
+      g_list_free_full (self->filters[i], (GDestroyNotify) filter_free);
+      self->filters[i] = NULL;
+    }
+  }
+  g_clear_pointer (&self->targets, g_hash_table_unref);
+
+  g_clear_object (&self->metadata_om);
+  g_clear_object (&self->stream_nodes_om);
+  g_clear_object (&self->nodes_om);
+  g_clear_object (&self->filter_nodes_om);
+}
+
+static void
+wp_filters_api_class_init (WpFiltersApiClass * klass)
+{
+  WpPluginClass *plugin_class = (WpPluginClass *) klass;
+
+  plugin_class->enable = wp_filters_api_enable;
+  plugin_class->disable = wp_filters_api_disable;
+
+  signals[ACTION_IS_FILTER_ENABLED] = g_signal_new_class_handler (
+      "is-filter-enabled", G_TYPE_FROM_CLASS (klass),
+      G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
+      (GCallback) wp_filters_api_is_filter_enabled,
+      NULL, NULL, NULL,
+      G_TYPE_BOOLEAN, 2, G_TYPE_STRING, G_TYPE_STRING);
+
+  signals[ACTION_GET_FILTER_TARGET] = g_signal_new_class_handler (
+      "get-filter-target", G_TYPE_FROM_CLASS (klass),
+      G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
+      (GCallback) wp_filters_api_get_filter_target,
+      NULL, NULL, NULL,
+      G_TYPE_INT, 2, G_TYPE_STRING, G_TYPE_STRING);
+
+  signals[ACTION_GET_FILTER_FROM_TARGET] = g_signal_new_class_handler (
+      "get-filter-from-target", G_TYPE_FROM_CLASS (klass),
+      G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
+      (GCallback) wp_filters_api_get_filter_from_target,
+      NULL, NULL, NULL,
+      G_TYPE_INT, 2, G_TYPE_STRING, G_TYPE_INT);
+
+  signals[ACTION_GET_DEFAULT_FILTER] = g_signal_new_class_handler (
+      "get-default-filter", G_TYPE_FROM_CLASS (klass),
+      G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
+      (GCallback) wp_filters_api_get_default_filter,
+      NULL, NULL, NULL,
+      G_TYPE_INT, 1, G_TYPE_STRING);
+
+  signals[SIGNAL_CHANGED] = g_signal_new (
+      "changed", G_TYPE_FROM_CLASS (klass),
+      G_SIGNAL_RUN_LAST, 0, NULL, NULL, NULL,
+      G_TYPE_NONE, 0);
+}
+
+WP_PLUGIN_EXPORT gboolean
+wireplumber__module_init (WpCore * core, GVariant * args, GError ** error)
+{
+  wp_plugin_register (g_object_new (wp_filters_api_get_type (),
+      "name", "filters-api",
+      "core", core,
+      NULL));
+  return TRUE;
+}
diff --git a/modules/module-lua-scripting/api/json.c b/modules/module-lua-scripting/api/json.c
index 2883a021..3d486be8 100644
--- a/modules/module-lua-scripting/api/json.c
+++ b/modules/module-lua-scripting/api/json.c
@@ -264,7 +264,7 @@ spa_json_array_new (lua_State *L)
           break;
         }
         default:
-          luaL_error (L, "Json does not support lua type ",
+          luaL_error (L, "Json does not support lua type %s",
               lua_typename(L, lua_type(L, -1)));
           break;
       }
diff --git a/src/config/policy.lua.d/10-default-policy.lua b/src/config/policy.lua.d/10-default-policy.lua
index 83d0a3b2..d3621a73 100644
--- a/src/config/policy.lua.d/10-default-policy.lua
+++ b/src/config/policy.lua.d/10-default-policy.lua
@@ -12,6 +12,9 @@ default_policy.policy = {
   -- surround audio if echo-cancel is enabled.
   ["filter.forward-format"] = false,
 
+  -- Whether to enable smart filter policy or not (experimental feature)
+  ["filter.smart"] = false,
+
   -- Set to 'true' to disable channel splitting & merging on nodes and enable
   -- passthrough of audio in the same format as the format of the device.
   -- Note that this breaks JACK support; it is generally not recommended
@@ -71,6 +74,12 @@ function default_policy.enable()
   -- API to access default nodes from scripts
   load_module("default-nodes-api")
 
+  -- Load smart filter policy if requested
+  if default_policy.policy["filter.smart"] then
+    load_script("filters-metadata.lua", default_policy.filters_metadata)
+    load_module("filters-api")
+  end
+
   -- API to access mixer controls, needed for volume ducking
   load_module("mixer-api")
 
diff --git a/src/config/policy.lua.d/30-filters-config.lua b/src/config/policy.lua.d/30-filters-config.lua
new file mode 100644
index 00000000..76aecad0
--- /dev/null
+++ b/src/config/policy.lua.d/30-filters-config.lua
@@ -0,0 +1,73 @@
+-- The smart filter policy configuration.
+-- You need to enable "filter.smart" in 10-default-policy.lua
+--
+
+-- The default filter metadata configuration when wireplumber starts. They also
+-- can be changed at runtime.
+default_policy.filters_metadata = {
+  ["filters"] = {
+    -- Input filters (meant to be linked with Audio/Sink device nodes)
+    {
+      ["stream-name"] = "output.virtual-sink",  -- loopback playback
+      ["node-name"] = "input.virtual-sink",     -- loopback sink
+      ["direction"] = "input",  -- can only be 'input' or 'output'
+      ["target"] = nil,  -- if nil, the default node will be used as target
+      ["mode"] = "always",  -- can be 'always', 'never', 'capture-only' or 'playback-only'
+      ["priority"] = 30,
+    },
+    {
+      ["stream-name"] = "filter-chain-playback",  -- filter-chain playback
+      ["node-name"] = "filter-chain-sink",        -- filter-chain sink
+      ["direction"] = "input",  -- can only be 'input' or 'output'
+      ["target"] = "speakers",  -- if nil, the default node will be used as target
+      ["mode"] = "always",  -- can be 'always', 'never', 'capture-only' or 'playback-only'
+      ["priority"] = 20,
+    },
+    {
+      ["stream-name"] = "echo-cancel-playback",  -- echo-cancel playback
+      ["node-name"] = "echo-cancel-sink",        -- echo-cancel sink
+      ["direction"] = "input",  -- can only be 'input' or 'output'
+      ["target"] = "speakers",  -- if nil, the default node will be used as target
+      ["mode"] = "capture-only",  -- can be 'always', 'never', 'playback-only' or 'capture-only'
+      ["priority"] = 10,
+    },
+
+    -- Output filters (meant to be linked with Audio/Source device nodes)
+    {
+      ["stream-name"] = "input.virtual-source",  -- loopback capture
+      ["node-name"] = "output.virtual-source",   -- loopback source
+      ["direction"] = "output",  -- can only be 'input' or 'output'
+      ["target"] = nil,  -- if nil, the default node will be used as target
+      ["mode"] = "always",  -- can be 'always', 'never', 'playback-only' or 'capture-only'
+      ["priority"] = 30,
+    },
+    {
+      ["stream-name"] = "filter-chain-capture",  -- filter-chain capture
+      ["node-name"] = "filter-chain-source",     -- filter-chain source
+      ["direction"] = "output",  -- can only be 'input' or 'output'
+      ["target"] = "microphone",  -- if nil, the default node will be used as target
+      ["mode"] = "capture-only",  -- can be 'always', 'never', 'playback-only' or 'capture-only'
+      ["priority"] = 20,
+    },
+    {
+      ["stream-name"] = "echo-cancel-capture",  -- echo-cancel capture
+      ["node-name"] = "echo-cancel-source",     -- echo-cancel source
+      ["direction"] = "output",  -- can only be 'input' or 'output'
+      ["target"] = "microphone",  -- if nil, the default node will be used as target
+      ["mode"] = "capture-only",  -- can be 'always', 'never', 'playback-only' or 'capture-only'
+      ["priority"] = 10,
+    }
+  },
+
+  -- The target node properties (any node properties can be defined)
+  ["targets"] = {
+    ["speakers"] = {
+      ["media.class"] = "Audio/Sink",
+      ["alsa.card_name"] = "my-speakers-card-name",
+    },
+    ["microphone"] = {
+      ["media.class"] = "Audio/Source",
+      ["alsa.card_name"] = "my-microphone-card-name",
+    }
+  }
+}
diff --git a/src/scripts/filters-metadata.lua b/src/scripts/filters-metadata.lua
new file mode 100644
index 00000000..04e3e6c6
--- /dev/null
+++ b/src/scripts/filters-metadata.lua
@@ -0,0 +1,39 @@
+-- WirePlumber
+--
+-- Copyright © 2023 Collabora Ltd.
+--    @author Julian Bouzas <julian.bouzas@collabora.com>
+--
+-- SPDX-License-Identifier: MIT
+
+-- Receive script arguments
+local config = ... or {}
+config["filters"] = config["filters"] or {}
+config["targets"] = config["targets"] or {}
+
+f_metadata = ImplMetadata("filters")
+f_metadata:activate(Features.ALL, function (m, e)
+  if e then
+    Log.warning("failed to activate filters metadata: " .. tostring(e))
+    return
+  end
+
+  Log.info("activated filters metadata")
+
+  -- Set filters metadata
+  local filters = {}
+  for _, f in ipairs(config["filters"]) do
+    table.insert (filters, Json.Object (f))
+  end
+  local filters_json = Json.Array (filters)
+  m:set (0, "filters.configured.filters", "Spa:String:JSON",
+          filters_json:to_string())
+
+  -- Set targets metadata
+  local targets = {}
+  for name, props in pairs(config["targets"]) do
+    targets[name] = Json.Object (props)
+  end
+  local targets_json = Json.Object (targets)
+  m:set (0, "filters.configured.targets", "Spa:String:JSON",
+          targets_json:to_string())
+end)
diff --git a/src/scripts/policy-node.lua b/src/scripts/policy-node.lua
index 99ad8473..f249f343 100644
--- a/src/scripts/policy-node.lua
+++ b/src/scripts/policy-node.lua
@@ -18,6 +18,7 @@ self.scanning = false
 self.pending_rescan = false
 self.events_skipped = false
 self.pending_error_timer = nil
+self.filters_api = Plugin.find("filters-api")
 
 function rescan()
   for si in linkables_om:iterate() do
@@ -437,9 +438,21 @@ function findDefaultLinkable (si)
   local si_props = si.properties
   local target_direction = getTargetDirection(si_props)
   local def_node_id = getDefaultNode(si_props, target_direction)
-  return linkables_om:lookup {
+  local si_target = linkables_om:lookup {
       Constraint { "node.id", "=", tostring(def_node_id) }
   }
+
+  -- get origin filter from default target if filters API is enabled
+  if self.filters_api ~= nil and si_target ~= nil then
+    local target_node_id = si_target.properties["node.id"]
+    target_node_id = self.filters_api:call("get-filter-from-target",
+        target_direction, target_node_id)
+    si_target = linkables_om:lookup {
+      Constraint { "node.id", "=", tostring(target_node_id) }
+    }
+  end
+
+  return si_target
 end
 
 function checkPassthroughCompatibility (si, si_target)
@@ -468,12 +481,19 @@ function findBestLinkable (si)
   } do
     local si_target_props = si_target.properties
     local si_target_node_id = si_target_props["node.id"]
+    local si_target_node = si:get_associated_proxy ("node")
+    local si_target_link_group = si_target_node.properties["node.link-group"]
     local priority = tonumber(si_target_props["priority.session"]) or 0
 
     Log.debug(string.format("Looking at: %s (%s)",
         tostring(si_target_props["node.name"]),
         tostring(si_target_node_id)))
 
+    -- Never use a filter as a best linkable if filters API is loaded
+    if self.filters_api ~= nil and si_target_link_group ~= nil then
+      goto skip_linkable
+    end
+
     if not canLink (si_props, si_target) then
       Log.debug("... cannot link, skip linkable")
       goto skip_linkable
@@ -517,6 +537,25 @@ function findBestLinkable (si)
       tostring(target_picked.properties["node.name"]),
       tostring(target_picked.properties["node.id"]),
       tostring(target_can_passthrough)))
+
+    -- get origin filter from target if filters API is enabled
+    if self.filters_api ~= nil and target_picked ~= nil then
+      local target_node_id = target_picked.properties["node.id"]
+      target_node_id = self.filters_api:call("get-filter-from-target",
+          target_direction, target_node_id)
+      target_picked = linkables_om:lookup {
+        Constraint { "node.id", "=", tostring(target_node_id) }
+      }
+      if target_picked == nil then
+        return nil, nil
+      end
+      target_can_passthrough = checkPassthroughCompatibility (si, target_picked)
+      Log.info(string.format("... best filter picked: %s (%s), can_passthrough:%s",
+        tostring(target_picked.properties["node.name"]),
+        tostring(target_picked.properties["node.id"]),
+        tostring(target_can_passthrough)))
+    end
+
     return target_picked, target_can_passthrough
   else
     return nil, nil
@@ -564,6 +603,28 @@ function lookupLink (si_id, si_target_id)
   return link
 end
 
+function checkFilter (si, handle_nonstreams)
+  -- handle all filter nodes if filters API is not loaded
+  if self.filters_api == nil then
+    return true
+  end
+
+  -- always handle filters if handle_nonstreams is true, even if it is disabled
+  if handle_nonstreams then
+    return true
+  end
+
+  -- always return true if this is not a filter
+  local node = si:get_associated_proxy ("node")
+  local link_group = node.properties["node.link-group"]
+  if link_group == nil then
+    return true
+  end
+
+  local direction = getTargetDirection(si.properties)
+  return self.filters_api:call("is-filter-enabled", direction, link_group)
+end
+
 function checkLinkable(si, handle_nonstreams)
   -- only handle stream session items
   local si_props = si.properties
@@ -572,6 +633,11 @@ function checkLinkable(si, handle_nonstreams)
     return false
   end
 
+  -- check filters
+  if not checkFilter (si, handle_nonstreams) then
+    return false
+  end
+
   -- Determine if we can handle item by this policy
   if endpoints_om:get_n_objects () > 0 and
       si_props["item.factory.name"] == "si-audio-adapter" then
@@ -652,6 +718,37 @@ function checkFollowDefault (si, si_target, has_node_defined_target)
   end
 end
 
+function findFilterTarget (si)
+  local node = si:get_associated_proxy ("node")
+  local direction = getTargetDirection (si.properties)
+  local link_group = node.properties["node.link-group"]
+  local target_id = -1
+
+  -- always return nil if filters API is not loaded
+  if self.filters_api == nil then
+    return nil
+  end
+
+  if link_group == nil then
+    -- if this is a client stream that is not a filter, link it to the highest
+    -- priority filter that does not have a group, if any.
+    target_id = self.filters_api:call("get-default-filter", direction)
+  else
+    -- if this is a filter, get its target
+    target_id = self.filters_api:call("get-filter-target",
+      direction, link_group)
+  end
+
+  if (target_id == -1) then
+    return nil
+  end
+
+  Log.info (".. filter target ID is " .. tostring(target_id))
+  return linkables_om:lookup {
+      Constraint { "node.id", "=", tostring(target_id) }
+  }
+end
+
 function handleLinkable (si)
   if checkPending () then
     return
@@ -683,11 +780,19 @@ function handleLinkable (si)
   local si_target, has_defined_target, has_node_defined_target
       = findDefinedTarget(si_props)
   local can_passthrough = si_target and canPassthrough(si, si_target)
-
   if si_target and si_must_passthrough and not can_passthrough then
     si_target = nil
   end
 
+  -- find filter target (always returns nil for non filters)
+  if si_target == nil then
+    si_target = findFilterTarget(si)
+    local can_passthrough = si_target and canPassthrough(si, si_target)
+    if si_target and si_must_passthrough and not can_passthrough then
+      si_target = nil
+    end
+  end
+
   -- if the client has seen a target that we haven't yet prepared, schedule
   -- a rescan one more time and hope for the best
   local si_id = si.id
@@ -876,6 +981,17 @@ if config.follow and default_nodes ~= nil then
   end)
 end
 
+-- listen for filter node changes
+if self.filters_api ~= nil then
+  self.filters_api:connect("changed", function ()
+    -- unlink all the filters and schedule rescan
+    for si in linkables_om:iterate { Constraint { "node.link-group", "+" } } do
+      unhandleLinkable (si)
+    end
+    scheduleRescan ()
+  end)
+end
+
 -- listen for target.node metadata changes if config.move is enabled
 if config.move then
   metadata_om:connect("object-added", function (om, metadata)
-- 
2.42.0


From bea7195a12ba1014f62e40f80c0f2e127a1aaa2e Mon Sep 17 00:00:00 2001
From: Julian Bouzas <julian.bouzas@collabora.com>
Date: Thu, 23 Mar 2023 09:26:45 -0400
Subject: [PATCH 03/11] config: do not restore stream target by default

---
 src/config/main.lua.d/40-stream-defaults.lua | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/config/main.lua.d/40-stream-defaults.lua b/src/config/main.lua.d/40-stream-defaults.lua
index b869099b..d25aab0d 100644
--- a/src/config/main.lua.d/40-stream-defaults.lua
+++ b/src/config/main.lua.d/40-stream-defaults.lua
@@ -6,7 +6,7 @@ stream_defaults.properties = {
   ["restore-props"] = true,
 
   -- whether to restore the last stream target or not
-  ["restore-target"] = true,
+  ["restore-target"] = false,
 
   -- the default channel volume for new streams whose props were never saved
   -- previously. This is only used if "restore-props" is set to true.
-- 
2.42.0


From f4d88774670d561b3a2d37c0d6534b204cdcb14f Mon Sep 17 00:00:00 2001
From: Julian Bouzas <julian.bouzas@collabora.com>
Date: Thu, 23 Mar 2023 09:41:23 -0400
Subject: [PATCH 04/11] config: enable smart filter policy

---
 src/config/policy.lua.d/10-default-policy.lua | 2 +-
 src/config/policy.lua.d/30-filters-config.lua | 5 +++--
 2 files changed, 4 insertions(+), 3 deletions(-)

diff --git a/src/config/policy.lua.d/10-default-policy.lua b/src/config/policy.lua.d/10-default-policy.lua
index d3621a73..412d47a8 100644
--- a/src/config/policy.lua.d/10-default-policy.lua
+++ b/src/config/policy.lua.d/10-default-policy.lua
@@ -13,7 +13,7 @@ default_policy.policy = {
   ["filter.forward-format"] = false,
 
   -- Whether to enable smart filter policy or not (experimental feature)
-  ["filter.smart"] = false,
+  ["filter.smart"] = true,
 
   -- Set to 'true' to disable channel splitting & merging on nodes and enable
   -- passthrough of audio in the same format as the format of the device.
diff --git a/src/config/policy.lua.d/30-filters-config.lua b/src/config/policy.lua.d/30-filters-config.lua
index 76aecad0..8d919fb7 100644
--- a/src/config/policy.lua.d/30-filters-config.lua
+++ b/src/config/policy.lua.d/30-filters-config.lua
@@ -63,11 +63,12 @@ default_policy.filters_metadata = {
   ["targets"] = {
     ["speakers"] = {
       ["media.class"] = "Audio/Sink",
-      ["alsa.card_name"] = "my-speakers-card-name",
+      ["alsa.card_name"] = "acp5x",
+      ["device.profile.description"] = "Speaker",
     },
     ["microphone"] = {
       ["media.class"] = "Audio/Source",
-      ["alsa.card_name"] = "my-microphone-card-name",
+      ["alsa.card_name"] = "acp5x",
     }
   }
 }
-- 
2.42.0


From 30d26697a83ce11c4a2343c0ad4ddb113e64f72b Mon Sep 17 00:00:00 2001
From: Ashok Sidipotu <ashok.sidipotu@collabora.com>
Date: Wed, 12 Jul 2023 10:17:37 +0530
Subject: [PATCH 05/11] policy-device-profile.lua: introduce user profile
 priority list

---
 src/config/main.lua.d/40-device-defaults.lua | 14 +++++++-------
 1 file changed, 7 insertions(+), 7 deletions(-)

diff --git a/src/config/main.lua.d/40-device-defaults.lua b/src/config/main.lua.d/40-device-defaults.lua
index 91c4e189..b87eec51 100644
--- a/src/config/main.lua.d/40-device-defaults.lua
+++ b/src/config/main.lua.d/40-device-defaults.lua
@@ -39,13 +39,13 @@ device_defaults.profile_priorities = {
     },
     -- lower the index higher the priority
     priorities = {
-      -- "a2dp-sink-sbc",
-      -- "a2dp-sink-aptx_ll",
-      -- "a2dp-sink-aptx",
-      -- "a2dp-sink-aptx_hd",
-      -- "a2dp-sink-ldac",
-      -- "a2dp-sink-aac",
-      -- "a2dp-sink-sbc_xq",
+      "a2dp-sink-aptx_ll",
+      "a2dp-sink-aptx",
+      "a2dp-sink-aptx_hd",
+      "a2dp-sink-ldac",
+      "a2dp-sink-aac",
+      "a2dp-sink-sbc",
+      "a2dp-sink-sbc_xq",
     }
   },
 }
-- 
2.42.0


From 4c6762c41a0d8f6298e2f35cea42a86ea3ef8ff9 Mon Sep 17 00:00:00 2001
From: Julian Bouzas <julian.bouzas@collabora.com>
Date: Fri, 3 Nov 2023 09:24:59 -0400
Subject: [PATCH 06/11] m-filters-api: remove get-default-filter API

This was redundant as you can get the default filter by calling
'get-filter-from-target' using the default target. The policy logic
should not change.
---
 modules/module-filters-api.c | 34 ----------------------------------
 src/scripts/policy-node.lua  | 17 +++++++----------
 2 files changed, 7 insertions(+), 44 deletions(-)

diff --git a/modules/module-filters-api.c b/modules/module-filters-api.c
index 32c67c85..d7dc781a 100644
--- a/modules/module-filters-api.c
+++ b/modules/module-filters-api.c
@@ -230,33 +230,6 @@ wp_filters_api_get_filter_from_target (WpFiltersApi * self,
   return res;
 }
 
-static gint
-wp_filters_api_get_default_filter (WpFiltersApi * self, const gchar *direction)
-{
-  WpDirection dir = WP_DIRECTION_INPUT;
-  GList *filters;
-
-  g_return_val_if_fail (direction, -1);
-
-  /* Get the filters for the given direction */
-  if (g_str_equal (direction, "output") || g_str_equal (direction, "Output"))
-    dir = WP_DIRECTION_OUTPUT;
-  filters = self->filters[dir];
-
-  /* The default filter is the highest priority filter without target, this is
-   * the first filer that is enabled because the list is sorted by priority */
-  while (filters) {
-    Filter *f = (Filter *) filters->data;
-    if (f->enabled && !f->target)
-      return wp_proxy_get_bound_id (WP_PROXY (f->node));
-
-    /* Advance */
-    filters = g_list_next (filters);
-  }
-
-  return -1;
-}
-
 static void
 sync_changed (WpCore * core, GAsyncResult * res, WpFiltersApi * self)
 {
@@ -881,13 +854,6 @@ wp_filters_api_class_init (WpFiltersApiClass * klass)
       NULL, NULL, NULL,
       G_TYPE_INT, 2, G_TYPE_STRING, G_TYPE_INT);
 
-  signals[ACTION_GET_DEFAULT_FILTER] = g_signal_new_class_handler (
-      "get-default-filter", G_TYPE_FROM_CLASS (klass),
-      G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
-      (GCallback) wp_filters_api_get_default_filter,
-      NULL, NULL, NULL,
-      G_TYPE_INT, 1, G_TYPE_STRING);
-
   signals[SIGNAL_CHANGED] = g_signal_new (
       "changed", G_TYPE_FROM_CLASS (klass),
       G_SIGNAL_RUN_LAST, 0, NULL, NULL, NULL,
diff --git a/src/scripts/policy-node.lua b/src/scripts/policy-node.lua
index f249f343..c5a15ec6 100644
--- a/src/scripts/policy-node.lua
+++ b/src/scripts/policy-node.lua
@@ -719,9 +719,6 @@ function checkFollowDefault (si, si_target, has_node_defined_target)
 end
 
 function findFilterTarget (si)
-  local node = si:get_associated_proxy ("node")
-  local direction = getTargetDirection (si.properties)
-  local link_group = node.properties["node.link-group"]
   local target_id = -1
 
   -- always return nil if filters API is not loaded
@@ -729,16 +726,16 @@ function findFilterTarget (si)
     return nil
   end
 
+  -- always return nil if this is not a filter
+  local node = si:get_associated_proxy ("node")
+  local link_group = node.properties["node.link-group"]
   if link_group == nil then
-    -- if this is a client stream that is not a filter, link it to the highest
-    -- priority filter that does not have a group, if any.
-    target_id = self.filters_api:call("get-default-filter", direction)
-  else
-    -- if this is a filter, get its target
-    target_id = self.filters_api:call("get-filter-target",
-      direction, link_group)
+    return nil
   end
 
+  -- get the filter target
+  local direction = getTargetDirection (si.properties)
+  target_id = self.filters_api:call("get-filter-target", direction, link_group)
   if (target_id == -1) then
     return nil
   end
-- 
2.42.0


From 1acb97e5606dfd842a71c9a2de520ee665f17fd0 Mon Sep 17 00:00:00 2001
From: Julian Bouzas <julian.bouzas@collabora.com>
Date: Fri, 3 Nov 2023 11:44:57 -0400
Subject: [PATCH 07/11] m-filters-api: add support for exclusive targets

Filters whose target are exclusive won't be linked to the default device if the
target does not exist.
---
 modules/module-filters-api.c                  | 127 ++++++++++++------
 src/config/policy.lua.d/30-filters-config.lua |  18 ++-
 src/scripts/filters-metadata.lua              |   7 +-
 src/scripts/policy-node.lua                   |  26 ++--
 4 files changed, 120 insertions(+), 58 deletions(-)

diff --git a/modules/module-filters-api.c b/modules/module-filters-api.c
index d7dc781a..8852a6c5 100644
--- a/modules/module-filters-api.c
+++ b/modules/module-filters-api.c
@@ -49,6 +49,12 @@ struct _Filter {
 };
 typedef struct _Filter Filter;
 
+struct _Target {
+  gboolean exclusive;
+  WpNode *node;
+};
+typedef struct _Target Target;
+
 static guint
 get_filter_priority (const gchar *link_group)
 {
@@ -87,6 +93,22 @@ filter_free (Filter *f)
   g_free (f);
 }
 
+static Target *
+target_new (gboolean exclusive, WpNode *node)
+{
+  Target *t = g_malloc0 (sizeof (Target));
+  t->exclusive = exclusive;
+  t->node = node ? g_object_ref (node) : NULL;
+  return t;
+}
+
+static void
+target_free (Target *t)
+{
+  g_clear_object (&t->node);
+  g_free (t);
+}
+
 static gint
 filter_equal_func (const Filter *f, const gchar *link_group)
 {
@@ -133,16 +155,18 @@ wp_filters_api_is_filter_enabled (WpFiltersApi * self, const gchar *direction,
   return found->enabled;
 }
 
-static gint
+static WpSpaJson *
 wp_filters_api_get_filter_target (WpFiltersApi * self, const gchar *direction,
     const gchar *link_group)
 {
   WpDirection dir = WP_DIRECTION_INPUT;
   GList *filters;
   Filter *found;
+  g_autoptr (WpSpaJson) res = wp_spa_json_new_object (
+      "exclusive", "b", FALSE, "bound_id", "i", -1, NULL);
 
-  g_return_val_if_fail (direction, -1);
-  g_return_val_if_fail (link_group, -1);
+  g_return_val_if_fail (direction, g_steal_pointer (&res));
+  g_return_val_if_fail (link_group, g_steal_pointer (&res));
 
   /* Get the filters for the given direction */
   if (g_str_equal (direction, "output") || g_str_equal (direction, "Output"))
@@ -153,10 +177,10 @@ wp_filters_api_get_filter_target (WpFiltersApi * self, const gchar *direction,
   filters = g_list_find_custom (filters, link_group,
       (GCompareFunc) filter_equal_func);
   if (!filters)
-    return -1;
+    return g_steal_pointer (&res);
   found = filters->data;
   if (!found->enabled)
-    return -1;
+    return g_steal_pointer (&res);
 
   /* Return the previous filter with matching target that is enabled */
   while ((filters = g_list_previous (filters))) {
@@ -164,18 +188,27 @@ wp_filters_api_get_filter_target (WpFiltersApi * self, const gchar *direction,
     if ((prev->target == found->target ||
         (prev->target && found->target &&
         g_str_equal (prev->target, found->target))) &&
-        prev->enabled)
-      return wp_proxy_get_bound_id (WP_PROXY (prev->node));
+        prev->enabled) {
+      return wp_spa_json_new_object (
+          "exclusive", "b", FALSE,
+          "bound_id", "i", wp_proxy_get_bound_id (WP_PROXY (prev->node)),
+          NULL);
+    }
   }
 
   /* Find the target */
   if (found->target) {
-    WpNode *node = g_hash_table_lookup (self->targets, found->target);
-    if (node)
-      return wp_proxy_get_bound_id (WP_PROXY (node));
+    Target *t = g_hash_table_lookup (self->targets, found->target);
+    if (t) {
+      return wp_spa_json_new_object (
+          "exclusive", "b", t->exclusive,
+          "bound_id", "i",
+              t->node ? (gint)wp_proxy_get_bound_id (WP_PROXY (t->node)) : -1,
+          NULL);
+    }
   }
 
-  return -1;
+  return g_steal_pointer (&res);
 }
 
 static gint
@@ -198,12 +231,17 @@ wp_filters_api_get_filter_from_target (WpFiltersApi * self,
   /* Find the first target matching target_id */
   while (filters) {
     Filter *f = (Filter *) filters->data;
-    gint f_target_id = wp_filters_api_get_filter_target (self, direction,
-        f->link_group);
-    if (f_target_id == target_id && f->enabled) {
-      target = f->target;
-      found = TRUE;
-      break;
+    if (f->enabled) {
+      gint f_target_id;
+      g_autoptr (WpSpaJson) f_target = wp_filters_api_get_filter_target (self,
+          direction, f->link_group);
+      if (f_target && wp_spa_json_is_object (f_target) &&
+          wp_spa_json_object_get (f_target, "bound_id", "i", &f_target_id, NULL)
+          && f_target_id == target_id) {
+        target = f->target;
+        found = TRUE;
+        break;
+      }
     }
 
     /* Advance */
@@ -323,9 +361,11 @@ reevaluate_targets (WpFiltersApi *self)
   for (; wp_iterator_next (it, &item); g_value_unset (&item)) {
     WpSpaJson *j = g_value_get_boxed (&item);
     g_autofree gchar *key = NULL;
-    WpSpaJson *props;
-    g_autoptr (WpNode) target = NULL;
-    WpNode *curr_target;
+    WpSpaJson *value;
+    gboolean exclusive = FALSE;
+    g_autoptr (WpSpaJson) props = NULL;
+    g_autoptr (WpNode) target_node = NULL;
+    Target *curr_target;
 
     key = wp_spa_json_parse_string (j);
     g_value_unset (&item);
@@ -334,27 +374,36 @@ reevaluate_targets (WpFiltersApi *self)
         "Could not get valid key-value pairs from target object");
       break;
     }
-    props = g_value_get_boxed (&item);
+    value = g_value_get_boxed (&item);
+    if (!value || !wp_spa_json_is_object (value)) {
+      wp_warning_object (self, "Target value must be a JSON object");
+      break;
+    }
 
-    /* Get current target */
-    curr_target = g_hash_table_lookup (self->targets, key);
+    /* Get exclusive */
+    wp_spa_json_object_get (value, "exclusive", "b", &exclusive, NULL);
 
-    /* Find the node and insert it into the table if found */
-    target = find_target_node (self, props);
-    if (target) {
-      /* Check if the target changed */
-      if (curr_target) {
-        guint32 target_bound_id = wp_proxy_get_bound_id (WP_PROXY (target));
-        guint32 curr_bound_id = wp_proxy_get_bound_id (WP_PROXY (curr_target));
-        if (target_bound_id != curr_bound_id)
-          changed = TRUE;
-      }
+    /* Get target node */
+    wp_spa_json_object_get (value, "props", "J", &props, NULL);
+    if (props)
+      target_node = find_target_node (self, props);
 
-      g_hash_table_insert (self->targets, g_strdup (key),
-          g_steal_pointer (&target));
-    } else {
-      if (curr_target)
+    /* Update values if target exists in the table, otherwise add new target */
+    curr_target = g_hash_table_lookup (self->targets, key);
+    if (curr_target) {
+      if (curr_target->exclusive != exclusive) {
+        curr_target->exclusive = exclusive;
+        changed = TRUE;
+      }
+      if (curr_target->node != target_node) {
+        g_clear_object (&curr_target->node);
+        curr_target->node = g_steal_pointer (&target_node);
         changed = TRUE;
+      }
+    } else {
+      g_hash_table_insert (self->targets, g_strdup (key),
+            target_new (exclusive, target_node));
+      changed = TRUE;
     }
   }
 
@@ -790,7 +839,7 @@ wp_filters_api_enable (WpPlugin * plugin, WpTransition * transition)
   g_autoptr (WpCore) core = wp_object_get_core (WP_OBJECT (self));
 
   self->targets = g_hash_table_new_full (g_str_hash, g_str_equal, g_free,
-      g_object_unref);
+      (GDestroyNotify) target_free);
 
   /* Create the metadata object manager */
   self->metadata_om = wp_object_manager_new ();
@@ -845,7 +894,7 @@ wp_filters_api_class_init (WpFiltersApiClass * klass)
       G_SIGNAL_RUN_LAST | G_SIGNAL_ACTION,
       (GCallback) wp_filters_api_get_filter_target,
       NULL, NULL, NULL,
-      G_TYPE_INT, 2, G_TYPE_STRING, G_TYPE_STRING);
+      WP_TYPE_SPA_JSON, 2, G_TYPE_STRING, G_TYPE_STRING);
 
   signals[ACTION_GET_FILTER_FROM_TARGET] = g_signal_new_class_handler (
       "get-filter-from-target", G_TYPE_FROM_CLASS (klass),
diff --git a/src/config/policy.lua.d/30-filters-config.lua b/src/config/policy.lua.d/30-filters-config.lua
index 8d919fb7..bf4b8d75 100644
--- a/src/config/policy.lua.d/30-filters-config.lua
+++ b/src/config/policy.lua.d/30-filters-config.lua
@@ -59,16 +59,22 @@ default_policy.filters_metadata = {
     }
   },
 
-  -- The target node properties (any node properties can be defined)
+  -- The filter targets
   ["targets"] = {
     ["speakers"] = {
-      ["media.class"] = "Audio/Sink",
-      ["alsa.card_name"] = "acp5x",
-      ["device.profile.description"] = "Speaker",
+      ["exclusive"] = false,
+      ["props"] = {
+        ["media.class"] = "Audio/Sink",
+        ["alsa.card_name"] = "acp5x",
+        ["device.profile.description"] = "Speaker",
+      }
     },
     ["microphone"] = {
-      ["media.class"] = "Audio/Source",
-      ["alsa.card_name"] = "acp5x",
+      ["exclusive"] = false,
+      ["props"] = {
+        ["media.class"] = "Audio/Source",
+        ["alsa.card_name"] = "acp5x",
+      }
     }
   }
 }
diff --git a/src/scripts/filters-metadata.lua b/src/scripts/filters-metadata.lua
index 04e3e6c6..49b4d0e8 100644
--- a/src/scripts/filters-metadata.lua
+++ b/src/scripts/filters-metadata.lua
@@ -30,8 +30,11 @@ f_metadata:activate(Features.ALL, function (m, e)
 
   -- Set targets metadata
   local targets = {}
-  for name, props in pairs(config["targets"]) do
-    targets[name] = Json.Object (props)
+  for name, value in pairs(config["targets"]) do
+    targets[name] = Json.Object {
+        exclusive = value.exclusive and true or false,
+        props = Json.Object (value.props)
+    }
   end
   local targets_json = Json.Object (targets)
   m:set (0, "filters.configured.targets", "Spa:String:JSON",
diff --git a/src/scripts/policy-node.lua b/src/scripts/policy-node.lua
index c5a15ec6..035c3006 100644
--- a/src/scripts/policy-node.lua
+++ b/src/scripts/policy-node.lua
@@ -719,31 +719,31 @@ function checkFollowDefault (si, si_target, has_node_defined_target)
 end
 
 function findFilterTarget (si)
-  local target_id = -1
-
   -- always return nil if filters API is not loaded
   if self.filters_api == nil then
-    return nil
+    return nil, false
   end
 
   -- always return nil if this is not a filter
   local node = si:get_associated_proxy ("node")
   local link_group = node.properties["node.link-group"]
   if link_group == nil then
-    return nil
+    return nil, false
   end
 
   -- get the filter target
   local direction = getTargetDirection (si.properties)
-  target_id = self.filters_api:call("get-filter-target", direction, link_group)
-  if (target_id == -1) then
-    return nil
+  local target_json = self.filters_api:call("get-filter-target", direction, link_group)
+  if target_json == nil then
+    return nil, false
   end
+  target = target_json:parse()
 
-  Log.info (".. filter target ID is " .. tostring(target_id))
+  Log.info (".. filter target ID is " .. tostring(target.bound_id) ..
+      " (" .. tostring (target.exclusive) .. ")")
   return linkables_om:lookup {
-      Constraint { "node.id", "=", tostring(target_id) }
-  }
+      Constraint { "node.id", "=", tostring(target.bound_id) }
+  }, target.exclusive
 end
 
 function handleLinkable (si)
@@ -783,7 +783,11 @@ function handleLinkable (si)
 
   -- find filter target (always returns nil for non filters)
   if si_target == nil then
-    si_target = findFilterTarget(si)
+    si_target, exclusive = findFilterTarget(si)
+    -- don't fallback if filter target is not found and exclusive is true
+    if si_target == nil and exclusive then
+      return
+    end
     local can_passthrough = si_target and canPassthrough(si, si_target)
     if si_target and si_must_passthrough and not can_passthrough then
       si_target = nil
-- 
2.42.0


From 0010f03fafa2841f7618dad4b609466b6dad11c7 Mon Sep 17 00:00:00 2001
From: Julian Bouzas <julian.bouzas@collabora.com>
Date: Mon, 6 Nov 2023 14:33:34 -0500
Subject: [PATCH 08/11] policy-bluetooth: remove application names array and
 use BT loopback filter

Uses a BT loopback filter to know when an application wants to capture audio
from the current BT device. If the BT loopback filter is used, wireplumber will
automatically switch the device to HSP/HFP profile, otherwise the BT device
profile is always set to A2DP.
---
 src/config/policy.lua.d/10-default-policy.lua |  10 --
 src/config/policy.lua.d/30-filters-config.lua |  17 ++-
 src/config/wireplumber.conf                   |  20 +++
 src/scripts/policy-bluetooth.lua              | 139 ++++++++++--------
 4 files changed, 110 insertions(+), 76 deletions(-)

diff --git a/src/config/policy.lua.d/10-default-policy.lua b/src/config/policy.lua.d/10-default-policy.lua
index 412d47a8..7d4ea77c 100644
--- a/src/config/policy.lua.d/10-default-policy.lua
+++ b/src/config/policy.lua.d/10-default-policy.lua
@@ -33,16 +33,6 @@ bluetooth_policy.policy = {
 
   -- Whether to use headset profile in the presence of an input stream.
   ["media-role.use-headset-profile"] = true,
-
-  -- Application names correspond to application.name in stream properties.
-  -- Applications which do not set media.role but which should be considered
-  -- for role based profile switching can be specified here.
-  ["media-role.applications"] = {
-    "Firefox", "Chromium input", "Google Chrome input", "Brave input",
-    "Microsoft Edge input", "Vivaldi input", "ZOOM VoiceEngine",
-    "Telegram Desktop", "telegram-desktop", "linphone", "Mumble",
-    "WEBRTC VoiceEngine", "Skype", "Firefox Developer Edition",
-  },
 }
 
 dsp_policy = {}
diff --git a/src/config/policy.lua.d/30-filters-config.lua b/src/config/policy.lua.d/30-filters-config.lua
index bf4b8d75..88429cdc 100644
--- a/src/config/policy.lua.d/30-filters-config.lua
+++ b/src/config/policy.lua.d/30-filters-config.lua
@@ -33,11 +33,19 @@ default_policy.filters_metadata = {
     },
 
     -- Output filters (meant to be linked with Audio/Source device nodes)
+    {
+      ["stream-name"] = "virtual-bluetooth-source-in",  -- loopback bluetooth capture
+      ["node-name"] = "virtual-bluetooth-source-out",   -- loopback bluetooth source
+      ["direction"] = "output",  -- can only be 'input' or 'output'
+      ["target"] = "bluetooth-source",  -- if nil, the default node will be used as target
+      ["mode"] = "always",  -- can be 'always', 'never', 'playback-only' or 'capture-only'
+      ["priority"] = 30,
+    },
     {
       ["stream-name"] = "input.virtual-source",  -- loopback capture
       ["node-name"] = "output.virtual-source",   -- loopback source
       ["direction"] = "output",  -- can only be 'input' or 'output'
-      ["target"] = nil,  -- if nil, the default node will be used as target
+      ["target"] = "microphone",  -- if nil, the default node will be used as target
       ["mode"] = "always",  -- can be 'always', 'never', 'playback-only' or 'capture-only'
       ["priority"] = 30,
     },
@@ -75,6 +83,13 @@ default_policy.filters_metadata = {
         ["media.class"] = "Audio/Source",
         ["alsa.card_name"] = "acp5x",
       }
+    },
+    ["bluetooth-source"] = {
+      ["exclusive"] = true,
+      ["props"] = {
+        ["media.class"] = "Audio/Source",
+        ["device.api"] = "bluez5"
+      }
     }
   }
 }
diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf
index 85d7be12..4c9dd568 100644
--- a/src/config/wireplumber.conf
+++ b/src/config/wireplumber.conf
@@ -77,6 +77,26 @@ context.modules = [
 
   # Provides factories to make SPA node objects.
   { name = libpipewire-module-spa-node-factory }
+
+  # Virtual Bluetooth Source
+  {
+    name = libpipewire-module-loopback
+    args = {
+      capture.props = {
+        node.name = virtual-bluetooth-source-in
+        node.description = "Virtual Bluetooth Source In"
+        audio.position = [ MONO ]
+        stream.dont-remix = true
+        node.passive = true
+      }
+      playback.props = {
+        node.name = virtual-bluetooth-source-out
+        node.description = "Virtual Bluetooth Source Out"
+        audio.position = [ MONO ]
+        media.class = Audio/Source
+      }
+    }
+  }
 ]
 
 wireplumber.components = [
diff --git a/src/scripts/policy-bluetooth.lua b/src/scripts/policy-bluetooth.lua
index f8f69a14..7aecb8b0 100644
--- a/src/scripts/policy-bluetooth.lua
+++ b/src/scripts/policy-bluetooth.lua
@@ -26,7 +26,6 @@
 
 local config = ...
 local use_persistent_storage = config["use-persistent-storage"] or false
-local applications = {}
 local use_headset_profile = config["media-role.use-headset-profile"] or false
 local profile_restore_timeout_msec = 2000
 
@@ -41,17 +40,6 @@ local last_profiles = {}
 local active_streams = {}
 local previous_streams = {}
 
-for _, value in ipairs(config["media-role.applications"] or {}) do
-  applications[value] = true
-end
-
-metadata_om = ObjectManager {
-  Interest {
-    type = "metadata",
-    Constraint { "metadata.name", "=", "default" },
-  }
-}
-
 devices_om = ObjectManager {
   Interest {
     type = "device",
@@ -68,6 +56,16 @@ streams_om = ObjectManager {
   }
 }
 
+nodes_om = ObjectManager {
+  Interest {
+    type = "node",
+    Constraint { "node.name", "=", "virtual-bluetooth-source-out", type = "pw-global" },
+    Constraint { "media.class", "matches", "Audio/Source", type = "pw-global" },
+  }
+}
+
+links_om = ObjectManager { Interest { type = "link" } }
+
 local function parseParam(param_to_parse, id)
   local param = param_to_parse:parse()
   if param.pod_type == "Object" and param.object_id == id then
@@ -117,19 +115,6 @@ local function isSwitched(device)
   return getSavedLastProfile(device) ~= nil
 end
 
-local function isBluez5AudioSink(sink_name)
-  if sink_name and string.find(sink_name, "bluez_output.") ~= nil then
-    return true
-  end
-  return false
-end
-
-local function isBluez5DefaultAudioSink()
-  local metadata = metadata_om:lookup()
-  local default_audio_sink = metadata:find(0, "default.audio.sink")
-  return isBluez5AudioSink(default_audio_sink)
-end
-
 local function findProfile(device, index, name)
   for p in device:iterate_params("EnumProfile") do
     local profile = parseParam(p, "EnumProfile")
@@ -228,7 +213,6 @@ local function switchProfile()
     end
 
     local cur_profile_name = getCurrentProfile(device)
-    saveLastProfile(device, cur_profile_name)
 
     _, index, name = findProfile(device, nil, cur_profile_name)
     if hasProfileInputRoute(device, index) then
@@ -251,6 +235,8 @@ local function switchProfile()
         index = index
       }
 
+      saveLastProfile(device, cur_profile_name)
+
       Log.info("Setting profile of '"
             .. device.properties["device.description"]
             .. "' from: " .. cur_profile_name
@@ -270,8 +256,6 @@ local function restoreProfile()
       local profile_name = getSavedLastProfile(device)
       local cur_profile_name = getCurrentProfile(device)
 
-      saveLastProfile(device, nil)
-
       if cur_profile_name then
         Log.info("Setting saved headset profile to: " .. cur_profile_name)
         saveHeadsetProfile(device, cur_profile_name)
@@ -286,6 +270,8 @@ local function restoreProfile()
             index = index
           }
 
+          saveLastProfile(device, nil)
+
           Log.info("Restoring profile of '"
                 .. device.properties["device.description"]
                 .. "' from: " .. cur_profile_name
@@ -312,18 +298,14 @@ local function triggerRestoreProfile()
   end)
 end
 
--- We consider a Stream of interest to have role Communication if it has
--- media.role set to Communication in props or it is in our list of
--- applications as these applications do not set media.role correctly or at
--- all.
-local function checkStreamStatus(stream)
-  local app_name = stream.properties["application.name"]
-  local stream_role = stream.properties["media.role"]
+function parseBool(var)
+  return var and (var:lower() == "true" or var == "1")
+end
 
-  if not (stream_role == "Communication" or applications[app_name]) then
-    return false
-  end
-  if not isBluez5DefaultAudioSink() then
+local function checkStreamStatus (stream)
+  -- Ignore monitor streams
+  local is_monitor = parseBool (stream.properties["stream.monitor"])
+  if is_monitor then
     return false
   end
 
@@ -334,7 +316,25 @@ local function checkStreamStatus(stream)
     return false
   end
 
-  return true
+  -- Make sure the virtual BT filter node exists
+  local node = nodes_om:lookup ()
+  if node == nil then
+    return false
+  end
+
+  -- Check if the stream is linked to the bluetooth loopback filter
+  local stream_id = tonumber(stream["bound-id"])
+  local bt_out_id = tonumber(node["bound-id"])
+  for l in links_om:iterate() do
+    local p = l.properties
+    local out_id = tonumber(p["link.output.node"])
+    local in_id = tonumber(p["link.input.node"])
+    if in_id == stream_id and out_id == bt_out_id then
+      return true
+    end
+  end
+
+  return false
 end
 
 local function handleStream(stream)
@@ -361,38 +361,47 @@ local function handleAllStreams()
   end
 end
 
-streams_om:connect("object-added", function (_, stream)
-  stream:connect("state-changed", function (stream, old_state, cur_state)
-    handleStream(stream)
-  end)
-  stream:connect("params-changed", handleStream)
-  handleStream(stream)
-end)
-
-streams_om:connect("object-removed", function (_, stream)
-  active_streams[stream["bound-id"]] = nil
-  previous_streams[stream["bound-id"]] = nil
-  triggerRestoreProfile()
-end)
-
 devices_om:connect("object-added", function (_, device)
   -- Devices are unswitched initially
-  if isSwitched(device) then
-    saveLastProfile(device, nil)
-  end
+  saveLastProfile(device, nil)
   handleAllStreams()
 end)
 
-metadata_om:connect("object-added", function (_, metadata)
-  metadata:connect("changed", function (m, subject, key, t, value)
-    if (use_headset_profile and subject == 0 and key == "default.audio.sink"
-        and isBluez5AudioSink(value)) then
-      -- If bluez sink is set as default, rescan for active input streams
-      handleAllStreams()
+links_om:connect("object-added", function (_, link)
+  if handleAllStreams then
+    local p = link.properties
+    for stream in streams_om:iterate {
+      Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" },
+      Constraint { "stream.monitor", "!", "true" }
+    } do
+      local in_id = tonumber(p["link.input.node"])
+      local stream_id = tonumber(stream["bound-id"])
+      if in_id == stream_id then
+        handleStream(stream)
+      end
     end
-  end)
+  end
+end)
+
+links_om:connect("object-removed", function (_, link)
+  if handleAllStreams then
+    local p = link.properties
+    for stream in streams_om:iterate {
+      Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" },
+      Constraint { "stream.monitor", "!", "true" }
+    } do
+      local in_id = tonumber(p["link.input.node"])
+      local stream_id = tonumber(stream["bound-id"])
+      if in_id == stream_id then
+        active_streams[stream["bound-id"]] = nil
+        previous_streams[stream["bound-id"]] = nil
+        triggerRestoreProfile()
+      end
+    end
+  end
 end)
 
-metadata_om:activate()
 devices_om:activate()
 streams_om:activate()
+nodes_om:activate()
+links_om:activate()
-- 
2.42.0


From ae7cde6dd7c7660da5bbd07f27fe18a624c8c67d Mon Sep 17 00:00:00 2001
From: Ethan Geller <ethang@valvesoftware.com>
Date: Mon, 13 Nov 2023 22:10:05 -0800
Subject: [PATCH 09/11] fix speaker tunings for galileo

---
 src/config/policy.lua.d/30-filters-config.lua | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/config/policy.lua.d/30-filters-config.lua b/src/config/policy.lua.d/30-filters-config.lua
index 88429cdc..a4b617d8 100644
--- a/src/config/policy.lua.d/30-filters-config.lua
+++ b/src/config/policy.lua.d/30-filters-config.lua
@@ -73,7 +73,7 @@ default_policy.filters_metadata = {
       ["exclusive"] = false,
       ["props"] = {
         ["media.class"] = "Audio/Sink",
-        ["alsa.card_name"] = "acp5x",
+        ["alsa.card_name"] = "sof-nau8821-max",
         ["device.profile.description"] = "Speaker",
       }
     },
-- 
2.42.0


From 0ed6dc783bcb4033fb82c0f9b3e2c26977c3e19e Mon Sep 17 00:00:00 2001
From: Ethan Geller <ethang@valvesoftware.com>
Date: Wed, 15 Nov 2023 14:32:56 -0800
Subject: [PATCH 10/11] Revert "policy-bluetooth: remove application names
 array and use BT loopback filter"

This reverts commit 5a760629b6e81268383f32406119fbb4ac3a42b0.
---
 src/config/policy.lua.d/10-default-policy.lua |  10 ++
 src/config/policy.lua.d/30-filters-config.lua |  17 +--
 src/config/wireplumber.conf                   |  20 ---
 src/scripts/policy-bluetooth.lua              | 139 ++++++++----------
 4 files changed, 76 insertions(+), 110 deletions(-)

diff --git a/src/config/policy.lua.d/10-default-policy.lua b/src/config/policy.lua.d/10-default-policy.lua
index 7d4ea77c..412d47a8 100644
--- a/src/config/policy.lua.d/10-default-policy.lua
+++ b/src/config/policy.lua.d/10-default-policy.lua
@@ -33,6 +33,16 @@ bluetooth_policy.policy = {
 
   -- Whether to use headset profile in the presence of an input stream.
   ["media-role.use-headset-profile"] = true,
+
+  -- Application names correspond to application.name in stream properties.
+  -- Applications which do not set media.role but which should be considered
+  -- for role based profile switching can be specified here.
+  ["media-role.applications"] = {
+    "Firefox", "Chromium input", "Google Chrome input", "Brave input",
+    "Microsoft Edge input", "Vivaldi input", "ZOOM VoiceEngine",
+    "Telegram Desktop", "telegram-desktop", "linphone", "Mumble",
+    "WEBRTC VoiceEngine", "Skype", "Firefox Developer Edition",
+  },
 }
 
 dsp_policy = {}
diff --git a/src/config/policy.lua.d/30-filters-config.lua b/src/config/policy.lua.d/30-filters-config.lua
index a4b617d8..37badd80 100644
--- a/src/config/policy.lua.d/30-filters-config.lua
+++ b/src/config/policy.lua.d/30-filters-config.lua
@@ -33,19 +33,11 @@ default_policy.filters_metadata = {
     },
 
     -- Output filters (meant to be linked with Audio/Source device nodes)
-    {
-      ["stream-name"] = "virtual-bluetooth-source-in",  -- loopback bluetooth capture
-      ["node-name"] = "virtual-bluetooth-source-out",   -- loopback bluetooth source
-      ["direction"] = "output",  -- can only be 'input' or 'output'
-      ["target"] = "bluetooth-source",  -- if nil, the default node will be used as target
-      ["mode"] = "always",  -- can be 'always', 'never', 'playback-only' or 'capture-only'
-      ["priority"] = 30,
-    },
     {
       ["stream-name"] = "input.virtual-source",  -- loopback capture
       ["node-name"] = "output.virtual-source",   -- loopback source
       ["direction"] = "output",  -- can only be 'input' or 'output'
-      ["target"] = "microphone",  -- if nil, the default node will be used as target
+      ["target"] = nil,  -- if nil, the default node will be used as target
       ["mode"] = "always",  -- can be 'always', 'never', 'playback-only' or 'capture-only'
       ["priority"] = 30,
     },
@@ -83,13 +75,6 @@ default_policy.filters_metadata = {
         ["media.class"] = "Audio/Source",
         ["alsa.card_name"] = "acp5x",
       }
-    },
-    ["bluetooth-source"] = {
-      ["exclusive"] = true,
-      ["props"] = {
-        ["media.class"] = "Audio/Source",
-        ["device.api"] = "bluez5"
-      }
     }
   }
 }
diff --git a/src/config/wireplumber.conf b/src/config/wireplumber.conf
index 4c9dd568..85d7be12 100644
--- a/src/config/wireplumber.conf
+++ b/src/config/wireplumber.conf
@@ -77,26 +77,6 @@ context.modules = [
 
   # Provides factories to make SPA node objects.
   { name = libpipewire-module-spa-node-factory }
-
-  # Virtual Bluetooth Source
-  {
-    name = libpipewire-module-loopback
-    args = {
-      capture.props = {
-        node.name = virtual-bluetooth-source-in
-        node.description = "Virtual Bluetooth Source In"
-        audio.position = [ MONO ]
-        stream.dont-remix = true
-        node.passive = true
-      }
-      playback.props = {
-        node.name = virtual-bluetooth-source-out
-        node.description = "Virtual Bluetooth Source Out"
-        audio.position = [ MONO ]
-        media.class = Audio/Source
-      }
-    }
-  }
 ]
 
 wireplumber.components = [
diff --git a/src/scripts/policy-bluetooth.lua b/src/scripts/policy-bluetooth.lua
index 7aecb8b0..f8f69a14 100644
--- a/src/scripts/policy-bluetooth.lua
+++ b/src/scripts/policy-bluetooth.lua
@@ -26,6 +26,7 @@
 
 local config = ...
 local use_persistent_storage = config["use-persistent-storage"] or false
+local applications = {}
 local use_headset_profile = config["media-role.use-headset-profile"] or false
 local profile_restore_timeout_msec = 2000
 
@@ -40,6 +41,17 @@ local last_profiles = {}
 local active_streams = {}
 local previous_streams = {}
 
+for _, value in ipairs(config["media-role.applications"] or {}) do
+  applications[value] = true
+end
+
+metadata_om = ObjectManager {
+  Interest {
+    type = "metadata",
+    Constraint { "metadata.name", "=", "default" },
+  }
+}
+
 devices_om = ObjectManager {
   Interest {
     type = "device",
@@ -56,16 +68,6 @@ streams_om = ObjectManager {
   }
 }
 
-nodes_om = ObjectManager {
-  Interest {
-    type = "node",
-    Constraint { "node.name", "=", "virtual-bluetooth-source-out", type = "pw-global" },
-    Constraint { "media.class", "matches", "Audio/Source", type = "pw-global" },
-  }
-}
-
-links_om = ObjectManager { Interest { type = "link" } }
-
 local function parseParam(param_to_parse, id)
   local param = param_to_parse:parse()
   if param.pod_type == "Object" and param.object_id == id then
@@ -115,6 +117,19 @@ local function isSwitched(device)
   return getSavedLastProfile(device) ~= nil
 end
 
+local function isBluez5AudioSink(sink_name)
+  if sink_name and string.find(sink_name, "bluez_output.") ~= nil then
+    return true
+  end
+  return false
+end
+
+local function isBluez5DefaultAudioSink()
+  local metadata = metadata_om:lookup()
+  local default_audio_sink = metadata:find(0, "default.audio.sink")
+  return isBluez5AudioSink(default_audio_sink)
+end
+
 local function findProfile(device, index, name)
   for p in device:iterate_params("EnumProfile") do
     local profile = parseParam(p, "EnumProfile")
@@ -213,6 +228,7 @@ local function switchProfile()
     end
 
     local cur_profile_name = getCurrentProfile(device)
+    saveLastProfile(device, cur_profile_name)
 
     _, index, name = findProfile(device, nil, cur_profile_name)
     if hasProfileInputRoute(device, index) then
@@ -235,8 +251,6 @@ local function switchProfile()
         index = index
       }
 
-      saveLastProfile(device, cur_profile_name)
-
       Log.info("Setting profile of '"
             .. device.properties["device.description"]
             .. "' from: " .. cur_profile_name
@@ -256,6 +270,8 @@ local function restoreProfile()
       local profile_name = getSavedLastProfile(device)
       local cur_profile_name = getCurrentProfile(device)
 
+      saveLastProfile(device, nil)
+
       if cur_profile_name then
         Log.info("Setting saved headset profile to: " .. cur_profile_name)
         saveHeadsetProfile(device, cur_profile_name)
@@ -270,8 +286,6 @@ local function restoreProfile()
             index = index
           }
 
-          saveLastProfile(device, nil)
-
           Log.info("Restoring profile of '"
                 .. device.properties["device.description"]
                 .. "' from: " .. cur_profile_name
@@ -298,14 +312,18 @@ local function triggerRestoreProfile()
   end)
 end
 
-function parseBool(var)
-  return var and (var:lower() == "true" or var == "1")
-end
+-- We consider a Stream of interest to have role Communication if it has
+-- media.role set to Communication in props or it is in our list of
+-- applications as these applications do not set media.role correctly or at
+-- all.
+local function checkStreamStatus(stream)
+  local app_name = stream.properties["application.name"]
+  local stream_role = stream.properties["media.role"]
 
-local function checkStreamStatus (stream)
-  -- Ignore monitor streams
-  local is_monitor = parseBool (stream.properties["stream.monitor"])
-  if is_monitor then
+  if not (stream_role == "Communication" or applications[app_name]) then
+    return false
+  end
+  if not isBluez5DefaultAudioSink() then
     return false
   end
 
@@ -316,25 +334,7 @@ local function checkStreamStatus (stream)
     return false
   end
 
-  -- Make sure the virtual BT filter node exists
-  local node = nodes_om:lookup ()
-  if node == nil then
-    return false
-  end
-
-  -- Check if the stream is linked to the bluetooth loopback filter
-  local stream_id = tonumber(stream["bound-id"])
-  local bt_out_id = tonumber(node["bound-id"])
-  for l in links_om:iterate() do
-    local p = l.properties
-    local out_id = tonumber(p["link.output.node"])
-    local in_id = tonumber(p["link.input.node"])
-    if in_id == stream_id and out_id == bt_out_id then
-      return true
-    end
-  end
-
-  return false
+  return true
 end
 
 local function handleStream(stream)
@@ -361,47 +361,38 @@ local function handleAllStreams()
   end
 end
 
-devices_om:connect("object-added", function (_, device)
-  -- Devices are unswitched initially
-  saveLastProfile(device, nil)
-  handleAllStreams()
+streams_om:connect("object-added", function (_, stream)
+  stream:connect("state-changed", function (stream, old_state, cur_state)
+    handleStream(stream)
+  end)
+  stream:connect("params-changed", handleStream)
+  handleStream(stream)
 end)
 
-links_om:connect("object-added", function (_, link)
-  if handleAllStreams then
-    local p = link.properties
-    for stream in streams_om:iterate {
-      Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" },
-      Constraint { "stream.monitor", "!", "true" }
-    } do
-      local in_id = tonumber(p["link.input.node"])
-      local stream_id = tonumber(stream["bound-id"])
-      if in_id == stream_id then
-        handleStream(stream)
-      end
-    end
+streams_om:connect("object-removed", function (_, stream)
+  active_streams[stream["bound-id"]] = nil
+  previous_streams[stream["bound-id"]] = nil
+  triggerRestoreProfile()
+end)
+
+devices_om:connect("object-added", function (_, device)
+  -- Devices are unswitched initially
+  if isSwitched(device) then
+    saveLastProfile(device, nil)
   end
+  handleAllStreams()
 end)
 
-links_om:connect("object-removed", function (_, link)
-  if handleAllStreams then
-    local p = link.properties
-    for stream in streams_om:iterate {
-      Constraint { "media.class", "matches", "Stream/Input/Audio", type = "pw-global" },
-      Constraint { "stream.monitor", "!", "true" }
-    } do
-      local in_id = tonumber(p["link.input.node"])
-      local stream_id = tonumber(stream["bound-id"])
-      if in_id == stream_id then
-        active_streams[stream["bound-id"]] = nil
-        previous_streams[stream["bound-id"]] = nil
-        triggerRestoreProfile()
-      end
+metadata_om:connect("object-added", function (_, metadata)
+  metadata:connect("changed", function (m, subject, key, t, value)
+    if (use_headset_profile and subject == 0 and key == "default.audio.sink"
+        and isBluez5AudioSink(value)) then
+      -- If bluez sink is set as default, rescan for active input streams
+      handleAllStreams()
     end
-  end
+  end)
 end)
 
+metadata_om:activate()
 devices_om:activate()
 streams_om:activate()
-nodes_om:activate()
-links_om:activate()
-- 
2.42.0


From 043390080937c05df74a48eaff5a9713ff0ea12f Mon Sep 17 00:00:00 2001
From: Ethan Geller <ethang@valvesoftware.com>
Date: Wed, 15 Nov 2023 14:35:00 -0800
Subject: [PATCH 11/11] fix filter chain targeting for mic.

---
 src/config/policy.lua.d/30-filters-config.lua | 2 +-
 1 file changed, 1 insertion(+), 1 deletion(-)

diff --git a/src/config/policy.lua.d/30-filters-config.lua b/src/config/policy.lua.d/30-filters-config.lua
index 37badd80..8e8725fc 100644
--- a/src/config/policy.lua.d/30-filters-config.lua
+++ b/src/config/policy.lua.d/30-filters-config.lua
@@ -73,7 +73,7 @@ default_policy.filters_metadata = {
       ["exclusive"] = false,
       ["props"] = {
         ["media.class"] = "Audio/Source",
-        ["alsa.card_name"] = "acp5x",
+        ["alsa.card_name"] = "sof-nau8821-max",
       }
     }
   }
-- 
2.42.0

