Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 19 additions & 0 deletions data/50-elementary-speaker-equalizer.conf
Original file line number Diff line number Diff line change
@@ -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
}
}
}
]
}
12 changes: 12 additions & 0 deletions data/generic-speakers-v1.ini
Original file line number Diff line number Diff line change
@@ -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;
16 changes: 16 additions & 0 deletions data/io.elementary.settings-daemon.gschema.xml
Original file line number Diff line number Diff line change
Expand Up @@ -183,4 +183,20 @@
<description>Indicates the preferred level of contrast for the user interface.</description>
</key>
</schema>
<schema id="io.elementary.settings-daemon.audio" path="/io/elementary/settings-daemon/audio/">
<key name="echo-cancellation" type="b">
<default>false</default>
<summary>Cancel speaker echo in the default microphone</summary>
</key>
</schema>
<schema id="io.elementary.settings-daemon.audio.equalizer">
<key name="enabled" type="b">
<default>false</default>
<summary>Enable the speaker equalizer for this device profile</summary>
</key>
<key name="gains" type="ad">
<default>[0.0, 0.0, 0.0, 0.0, 0.0]</default>
<summary>Five saved speaker equalizer gains in decibels</summary>
</key>
</schema>
</schemalist>
11 changes: 11 additions & 0 deletions data/meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -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',
)
40 changes: 40 additions & 0 deletions docs/speaker-equalizer.md
Original file line number Diff line number Diff line change
@@ -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`.
12 changes: 12 additions & 0 deletions meson.build
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions po/POTFILES
Original file line number Diff line number Diff line change
Expand Up @@ -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
6 changes: 6 additions & 0 deletions src/Application.vala
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 ();
Expand All @@ -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 ());

Expand Down
134 changes: 134 additions & 0 deletions src/Backends/Audio.vala
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading