diff --git a/bin/omarchy-audio-output-set-default b/bin/omarchy-audio-output-set-default index 5f7d8f04..a742a2b3 100755 --- a/bin/omarchy-audio-output-set-default +++ b/bin/omarchy-audio-output-set-default @@ -15,6 +15,17 @@ fi timeout 2 wpctl set-default "$node_id" 2>/dev/null || true timeout 2 pactl set-default-sink "$sink_name" 2>/dev/null || true -timeout 2 pactl list short sink-inputs 2>/dev/null | awk '{ print $1 }' | while read -r input; do +# Move only real application streams. A DSP filter-chain's own output is also a +# sink input but carries no application.name, and moving it would rewire the +# processing itself -- onto headphones, or into its own virtual sink, which is a +# cycle. EasyEffects' output stream must stay put for the same reason. +timeout 2 pactl list sink-inputs 2>/dev/null | awk ' + /^Sink Input #/ {id = substr($3, 2)} + /application\.name = / { + app = $0 + sub(/.*application\.name = "/, "", app) + sub(/"$/, "", app) + if (app != "EasyEffects") print id + }' | while read -r input; do [[ -n $input ]] && timeout 2 pactl move-sink-input "$input" "$sink_name" 2>/dev/null || true done diff --git a/bin/omarchy-audio-output-sink b/bin/omarchy-audio-output-sink new file mode 100755 index 00000000..f47bf984 --- /dev/null +++ b/bin/omarchy-audio-output-sink @@ -0,0 +1,55 @@ +#!/bin/bash + +# omarchy:summary=Print the sink whose volume and mute a given output really uses +# omarchy:args=[sink-name] +# omarchy:group=audio +# omarchy:examples=omarchy audio output sink | omarchy audio output sink omarchy_speaker_tuning + +set -uo pipefail + +# A DSP sink -- a speaker tuning filter-chain, or EasyEffects -- can be the +# selected output without being where loudness lives. Changing its volume alters +# the level going *into* the processing: the display moves while the speakers do +# not, and on a chain with a compressor or limiter the tone changes too. Resolve +# through it to the physical sink it feeds. +# +# With no argument this resolves the current default output, so when headphones or +# HDMI are selected it returns those, not the speakers a tuning happens to front. +# Callers that need to describe some *other* output -- an output switcher naming +# the next one in the rotation -- pass that sink explicitly. + +sink="${1:-$(pactl get-default-sink 2>/dev/null)}" + +if [[ -z $sink || $sink == alsa_output.* ]]; then + printf '%s\n' "$sink" + exit 0 +fi + +# A DSP sink feeds its physical output through a stream of its own; follow that +# stream down to the sink underneath. +downstream="$(pactl list sink-inputs 2>/dev/null | + awk -v virt="$sink" ' + /^Sink Input #/ {target = ""} + /^[[:space:]]*Sink:/ {target = $2} + /node\.name = / { + name = $0 + sub(/.*node\.name = "/, "", name) + sub(/"$/, "", name) + if (index(name, virt) == 1 && target != "") {print target; exit} + } + /application\.name = "EasyEffects"/ { + if (virt == "easyeffects_sink" && target != "") {print target; exit} + }')" + +if [[ -n $downstream ]]; then + name="$(pactl list sinks short 2>/dev/null | + awk -v id="$downstream" '$1 == id {print $2; exit}')" + if [[ -n $name ]]; then + printf '%s\n' "$name" + exit 0 + fi +fi + +# Nothing resolvable downstream -- the DSP sink may simply be idle and unlinked. +# Fall back to the sink itself so callers still have something to act on. +printf '%s\n' "$sink" diff --git a/bin/omarchy-audio-output-switch b/bin/omarchy-audio-output-switch index 73af2246..11d2a356 100755 --- a/bin/omarchy-audio-output-switch +++ b/bin/omarchy-audio-output-switch @@ -2,7 +2,14 @@ # omarchy:summary=Switch between audio outputs while preserving the mute status -sinks=$(timeout 2 pactl -f json list sinks | jq '[.[] | select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any))]') +# Skip the physical sink an active speaker tuning fronts: rotating onto it would +# silently bypass the tuning rather than pick a different output. +fronted=$(omarchy-audio-tuning fronted-sink 2>/dev/null || true) + +sinks=$(timeout 2 pactl -f json list sinks | + jq --arg fronted "$fronted" '[.[] + | select((.ports | length == 0) or ([.ports[]? | .availability != "not available"] | any)) + | select($fronted == "" or .name != $fronted)]') sinks_count=$(jq 'length' <<<"$sinks") if (( sinks_count == 0 )); then @@ -22,8 +29,18 @@ fi next_sink=$(jq -c ".[$next_sink_index]" <<<"$sinks") next_sink_name=$(jq -r '.name' <<<"$next_sink") next_sink_description=$(jq -r '.description // .properties."device.description" // .name' <<<"$next_sink") -next_sink_volume=$(jq -r '.volume | to_entries[0].value.value_percent | sub("%"; "") | tonumber' <<<"$next_sink") -next_sink_is_muted=$(jq -r '.mute' <<<"$next_sink") +# A tuning sink sits at a fixed 100% and unmuted while real loudness lives on the +# physical sink beneath it, so read the level from whichever sink actually carries +# it or the OSD contradicts the volume keys. +next_sink_effective=$(omarchy-audio-output-sink "$next_sink_name") +next_sink_volume=$(timeout 2 pactl get-sink-volume "$next_sink_effective" 2>/dev/null | + awk 'NR == 1 {for (i = 1; i <= NF; i++) if ($i ~ /%$/) {sub("%", "", $i); print $i; exit}}') +[[ -n $next_sink_volume ]] || next_sink_volume=$(jq -r '.volume | to_entries[0].value.value_percent | sub("%"; "") | tonumber' <<<"$next_sink") +if [[ $(timeout 2 pactl get-sink-mute "$next_sink_effective" 2>/dev/null) == *yes ]]; then + next_sink_is_muted=true +else + next_sink_is_muted=false +fi if [[ $next_sink_is_muted == "true" ]] || (( next_sink_volume == 0 )); then icon_state="muted" diff --git a/bin/omarchy-audio-output-volume b/bin/omarchy-audio-output-volume index cc9a02c2..22a24aa5 100755 --- a/bin/omarchy-audio-output-volume +++ b/bin/omarchy-audio-output-volume @@ -11,20 +11,27 @@ if [[ -z $action ]]; then exit 1 fi -volume_state() { - wpctl get-volume @DEFAULT_AUDIO_SINK@ -} +# Resolve through any DSP sink to the physical one, so the keys always move real +# loudness and the processing always sees full-scale input. Shared with the audio +# panel and the output switcher. +sink="$(omarchy-audio-output-sink)" +if [[ -z $sink ]]; then + echo "Could not resolve an audio sink to control." >&2 + exit 1 +fi +# pactl reports the same percentage scale wpctl does (both are the raw volume +# over PA_VOLUME_NORM), so the OSD reads identically either way. volume_percent() { - volume_state | awk '{ for (i=1; i<=NF; i++) if ($i ~ /^[0-9.]+$/) print int($i * 100) }' + pactl get-sink-volume "$sink" 2>/dev/null | + awk 'NR == 1 { + for (i = 1; i <= NF; i++) + if ($i ~ /%$/) {sub("%", "", $i); print $i; exit} + }' } volume_muted() { - volume_state | grep -q MUTED -} - -unmute_output() { - wpctl set-mute @DEFAULT_AUDIO_SINK@ 0 >/dev/null + [[ $(pactl get-sink-mute "$sink" 2>/dev/null) == *yes ]] } case "$action" in @@ -37,31 +44,43 @@ if [[ $action == "mute-toggle" ]]; then debounce_file="$runtime_dir/omarchy-audio-output-volume-mute-toggle.last" now=$(date +%s%3N) last=0 - [[ -r $debounce_file ]] && read -r last < "$debounce_file" || true - if (( now - last < 250 )); then + [[ -r $debounce_file ]] && read -r last <"$debounce_file" || true + if ((now - last < 250)); then exit 0 fi - printf '%s\n' "$now" > "$debounce_file" + printf '%s\n' "$now" >"$debounce_file" - wpctl set-mute @DEFAULT_AUDIO_SINK@ toggle >/dev/null -elif [[ $action == +* ]]; then - step="${action#+}" - unmute_output - wpctl set-volume -l 1.0 @DEFAULT_AUDIO_SINK@ "${step}%+" -elif [[ $action == -* ]]; then - step="${action#-}" - unmute_output - wpctl set-volume @DEFAULT_AUDIO_SINK@ "${step}%-" + pactl set-sink-mute "$sink" toggle +elif [[ $action =~ ^([+-])([0-9]+)$ ]]; then + direction="${BASH_REMATCH[1]}" + step="${BASH_REMATCH[2]}" + + current="$(volume_percent)" + if [[ -z $current ]]; then + echo "Could not read volume for $sink." >&2 + exit 1 + fi + + if [[ $direction == "+" ]]; then + next=$((current + step)) + ((next <= 100)) || next=100 + else + next=$((current - step)) + ((next >= 0)) || next=0 + fi + + pactl set-sink-mute "$sink" 0 + pactl set-sink-volume "$sink" "${next}%" else echo "Unknown volume action: $action" exit 1 fi percent=$(volume_percent) -if volume_muted || (( percent == 0 )); then +if volume_muted || ((${percent:-0} == 0)); then icon="volume-muted" else icon="volume-high" fi -omarchy-osd -i "$icon" -p "$percent" +omarchy-osd -i "$icon" -p "${percent:-0}" diff --git a/bin/omarchy-audio-sink-availability b/bin/omarchy-audio-sink-availability index 0f333e26..531b5bff 100755 --- a/bin/omarchy-audio-sink-availability +++ b/bin/omarchy-audio-sink-availability @@ -3,9 +3,18 @@ # omarchy:summary=Print PulseAudio sink availability for the shell # omarchy:group=audio -pactl list sinks 2>/dev/null | awk ' +# A speaker tuning is a virtual sink in front of the real speakers. Both exist +# in the graph, but selecting the physical one would only bypass the tuning, so +# report it unavailable and keep it out of the output list. +fronted="$(omarchy-audio-tuning fronted-sink 2>/dev/null || true)" + +pactl list sinks 2>/dev/null | awk -v fronted="$fronted" ' function emit_sink() { if (name == "") return + if (fronted != "" && name == fronted) { + print name "\t0" + return + } print name "\t" ((port_count == 0 || available) ? 1 : 0) } diff --git a/bin/omarchy-audio-tuning b/bin/omarchy-audio-tuning new file mode 100755 index 00000000..c7a73125 --- /dev/null +++ b/bin/omarchy-audio-tuning @@ -0,0 +1,329 @@ +#!/bin/bash + +# omarchy:summary=Manage the speaker tuning for this laptop +# omarchy:args= [--force] +# omarchy:group=audio +# omarchy:examples=omarchy audio tuning status | omarchy audio tuning on | omarchy audio tuning off + +set -uo pipefail + +tunings_dir="$OMARCHY_PATH/default/audio/tunings" +config_home="${XDG_CONFIG_HOME:-$HOME/.config}" + +# The tuning is hosted by its own PipeWire client, under its own config name, so +# switching it needs no audio restart -- a restart drops every PulseAudio client's +# connection, and applications that do not reconnect (Spotify) then have to be +# restarted by hand. The name is deliberately not PipeWire's stock +# filter-chain.conf, which merges every fragment in filter-chain.conf.d/ and would +# make this service host unrelated user filters too. +host_config_name=omarchy-speaker-tuning.conf +host_config="$config_home/pipewire/$host_config_name" +host_source="$OMARCHY_PATH/default/audio/filter-chain-host.conf" +fragment="$config_home/pipewire/$host_config_name.d/90-tuning.conf" +unit_name=omarchy-speaker-tuning.service +unit="$config_home/systemd/user/$unit_name" +unit_source="$OMARCHY_PATH/default/systemd/user/$unit_name" + +# Earlier revisions loaded the tuning into the daemon, as a WirePlumber smart +# filter, or into the shared filter-chain.conf.d namespace. Remove all three so +# they cannot be loaded alongside the current one. +stale_daemon="$config_home/pipewire/pipewire.conf.d/90-omarchy-speaker-tuning.conf" +stale_wireplumber="$config_home/wireplumber/wireplumber.conf.d/90-omarchy-speaker-tuning.conf" +stale_shared="$config_home/pipewire/filter-chain.conf.d/90-omarchy-speaker-tuning.conf" + +sink_name=omarchy_speaker_tuning + +action="${1:-status}" +force=0 +[[ ${2:-} == "--force" ]] && force=1 + +sink_matching() { + pactl list sinks short 2>/dev/null | awk -v p="$1" '$2 ~ p {print $2; exit}' +} + +# Print the tuning directory matching this laptop, if any. Matching is data, not +# code: a tuning declares the DMI string it belongs to and the sink it expects, so +# most tunings can be added as a directory with no new script. A tuning whose +# hardware needs a sharper test can set match_command to any predicate instead. +tuning_match() { + local dir + for dir in "$tunings_dir"/*/; do + [[ -r $dir/tuning.conf ]] || continue + + unset match_dmi match_command sink_pattern + # shellcheck disable=SC1090 + source "$dir/tuning.conf" + + # Deliberately does not look at the live audio graph. The install hooks run in + # the ISO chroot with no audio server, and a match that depended on a present + # sink would come back empty there -- so the machine would get neither the LV2 + # dependency nor the tuning, and nothing would retry. + if [[ -n ${match_command:-} ]]; then + "$match_command" 2>/dev/null || continue + else + [[ -n ${match_dmi:-} ]] || continue + omarchy-hw-match "$match_dmi" 2>/dev/null || continue + fi + + # Required whichever way the tuning matched: the graph's target sink is + # substituted from it, so a tuning without one cannot be installed and must + # not be reported as a match. + [[ -n ${sink_pattern:-} ]] || continue + + printf '%s\n' "${dir%/}" + return 0 + done + return 1 +} + +# The physical sink the matched tuning is built for, taken from the tuning's own +# sink_pattern rather than a hard-coded regex, so hardware with a different sink +# name needs no change here. +tuned_hardware_sink() { + local dir found + dir="$(tuning_match)" || return 1 + unset sink_pattern + # shellcheck disable=SC1090 + source "$dir/tuning.conf" + [[ -n ${sink_pattern:-} ]] || return 1 + found="$(sink_matching "$sink_pattern")" + [[ -n $found ]] || return 1 + printf '%s\n' "$found" +} + +tuning_present() { + pactl list sinks short 2>/dev/null | awk '{print $2}' | grep -qx "$sink_name" +} + +# Only real application streams may be moved. A filter-chain's own output is also +# a sink input but carries no application.name, and moving it would rewire the +# tuning itself. +app_streams() { + pactl list sink-inputs 2>/dev/null | awk ' + /^Sink Input #/ {id = substr($3, 2)} + /application\.name = / { + app = $0 + sub(/.*application\.name = "/, "", app) + sub(/"$/, "", app) + if (app != "EasyEffects") print id + }' +} + +move_apps_to() { + local target="$1" id + for id in $(app_streams); do + pactl move-sink-input "$id" "$target" 2>/dev/null || true + done +} + +# WirePlumber can link the output elsewhere if the target is missing when the host +# starts. node.dont-fallback guards against it, but verify rather than assume. +tuning_downstream_sink() { + omarchy-audio-output-sink "$sink_name" 2>/dev/null +} + +easyeffects_running() { + pactl list sinks short 2>/dev/null | awk '{print $2}' | grep -qx easyeffects_sink || + pgrep -u "$(id -u)" -x easyeffects >/dev/null 2>&1 || + systemctl --user is-active --quiet easyeffects.service 2>/dev/null +} + +# Unloading a daemon-loaded drop-in is the one case that still needs an audio +# restart, because the daemon only reads its own config at startup. +drop_stale_daemon_config() { + [[ -e $stale_daemon || -e $stale_wireplumber ]] || return 0 + rm -f "$stale_daemon" "$stale_wireplumber" + omarchy-restart-audio >/dev/null 2>&1 + local _ + for _ in {1..40}; do + pactl info >/dev/null 2>&1 && break + sleep 0.25 + done +} + +case "$action" in + match) + tuning_match + ;; + + fronted-sink) + # The tuning is a virtual sink in front of the real speakers, so both exist in + # the graph. Selecting the physical one would only bypass the tuning, so + # callers keep it out of the output list while the tuning is up. This answers + # "is a tuning in place", not "where should volume go" -- for the latter see + # omarchy-audio-output-sink, which follows the current default output. + tuning_present || exit 1 + tuned_hardware_sink + ;; + + status) + if [[ -r $fragment ]]; then + echo "Installed: yes ($fragment)" + else + echo "Installed: no" + fi + # Both is-active and is-enabled print their answer *and* exit non-zero when + # negative, so a "|| echo" fallback prints it twice. + host_state="$(systemctl --user is-active "$unit_name" 2>/dev/null)" + host_enabled="$(systemctl --user is-enabled "$unit_name" 2>/dev/null)" + echo "Host service: ${host_state:-inactive} (${host_enabled:-disabled})" + if tuning_present; then + echo "Tuning sink: present" + else + echo "Tuning sink: absent" + fi + echo "Default sink: $(pactl get-default-sink 2>/dev/null)" + if dir="$(tuning_match)"; then + unset description + # shellcheck disable=SC1090 + source "$dir/tuning.conf" + echo "Matches: ${description:-?} ($(basename "$dir"))" + else + echo "Matches: nothing ships for this laptop" + fi + ;; + + off) + if [[ ! -r $fragment && ! -r $unit && ! -r $stale_daemon && ! -r $stale_wireplumber && + ! -r $stale_shared ]]; then + echo "No speaker tuning installed." + exit 0 + fi + + speakers="$(tuned_hardware_sink)" || speakers="" + + systemctl --user disable --now "$unit_name" >/dev/null 2>&1 + rm -f "$fragment" "$host_config" "$unit" "$stale_shared" + rmdir "$config_home/pipewire/$host_config_name.d" 2>/dev/null + systemctl --user daemon-reload >/dev/null 2>&1 + drop_stale_daemon_config + + for _ in {1..20}; do + tuning_present || break + sleep 0.25 + done + + if [[ -n $speakers ]]; then + pactl set-default-sink "$speakers" >/dev/null 2>&1 + # Streams left on the vanished tuning sink reconnect wherever PipeWire puts + # them, which is not necessarily the speakers. + move_apps_to "$speakers" + fi + echo "Speaker tuning removed." + ;; + + on) + [[ -d $tunings_dir ]] || { + echo "No tunings shipped at $tunings_dir" >&2 + exit 1 + } + + selected="$(tuning_match)" || { + echo "No speaker tuning matches this laptop." + exit 0 + } + + unset description sink_pattern + # shellcheck disable=SC1090 + source "$selected/tuning.conf" + + # At first-run the session is up but the sink can still be settling. + for _ in {1..20}; do + speaker_sink="$(sink_matching "$sink_pattern")" + [[ -n $speaker_sink ]] && break + sleep 0.5 + done + [[ -n ${speaker_sink:-} ]] || { + echo "A tuning applies to this laptop but no sink matching $sink_pattern" >&2 + echo "is present, so there is no audio server yet. Re-run after login:" >&2 + echo " omarchy audio tuning on" >&2 + exit 1 + } + + if easyeffects_running; then + cat >&2 <<'EOF' +EasyEffects is running. It moves any stream that follows the default sink to its +own sink, so a tuning installed now would be bypassed. + +Stop it first: systemctl --user disable --now easyeffects.service +EOF + exit 1 + fi + + # Every tuning ends in a limiter, which is an LV2 plugin. Without it the graph + # fails to instantiate and the tuning sink never appears. + ls /usr/lib/lv2/lsp-plugins.lv2/limiter_stereo.ttl >/dev/null 2>&1 || { + echo "lsp-plugins-lv2 is required for the tuning limiter." >&2 + exit 1 + } + + rendered="$(mktemp)" + trap 'rm -f "$rendered"' EXIT + sed "s|@SPEAKER_SINK@|$speaker_sink|g" "$selected/filter-chain.conf" >"$rendered" + + # Everything that makes the tuning current has to match, not just the graph: + # an active-but-disabled service disappears at next login, and a stale unit + # file would shadow later fixes to the shipped one indefinitely. + if ((!force)) && [[ -r $fragment ]] && cmp -s "$rendered" "$fragment" && + [[ -r $host_config ]] && cmp -s "$host_source" "$host_config" && + [[ -r $unit ]] && cmp -s "$unit_source" "$unit" && + systemctl --user is-active --quiet "$unit_name" 2>/dev/null && + systemctl --user is-enabled --quiet "$unit_name" 2>/dev/null && + [[ "$(tuning_downstream_sink)" == "$speaker_sink" ]]; then + echo "Speaker tuning already current: $description" + exit 0 + fi + + drop_stale_daemon_config + + rm -f "$stale_shared" + install -Dm644 "$host_source" "$host_config" + install -Dm644 "$rendered" "$fragment" + install -Dm644 "$unit_source" "$unit" + systemctl --user daemon-reload >/dev/null 2>&1 + systemctl --user enable "$unit_name" >/dev/null 2>&1 + systemctl --user restart "$unit_name" >/dev/null 2>&1 + echo "Installed speaker tuning: $description" + + for _ in {1..40}; do + tuning_present && break + sleep 0.25 + done + if ! tuning_present; then + systemctl --user disable --now "$unit_name" >/dev/null 2>&1 + rm -f "$fragment" "$host_config" "$unit" + systemctl --user daemon-reload >/dev/null 2>&1 + echo "Tuning sink never appeared, so it was removed. Audio is untouched." >&2 + echo "Check: systemctl --user status $unit_name" >&2 + exit 1 + fi + + # Confirm the output really landed on the sink this tuning was measured for. + for _ in {1..20}; do + [[ "$(tuning_downstream_sink)" == "$speaker_sink" ]] && break + sleep 0.25 + done + downstream="$(tuning_downstream_sink)" + if [[ $downstream != "$speaker_sink" ]]; then + systemctl --user disable --now "$unit_name" >/dev/null 2>&1 + rm -f "$fragment" "$host_config" "$unit" + systemctl --user daemon-reload >/dev/null 2>&1 + echo "The tuning output linked to ${downstream:-nothing} instead of" >&2 + echo "$speaker_sink, so it was removed rather than left tuning the wrong" >&2 + echo "device. Audio is untouched." >&2 + exit 1 + fi + + pactl set-default-sink "$sink_name" >/dev/null 2>&1 + # A default sink only captures newly created streams, so anything already + # playing would keep bypassing the tuning until its app was restarted. + move_apps_to "$sink_name" + + echo "Speakers now play through the tuning." + ;; + + *) + echo "Usage: omarchy-audio-tuning [--force]" >&2 + exit 2 + ;; +esac diff --git a/bin/omarchy-first-run b/bin/omarchy-first-run index cd9f99d7..c31bd30c 100755 --- a/bin/omarchy-first-run +++ b/bin/omarchy-first-run @@ -115,6 +115,8 @@ run_first_run_step "set GNOME theme" \ bash "$OMARCHY_PATH/install/user/first-run/gnome-theme.sh" run_first_run_step "set GTK primary paste" \ bash "$OMARCHY_PATH/install/user/first-run/gtk-primary-paste.sh" +run_first_run_step "apply speaker tuning" \ + bash "$OMARCHY_PATH/install/user/first-run/audio-tuning.sh" wait_for_notifications run_first_run_step "show welcome notification" \ diff --git a/default/audio/filter-chain-host.conf b/default/audio/filter-chain-host.conf new file mode 100644 index 00000000..4951baea --- /dev/null +++ b/default/audio/filter-chain-host.conf @@ -0,0 +1,40 @@ +# Host config for the Omarchy speaker tuning. +# +# This exists so the tuning gets its own PipeWire client rather than sharing +# PipeWire's stock filter-chain.conf. That config merges every fragment in +# ~/.config/pipewire/filter-chain.conf.d/, so hosting the tuning there would load +# any unrelated filter a user keeps in that directory -- duplicating filters +# already hosted elsewhere, and stopping them all when the tuning is switched off. +# +# Installed as ~/.config/pipewire/omarchy-speaker-tuning.conf with the tuning +# graph merged from omarchy-speaker-tuning.conf.d/, and run with +# pipewire -c omarchy-speaker-tuning.conf +# +# The contents are the minimum a filter-hosting client needs, taken from +# /usr/share/pipewire/filter-chain.conf. + +context.properties = { + log.level = 0 +} + +context.spa-libs = { + audio.convert.* = audioconvert/libspa-audioconvert + support.* = support/libspa-support +} + +context.modules = [ + # Boost the audio thread priority. + { name = libpipewire-module-rt + args = { } + flags = [ ifexists nofail ] + } + + # The native communication protocol. + { name = libpipewire-module-protocol-native } + + # Lets this process provide nodes to PipeWire. + { name = libpipewire-module-client-node } + + # Wraps nodes in an adapter with a converter and resampler. + { name = libpipewire-module-adapter } +] diff --git a/default/audio/tunings/dell-xps14-da14260/filter-chain.conf b/default/audio/tunings/dell-xps14-da14260/filter-chain.conf new file mode 100644 index 00000000..28bbaf65 --- /dev/null +++ b/default/audio/tunings/dell-xps14-da14260/filter-chain.conf @@ -0,0 +1,137 @@ +# Dell XPS 14 DA14260 speaker tuning. +# +# Biquad chain fitted to the measured response of the xps-audio-linux EasyEffects +# profile under a dense pink-weighted multitone of 104 bin-aligned tones, +# followed by a lookahead limiter. Measures 1.24 dB RMS against that reference +# (0.97 dB weighted over the fit's own error metric). +# +# Q below 200 Hz is capped at 1.8 on purpose. A closer magnitude fit is possible +# with high-Q sections, but the reference produces its narrow bass features by +# convolution, and reproducing them with high-Q biquads swung group delay 31 ms +# across 63-80 Hz, which smears bass transients. The cap costs 0.33 dB and +# halves the swing. +# +# This is a plain filter-chain sink rather than a WirePlumber smart filter. A +# smart filter is the better shape -- it would leave the real device as the +# default output instead of adding a second one -- but on PipeWire 1.6.8 / +# WirePlumber 0.5.15 this graph loads and links correctly as a smart filter and +# then passes audio through unprocessed: its controls are present and +# mpv -> filter -> sink links are made, yet the filter's input monitor and the +# speaker sink's monitor measure identically. Revisit when that is understood. +# +# Channels are wired explicitly because the limiter is a stereo plugin; a mono +# graph is duplicated per channel and would limit each side independently, +# shifting the stereo image on bass transients. + +context.modules = [ + { name = libpipewire-module-filter-chain + args = { + node.description = "Laptop Speakers" + media.name = "Laptop Speakers" + + filter.graph = { + nodes = [ + { type = builtin name = s0_l label = bq_highpass control = { "Freq" = 60.9 "Q" = 1.0 } } + { type = builtin name = s1_l label = bq_highpass control = { "Freq" = 60.9 "Q" = 1.0 } } + { type = builtin name = s2_l label = bq_peaking control = { "Freq" = 83.4 "Q" = 1.8 "Gain" = -8.0 } } + { type = builtin name = s3_l label = bq_peaking control = { "Freq" = 100.4 "Q" = 1.59 "Gain" = 7.47 } } + { type = builtin name = s4_l label = bq_peaking control = { "Freq" = 250.5 "Q" = 2.966 "Gain" = -4.7 } } + { type = builtin name = s5_l label = bq_peaking control = { "Freq" = 419.8 "Q" = 3.0 "Gain" = -5.83 } } + { type = builtin name = s6_l label = bq_peaking control = { "Freq" = 631.3 "Q" = 2.515 "Gain" = -10.33 } } + { type = builtin name = s7_l label = bq_peaking control = { "Freq" = 894.4 "Q" = 4.0 "Gain" = -2.42 } } + { type = builtin name = s8_l label = bq_peaking control = { "Freq" = 1355.7 "Q" = 2.884 "Gain" = 6.92 } } + { type = builtin name = s9_l label = bq_peaking control = { "Freq" = 1707.2 "Q" = 1.311 "Gain" = -6.54 } } + { type = builtin name = s10_l label = bq_peaking control = { "Freq" = 3100.0 "Q" = 0.5 "Gain" = -10.09 } } + { type = builtin name = s11_l label = bq_peaking control = { "Freq" = 3200.0 "Q" = 1.048 "Gain" = 3.09 } } + { type = builtin name = s12_l label = bq_highshelf control = { "Freq" = 6015.2 "Q" = 1.5 "Gain" = -1.34 } } + + { type = builtin name = s0_r label = bq_highpass control = { "Freq" = 60.9 "Q" = 1.0 } } + { type = builtin name = s1_r label = bq_highpass control = { "Freq" = 60.9 "Q" = 1.0 } } + { type = builtin name = s2_r label = bq_peaking control = { "Freq" = 83.4 "Q" = 1.8 "Gain" = -8.0 } } + { type = builtin name = s3_r label = bq_peaking control = { "Freq" = 100.4 "Q" = 1.59 "Gain" = 7.47 } } + { type = builtin name = s4_r label = bq_peaking control = { "Freq" = 250.5 "Q" = 2.966 "Gain" = -4.7 } } + { type = builtin name = s5_r label = bq_peaking control = { "Freq" = 419.8 "Q" = 3.0 "Gain" = -5.83 } } + { type = builtin name = s6_r label = bq_peaking control = { "Freq" = 631.3 "Q" = 2.515 "Gain" = -10.33 } } + { type = builtin name = s7_r label = bq_peaking control = { "Freq" = 894.4 "Q" = 4.0 "Gain" = -2.42 } } + { type = builtin name = s8_r label = bq_peaking control = { "Freq" = 1355.7 "Q" = 2.884 "Gain" = 6.92 } } + { type = builtin name = s9_r label = bq_peaking control = { "Freq" = 1707.2 "Q" = 1.311 "Gain" = -6.54 } } + { type = builtin name = s10_r label = bq_peaking control = { "Freq" = 3100.0 "Q" = 0.5 "Gain" = -10.09 } } + { type = builtin name = s11_r label = bq_peaking control = { "Freq" = 3200.0 "Q" = 1.048 "Gain" = 3.09 } } + { type = builtin name = s12_r label = bq_highshelf control = { "Freq" = 6015.2 "Q" = 1.5 "Gain" = -1.34 } } + { type = lv2 + name = limiter + plugin = "http://lsp-plug.in/plugins/lv2/limiter_stereo" + control = { + # Both default to enabled: "alr" regulates level toward the + # threshold and "boost" normalises the threshold up to full + # scale. A fixed tuning must switch them off or its tone drifts + # with programme level. + "alr" = 0 + "boost" = 0 + "g_in" = 0.5456 + "th" = 0.891 + } + } + ] + + links = [ + { output = "s0_l:Out" input = "s1_l:In" } + { output = "s1_l:Out" input = "s2_l:In" } + { output = "s2_l:Out" input = "s3_l:In" } + { output = "s3_l:Out" input = "s4_l:In" } + { output = "s4_l:Out" input = "s5_l:In" } + { output = "s5_l:Out" input = "s6_l:In" } + { output = "s6_l:Out" input = "s7_l:In" } + { output = "s7_l:Out" input = "s8_l:In" } + { output = "s8_l:Out" input = "s9_l:In" } + { output = "s9_l:Out" input = "s10_l:In" } + { output = "s10_l:Out" input = "s11_l:In" } + { output = "s11_l:Out" input = "s12_l:In" } + { output = "s12_l:Out" input = "limiter:in_l" } + { output = "s0_r:Out" input = "s1_r:In" } + { output = "s1_r:Out" input = "s2_r:In" } + { output = "s2_r:Out" input = "s3_r:In" } + { output = "s3_r:Out" input = "s4_r:In" } + { output = "s4_r:Out" input = "s5_r:In" } + { output = "s5_r:Out" input = "s6_r:In" } + { output = "s6_r:Out" input = "s7_r:In" } + { output = "s7_r:Out" input = "s8_r:In" } + { output = "s8_r:Out" input = "s9_r:In" } + { output = "s9_r:Out" input = "s10_r:In" } + { output = "s10_r:Out" input = "s11_r:In" } + { output = "s11_r:Out" input = "s12_r:In" } + { output = "s12_r:Out" input = "limiter:in_r" } + ] + + inputs = [ "s0_l:In" "s0_r:In" ] + outputs = [ "limiter:out_l" "limiter:out_r" ] + } + + audio.channels = 2 + audio.position = [ FL FR ] + + capture.props = { + node.name = "omarchy_speaker_tuning" + media.class = Audio/Sink + } + playback.props = { + node.name = "omarchy_speaker_tuning_output" + node.passive = true + target.object = "@SPEAKER_SINK@" + # This stream is the filter's output and is a movable sink input like any + # other, so anything that reroutes "all streams" to a newly selected + # output would drag the processing along with it -- onto headphones, or + # into the tuning's own sink, which is a cycle. Pin it. + node.dont-move = true + # If the speaker sink is not present yet -- the tuning host can start + # before the device is discovered -- WirePlumber would otherwise link this + # output to whatever default exists, quietly tuning the wrong device while + # the tuning sink still looks healthy. Wait for the named target instead. + # Both are needed: without linger, WirePlumber destroys the node rather + # than waiting (see its scripts/linking/find-defined-target.lua). + node.dont-fallback = true + node.linger = true + } + } + } +] diff --git a/default/audio/tunings/dell-xps14-da14260/tuning.conf b/default/audio/tunings/dell-xps14-da14260/tuning.conf new file mode 100644 index 00000000..26c43be8 --- /dev/null +++ b/default/audio/tunings/dell-xps14-da14260/tuning.conf @@ -0,0 +1,31 @@ +## Dell XPS 14 DA14260 internal speakers. +## +## Thirteen biquads and a lookahead limiter, applied as a PipeWire filter-chain +## in front of the internal speaker sink. The stock Linux path already loads +## Dell's Cirrus smart-amplifier firmware; this adds the perceptual voicing the +## Windows Waves layer provides and Linux does not. + +description="Dell XPS 14 (2026) speakers" +## Matched on the DMI product name plus the presence of the sink below, which +## together are specific enough that no per-model predicate script is needed. +## Hardware needing a sharper test can set match_command to any predicate. +match_dmi="XPS 14 DA14260" +## Unescaped dots: this is passed to awk as a string, where a backslash escape +## would be consumed before the regex sees it. +sink_pattern='^alsa_output.*sof_sdw.*HiFi__Speaker__sink$' + +## Provenance. Derived by measuring the response of the xps-clone EasyEffects +## profile from https://github.com/spencerbull/xps-audio-linux (MIT) and fitting +## a biquad chain to it. No upstream asset is redistributed: the convolution +## impulse response is not carried, so this tuning has no binary blob and is +## sample-rate agnostic. +derived_from="xps-audio-linux xps-clone (MIT, spencerbull)" +validated_by="dhh" +validated_on="2026-07-24" + +## Measured against that reference under a dense pink-weighted multitone of 104 +## bin-aligned tones. See docs/AUDIO-TUNING.md for how to reproduce these. +magnitude_rms_db="1.24" +bass_group_delay_swing_ms="13.2" +limiter_headroom_db="1.6" ## worst-case peak on a hot master vs -1 dBFS +dynamic_range_delta_lu="0.1" diff --git a/default/systemd/user/omarchy-speaker-tuning.service b/default/systemd/user/omarchy-speaker-tuning.service new file mode 100644 index 00000000..b26ce758 --- /dev/null +++ b/default/systemd/user/omarchy-speaker-tuning.service @@ -0,0 +1,32 @@ +[Unit] +Description=Omarchy speaker tuning filter-chain +Documentation=https://github.com/basecamp/omarchy/blob/master/docs/AUDIO-TUNING.md +# WirePlumber does the linking, so starting before it is up risks the output being +# linked before the speaker device has been discovered. +After=pipewire.service wireplumber.service +Requires=pipewire.service +Wants=wireplumber.service +# Restart with the audio daemon, since the filter-chain loses its connection when +# PipeWire goes away. +PartOf=pipewire.service + +[Service] +Type=simple +# Hosts the tuning as a PipeWire *client* rather than loading it into the daemon +# from pipewire.conf.d, which is only read at daemon startup. That is what lets +# the tuning be switched on and off without restarting pipewire-pulse -- a +# restart drops every PulseAudio client's connection, and applications that do +# not reconnect (Spotify) have to be restarted by hand. +# +# It also contains failure: a malformed tuning breaks only this service, where a +# bad drop-in in the daemon's own config stops PipeWire from starting at all. +# +# The config name is deliberately not PipeWire's stock filter-chain.conf, which +# merges every fragment in ~/.config/pipewire/filter-chain.conf.d/ and would make +# this service host unrelated user filters too. +ExecStart=/usr/bin/pipewire -c omarchy-speaker-tuning.conf +Restart=on-failure +RestartSec=2 + +[Install] +WantedBy=graphical-session.target diff --git a/docs/AUDIO-TUNING.md b/docs/AUDIO-TUNING.md new file mode 100644 index 00000000..e2876725 --- /dev/null +++ b/docs/AUDIO-TUNING.md @@ -0,0 +1,126 @@ +# Speaker tunings + +Laptop speakers ship voiced by the vendor's Windows DSP layer, which Linux does +not get. A tuning restores that as a PipeWire filter-chain in front of the +internal speaker sink: a declarative graph hosted by a small PipeWire client, with +no GUI app and no binary blob. + +``` +default/audio/tunings/-/ +├── tuning.conf # description, match, provenance, measurements +└── filter-chain.conf # the graph, with @SPEAKER_SINK@ substituted on install +``` + +`on` renders the graph into `~/.config/pipewire/omarchy-speaker-tuning.conf.d/` +and runs it as its own PipeWire client via `omarchy-speaker-tuning.service`, rather than +loading it into the audio daemon. The daemon only reads its own config at startup, +so a daemon-loaded tuning could only be switched by restarting PipeWire — which +drops every PulseAudio client's connection, and applications that do not reconnect +(Spotify) then have to be restarted by hand. Hosting it separately makes switching +a start/stop of one small process, and contains failure: a malformed tuning breaks +only that service instead of stopping PipeWire from starting at all. + +Tunings apply automatically: `install/hardware/speaker-tuning.sh` installs the +LV2 dependency and `install/user/first-run/audio-tuning.sh` applies the tuning, +both gated on the match. Machines without a matching tuning are untouched. + +Switching it on happens at first-run, not at finalize-user time, because finalize-user +also runs in the ISO chroot where there is no audio server: the sink a tuning has +to target does not exist there, so nothing could be written — and nothing would +retry, since the finalizer marks all shipped migrations complete on a fresh +install. Matching itself deliberately does not consult the audio graph, so the +LV2 dependency is still installed in the chroot. + +```bash +omarchy audio tuning on # install the matching tuning +omarchy audio tuning off # remove it, back to raw speakers +omarchy audio tuning status # installed? in use? what matches? +``` + +`match` and `fronted-sink` are also accepted; they exist for the install hooks +and the sink-listing scripts rather than for daily use. + +## Adding a tuning + +Add a directory with a `tuning.conf` and a `filter-chain.conf`. No new command is +needed: matching is data. A tuning declares `match_dmi` (checked against the DMI +product name and family) and `sink_pattern`, and needing both is specific enough +for most hardware. If yours needs a sharper test, set `match_command` to any +predicate instead — an `omarchy-hw-*` script, for example. `sink_pattern` is +required either way, since the graph's target sink is substituted from it. + +Gate narrowly and widen as models are validated; a tuning aimed at the wrong +drivers can sound worse than none and can stress them. + +Two hard requirements: + +- **End in a limiter.** Peaks must stay under 0 dBFS with headroom. +- **Do not boost what the drivers cannot deliver.** The XPS 14 tuning + deliberately *cuts* 40 Hz by around 18 dB. Excursion down there buys nothing + and costs distortion. + +## Building one + +Measuring a laptop and fitting a filter-chain to it is a separate job with its own +tools, in [omarchy-audio-tuner](https://github.com/omacom-io/omarchy-audio-tuner). +It is not installed by default — almost nobody authoring a tuning, and it needs +python, ffmpeg and mpv. + +```bash +omarchy pkg add omarchy-audio-tuner +``` + +Its README is the walkthrough, and covers both cases: copying a reference that +already sounds right (how the XPS 14 tuning was made, no microphone needed), and +designing from scratch, which needs a *calibrated* measurement mic and a target +curve that measurement alone cannot give you. + +## What a tuning must report + +A tuning is not reviewable on "sounds better to me". Record these, measured, in +`tuning.conf`: + +| Field | What it is | +|---|---| +| `magnitude_rms_db` | Deviation from the reference or target it was fitted to | +| `bass_group_delay_swing_ms` | Max minus min group delay, 30–300 Hz | +| `limiter_headroom_db` | Worst-case peak against the limiter threshold | +| `dynamic_range_delta_lu` | LRA change against the reference | + +Measure electrically by capturing the physical speaker sink's monitor, which sits +upstream of the volume control, so results are independent of listening level. + +## How this fits the audio graph + +Two things about the surrounding system are worth knowing, because both caused +real bugs: + +- **Volume lives downstream of the tuning.** A tuning is a virtual sink that + becomes the default output, and changing *its* volume would alter the level + going into the processing — moving the display while the speakers stay put, and + changing the tone of anything with a compressor or limiter in it. + `omarchy-audio-output-sink` is the single definition of "which sink does this + output's volume really use": it resolves a sink through any DSP sink to the + physical one, and with no argument resolves the current default output. The + volume keys, the output switcher's OSD and the audio panel all use it, so they + cannot disagree. Resolving the *current default* rather than "whatever a tuning + fronts" is what keeps it correct when headphones or HDMI are selected while a + tuning still exists. +- **The fronted sink is hidden.** The tuning and the physical speakers both exist + in the graph, and selecting the physical one would only bypass the tuning. So + `omarchy-audio-sink-availability` reports it unavailable and + `omarchy-audio-output-switch` skips it, leaving one speaker entry in the panel. + A WirePlumber smart filter would remove the need for both — it leaves the real + device as the default output — but on PipeWire 1.6.8 / WirePlumber 0.5.15 the + graph loads and links correctly as a smart filter and then passes audio through + unprocessed. Revisit when that is understood. +- **EasyEffects cannot coexist with a tuning.** It moves any stream that follows + the default sink to its own sink, so it would grab audio back from a + filter-chain. `on` refuses while it is running rather than installing a graph + that would be bypassed. +- **The tuning's own output must not be moved.** A filter-chain's output is a + playback stream like any other, so anything rerouting "all streams" to a newly + selected output would drag the processing with it — onto headphones, or into the + tuning's own sink, which is a cycle. The tuning sets `node.dont-move`, and + `omarchy-audio-output-set-default` moves only streams that carry an + `application.name`. diff --git a/install/hardware/all.sh b/install/hardware/all.sh index 2ad7f096..27e7e37c 100644 --- a/install/hardware/all.sh +++ b/install/hardware/all.sh @@ -38,4 +38,5 @@ run_logged "$OMARCHY_INSTALL/hardware/fix-bcm43xx.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-surface-keyboard.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-yt6801-ethernet-adapter.sh" run_logged "$OMARCHY_INSTALL/hardware/fix-tuxedo-backlight.sh" +run_logged "$OMARCHY_INSTALL/hardware/speaker-tuning.sh" run_logged "$OMARCHY_INSTALL/hardware/pacman.sh" diff --git a/install/hardware/speaker-tuning.sh b/install/hardware/speaker-tuning.sh new file mode 100644 index 00000000..57a557f9 --- /dev/null +++ b/install/hardware/speaker-tuning.sh @@ -0,0 +1,10 @@ +# Install the LV2 plugins that shipped speaker tunings need. +# +# Every tuning ends in a lookahead limiter, which is an LV2 plugin. Without it +# the filter-chain graph fails to instantiate and the tuning sink silently never +# appears, so the package is a hard requirement wherever a tuning applies. It is +# only pulled in on machines that have one. + +if omarchy-audio-tuning match >/dev/null; then + omarchy-pkg-add lsp-plugins-lv2 +fi diff --git a/install/omarchy-other.packages b/install/omarchy-other.packages index e9df35d0..9cc298ba 100644 --- a/install/omarchy-other.packages +++ b/install/omarchy-other.packages @@ -62,6 +62,9 @@ linux-firmware-marvell # Dell laptop support packages dell-xps-touchpad-haptics +# Speaker tunings (LV2 limiter every tuning ends in) +lsp-plugins-lv2 + # T2 MacBook support packages apple-bcm-firmware apple-t2-audio-config diff --git a/install/user/first-run/audio-tuning.sh b/install/user/first-run/audio-tuning.sh new file mode 100755 index 00000000..1564efbe --- /dev/null +++ b/install/user/first-run/audio-tuning.sh @@ -0,0 +1,14 @@ +#!/bin/bash + +# Apply the speaker tuning for this laptop. Runs at first-run rather than at +# finalize-user time because finalize-user also runs in the ISO chroot, where +# there is no audio server: the sink the tuning has to target does not exist, so +# nothing could be written and nothing would retry -- the finalizer marks all +# shipped migrations complete on a fresh install. By first-run the session is up +# and the sink is present. +# +# A no-op on machines no tuning matches. + +set -euo pipefail + +omarchy-audio-tuning on diff --git a/migrations/1784960000.sh b/migrations/1784960000.sh new file mode 100644 index 00000000..c7cd3a90 --- /dev/null +++ b/migrations/1784960000.sh @@ -0,0 +1,10 @@ +echo "Install the speaker tuning for this laptop, if one ships for it" + +# Speaker tunings are PipeWire filter-chain drop-ins gated on a hardware +# predicate, so this is a no-op on machines without one. The limiter is an LV2 +# plugin and the graph will not instantiate without it. + +if omarchy-audio-tuning match >/dev/null 2>&1; then + omarchy-pkg-add lsp-plugins-lv2 + omarchy-audio-tuning on +fi diff --git a/shell/plugins/panels/audio/Panel.qml b/shell/plugins/panels/audio/Panel.qml index 5a29b37c..a47fdf61 100644 --- a/shell/plugins/panels/audio/Panel.qml +++ b/shell/plugins/panels/audio/Panel.qml @@ -46,7 +46,11 @@ Panel { var list = [] for (var i = 0; i < nodes.length; i++) { var n = nodes[i] - if (n && n.isStream && isPlaybackStream(n)) list.push(n) + if (!n || !n.isStream || !isPlaybackStream(n)) continue + // A tuning's output is a playback stream too, but it is the processing + // itself rather than an application, so it does not belong in the list. + if (String(n.name || "").indexOf("omarchy_speaker_tuning") === 0) continue + list.push(n) } return list } @@ -105,8 +109,39 @@ Panel { property var displayAudioSources: [] property var displayAudioStreams: [] - readonly property real outputVolume: sink && sink.audio ? sink.audio.volume : 0 - readonly property bool outputMuted: sink && sink.audio ? sink.audio.muted : false + // A DSP sink -- a speaker tuning, or EasyEffects -- can be the selected output + // without being where loudness lives: changing its volume alters the level going + // *into* the processing, so the slider would move while the speakers did not, + // and on a chain with a limiter it would change the tone as well. + // + // omarchy-audio-output-sink resolves the *current* default output through any + // such sink to the physical one, which is the same definition the volume keys + // and the output switcher use. Resolving the default (rather than "whatever a + // tuning fronts") is what keeps this correct when headphones or HDMI are + // selected while a tuning still exists. + property string volumeSinkName: "" + + readonly property var volumeSink: { + if (volumeSinkName === "" || !sink) return sink + if (volumeSinkName === String(sink.name)) return sink + for (var i = 0; i < nodes.length; i++) { + var n = nodes[i] + if (n && n.isSink && !n.isStream && String(n.name) === volumeSinkName && n.audio) + return n + } + return sink + } + + // Re-resolve whenever the selected output changes; the timer below is only a + // safety net for the tuning being applied or removed underneath us. + onSinkChanged: resolveVolumeSink() + + function resolveVolumeSink() { + if (!volumeSinkProc.running) volumeSinkProc.running = true + } + + readonly property real outputVolume: volumeSink && volumeSink.audio ? volumeSink.audio.volume : 0 + readonly property bool outputMuted: volumeSink && volumeSink.audio ? volumeSink.audio.muted : false readonly property real inputVolume: source && source.audio ? source.audio.volume : 0 readonly property bool inputMuted: source && source.audio ? source.audio.muted : false @@ -377,7 +412,7 @@ Panel { function setOutputVolume(v) { if (!sink || !sink.audio) return - sink.audio.volume = Math.max(0, Math.min(1, v)) + volumeSink.audio.volume = Math.max(0, Math.min(1, v)) } function setInputVolume(v) { @@ -386,7 +421,7 @@ Panel { } function toggleOutputMute() { - if (sink && sink.audio) sink.audio.muted = !sink.audio.muted + if (volumeSink && volumeSink.audio) volumeSink.audio.muted = !volumeSink.audio.muted } function toggleInputMute() { @@ -525,6 +560,15 @@ Panel { } } + Process { + id: volumeSinkProc + command: ["omarchy-audio-output-sink"] + stdout: StdioCollector { + waitForEnd: true + onStreamFinished: root.volumeSinkName = String(text).trim() + } + } + Timer { interval: 5000 running: root.opened @@ -533,6 +577,17 @@ Panel { onTriggered: if (!sinkAvailabilityProc.running) sinkAvailabilityProc.running = true } + // Runs whether or not the panel is open: the bar shows and scrolls the output + // volume too, so an unresolved sink there would read and change the virtual + // tuning sink instead of the speakers. + Timer { + interval: 15000 + running: true + repeat: true + triggeredOnStart: true + onTriggered: root.resolveVolumeSink() + } + Timer { id: audioModelRefreshTimer interval: 75