Reported to suit the XPS 16 as well, so the tuning now matches both machines and the directory is named for the pair rather than the one model. Gating moves from the DMI product name to the product SKU, which is what Dell keys the Cirrus speaker firmware on -- 10280db9 for the XPS 14, 10280dba for the XPS 16 -- so it identifies the speaker hardware itself instead of a marketing name. SKUs are compared as whole values, not substrings, so this cannot widen to the rest of the XPS line the way a short product-name match would: product_family on these machines is "Dell Laptops". match_sku and match_dmi are now lists, letting one tuning name every model it covers. A plain string still works as a single entry. The measurements in tuning.conf were taken on the XPS 14 (0DB9). The XPS 16 is covered on report rather than measurement, and tuning.conf and the graph both say so, because the profile's bass lift and high-pass were fitted against one machine's excursion and distortion limits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
142 lines
7.0 KiB
Markdown
142 lines
7.0 KiB
Markdown
# 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 how to recognise its hardware, plus the
|
||
`sink_pattern` its graph targets:
|
||
|
||
| Key | Matches on | Notes |
|
||
|---|---|---|
|
||
| `match_sku` | DMI product SKU, whole value | Most precise. Vendors key speaker firmware on it, so it identifies the hardware rather than a marketing name |
|
||
| `match_dmi` | DMI product name or family, substring | Convenient, but a short string widens fast — `product_family` here is `Dell Laptops` |
|
||
| `match_command` | Any predicate you name | For hardware needing a sharper test than either |
|
||
|
||
`match_sku` and `match_dmi` are lists, so one tuning can cover several models it has
|
||
been validated on:
|
||
|
||
```bash
|
||
match_sku=("0DB9" "0DBA") # XPS 14 and XPS 16
|
||
```
|
||
|
||
`sink_pattern` is required whichever method you use, 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. When a tuning covers a model
|
||
it was not measured on, say so in `tuning.conf` — the provenance fields are there to
|
||
keep that distinction visible rather than implied.
|
||
|
||
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`.
|