Add per-laptop speaker tunings, starting with the XPS 14

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, matched to the machine by DMI string and expected sink.

Adding a laptop is a directory under default/audio/tunings with two files and no
new code: matching is data. The XPS 14 DA14260 tuning included here was derived by
measuring the xps-audio-linux EasyEffects profile (MIT) and fitting a biquad chain
to it, so no impulse response or other upstream asset is redistributed. It measures
1.24 dB RMS against that reference, and matches its dynamic range within 0.1 LU --
the reference's multiband compressor turned out to contribute nothing, so a linear
chain replaces it. Bass Q is capped deliberately: a closer magnitude fit swung
group delay 31 ms across 63-80 Hz, which smears bass transients.

The graph runs as its own PipeWire client under its own config name rather than
loading into the audio daemon. The daemon only reads its 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 also contains
failure, since a malformed tuning breaks only that service.

Three things about the surrounding audio graph needed fixing for this to behave:

- Volume must live downstream of the tuning. omarchy-audio-output-sink is now the
  single definition of which sink an output's volume really uses, shared by the
  volume keys, the output switcher's OSD and the audio panel, so they cannot
  disagree. It resolves the current default output, which keeps it correct when
  headphones are selected while a tuning exists.
- The tuning's own output is a movable sink input, so rerouting "all streams" to a
  newly selected output would drag the processing onto headphones, or into the
  tuning's own sink, which is a cycle. It is pinned, and stream moves are limited
  to streams carrying an application.name.
- The physical sink a tuning fronts is not independently selectable, since picking
  it would only bypass the tuning, so it is kept out of the output list.

Applying happens at first-run, not finalize-user, because finalize-user also runs
in the ISO chroot where there is no audio server and nothing would retry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
David Heinemeier Hansson
2026-07-24 18:25:02 -07:00
co-authored by Claude Opus 5
parent 248659de5a
commit aa9f0c54c5
18 changed files with 934 additions and 33 deletions
+126
View File
@@ -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/<vendor>-<model>/
├── 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, 30300 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`.