diff --git a/data/io.elementary.settings-daemon.gschema.xml b/data/io.elementary.settings-daemon.gschema.xml
index fb1c44ae..df865c94 100644
--- a/data/io.elementary.settings-daemon.gschema.xml
+++ b/data/io.elementary.settings-daemon.gschema.xml
@@ -183,4 +183,25 @@
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
+
+
+
+ 'device-default'
+ Speaker equalizer preset
+
+
+ [0.0, 0.0, 0.0, 0.0, 0.0]
+ Five custom speaker equalizer gains in decibels
+
+
diff --git a/docs/speaker-equalizer.md b/docs/speaker-equalizer.md
new file mode 100644
index 00000000..d0c50af4
--- /dev/null
+++ b/docs/speaker-equalizer.md
@@ -0,0 +1,181 @@
+# Speaker equalizer contract, version 1
+
+Review candidate on `speaker-eq`, based on `59c80f5`. The companion Sound
+change is based on `08b0dd6`. Neither change is ready for packaging until two
+independent security and human/minimality reviews cover the frozen sources.
+
+The existing session settings daemon owns one native PipeWire connection.
+Sound writes relocatable GSettings preferences and reads the existing Audio
+D-Bus object's separate `GetEqualizerStatus`/`EqualizerChanged` interface.
+The existing echo-cancellation status tuple and EchoProcessor are unchanged.
+There is no Sound-side PipeWire client, graph setter, plugin selector, new
+service or DSP implementation. The client uses libWirePlumber >= 0.5.0 directly from Vala. Meson generates
+its VAPI from the installed Wp-0.5 GIR; only three metadata corrections are
+maintained. The remaining C file securely opens root-installed profiles.
+Pure PulseAudio reports EQ unavailable and keeps its AEC path.
+
+Build/API minimum and tested runtime are separate. The WpCore sync/connect,
+ObjectManager, Node, async enum/set_param and SpaPod builder/iterator APIs used
+here exist in the upstream 0.5.0 headers. The physical audioconvert filter-graph
+feature and node.cache-params option exist in PipeWire 1.4.0. Neither fact is a
+runtime compatibility test. This candidate was exercised on PW 1.6.2, libWP
+0.5.13, Vala 0.56.19, GTK 4.22.4 and Granite 7.8.1. The project's existing
+GTK >= 4.20 requirement is unchanged. Production no longer directly requires
+PipeWire/SPA development headers; the private SPA Device fixture does.
+Older runtimes need the same native controls/metadata/lifecycle verification
+before distribution support is claimed. A WP policy daemon is not required
+by the library client; a distribution still owns graph/route provisioning.
+
+## Root-installed policy and profile
+
+Profiles live in `${datadir}/io.elementary.settings-daemon/equalizers/v1/`.
+The directory and all parents must be root-owned and not group/world writable.
+The same rules apply to each regular profile file (maximum 8192 bytes).
+Symlinks and profile IDs other than 1–64 ASCII letters, digits, `_` and `-`
+are refused. There is no home-directory override or caller-supplied path.
+Version 1 profile content is illustrated by `tests/fixture-v1.ini`; it is a
+synthetic test fixture and is deliberately not installed as hardware tuning.
+
+The physical node must carry `elementary.eq.profile = ` and match
+the profile's exact `Node` and active PulseAudio speaker `Route`. Its current
+`object.serial` and `device.id` must agree between PulseAudio and native
+PipeWire. AEC's exactly owned output alias resolves to its physical master.
+Virtual/filter endpoints and endpoints with a master device are excluded.
+There are no DMI checks or vendor/device presets in this implementation.
+
+The profile provider owns root WirePlumber graph/lifecycle policy. A provider
+must demonstrate ordinary speaker/headphone switching and node recreation
+with the daemon as the sole six-control writer before adopting a profile:
+
+* Install the five built-in biquads in audioconvert graph zero, in profile
+ order, named `eos_eq_1` through `eos_eq_5`. Labels corresponding to `Types`
+ are `bq_lowshelf`, `bq_peaking`, and `bq_highshelf`. Frequencies and Q are
+ immutable. Initial gains must be zero; the daemon applies user preferences.
+* A final built-in `linear` node named `eos_eq_h` supplies profile `Headroom`
+ through `Mult` when enabled, and unity for Off/Flat. Initialize `Mult` to 1.
+ The daemon owns this sixth fixed setter; `Add` and `Control` must remain zero. Connect its
+ audio input/output in series; no control links, channel gain/volume mappings,
+ other uses of `eos_eq_`, or concurrent owners of these names are allowed.
+* Start the physical graph neutral and validate ordinary route changes with
+ the daemon's six-control bypass. It manages the selected physical node,
+ not every inactive node. Include previously selected/nondefault nodes in
+ downstream route testing; this candidate does not prove that delivery path.
+* Set `node.cache-params=false` on the profiled node before graph installation.
+ With default caching in the tested PW 1.6.2 bridge, PropInfo stayed stale
+ after graph installation while Props exposed all 48 controls; the backend
+ correctly refused that incomplete metadata. This requirement is confined
+ to the profiled node, not a global cache change. PW 1.6.2 defaults caching to
+ true in [impl-node.c](https://github.com/PipeWire/pipewire/blob/1.6.2/src/pipewire/impl-node.c#L1091).
+* Remove any old downstream EQ graph before adoption, so the two cannot stack.
+
+`FirstGraph=true` and `Namespace=eos_eq` declare this installation contract.
+They are **not cryptographic ownership, a privilege boundary against another
+session client, or proof of graph topology**. PipeWire PropInfo exposes the
+first graph's controls; Props can enumerate multiple graphs. The backend
+requires all 48 reserved controls exactly once in both metadata and values,
+validates frequency/Q and the fixed linear controls, and rejects unknown/duplicate reserved names.
+This catches observable collisions, not malicious topology impersonation.
+Graph labels, links, coefficients' provenance and physical identity ultimately
+depend on the root policy. If stronger ownership is required, this API cannot
+provide it; do not invent a graph-text or privileged broker workaround.
+
+## Preferences and headroom
+
+Each profile revision + physical node name + speaker route hashes to a stable
+relocatable GSettings path. Runtime IDs do not become preference identifiers.
+Enabled Device Default sends the five factory gains; Custom sends five bounded
+stored gains. Flat and disabled send five zero gains. Reset restores factory
+gains and selects Device Default, preserving the enabled setting. Arbitrary
+frequencies, Q, plugins, files or graph text are never accepted from Sound.
+
+**Only five Gain controls and `eos_eq_h:Mult` are written.** Enabled Device
+Default/Custom uses the immutable profile's headroom value. Off and Flat use
+zero gains and unity headroom, restoring pre-graph loudness within the declared
+series-graph contract. Physical volume, balance and mute are untouched. This
+sixth setter follows the explicit product decision; there is no headroom UI,
+user-supplied multiplier, extra D-Bus method or graph API.
+
+The root profile author must approve headroom for the complete allowed gain
+envelope and device. Finite/range checks do not establish clipping protection.
+There is no limiter or invented factory compensation. No real device profile
+or production WirePlumber policy is supplied in this upstream candidate.
+
+## Native lifecycle and limitations
+
+GSettings events coalesce over 100 ms. PA discovery has a two-second deadline
+and generation checks. PA notifications mark discovery pending and hide applied
+status while querying. Unchanged identity/profile content/preferences do not
+invalidate or reissue the native request; a failed foreign-control audit stays
+failed. Changed serial/device/name/profile/port or saved preferences authorizes
+a new bounded request. Invalid discovery revokes the request. The native
+backend matches the current serial, freshly enumerates metadata and values,
+sends only six fixed parameters, awaits WpCore.sync, then freshly enumerates
+again before confirming. This uses asynchronous enum_params, not libWP's
+cached enum_params_sync. Each native round has a two-second deadline. A
+stalled round cancels and disconnects its Core: native tests showed that
+GCancellable alone does not complete stalled libWP enumeration/sync until
+the server responds. Revision/core guards reject stale completions.
+
+Idle nodes remain unconfirmed until Node state_changed reports RUNNING.
+A changed request authorizes at most one write attempt. That permission is
+consumed before the write; notifications, unchanged PA discovery, idle/resume
+and reconnect are read-only audits. Foreign gains/headroom fail without being
+overwritten. A pending, never-attempted explicit request can finish after
+reconnect only after identity and all controls validate again.
+A valid saved enabled preference can be withdrawn
+through Off while native status is failed; enable/preset/gain controls remain
+blocked until available. Invalid graph controls still prevent native writes. Native connection loss triggers daemon rediscovery
+after two seconds. PipeWire's full Props (including volume/mute/balance),
+`filter-graph.disable`, defaults and stream routing are never written by EQ.
+
+The final evidence report distinguishes tests from untested lifecycle paths.
+The synthetic active bridge now exercises real PA profile/route discovery,
+production daemon/model, GSettings and native controls together. It does not
+prove real device policy, suspend/resume,
+channel acoustics, production clipping safety, or AEC convergence. The AEC
+reference remains upstream of physical-node EQ; a required post-EQ reference
+would be a separate topology decision. Downstream factory migration remains
+separate. Fedora implementation remains out of scope and gated by the fresh
+current signed Ubuntu ISO prerequisite.
+
+Primary API/source references:
+[PW node API](https://docs.pipewire.org/group__pw__node.html),
+[filter-chain controls](https://docs.pipewire.org/page_module_filter_chain.html),
+[PW 1.6.2 native sequences](https://github.com/PipeWire/pipewire/blob/1.6.2/src/modules/module-protocol-native/protocol-native.c),
+[PW 1.6.2 built-ins](https://github.com/PipeWire/pipewire/blob/1.6.2/spa/plugins/filter-graph/plugin_builtin.c),
+[WirePlumber software DSP](https://pipewire.pages.freedesktop.org/wireplumber/policies/software_dsp.html).
+
+## Downstream speaker-route delivery gate
+
+The practical provider contract is a neutral physical graph at creation
+(zero gains, linear Mult=1), the real root profile/marker/cache setting, and
+ordinary route/lifecycle validation. The daemon alone writes the six controls:
+observed non-speaker or unknown active ports on the same validated physical
+node request zero/unity; speaker return restores saved preferences. It does
+not reset physical volume. Real hotplug, manual port/default changes, stream
+continuity and any audible transition remain downstream integration checks.
+Observed post-route bypass is not a claim of sample-atomic switching. There
+is no all-writers barrier against arbitrary direct SPA/root writers, no new
+broker, and no second setter racing the daemon's foreign-control audit.
+
+Vale's existing `vale-speaker-route.lua` and stock smart-filter disabled
+metadata control its old paired virtual filter. They do not automatically
+provide lifecycle semantics for the new physical-node graph. Reuse the
+existing policy location, and remove the old graph before adopting the new
+one. The current source candidate supplies no shipping hardware profile.
+
+The actual existing StarFighter values are 105 Hz/Q .80/+7.5 dB,
+190/.75/+8, 280/1/-1.5, 2800/1.1/+1.5, 8500/.7/+.5 (two low shelves,
+two peaks, one high shelf). Factory profile/envelope and fixed headroom remain
+separate work. Derive headroom from the actual native biquad response across
+the allowed gain envelope, with native DSP clipping controls. The synthetic
+fixture's .25 is not calibration; a sampled sinusoidal bound alone is not a
+proof for arbitrary PCM transients. Hardware acoustics may remain untested
+without blocking a demonstrated digital envelope. No vendor/DMI preset enters
+this generic upstream implementation.
+
+Version rationale: [WP 0.5.0 Core](https://github.com/PipeWire/wireplumber/blob/0.5.0/lib/wp/core.h),
+[WP 0.5.0 parameter API](https://github.com/PipeWire/wireplumber/blob/0.5.0/lib/wp/proxy-interfaces.h),
+[WP 0.5.0 pods](https://github.com/PipeWire/wireplumber/blob/0.5.0/lib/wp/spa-pod.h),
+[PW 1.4.0 NEWS](https://github.com/PipeWire/pipewire/blob/1.4.0/NEWS),
+[PW 1.4.0 cache policy](https://github.com/PipeWire/pipewire/blob/1.4.0/src/pipewire/impl-node.c).
diff --git a/meson.build b/meson.build
index ec8b9ad1..f1800eb1 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.0')
+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..da5be7ee 100644
--- a/po/POTFILES
+++ b/po/POTFILES
@@ -3,3 +3,6 @@ src/Application.vala
src/Backends/SystemUpdate.vala
src/Backends/UbuntuDrivers.vala
src/Utils/PkUtils.vala
+src/Backends/Audio.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..a69feb5c
--- /dev/null
+++ b/src/Backends/Audio.vala
@@ -0,0 +1,127 @@
+/*
+ * 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);
+ equalizer = new SpeakerEqualizer (c);
+ equalizer.changed.connect (() => equalizer_changed ());
+ 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);
+ 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 = null;
+ 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..8d4e684e
--- /dev/null
+++ b/src/Backends/EchoProcessor.vala
@@ -0,0 +1,571 @@
+/*
+ * 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 bool pending;
+ private uint refresh_id;
+ private string? observed_source;
+ private string? observed_sink;
+ private signal void defaults_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 {
+ settings.changed["echo-cancellation"].connect (refresh);
+ refresh ();
+ }
+
+ public void stop () {
+ settings.changed["echo-cancellation"].disconnect (refresh);
+ if (refresh_id != 0) {
+ Source.remove (refresh_id);
+ refresh_id = 0;
+ }
+ }
+
+ public void refresh () {
+ pending = true;
+ if (busy || 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) {
+ 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=\"device.echo_cancel.owner=io.elementary.settings.sound\" " +
+ "sink_properties=\"device.echo_cancel.owner=io.elementary.settings.sound\"";
+ }
+
+ 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;
+ }
+ }
+
+ 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;
+ }
+ }
+
+ 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;
+ return true;
+ }
+
+ private async bool activate (string source, string sink, out bool selection_changed) {
+ selection_changed = false;
+ string? current_source = null;
+ string? current_sink = null;
+ if (!(yield owns_endpoints ()) || !(yield server_defaults (out current_source, out current_sink))) {
+ 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;
+ }
+
+ private async void reconcile () {
+ 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 (!settings.get_boolean ("echo-cancellation") || !available) {
+ if (!(yield release ())) {
+ error = _("Could not disable echo cancellation.");
+ } else if (!available && settings.get_boolean ("echo-cancellation")) {
+ error = _("Connect a microphone and an audio output to use echo cancellation.");
+ }
+ } else if (module_index != PulseAudio.INVALID_INDEX &&
+ source_master == target_source && sink_master == target_sink &&
+ source == SOURCE_NAME && sink == SINK_NAME && (yield owns_endpoints ())) {
+ enabled = true;
+ error = null;
+ } 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) {
+ settings.set_boolean ("echo-cancellation", false);
+ }
+ busy = 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) {
+ settings.set_boolean ("echo-cancellation", false);
+ }
+ }
+ }
+ }
+ } 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 ();
+ settings.set_boolean ("echo-cancellation", false);
+ }
+ }
+ } 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;
+ });
+ }
+ }
+
+ busy = false;
+ if (pending && context.get_state () == PulseAudio.Context.State.READY) {
+ refresh ();
+ }
+ }
+}
diff --git a/src/Backends/SpeakerEqualizer.vala b/src/Backends/SpeakerEqualizer.vala
new file mode 100644
index 00000000..fdfada50
--- /dev/null
+++ b/src/Backends/SpeakerEqualizer.vala
@@ -0,0 +1,524 @@
+/*
+ * 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 Wp.ObjectManager? native_nodes;
+ private Wp.Node? native_node;
+ private string native_name = "";
+ private string native_error = "";
+ private int 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 double target_headroom;
+ 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 stopped;
+ private bool querying;
+ private bool pending;
+ private uint generation;
+ private uint refresh_id;
+ private uint reconnect_id;
+ private Variant? last_request;
+
+ public SpeakerEqualizer (PulseAudio.Context context) {
+ this.context = context;
+ }
+
+ public Audio.EqualizerStatus get_status () {
+ return {
+ eligible && profile != null && state != 4 && (state != 0 || pending || querying || error != ""),
+ querying || pending || native_busy || native_pending,
+ eligible && !querying && !pending && !native_busy && !native_pending && state == 3,
+ 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 = false;
+ last_request = null;
+ native_revision++;
+ may_write = native_pending = false;
+ state = 0;
+ }
+
+ private void native_failed (string message) {
+ may_write = native_pending = false;
+ state = 4;
+ native_error = message;
+ changed ();
+ }
+
+ 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 () {
+ state = 4;
+ 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 = 0;
+ refresh (echo_master);
+ return Source.REMOVE;
+ });
+ }
+ }
+
+ public void stop () {
+ stopped = true;
+ 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 device profile.");
+ close_native ();
+ finish (); return;
+ }
+ if (selected == EchoProcessor.SINK_NAME) selected = echo_master;
+ if (selected == null) { invalidate (); finish (); return; }
+ string? name = null, serial = null, device = null, profile_id = null, port = null;
+ bool physical = false;
+ 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 : "";
+ });
+ 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) {
+ 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);
+ if (name != selected_profile.node) {
+ throw new IOError.INVALID_DATA (_("The equalizer profile does not match the physical output."));
+ }
+ // Preferences are stable across transient PW IDs, scoped to the
+ // installed profile revision, physical node name and speaker route.
+ string identity = Checksum.compute_for_string (ChecksumType.SHA256,
+ profile_id + "\n" + name + "\n" + selected_profile.route);
+ string path = "/io/elementary/settings-daemon/audio/equalizer/%s/".printf (identity);
+ 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 = port == profile.route;
+ var gains = new double[5];
+ string preset = settings.get_string ("preset");
+ bool enabled = eligible && settings.get_boolean ("enabled");
+ if (preset != "device-default" && preset != "flat" && preset != "custom") {
+ throw new IOError.INVALID_DATA (_("The equalizer preset is invalid."));
+ }
+ double headroom = 1;
+ if (enabled && preset != "flat") {
+ headroom = profile.headroom;
+ if (preset == "device-default") gains = profile.defaults;
+ else {
+ Variant stored = settings.get_value ("gains");
+ 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 Variant ("(ssssssbsv)", name, serial, device, profile_id, port,
+ profile.signature, settings.get_boolean ("enabled"), preset, settings.get_value ("gains"));
+ ensure_native ();
+ // Only changed identity/profile/preferences authorize an attempt.
+ // Rediscovery, notifications, idle/resume and reconnect only audit.
+ if (last_request == null || !last_request.equal (desired)) {
+ last_request = desired;
+ target_gains = gains;
+ target_headroom = headroom;
+ native_revision++;
+ may_write = true;
+ state = 0;
+ native_error = "";
+ }
+ queue_native ();
+
+ } catch (Error e) {
+ if (!valid_profile) invalidate ();
+ else 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 = 0;
+ }
+ });
+ native_nodes.installed.connect (() => {
+ 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 == 4) 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];
+ if (parameters == null) throw new IOError.INVALID_DATA (_("The installed equalizer controls are incomplete."));
+ Value item;
+ while (parameters.next (out item)) {
+ unowned Wp.SpaPod pod = (Wp.SpaPod) item.get_boxed ();
+ var properties = pod.new_iterator ();
+ 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 == "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 (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 == 4) 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 = 0; 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.get_child_value (0).get_string () ||
+ props.get ("object.serial") != desired.get_child_value (1).get_string () ||
+ props.get ("device.id") != desired.get_child_value (2).get_string () ||
+ props.get ("elementary.eq.profile") != desired.get_child_value (3).get_string () ||
+ 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) { state = 1; return; }
+ state = 2;
+ 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;
+ controls (metadata, true);
+ var parameters = yield selected.enum_params ("Props", null, cancel);
+ if (!current (revision, owner)) return;
+ var values = controls (parameters, false);
+ 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 = Math.fabs (values[45] - target_headroom) <= 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 = 3; native_error = ""; return; }
+ var params = new Wp.SpaPodBuilder.@struct ();
+ 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 ((float) target_headroom);
+ 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);
+ if (current (revision, owner)) native_pending = true; // Fresh read-only confirmation, including after idle.
+ } 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 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 double headroom;
+
+ public Profile (string id) 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");
+ headroom = file.get_double ("Profile", "Headroom");
+ if (node == "" || node.length > 256 || route == "" || route.length > 128 ||
+ types.length != 5 || frequencies.length != 5 || q.length != 5 || defaults.length != 5 ||
+ minimum.length != 5 || maximum.length != 5 || !headroom.is_finite () || headroom <= 0 || headroom > 1) {
+ throw new IOError.INVALID_DATA (_("The equalizer profile is incomplete."));
+ }
+ for (int i = 0; i < 5; i++) {
+ if ((types[i] != "low-shelf" && types[i] != "peak" && types[i] != "high-shelf") ||
+ !frequencies[i].is_finite () || frequencies[i] < 20 || frequencies[i] > 20000 ||
+ !q[i].is_finite () || q[i] <= 0 || q[i] > 10 ||
+ !defaults[i].is_finite () || !minimum[i].is_finite () || !maximum[i].is_finite () ||
+ minimum[i] < -24 || minimum[i] > 0 || maximum[i] < 0 || maximum[i] > 12 ||
+ defaults[i] < minimum[i] || defaults[i] > maximum[i]) {
+ throw new IOError.INVALID_DATA (_("An equalizer profile band is invalid."));
+ }
+ }
+ }
+ }
+}
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/bridge-device.c b/tests/bridge-device.c
new file mode 100644
index 00000000..5b4dc8b3
--- /dev/null
+++ b/tests/bridge-device.c
@@ -0,0 +1,143 @@
+/* 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;
+ uint32_t count = id == SPA_PARAM_EnumRoute ? 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;
+ 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 (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", "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..57d3169c
--- /dev/null
+++ b/tests/fixture-v1.ini
@@ -0,0 +1,14 @@
+# 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=100;250;1000;4000;10000;
+Q=0.7;0.7;0.7;0.7;0.7;
+DefaultGains=-1;-2;-3;-4;-5;
+MinimumGains=-12;-12;-12;-12;-12;
+MaximumGains=0;0;0;0;0;
+Headroom=0.25
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/run-components.sh b/tests/run-components.sh
new file mode 100644
index 00000000..790b308f
--- /dev/null
+++ b/tests/run-components.sh
@@ -0,0 +1,9 @@
+#!/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/controls exercise the actual backend in the companion
+# Sound tests/run-active-bridge.sh; the removed C-wrapper tests are archived in R2.
+python3 tests/profile-files.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;
+}