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