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