From 676e39c83f985f11fc5d87fb62040c6afd574d7c Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Tue, 21 Mar 2023 09:18:30 -0400 Subject: [PATCH 1/8] 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 aaf2938..3bb93f0 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 1920291..91c4e18 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 d2b4bf11b37714bd1df561aa3d5b80c7e6a93920 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Fri, 17 Mar 2023 11:06:11 -0400 Subject: [PATCH 2/8] 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 4930bfa..4a33701 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 0000000..32c67c8 --- /dev/null +++ b/modules/module-filters-api.c @@ -0,0 +1,905 @@ +/* WirePlumber + * + * Copyright © 2023 Collabora Ltd. + * @author Julian Bouzas + * + * SPDX-License-Identifier: MIT + */ + +#include + +#include + +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 2883a02..3d486be 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 83d0a3b..d3621a7 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 0000000..76aecad --- /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 0000000..04e3e6c --- /dev/null +++ b/src/scripts/filters-metadata.lua @@ -0,0 +1,39 @@ +-- WirePlumber +-- +-- Copyright © 2023 Collabora Ltd. +-- @author Julian Bouzas +-- +-- 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 99ad847..f249f34 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 2e409744d3c027feb37da07db461aaccffeb4c05 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Thu, 23 Mar 2023 09:26:45 -0400 Subject: [PATCH 3/8] 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 b869099..d25aab0 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 eab5f0342bd4054184f26763db495b9f18c02b03 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Thu, 23 Mar 2023 09:41:23 -0400 Subject: [PATCH 4/8] 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 d3621a7..412d47a 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 76aecad..8d919fb 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 4ef67b81a2eb4cd85cbbf32dd4bf599382b58761 Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Fri, 3 Nov 2023 09:24:59 -0400 Subject: [PATCH 5/8] 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 32c67c8..d7dc781 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 f249f34..c5a15ec 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 3908c5bdb3e65c1021a8e8a8f5c74d0866b2b0cf Mon Sep 17 00:00:00 2001 From: Julian Bouzas Date: Fri, 3 Nov 2023 11:44:57 -0400 Subject: [PATCH 6/8] 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 d7dc781..8852a6c 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 8d919fb..bf4b8d7 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 04e3e6c..49b4d0e 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 c5a15ec..035c300 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 56054fb96e82014ed99138faa1ee02ec9ce91140 Mon Sep 17 00:00:00 2001 From: Ethan Geller Date: Mon, 13 Nov 2023 22:10:05 -0800 Subject: [PATCH 7/8] 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 bf4b8d7..37badd8 100644 --- a/src/config/policy.lua.d/30-filters-config.lua +++ b/src/config/policy.lua.d/30-filters-config.lua @@ -65,7 +65,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 9c3daaebe8aefedee01fc7fbe51c148cb12f6bd0 Mon Sep 17 00:00:00 2001 From: Ethan Geller Date: Wed, 15 Nov 2023 14:35:00 -0800 Subject: [PATCH 8/8] 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 37badd8..8e8725f 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