loudness-tracked-tilt #1

Merged
steve merged 9 commits from loudness-tracked-tilt into main 2026-08-29 09:11:10 -04:00
11 changed files with 588 additions and 72 deletions

BIN
15_1/tweeters-44k.wav Normal file

Binary file not shown.

BIN
15_1/tweeters-48k.wav Normal file

Binary file not shown.

BIN
15_1/tweeters-96k.wav Normal file

Binary file not shown.

BIN
15_1/woofers-44k.wav Normal file

Binary file not shown.

BIN
15_1/woofers-48k.wav Normal file

Binary file not shown.

BIN
15_1/woofers-96k.wav Normal file

Binary file not shown.

215
INSTALL.md Normal file
View File

@@ -0,0 +1,215 @@
# Install
How to get this DSP graph running on a **MacBook Pro 15,1** under Linux (T2 /
`t2linux`, or Asahi on the Intel-T2 stack where applicable).
For what the graph *does*, see [README.md](README.md). This file is only the
mechanics of getting the pieces in place.
---
## 1. What has to be true first
This repo is **just a `graph.json`** (plus `apply.sh`). It is not a driver and
not self-contained. Three things must already exist on the machine:
| Requirement | Provided by | Why |
|---|---|---|
| Raw speaker PCM exposed by the kernel/ALSA | the `t2linux` kernel + ALSA stack | there is nothing to process otherwise |
| A hidden sink `alsa_output.platform-sound.RawSpeakers` with a filter-chain spliced in front of it | the **t2 speaker-DSP package** (`t2-linux-audio` / `t2-apple-audio-dsp`), specifically its WirePlumber drop-in `51-t2-dsp.conf` | this graph *attaches to* that spliced filter-chain; no package → no sink → nothing to apply |
| The FIR correction files at `/usr/share/t2-linux-audio/15_1/` | the same package | `graph.json` references them by absolute path (see §4) |
| The LV2 plugins the graph loads | your distro + a source build for one of them | see §3 |
If `wpctl status` shows no **"MacBook Pro 15,1 DSP Speakers"** sink and no
`alsa_output.platform-sound.RawSpeakers`, stop here and install the t2 speaker-DSP
package for your distro first — see <https://wiki.t2linux.org/guides/audio-config/>.
---
## 2. Get the repo
```sh
git clone <this-repo> mbp15-1-audio-dsp
cd mbp15-1-audio-dsp
```
---
## 3. LV2 plugin dependencies
The graph loads four LV2 plugins from three bundles. (`copy` and `convolver` are
PipeWire builtins — nothing to install.)
| Plugin URI in `graph.json` | Bundle | Package (varies by distro) |
|---|---|---|
| `http://lsp-plug.in/plugins/lv2/para_equalizer_x16_stereo` | LSP Plugins | `lsp-plugins` / `lsp-plugins-lv2` |
| `http://lsp-plug.in/plugins/lv2/mb_compressor_stereo` | LSP Plugins | ″ |
| `http://lsp-plug.in/plugins/lv2/loud_comp_mono` | LSP Plugins | ″ |
| `http://plugin.org.uk/swh-plugins/fastLookaheadLimiter` | SWH Plugins | `swh-plugins` / `lv2-swh-plugins` |
| `https://chadmed.au/bankstown` | Bankstown | **not packaged on most distros — build from source** |
### 3a. LSP + SWH (from your package manager)
```sh
# Fedora / Fedora Asahi Remix
sudo dnf install lsp-plugins swh-plugins
# Arch / t2linux
sudo pacman -S lsp-plugins swh-plugins # or AUR: lsp-plugins-lv2
# Debian / Ubuntu
sudo apt install lsp-plugins swh-plugins
```
Package names drift — if the above miss, search: `dnf search lsp`,
`pacman -Ss lsp-plugins`, `apt-cache search swh`. The authority is whether the
URIs resolve (§3c), not the package name.
### 3b. Bankstown (source build)
Bankstown (`virtualbass` in the graph) is chadmed's psychoacoustic bass plugin.
On Fedora Asahi Remix it ships in the `asahi-audio` stack; everywhere else,
build it:
```sh
# needs: rust/cargo, clang, lv2 headers, git
git clone https://github.com/chadmed/bankstown
cd bankstown
make # runs: cargo build --release
# install the bundle. LIBDIR defaults to /usr/lib64 — override on distros
# that use /usr/lib (Arch, Debian/Ubuntu):
sudo make install # Fedora
sudo make install LIBDIR=/usr/lib # Arch, Debian, Ubuntu
# → installs to $LIBDIR/lv2/bankstown.lv2/
cd ..
```
Per-user install (no sudo) also works — copy the built
`target/release/libbankstown.so` → `~/.lv2/bankstown.lv2/bankstown.so` alongside
`bankstown.ttl` and `manifest.ttl` from the repo.
### 3c. Verify all four URIs resolve
```sh
for uri in \
http://lsp-plug.in/plugins/lv2/para_equalizer_x16_stereo \
http://lsp-plug.in/plugins/lv2/mb_compressor_stereo \
http://lsp-plug.in/plugins/lv2/loud_comp_mono \
http://plugin.org.uk/swh-plugins/fastLookaheadLimiter \
https://chadmed.au/bankstown ; do
lv2ls | grep -qxF "$uri" && echo "ok $uri" || echo "MISSING $uri"
done
```
Every line must say `ok`. `lv2ls` is from `lilv` (`lilv-utils` / `lilv`).
---
## 4. FIR correction files
`graph.json` references them by **absolute path**:
```
/usr/share/t2-linux-audio/15_1/tweeters-44k.wav tweeters-48k.wav tweeters-96k.wav
/usr/share/t2-linux-audio/15_1/woofers-44k.wav woofers-48k.wav woofers-96k.wav
```
```sh
ls -l /usr/share/t2-linux-audio/15_1/*.wav
```
These are **not vendored in this repo** and the paths are **deliberately not
relative**:
- The t2 speaker-DSP package installs them to this fixed FHS path on every
distro, and you already need that package for the sink to exist at all (§1),
so the absolute path is stable wherever the prerequisite is met.
- PipeWire's convolver resolves a non-absolute `filename` against the process
working directory, which for a WirePlumber-spawned service is unpredictable
(`/` or `$HOME`). Relative paths would be *less* portable, not more.
If you have your own recalibrated FIRs, drop them at that path (or edit the six
`filename` entries in `graph.json` to point at yours) before §5.
---
## 5. Apply
```sh
./apply.sh
```
This does:
1. `sudo cp graph.json /usr/share/t2-linux-audio/15_1/graph.json`
2. `systemctl --user restart wireplumber`
Optional sanity check first (the tool is `python3-json` / stdlib):
```sh
python3 -m json.tool graph.json > /dev/null && echo "graph.json OK"
```
---
## 6. Confirm it loaded
```sh
wpctl status | grep -i "DSP Speakers"
pw-cli ls Node | grep -i t2-151-speakers
```
Select **"MacBook Pro 15,1 DSP Speakers"** as the output (`wpctl set-default
<id>`, or your DE's sound settings), then play something.
To watch the plugins actually run / spot xruns:
```sh
pw-top # look for the filter-chain node, check for XRUN
```
---
## 7. Pick a sound profile
`user_eq` is the first node in the graph and the only block meant for
hand-editing — an 8-band tone control that defaults flat (= the reference
voicing). Paste one of the preset rows from
[README.md § User preference EQ](README.md#user-preference-eq) into its `control`
block and re-run `./apply.sh`.
---
## 8. After a system update
A `t2-linux-audio` package update **overwrites**
`/usr/share/t2-linux-audio/15_1/graph.json` and silently reverts to the stock
graph. Re-run `./apply.sh` afterward. (Plugin packages updating is fine — the
graph only cares that the URIs still resolve.)
---
## 9. Revert to stock
```sh
sudo cp /path/to/t2-apple-audio-dsp/configs/15_1/graph.json \
/usr/share/t2-linux-audio/15_1/graph.json
systemctl --user restart wireplumber
```
Or reinstall the t2 speaker-DSP package.
---
## Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| No "DSP Speakers" sink after apply | t2 speaker-DSP package / `51-t2-dsp.conf` not installed | §1 |
| Sink present, but silent / falls back to another device | a plugin URI failed to load, so the whole filter-chain fails | §3c — find the `MISSING` line |
| `wireplumber` won't start after apply | malformed `graph.json` | `python3 -m json.tool graph.json`; `journalctl --user -u wireplumber -b` |
| Works, but no bass enhancement | `bankstown` (`virtualbass`) not loaded | §3b, then §3c |
| Distortion when loud | drive too high for your unit — see [README.md § Tuning knobs](README.md#tuning-knobs) | lower `wlim.limit`, or the `user_eq` bass bands |
| Reverted itself after an update | expected — see §8 | re-run `./apply.sh` |

237
README.md
View File

@@ -22,21 +22,65 @@ All structural elements are unchanged: node names, FIR `.wav` paths
output, allowed rates 48000/44100, and the `capture.volumes` mapping that ties
the sink volume slider to the loudness-compensation stage.
## Prior art — why this fork
Two recurring complaints about the upstream chains, from their own issue
trackers and docs, motivated the changes here:
**It sounds thin / not warm enough.** `asahi-audio` deliberately targets *"a
mostly flat response ... without adding an excessive amount of colour"* and
explicitly rejects Apple's *"exaggerated Harman curve."* Flat magnitude is
equal-energy-per-*Hz*, which reads as bass-light against pink-balanced program
material (see [Design rationale](#why-the-voicing-curve-exists--and-why-upstream-sounds-thin)).
Users keep asking for the warmth back:
- [asahi-audio #31](https://github.com/AsahiLinux/asahi-audio/issues/31) — request for a bass/treble ("smiley") curve + a guide to change it system-wide; closed with no documented answer.
- [asahi-audio #93](https://github.com/AsahiLinux/asahi-audio/issues/93) — *"Lacks proper bass and high frequencies, sounding flat, thin, and muddy";* closed as not planned.
- [t2-apple-audio-dsp #21](https://github.com/lemmyg/t2-apple-audio-dsp/issues/21) (direct upstream) — *"Quality is good but volume is quite low even at maximum volume."*
- [Asahi audio docs](https://asahilinux.org/docs/sw/audio-userspace/) known issues: the 13″ MBA EQ *"might be a bit harsh on the treble."*
**It distorts when loud, because there is no output limiter.** The Asahi docs
state plainly: *"There is no final limiter/compressor in the current DSP
chains"* (only an input compressor), so *"content in high-gain regions of the EQ
curve might cause distortion or clipping."* They also flag *"the 200 Hz region"*
for distortion risk and note `bankstown` is *"prone to saturation artifacts at
high volumes."*
- [asahi-audio #22](https://github.com/AsahiLinux/asahi-audio/issues/22) — j313 distortion at 100 %; traced to convolver gain being too hot, worked around by dropping it to ~0.6.
- [asahi-audio #42](https://github.com/AsahiLinux/asahi-audio/issues/42) — J474 distortion above 72 % volume.
- [asahi-audio #91](https://github.com/AsahiLinux/asahi-audio/issues/91) — j313 *"severe speaker distortion"* at 55 %; `speakersafetyd` logs nothing, i.e. it's in the signal processing, not the protection model.
This fork's answers, point by point:
| Upstream complaint | Change here |
|---|---|
| flat / thin / "not warm enough" | EQ bass tilt (≈ +3 dB/octave per-Hz) ahead of the dynamics |
| no final limiter → clipping in high-gain EQ regions | `limiter` + post-FIR `wlim` / `tlim` on the real driver signal |
| 200 Hz distortion risk | fine multiband split through the low-mids |
| `bankstown` saturates at volume | EQ `g_in 0.5` pad + `sat_second` / `sat_third` reduced |
| convolver gain too hot at 100 % (#22) | FIR kept near unity; drive lives in the dynamically-governed EQ |
| "volume too low at max" (#21) | `g_out` makeup + loudness maximisation into the limiters |
## Signal chain
```
in ─▶ equalizer ─▶ virtualbass ─▶ multiband_compressor ─▶ limiter ─▶ ell/elr ─▶ copyL/R ─┬▶ convLT/convRT (tweeter FIR) ─▶ out
(LSP x16) (bankstown) (LSP mb_comp x4) (fastLookahead) (loud_comp) └▶ convLW/convRW (woofer FIR) ─▶ out
in ─▶ user_eq ─▶ equalizer ─▶ virtualbass ─▶ multiband_compressor ─▶ limiter ─▶ ell/elr ─▶ copyL/R ─┬▶ convLT/convRT ─▶ tlim ─▶ out
(LSP x16) (LSP x16) (bankstown) (LSP mb_comp x8) (fastLookahead) (loud_comp) │ (tweeter FIR) (limit)
user prefs voicing └▶ convLW/convRW ─▶ wlim ─▶ out
(woofer FIR) (limit)
```
## Changes vs. upstream `15_1/graph.json`
| Stage | Upstream | This fork | Purpose |
|---|---|---|---|
| Pre-EQ | *none* | LSP `para_equalizer_x16_stereo`, `g_in 0.7` | Warm/bass-forward voicing curve |
| EQ band 0 | n/a | 24 dB/oct **high-pass @ 50 Hz** (`ft_0 2`, `s_0 1`) | Keep subsonic energy off the small woofers |
| Dynamics | single-band `compressor_stereo` | `mb_compressor_stereo`, 4 bands (xover 120/400/1000 Hz) | See rationale below |
| Woofer FIR gain | `1.0` | `1.5` (`convLW` / `convRW`) | More low-end output (overdrive) |
| Pre-EQ | *none* | LSP `para_equalizer_x16_stereo`, `g_in 0.5` | Warm/bass-forward voicing curve |
| Gain staging | n/a | EQ `g_in` padded to 0.5 (≈ −6 dB); the ~3 dB net loss restored at `multiband_compressor.g_out 1.4`, with band thresholds `al_*` scaled to match | Run the EQ + `virtualbass` cooler; recover level only after the compressor detectors, right before the limiter |
| EQ band 0 | n/a | 48 dB/oct **high-pass @ 60 Hz** (`ft_0 2`, `s_0 3`) | Kill everything below the woofer's usable range — −3 dB at 60 Hz, ≈ −30 dB by 40 Hz |
| Dynamics | single-band `compressor_stereo` | `mb_compressor_stereo`, **8 bands** (xover 60/80/100/130/160/200/500 Hz), Modern mode | Per-band peak control that doesn't duck the mids on a bass beat; the woofer range is split finely (five bands 60–200 Hz) so the 60–80 and 80–100 Hz octaves can be clamped harder than the rest |
| Woofer FIR gain | `1.0` | `1.15` (`convLW` / `convRW`) | Small trim only; the low-end drive now lives in the EQ bass bells (upstream, so it passes through the compressor + limiters instead of being an uncontrolled post-gain) |
| Bass EQ bells | n/a | 31.5–200 Hz boosted ~+2.3 dB above the base warm tilt to offset the FIR gain reduction | Same woofer output level, but dynamically governed |
| Post-FIR limiters | *none* | `wlim` (−2 dB) after woofer FIR, `tlim` (−1 dB) after tweeter FIR | Hard ceiling on the *actual* driver signal — excursion / clip backstop |
Everything else is byte-identical to upstream.
@@ -45,53 +89,185 @@ Everything else is byte-identical to upstream.
**Goal:** mild-volume music should sound warm and full; bass-heavy material
should not distort the woofers or duck the midrange.
### Why the voicing curve exists — and why upstream sounds thin
Music is mastered for systems with a roughly **equal-energy-per-octave** (pink)
balance and the headroom to reproduce it. The forked FIR filters (`asahi-audio` /
`t2-apple-audio-dsp`) correct the drivers to **flat magnitude = equal energy per
_Hz_** — measurement-correct, but each octave down then carries the same per-Hz
energy across half the bandwidth, so it lands **bass-light** ("not warm enough").
Matching the per-octave balance needs ≈ **+3 dB/octave** of per-Hz lift toward the
lows — that is what the EQ bass bells are for.
That tilt can't be static. The two things it can break are different above and
below **~150 Hz**:
| Region | Failure mode | Scaling | Guarded by |
|---|---|---|---|
| **< ~150 Hz** | woofer **over-excursion** — cone bottoms out | displacement ∝ 1/f² ≈ **+12 dB/octave** for constant SPL | fine multiband split (one limiter per bass octave, held release) + the 20 Hz subsonic HPF; `virtualbass` supplies deep sub as harmonics so the cone never has to move for it |
| **> ~150 Hz** | **over-voltage** — demanded level exceeds the amp's max swing to the cone | ≈ flat (voltage/thermal, not displacement) | multiband peak control per band, then `limiter` / `wlim` / `tlim` as backstop |
The `fastLookaheadLimiter` stages are a *second* line of defence only, because
they are **broadband**: when one triggers it ducks every frequency at once, so a
loud trombone transient pulls the violins down with it. The multiband compressor
is the *first* line precisely because its gain reduction stays inside the
offending band — the more work it does, the less the broadband limiters engage
and the cleaner the result. Net: full warmth at low level, graceful flattening
toward the FIR's flat-per-Hz curve as it gets loud, with cheap pitch-reinforcing
harmonic distortion traded for ugly (and mechanically risky) excursion
distortion.
### Warm at low volume
- **Warm at low volume** is handled two ways:
- `ell` / `elr` (`loud_comp_mono`) is a true ISO-226 equal-loudness
compensator. The sink volume slider feeds `ell:volume` / `elr:volume`
(cubic, −65→0 dB), so bass/treble lift automatically increases as you turn
the volume down and recedes as you turn it up.
- The static EQ bells (31.5–125 Hz, +1.4 to +2.5 dB) add a fixed warmth tilt.
- The static EQ bells (31.5–125 Hz) add a fixed warmth tilt. Note LSP's `g_*`
ports are **linear amplitude, not dB** — `g_3 = 3.26` is ≈ +10 dB, offset by
`g_in 0.5` (≈ −6 dB). This is a hot bass shelf on purpose; the dynamics
stages below exist to keep it safe when loud. The bass boost lives here
rather than in the woofer FIR gain (kept near unity at 1.15) so it passes
through the compressor and limiters and is dynamically controlled, instead
of being a fixed post-everything gain that only `wlim` can catch.
- **Gain staging.** `g_in` on the EQ is padded to 0.5 so the boosted bands and
`virtualbass`'s saturation stages run with headroom rather than near/over
0 dBFS. The signal path is 32-bit float end-to-end (real clipping only happens
at the ALSA sink), but a cooler operating point keeps `virtualbass` from being
over-driven and keeps every plugin's internal detectors honest. The ~3 dB net
level loss is put back at `multiband_compressor.g_out` (1.0 → 1.4) — *after*
the band detectors, immediately before the main limiter — and the band
thresholds `al_*` were scaled by the same factor so the compressor behaves
exactly as before, just at a lower internal level.
- **Bass beats don't distort** is handled by multiband, not broadband,
compression. A single-band compressor keyed off a kick drum applies gain
reduction to the *whole* spectrum — vocals and mids pump on every beat, and
loud bass can shut the woofers down across all frequencies. The multiband
confines the gain reduction to band 0 (< 120 Hz): ~10:1 above ≈ −17 dB with a
10 ms attack, so the sub band is clamped to a near-fixed ceiling while the
midrange stays open.
loud bass can shut the woofers down across all frequencies. The 8-band
multiband keeps each band responding only to its own energy. The seven bands
below ~500 Hz — where over-excursion and boom live — are effectively limiters:
| Band | Range | `cr` | `kn` | `al` (≈ dB) | Note |
|---|---|---|---|---|---|
| 0 | < 60 Hz | 50 | 0.10 | 0.093 (−21) | catch band — mostly empty now that the EQ HPFs hard at 60 Hz |
| 1 | 60–80 Hz | 50 | 0.06 | 0.078 (−22) | hardest clamp — lowest ceiling, highest ratio, widest (softest) knee so the 50:1 eases in |
| 2 | 80–100 Hz | 30 | 0.12 | 0.095 (−20) | clamped harder than the rest, a step gentler than 60–80 |
| 3 | 100–130 Hz | 20 | 0.20 | 0.120 (−18) | midbass, as the old 90–200 band |
| 4 | 130–160 Hz | 18 | 0.24 | 0.130 (−18) | |
| 5 | 160–200 Hz | 16 | 0.28 | 0.140 (−17) | |
| 6 | 200–500 Hz | 15 | 0.30 | 0.159 (−16) | low-mid body, as the old 200–500 band |
| 7 | 500 Hz+ | 5 | 0.40 | 0.284 (−11) | single gentle band above 500 Hz (was three: 500/1500/5000) |
(Every `mb_compressor` port is documented in
[mb-compressor-params.md](mb-compressor-params.md).)
Band 1 (60–80 Hz) and band 2 (80–100 Hz) carry the lowest ceilings and the
highest ratios, so the two octaves that drive woofer excursion hardest are
clamped ahead of everything else — their wide knees (`kn` down at `0.06` /
`0.12`, i.e. −24 / −18 dB) make that heavy ratio ramp in gradually rather than
snap. Band 7 limits gently (`cr 5`) and does not
move because of a kick drum. The EQ is left untouched, so anything below the
thresholds — i.e. quiet listening — passes with its full warm tilt intact;
only loud peaks are clamped.
- **Woofers can't bottom out.** The woofer FIR is near unity now (`1.15`), but
`loud_comp` still adds bass gain after the main limiter, so the very last
stage is unguarded. `wlim` / `tlim` are `fastLookaheadLimiter` instances placed
*after* the convolvers, so they clamp the real signal the drivers see
regardless of upstream gain. `wlim` at −2 dB is the mechanical-excursion
backstop; `tlim` at −1 dB protects the tweeters and keeps the two paths
time-aligned (equal lookahead latency — no comb filtering at the crossover).
- **`virtualbass` (bankstown)** synthesizes harmonics of the bass in the
60–150 Hz window, so the ear perceives low end the driver never has to
physically produce — the psychoacoustic counterpart to the 50 Hz high-pass.
physically produce — the psychoacoustic counterpart to the 60 Hz high-pass.
## User preference EQ
`user_eq` is the **first node in the graph** and the only block meant for
hand-editing — a plain 8-band tone control for matching the sound to content
type. It defaults **flat** (every `g_*` = `1.0`), which *is* the reference
voicing; editing it never touches the calibrated `equalizer` / dynamics below.
Because it sits ahead of the compressor and limiters, even an aggressive preset
is dynamically governed — it can't clip or over-excurse, it just gets
compressed if pushed hard.
| Band | `f` | Type | Region |
|---|---|---|---|
| 0 | 70 Hz | low shelf | sub weight / rumble |
| 1 | 110 Hz | bell | bass punch |
| 2 | 220 Hz | bell | warmth / boom |
| 3 | 450 Hz | bell | body / mud |
| 4 | 1 kHz | bell | mids / nasal |
| 5 | 2.5 kHz | bell | presence / attack |
| 6 | 6 kHz | bell | detail / sibilance |
| 7 | 10 kHz | high shelf | air |
Edit the `g_*` values in the `user_eq` `control` block. **Linear, not dB**
(`+3 dB ≈ 1.41`, `−3 dB ≈ 0.71`). Keep each between `0.5` (−6 dB) and `2.0`
(+6 dB). Run `./apply.sh` after.
### Presets — the 8 `g_*` values, `g_0`…`g_7`
| Preset | 70 | 110 | 220 | 450 | 1k | 2.5k | 6k | 10k |
|---|---|---|---|---|---|---|---|---|
| **Reference** (flat) | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 | 1.00 |
| Rock / Pop | 1.00 | 1.26 | 1.00 | 0.94 | 1.00 | 1.12 | 1.19 | 1.12 |
| Classical / Acoustic | 1.00 | 1.00 | 1.06 | 1.00 | 1.00 | 1.00 | 1.12 | 1.12 |
| Electronic / Hip-Hop | 1.26 | 1.19 | 1.00 | 0.94 | 1.00 | 1.00 | 1.06 | 1.00 |
| Movie — dialogue | 0.84 | 0.94 | 1.00 | 1.06 | 1.19 | 1.19 | 1.06 | 1.00 |
| Movie — action | 1.41 | 1.12 | 1.00 | 1.00 | 1.00 | 1.06 | 1.12 | 1.12 |
| Late-night (low level) | 0.63 | 0.79 | 1.00 | 1.00 | 1.06 | 1.12 | 1.00 | 0.94 |
## Tuning knobs
In `graph.json`, `multiband_compressor.control`:
If the woofers still bottom out or anything distorts, in order of preference:
| Key | Now | Effect |
|---|---|---|
| `al_0` | `0.142` (≈ −17 dB) | Bass clamp threshold — lower = clamps sooner |
| `cr_0` | `10.0` | Bass ratio — raise toward 20:1+ for true limiting |
| `at_0` | `10.0` ms | Lower to ~5 ms if kick transients still poke through |
| `ce_1` / `ce_2` / `ce_3` | `1` | Set to `0` to disable bands 1–3 and compress **only** the bass |
| `sf_1` | `120.0` Hz | Lower to ~90–100 Hz to keep band 0 off the low mids |
| Where | Key | Now | Effect |
|---|---|---|---|
| `wlim.control` | `limit` | `-2` | Lower to `-3` / `-4` — hard woofer ceiling, dB |
| `equalizer.control` | `g_1`–`g_5` | `1.82 / 2.48 / 3.26 / 2.61 / 1.75` | The bass boost — lower all five proportionally for less low-end drive overall |
| `convLW` / `convRW` `config` | `gain` | `1.15` | FIR trim; leave it — adjust the EQ bells instead |
| `multiband_compressor.control` | `al_0` | `0.093` (≈ −21 dB) | Lower = < 60 Hz band clamps sooner |
| `multiband_compressor.control` | `al_1` / `al_2` | `0.078` / `0.095` | The 60–80 / 80–100 Hz ceilings — lower these two to pull the peak down further in those octaves |
| `multiband_compressor.control` | `cr_1` / `kn_1` | `50.0` / `0.06` | 60–80 Hz clamp — highest ratio, widest knee. Less aggressive: lower `cr_1` toward `20`. Sharper corner / less low-level squash: raise `kn_1` toward `1.0` |
| `multiband_compressor.control` | `cr_2` / `kn_2` | `30.0` / `0.12` | 80–100 Hz clamp — a step gentler than 60–80 |
| `multiband_compressor.control` | `cr_0` | `50.0` | Already near brick-wall; leave it |
| `multiband_compressor.control` | `at_0` | `4.0` ms | Lower toward ~3 ms if kick transients poke through (adds some LF harmonic distortion) |
In `graph.json`, woofer convolvers (`convLW` / `convRW`): `gain 1.5` sets actual
woofer SPL — the dynamics ceiling won't save you if this is too hot; back toward
`1.35` if heavy bass still distorts.
If the midrange sounds over-controlled / lifeless, raise `al_3`–`al_7` (higher =
those bands stay out of the way longer) or lower their ratios `cr_3`–`cr_7`
toward `2.0`. Band 6 (200–500 Hz) at `cr 15` reaches into low-mid body — if male
vocals / snare sound boxy or thin, drop `cr_6` back toward `8`.
**Gain staging.** To run the EQ / `virtualbass` even cooler, lower
`equalizer.g_in` further (e.g. `0.4`, `0.35`) and put the same factor back into
`multiband_compressor.g_out`, then scale `al_0`–`al_7` by that factor so the
compressor keeps the same behaviour. If bass feels thinner after the pad, nudge
`virtualbass.amt` up (`1.0` → `1.2`) rather than raising `g_in` back.
`tlim.control` `limit` (`-1`) is the tweeter ceiling — rarely needs touching, but
**keep `tlim` present even if you disable it** (`limit` high), because it also
holds the tweeter/woofer time alignment.
## Install
Full instructions — prerequisites, the LV2 plugin dependencies (LSP, SWH, and a
source build of Bankstown), the FIR files, verification and troubleshooting —
are in **[INSTALL.md](INSTALL.md)**.
Short version, once the prerequisites are met:
```sh
./apply.sh
```
This copies `graph.json` to `/usr/share/t2-linux-audio/15_1/graph.json` (needs
`sudo`) and restarts WirePlumber (`systemctl --user restart wireplumber`).
Requires the `t2-linux-audio` / `t2-apple-audio-dsp` package to already be
installed (it provides the FIR `.wav` files, `51-t2-dsp.conf`, and the
`mic.json` graph).
copies `graph.json` to `/usr/share/t2-linux-audio/15_1/graph.json` (needs `sudo`)
and restarts WirePlumber. Requires the `t2-linux-audio` / `t2-apple-audio-dsp`
package (FIR `.wav` files, `51-t2-dsp.conf`, `mic.json`) and the LV2 plugins the
graph loads.
## Revert
@@ -102,4 +278,5 @@ systemctl --user restart wireplumber
```
Note: a `t2-linux-audio` package update will overwrite the installed file and
silently revert these changes — re-run `./apply.sh` afterward.
silently revert these changes — re-run `./apply.sh` afterward. See
[INSTALL.md § 8](INSTALL.md#8-after-a-system-update).

BIN
cap.wav Normal file

Binary file not shown.

View File

@@ -3,6 +3,22 @@
"media.name": "MacBook Pro 15,1 DSP Speakers",
"filter.graph": {
"nodes": [
{
"type": "lv2",
"plugin": "http://lsp-plug.in/plugins/lv2/para_equalizer_x16_stereo",
"name": "user_eq",
"control": {
"enabled": 1, "mode": 0, "g_in": 1.0, "g_out": 1.0,
"ft_0": 5, "f_0": 70.0, "g_0": 1.0, "q_0": 0.7, "s_0": 0,
"ft_1": 1, "f_1": 110.0, "g_1": 1.0, "q_1": 1.0,
"ft_2": 1, "f_2": 220.0, "g_2": 1.0, "q_2": 1.0,
"ft_3": 1, "f_3": 450.0, "g_3": 1.0, "q_3": 1.0,
"ft_4": 1, "f_4": 1000.0, "g_4": 1.0, "q_4": 1.0,
"ft_5": 1, "f_5": 2500.0, "g_5": 1.0, "q_5": 1.0,
"ft_6": 1, "f_6": 6000.0, "g_6": 1.0, "q_6": 1.0,
"ft_7": 3, "f_7": 10000.0, "g_7": 1.0, "q_7": 0.7, "s_7": 0
}
},
{
"type": "lv2",
"plugin": "http://lsp-plug.in/plugins/lv2/para_equalizer_x16_stereo",
@@ -10,24 +26,24 @@
"control": {
"enabled": 1,
"mode": 0,
"g_in": 0.7,
"g_in": 0.5,
"g_out": 1.0,
"ft_0": 2, "f_0": 50.0, "g_0": 1.0, "q_0": 1.41, "s_0": 1, "xm_0": 0, "fm_0": 0,
"ft_1": 1, "f_1": 31.5, "g_1": 1.4, "q_1": 1.41, "s_1": 0, "xm_1": 0, "fm_1": 0,
"ft_2": 1, "f_2": 50.0, "g_2": 1.9, "q_2": 1.41, "s_2": 0, "xm_2": 0, "fm_2": 0,
"ft_3": 1, "f_3": 80.0, "g_3": 2.5, "q_3": 1.41, "s_3": 0, "xm_3": 0, "fm_3": 0,
"ft_4": 1, "f_4": 125.0, "g_4": 2.0, "q_4": 1.41, "s_4": 0, "xm_4": 0, "fm_4": 0,
"ft_5": 1, "f_5": 200.0, "g_5": 1.5, "q_5": 1.41, "s_5": 0, "xm_5": 0, "fm_5": 0,
"ft_6": 1, "f_6": 315.0, "g_6": 1.35, "q_6": 1.41, "s_6": 0, "xm_6": 0, "fm_6": 0,
"ft_7": 1, "f_7": 500.0, "g_7": 1.19, "q_7": 1.41, "s_7": 0, "xm_7": 0, "fm_7": 0,
"ft_8": 1, "f_8": 800.0, "g_8": 1.09, "q_8": 1.41, "s_8": 0, "xm_8": 0, "fm_8": 0,
"ft_9": 1, "f_9": 1250.0, "g_9": 0.85, "q_9": 1.41, "s_9": 0, "xm_9": 0, "fm_9": 0,
"ft_10": 1, "f_10": 2000.0, "g_10": 0.8, "q_10": 1.41, "s_10": 0, "xm_10": 0, "fm_10": 0,
"ft_11": 1, "f_11": 3150.0, "g_11": 0.8, "q_11": 1.41, "s_11": 0, "xm_11": 0, "fm_11": 0,
"ft_12": 1, "f_12": 5000.0, "g_12": 0.8, "q_12": 1.41, "s_12": 0, "xm_12": 0, "fm_12": 0,
"ft_13": 1, "f_13": 8000.0, "g_13": 0.8, "q_13": 1.41, "s_13": 0, "xm_13": 0, "fm_13": 0,
"ft_14": 1, "f_14": 12500.0, "g_14": 0.82, "q_14": 1.41, "s_14": 0, "xm_14": 0, "fm_14": 0,
"ft_15": 1, "f_15": 20000.0, "g_15": 0.97, "q_15": 1.41, "s_15": 0, "xm_15": 0, "fm_15": 0
"ft_0": 2, "f_0": 20.0, "g_0": 1.0, "q_0": 1.41, "s_0": 3, "xm_0": 0, "fm_0": 0,
"ft_1": 1, "f_1": 31.5, "g_1": 2.51, "q_1": 1.41, "s_1": 0, "xm_1": 0, "fm_1": 0,
"ft_2": 1, "f_2": 50.0, "g_2": 2.24, "q_2": 1.41, "s_2": 0, "xm_2": 0, "fm_2": 0,
"ft_3": 1, "f_3": 80.0, "g_3": 2.00, "q_3": 1.41, "s_3": 0, "xm_3": 0, "fm_3": 0,
"ft_4": 1, "f_4": 125.0, "g_4": 1.78, "q_4": 1.41, "s_4": 0, "xm_4": 0, "fm_4": 0,
"ft_5": 1, "f_5": 200.0, "g_5": 1.58, "q_5": 1.41, "s_5": 0, "xm_5": 0, "fm_5": 0,
"ft_6": 1, "f_6": 315.0, "g_6": 1.41, "q_6": 1.41, "s_6": 0, "xm_6": 0, "fm_6": 0,
"ft_7": 1, "f_7": 500.0, "g_7": 1.26, "q_7": 1.41, "s_7": 0, "xm_7": 0, "fm_7": 0,
"ft_8": 1, "f_8": 800.0, "g_8": 1.12, "q_8": 1.41, "s_8": 0, "xm_8": 0, "fm_8": 0,
"ft_9": 1, "f_9": 1250.0, "g_9": 1.06, "q_9": 1.41, "s_9": 0, "xm_9": 0, "fm_9": 0,
"ft_10": 1, "f_10": 2000.0, "g_10": 1.0, "q_10": 1.41, "s_10": 0, "xm_10": 0, "fm_10": 0,
"ft_11": 1, "f_11": 3150.0, "g_11": 1.0, "q_11": 1.41, "s_11": 0, "xm_11": 0, "fm_11": 0,
"ft_12": 1, "f_12": 5000.0, "g_12": 0.94, "q_12": 1.41, "s_12": 0, "xm_12": 0, "fm_12": 0,
"ft_13": 1, "f_13": 8000.0, "g_13": 0.94, "q_13": 1.41, "s_13": 0, "xm_13": 0, "fm_13": 0,
"ft_14": 1, "f_14": 12500.0, "g_14": 1.0, "q_14": 1.41, "s_14": 0, "xm_14": 0, "fm_14": 0,
"ft_15": 1, "f_15": 20000.0, "g_15": 1.0, "q_15": 1.41, "s_15": 0, "xm_15": 0, "fm_15": 0
}
},
{
@@ -37,8 +53,8 @@
"control": {
"bypass": 0,
"amt": 1.0,
"sat_second": 1.5,
"sat_third": 2.0,
"sat_second": 1.0,
"sat_third": 1.0,
"blend": 1.0,
"ceil": 150.0,
"floor": 60.0
@@ -50,20 +66,26 @@
"name": "multiband_compressor",
"control": {
"enabled": 1,
"mode": 0,
"mode": 1,
"g_in": 1.0,
"g_out": 1.0,
"g_out": 1.15,
"g_dry": 0.0001,
"g_wet": 1.0,
"cbe_1": 1, "sf_1": 120.0,
"cbe_2": 1, "sf_2": 400.0,
"cbe_3": 1, "sf_3": 1000.0,
"cbe_4": 0, "cbe_5": 0, "cbe_6": 0, "cbe_7": 0,
"ce_0": 1, "at_0": 10.0, "rt_0": 100.0, "cr_0": 10.0, "kn_0": 0.15, "al_0": 0.142, "mk_0": 1.5, "bth_0": 1.0, "bsa_0": 1.0, "scm_0": 0,
"ce_1": 1, "at_1": 20.0, "rt_1": 200.0, "cr_1": 3.0, "kn_1": 0.5, "al_1": 0.226, "mk_1": 2.0, "bth_1": 1.0, "bsa_1": 1.0, "scm_1": 0,
"ce_2": 1, "at_2": 20.0, "rt_2": 200.0, "cr_2": 3.0, "kn_2": 0.5, "al_2": 0.226, "mk_2": 2.0, "bth_2": 1.0, "bsa_2": 1.0, "scm_2": 0,
"ce_3": 1, "at_3": 20.0, "rt_3": 200.0, "cr_3": 1.5, "kn_3": 0.5, "al_3": 0.398, "mk_3": 1.41, "bth_3": 1.0, "bsa_3": 1.0, "scm_3": 0,
"ce_4": 0, "ce_5": 0, "ce_6": 0, "ce_7": 0
"cbe_1": 1, "sf_1": 60.0,
"cbe_2": 1, "sf_2": 80.0,
"cbe_3": 1, "sf_3": 100.0,
"cbe_4": 1, "sf_4": 130.0,
"cbe_5": 1, "sf_5": 160.0,
"cbe_6": 1, "sf_6": 200.0,
"cbe_7": 1, "sf_7": 500.0,
"ce_0": 1, "at_0": 5.0, "rt_0": 100.0, "cr_0": 30.0, "kn_0": 0.10, "al_0": 0.090, "mk_0": 1.0, "bth_0": 1.0, "bsa_0": 1.0, "scm_0": 0,
"ce_1": 1, "at_1": 5.0, "rt_1": 90.0, "cr_1": 20.0, "kn_1": 0.12, "al_1": 0.095, "mk_1": 1.2, "bth_1": 1.0, "bsa_1": 1.0, "scm_1": 0,
"ce_2": 1, "at_2": 4.0, "rt_2": 80.0, "cr_2": 16.0, "kn_2": 0.15, "al_2": 0.105, "mk_2": 1.3, "bth_2": 1.0, "bsa_2": 1.0, "scm_2": 0,
"ce_3": 1, "at_3": 4.0, "rt_3": 70.0, "cr_3": 12.0, "kn_3": 0.18, "al_3": 0.115, "mk_3": 1.4, "bth_3": 1.0, "bsa_3": 1.0, "scm_3": 0,
"ce_4": 1, "at_4": 4.0, "rt_4": 60.0, "cr_4": 10.0, "kn_4": 0.20, "al_4": 0.125, "mk_4": 1.5, "bth_4": 1.0, "bsa_4": 1.0, "scm_4": 0,
"ce_5": 1, "at_5": 3.5, "rt_5": 50.0, "cr_5": 8.0, "kn_5": 0.25, "al_5": 0.140, "mk_5": 1.5, "bth_5": 1.0, "bsa_5": 1.0, "scm_5": 0,
"ce_6": 1, "at_6": 3.0, "rt_6": 40.0, "cr_6": 6.0, "kn_6": 0.30, "al_6": 0.165, "mk_6": 1.4, "bth_6": 1.0, "bsa_6": 1.0, "scm_6": 0,
"ce_7": 1, "at_7": 2.5, "rt_7": 30.0, "cr_7": 4.0, "kn_7": 0.40, "al_7": 0.300, "mk_7": 1.2, "bth_7": 1.0, "bsa_7": 1.0, "scm_7": 0
}
},
{
@@ -73,7 +95,7 @@
"control": {
"ingain": 0,
"limit": -1,
"release": 0.8
"release": 0.35
}
},
{
@@ -117,7 +139,7 @@
"/usr/share/t2-linux-audio/15_1/tweeters-96k.wav"
],
"channel": 0,
"gain": 1.0
"gain": 1.1
}
},
{
@@ -131,7 +153,7 @@
"/usr/share/t2-linux-audio/15_1/tweeters-96k.wav"
],
"channel": 0,
"gain": 1.0
"gain": 1.1
}
},
{
@@ -145,7 +167,7 @@
"/usr/share/t2-linux-audio/15_1/woofers-96k.wav"
],
"channel": 0,
"gain": 1.5
"gain": 1.2
}
},
{
@@ -159,11 +181,33 @@
"/usr/share/t2-linux-audio/15_1/woofers-96k.wav"
],
"channel": 0,
"gain": 1.5
"gain": 1.2
}
},
{
"type": "lv2",
"name": "wlim",
"plugin": "http://plugin.org.uk/swh-plugins/fastLookaheadLimiter",
"control": {
"ingain": 0,
"limit": -2,
"release": 0.25
}
},
{
"type": "lv2",
"name": "tlim",
"plugin": "http://plugin.org.uk/swh-plugins/fastLookaheadLimiter",
"control": {
"ingain": 0,
"limit": -1,
"release": 0.15
}
}
],
"links": [
{"output": "user_eq:out_l", "input": "equalizer:in_l"},
{"output": "user_eq:out_r", "input": "equalizer:in_r"},
{"output": "equalizer:out_l", "input": "virtualbass:in_l"},
{"output": "equalizer:out_r", "input": "virtualbass:in_r"},
{"output": "virtualbass:out_l", "input": "multiband_compressor:in_l"},
@@ -177,17 +221,21 @@
{"output": "copyL:Out", "input": "convLT:In"},
{"output": "copyL:Out", "input": "convLW:In"},
{"output": "copyR:Out", "input": "convRT:In"},
{"output": "copyR:Out", "input": "convRW:In"}
{"output": "copyR:Out", "input": "convRW:In"},
{"output": "convLW:Out", "input": "wlim:in_1"},
{"output": "convRW:Out", "input": "wlim:in_2"},
{"output": "convLT:Out", "input": "tlim:in_1"},
{"output": "convRT:Out", "input": "tlim:in_2"}
],
"inputs": [
"equalizer:in_l",
"equalizer:in_r"
"user_eq:in_l",
"user_eq:in_r"
],
"outputs": [
"convLW:Out",
"convRW:Out",
"convLT:Out",
"convRT:Out"
"wlim:out_1",
"wlim:out_2",
"tlim:out_1",
"tlim:out_2"
],
"capture.volumes": [
{

76
mb-compressor-params.md Normal file
View File

@@ -0,0 +1,76 @@
# LSP `mb_compressor_stereo` — parameter reference
Every key in the `multiband_compressor` node of [graph.json](graph.json), with the
LSP port name in **bold** and the JSON symbol in `code`. Values shown are the
ones currently in the graph (8-band split, `split-limiting` branch).
Source of truth: `mb_compressor_stereo.ttl` from lsp-plugins 25.08.
## Global (once, not per band)
| Key | LSP name | Value | Meaning |
|---|---|---|---|
| `enabled` | Enabled | 1 | plugin active |
| `mode` | Compressor mode | 1 = **Modern** | band-split topology. `0` Classic = analog-style tree of crossovers; `1` Modern = flat per-band summation (cleaner, used here); `2` Linear Phase = FIR, high latency |
| `g_in` / `g_out` | Input / Output gain | 1.0 / 1.4 | linear. `g_out 1.4` ≈ +3 dB — where the EQ's `g_in 0.5` pad is restored, *after* the band detectors |
| `g_dry` / `g_wet` | Dry / Wet gain | 0.0001 / 1.0 | parallel mix — effectively 100 % wet |
## Crossover (index 1–7)
| Key | LSP name | Meaning |
|---|---|---|
| `cbe_N` | **Compression band enable N** | turns split point N on → creates that band boundary. All 7 = `1` → 8 bands |
| `sf_N` | **Split frequency N** (Hz) | the crossover frequency. Currently `60 / 80 / 100 / 130 / 160 / 200 / 500` |
## Per band (index 0–7)
| Key | LSP name | Units | Meaning |
|---|---|---|---|
| `ce_N` | **Compressor enable N** | toggle | band compresses (`0` = passes flat) |
| `al_N` | **Attack threshold N** | linear gain (0.001–1.0) | the threshold — level above this is compressed. dB = 20·log₁₀(g); `0.093` ≈ −21 dB |
| `at_N` | **Attack time N** | ms | how fast gain reduction engages once over threshold |
| `rt_N` | **Release time N** | ms | how fast gain recovers once back under threshold |
| `cr_N` | **Ratio N** | x:1 (1–100) | dB in per dB out above threshold. `1` = off, `2` = gentle, `50` ≈ brick-wall limiter |
| `kn_N` | **Knee N** | linear gain (0.063–1.0) | width of the soft transition **around** the threshold. `1.0` = hard corner; **lower = wider / softer** (`0.063` ≈ ±24 dB — ratio ramps in over a huge range). Plugin default `0.5` (−6 dB) |
| `mk_N` | **Makeup gain N** | linear (0.001–1000) | post-compression level trim for the band. `1.5` ≈ +3.5 dB, `2.0` = +6 dB |
| `scm_N` | **Sidechain mode N** | enum | detector: `0` = **Peak**, `1` RMS, `2` LPF, `3` SMA. Peak is used on every band (right for limiting; plugin default is RMS) |
| `bth_N` | **Boost threshold N** | linear gain (1e-6–1e-3) | only used when band compression mode = Up / Boost. **Inert here** — see note below |
| `bsa_N` | **Boost signal amount N** | linear gain (2.5e-4–3981) | ditto — inert |
Other per-band ports exist in the plugin but are left at default and not written
to the graph: `cm_N` (compression mode: Down/Up/Boost — default Down),
`bs_N`/`bm_N` (band solo/mute), `rrl_N`/`rl_N` (release threshold/level — `0` =
track the attack threshold), and the `sc*_N` sidechain family
(`sce_/scs_/sscs_/sla_/scr_/scp_/sclc_…`).
## Current bands, in plain English
| Band | Range | `al` (thr) | `at` / `rt` | `cr` | `kn` | `mk` | reads as |
|---|---|---|---|---|---|---|---|
| 0 | < 60 Hz | 0.093 (−21 dB) | 2 / 120 ms | 50 | 0.10 (−20 dB) | 1.5 (+3.5 dB) | fast brick-wall; near-empty behind the 60 Hz EQ high-pass |
| 1 | 60–80 Hz | 0.078 (−22 dB) | 5 / 120 ms | 50 | 0.06 (−24 dB, clamped) | 1.5 (+3.5 dB) | lowest ceiling, 50:1, softest knee → heavy but gradual |
| 2 | 80–100 Hz | 0.095 (−20 dB) | 6 / 130 ms | 30 | 0.12 (−18 dB) | 1.7 (+4.6 dB) | one notch gentler than band 1 |
| 3 | 100–130 Hz | 0.120 (−18 dB) | 8 / 150 ms | 20 | 0.20 (−14 dB) | 2.0 (+6 dB) | old "90–200" midbass character |
| 4 | 130–160 Hz | 0.130 (−18 dB) | 8 / 150 ms | 18 | 0.24 (−12 dB) | 2.0 (+6 dB) | " |
| 5 | 160–200 Hz | 0.140 (−17 dB) | 9 / 150 ms | 16 | 0.28 (−11 dB) | 2.0 (+6 dB) | eases toward the body band |
| 6 | 200–500 Hz | 0.159 (−16 dB) | 10 / 150 ms | 15 | 0.30 (−10 dB) | 2.0 (+6 dB) | low-mid body (snare / male vocal) |
| 7 | 500 Hz+ | 0.284 (−11 dB) | 8 / 150 ms | 5 | 0.40 (−8 dB) | 1.3 (+2.3 dB) | single gentle catch-all above 500 Hz |
## Notes / gotchas
- **Knee direction.** Lower `kn` = **wider / softer** knee, not harder. `kn_1 =
0.06` on the 60–80 Hz band is the softest the plugin allows (it clamps `0.06`
→ `0.063`). That band still pulls peaks down hard because of the low threshold
+ 50:1 ratio; the wide knee just means the ratio ramps in gradually starting
well below −22 dB (so there is some low-level gain reduction too). To clamp
those octaves only near the ceiling and keep them cleaner below it, *raise*
`kn_1` / `kn_2` toward `0.3`–`0.5`.
- **`bth_N` / `bsa_N` are dead config.** Set to `1.0` in the graph (inherited
from upstream) but their valid ranges are ~`1e-6`–`1e-3` and
`2.5e-4`–`3981` — the host clamps them. They only apply to upward / boost
compression, which is not enabled (`cm_N` is never set, so every band is
downward), so it is harmless.
- **`al_N` is linear, not dB.** Same for `kn_N`, `mk_N`, `g_*`. Convert with
`dB = 20·log₁₀(value)`.