Skip to content
Closed
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
23 changes: 23 additions & 0 deletions docs/speaker-equalizer.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Speaker equalizer controls

Sound consumes settings-daemon status and preferences; it does not own audio
processing. Closing Settings leaves enabled EQ and noise cancellation running.

The five gain controls are Bass, Low mids, Mids, Air and Upper balance. Their
names are fixed here; the device profile supplies frequencies, Q, gain ranges
and optional recommended gains. Frequencies appear in tooltips. Reset sits
below the sliders and preserves On/Off. Sliders remain editable while Off.

User preferences take precedence over recommendations. Invalid profiles remain
unavailable; a supported operation failure can be recovered by an explicit
changed preference after the daemon revalidates the graph.

Private integration checks use the companion daemon source and vendor marker policy
and profile, in the existing SDK without `/dev/snd`:

```
EQ_PRIVATE_TEST=1 EQ_OEM_RULE=<rule> EQ_OEM_PROFILE=<profile> \
sh tests/run-components.sh <daemon-source>
```

The native bridge uses synthetic card/route metadata and private audio servers.
3 changes: 3 additions & 0 deletions po/POTFILES
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,10 @@ src/TestPopover.vala
src/PulseAudioManager.vala
src/Plug.vala
src/OutputPanel.vala
src/EchoCancellation.vala
src/InputPanel.vala
src/InputDeviceMonitor.vala
src/DeviceRow.vala
src/Device.vala
src/SpeakerEqualizer.vala
src/SpeakerEqualizerPanel.vala
25 changes: 14 additions & 11 deletions src/ApplicationsPanel.vala
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,16 @@
* Authored by: Leonhard Kargl <leo.kargl@proton.me>
*/

public class Sound.ApplicationsPanel : Gtk.Box {
public class Sound.ApplicationsPanel : Switchboard.SettingsPage {
public ApplicationsPanel () {
Object (
title: _("Applications"),
icon: new ThemedIcon ("preferences-desktop-apps")
);
}

construct {
show_end_title_buttons = true;
var pulse_audio_manager = PulseAudioManager.get_default ();

var placeholder = new Granite.Placeholder (_("No applications currently emitting sounds")) {
Expand All @@ -20,23 +28,18 @@ public class Sound.ApplicationsPanel : Gtk.Box {
list_box.set_placeholder (placeholder);
list_box.add_css_class (Granite.STYLE_CLASS_RICH_LIST);

var scrolled_window = new Gtk.ScrolledWindow () {
child = list_box,
vexpand = true
};

var frame = new Gtk.Frame (null) {
child = scrolled_window
child = list_box
};

var reset_button = new Gtk.Button.with_label (_("Reset all apps to default")) {
halign = END
};

orientation = VERTICAL;
spacing = 12;
append (frame);
append (reset_button);
var content_box = new Gtk.Box (VERTICAL, 12);
content_box.append (frame);
content_box.append (reset_button);
child = content_box;

// TODO: Reset also non active applications
reset_button.clicked.connect (() => {
Expand Down
111 changes: 111 additions & 0 deletions src/EchoCancellation.vala
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
/*
* SPDX-License-Identifier: LGPL-2.0-or-later
* SPDX-FileCopyrightText: 2026 elementary, Inc. (https://elementary.io)
*/

public class Sound.EchoCancellation : Object {
public const string SOURCE_NAME = "elementary_echo_cancel_source";
public const string SINK_NAME = "elementary_echo_cancel_sink";

public struct Status {
public bool enabled;
public bool available;
public bool busy;
public string error;
public string source_master;
public string sink_master;
}

[DBus (name = "io.elementary.settings_daemon.Audio")]
private interface Audio : DBusProxy {
public abstract async Status get_status () throws DBusError, IOError;
public signal void state_changed ();
}

public bool requested { get; set; }
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; }

private Settings? settings;
private Audio? audio;
private uint owner_generation;

construct {
var schema = SettingsSchemaSource.get_default ().lookup ("io.elementary.settings-daemon.audio", true);
if (schema == null) {
error = _("The settings audio service is unavailable.");
return;
}
settings = new Settings.full (schema, null, null);
settings.bind ("echo-cancellation", this, "requested", SettingsBindFlags.DEFAULT);
Bus.watch_name (SESSION, "io.elementary.settings-daemon", NONE, (connection) => {
var generation = ++owner_generation;
connection.get_proxy.begin<Audio> ("io.elementary.settings-daemon", "/io/elementary/settings_daemon", NONE,
null, (obj, res) => {
try {
var proxy = connection.get_proxy.end<Audio> (res);
if (generation != owner_generation) {
return;
}
audio = proxy;
audio.state_changed.connect (() => refresh.begin ());
refresh.begin ();
} catch (Error e) {
if (generation == owner_generation) {
error = e.message;
}
}
});
}, () => {
owner_generation++;
audio = null;
source_master = sink_master = null;
enabled = available = busy = false;
error = _("The settings audio service is unavailable.");
});
}

public async void refresh () {
var generation = owner_generation;
var service = audio;
if (service == null) {
return;
}
try {
var status = yield service.get_status ();
if (generation != owner_generation || service != audio) {
return;
}
enabled = status.enabled;
available = status.available;
busy = status.busy;
error = status.error;
source_master = status.source_master == "" ? null : status.source_master;
sink_master = status.sink_master == "" ? null : status.sink_master;
} catch (Error e) {
if (generation != owner_generation || service != audio) {
return;
}
available = busy = false;
error = e.message;
}
}

public void request (bool enable) {
if (settings != null) {
requested = enable;
}
}

public string? resolve_source (string? name) {
return name == SOURCE_NAME ? source_master : name;
}

public string? resolve_sink (string? name) {
return name == SINK_NAME ? sink_master : name;
}
}
100 changes: 84 additions & 16 deletions src/InputPanel.vala
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,29 @@
* Authored by: Corentin Noël <corentin@elementary.io>
*/

public class Sound.InputPanel : Gtk.Box {
public class Sound.InputPanel : Switchboard.SettingsPage {
private Device? default_device = null;
private Gtk.LevelBar level_bar;
private Gtk.ListBox devices_listbox;
private Gtk.Scale volume_scale;
private Gtk.Switch volume_switch;
private Gtk.Switch echo_switch;
private Gtk.Label echo_status;
private ulong echo_switch_handler;
private EchoCancellation echo_cancellation;
private InputDeviceMonitor device_monitor;
private bool monitor_visible;
private unowned PulseAudioManager pam;

public InputPanel () {
Object (
title: _("Input"),
icon: new ThemedIcon ("audio-input-microphone")
);
}

construct {
margin_bottom = 12;
show_end_title_buttons = true;

var no_device_grid = new Granite.Placeholder (
_("No Connected Audio Devices Detected")
Expand All @@ -25,8 +37,7 @@ public class Sound.InputPanel : Gtk.Box {
};

devices_listbox = new Gtk.ListBox () {
activate_on_single_click = true,
vexpand = true
activate_on_single_click = true
};
devices_listbox.set_placeholder (no_device_grid);
devices_listbox.add_css_class (Granite.STYLE_CLASS_RICH_LIST);
Expand All @@ -35,12 +46,8 @@ public class Sound.InputPanel : Gtk.Box {
pam.set_default_device.begin (((Sound.DeviceRow) row).device);
});

var scrolled = new Gtk.ScrolledWindow () {
child = devices_listbox
};

var devices_frame = new Gtk.Frame (null) {
child = scrolled
child = devices_listbox
};

var volume_label = new Granite.HeaderLabel (_("Input Volume"));
Expand Down Expand Up @@ -74,17 +81,55 @@ public class Sound.InputPanel : Gtk.Box {
volume_grid.attach (volume_scale, 0, 2);
volume_grid.attach (volume_switch, 1, 1, 1, 2);

orientation = VERTICAL;
spacing = 18;
append (devices_frame);
append (volume_grid);
echo_switch = new Gtk.Switch () {
valign = START
};
echo_switch.state_set.connect (() => {
return true;
});
echo_switch_handler = echo_switch.notify["active"].connect (() => {
echo_cancellation.request (echo_switch.active);
});

var echo_description = new Gtk.Label (
_("Reduce background noise and speaker echo in new applications using the default microphone.")
) {
wrap = true,
xalign = 0,
hexpand = true
};
echo_description.add_css_class (Granite.STYLE_CLASS_DIM_LABEL);

echo_status = new Gtk.Label (null) {
wrap = true,
xalign = 0
};
echo_status.add_css_class (Granite.STYLE_CLASS_DIM_LABEL);

var echo_grid = new Gtk.Grid () {
column_spacing = 12,
row_spacing = 3
};
echo_grid.attach (new Granite.HeaderLabel (_("Noise Cancellation")), 0, 0);
echo_grid.attach (echo_description, 0, 1);
echo_grid.attach (echo_status, 0, 2);
echo_grid.attach (echo_switch, 1, 0, 1, 2);

var content_box = new Gtk.Box (VERTICAL, 18);
content_box.append (devices_frame);
content_box.append (volume_grid);
content_box.append (echo_grid);
child = content_box;

device_monitor = new InputDeviceMonitor ();
device_monitor.update_fraction.connect ((fraction) => {
level_bar.value = fraction;
});

pam = PulseAudioManager.get_default ();
echo_cancellation = pam.echo_cancellation;
echo_cancellation.notify.connect (echo_status_changed);
echo_status_changed ();
pam.new_device.connect (add_device);
pam.notify["default-input"].connect (() => {
default_changed ();
Expand All @@ -93,8 +138,23 @@ public class Sound.InputPanel : Gtk.Box {
connect_signals ();
}

private void echo_status_changed () {
SignalHandler.block (echo_switch, echo_switch_handler);
echo_switch.sensitive = !echo_cancellation.busy &&
(echo_cancellation.available || echo_cancellation.enabled || echo_cancellation.requested);
echo_switch.state = echo_cancellation.enabled;
// Setting active even to its current value cancels GTK's animation.
if (echo_switch.active != echo_cancellation.requested) {
echo_switch.active = echo_cancellation.requested;
}
SignalHandler.unblock (echo_switch, echo_switch_handler);
echo_status.label = echo_cancellation.error != null ? echo_cancellation.error : "";
echo_status.visible = echo_status.label != "";
}

public void set_visibility (bool is_visible) {
if (is_visible) {
monitor_visible = is_visible;
if (is_visible && default_device != null) {
device_monitor.start_record ();
} else {
device_monitor.stop_record ();
Expand Down Expand Up @@ -131,10 +191,18 @@ public class Sound.InputPanel : Gtk.Box {
}

default_device = pam.default_input;
volume_switch.sensitive = default_device != null;
volume_scale.sensitive = default_device != null && !default_device.is_muted;
if (default_device == null) {
device_monitor.stop_record ();
}
if (default_device != null) {
device_monitor.set_device (default_device);
if (monitor_visible) {
device_monitor.start_record ();
}
if (volume_switch.active == default_device.is_muted) {
volume_switch.activate ();
volume_switch.active = !default_device.is_muted;
}
volume_scale.set_value (default_device.volume);
default_device.notify.connect (device_notify);
Expand All @@ -149,7 +217,7 @@ public class Sound.InputPanel : Gtk.Box {
switch (pspec.get_name ()) {
case "is-muted":
if (volume_switch.active == default_device.is_muted) {
volume_switch.activate ();
volume_switch.active = !default_device.is_muted;
}

volume_scale.sensitive = !default_device.is_muted;
Expand Down
Loading