diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml
index 12fa9e65..603b5af7 100644
--- a/.github/workflows/main.yml
+++ b/.github/workflows/main.yml
@@ -25,7 +25,7 @@ jobs:
- name: Install Dependencies
run: |
apt update
- apt install -y libaccountsservice-dev libdbus-1-dev libgranite-7-dev libgeoclue-2-dev libfwupd-dev libpackagekit-glib2-dev libgdk-pixbuf-2.0-dev libgexiv2-dev meson systemd-dev valac
+ apt install -y libaccountsservice-dev libdbus-1-dev libgranite-7-dev libgeoclue-2-dev libfwupd-dev libpackagekit-glib2-dev libgdk-pixbuf-2.0-dev libgexiv2-dev meson systemd-dev valac libpulse-dev libwireplumber-0.5-dev
- name: Build
env:
DESTDIR: out
diff --git a/data/50-elementary-speaker-equalizer.conf b/data/50-elementary-speaker-equalizer.conf
new file mode 100644
index 00000000..d53eb2c6
--- /dev/null
+++ b/data/50-elementary-speaker-equalizer.conf
@@ -0,0 +1,19 @@
+{
+ "monitor.alsa.rules": [
+ {
+ "matches": [
+ {
+ "node.name": "~alsa_output.*",
+ "elementary.eq.profile": null,
+ "media.class": "Audio/Sink"
+ }
+ ],
+ "actions": {
+ "update-props": {
+ "elementary.eq.profile": "generic-speakers-v1",
+ "node.cache-params": false
+ }
+ }
+ }
+ ]
+}
diff --git a/data/generic-speakers-v1.ini b/data/generic-speakers-v1.ini
new file mode 100644
index 00000000..40722d52
--- /dev/null
+++ b/data/generic-speakers-v1.ini
@@ -0,0 +1,12 @@
+[Profile]
+Version=1
+Namespace=eos_eq
+FirstGraph=true
+Node=*
+Route=*
+Types=low-shelf;peak;peak;peak;high-shelf;
+Frequencies=200;240;1000;16000;200;
+Q=0.707;0.6;1;6.606;0.707;
+DefaultGains=
+MinimumGains=-12;-12;-12;-12;-12;
+MaximumGains=12;12;12;12;12;
diff --git a/data/io.elementary.settings-daemon.gschema.xml b/data/io.elementary.settings-daemon.gschema.xml
index fb1c44ae..c7a4e7f5 100644
--- a/data/io.elementary.settings-daemon.gschema.xml
+++ b/data/io.elementary.settings-daemon.gschema.xml
@@ -183,4 +183,20 @@
Indicates the preferred level of contrast for the user interface.
+
+
+ false
+ Cancel speaker echo in the default microphone
+
+
+
+
+ false
+ Enable the speaker equalizer for this device profile
+
+
+ [0.0, 0.0, 0.0, 0.0, 0.0]
+ Five saved speaker equalizer gains in decibels
+
+
diff --git a/data/meson.build b/data/meson.build
index 5a913ae7..800d5e07 100644
--- a/data/meson.build
+++ b/data/meson.build
@@ -80,3 +80,14 @@ if systemd_system_unit_dir != 'no' and systemd_user_unit_dir != 'no'
install_dir: systemd_user_unit_dir
)
endif
+
+install_data(
+ 'generic-speakers-v1.ini',
+ install_dir: datadir / 'io.elementary.settings-daemon' / 'equalizers' / 'v1',
+)
+
+# Native graph creation requires WirePlumber 0.5.13 or later.
+install_data(
+ '50-elementary-speaker-equalizer.conf',
+ install_dir: datadir / 'wireplumber' / 'wireplumber.conf.d',
+)
diff --git a/docs/speaker-equalizer.md b/docs/speaker-equalizer.md
new file mode 100644
index 00000000..a78f5474
--- /dev/null
+++ b/docs/speaker-equalizer.md
@@ -0,0 +1,40 @@
+# Speaker equalizer
+
+Settings-daemon owns processing and reconnects independently of Sound Settings.
+EQ attaches to the physical output, preserving its volume, mute, balance and
+routing. It does not create a second EQ output.
+
+The five controls are Bass (low shelf), Low mids, Mids, Air (peaks), and Upper
+balance (high shelf). Their names are fixed in Sound. Profiles specify each
+filter's frequency, Q, gain range and optional recommended gain. The generic
+profile has neutral gains and starts disabled.
+
+Root-installed profiles live in `${datadir}/io.elementary.settings-daemon/equalizers/v1`.
+Profiles and parent directories must be root-owned, not writable by other users,
+and not symlinks. A declared invalid or missing profile does not fall back.
+
+Vendor policy handles hardware matching and sets `elementary.eq.profile` to
+the root-installed profile ID. It must not pre-install an EQ graph. It may
+use a schema override to enable recommended profiles initially. Stored user
+preferences always win. Other routes keep the same graph layout with independent
+neutral defaults. Reset restores recommendations without changing On/Off.
+
+The graph uses five series biquads `eos_eq_1` through `eos_eq_5`, followed by
+`eos_eq_h` with Mult=1, Add=0 and Control=0. Graph order 0 and the `eos_eq_`
+control namespace are reserved for the daemon on marked outputs. Set
+`node.cache-params=false`; the daemon validates fresh controls before writing.
+WirePlumber >= 0.5.13 and PipeWire >= 1.4 are required.
+
+The daemon attaches the validated profile graph only when EQ is enabled. Off
+removes it without changing the physical output or saved gains. There is no
+automatic attenuation or limiter. Another client's control changes are left
+alone until the user changes a preference and validation succeeds.
+
+Private integration checks run in the existing SDK without `/dev/snd`:
+
+```
+EQ_PRIVATE_TEST=1 sh tests/run-components.sh
+```
+
+The companion Sound runner takes this daemon source plus the matching vendor
+rule/profile via `EQ_OEM_RULE` and `EQ_OEM_PROFILE`.
diff --git a/meson.build b/meson.build
index ec8b9ad1..78b5da59 100644
--- a/meson.build
+++ b/meson.build
@@ -7,6 +7,17 @@ project('io.elementary.settings-daemon',
gnome = import('gnome')
+pulse_dep = dependency('libpulse')
+pulse_loop_dep = dependency('libpulse-mainloop-glib')
+# libWirePlumber owns the native client; its GIR is generated with our metadata.
+wireplumber_dep = dependency('wireplumber-0.5', version: '>=0.5.13')
+wireplumber_vapi = gnome.generate_vapi(
+ 'wireplumber-0.5',
+ sources: wireplumber_dep.get_variable(pkgconfig: 'prefix') / 'share/gir-1.0/Wp-0.5.gir',
+ packages: ['gio-2.0'],
+ metadata_dirs: [meson.current_source_dir() / 'vapi'],
+)
+
fwupd_dep = dependency('fwupd')
gio_dep = dependency ('gio-2.0')
glib_dep = dependency('glib-2.0')
@@ -33,6 +44,7 @@ libgeoclue_dep = dependency ('libgeoclue-2.0')
prefix = get_option('prefix')
datadir = prefix / get_option('datadir')
+add_project_arguments('-DEQ_PROFILE_DIR="' + datadir / 'io.elementary.settings-daemon/equalizers/v1' + '"', language: 'c')
bindir = prefix / get_option('bindir')
conf_data = configuration_data()
diff --git a/po/POTFILES b/po/POTFILES
index 918c1a68..01307f13 100644
--- a/po/POTFILES
+++ b/po/POTFILES
@@ -3,3 +3,7 @@ src/Application.vala
src/Backends/SystemUpdate.vala
src/Backends/UbuntuDrivers.vala
src/Utils/PkUtils.vala
+src/Backends/Audio.vala
+src/Backends/EchoProcessor.vala
+src/Backends/SpeakerEqualizer.vala
+src/Backends/speaker-equalizer-profile.c
diff --git a/src/Application.vala b/src/Application.vala
index 1b02e610..9f97609b 100644
--- a/src/Application.vala
+++ b/src/Application.vala
@@ -18,6 +18,7 @@ public sealed class SettingsDaemon.Application : Gtk.Application {
private Backends.PrefersColorSchemeSettings prefers_color_scheme_settings;
private Backends.AccentColorManager accent_color_manager;
+ private Backends.Audio audio;
private Backends.Housekeeping housekeeping;
private Backends.PowerProfilesSync power_profiles_sync;
private Backends.ApplicationShortcuts application_shortcuts;
@@ -55,6 +56,8 @@ public sealed class SettingsDaemon.Application : Gtk.Application {
query_end.connect (() => release ());
base.startup ();
+ audio.start ();
+
housekeeping = new Backends.Housekeeping ();
power_profiles_sync = new Backends.PowerProfilesSync ();
application_shortcuts = new Backends.ApplicationShortcuts ();
@@ -80,6 +83,9 @@ public sealed class SettingsDaemon.Application : Gtk.Application {
protected override bool dbus_register (DBusConnection connection, string object_path) throws Error {
base.dbus_register (connection, object_path);
+ audio = new Backends.Audio ();
+ connection.register_object (object_path, audio);
+
if (!SettingsDaemon.Utils.is_sysupdate ()) {
connection.register_object (object_path, new Backends.SystemUpdate ());
diff --git a/src/Backends/Audio.vala b/src/Backends/Audio.vala
new file mode 100644
index 00000000..a20aa68f
--- /dev/null
+++ b/src/Backends/Audio.vala
@@ -0,0 +1,134 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2026 elementary, Inc. (https://elementary.io)
+ */
+
+[DBus (name = "io.elementary.settings_daemon.Audio")]
+public class SettingsDaemon.Backends.Audio : Object {
+ public struct Status {
+ public bool enabled;
+ public bool available;
+ public bool busy;
+ public string error;
+ public string source_master;
+ public string sink_master;
+ }
+
+ public struct EqualizerStatus {
+ public bool available;
+ public bool busy;
+ public bool applied;
+ public string error;
+ public string settings_path;
+ public string node;
+ public string route;
+ public double[] frequencies;
+ public string[] types;
+ public double[] defaults;
+ public double[] minimum;
+ public double[] maximum;
+ }
+
+ public signal void state_changed ();
+ public signal void equalizer_changed ();
+ private PulseAudio.GLibMainLoop loop = new PulseAudio.GLibMainLoop ();
+ private PulseAudio.Context context;
+ private EchoProcessor? processor;
+ private SpeakerEqualizer? equalizer;
+ private uint reconnect_id;
+
+ [DBus (visible = false)]
+ public void start () {
+ connect_audio ();
+ }
+
+ public Status get_status () throws DBusError, IOError {
+ if (processor == null) {
+ return { false, false, false, _("Could not connect to the audio server."), "", "" };
+ }
+ return { processor.enabled, processor.available, processor.busy,
+ processor.error ?? "", processor.source_alias ?? "", processor.sink_alias ?? "" };
+ }
+
+ public EqualizerStatus get_equalizer_status () throws DBusError, IOError {
+ if (equalizer != null) return equalizer.get_status ();
+ return { false, false, false, _("The audio service is unavailable."), "", "", "",
+ new double[0], new string[0], new double[0], new double[0], new double[0] };
+ }
+
+ private void connect_audio () {
+ context = new PulseAudio.Context (loop.get_api (), "elementary Settings Daemon");
+ context.set_state_callback ((c) => {
+ switch (c.get_state ()) {
+ case PulseAudio.Context.State.READY:
+ processor = new EchoProcessor (c);
+ if (equalizer == null) {
+ equalizer = new SpeakerEqualizer (c);
+ equalizer.changed.connect (() => equalizer_changed ());
+ } else {
+ equalizer.reconnect (c);
+ }
+ processor.notify.connect (() => state_changed ());
+ string? last_echo_master = null;
+ processor.notify.connect (() => {
+ if (processor != null && equalizer != null && last_echo_master != processor.sink_alias) {
+ last_echo_master = processor.sink_alias;
+ equalizer.refresh (last_echo_master);
+ }
+ });
+ c.set_subscribe_callback ((connection, event, index) => {
+ var facility = event & PulseAudio.Context.SubscriptionEventType.FACILITY_MASK;
+ if (facility == PulseAudio.Context.SubscriptionEventType.SERVER ||
+ facility == PulseAudio.Context.SubscriptionEventType.SINK ||
+ facility == PulseAudio.Context.SubscriptionEventType.CARD) {
+ equalizer.refresh (processor.sink_alias);
+ }
+ if (facility == PulseAudio.Context.SubscriptionEventType.SERVER) {
+ processor.observe_defaults ();
+ } else if (facility == PulseAudio.Context.SubscriptionEventType.MODULE &&
+ (event & PulseAudio.Context.SubscriptionEventType.TYPE_MASK) ==
+ PulseAudio.Context.SubscriptionEventType.REMOVE) {
+ processor.module_removed (index);
+ } else {
+ processor.refresh ();
+ }
+ });
+ c.subscribe (PulseAudio.Context.SubscriptionMask.SERVER |
+ PulseAudio.Context.SubscriptionMask.SOURCE | PulseAudio.Context.SubscriptionMask.SINK |
+ PulseAudio.Context.SubscriptionMask.MODULE | PulseAudio.Context.SubscriptionMask.SOURCE_OUTPUT |
+ PulseAudio.Context.SubscriptionMask.SINK_INPUT | PulseAudio.Context.SubscriptionMask.CARD,
+ (connection, success) => {
+ if (success == 1 && processor != null && processor.context == connection) {
+ processor.observe_defaults ();
+ }
+ });
+ equalizer.refresh (processor.sink_alias);
+ state_changed ();
+ break;
+ case PulseAudio.Context.State.FAILED:
+ case PulseAudio.Context.State.TERMINATED:
+ if (equalizer != null) equalizer.stop ();
+ equalizer_changed ();
+ if (processor != null) {
+ processor.stop ();
+ }
+ processor = null;
+ c.set_subscribe_callback (null);
+ c.set_state_callback (null);
+ c.disconnect ();
+ state_changed ();
+ if (reconnect_id == 0) {
+ reconnect_id = Timeout.add_seconds (2, () => {
+ reconnect_id = 0;
+ connect_audio ();
+ return Source.REMOVE;
+ });
+ }
+ break;
+ default:
+ break;
+ }
+ });
+ context.connect (null, PulseAudio.Context.Flags.NOAUTOSPAWN);
+ }
+}
diff --git a/src/Backends/EchoProcessor.vala b/src/Backends/EchoProcessor.vala
new file mode 100644
index 00000000..d527ae9f
--- /dev/null
+++ b/src/Backends/EchoProcessor.vala
@@ -0,0 +1,730 @@
+/*
+ * SPDX-License-Identifier: LGPL-2.0-or-later
+ * SPDX-FileCopyrightText: 2026 elementary, Inc. (https://elementary.io)
+ */
+
+internal class SettingsDaemon.Backends.EchoProcessor : Object {
+ public const string SOURCE_NAME = "elementary_echo_cancel_source";
+ public const string SINK_NAME = "elementary_echo_cancel_sink";
+
+ public PulseAudio.Context context { get; construct; }
+ public bool enabled { get; private set; }
+ public bool available { get; private set; }
+ public bool busy { get; private set; }
+ public string? error { get; private set; }
+ public string? source_master { get; private set; }
+ public string? sink_master { get; private set; }
+
+ public string? source_alias {
+ get {
+ return module_index != PulseAudio.INVALID_INDEX && source_index != PulseAudio.INVALID_INDEX ?
+ source_master : null;
+ }
+ }
+ public string? sink_alias {
+ get {
+ return module_index != PulseAudio.INVALID_INDEX && sink_index != PulseAudio.INVALID_INDEX ?
+ sink_master : null;
+ }
+ }
+
+ private Settings settings = new Settings ("io.elementary.settings-daemon.audio");
+ private uint32 module_index = PulseAudio.INVALID_INDEX;
+ private uint32 source_index = PulseAudio.INVALID_INDEX;
+ private uint32 sink_index = PulseAudio.INVALID_INDEX;
+ private struct RestoredStream {
+ public uint32 index;
+ public string target;
+ }
+ private RestoredStream[] restored_recordings = {};
+ private RestoredStream[] restored_playbacks = {};
+ private bool pending;
+ private bool reconciling;
+ private bool restoring_preference;
+ private bool restore_failed;
+ private uint refresh_id;
+ private string? observed_source;
+ private string? observed_sink;
+ private signal void defaults_changed ();
+ private signal void endpoints_changed ();
+
+ public void observe_defaults () {
+ context.get_server_info ((c, info) => {
+ if (info != null) {
+ observed_source = info.default_source_name;
+ observed_sink = info.default_sink_name;
+ defaults_changed ();
+ refresh ();
+ }
+ });
+ }
+
+ public EchoProcessor (PulseAudio.Context context) {
+ Object (context: context);
+ }
+
+ construct {
+ restoring_preference = settings.get_boolean ("echo-cancellation");
+ settings.changed["echo-cancellation"].connect (preference_changed);
+ }
+
+ public void stop () {
+ restored_recordings = {};
+ restored_playbacks = {};
+ settings.changed["echo-cancellation"].disconnect (preference_changed);
+ if (refresh_id != 0) {
+ Source.remove (refresh_id);
+ refresh_id = 0;
+ }
+ }
+
+ private void preference_changed () {
+ if (!settings.get_boolean ("echo-cancellation")) {
+ restored_recordings = {};
+ restored_playbacks = {};
+ }
+ restoring_preference = false;
+ restore_failed = false;
+ refresh ();
+ }
+
+ private void enable_failed () {
+ if (!restoring_preference) {
+ settings.set_boolean ("echo-cancellation", false);
+ }
+ // Keep the failed request and diagnostic until the next user change.
+ restore_failed = true;
+ }
+
+ public void refresh () {
+ endpoints_changed ();
+ pending = true;
+ busy = true;
+ if (reconciling || refresh_id != 0) {
+ return;
+ }
+
+ // Coalesce a burst of device/default events before replacing the
+ // filter. PipeWire applies paired metadata selections asynchronously.
+ refresh_id = Timeout.add (100, () => {
+ refresh_id = 0;
+ reconcile.begin ();
+ return Source.REMOVE;
+ });
+ }
+
+ public void module_removed (uint32 index) {
+ if (index == module_index) {
+ module_index = PulseAudio.INVALID_INDEX;
+ source_master = sink_master = null;
+ enabled = false;
+ }
+
+ refresh ();
+ }
+
+ // These arguments are PulseAudio module arguments, never shell commands.
+ private static string quote (string name) {
+ return "\"%s\"".printf (name.replace ("\\", "\\\\").replace ("\"", "\\\""));
+ }
+
+ private static string arguments (string source, string sink) {
+ string owner = "device.echo_cancel.owner=io.elementary.settings.sound";
+ return "source_master=%s sink_master=%s source_name=%s sink_name=%s ".printf (
+ quote (source), quote (sink), SOURCE_NAME, SINK_NAME
+ ) + "aec_method=webrtc aec_args=\"analog_gain_control=0 digital_gain_control=0\" " +
+ "source_properties=%s sink_properties=%s".printf (
+ quote (owner + " device.master_device=" + source),
+ quote (owner + " device.master_device=" + sink)
+ );
+ }
+
+ private async bool discover_module () {
+ bool complete = false;
+ uint32 discovered_index = PulseAudio.INVALID_INDEX;
+ string? discovered_source = null;
+ string? discovered_sink = null;
+ var operation = context.get_module_info_list ((c, info, eol) => {
+ if (eol != 0) {
+ complete = eol > 0;
+ }
+ if (info == null || info.name != "module-echo-cancel" || info.argument == null) {
+ return;
+ }
+
+ // Adopt only a module with the exact arguments we generate. Never
+ // unload another application's filter, even if its names collide.
+ try {
+ string[] words;
+ Shell.parse_argv (info.argument, out words);
+ string? source = null;
+ string? sink = null;
+ foreach (var word in words) {
+ if (word.has_prefix ("source_master=")) {
+ source = word.substring (14);
+ } else if (word.has_prefix ("sink_master=")) {
+ sink = word.substring (12);
+ }
+ }
+
+ if (source != null && sink != null && info.argument == arguments (source, sink)) {
+ discovered_index = info.index;
+ discovered_source = source;
+ discovered_sink = sink;
+ }
+ } catch (ShellError e) {
+ warning ("Reading echo cancellation module arguments failed: %s", e.message);
+ }
+ });
+ var success = (yield wait (operation)) && complete;
+ if (success && discovered_index != PulseAudio.INVALID_INDEX) {
+ module_index = discovered_index;
+ source_master = discovered_source;
+ sink_master = discovered_sink;
+ }
+ return success;
+ }
+
+ // Completion, cancellation, disconnect and timeout all resume the caller.
+ // Cancel before releasing callback data on timeout (libpulse's contract).
+ private async bool wait (PulseAudio.Operation? operation) {
+ if (operation == null) {
+ return false;
+ }
+
+ if (operation.get_state () != PulseAudio.Operation.State.RUNNING) {
+ return operation.get_state () == PulseAudio.Operation.State.DONE;
+ }
+
+ bool timed_out = false;
+ var timeout = new TimeoutSource (5000);
+ timeout.set_callback (() => {
+ timed_out = true;
+ operation.cancel ();
+ wait.callback ();
+ return Source.REMOVE;
+ });
+ PulseAudio.operation_set_state_callback (operation, (op) => {
+ if (!timed_out && op.get_state () != PulseAudio.Operation.State.RUNNING) {
+ timeout.destroy ();
+ wait.callback ();
+ }
+ });
+ timeout.attach ();
+ yield;
+ PulseAudio.operation_set_state_callback (operation, null);
+ return !timed_out && operation.get_state () == PulseAudio.Operation.State.DONE;
+ }
+
+ private async bool server_defaults (out string? source, out string? sink) {
+ string? current_source = null;
+ string? current_sink = null;
+ var operation = context.get_server_info ((c, info) => {
+ if (info != null) {
+ current_source = info.default_source_name;
+ current_sink = info.default_sink_name;
+ observed_source = current_source;
+ observed_sink = current_sink;
+ }
+ });
+ var success = yield wait (operation);
+ source = current_source;
+ sink = current_sink;
+ return success && source != null && sink != null;
+ }
+
+ private struct Endpoint {
+ public bool complete;
+ public bool exists;
+ public bool physical;
+ public bool is_owned;
+ public uint32 index;
+ }
+
+ private async Endpoint source_info (string? name) {
+ Endpoint result = { false, false, false, false, PulseAudio.INVALID_INDEX };
+ if (name == null) {
+ return result;
+ }
+ var op = context.get_source_info_by_name (name, (c, info, eol) => {
+ if (eol != 0) {
+ result.complete = eol > 0 || c.errno () == PulseAudio.Error.NOENTITY;
+ } else if (info != null) {
+ result.exists = true;
+ result.index = info.index;
+ result.is_owned = info.owner_module == module_index &&
+ info.proplist.gets ("device.echo_cancel.owner") == "io.elementary.settings.sound";
+ result.physical = name != SOURCE_NAME && !name.has_suffix (".monitor") &&
+ info.monitor_of_sink == PulseAudio.INVALID_INDEX &&
+ info.proplist.gets ("device.class") != "filter" &&
+ info.proplist.gets ("node.virtual") != "true" &&
+ info.proplist.gets ("device.master_device") == null &&
+ (PulseAudio.SourceFlags.HARDWARE in info.flags || info.card != PulseAudio.INVALID_INDEX);
+
+ }
+ });
+ if (!(yield wait (op)) || !result.complete) {
+ result = { false, false, false, false, PulseAudio.INVALID_INDEX };
+ }
+ return result;
+ }
+
+ private async Endpoint sink_info (string? name) {
+ Endpoint result = { false, false, false, false, PulseAudio.INVALID_INDEX };
+ if (name == null) {
+ return result;
+ }
+ var op = context.get_sink_info_by_name (name, (c, info, eol) => {
+ if (eol != 0) {
+ result.complete = eol > 0 || c.errno () == PulseAudio.Error.NOENTITY;
+ } else if (info != null) {
+ result.exists = true;
+ result.index = info.index;
+ result.is_owned = info.owner_module == module_index &&
+ info.proplist.gets ("device.echo_cancel.owner") == "io.elementary.settings.sound";
+ result.physical = name != SINK_NAME &&
+ info.proplist.gets ("device.class") != "filter" &&
+ info.proplist.gets ("node.virtual") != "true" &&
+ info.proplist.gets ("device.master_device") == null &&
+ (PulseAudio.SinkFlags.HARDWARE in info.flags || info.card != PulseAudio.INVALID_INDEX);
+ }
+ });
+ if (!(yield wait (op)) || !result.complete) {
+ result = { false, false, false, false, PulseAudio.INVALID_INDEX };
+ }
+ return result;
+ }
+
+ private async bool physical_source (string? name) {
+ return (yield source_info (name)).physical;
+ }
+
+ private async bool physical_sink (string? name) {
+ return (yield sink_info (name)).physical;
+ }
+
+ private async bool set_default (string name, bool input, string expected) {
+ string? previous_source;
+ string? previous_sink;
+ if (!(yield server_defaults (out previous_source, out previous_sink))) {
+ return false;
+ }
+
+ var previous = input ? previous_source : previous_sink;
+ if (previous == name) {
+ return true;
+ }
+ if (previous != expected) {
+ return false;
+ }
+
+ bool success = false;
+ PulseAudio.Context.SuccessCb callback = (c, result) => { success = result == 1; };
+ var operation = input ? context.set_default_source (name, callback) : context.set_default_sink (name, callback);
+ if (!(yield wait (operation)) || !success) {
+ return false;
+ }
+
+ // The server subscription observes PipeWire's effective default,
+ // which may follow the operation acknowledgement. No polling.
+ bool expired = false;
+ ulong handler = defaults_changed.connect (() => set_default.callback ());
+ var deadline = new TimeoutSource (2000);
+ deadline.set_callback (() => {
+ expired = true;
+ set_default.callback ();
+ return Source.REMOVE;
+ });
+ deadline.attach ();
+ while (!expired) {
+ var current = input ? observed_source : observed_sink;
+ if (current == name || current != (input ? previous_source : previous_sink)) {
+ break;
+ }
+ yield;
+ }
+ deadline.destroy ();
+ disconnect (handler);
+ return !expired && (input ? observed_source : observed_sink) == name;
+ }
+
+ private async bool endpoints_available () {
+ var source = yield source_info (SOURCE_NAME);
+ var sink = yield sink_info (SINK_NAME);
+ return source.complete && sink.complete && !source.exists && !sink.exists;
+ }
+
+ private async bool owns_endpoints (bool allow_missing = false) {
+ var source = yield source_info (SOURCE_NAME);
+ var sink = yield sink_info (SINK_NAME);
+ source_index = source.is_owned ? source.index : PulseAudio.INVALID_INDEX;
+ sink_index = sink.is_owned ? sink.index : PulseAudio.INVALID_INDEX;
+ return source.complete && sink.complete &&
+ (source.is_owned || (allow_missing && !source.exists)) &&
+ (sink.is_owned || (allow_missing && !sink.exists));
+ }
+
+ private async bool restore_default (string? master, bool input) {
+ var valid = input ? (yield physical_source (master)) : (yield physical_sink (master));
+ if (!valid) {
+ return true;
+ }
+
+ string? source;
+ string? sink;
+ if (!(yield server_defaults (out source, out sink))) {
+ return false;
+ }
+
+ if ((input && source == SOURCE_NAME) || (!input && sink == SINK_NAME)) {
+ return yield set_default (master, input, input ? SOURCE_NAME : SINK_NAME);
+ }
+
+ return true;
+ }
+
+ private async bool restore_streams () {
+ // Unknown or foreign ownership must never authorize stream teardown.
+ if (!(yield owns_endpoints (true))) {
+ return false;
+ }
+ uint32[] recordings = {};
+ uint32[] playbacks = {};
+ bool success = true;
+ var operation = context.get_source_output_info_list ((c, info, eol) => {
+ if (eol < 0) {
+ success = false;
+ } else if (info != null && info.source == source_index && info.owner_module != module_index) {
+ recordings += info.index;
+ }
+ });
+ if (!(yield wait (operation)) || !success) {
+ return false;
+ }
+
+ operation = context.get_sink_input_info_list ((c, info, eol) => {
+ if (eol < 0) {
+ success = false;
+ } else if (info != null && info.sink == sink_index && info.owner_module != module_index) {
+ playbacks += info.index;
+ }
+ });
+ if (!(yield wait (operation)) || !success) {
+ return false;
+ }
+
+ string? source;
+ string? sink;
+ if (!(yield server_defaults (out source, out sink))) {
+ return false;
+ }
+
+ var target_source = (yield physical_source (source_master)) ? source_master : source;
+ var target_sink = (yield physical_sink (sink_master)) ? sink_master : sink;
+ if ((recordings.length > 0 && !(yield physical_source (target_source))) ||
+ (playbacks.length > 0 && !(yield physical_sink (target_sink)))) {
+ return false;
+ }
+
+ // Rescue only streams on our endpoints. Refuse to tear them down if
+ // the server cannot move them (for example, PA_STREAM_DONT_MOVE).
+ foreach (var index in recordings) {
+ success = false;
+ operation = context.move_source_output_by_name (
+ index, target_source, (c, result) => { success = result == 1; }
+ );
+ if (!(yield wait (operation)) || !success) {
+ return false;
+ }
+ remember_stream (true, index, target_source);
+ }
+
+ foreach (var index in playbacks) {
+ success = false;
+ operation = context.move_sink_input_by_name (index, target_sink, (c, result) => { success = result == 1; });
+ if (!(yield wait (operation)) || !success) {
+ return false;
+ }
+ remember_stream (false, index, target_sink);
+ }
+
+ return true;
+ }
+
+ private void remember_stream (bool input, uint32 index, string target) {
+ RestoredStream[] streams = input ? restored_recordings : restored_playbacks;
+ for (int i = 0; i < streams.length; i++) {
+ if (streams[i].index == index) {
+ streams[i].target = target;
+ return;
+ }
+ }
+ RestoredStream stream = { index, target };
+ if (input) {
+ restored_recordings += stream;
+ } else {
+ restored_playbacks += stream;
+ }
+ }
+
+ private async bool reattach_streams (bool input) {
+ if (!(yield owns_endpoints ())) {
+ return false;
+ }
+ RestoredStream[] streams = input ? restored_recordings : restored_playbacks;
+ foreach (var stream in streams) {
+ var physical = input ? (yield source_info (stream.target)) : (yield sink_info (stream.target));
+ if (!physical.complete) {
+ return false;
+ }
+ bool still_restored = false;
+ var operation = input ? context.get_source_output_info (stream.index, (c, info) => {
+ if (info != null) still_restored = physical.exists && info.source == physical.index;
+ }) : context.get_sink_input_info (stream.index, (c, info) => {
+ if (info != null) still_restored = physical.exists && info.sink == physical.index;
+ });
+ if (!(yield wait (operation)) && context.errno () != PulseAudio.Error.NOENTITY) {
+ return false;
+ }
+ // A closed stream or an external routing change is not ours to undo.
+ if (!still_restored) {
+ continue;
+ }
+ if (!settings.get_boolean ("echo-cancellation") || !(yield owns_endpoints ())) {
+ return false;
+ }
+ bool success = false;
+ operation = input ? context.move_source_output_by_name (stream.index, SOURCE_NAME,
+ (c, result) => { success = result == 1; }) : context.move_sink_input_by_name (stream.index, SINK_NAME,
+ (c, result) => { success = result == 1; });
+ if (!(yield wait (operation)) || !success) {
+ return false;
+ }
+ }
+ if (input) {
+ restored_recordings = {};
+ } else {
+ restored_playbacks = {};
+ }
+ return true;
+ }
+
+ private async bool release () {
+ if (module_index == PulseAudio.INVALID_INDEX) {
+ enabled = false;
+ source_master = sink_master = null;
+ return true;
+ }
+
+ // Only restore defaults still pointing at our filter. An external
+ // device selection takes precedence. Never write volumes or stream
+ // restore entries, or move streams attached to other endpoints.
+ if (!(yield restore_streams ()) || !(yield restore_default (source_master, true)) ||
+ !(yield restore_default (sink_master, false))) {
+ return false;
+ }
+
+ bool success = false;
+ var operation = context.unload_module (module_index, (c, result) => { success = result == 1; });
+ if (!(yield wait (operation)) || (!success && module_index != PulseAudio.INVALID_INDEX)) {
+ return false;
+ }
+
+ module_index = PulseAudio.INVALID_INDEX;
+ source_master = sink_master = null;
+ enabled = false;
+ if (!settings.get_boolean ("echo-cancellation")) {
+ restored_recordings = {};
+ restored_playbacks = {};
+ }
+ // Unload acknowledgement can precede removal of the published endpoints.
+ return yield await_endpoints (false);
+ }
+
+ private async bool await_endpoints (bool present = true) {
+ bool expired = false;
+ bool changed = false;
+ bool waiting = false;
+ ulong handler = endpoints_changed.connect (() => {
+ changed = true;
+ if (waiting) {
+ await_endpoints.callback ();
+ }
+ });
+ var deadline = new TimeoutSource (2000);
+ deadline.set_callback (() => {
+ expired = true;
+ if (waiting) {
+ await_endpoints.callback ();
+ }
+ return Source.REMOVE;
+ });
+ deadline.attach ();
+ bool ready = false;
+ while (!expired) {
+ changed = false;
+ var source = yield source_info (SOURCE_NAME);
+ var sink = yield sink_info (SINK_NAME);
+ if (!source.complete || !sink.complete || (present &&
+ ((source.exists && !source.is_owned) || (sink.exists && !sink.is_owned)))) {
+ break;
+ }
+ if (present ? source.is_owned && sink.is_owned : !source.exists && !sink.exists) {
+ ready = !expired;
+ break;
+ }
+ if (!changed && !expired) {
+ waiting = true;
+ yield;
+ waiting = false;
+ }
+ }
+ deadline.destroy ();
+ disconnect (handler);
+ return ready;
+ }
+
+ private async bool activate (string source, string sink, out bool selection_changed) {
+ selection_changed = false;
+ string? current_source = null;
+ string? current_sink = null;
+ // PipeWire can acknowledge the module before publishing its Pulse
+ // endpoints. Wait for subscription events, with the same ownership gate.
+ if (!(yield await_endpoints ()) || !(yield owns_endpoints ()) ||
+ !(yield server_defaults (out current_source, out current_sink)) ||
+ !settings.get_boolean ("echo-cancellation")) {
+ return false;
+ }
+
+ if ((current_source != source && current_source != SOURCE_NAME) ||
+ (current_sink != sink && current_sink != SINK_NAME)) {
+ selection_changed = true;
+ return false;
+ }
+
+ var sink_selected = yield set_default (SINK_NAME, false, sink);
+ if (!(yield server_defaults (out current_source, out current_sink))) {
+ return false;
+ }
+
+ selection_changed = (current_source != source && current_source != SOURCE_NAME) ||
+ (current_sink != sink && current_sink != SINK_NAME);
+ if (!sink_selected || selection_changed || !settings.get_boolean ("echo-cancellation")) {
+ return false;
+ }
+
+ var source_selected = yield set_default (SOURCE_NAME, true, source);
+ if (!source_selected && (yield server_defaults (out current_source, out current_sink))) {
+ selection_changed = current_source != source && current_source != SOURCE_NAME;
+ }
+
+ return source_selected && (yield reattach_streams (true)) && (yield reattach_streams (false));
+ }
+
+ private async void reconcile () {
+ reconciling = true;
+ busy = true;
+ pending = false;
+ string? source = null;
+ string? sink = null;
+ // Rediscover before trusting absence, including after canceled loads
+ // and MODULE NEW events. The same serialized, bounded path owns cleanup.
+ if ((module_index != PulseAudio.INVALID_INDEX || (yield discover_module ())) &&
+ (yield server_defaults (out source, out sink))) {
+ enabled = module_index != PulseAudio.INVALID_INDEX && source == SOURCE_NAME && sink == SINK_NAME;
+ var target_source = source == SOURCE_NAME ? source_master : source;
+ var target_sink = sink == SINK_NAME ? sink_master : sink;
+ available = (yield physical_source (target_source)) && (yield physical_sink (target_sink));
+ if (restore_failed) {
+ // Still discover and release a load that completed after timeout.
+ yield release ();
+ } else if (!settings.get_boolean ("echo-cancellation") || !available) {
+ if (!(yield release ())) {
+ error = _("Could not disable echo cancellation.");
+ } else {
+ error = !available && settings.get_boolean ("echo-cancellation") ?
+ _("Connect a microphone and an audio output to use echo cancellation.") : null;
+ }
+ } else if (module_index != PulseAudio.INVALID_INDEX &&
+ source_master == target_source && sink_master == target_sink &&
+ source == SOURCE_NAME && sink == SINK_NAME && (yield owns_endpoints ())) {
+ if ((yield reattach_streams (true)) && (yield reattach_streams (false))) {
+ enabled = true;
+ error = null;
+ } else {
+ error = _("Could not enable echo cancellation.");
+ }
+ } else if (yield release ()) {
+ if (!(yield endpoints_available ())) {
+ error = _("The echo cancellation device names are already in use.");
+ if (context.get_state () == PulseAudio.Context.State.READY) {
+ enable_failed ();
+ }
+ reconciling = false;
+ refresh ();
+ return;
+ }
+
+ uint32 loaded_index = PulseAudio.INVALID_INDEX;
+ var operation = context.load_module (
+ "module-echo-cancel", arguments (target_source, target_sink),
+ (c, index) => { loaded_index = index; }
+ );
+ if ((yield wait (operation)) && loaded_index != PulseAudio.INVALID_INDEX) {
+ module_index = loaded_index;
+ source_master = target_source;
+ sink_master = target_sink;
+ // A load may complete after a newer disable request.
+ if (!settings.get_boolean ("echo-cancellation")) {
+ yield release ();
+ } else {
+ bool selection_changed;
+ if (yield activate (target_source, target_sink, out selection_changed)) {
+ enabled = true;
+ error = null;
+ } else {
+ yield release ();
+ if (selection_changed) {
+ pending = true;
+ } else if (settings.get_boolean ("echo-cancellation")) {
+ error = _("Could not enable echo cancellation.");
+ if (context.get_state () == PulseAudio.Context.State.READY) {
+ enable_failed ();
+ }
+ }
+ }
+ }
+ } else {
+ error = _("Echo cancellation is not available on this audio server.");
+ if (context.get_state () == PulseAudio.Context.State.READY) {
+ // Cancelling a client operation does not cancel the
+ // server command. Discover and release a late load.
+ yield discover_module ();
+ yield release ();
+ enable_failed ();
+ }
+ }
+ } else {
+ error = _("Could not update echo cancellation.");
+ }
+ } else {
+ available = false;
+ enabled = false;
+ error = _("Could not connect to the audio server.");
+ if (context.get_state () == PulseAudio.Context.State.READY && refresh_id == 0) {
+ refresh_id = Timeout.add_seconds (2, () => {
+ refresh_id = 0;
+ refresh ();
+ return Source.REMOVE;
+ });
+ }
+ }
+
+ reconciling = false;
+ if (pending && context.get_state () == PulseAudio.Context.State.READY) {
+ refresh ();
+ } else {
+ busy = false;
+ }
+ }
+}
diff --git a/src/Backends/SpeakerEqualizer.vala b/src/Backends/SpeakerEqualizer.vala
new file mode 100644
index 00000000..6497d699
--- /dev/null
+++ b/src/Backends/SpeakerEqualizer.vala
@@ -0,0 +1,825 @@
+/*
+ * SPDX-License-Identifier: GPL-3.0-or-later
+ * SPDX-FileCopyrightText: 2026 elementary, Inc. (https://elementary.io)
+ */
+
+internal class SettingsDaemon.Backends.SpeakerEqualizer : Object {
+
+ public signal void changed ();
+ private PulseAudio.Context context;
+ private Wp.Core? core;
+ private uint32? server_cookie;
+ private Wp.ObjectManager? native_nodes;
+ private Wp.Node? native_node;
+ private string native_name = "";
+ private string native_error = "";
+ private enum NativeState {
+ UNBOUND,
+ WAITING_FOR_AUDIO,
+ AUDITING,
+ CONFIRMED,
+ FAILED
+ }
+
+ private NativeState state;
+ private uint native_revision;
+ private uint native_idle;
+ private uint connect_deadline;
+ private ulong disconnected_handler;
+ private ulong params_handler;
+ private ulong state_handler;
+ private bool native_busy;
+ private bool native_pending;
+ private bool may_write;
+ private double[] target_gains;
+ private bool target_enabled;
+ private Settings? settings;
+ private Profile? profile;
+ private string settings_path = "";
+ private string node = "";
+ private string route = "";
+ private string error = "";
+ private string? echo_master;
+ private bool eligible;
+ private bool retiring;
+ private bool stopped;
+ private bool querying;
+ private bool pending;
+ private uint generation;
+ private uint refresh_id;
+ private uint reconnect_id;
+ private Request? last_request;
+
+ public SpeakerEqualizer (PulseAudio.Context context) {
+ this.context = context;
+ }
+
+ public void reconnect (PulseAudio.Context context) {
+ this.context = context;
+ stopped = false;
+ // A surviving native graph is audited, never overwritten on reconnect.
+ may_write = false;
+ if (state != NativeState.FAILED) {
+ state = NativeState.UNBOUND;
+ }
+ }
+
+ public Audio.EqualizerStatus get_status () {
+ return {
+ eligible && profile != null && (state != NativeState.UNBOUND || pending || querying || error != ""),
+ querying || pending || native_busy || native_pending,
+ eligible && !querying && !pending && !native_busy && !native_pending && state == NativeState.CONFIRMED,
+ error != "" ? error : native_error,
+ settings_path, node, route,
+ profile != null ? profile.frequencies : new double[0],
+ profile != null ? profile.types : new string[0],
+ profile != null ? profile.defaults : new double[0],
+ profile != null ? profile.minimum : new double[0],
+ profile != null ? profile.maximum : new double[0]
+ };
+ }
+
+ public void refresh (string? current_echo_master) {
+ if (stopped) {
+ return;
+ }
+ echo_master = current_echo_master;
+ generation++;
+ pending = true;
+ if (refresh_id == 0 && !querying) {
+ refresh_id = Timeout.add (100, () => {
+ refresh_id = 0;
+ reconcile.begin ();
+ return Source.REMOVE;
+ });
+ }
+ changed ();
+ }
+
+ private void settings_changed () {
+ refresh (echo_master);
+ }
+
+ private void invalidate () {
+ eligible = retiring = false;
+ last_request = null;
+ native_revision++;
+ may_write = native_pending = false;
+ state = NativeState.UNBOUND;
+ }
+
+ private void native_failed (string message) {
+ may_write = native_pending = false;
+ state = NativeState.FAILED;
+ native_error = message;
+ changed ();
+ if (retiring) {
+ refresh (echo_master);
+ }
+ }
+
+ private void unbind () {
+ if (native_node == null) {
+ return;
+ }
+ native_node.disconnect (params_handler);
+ native_node.disconnect (state_handler);
+ native_node = null;
+ }
+
+ private void close_native () {
+ native_revision++;
+ if (connect_deadline != 0) {
+ Source.remove (connect_deadline);
+ }
+ connect_deadline = 0;
+ unbind ();
+ native_nodes = null;
+ if (core != null) {
+ SignalHandler.disconnect (core, disconnected_handler);
+ core.disconnect ();
+ }
+ core = null;
+ }
+
+ private void connection_lost () {
+ may_write = false;
+ state = NativeState.FAILED;
+ native_error = _("The PipeWire equalizer connection failed.");
+ native_pending = false;
+ close_native ();
+ changed ();
+ if (!stopped && reconnect_id == 0) {
+ reconnect_id = Timeout.add_seconds (2, () => {
+ reconnect_id = 0;
+ state = NativeState.UNBOUND;
+ refresh (echo_master);
+ return Source.REMOVE;
+ });
+ }
+ }
+
+ public void stop () {
+ stopped = true;
+ may_write = pending = native_pending = false;
+ generation++;
+ if (refresh_id != 0) {
+ Source.remove (refresh_id);
+ }
+ refresh_id = 0;
+ if (reconnect_id != 0) {
+ Source.remove (reconnect_id);
+ }
+ reconnect_id = 0;
+ if (settings != null) {
+ settings.changed.disconnect (settings_changed);
+ }
+ settings = null;
+ close_native ();
+ if (native_idle != 0) {
+ Source.remove (native_idle);
+ }
+ native_idle = 0;
+ eligible = false;
+ }
+
+ private async bool wait (PulseAudio.Operation? operation) {
+ if (operation == null) {
+ return false;
+ }
+ if (operation.get_state () != RUNNING) {
+ return operation.get_state () == DONE;
+ }
+ bool expired = false;
+ var timeout = new TimeoutSource (2000);
+ timeout.set_callback (() => {
+ expired = true;
+ operation.cancel ();
+ wait.callback ();
+ return Source.REMOVE;
+ });
+ PulseAudio.operation_set_state_callback (operation, (op) => {
+ if (!expired && op.get_state () != RUNNING) {
+ timeout.destroy ();
+ wait.callback ();
+ }
+ });
+ timeout.attach ();
+ yield;
+ PulseAudio.operation_set_state_callback (operation, null);
+ return !expired && operation.get_state () == DONE;
+ }
+
+ private async void reconcile () {
+ querying = true;
+ pending = false;
+ uint request = generation;
+ string? selected = null;
+ bool pipewire = false;
+ var op = context.get_server_info ((c, info) => {
+ if (info != null) {
+ selected = info.default_sink_name;
+ pipewire = info.server_name != null && info.server_name.contains ("PipeWire");
+ }
+ });
+ bool complete = yield wait (op);
+ if (stopped || request != generation) {
+ finish ();
+ return;
+ }
+ if (!complete || !pipewire || selected == null) {
+ invalidate ();
+ error = _("Speaker equalization requires PipeWire and a supported physical output.");
+ close_native ();
+ finish ();
+ return;
+ }
+ if (selected == EchoProcessor.SINK_NAME) {
+ selected = echo_master;
+ }
+ if (retiring && (state == NativeState.CONFIRMED || state == NativeState.FAILED) && !native_busy) {
+ node = "";
+ close_native ();
+ invalidate ();
+ }
+ // Remove our graph from the old physical output before following
+ // another default, including its shared speaker/headphone route.
+ bool release = selected != null && node != "" && selected != node;
+ if (node != "") {
+ selected = node;
+ }
+ if (selected == null) {
+ invalidate ();
+ finish ();
+ return;
+ }
+ string? name = null, serial = null, device = null, profile_id = null, port = null;
+ bool physical = false;
+ string[] ports = {};
+ bool ended = false;
+ op = context.get_sink_info_by_name (selected, (c, info, eol) => {
+ if (eol != 0) {
+ ended = eol > 0;
+ }
+ if (info == null) {
+ return;
+ }
+ physical = info.proplist.gets ("device.class") != "filter" &&
+ info.proplist.gets ("node.virtual") != "true" &&
+ info.proplist.gets ("device.master_device") == null &&
+ (PulseAudio.SinkFlags.HARDWARE in info.flags || info.card != PulseAudio.INVALID_INDEX);
+ name = info.name;
+ serial = info.proplist.gets ("object.serial");
+ device = info.proplist.gets ("device.id");
+ profile_id = info.proplist.gets ("elementary.eq.profile");
+ port = info.active_port != null ? info.active_port.name : "";
+ foreach (var candidate in info.ports) {
+ if (candidate != null) {
+ ports += candidate.name;
+ }
+ }
+ });
+ complete = yield wait (op);
+ if (stopped || request != generation) {
+ finish ();
+ return;
+ }
+ error = "";
+ if (!complete || !ended || !physical || name == null || serial == null || device == null ||
+ profile_id == null || port == null) {
+ if (node != "") {
+ node = "";
+ close_native ();
+ refresh (echo_master);
+ }
+ invalidate ();
+ error = _("No speaker equalizer profile is available for this output.");
+ finish ();
+ return;
+ }
+ bool valid_profile = false;
+ try {
+ var selected_profile = new Profile (profile_id, name, port);
+ bool valid_route = port == "" ? ports.length == 0 : port in ports;
+ if ((!valid_route && node == "") ||
+ (profile_id != "generic-speakers-v1" && !(selected_profile.route in ports))) {
+ node = "";
+ close_native ();
+ invalidate ();
+ error = _("No speaker equalizer profile is available for this output.");
+ if (release) {
+ refresh (echo_master);
+ }
+ finish ();
+ return;
+ }
+ // Speaker recommendations belong only to their declared route.
+ // Other routes use the same validated graph with neutral defaults.
+ string preferences_profile = profile_id;
+ if (profile_id != "generic-speakers-v1" && port != selected_profile.route) {
+ selected_profile.route = port;
+ selected_profile.defaults = {};
+ preferences_profile = profile_id + "-neutral";
+ }
+ // Preferences are stable across transient PW IDs, scoped to the
+ // installed profile revision, physical node name and selected route.
+ string identity = Checksum.compute_for_string (ChecksumType.SHA256,
+ preferences_profile + "\n" + name + "\n" + selected_profile.route);
+ string path = valid_route ?
+ "/io/elementary/settings-daemon/audio/equalizer/%s/".printf (identity) : settings_path;
+ if (settings_path != path || settings == null) {
+ if (settings != null) {
+ settings.changed.disconnect (settings_changed);
+ }
+ settings = new Settings.with_path ("io.elementary.settings-daemon.audio.equalizer", path);
+ settings.changed.connect (settings_changed);
+ settings_path = path;
+ }
+ profile = selected_profile;
+ valid_profile = true;
+ node = name;
+ route = port;
+ eligible = valid_route && port == profile.route && !release;
+ if (!valid_route) {
+ error = _("The selected output has no active route.");
+ }
+ var saved_enabled = settings.get_user_value ("enabled");
+ bool requested = saved_enabled != null ? saved_enabled.get_boolean () :
+ profile.defaults.length == 5 && settings.get_boolean ("enabled");
+ bool enabled = eligible && requested;
+ var stored = settings.get_user_value ("gains");
+ if (stored == null) {
+ var builder = new VariantBuilder (new VariantType ("ad"));
+ double[] defaults = profile.defaults.length == 5 ? profile.defaults : new double[5];
+ foreach (double gain in defaults) {
+ builder.add ("d", gain);
+ }
+ stored = builder.end ();
+ }
+ var gains = new double[5];
+ if (enabled) {
+ if (stored.n_children () != 5) {
+ throw new IOError.INVALID_DATA (_("Five equalizer gains are required."));
+ }
+ for (int i = 0; i < 5; i++) {
+ gains[i] = stored.get_child_value (i).get_double ();
+ }
+ for (int i = 0; i < 5; i++) {
+ if (!gains[i].is_finite () || gains[i] < profile.minimum[i] || gains[i] > profile.maximum[i]) {
+ throw new IOError.INVALID_DATA (_("An equalizer gain is outside the device profile range."));
+ }
+ }
+ }
+ var desired = new Request () {
+ node = name,
+ serial = serial,
+ device = device,
+ profile = profile_id,
+ route = port,
+ signature = profile.signature,
+ enabled = requested,
+ valid_route = valid_route,
+ gains = stored
+ };
+ ensure_native ();
+ // Only changed identity/profile/preferences authorize an attempt.
+ // Rediscovery, notifications, idle/resume and reconnect only audit.
+ if (last_request == null || !last_request.matches (desired) || retiring != release) {
+ last_request = desired;
+ target_gains = gains;
+ target_enabled = enabled;
+ native_revision++;
+ retiring = release;
+ may_write = true;
+ state = NativeState.UNBOUND;
+ native_error = "";
+ }
+ queue_native ();
+
+ } catch (Error e) {
+ if (!valid_profile) {
+ invalidate ();
+ node = "";
+ close_native ();
+ if (release) {
+ refresh (echo_master);
+ }
+ } else {
+ // A corrected preference may equal the last valid request.
+ last_request = null;
+ native_failed (e.message);
+ }
+ error = e.message;
+ }
+ finish ();
+ }
+
+ private void finish () {
+ querying = false;
+ changed ();
+ if (pending && !stopped) {
+ refresh (echo_master);
+ }
+ }
+
+ private void ensure_native () {
+ if (core != null && native_name != node) {
+ close_native ();
+ }
+ if (core != null) {
+ return;
+ }
+ Wp.init (Wp.InitFlags.PIPEWIRE | Wp.InitFlags.SPA_TYPES);
+ native_name = node;
+ core = new Wp.Core (null, null, null);
+ disconnected_handler = core.disconnected.connect (connection_lost);
+ native_nodes = new Wp.ObjectManager ();
+ var interest = new Wp.ObjectInterest.type (typeof (Wp.Node));
+ interest.add_constraint (Wp.ConstraintType.PW_GLOBAL_PROPERTY, "node.name",
+ Wp.ConstraintVerb.EQUALS, new Variant.string (node));
+ native_nodes.add_interest_full ((owned) interest);
+ native_nodes.request_object_features (typeof (Wp.Node), (Wp.ObjectFeatures)
+ (Wp.ProxyFeatures.PIPEWIRE_OBJECT_FEATURES_MINIMAL | Wp.ProxyFeatures.PIPEWIRE_OBJECT_FEATURE_PARAM_PROPS));
+ native_nodes.objects_changed.connect (() => {
+ native_revision++;
+ queue_native ();
+ });
+ native_nodes.object_removed.connect ((obj) => {
+ if (obj == native_node) {
+ unbind ();
+ state = NativeState.UNBOUND;
+ }
+ });
+ native_nodes.installed.connect (() => {
+ uint32 cookie = core.get_remote_cookie ();
+ // Object serials can repeat in a new server; its graphs cannot survive.
+ if (server_cookie != null && server_cookie != cookie) {
+ native_revision++;
+ may_write = true;
+ state = NativeState.UNBOUND;
+ native_error = "";
+ }
+ server_cookie = cookie;
+ if (connect_deadline != 0) {
+ Source.remove (connect_deadline);
+ }
+ connect_deadline = 0;
+ queue_native ();
+ });
+ core.install_object_manager (native_nodes);
+ connect_deadline = Timeout.add (2000, () => {
+ connect_deadline = 0;
+ connection_lost ();
+ return Source.REMOVE;
+ });
+ if (!core.connect ()) {
+ connection_lost ();
+ }
+ }
+
+ private void queue_native () {
+ if (stopped || last_request == null || state == NativeState.FAILED) {
+ return;
+ }
+ native_pending = true;
+ if (!native_busy && native_idle == 0) {
+ native_idle = Idle.add (() => {
+ native_idle = 0;
+ audit_native.begin ();
+ return Source.REMOVE;
+ });
+ }
+ changed ();
+ }
+
+ private bool current (uint revision, Wp.Core owner) {
+ return !stopped && revision == native_revision && owner == core && last_request != null;
+ }
+
+ private int control_slot (string name, ref uint64 mask) throws Error {
+ if (!name.has_prefix ("eos_eq_")) {
+ return -1;
+ }
+ string[] ports = { "Freq", "Q", "Gain", "b0", "b1", "b2", "a0", "a1", "a2" };
+ int slot = name == "eos_eq_h:Mult" ? 45 : name == "eos_eq_h:Add" ? 46 : name == "eos_eq_h:Control" ? 47 : -1;
+ for (int band = 0; band < 5; band++) {
+ for (int port = 0; port < ports.length; port++) {
+ if (name == "eos_eq_%d:%s".printf (band + 1, ports[port])) {
+ slot = band * 9 + port;
+ }
+ }
+ }
+ if (slot < 0 || (mask & (1UL << slot)) != 0) {
+ throw new IOError.INVALID_DATA (_("Equalizer controls are malformed or duplicated."));
+ }
+ mask |= 1UL << slot;
+ return slot;
+ }
+
+ private float[]? controls (Wp.Iterator? parameters, bool metadata) throws Error {
+ uint64 mask = 0;
+ var values = new float[48];
+ uint blocks = 0;
+ uint graphs = 0;
+ if (parameters == null) {
+ throw new IOError.INVALID_DATA (_("The installed equalizer controls are incomplete."));
+ }
+ Value item;
+ while (parameters.next (out item)) {
+ blocks++;
+ unowned Wp.SpaPod pod = (Wp.SpaPod) item.get_boxed ();
+ var properties = pod.new_iterator ();
+ bool device_properties = false;
+ Value entry;
+ while (properties.next (out entry)) {
+ unowned Wp.SpaPod property = (Wp.SpaPod) entry.get_boxed ();
+ unowned string key;
+ Wp.SpaPod value;
+ if (!property.get_property (out key, out value)) {
+ break;
+ }
+ if (!metadata && (key == "volume" || key == "device")) {
+ device_properties = true;
+ }
+ if (metadata && key == "name") {
+ unowned string name;
+ if (value.get_string (out name)) {
+ control_slot (name, ref mask);
+ }
+ } else if (!metadata && key == "params" && value.is_struct ()) {
+ var params = value.new_iterator ();
+ Value k, v;
+ while (params.next (out k)) {
+ unowned string name;
+ if (!params.next (out v)) {
+ throw new IOError.INVALID_DATA (_("Equalizer controls are malformed or duplicated."));
+ }
+ if (!((Wp.SpaPod) k.get_boxed ()).get_string (out name)) {
+ throw new IOError.INVALID_DATA (_("Equalizer controls are malformed or duplicated."));
+ }
+ int slot = control_slot (name, ref mask);
+ if (slot >= 0 && (!((Wp.SpaPod) v.get_boxed ()).get_float (out values[slot]) || !values[slot].is_finite ())) {
+ throw new IOError.INVALID_DATA (_("Equalizer controls are malformed or duplicated."));
+ }
+ }
+ }
+ }
+ if (!metadata && !device_properties) {
+ graphs++;
+ }
+ }
+ // Audioconvert and ALSA expose their own Props as well as graph controls.
+ // A graph without controls still occupies a slot and must be preserved.
+ if (!metadata && (graphs > 1 || (graphs == 1 && mask == 0))) {
+ throw new IOError.INVALID_DATA (_("Another application is processing this output."));
+ }
+ if (!metadata && blocks == 0) {
+ throw new IOError.INVALID_DATA (_("The output controls are unavailable."));
+ }
+ if (mask == 0) {
+ return null;
+ }
+ if (mask != (1UL << 48) - 1) {
+ throw new IOError.INVALID_DATA (_("The installed equalizer controls are incomplete."));
+ }
+ return values;
+ }
+
+ private async void audit_native () {
+ native_pending = false;
+ if (core == null || native_nodes == null || last_request == null || state == NativeState.FAILED) {
+ return;
+ }
+ native_busy = true;
+ uint revision = native_revision;
+ var owner = core;
+ var desired = last_request;
+ var installed = profile;
+ var cancel = new Cancellable ();
+ uint deadline = 0;
+ try {
+ var count = native_nodes.get_n_objects ();
+ if (count == 0) {
+ state = NativeState.UNBOUND;
+ return;
+ }
+ if (count != 1) {
+ throw new IOError.INVALID_DATA (_("The physical output name is not unique."));
+ }
+ var objects = native_nodes.new_iterator ();
+ Value item;
+ objects.next (out item);
+ var selected = (Wp.Node) item.get_object ();
+ var props = selected.get_properties ();
+ if (props.get ("node.name") != desired.node ||
+ props.get ("object.serial") != desired.serial ||
+ props.get ("device.id") != desired.device ||
+ props.get ("elementary.eq.profile") != desired.profile ||
+ props.get ("media.class") != "Audio/Sink" || props.get ("node.virtual") == "true") {
+ throw new IOError.INVALID_DATA (_("The physical output or its equalizer profile changed."));
+ }
+ if (native_node != selected) {
+ unbind ();
+ native_node = selected;
+ params_handler = selected.params_changed.connect ((id) => {
+ if (id == "Props") {
+ queue_native ();
+ }
+ });
+ state_handler = selected.state_changed.connect (() => {
+ native_revision++;
+ queue_native ();
+ });
+ }
+ if (selected.state != Wp.NodeState.RUNNING && target_enabled) {
+ state = NativeState.WAITING_FOR_AUDIO;
+ return;
+ }
+ state = NativeState.AUDITING;
+ deadline = Timeout.add (2000, () => {
+ deadline = 0;
+ cancel.cancel ();
+ if (core == owner) {
+ connection_lost ();
+ }
+ return Source.REMOVE;
+ });
+ var metadata = yield selected.enum_params ("PropInfo", null, cancel);
+ if (!current (revision, owner)) {
+ return;
+ }
+ var shape = controls (metadata, true);
+ var parameters = yield selected.enum_params ("Props", null, cancel);
+ if (!current (revision, owner)) {
+ return;
+ }
+ var values = controls (parameters, false);
+ bool attached = shape != null || values != null;
+ if (attached) {
+ if (shape == null || values == null) {
+ throw new IOError.INVALID_DATA (_("The installed equalizer controls are incomplete."));
+ }
+ for (int i = 0; i < 5; i++) {
+ if (Math.fabs (values[i * 9] - installed.frequencies[i]) > 0.01 ||
+ Math.fabs (values[i * 9 + 1] - installed.q[i]) > 0.0001) {
+ throw new IOError.INVALID_DATA (_("The equalizer does not match its installed profile."));
+ }
+ }
+ if (values[46] != 0 || values[47] != 0) {
+ throw new IOError.INVALID_DATA (_("The equalizer headroom control changed."));
+ }
+ }
+ bool matches = attached == target_enabled;
+ if (matches && attached) {
+ matches = Math.fabs (values[45] - 1) <= 0.00001;
+ for (int i = 0; i < 5; i++) {
+ matches &= Math.fabs (values[i * 9 + 2] - target_gains[i]) <= 0.001;
+ }
+ }
+ if (!matches && !may_write) {
+ throw new IOError.INVALID_DATA (_("The equalizer controls changed outside Sound settings."));
+ }
+ may_write = false;
+ if (matches) {
+ state = NativeState.CONFIRMED;
+ native_error = "";
+ if (retiring) {
+ refresh (echo_master);
+ }
+ return;
+ }
+ var params = new Wp.SpaPodBuilder.@struct ();
+ if (attached != target_enabled) {
+ params.add_string ("audioconvert.filter-graph.0");
+ params.add_string (target_enabled ? installed.graph (target_gains) : "");
+ } else {
+ for (int i = 0; i < 5; i++) {
+ params.add_string ("eos_eq_%d:Gain".printf (i + 1));
+ params.add_float ((float) target_gains[i]);
+ }
+ params.add_string ("eos_eq_h:Mult");
+ params.add_float (1.0f);
+ }
+ var setter = new Wp.SpaPodBuilder.object ("Spa:Pod:Object:Param:Props", "Props");
+ setter.add_property ("params");
+ setter.add_pod (params.end ());
+ if (!selected.set_param ("Props", 0, setter.end ())) {
+ throw new IOError.FAILED (_("PipeWire rejected the equalizer controls."));
+ }
+ yield owner.sync (cancel);
+ // Confirm the write with a fresh enumeration, including after idle.
+ if (current (revision, owner)) {
+ native_pending = true;
+ }
+ } catch (Error e) {
+ if (current (revision, owner)) {
+ native_failed (e.message);
+ }
+ } finally {
+ if (deadline != 0) {
+ Source.remove (deadline);
+ }
+ native_busy = false;
+ if (native_pending) {
+ queue_native ();
+ }
+ changed ();
+ }
+ }
+
+ private class Request : Object {
+ public string node;
+ public string serial;
+ public string device;
+ public string profile;
+ public string route;
+ public string signature;
+ public bool enabled;
+ public bool valid_route;
+ public Variant gains;
+
+ public bool matches (Request other) {
+ return node == other.node && serial == other.serial && device == other.device &&
+ profile == other.profile && route == other.route && signature == other.signature &&
+ enabled == other.enabled && valid_route == other.valid_route && gains.equal (other.gains);
+ }
+ }
+
+ private class Profile : Object {
+ public string signature;
+ public string node;
+ public string route;
+ public string[] types;
+ public double[] frequencies;
+ public double[] q;
+ public double[] defaults;
+ public double[] minimum;
+ public double[] maximum;
+
+ public string graph (double[] gains) {
+ string[] filters = { "bq_lowshelf", "bq_peaking", "bq_peaking", "bq_peaking", "bq_highshelf" };
+ var graph = new StringBuilder ("{ nodes = [");
+ for (int i = 0; i < 5; i++) {
+ graph.append_printf ("{ type = builtin name = eos_eq_%d label = %s " +
+ "control = { Freq = %s Q = %s Gain = %s } }",
+ i + 1, filters[i], frequencies[i].to_string (), q[i].to_string (), gains[i].to_string ());
+ }
+ graph.append ("{ type = builtin name = eos_eq_h label = linear " +
+ "control = { Mult = 1 Add = 0 Control = 0 } } ] links = [");
+ for (int i = 1; i < 5; i++) {
+ graph.append_printf ("{ output = \"eos_eq_%d:Out\" input = \"eos_eq_%d:In\" }", i, i + 1);
+ }
+ graph.append ("{ output = \"eos_eq_5:Out\" input = \"eos_eq_h:In\" } ] }");
+ return graph.str;
+ }
+
+ public Profile (string id, string output, string selected_route) throws Error {
+ var file = new KeyFile ();
+ string data = SpeakerEqualizerProfile.read (id);
+ signature = Checksum.compute_for_string (ChecksumType.SHA256, data);
+ file.load_from_data (data, data.length, KeyFileFlags.NONE);
+ if (file.get_integer ("Profile", "Version") != 1 ||
+ file.get_string ("Profile", "Namespace") != "eos_eq" ||
+ !file.get_boolean ("Profile", "FirstGraph")) {
+ throw new IOError.INVALID_DATA (_("The equalizer profile version or namespace is invalid."));
+ }
+ node = file.get_string ("Profile", "Node");
+ route = file.get_string ("Profile", "Route");
+ types = file.get_string_list ("Profile", "Types");
+ frequencies = file.get_double_list ("Profile", "Frequencies");
+ q = file.get_double_list ("Profile", "Q");
+ defaults = file.get_double_list ("Profile", "DefaultGains");
+ minimum = file.get_double_list ("Profile", "MinimumGains");
+ maximum = file.get_double_list ("Profile", "MaximumGains");
+ // The shipped generic layout has no OEM tuning. Its wildcard binds
+ // to the same physical node checked by PulseAudio and PipeWire.
+ bool generic = id == "generic-speakers-v1";
+ if (generic && node == "*") {
+ node = output;
+ }
+ if (generic && route == "*") {
+ route = selected_route;
+ }
+ if (node == "" || node.length > 256 || (!generic && route == "") || route.length > 128 ||
+ types.length != 5 || frequencies.length != 5 || q.length != 5 || defaults.length != (generic ? 0 : 5) ||
+ minimum.length != 5 || maximum.length != 5) {
+ throw new IOError.INVALID_DATA (_("The equalizer profile is incomplete."));
+ }
+ string[] layout_types = { "low-shelf", "peak", "peak", "peak", "high-shelf" };
+ for (int i = 0; i < 5; i++) {
+ if (types[i] != layout_types[i] ||
+ !frequencies[i].is_finite () || frequencies[i] < 20 || frequencies[i] > 20000 ||
+ !q[i].is_finite () || q[i] <= 0 || q[i] > 10 ||
+ !minimum[i].is_finite () || !maximum[i].is_finite () ||
+ minimum[i] < -24 || minimum[i] > 0 || maximum[i] < 0 || maximum[i] > 12 ||
+ (defaults.length == 5 && (!defaults[i].is_finite () ||
+ defaults[i] < minimum[i] || defaults[i] > maximum[i]))) {
+ throw new IOError.INVALID_DATA (_("An equalizer profile band is invalid."));
+ }
+ }
+ if (node != output) {
+ throw new IOError.INVALID_DATA (_("The equalizer profile does not match the physical output."));
+ }
+ }
+ }
+}
diff --git a/src/Backends/speaker-equalizer-profile.c b/src/Backends/speaker-equalizer-profile.c
new file mode 100644
index 00000000..f23af4f9
--- /dev/null
+++ b/src/Backends/speaker-equalizer-profile.c
@@ -0,0 +1,46 @@
+/* SPDX-License-Identifier: GPL-3.0-or-later */
+#include "speaker-equalizer-profile.h"
+#include
+#include
+#include
+#include
+#include
+#ifndef EQ_PROFILE_DIR
+#define EQ_PROFILE_DIR "/usr/share/io.elementary.settings-daemon/equalizers/v1"
+#endif
+
+char *eq_profile_read (const char *id, GError **error)
+{
+ int fd = -1;
+ char *result = NULL;
+ g_auto (GStrv) parts = NULL;
+ struct stat st;
+ if (!id || !*id || strlen (id) > 64) goto out;
+ for (const char *p = id; *p; p++)
+ if (!g_ascii_isalnum (*p) && *p != '-' && *p != '_') goto out;
+ /* Walk every directory without following links. No user config fallback. */
+ fd = open ("/", O_RDONLY | O_DIRECTORY | O_CLOEXEC);
+ parts = g_strsplit (EQ_PROFILE_DIR, "/", -1);
+ for (char **p = parts; *p && fd >= 0; p++) {
+ if (!**p) continue;
+ int next = openat (fd, *p, O_RDONLY | O_DIRECTORY | O_NOFOLLOW | O_CLOEXEC);
+ close (fd); fd = next;
+ if (fd < 0 || fstat (fd, &st) || st.st_uid != 0 || (st.st_mode & 022)) goto out;
+ }
+ if (fd < 0) goto out;
+ char filename[72];
+ g_snprintf (filename, sizeof filename, "%s.ini", id);
+ int file = openat (fd, filename, O_RDONLY | O_NOFOLLOW | O_NONBLOCK | O_CLOEXEC);
+ close (fd); fd = file;
+ if (fd < 0 || fstat (fd, &st) || !S_ISREG (st.st_mode) || st.st_uid != 0 ||
+ (st.st_mode & 022) || st.st_size <= 0 || st.st_size > 8192) goto out;
+ result = g_malloc (st.st_size + 1);
+ if (read (fd, result, st.st_size) != st.st_size) g_clear_pointer (&result, g_free);
+ else if (memchr (result, '\0', st.st_size)) g_clear_pointer (&result, g_free);
+ else result[st.st_size] = '\0';
+out:
+ if (fd >= 0) close (fd);
+ if (!result) g_set_error_literal (error, G_FILE_ERROR, G_FILE_ERROR_INVAL,
+ _("No valid root-installed equalizer profile is available."));
+ return result;
+}
diff --git a/src/Backends/speaker-equalizer-profile.h b/src/Backends/speaker-equalizer-profile.h
new file mode 100644
index 00000000..baffd9b9
--- /dev/null
+++ b/src/Backends/speaker-equalizer-profile.h
@@ -0,0 +1,4 @@
+/* SPDX-License-Identifier: GPL-3.0-or-later */
+#pragma once
+#include
+char *eq_profile_read (const char *id, GError **error);
diff --git a/src/meson.build b/src/meson.build
index e5cb5503..b63eb8cb 100644
--- a/src/meson.build
+++ b/src/meson.build
@@ -1,5 +1,14 @@
+equalizer_library = static_library(
+ 'speaker-equalizer-profile',
+ 'Backends/speaker-equalizer-profile.c',
+ dependencies: [gio_dep],
+)
+
sources = files(
'AccountsService.vala',
+ 'Backends/Audio.vala',
+ 'Backends/SpeakerEqualizer.vala',
+ 'Backends/EchoProcessor.vala',
'Application.vala',
'Backends/AccentColorManager.vala',
'Backends/ApplicationShortcuts.vala',
@@ -28,8 +37,13 @@ endif
executable(
meson.project_name(),
sources,
+ link_with: equalizer_library,
dependencies: [
config_dep,
+ wireplumber_dep,
+ wireplumber_vapi,
+ pulse_dep,
+ pulse_loop_dep,
fwupd_dep,
gio_dep,
glib_dep,
@@ -40,6 +54,7 @@ executable(
m_dep,
pk_dep
],
- vala_args: args,
+ vala_args: args + ['--vapidir=' + meson.project_source_root() / 'vapi',
+ '--vapidir=' + meson.project_build_root(), '--pkg=libpulse-operation', '--pkg=speaker-equalizer-profile'],
install: true,
)
diff --git a/tests/aec/README.md b/tests/aec/README.md
new file mode 100644
index 00000000..c7d2caa3
--- /dev/null
+++ b/tests/aec/README.md
@@ -0,0 +1,25 @@
+Run in an isolated copy of this checkout in the existing private SDK, with no
+`/dev/snd` and with libpulse development files, valac, PipeWire, pipewire-pulse,
+WirePlumber, pactl and the ALSA null plugin available:
+
+```
+AEC_PRIVATE_TEST=1 sh tests/run-aec.sh
+```
+
+The runner compiles the production EchoProcessor directly, independently of EQ.
+It uses private runtime/config directories and ALSA null devices, following the
+companion Sound harness. Only the owner receives an LD_PRELOAD fault shim:
+following a successful native module load, native endpoint queries temporarily
+return NOENTITY. Real endpoint notifications wake the production activation path.
+
+The three cases cover publication within the deadline, expiration while restoring
+a saved preference, and expiration after an explicit enable. The timeout cases
+check cleanup, preference retention versus explicit rollback, no repeated loads
+under further device notifications, and recovery after a deliberate disable/enable.
+On the original source, delayed publication causes immediate activation failure
+and saved-preference rollback. The short-delay case should fail there.
+
+This models the asynchronous publication race; it does not reproduce HDA discovery
+or establish that endpoint publication was the actual trigger in F2. Run the
+companion lifecycle/ownership/stream suite and the parent hardware restart check
+separately. Generated files stay under `tests/aec/build`.
diff --git a/tests/aec/endpoint-delay.c b/tests/aec/endpoint-delay.c
new file mode 100644
index 00000000..b534a8c4
--- /dev/null
+++ b/tests/aec/endpoint-delay.c
@@ -0,0 +1,65 @@
+/* Fault injection around real libpulse operations, not a replacement backend.
+ * A successful load is followed by a bounded period of NOENTITY responses,
+ * as when PipeWire has not yet exported the filter's Pulse endpoints.
+ */
+#define _GNU_SOURCE
+#include
+#include
+#include
+#include
+#include
+#include
+
+static double hidden_until;
+static double now (void) {
+ struct timespec t;
+ clock_gettime (CLOCK_MONOTONIC, &t);
+ return t.tv_sec + t.tv_nsec / 1e9;
+}
+
+struct load_callback {
+ pa_context_index_cb_t callback;
+ void *userdata;
+};
+
+static void loaded (pa_context *c, uint32_t index, void *userdata) {
+ struct load_callback *data = userdata;
+ if (index != PA_INVALID_INDEX) {
+ FILE *file = fopen (getenv ("AEC_TEST_DELAY_FILE"), "r");
+ unsigned delay = 0;
+ if (file) {
+ if (fscanf (file, "%u", &delay) != 1) abort ();
+ fclose (file);
+ }
+ hidden_until = now () + delay / 1000.0;
+ }
+ data->callback (c, index, data->userdata);
+ free (data);
+}
+
+pa_operation *pa_context_load_module (pa_context *c, const char *name, const char *args,
+ pa_context_index_cb_t callback, void *userdata) {
+ typeof (&pa_context_load_module) real = dlsym (RTLD_NEXT, "pa_context_load_module");
+ struct load_callback *data = malloc (sizeof *data);
+ *data = (struct load_callback) { callback, userdata };
+ fprintf (stderr, "AEC_TEST_LOAD\n");
+ pa_operation *op = real (c, name, args, loaded, data);
+ if (!op) free (data);
+ return op;
+}
+
+pa_operation *pa_context_get_source_info_by_name (pa_context *c, const char *name,
+ pa_source_info_cb_t callback, void *userdata) {
+ typeof (&pa_context_get_source_info_by_name) real = dlsym (RTLD_NEXT, "pa_context_get_source_info_by_name");
+ if (now () < hidden_until && strcmp (name, "elementary_echo_cancel_source") == 0)
+ name = "aec_test_not_published";
+ return real (c, name, callback, userdata);
+}
+
+pa_operation *pa_context_get_sink_info_by_name (pa_context *c, const char *name,
+ pa_sink_info_cb_t callback, void *userdata) {
+ typeof (&pa_context_get_sink_info_by_name) real = dlsym (RTLD_NEXT, "pa_context_get_sink_info_by_name");
+ if (now () < hidden_until && strcmp (name, "elementary_echo_cancel_sink") == 0)
+ name = "aec_test_not_published";
+ return real (c, name, callback, userdata);
+}
diff --git a/tests/aec/owner.vala b/tests/aec/owner.vala
new file mode 100644
index 00000000..a5485426
--- /dev/null
+++ b/tests/aec/owner.vala
@@ -0,0 +1,58 @@
+// Isolated production EchoProcessor runner; no EQ or desktop bus dependency.
+int main (string[] args) {
+ assert (Environment.get_variable ("AEC_PRIVATE_TEST") == "1");
+ assert (!FileUtils.test ("/dev/snd", FileTest.EXISTS));
+ assert (Environment.get_variable ("PULSE_SERVER").has_prefix ("unix:" + Environment.get_variable ("XDG_RUNTIME_DIR") + "/"));
+ var settings = new Settings ("io.elementary.settings-daemon.audio");
+ settings.set_boolean ("echo-cancellation", args[1] == "restore");
+ var main_loop = new MainLoop ();
+ var pulse_loop = new PulseAudio.GLibMainLoop ();
+ var context = new PulseAudio.Context (pulse_loop.get_api (), "AEC isolated regression");
+ SettingsDaemon.Backends.EchoProcessor? processor = null;
+ context.set_state_callback ((c) => {
+ if (c.get_state () == PulseAudio.Context.State.READY) {
+ processor = new SettingsDaemon.Backends.EchoProcessor (c);
+ c.set_subscribe_callback ((connection, event, index) => {
+ var facility = event & PulseAudio.Context.SubscriptionEventType.FACILITY_MASK;
+ if (facility == PulseAudio.Context.SubscriptionEventType.SERVER) {
+ processor.observe_defaults ();
+ } else if (facility == PulseAudio.Context.SubscriptionEventType.MODULE &&
+ (event & PulseAudio.Context.SubscriptionEventType.TYPE_MASK) == PulseAudio.Context.SubscriptionEventType.REMOVE) {
+ processor.module_removed (index);
+ } else {
+ processor.refresh ();
+ }
+ });
+ c.subscribe (PulseAudio.Context.SubscriptionMask.SERVER | PulseAudio.Context.SubscriptionMask.SOURCE |
+ PulseAudio.Context.SubscriptionMask.SINK | PulseAudio.Context.SubscriptionMask.MODULE,
+ (connection, success) => {
+ assert (success == 1);
+ processor.observe_defaults ();
+ });
+ } else if (c.get_state () == PulseAudio.Context.State.FAILED) {
+ main_loop.quit ();
+ }
+ });
+ var input = new IOChannel.unix_new (0);
+ input.add_watch (IOCondition.IN, (channel, condition) => {
+ try {
+ string line;
+ channel.read_line (out line, null, null);
+ settings.set_boolean ("echo-cancellation", line.strip () == "enable");
+ } catch (Error e) {
+ error (e.message);
+ }
+ return Source.CONTINUE;
+ });
+ Timeout.add (100, () => {
+ if (processor != null) {
+ stdout.printf ("%s %s %s %s %s\n", processor.enabled.to_string (), processor.available.to_string (),
+ processor.busy.to_string (), settings.get_boolean ("echo-cancellation").to_string (), processor.error ?? "-");
+ stdout.flush ();
+ }
+ return Source.CONTINUE;
+ });
+ context.connect (null, PulseAudio.Context.Flags.NOAUTOSPAWN);
+ main_loop.run ();
+ return 0;
+}
diff --git a/tests/aec/reconnect.py b/tests/aec/reconnect.py
new file mode 100644
index 00000000..06b535cd
--- /dev/null
+++ b/tests/aec/reconnect.py
@@ -0,0 +1,169 @@
+#!/usr/bin/env python3
+"""Production AEC startup with delayed Pulse endpoint visibility, in a private SDK.
+Uses the companion harness's private PW/Pulse/WP stack and ALSA null fixtures.
+"""
+import os
+from pathlib import Path
+import queue
+import subprocess
+import tempfile
+import threading
+import time
+
+assert os.environ.get('AEC_PRIVATE_TEST') == '1'
+assert not Path('/dev/snd').exists()
+BUILD = Path(__file__).resolve().parent / 'build'
+
+
+def scenario(delay, restored):
+ with tempfile.TemporaryDirectory(prefix='runtime-', dir=BUILD) as directory:
+ root = Path(directory)
+ root.chmod(0o700)
+ env = dict(os.environ, XDG_RUNTIME_DIR=str(root), XDG_CONFIG_HOME=str(root / 'config'),
+ XDG_STATE_HOME=str(root / 'state'), XDG_CACHE_HOME=str(root / 'cache'),
+ PULSE_SERVER='unix:' + str(root / 'native'), PIPEWIRE_RUNTIME_DIR=str(root),
+ PIPEWIRE_REMOTE='pipewire-0', GSETTINGS_BACKEND='memory',
+ GSETTINGS_SCHEMA_DIR=str(BUILD / 'schemas'), ALSA_CONFIG_PATH=str(root / 'asound.conf'))
+ for key in ('DBUS_SESSION_BUS_ADDRESS', 'DISPLAY', 'WAYLAND_DISPLAY', 'LD_PRELOAD',
+ 'PIPEWIRE_CONFIG_PREFIX', 'PIPEWIRE_CONFIG_NAME', 'PIPEWIRE_CORE'):
+ env.pop(key, None)
+ (root / 'asound.conf').write_text('pcm.!default { type null }\n')
+ config = root / 'pipewire'
+ (config / 'pipewire.conf.d').mkdir(parents=True)
+ env['PIPEWIRE_CONFIG_DIR'] = str(config)
+ for name in ('pipewire.conf', 'client.conf', 'pipewire-pulse.conf'):
+ text = (Path('/usr/share/pipewire') / name).read_text()
+ (config / name).write_text(text.replace('unix:native', 'unix:' + str(root / 'native')))
+ objects = []
+ for direction, media, name in (('sink', 'Sink', 'physical_output'), ('source', 'Source', 'physical_input')):
+ objects.append('''{ factory = adapter args = {
+ factory.name = api.alsa.pcm.%s node.name = %s node.description = %s
+ media.class = Audio/%s device.api = alsa device.class = sound api.alsa.path = default
+ api.alsa.period-size = 480 api.alsa.headroom = 0 api.alsa.disable-mmap = true
+ audio.rate = 48000 audio.channels = 2 audio.position = [ FL FR ]
+ } }''' % (direction, name, name, media))
+ (config / 'pipewire.conf.d/10-fixtures.conf').write_text('context.objects = [\n' + '\n'.join(objects) + '\n]\n')
+ processes, logs = [], []
+
+ def start(name, args, **kwargs):
+ log = open(root / (name + '.log'), 'w')
+ logs.append(log)
+ process = subprocess.Popen(args, env=kwargs.pop('env', env), stderr=log,
+ stdout=kwargs.pop('stdout', log), **kwargs)
+ processes.append(process)
+ return process
+
+ def pactl(*args, check=True):
+ return subprocess.run(['pactl', *args], env=env, capture_output=True, text=True,
+ check=check, timeout=8).stdout.strip()
+
+ def eventually(fn, limit=10):
+ end = time.monotonic() + limit
+ while time.monotonic() < end:
+ result = fn()
+ if result:
+ return result
+ time.sleep(.03)
+ raise AssertionError('Timed out: ' + repr(fn))
+
+ samples = queue.Queue()
+ last = None
+
+ def status(predicate, limit=10):
+ nonlocal last
+ end = time.monotonic() + limit
+ while time.monotonic() < end:
+ try:
+ last = samples.get(timeout=.2)
+ except queue.Empty:
+ continue
+ if predicate(last):
+ return last
+ raise AssertionError('AEC status timeout: ' + repr(last))
+
+ def owned():
+ return [line for line in pactl('list', 'short', 'modules').splitlines()
+ if '\tmodule-echo-cancel\t' in line and 'device.echo_cancel.owner=io.elementary.settings.sound' in line]
+
+ try:
+ bus = start('bus', ['dbus-daemon', '--session', '--nofork', '--print-address=1'],
+ stdout=subprocess.PIPE, text=True)
+ env['DBUS_SESSION_BUS_ADDRESS'] = bus.stdout.readline().strip()
+ assert env['DBUS_SESSION_BUS_ADDRESS']
+ start('pipewire', ['pipewire'])
+ eventually(lambda: (root / 'pipewire-0').exists())
+ start('pulse', ['pipewire-pulse'])
+ start('wireplumber', ['wireplumber'])
+ eventually(lambda: 'physical_input' in pactl('list', 'short', 'sources', check=False))
+ eventually(lambda: 'physical_output' in pactl('list', 'short', 'sinks', check=False))
+ pactl('set-default-source', 'physical_input')
+ pactl('set-default-sink', 'physical_output')
+ delay_file = root / 'delay'
+ delay_file.write_text(str(delay))
+ owner_env = dict(env, LD_PRELOAD=str(BUILD / 'endpoint-delay.so'), AEC_TEST_DELAY_FILE=str(delay_file))
+ owner = start('owner', [str(BUILD / 'owner'), 'restore' if restored else 'explicit'],
+ env=owner_env, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
+
+ def read():
+ for line in owner.stdout:
+ samples.put(line.strip().split(' ', 4))
+ threading.Thread(target=read, daemon=True).start()
+ if not restored:
+ status(lambda s: s[1:3] == ['true', 'false'])
+ owner.stdin.write('enable\n')
+ owner.stdin.flush()
+ eventually(lambda: 'AEC_TEST_LOAD' in (root / 'owner.log').read_text())
+ eventually(owned)
+ # Supply endpoint subscription events after the visibility gap.
+ # Repeated mute notifications also exercise the bounded failure latch.
+ start_time = time.monotonic()
+ while time.monotonic() - start_time < 3.5:
+ if owned():
+ pactl('set-sink-mute', 'elementary_echo_cancel_sink', 'toggle')
+ time.sleep(.1)
+ if delay < 2000:
+ status(lambda s: s[:5] == ['true', 'true', 'false', 'true', '-'])
+ assert len(owned()) == 1
+ assert (root / 'owner.log').read_text().count('AEC_TEST_LOAD') == 1
+ info = pactl('info')
+ assert 'Default Source: elementary_echo_cancel_source' in info
+ assert 'Default Sink: elementary_echo_cancel_sink' in info
+ else:
+ state = status(lambda s: s[0] == 'false' and s[2] == 'false' and s[4] != '-')
+ assert state[3] == ('true' if restored else 'false'), state
+ assert not owned()
+ # Continue external notifications past the timeout; no new load.
+ for _ in range(12):
+ pactl('set-sink-mute', 'physical_output', 'toggle')
+ time.sleep(.1)
+ assert (root / 'owner.log').read_text().count('AEC_TEST_LOAD') == 1
+ delay_file.write_text('0')
+ owner.stdin.write('disable\n')
+ owner.stdin.flush()
+ status(lambda s: s[2:4] == ['false', 'false'])
+ owner.stdin.write('enable\n')
+ owner.stdin.flush()
+ status(lambda s: s[:5] == ['true', 'true', 'false', 'true', '-'])
+ assert len(owned()) == 1
+ print('PASS', 'restore' if restored else 'explicit', 'endpoint gap', delay, flush=True)
+ except Exception:
+ for path in root.glob('*.log'):
+ print(path.name, path.read_text())
+ raise
+ finally:
+ for process in reversed(processes):
+ if process.poll() is None:
+ process.terminate()
+ for process in reversed(processes):
+ try:
+ process.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ process.kill()
+ process.wait()
+ for log in logs:
+ log.close()
+
+
+scenario(600, True)
+scenario(6000, True)
+scenario(6000, False)
diff --git a/tests/bridge-device.c b/tests/bridge-device.c
new file mode 100644
index 00000000..4fb94f73
--- /dev/null
+++ b/tests/bridge-device.c
@@ -0,0 +1,146 @@
+/* SPDX-License-Identifier: GPL-3.0-or-later
+ * Private synthetic SPA card: real Device/Route protocol, no hardware.
+ * This fixture is not part of the daemon or installed policy.
+ */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+struct fixture {
+ struct spa_device device;
+ struct spa_hook_list hooks;
+ struct spa_param_info params[4];
+ struct pw_main_loop *loop;
+ struct pw_properties *props;
+ unsigned route;
+};
+
+static void info (struct fixture *f)
+{
+ struct spa_device_info i = SPA_DEVICE_INFO_INIT ();
+ i.change_mask = SPA_DEVICE_CHANGE_MASK_PROPS | SPA_DEVICE_CHANGE_MASK_PARAMS;
+ i.props = &f->props->dict;
+ i.params = f->params;
+ i.n_params = 4;
+ spa_device_emit_info (&f->hooks, &i);
+}
+
+static int add_listener (void *object, struct spa_hook *hook,
+ const struct spa_device_events *events, void *data)
+{
+ struct fixture *f = object;
+ struct spa_hook_list save;
+ spa_hook_list_isolate (&f->hooks, &save, hook, events, data);
+ info (f);
+ spa_hook_list_join (&f->hooks, &save);
+ return 0;
+}
+
+static int sync_device (void *object, int seq)
+{
+ struct fixture *f = object;
+ spa_device_emit_result (&f->hooks, seq, 0, 0, NULL);
+ return 0;
+}
+
+static int enumerate (void *object, int seq, uint32_t id, uint32_t index,
+ uint32_t max, const struct spa_pod *filter)
+{
+ struct fixture *f = object;
+ bool hdmi = getenv ("EQ_TEST_HDMI") != NULL;
+ uint32_t count = id == SPA_PARAM_EnumRoute && !hdmi ? 2 : 1;
+ for (uint32_t n = 0; index < count && n < max; n++, index++) {
+ uint8_t buffer[1024];
+ struct spa_pod_builder b = SPA_POD_BUILDER_INIT (buffer, sizeof buffer);
+ struct spa_pod *pod;
+ if (id == SPA_PARAM_EnumRoute || id == SPA_PARAM_Route) {
+ int route = id == SPA_PARAM_Route ? f->route : index;
+ int device = 0, profile = 0;
+ bool generic = getenv ("EQ_TEST_GENERIC") != NULL;
+ pod = spa_pod_builder_add_object (&b, SPA_TYPE_OBJECT_ParamRoute, id,
+ SPA_PARAM_ROUTE_index, SPA_POD_Int (route),
+ SPA_PARAM_ROUTE_direction, SPA_POD_Id (SPA_DIRECTION_OUTPUT),
+ SPA_PARAM_ROUTE_name, SPA_POD_String (hdmi ? "hdmi-output-0" : generic ? (route ? "analog-output-headphones" : "analog-output-speaker") :
+ (route ? "fixture-headphones" : "fixture-speaker")),
+ SPA_PARAM_ROUTE_description, SPA_POD_String (route ? "Fixture Headphones" : "Fixture Speakers"),
+ SPA_PARAM_ROUTE_priority, SPA_POD_Int (route ? 10 : 100),
+ SPA_PARAM_ROUTE_available, SPA_POD_Id (SPA_PARAM_AVAILABILITY_yes),
+ SPA_PARAM_ROUTE_device, SPA_POD_Int (0),
+ SPA_PARAM_ROUTE_devices, SPA_POD_Array (sizeof (int), SPA_TYPE_Int, 1, &device),
+ SPA_PARAM_ROUTE_profiles, SPA_POD_Array (sizeof (int), SPA_TYPE_Int, 1, &profile));
+ } else if (id == SPA_PARAM_EnumProfile || id == SPA_PARAM_Profile) {
+ pod = spa_pod_builder_add_object (&b, SPA_TYPE_OBJECT_ParamProfile, id,
+ SPA_PARAM_PROFILE_index, SPA_POD_Int (0),
+ SPA_PARAM_PROFILE_name, SPA_POD_String ("fixture-output"),
+ SPA_PARAM_PROFILE_description, SPA_POD_String ("Fixture output"),
+ SPA_PARAM_PROFILE_available, SPA_POD_Id (SPA_PARAM_AVAILABILITY_yes));
+ } else return -ENOENT;
+ struct spa_result_device_params result = { .id = id, .index = index, .next = index + 1, .param = pod };
+ spa_device_emit_result (&f->hooks, seq, 0, SPA_RESULT_TYPE_DEVICE_PARAMS, &result);
+ }
+ return 0;
+}
+
+static int set_param (void *object, uint32_t id, uint32_t flags, const struct spa_pod *pod)
+{
+ struct fixture *f = object;
+ int route = 0;
+ if (id == SPA_PARAM_Profile) return 0;
+ if (id != SPA_PARAM_Route || !pod ||
+ spa_pod_parse_object (pod, SPA_TYPE_OBJECT_ParamRoute, NULL,
+ SPA_PARAM_ROUTE_index, SPA_POD_Int (&route)) < 0 || route < 0 || route > 1) return -EINVAL;
+ if (f->route != (unsigned)route) {
+ f->route = route;
+ f->params[3].flags ^= SPA_PARAM_INFO_SERIAL;
+ info (f);
+ }
+ return 0;
+}
+
+static const struct spa_device_methods methods = {
+ SPA_VERSION_DEVICE_METHODS, .add_listener = add_listener, .sync = sync_device,
+ .enum_params = enumerate, .set_param = set_param
+};
+static void quit (void *data, int signal) { pw_main_loop_quit (((struct fixture *)data)->loop); }
+static void bound (void *data, uint32_t id) { printf ("%u\n", id); fflush (stdout); }
+static const struct pw_proxy_events proxy_events = { PW_VERSION_PROXY_EVENTS, .bound = bound };
+
+int main (int argc, char **argv)
+{
+ assert (getenv ("EQ_PRIVATE_TEST") && !strcmp (getenv ("PIPEWIRE_REMOTE"), "elementary-eq-bridge"));
+ struct fixture f = { 0 };
+ struct spa_hook proxy_hook;
+ pw_init (&argc, &argv);
+ f.device.iface = SPA_INTERFACE_INIT (SPA_TYPE_INTERFACE_Device, SPA_VERSION_DEVICE, &methods, &f);
+ spa_hook_list_init (&f.hooks);
+ f.params[0] = SPA_PARAM_INFO (SPA_PARAM_EnumProfile, SPA_PARAM_INFO_READ);
+ f.params[1] = SPA_PARAM_INFO (SPA_PARAM_Profile, SPA_PARAM_INFO_READWRITE);
+ f.params[2] = SPA_PARAM_INFO (SPA_PARAM_EnumRoute, SPA_PARAM_INFO_READ);
+ f.params[3] = SPA_PARAM_INFO (SPA_PARAM_Route, SPA_PARAM_INFO_READWRITE);
+ f.props = pw_properties_new ("device.name", argc > 1 ? argv[1] : "elementary.eq.card", "media.class", "Audio/Device",
+ "device.description", "Private EQ bridge fixture", "device.api", "fixture", NULL);
+ f.loop = pw_main_loop_new (NULL);
+ struct pw_loop *loop = pw_main_loop_get_loop (f.loop);
+ pw_loop_add_signal (loop, SIGINT, quit, &f);
+ pw_loop_add_signal (loop, SIGTERM, quit, &f);
+ struct pw_context *context = pw_context_new (loop, NULL, 0);
+ struct pw_core *core = pw_context_connect (context, NULL, 0);
+ assert (core);
+ struct pw_proxy *proxy = pw_core_export (core, SPA_TYPE_INTERFACE_Device, &f.props->dict, &f.device, 0);
+ assert (proxy);
+ pw_proxy_add_listener (proxy, &proxy_hook, &proxy_events, &f);
+ pw_main_loop_run (f.loop);
+ pw_proxy_destroy (proxy);
+ pw_core_disconnect (core);
+ pw_context_destroy (context);
+ pw_main_loop_destroy (f.loop);
+ pw_properties_free (f.props);
+ return 0;
+}
diff --git a/tests/fixture-v1.ini b/tests/fixture-v1.ini
new file mode 100644
index 00000000..b12a1b70
--- /dev/null
+++ b/tests/fixture-v1.ini
@@ -0,0 +1,13 @@
+# Synthetic data only. Do not install as a hardware profile.
+[Profile]
+Version=1
+Namespace=eos_eq
+FirstGraph=true
+Node=elementary.eq.test
+Route=fixture-speaker
+Types=low-shelf;peak;peak;peak;high-shelf;
+Frequencies=200;240;1000;16000;200;
+Q=0.707;0.6;1;6.606;0.707;
+DefaultGains=-1;-2;-3;-4;-5;
+MinimumGains=-12;-12;-12;-12;-12;
+MaximumGains=0;0;0;0;0;
diff --git a/tests/generic-envelope.py b/tests/generic-envelope.py
new file mode 100644
index 00000000..c89ec517
--- /dev/null
+++ b/tests/generic-envelope.py
@@ -0,0 +1,82 @@
+#!/usr/bin/env python3
+"""Render the five-band profile layout with native SPA filters."""
+import array
+import configparser
+import itertools
+import json
+import math
+import os
+from pathlib import Path
+import subprocess
+import tempfile
+
+assert os.environ.get('EQ_PRIVATE_TEST') == '1' and not Path('/dev/snd').exists()
+base = Path(__file__).resolve().parent
+profile = configparser.ConfigParser()
+profile.read(base.parent/'data/generic-speakers-v1.ini')
+p = profile['Profile']
+def values(key):
+ return [float(value) for value in p[key].split(';') if value]
+types = [value for value in p['Types'].split(';') if value]
+frequencies, qs = values('Frequencies'), values('Q')
+minimum, maximum = values('MinimumGains'), values('MaximumGains')
+assert p['Node'] == '*' and not values('DefaultGains')
+rules = json.loads((base.parent/'data/50-elementary-speaker-equalizer.conf').read_text())
+assert 'node.filter-graph.rules' not in rules
+labels = {'low-shelf': 'bq_lowshelf', 'peak': 'bq_peaking', 'high-shelf': 'bq_highshelf'}
+graph = dict(nodes=[dict(type='builtin', name=f'eos_eq_{i+1}', label=labels[kind],
+ control=dict(Freq=frequency, Q=q, Gain=0.))
+ for i, (kind, frequency, q) in enumerate(zip(types, frequencies, qs))],
+ links=[dict(output=f'eos_eq_{i}:Out', input=f'eos_eq_{i+1}:In') for i in range(1,5)])
+graph['nodes'].append(dict(type='builtin', name='eos_eq_h', label='linear',
+ control=dict(Mult=1., Add=0., Control=0.)))
+graph['links'].append(dict(output='eos_eq_5:Out', input='eos_eq_h:In'))
+
+# Reuse the native SPA fixture; no reimplementation of its DSP arithmetic.
+peaks, transient_overshoots, rendered = [], 0, 0
+with tempfile.TemporaryDirectory(prefix='generic-eq-pcm-') as work:
+ case = Path(work)/'graph.json'
+ def render(rate, gains, multiplier, mono, measure=True):
+ global rendered, transient_overshoots
+ for node, gain in zip(graph['nodes'], gains):
+ node['control']['Gain'] = gain
+ graph['nodes'][-1]['control']['Mult'] = multiplier
+ case.write_text(json.dumps(graph))
+ samples = array.array('f')
+ for value in mono:
+ samples.extend((value, -value*.5))
+ result = subprocess.run([str(base/'render-graph'), str(case), str(rate)],
+ input=samples.tobytes(), capture_output=True, timeout=10)
+ assert result.returncode == 0, result.stderr
+ output = array.array('f')
+ output.frombytes(result.stdout)
+ assert len(output) == len(samples) and all(math.isfinite(v) for v in output)
+ assert max(abs(r+l*.5) for l, r in zip(output[::2], output[1::2])) < 1e-6
+ rendered += 1
+ if measure:
+ peak = max(abs(value) for value in output)
+ transient_overshoots += peak > 1
+ peaks.append(peak)
+ return output[::2]
+
+ for rate in (32000, 48000, 192000):
+ impulse = array.array('f', [1]+[0]*(rate//4-1))
+ for gains in itertools.product(*zip(minimum, maximum)):
+ h = render(rate, gains, 1., impulse, False)
+ adversarial = array.array('f', [1 if v >= 0 else -1 for v in reversed(h)])
+ render(rate, gains, 1.0, adversarial)
+ for gains in ([0]*5, [-6,0,0,0,0], [0,0,-6,0,0], maximum):
+ multiplier = 1.0
+ if not any(gains):
+ assert multiplier == 1
+ h = render(rate, gains, 1., impulse, False)
+ adversarial = array.array('f', [1 if v >= 0 else -1 for v in reversed(h)])
+ render(rate, gains, multiplier, adversarial)
+ for frequency in frequencies:
+ sine = array.array('f', [.5*math.sin(2*math.pi*frequency*i/rate) for i in range(rate//4)])
+ output = render(rate, gains, multiplier, sine)
+ if not any(gains):
+ assert output == sine
+print(json.dumps(dict(finite_native_cases=rendered,
+ largest_native_pcm_peak=max(peaks),
+ full_scale_transient_overshoots=transient_overshoots)))
diff --git a/tests/graph-rules.c b/tests/graph-rules.c
new file mode 100644
index 00000000..a5a4b1f0
--- /dev/null
+++ b/tests/graph-rules.c
@@ -0,0 +1,24 @@
+/* Exercise the native configuration merge and ALSA property rules privately. */
+#include
+#include
+#include
+
+int main (int argc, char **argv)
+{
+ assert (argc == 2 || argc == 3);
+ wp_init (WP_INIT_ALL);
+ g_autoptr (GError) error = NULL;
+ g_autoptr (WpConf) conf = wp_conf_new_open ("wireplumber.conf", NULL, &error);
+ assert (conf && !error);
+ g_autoptr (WpProperties) props = wp_properties_new (
+ "node.name", argv[1], "media.class", "Audio/Sink", NULL);
+ if (argc == 3) {
+ wp_properties_set (props, "elementary.eq.profile", argv[2]);
+ }
+ g_autoptr (WpSpaJson) rules = wp_conf_get_section (conf, "monitor.alsa.rules");
+ assert (rules);
+ wp_json_utils_match_rules_update_properties (rules, props);
+ const char *profile = wp_properties_get (props, "elementary.eq.profile");
+ puts (profile ? profile : "");
+ return 0;
+}
diff --git a/tests/profile-file.c b/tests/profile-file.c
new file mode 100644
index 00000000..62ae221f
--- /dev/null
+++ b/tests/profile-file.c
@@ -0,0 +1,13 @@
+/* Root profile loader boundary, linked to the production implementation. */
+#include "Backends/speaker-equalizer-profile.h"
+#include
+int main (int argc, char **argv)
+{
+ if (argc != 2) return 2;
+ GError *error = NULL;
+ char *data = eq_profile_read (argv[1], &error);
+ if (error) { fprintf (stderr, "%s\n", error->message); g_error_free (error); }
+ if (!data) return 1;
+ g_free (data);
+ return 0;
+}
diff --git a/tests/profile-files.py b/tests/profile-files.py
new file mode 100644
index 00000000..fbe40a8d
--- /dev/null
+++ b/tests/profile-files.py
@@ -0,0 +1,60 @@
+#!/usr/bin/env python3
+"""Run only in the isolated root SDK; never creates a real installed profile."""
+import json
+import os
+from pathlib import Path
+import shutil
+import subprocess
+import tempfile
+
+assert os.getuid() == 0 and not Path('/dev/snd').exists()
+base = Path(__file__).resolve().parent
+source = base.parent / 'src'
+root = Path(tempfile.mkdtemp(prefix='elementary-eq-profile-', dir='/root'))
+directory = root / 'v1'
+directory.mkdir()
+profile = directory / 'fixture-v1.ini'
+checks = {}
+try:
+ flags = subprocess.check_output(['pkg-config', '--cflags', '--libs', 'gio-2.0'], text=True).split()
+ subprocess.run(['cc', '-Wall', '-Wextra', '-Wno-unused-parameter', '-I'+str(source), '-DGETTEXT_PACKAGE="io.elementary.settings-daemon"',
+ '-DEQ_PROFILE_DIR='+json.dumps(str(directory)), str(base/'profile-file.c'),
+ str(source/'Backends/speaker-equalizer-profile.c'), *flags,
+ '-o', str(root/'reader')], check=True)
+ def check(name, accepted=False, identifier='fixture-v1'):
+ result = subprocess.run([str(root/'reader'), identifier], capture_output=True, text=True)
+ checks[name] = result.returncode == (0 if accepted else 1)
+ assert checks[name], (name, result)
+ shutil.copyfile(base/'fixture-v1.ini', profile)
+ check('root_regular_profile', True)
+ check('path_traversal_refused', identifier='../fixture-v1')
+ check('missing_profile_refused', identifier='absent')
+ profile.chmod(0o666)
+ check('writable_profile_refused')
+ profile.chmod(0o644)
+ os.chown(profile, 1000, -1)
+ check('nonroot_profile_refused')
+ os.chown(profile, 0, -1)
+ directory.chmod(0o777)
+ check('writable_parent_refused')
+ directory.chmod(0o755)
+ target = directory/'target'
+ profile.rename(target)
+ profile.symlink_to(target)
+ check('symlink_file_refused')
+ profile.unlink()
+ target.rename(profile)
+ directory.rename(root/'actual')
+ directory.symlink_to(root/'actual')
+ check('symlink_directory_refused')
+ directory.unlink()
+ (root/'actual').rename(directory)
+ profile.write_bytes(b'\x00hidden')
+ check('embedded_nul_refused')
+ profile.write_bytes(b'a'*8193)
+ check('oversize_refused')
+finally:
+ shutil.rmtree(root)
+ (base/'results').mkdir(exist_ok=True)
+ (base/'results/profile-checks.json').write_text(json.dumps(checks, indent=2)+'\n')
+print(json.dumps(checks))
diff --git a/tests/render-graph.c b/tests/render-graph.c
new file mode 100644
index 00000000..3e54a949
--- /dev/null
+++ b/tests/render-graph.c
@@ -0,0 +1,60 @@
+/* Native SPA rendering fixture: no DSP implementation or audio-server connection. */
+#include
+#include
+#include
+#include
+#include
+#include
+#include
+
+int main(int argc, char **argv)
+{
+ assert(argc == 3 && getenv("EQ_PRIVATE_TEST") && access("/dev/snd", F_OK) != 0);
+ FILE *file = fopen(argv[1], "r");
+ assert(file);
+ assert(fseek(file, 0, SEEK_END) == 0);
+ long size = ftell(file);
+ assert(size > 0 && size < 65536);
+ rewind(file);
+ char *json = calloc(size + 1, 1);
+ assert(json && fread(json, 1, size, file) == (size_t)size);
+ fclose(file);
+ pw_init(&argc, &argv);
+ struct pw_main_loop *loop = pw_main_loop_new(NULL);
+ struct pw_context *context = pw_context_new(pw_main_loop_get_loop(loop), NULL, 0);
+ assert(context);
+ struct spa_handle *handle = pw_context_load_spa_handle(context, "filter.graph", &SPA_DICT_ITEMS(
+ SPA_DICT_ITEM("library.name", "filter-graph/libspa-filter-graph"),
+ SPA_DICT_ITEM("filter.graph", json),
+ SPA_DICT_ITEM("clock.quantum-limit", "1024"),
+ SPA_DICT_ITEM("filter-graph.n_inputs", "2"),
+ SPA_DICT_ITEM("filter-graph.n_outputs", "2")));
+ assert(handle);
+ struct spa_filter_graph *graph;
+ assert(spa_handle_get_interface(handle, SPA_TYPE_INTERFACE_FilterGraph, (void **)&graph) == 0);
+ assert(spa_filter_graph_activate(graph, &SPA_DICT_ITEMS(
+ SPA_DICT_ITEM("audio.rate", argv[2]), SPA_DICT_ITEM("filter-graph.n_inputs", "2"))) == 0);
+ float interleaved[2048], left[1024], right[1024], out_left[1024], out_right[1024];
+ size_t count;
+ while ((count = fread(interleaved, sizeof(float), 2048, stdin)) != 0) {
+ assert(count % 2 == 0);
+ for (size_t i = 0; i < count / 2; i++) {
+ left[i] = interleaved[i * 2]; right[i] = interleaved[i * 2 + 1];
+ }
+ const void *in[] = { left, right };
+ void *out[] = { out_left, out_right };
+ assert(spa_filter_graph_process(graph, in, out, count / 2) == 0);
+ for (size_t i = 0; i < count / 2; i++) {
+ interleaved[i * 2] = out_left[i]; interleaved[i * 2 + 1] = out_right[i];
+ }
+ assert(fwrite(interleaved, sizeof(float), count, stdout) == count);
+ }
+ assert(!ferror(stdin));
+ assert(spa_filter_graph_deactivate(graph) == 0);
+ pw_unload_spa_handle(handle);
+ pw_context_destroy(context);
+ pw_main_loop_destroy(loop);
+ pw_deinit();
+ free(json);
+ return 0;
+}
diff --git a/tests/run-aec.sh b/tests/run-aec.sh
new file mode 100644
index 00000000..949b88bd
--- /dev/null
+++ b/tests/run-aec.sh
@@ -0,0 +1,16 @@
+#!/bin/sh
+# Existing private SDK only; never connect to the desktop audio server.
+set -eu
+test "${AEC_PRIVATE_TEST:-}" = 1
+test ! -e /dev/snd
+cd "$(dirname "$0")/.."
+build_dir="$PWD/tests/aec/build"
+mkdir -p "$build_dir/schemas"
+cp data/io.elementary.settings-daemon.gschema.xml "$build_dir/schemas/"
+glib-compile-schemas "$build_dir/schemas"
+cc -shared -fPIC -Wall -Wextra -Werror tests/aec/endpoint-delay.c \
+ $(pkg-config --cflags --libs libpulse) -ldl -o "$build_dir/endpoint-delay.so"
+valac --pkg libpulse --pkg libpulse-mainloop-glib --pkg gio-2.0 --pkg libpulse-operation \
+ --vapidir=vapi -X '-DGETTEXT_PACKAGE="io.elementary.settings-daemon"' \
+ -o "$build_dir/owner" src/Backends/EchoProcessor.vala tests/aec/owner.vala
+python3 tests/aec/reconnect.py
diff --git a/tests/run-components.sh b/tests/run-components.sh
new file mode 100644
index 00000000..58b87008
--- /dev/null
+++ b/tests/run-components.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+# Run in the isolated SDK copy, not against the desktop session.
+set -eu
+test "${EQ_PRIVATE_TEST:-}" = 1
+test ! -e /dev/snd
+cd "$(dirname "$0")/.."
+# Native lifecycle and controls use the companion Sound tests/run-active-bridge.sh.
+python3 tests/profile-files.py
+cc tests/render-graph.c $(pkg-config --cflags --libs libpipewire-0.3) -o tests/render-graph
+python3 tests/generic-envelope.py
diff --git a/vapi/Wp-0.5.metadata b/vapi/Wp-0.5.metadata
new file mode 100644
index 00000000..ff317043
--- /dev/null
+++ b/vapi/Wp-0.5.metadata
@@ -0,0 +1,3 @@
+Transition.get_source_object skip
+SpaPod.get_string.value unowned
+SpaPod.get_property.key unowned
diff --git a/vapi/libpulse-operation.vapi b/vapi/libpulse-operation.vapi
new file mode 100644
index 00000000..91067259
--- /dev/null
+++ b/vapi/libpulse-operation.vapi
@@ -0,0 +1,7 @@
+[CCode (cheader_filename = "pulse/operation.h")]
+namespace PulseAudio {
+ [CCode (cname = "pa_operation_notify_cb_t")]
+ public delegate void OperationNotifyCb (Operation operation);
+ [CCode (cname = "pa_operation_set_state_callback")]
+ public static void operation_set_state_callback (Operation operation, OperationNotifyCb? cb);
+}
diff --git a/vapi/speaker-equalizer-profile.vapi b/vapi/speaker-equalizer-profile.vapi
new file mode 100644
index 00000000..3395f457
--- /dev/null
+++ b/vapi/speaker-equalizer-profile.vapi
@@ -0,0 +1,5 @@
+[CCode (cheader_filename = "Backends/speaker-equalizer-profile.h")]
+namespace SpeakerEqualizerProfile {
+ [CCode (cname = "eq_profile_read")]
+ public string read (string id) throws GLib.FileError;
+}