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; +}