Compare commits
82 Commits
split-limi
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5dd64c531d | ||
|
|
e330eeb495 | ||
|
|
00ed8acc57 | ||
|
|
ddc905558f | ||
|
|
bace1ea0b8 | ||
|
|
1f9c86bbde | ||
|
|
4955e01299 | ||
|
|
0a7c917314 | ||
|
|
ef6b91f31a | ||
|
|
0f4d93c111 | ||
|
|
20f52cc860 | ||
|
|
bd39f91b3d | ||
|
|
42b33e43aa | ||
|
|
d9408b3d0e | ||
|
|
66fed3ccfb | ||
|
|
5f156b63ce | ||
|
|
7df6a8f83b | ||
|
|
922a08d138 | ||
|
|
b50f9b92e1 | ||
|
|
b635df23f6 | ||
|
|
77f3eebf44 | ||
|
|
f16065ee20 | ||
|
|
e48b086c22 | ||
|
|
6305d597a1 | ||
|
|
8eb1397043 | ||
|
|
2e695198e5 | ||
|
|
d52e48dce1 | ||
|
|
7aea4a7367 | ||
|
|
d80fd810f5 | ||
|
|
e006be4f8b | ||
|
|
9c4c618b72 | ||
|
|
c44e1081be | ||
|
|
7e89fb631e | ||
|
|
16538a0f46 | ||
|
|
2bcb215725 | ||
|
22863af6ff
|
|||
|
|
225ba3f467 | ||
|
|
a681681fe3 | ||
|
|
b88ad5b696 | ||
|
|
cc044b4cd2 | ||
|
|
f5bbf2beb9 | ||
|
|
acde5c460e | ||
|
|
4f13afc098 | ||
|
|
6f88e094b8 | ||
|
|
211545f5d1 | ||
|
|
ce8fd1ae20 | ||
|
|
2894f0dbda | ||
|
ef40ad804b
|
|||
|
|
314ec3e3bb | ||
|
|
320526e438 | ||
|
|
906470ba2d | ||
|
|
4cd548c643 | ||
|
|
48a946e9b4 | ||
|
|
2bfa50b792 | ||
|
|
d22bbfd325 | ||
|
|
f2a467996a | ||
|
|
ec75582cd6 | ||
|
|
e6c60b18ca | ||
|
|
dc5550122b | ||
|
|
17d03202f3 | ||
|
|
6549b51af9 | ||
|
|
4ae3bdbb1d | ||
|
|
e8f349d6d3 | ||
|
|
d8fcc5d4da | ||
|
|
c653818dbc | ||
|
62a84ce275
|
|||
|
70c7a1021b
|
|||
|
|
7bb875cce0 | ||
|
b779f53a71
|
|||
|
ed3a44d127
|
|||
|
|
606cc96a9e | ||
|
|
759f6b6c79 | ||
|
|
2420f6edb1 | ||
|
e53c500933
|
|||
|
9c96be76d4
|
|||
|
|
11d14caff7 | ||
|
5cd526d10b
|
|||
|
a582af12b4
|
|||
|
|
c48172e48a | ||
|
0ea346484a
|
|||
|
|
97b8229ce1 | ||
|
|
a3e795767b |
14
.gitignore
vendored
Normal file
14
.gitignore
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
# Bankstown source checkout + build output from install-deps.sh
|
||||||
|
/build/
|
||||||
|
|
||||||
|
# Local user_eq override consumed by apply.sh (copy from user_eq.example.json)
|
||||||
|
/user_eq.json
|
||||||
|
|
||||||
|
# Generated baked single-stage FIR files & simple graph
|
||||||
|
/15_1/baked-*.wav
|
||||||
|
/baked-*.wav
|
||||||
|
/graph_simple.json
|
||||||
|
|
||||||
|
# Python bytecode cache
|
||||||
|
*.pyc
|
||||||
|
__pycache__/
|
||||||
BIN
15_1/tweeters-44k.wav
Normal file
BIN
15_1/tweeters-44k.wav
Normal file
Binary file not shown.
BIN
15_1/tweeters-48k.wav
Normal file
BIN
15_1/tweeters-48k.wav
Normal file
Binary file not shown.
BIN
15_1/tweeters-96k.wav
Normal file
BIN
15_1/tweeters-96k.wav
Normal file
Binary file not shown.
BIN
15_1/woofers-44k.wav
Normal file
BIN
15_1/woofers-44k.wav
Normal file
Binary file not shown.
BIN
15_1/woofers-48k.wav
Normal file
BIN
15_1/woofers-48k.wav
Normal file
Binary file not shown.
BIN
15_1/woofers-96k.wav
Normal file
BIN
15_1/woofers-96k.wav
Normal file
Binary file not shown.
238
INSTALL.md
Normal file
238
INSTALL.md
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
# 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
|
||||||
|
chmod +x apply.sh install-deps.sh # if git didn't preserve the bit
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. LV2 plugin dependencies
|
||||||
|
|
||||||
|
The graph loads four LV2 plugins from three bundles. (`copy` and `convolver` are
|
||||||
|
PipeWire builtins — nothing to install.)
|
||||||
|
|
||||||
|
> **Shortcut:** `./install-deps.sh` does everything in this section — detects
|
||||||
|
> `dnf`/`pacman`/`apt`/`zypper`, installs LSP + SWH, builds Bankstown from source
|
||||||
|
> if it's missing, then verifies all five URIs. The manual steps below are what
|
||||||
|
> it runs, for reference or when it can't.
|
||||||
|
|
||||||
|
| 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
|
||||||
|
```
|
||||||
|
|
||||||
|
`apply.sh`:
|
||||||
|
|
||||||
|
1. validates `graph.json` is well-formed JSON (`python3` or `jq`)
|
||||||
|
2. **builds the effective graph** → `~/.audiograph.json`: if `user_eq.json`
|
||||||
|
exists next to the script, its contents replace the `user_eq` node's
|
||||||
|
`control` block (`jq`); otherwise it's a straight copy of `graph.json` (§7)
|
||||||
|
3. checks `/usr/share/t2-linux-audio/15_1/` exists (§1)
|
||||||
|
4. checks every FIR `.wav` **referenced by the graph** is present (§4)
|
||||||
|
5. checks every LV2 plugin URI **the graph loads** resolves in `lv2ls` (§3)
|
||||||
|
|
||||||
|
then `sudo cp`s `~/.audiograph.json` into place, restarts WirePlumber, and
|
||||||
|
confirms the sink came up. Steps 4–5 read the paths/URIs straight out of the
|
||||||
|
merged graph, so they stay correct if you edit either file.
|
||||||
|
|
||||||
|
Pass `-f` to skip the preflight (JSON validation still runs):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./apply.sh -f
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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 — an 8-band tone control that defaults
|
||||||
|
flat (= the reference voicing). To change it without touching the committed
|
||||||
|
`graph.json`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cp user_eq.example.json user_eq.json # git-ignored
|
||||||
|
$EDITOR user_eq.json # set the g_* values
|
||||||
|
./apply.sh # merges it into ~/.audiograph.json and installs
|
||||||
|
```
|
||||||
|
|
||||||
|
`jq` must be installed for this path. Delete `user_eq.json` to return to the
|
||||||
|
default. Preset values (Rock, Classical, Movie–dialogue, …) are in
|
||||||
|
[README.md § User preference EQ](README.md#user-preference-eq).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 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` |
|
||||||
296
README.advanced.md
Normal file
296
README.advanced.md
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
# MacBook Pro 15,1 — custom speaker DSP graph
|
||||||
|
|
||||||
|
A modified PipeWire `filter-chain` graph for the built-in speakers of the
|
||||||
|
**MacBook Pro 15,1** (Intel T2) running Linux, plus a script to install it.
|
||||||
|
|
||||||
|
This is **not a driver**. The T2 kernel/ALSA stack exposes the raw speaker PCM;
|
||||||
|
WirePlumber (via `t2-linux-audio`'s `51-t2-dsp.conf`) renames it to
|
||||||
|
`alsa_output.platform-sound.RawSpeakers`, hides it, and splices this graph in
|
||||||
|
front of it. The graph does the crossover, voicing EQ, dynamics, and FIR
|
||||||
|
correction that the T2's own DSP does under macOS.
|
||||||
|
|
||||||
|
## Origin
|
||||||
|
|
||||||
|
Forked from `configs/15_1/graph.json` in
|
||||||
|
[lemmyg/t2-apple-audio-dsp](https://github.com/lemmyg/t2-apple-audio-dsp)
|
||||||
|
(which itself borrows FIR filters and structure from
|
||||||
|
[chadmed/asahi-audio](https://github.com/chadmed/asahi-audio)).
|
||||||
|
|
||||||
|
All structural elements are unchanged: node names, FIR `.wav` paths
|
||||||
|
(`/usr/share/t2-linux-audio/15_1/`), `capture.props` / `playback.props`,
|
||||||
|
`target.object = alsa_output.platform-sound.RawSpeakers`, 4-channel FL/FR/RL/RR
|
||||||
|
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 ─▶ 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.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.
|
||||||
|
|
||||||
|
## Design rationale
|
||||||
|
|
||||||
|
**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) 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 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 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 |
|
||||||
|
|
||||||
|
Values are **linear, not dB** (`+3 dB ≈ 1.41`, `−3 dB ≈ 0.71`). Keep each `g_*`
|
||||||
|
between `0.5` (−6 dB) and `2.0` (+6 dB).
|
||||||
|
|
||||||
|
Two ways to set it, both followed by `./apply.sh`:
|
||||||
|
|
||||||
|
- **Override file (recommended, survives `git pull`).** Copy the template and
|
||||||
|
edit it:
|
||||||
|
```sh
|
||||||
|
cp user_eq.example.json user_eq.json
|
||||||
|
$EDITOR user_eq.json
|
||||||
|
```
|
||||||
|
`user_eq.json` is git-ignored. When present, `apply.sh` splices its contents
|
||||||
|
into the `user_eq` node's `control` block with `jq`, writes the result to
|
||||||
|
`~/.audiograph.json`, and installs that. Delete `user_eq.json` to go back to
|
||||||
|
the committed default.
|
||||||
|
- **Edit `graph.json` directly** — change the `g_*` in the `user_eq` `control`
|
||||||
|
block. Simple, but a `git pull` will conflict.
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
If the woofers still bottom out or anything distorts, in order of preference:
|
||||||
|
|
||||||
|
| 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) |
|
||||||
|
|
||||||
|
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, with the `t2-linux-audio` / `t2-apple-audio-dsp` package already
|
||||||
|
installed (it provides the FIR `.wav` files, `51-t2-dsp.conf` and `mic.json`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./install-deps.sh # LSP + SWH plugins, builds Bankstown from source
|
||||||
|
./apply.sh # preflights, then copies the graph in and reloads WirePlumber
|
||||||
|
```
|
||||||
|
|
||||||
|
`apply.sh` refuses to install if a referenced FIR file or plugin URI is missing
|
||||||
|
(`-f` skips those checks).
|
||||||
|
|
||||||
|
## Revert
|
||||||
|
|
||||||
|
```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
|
||||||
|
```
|
||||||
|
|
||||||
|
Note: a `t2-linux-audio` package update will overwrite the installed file and
|
||||||
|
silently revert these changes — re-run `./apply.sh` afterward. See
|
||||||
|
[INSTALL.md § 8](INSTALL.md#8-after-a-system-update).
|
||||||
313
README.md
313
README.md
@@ -1,171 +1,200 @@
|
|||||||
# MacBook Pro 15,1 — custom speaker DSP graph
|
# MacBook Pro 15,1 — Warm, Natural Audio DSP for t2linux
|
||||||
|
|
||||||
A modified PipeWire `filter-chain` graph for the built-in speakers of the
|
|
||||||
**MacBook Pro 15,1** (Intel T2) running Linux, plus a script to install it.
|
|
||||||
|
|
||||||
This is **not a driver**. The T2 kernel/ALSA stack exposes the raw speaker PCM;
|
based on https://github.com/lemmyg/t2-apple-audio-dsp/tree/master
|
||||||
WirePlumber (via `t2-linux-audio`'s `51-t2-dsp.conf`) renames it to
|
|
||||||
`alsa_output.platform-sound.RawSpeakers`, hides it, and splices this graph in
|
|
||||||
front of it. The graph does the crossover, voicing EQ, dynamics, and FIR
|
|
||||||
correction that the T2's own DSP does under macOS.
|
|
||||||
|
|
||||||
## Origin
|
[](https://github.com/mynameisdeleted/mbp15-1-audio-dsp) [](https://git.fairfaxmedia.net/t2linux/mbp15-1-audio-dsp.git) [](https://wiki.t2linux.org/) [](https://github.com/AsahiLinux/asahi-audio) [](https://pipewire.org/) []()
|
||||||
|
|
||||||
Forked from `configs/15_1/graph.json` in
|
A custom PipeWire `filter-chain` DSP graph engineered to deliver warm, natural audio to **t2linux** on the **MacBook Pro 15,1** (2018/2019 Intel T2)—aimed at matching or beating macOS (OS X) audio quality both subjectively and objectively.
|
||||||
[lemmyg/t2-apple-audio-dsp](https://github.com/lemmyg/t2-apple-audio-dsp)
|
|
||||||
(which itself borrows FIR filters and structure from
|
|
||||||
[chadmed/asahi-audio](https://github.com/chadmed/asahi-audio)).
|
|
||||||
|
|
||||||
All structural elements are unchanged: node names, FIR `.wav` paths
|
> [!NOTE]
|
||||||
(`/usr/share/t2-linux-audio/15_1/`), `capture.props` / `playback.props`,
|
> **Architecture:** This is **not a kernel driver**. The Linux T2 kernel/ALSA stack exposes raw speaker PCM. WirePlumber hides the raw device and splices this graph in front of it (`alsa_output.platform-sound.RawSpeakers`), executing warm voicing EQ, psychoacoustic sub-bass, 8-band dynamic control, driver crossover, FIR correction, and hard driver protection limiters.
|
||||||
`target.object = alsa_output.platform-sound.RawSpeakers`, 4-channel FL/FR/RL/RR
|
|
||||||
output, allowed rates 48000/44100, and the `capture.volumes` mapping that ties
|
|
||||||
the sink volume slider to the loudness-compensation stage.
|
|
||||||
|
|
||||||
## Signal chain
|
---
|
||||||
|
|
||||||
```
|
## ⚡ Key Improvements Over Upstream
|
||||||
in ─▶ equalizer ─▶ virtualbass ─▶ multiband_compressor ─▶ limiter ─▶ ell/elr ─▶ copyL/R ─┬▶ convLT/convRT ─▶ tlim ─▶ out
|
|
||||||
(LSP x16) (bankstown) (LSP mb_comp x8) (fastLookahead) (loud_comp) │ (tweeter FIR) (limit)
|
Upstream graphs (`asahi-audio` / `t2-apple-audio-dsp`) target a measurement-flat response that can sound thin, treble-heavy, and distort at high volumes due to missing driver limiters.
|
||||||
└▶ convLW/convRW ─▶ wlim ─▶ out
|
|
||||||
(woofer FIR) (limit)
|
| Upstream Limitation | Solution in This Graph | Real-World Result |
|
||||||
|
|---|---|---|
|
||||||
|
| ❄️ **Thin / Cold Sound** | Equal-energy warm voicing curve (+3 dB/octave tilt) | Rich, full, balanced audio across all genres |
|
||||||
|
| 💥 **Distortion at High Volume** | Post-FIR driver limiters (`wlim` @ -2dB, `tlim` @ -1dB) | Crystal clean output at 100% volume with zero amp clipping |
|
||||||
|
| 🔊 **Woofer Over-Excursion** | 60 Hz high-pass + 8-band multiband compressor | Woofers don't bottom out or rattle on heavy bass beats |
|
||||||
|
| 🔇 **No Deep Sub-Bass** | Psychoacoustic sub-bass (`virtualbass` via Bankstown) | Extended perceived low-end without physical cone strain |
|
||||||
|
| 🛡️ **Voice-Coil Overheating** | Dual Woofer & Tweeter Virtual Thermal Guards | Continuous white noise automatically attenuated to 500mW / 75mW |
|
||||||
|
| 🎚️ **Fixed / Rigid EQ** | Isolated 8-band `user_eq` preference node | Custom tone presets that survive git updates |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎛️ Signal Processing Chain
|
||||||
|
|
||||||
|
Audio flows through tone controls, dynamic management, ISO-226 equal loudness tracking, FIR driver correction, and physical driver protection limiters:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph Stage1 ["1. Input & Voicing"]
|
||||||
|
In["🔊 Audio Input"]:::input --> UserEQ["🎚️ User EQ (8-Band Tone Control)"]:::eq
|
||||||
|
UserEQ --> EQ["🎼 Voicing EQ (+3dB/oct Warmth & 60Hz HPF)"]:::eq
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Stage2 ["2. Dynamics & Headroom Management"]
|
||||||
|
EQ --> VB["🔊 Virtual Bass (Bankstown Sub-Harmonics)"]:::dynamics
|
||||||
|
VB --> MBComp["📊 Multiband Compressor (8-Band LSP)"]:::dynamics
|
||||||
|
MBComp --> LoudComp["👂 Loudness Comp (ISO-226 2k FFT)"]:::dynamics
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Stage3 ["3. Crossover & Driver FIR Correction"]
|
||||||
|
LoudComp --> Copy["🔀 4-Channel Crossover Splitter"]:::input
|
||||||
|
|
||||||
|
subgraph Tweeters ["Tweeter Channels"]
|
||||||
|
Copy --> ConvLT["🔊 Tweeter L FIR (convLT)"]:::fir
|
||||||
|
Copy --> ConvRT["🔊 Tweeter R FIR (convRT)"]:::fir
|
||||||
|
ConvLT --> TGuard["🛡️ Tweeter Thermal Guard (75mW / 1.0s Tau)"]:::limiter
|
||||||
|
ConvRT --> TGuard
|
||||||
|
TGuard --> TLim["🛡️ Tweeter Limiter (-1 dB Ceiling)"]:::limiter
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Woofers ["Woofer Channels"]
|
||||||
|
Copy --> ConvLW["🔊 Woofer L FIR (convLW)"]:::fir
|
||||||
|
Copy --> ConvRW["🔊 Woofer R FIR (convRW)"]:::fir
|
||||||
|
ConvLW --> WLim["🛡️ Woofer Limiter (-2 dB Ceiling)"]:::limiter
|
||||||
|
ConvRW --> WLim
|
||||||
|
WLim --> WGuard["🛡️ Woofer Thermal Guard (500mW / 2.5s Tau)"]:::limiter
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph Stage4 ["4. Driver Output"]
|
||||||
|
TLim --> Out["🔈 RawSpeakers Sink"]:::input
|
||||||
|
WGuard --> Out
|
||||||
|
end
|
||||||
|
|
||||||
|
classDef input fill:#2d3748,stroke:#4a5568,color:#fff;
|
||||||
|
classDef eq fill:#2b6cb0,stroke:#3182ce,color:#fff;
|
||||||
|
classDef dynamics fill:#d69e2e,stroke:#d69e2e,color:#000;
|
||||||
|
classDef fir fill:#805ad5,stroke:#9f7aea,color:#fff;
|
||||||
|
classDef limiter fill:#c53030,stroke:#e53e3e,color:#fff;
|
||||||
```
|
```
|
||||||
|
|
||||||
## Changes vs. upstream `15_1/graph.json`
|
---
|
||||||
|
|
||||||
| Stage | Upstream | This fork | Purpose |
|
## 🛡️ Dual-Driver Virtual Thermal Guards & Power Caps
|
||||||
|---|---|---|---|
|
|
||||||
| 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.
|
Because Linux cannot access the T2 hardware current-sensing ADCs, we implement a **Virtual Voice-Coil Thermal Model** that models continuous electrical power dissipation ($P = V_{\text{rms}}^2 / R_{\text{vc}}$) and exponential cooling into the air gap:
|
||||||
|
|
||||||
## Design rationale
|
$$C_{\text{th}} \frac{d\Delta T(t)}{dt} = \frac{V_{\text{rms}}^2(t)}{R_{\text{vc}}} - \frac{\Delta T(t)}{R_{\text{th}}}$$
|
||||||
|
|
||||||
**Goal:** mild-volume music should sound warm and full; bass-heavy material
|
### Thermal Guard Configurations
|
||||||
should not distort the woofers or duck the midrange.
|
|
||||||
|
|
||||||
- **Warm at low volume** is handled two ways:
|
* **Woofer Thermal Guard (`thermal_guard` @ Post-`wlim`):**
|
||||||
- `ell` / `elr` (`loud_comp_mono`) is a true ISO-226 equal-loudness
|
* **Continuous Power Cap:** **$1.0\text{ W}$ ($1,000\text{ mW}$)** per channel ($16\%$ of max $6.25\text{ W}$ peak power).
|
||||||
compensator. The sink volume slider feeds `ell:volume` / `elr:volume`
|
* **Linear RMS Threshold (`al`):** `0.4000` ($-7.96\text{ dBFS}$ RMS).
|
||||||
(cubic, −65→0 dB), so bass/treble lift automatically increases as you turn
|
* **Heating Tau ($\tau_{\text{heat}}$):** $2,500\text{ ms}$ (2.5 seconds).
|
||||||
the volume down and recedes as you turn it up.
|
* **Cooling Tau ($\tau_{\text{cool}}$):** $5,000\text{ ms}$ (5.0 seconds).
|
||||||
- The static EQ bells (31.5–125 Hz) add a fixed warmth tilt. Note LSP's `g_*`
|
* **Tweeter Thermal Guard (`tweeter_thermal_guard` @ Pre-`tlim`):**
|
||||||
ports are **linear amplitude, not dB** — `g_3 = 3.26` is ≈ +10 dB, offset by
|
* **Continuous Power Cap:** **$100\text{ mW}$ ($0.100\text{ W}$)** per channel ($1.6\%$ of max $6.25\text{ W}$ peak power).
|
||||||
`g_in 0.5` (≈ −6 dB). This is a hot bass shelf on purpose; the dynamics
|
* **Linear RMS Threshold (`al`):** `0.126491` ($-17.96\text{ dBFS}$ RMS).
|
||||||
stages below exist to keep it safe when loud. The bass boost lives here
|
* **Heating Tau ($\tau_{\text{heat}}$):** $1,000\text{ ms}$ (1.0 second).
|
||||||
rather than in the woofer FIR gain (kept near unity at 1.15) so it passes
|
* **Cooling Tau ($\tau_{\text{cool}}$):** $2,000\text{ ms}$ (2.0 seconds, fast decay recovery from Zoom/WebRTC glitches).
|
||||||
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
|
> **Acoustic Result:** Dynamic music (Blues, Classical, Rock) has high crest factor ($12\text{ dB} - 18\text{ dB}$) and operates at $0\text{ dB}$ gain reduction with full peak dynamics. Continuous high-power signals (full-volume white noise or sine sweeps) are automatically attenuated to harmless power levels after ~2 seconds.
|
||||||
`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 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 |
|
## 🚀 Quick Start
|
||||||
|---|---|---|---|---|---|
|
|
||||||
| 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
|
### 1. Install Dependencies
|
||||||
[mb-compressor-params.md](mb-compressor-params.md).)
|
Installs required LSP & SWH plugins via your package manager (`dnf`, `pacman`, `apt`, `zypper`) and builds Bankstown from source:
|
||||||
|
```bash
|
||||||
|
./install-deps.sh
|
||||||
|
```
|
||||||
|
|
||||||
Band 1 (60–80 Hz) and band 2 (80–100 Hz) carry the lowest ceilings and the
|
### 2. Apply Graph
|
||||||
highest ratios, so the two octaves that drive woofer excursion hardest are
|
Preflights FIR paths and plugin URIs, merges user EQ overrides, copies the configuration to WirePlumber, and reloads:
|
||||||
clamped ahead of everything else — their wide knees (`kn` down at `0.06` /
|
```bash
|
||||||
`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 60 Hz high-pass.
|
|
||||||
|
|
||||||
## Tuning knobs
|
|
||||||
|
|
||||||
If the woofers still bottom out or anything distorts, in order of preference:
|
|
||||||
|
|
||||||
| 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) |
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
```sh
|
|
||||||
./apply.sh
|
./apply.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
This copies `graph.json` to `/usr/share/t2-linux-audio/15_1/graph.json` (needs
|
> [!TIP]
|
||||||
`sudo`) and restarts WirePlumber (`systemctl --user restart wireplumber`).
|
> Ensure **"MacBook Pro 15,1 DSP Speakers"** is selected as the default output in your desktop sound settings.
|
||||||
|
|
||||||
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).
|
|
||||||
|
|
||||||
## Revert
|
## 🎚️ Sound Profiles & User EQ
|
||||||
|
|
||||||
```sh
|
Customize tone settings without touching calibrated internal DSP nodes. `user_eq` sits at the front of the chain, so even aggressive boosts are safely governed by downstream multiband limiters.
|
||||||
|
|
||||||
|
### Quick Preset Setup
|
||||||
|
|
||||||
|
1. **Create your override file:**
|
||||||
|
```bash
|
||||||
|
cp user_eq.example.json user_eq.json
|
||||||
|
```
|
||||||
|
2. **Edit `user_eq.json`** with your preferred gain multipliers (`g_0` to `g_7`) and apply:
|
||||||
|
```bash
|
||||||
|
./apply.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### Recommended Tone Presets
|
||||||
|
|
||||||
|
| Profile | 70 Hz (`g_0`) | 110 Hz (`g_1`) | 220 Hz (`g_2`) | 450 Hz (`g_3`) | 1 kHz (`g_4`) | 2.5 kHz (`g_5`) | 6 kHz (`g_6`) | 10 kHz (`g_7`) |
|
||||||
|
|---|---|---|---|---|---|---|---|---|
|
||||||
|
| **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 Focus)**| `0.84` | `0.94` | `1.00` | `1.06` | `1.19` | `1.19` | `1.06` | `1.00` |
|
||||||
|
| **Movie (Action / Bass)** | `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.19` | `1.00` | `0.94` |
|
||||||
|
|
||||||
|
*(Note: Gain values are linear multipliers: `1.0` = 0 dB, `1.41` ≈ +3 dB boost, `0.71` ≈ -3 dB cut)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ Fine-Tuning Guide
|
||||||
|
|
||||||
|
If your specific physical unit requires custom acoustic tuning:
|
||||||
|
|
||||||
|
* **Woofer Ceiling:** Edit `wlim.control.limit` in `graph.json` (Default: `0` dBFS into `thermal_guard`).
|
||||||
|
* **Thermal Recalibration:** Edit `al` in `thermal_guard` (`al = sqrt(P_watts / 6.25W)`).
|
||||||
|
* **Sub-Bass Synthesis:** Adjust `virtualbass.control.amt` (Default: `1.0`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚠️ AT-YOUR-OWN-RISK DISCLAIMER & Replacement Resources
|
||||||
|
|
||||||
|
> [!CAUTION]
|
||||||
|
> **USE AT YOUR OWN RISK:** Loud signals in general, as well as all continuous loud audio (e.g. uncompressed music, white/pink noise, full-volume sine sweeps, or software glitches), carry an inherent physical risk of voice-coil overheating, mechanical over-excursion, or permanent speaker driver damage.
|
||||||
|
>
|
||||||
|
> While this DSP engine includes virtual thermal-emulation limiters and dynamic excursion limiters as a **best-effort protective measure**, this software is provided **"AS IS", WITH NO IMPLIED WARRANTY OR GUARANTEE OF ANY KIND**. You are solely responsible for protecting your own hardware and managing playback volume levels. The authors and maintainers assume no responsibility or liability for damaged speaker cones, blown voice coils, or hardware failures.
|
||||||
|
|
||||||
|
### 🔧 Speaker Replacement & Repair Resources for Power Users
|
||||||
|
For expert users who wish to customize thresholds, push thermal limits higher will run a risk of speaker blowout.
|
||||||
|
Dont increase the limits unless you understand and are prepared for a speaker-driver replacement process at your own risk.
|
||||||
|
If you wish to replace speaker drivers or to be able to do so yourself these instructions and part-links may help.
|
||||||
|
* 🛠️ **iFixit Repair Guide:** [MacBook Pro 15" Touch Bar 2018 Speaker Replacement - iFixit](https://www.ifixit.com/Guide/MacBook+Pro+15-Inch+Touch+Bar+2018+Right+Speaker+Replacement/122784)
|
||||||
|
* 🛒 **Replacement Speaker Modules:** Cheap OEM speaker assemblies are readily available if you ever push limits and need a fresh set of drivers: search for MacBook Pro 15,1 / 15,2 (A1990 2018/2019) Left & Right Speaker Assemblies on [iFixit Parts](https://www.ifixit.com/Parts/MacBook_Pro_15%22_Touch_Bar) or [eBay A1990 Speakers](https://www.ebay.com/sch/i.html?_nkw=macbook+pro+a1990+speakers).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📚 Documentation & Repository Links
|
||||||
|
|
||||||
|
### 🔗 Repositories & Mirrors
|
||||||
|
* 🐙 **GitHub Repository:** [github.com/mynameisdeleted/mbp15-1-audio-dsp](https://github.com/mynameisdeleted/mbp15-1-audio-dsp)
|
||||||
|
* 🏢 **Fairfax Media Git Server:** [git.fairfaxmedia.net/t2linux/mbp15-1-audio-dsp](https://git.fairfaxmedia.net/t2linux/mbp15-1-audio-dsp.git)
|
||||||
|
|
||||||
|
### 📖 Guides & Deep Dives
|
||||||
|
* 📖 **[INSTALL.md](INSTALL.md)** — Full prerequisites, manual plugin build steps, package manager lookup, and troubleshooting.
|
||||||
|
* 🎓 **[README.advanced.md](README.advanced.md)** — Comprehensive electroacoustic design rationale, magnitude-vs-power physics, gain staging equations, issue tracebacks, and complete parameter reference.
|
||||||
|
* 📊 **[mb-compressor-params.md](mb-compressor-params.md)** — Detailed parameter guide for the 8-band LSP multiband compressor.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔄 Reverting to Stock
|
||||||
|
|
||||||
|
To return to the stock PipeWire graph provided by `t2-apple-audio-dsp`:
|
||||||
|
```bash
|
||||||
sudo cp /path/to/t2-apple-audio-dsp/configs/15_1/graph.json \
|
sudo cp /path/to/t2-apple-audio-dsp/configs/15_1/graph.json \
|
||||||
/usr/share/t2-linux-audio/15_1/graph.json
|
/usr/share/t2-linux-audio/15_1/graph.json
|
||||||
systemctl --user restart wireplumber
|
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.
|
|
||||||
|
|||||||
185
apply.sh
185
apply.sh
@@ -1,19 +1,188 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
# Apply script for MacBook Pro 15,1 audio DSP graph edits
|
# Apply the MacBook Pro 15,1 audio DSP graph:
|
||||||
|
# build the effective graph, preflight the prerequisites, install, reload.
|
||||||
|
#
|
||||||
|
# ./apply.sh validate + preflight + build + install + reload
|
||||||
|
# ./apply.sh -f skip the preflight checks (JSON validation still runs)
|
||||||
|
#
|
||||||
|
# If user_eq.json exists next to this script, its contents replace the
|
||||||
|
# "user_eq" node's control block (requires jq) - copy user_eq.example.json to
|
||||||
|
# user_eq.json and edit. The merged graph is written to ~/.audiograph.json and
|
||||||
|
# that file is what gets installed.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
GRAPH_SRC="$SCRIPT_DIR/graph.json"
|
GRAPH_SRC="$SCRIPT_DIR/graph.json"
|
||||||
|
OVERRIDE="$SCRIPT_DIR/user_eq.json"
|
||||||
|
MERGED="$HOME/.audiograph.json"
|
||||||
GRAPH_DST="/usr/share/t2-linux-audio/15_1/graph.json"
|
GRAPH_DST="/usr/share/t2-linux-audio/15_1/graph.json"
|
||||||
|
|
||||||
if [ ! -f "$GRAPH_SRC" ]; then
|
FORCE=0
|
||||||
echo "Error: $GRAPH_SRC not found!"
|
SIMPLE=0
|
||||||
exit 1
|
HOT=0
|
||||||
|
for arg in "${@:-}"; do
|
||||||
|
case "$arg" in
|
||||||
|
-f|--force|--no-check) FORCE=1 ;;
|
||||||
|
-b|--bake|--simple) SIMPLE=1 ;;
|
||||||
|
-h|--hot|--soft) HOT=1 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
have() { command -v "$1" >/dev/null 2>&1; }
|
||||||
|
die() { echo "Error: $*" >&2; exit 1; }
|
||||||
|
|
||||||
|
if [ "$SIMPLE" -eq 1 ]; then
|
||||||
|
echo "==> Baking static DSP stages into single-stage FIR files..."
|
||||||
|
python3 "$SCRIPT_DIR/bake-graph.py" || die "bake-graph.py failed"
|
||||||
|
GRAPH_SRC="$SCRIPT_DIR/graph_simple.json"
|
||||||
|
echo "==> Installing baked FIR files -> /usr/share/t2-linux-audio/15_1/"
|
||||||
|
sudo cp "$SCRIPT_DIR/15_1/baked-"*.wav "/usr/share/t2-linux-audio/15_1/" || die "Failed to copy baked FIR files"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Copying graph.json to system path..."
|
# --- json validation helper -------------------------------------------
|
||||||
sudo cp "$GRAPH_SRC" "$GRAPH_DST"
|
json_ok() {
|
||||||
|
have python3 && { python3 -c 'import json, sys; json.load(open(sys.argv[1]))' "$1" 2>/dev/null && return 0; return 1; }
|
||||||
|
have jq && { jq . "$1" >/dev/null 2>&1 && return 0; return 1; }
|
||||||
|
return 2 # cannot check
|
||||||
|
}
|
||||||
|
|
||||||
echo "Restarting WirePlumber..."
|
json_ok "$GRAPH_SRC"; rc=$?
|
||||||
|
[ "$rc" -eq 1 ] && die "graph.json is not valid JSON"
|
||||||
|
[ "$rc" -eq 2 ] && echo "warn: no python3/jq - skipping JSON validation"
|
||||||
|
[ "$rc" -eq 0 ] && echo "ok: graph.json is valid JSON"
|
||||||
|
|
||||||
|
# --- build the effective graph -> ~/.audiograph.json --------------------
|
||||||
|
if [ "$SIMPLE" -eq 1 ]; then
|
||||||
|
cp "$GRAPH_SRC" "$MERGED"
|
||||||
|
echo "ok: baked single-stage graph_simple.json -> $MERGED"
|
||||||
|
elif [ -f "$OVERRIDE" ]; then
|
||||||
|
have jq || die "$OVERRIDE exists but jq is not installed"
|
||||||
|
jq -e . "$OVERRIDE" >/dev/null 2>&1 || die "$OVERRIDE is not valid JSON"
|
||||||
|
jq -e 'any(.["filter.graph"].nodes[]; .name == "user_eq")' "$GRAPH_SRC" >/dev/null \
|
||||||
|
|| die "$GRAPH_SRC has no node named user_eq to override"
|
||||||
|
jq --slurpfile ov "$OVERRIDE" \
|
||||||
|
'(.["filter.graph"].nodes[] | select(.name == "user_eq") | .control) = $ov[0]' \
|
||||||
|
"$GRAPH_SRC" > "$MERGED" || die "jq merge failed"
|
||||||
|
echo "ok: merged user_eq.json -> $MERGED"
|
||||||
|
else
|
||||||
|
cp "$GRAPH_SRC" "$MERGED"
|
||||||
|
echo "ok: no user_eq.json - graph as-is -> $MERGED"
|
||||||
|
fi
|
||||||
|
|
||||||
|
json_ok "$MERGED"; rc=$?
|
||||||
|
[ "$rc" -eq 1 ] && die "merged graph $MERGED is not valid JSON"
|
||||||
|
|
||||||
|
# --- preflight (against the merged graph) ------------------------------
|
||||||
|
if [ "$FORCE" -eq 0 ]; then
|
||||||
|
[ -d "$(dirname "$GRAPH_DST")" ] || die \
|
||||||
|
"destination directory $(dirname "$GRAPH_DST") does not exist (is t2-linux-audio-15-1 installed?)"
|
||||||
|
|
||||||
|
# check all convolver wav files exist
|
||||||
|
if have jq; then
|
||||||
|
wav_files=$(jq -r '.. | .filename? | strings' "$MERGED")
|
||||||
|
for wav in $wav_files; do
|
||||||
|
[ -f "$wav" ] || die "FIR WAV file missing: $wav"
|
||||||
|
done
|
||||||
|
echo "ok: FIR .wav files present"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# check plugin URIs resolve
|
||||||
|
if have lv2ls && have jq; then
|
||||||
|
installed_lv2=$(lv2ls)
|
||||||
|
graph_lv2=$(jq -r '.["filter.graph"].nodes[] | select(.type=="lv2") | .plugin' "$MERGED")
|
||||||
|
for uri in $graph_lv2; do
|
||||||
|
echo "$installed_lv2" | grep -Fqx "$uri" || die \
|
||||||
|
"LV2 plugin URI missing: $uri (check lilv-utils/installed plugins)"
|
||||||
|
done
|
||||||
|
echo "ok: all LV2 plugins resolve"
|
||||||
|
else
|
||||||
|
echo "warn: lv2ls not found - cannot verify plugins (install lilv-utils / lilv)"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- preserve master volume (only needed for full WirePlumber restart) --
|
||||||
|
SAVED_VOL=""
|
||||||
|
IS_MUTED=0
|
||||||
|
if [ "$HOT" -eq 0 ] && have wpctl; then
|
||||||
|
VOL_OUT="$(wpctl get-volume @DEFAULT_AUDIO_SINK@ 2>/dev/null || true)"
|
||||||
|
if [ -n "$VOL_OUT" ]; then
|
||||||
|
SAVED_VOL="$(echo "$VOL_OUT" | awk '{print $2}')"
|
||||||
|
if echo "$VOL_OUT" | grep -qi "MUTED"; then
|
||||||
|
IS_MUTED=1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- install ----------------------------------------------------------
|
||||||
|
if [ "$SIMPLE" -eq 1 ]; then
|
||||||
|
echo "Installing baked FIR files -> $(dirname "$GRAPH_DST")/"
|
||||||
|
for f in "$SCRIPT_DIR/15_1/baked-"*.wav; do
|
||||||
|
bn="$(basename "$f")"
|
||||||
|
sudo cp "$f" "$(dirname "$GRAPH_DST")/$bn.tmp"
|
||||||
|
sudo mv -f "$(dirname "$GRAPH_DST")/$bn.tmp" "$(dirname "$GRAPH_DST")/$bn"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Installing $MERGED -> $GRAPH_DST"
|
||||||
|
sudo cp "$MERGED" "$GRAPH_DST.tmp"
|
||||||
|
sudo mv -f "$GRAPH_DST.tmp" "$GRAPH_DST"
|
||||||
|
|
||||||
|
if [ "$HOT" -eq 1 ]; then
|
||||||
|
echo "Hot-reloading WirePlumber graph (zero audio drop)..."
|
||||||
|
if systemctl --user reload wireplumber 2>/dev/null || pkill -HUP -f wireplumber 2>/dev/null; then
|
||||||
|
echo "ok: sent SIGHUP live reload to WirePlumber"
|
||||||
|
sleep 0.4
|
||||||
|
else
|
||||||
|
echo "warn: SIGHUP failed - restarting WirePlumber fallback"
|
||||||
|
systemctl --user restart wireplumber
|
||||||
|
sleep 1.2
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Ensure output is unmuted after live reload
|
||||||
|
if [ -n "$SAVED_VOL" ] && have wpctl; then
|
||||||
|
wpctl set-mute @DEFAULT_AUDIO_SINK@ 0 2>/dev/null || true
|
||||||
|
wpctl set-volume @DEFAULT_AUDIO_SINK@ "$SAVED_VOL" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "Done - FIR graph reloaded live."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- full WirePlumber restart -----------------------------------------
|
||||||
|
echo "Restarting WirePlumber"
|
||||||
systemctl --user restart wireplumber
|
systemctl --user restart wireplumber
|
||||||
|
sleep 1.2
|
||||||
|
|
||||||
echo "Done! The new DSP configuration is loaded."
|
# --- wait for DSP sink to be active & restore master volume ------------
|
||||||
|
for i in {1..6}; do
|
||||||
|
if have wpctl && wpctl status 2>/dev/null | grep -qi "DSP Speakers"; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.5
|
||||||
|
done
|
||||||
|
|
||||||
|
if [ -n "$SAVED_VOL" ] && have wpctl; then
|
||||||
|
# Force unmute on default sink + explicit DSP Speakers sink ID
|
||||||
|
wpctl set-mute @DEFAULT_AUDIO_SINK@ 0 2>/dev/null || true
|
||||||
|
|
||||||
|
DSP_ID="$(wpctl status 2>/dev/null | grep -i "DSP Speakers" | grep -oE '[0-9]+\.' | head -n1 | tr -d '.')"
|
||||||
|
if [ -n "$DSP_ID" ]; then
|
||||||
|
wpctl set-mute "$DSP_ID" 0 2>/dev/null || true
|
||||||
|
wpctl set-volume "$DSP_ID" "$SAVED_VOL" 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
wpctl set-volume @DEFAULT_AUDIO_SINK@ "$SAVED_VOL" 2>/dev/null || true
|
||||||
|
|
||||||
|
if [ "$IS_MUTED" -eq 1 ]; then
|
||||||
|
wpctl set-mute @DEFAULT_AUDIO_SINK@ 1 2>/dev/null || true
|
||||||
|
[ -n "$DSP_ID" ] && wpctl set-mute "$DSP_ID" 1 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
echo "ok: preserved master volume (${SAVED_VOL})"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# --- confirm --------------------------------------------------------
|
||||||
|
if have wpctl && wpctl status 2>/dev/null | grep -qi "DSP Speakers"; then
|
||||||
|
echo "Done - 'MacBook Pro 15,1 DSP Speakers' sink is up."
|
||||||
|
else
|
||||||
|
echo "Done - graph installed, WirePlumber reloaded."
|
||||||
|
echo "If no 'DSP Speakers' sink shows: journalctl --user -u wireplumber -b -e"
|
||||||
|
fi
|
||||||
|
|||||||
390
bake-graph.py
Executable file
390
bake-graph.py
Executable file
@@ -0,0 +1,390 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
bake-graph.py — Single-Stage FIR Convolver & Graph Simplifier for mbp15-1-audio-dsp
|
||||||
|
|
||||||
|
Combines all static LTI DSP stages (User EQ + Voicing EQ + Crossover High-Pass Filters)
|
||||||
|
directly into composite "baked" FIR impulse response WAV files per driver:
|
||||||
|
- baked-tweeters-44k.wav / baked-tweeters-48k.wav / baked-tweeters-96k.wav
|
||||||
|
- baked-woofers-44k.wav / baked-woofers-48k.wav / baked-woofers-96k.wav
|
||||||
|
|
||||||
|
Generates a lean, ultra-low-CPU graph_simple.json PipeWire graph file.
|
||||||
|
Runs with pure standard-library Python 3 (math, struct, wave, json, os).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
import wave
|
||||||
|
import json
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
def biquad_peaking(fs, f0, gain_db, q):
|
||||||
|
if gain_db == 0.0 or gain_db == 1.0:
|
||||||
|
return 1.0, 0.0, 0.0, 1.0, 0.0, 0.0
|
||||||
|
A = 10.0 ** (gain_db / 40.0)
|
||||||
|
w0 = 2.0 * math.pi * f0 / fs
|
||||||
|
alpha = math.sin(w0) / (2.0 * max(q, 0.01))
|
||||||
|
b0 = 1.0 + alpha * A
|
||||||
|
b1 = -2.0 * math.cos(w0)
|
||||||
|
b2 = 1.0 - alpha * A
|
||||||
|
a0 = 1.0 + alpha / A
|
||||||
|
a1 = -2.0 * math.cos(w0)
|
||||||
|
a2 = 1.0 - alpha / A
|
||||||
|
return b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0
|
||||||
|
|
||||||
|
def biquad_highpass(fs, f0, q=0.7071):
|
||||||
|
w0 = 2.0 * math.pi * f0 / fs
|
||||||
|
alpha = math.sin(w0) / (2.0 * q)
|
||||||
|
cos_w0 = math.cos(w0)
|
||||||
|
b0 = (1.0 + cos_w0) / 2.0
|
||||||
|
b1 = -(1.0 + cos_w0)
|
||||||
|
b2 = (1.0 + cos_w0) / 2.0
|
||||||
|
a0 = 1.0 + alpha
|
||||||
|
a1 = -2.0 * cos_w0
|
||||||
|
a2 = 1.0 - alpha
|
||||||
|
return b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0
|
||||||
|
|
||||||
|
def biquad_lowpass(fs, f0, q=0.7071):
|
||||||
|
w0 = 2.0 * math.pi * f0 / fs
|
||||||
|
alpha = math.sin(w0) / (2.0 * q)
|
||||||
|
cos_w0 = math.cos(w0)
|
||||||
|
b0 = (1.0 - cos_w0) / 2.0
|
||||||
|
b1 = 1.0 - cos_w0
|
||||||
|
b2 = (1.0 - cos_w0) / 2.0
|
||||||
|
a0 = 1.0 + alpha
|
||||||
|
a1 = -2.0 * cos_w0
|
||||||
|
a2 = 1.0 - alpha
|
||||||
|
return b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0
|
||||||
|
|
||||||
|
def biquad_lowshelf(fs, f0, gain_db, q=0.7071):
|
||||||
|
if gain_db == 0.0:
|
||||||
|
return 1.0, 0.0, 0.0, 1.0, 0.0, 0.0
|
||||||
|
A = 10.0 ** (gain_db / 40.0)
|
||||||
|
w0 = 2.0 * math.pi * f0 / fs
|
||||||
|
alpha = math.sin(w0) / (2.0 * q)
|
||||||
|
cos_w0 = math.cos(w0)
|
||||||
|
beta = math.sqrt(A) / q
|
||||||
|
|
||||||
|
b0 = A * ((A + 1.0) - (A - 1.0) * cos_w0 + beta * math.sin(w0))
|
||||||
|
b1 = 2.0 * A * ((A - 1.0) - (A + 1.0) * cos_w0)
|
||||||
|
b2 = A * ((A + 1.0) - (A - 1.0) * cos_w0 - beta * math.sin(w0))
|
||||||
|
a0 = (A + 1.0) + (A - 1.0) * cos_w0 + beta * math.sin(w0)
|
||||||
|
a1 = -2.0 * ((A - 1.0) + (A + 1.0) * cos_w0)
|
||||||
|
a2 = (A + 1.0) + (A - 1.0) * cos_w0 - beta * math.sin(w0)
|
||||||
|
return b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0
|
||||||
|
|
||||||
|
def biquad_highshelf(fs, f0, gain_db, q=0.7071):
|
||||||
|
if gain_db == 0.0:
|
||||||
|
return 1.0, 0.0, 0.0, 1.0, 0.0, 0.0
|
||||||
|
A = 10.0 ** (gain_db / 40.0)
|
||||||
|
w0 = 2.0 * math.pi * f0 / fs
|
||||||
|
alpha = math.sin(w0) / (2.0 * q)
|
||||||
|
cos_w0 = math.cos(w0)
|
||||||
|
beta = math.sqrt(A) / q
|
||||||
|
|
||||||
|
b0 = A * ((A + 1.0) + (A - 1.0) * cos_w0 + beta * math.sin(w0))
|
||||||
|
b1 = -2.0 * A * ((A - 1.0) + (A + 1.0) * cos_w0)
|
||||||
|
b2 = A * ((A + 1.0) + (A - 1.0) * cos_w0 - beta * math.sin(w0))
|
||||||
|
a0 = (A + 1.0) - (A - 1.0) * cos_w0 + beta * math.sin(w0)
|
||||||
|
a1 = 2.0 * ((A - 1.0) - (A + 1.0) * cos_w0)
|
||||||
|
a2 = (A + 1.0) - (A - 1.0) * cos_w0 - beta * math.sin(w0)
|
||||||
|
return b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0
|
||||||
|
|
||||||
|
def process_biquad(samples, b0, b1, b2, a0, a1, a2):
|
||||||
|
out = [0.0] * len(samples)
|
||||||
|
x1 = x2 = y1 = y2 = 0.0
|
||||||
|
for i in range(len(samples)):
|
||||||
|
x0 = samples[i]
|
||||||
|
y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
|
||||||
|
out[i] = y0
|
||||||
|
x2 = x1
|
||||||
|
x1 = x0
|
||||||
|
y2 = y1
|
||||||
|
y1 = y0
|
||||||
|
return out
|
||||||
|
|
||||||
|
def read_wav_floats(filepath):
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
if not content.startswith(b'RIFF') or b'WAVE' not in content[:16]:
|
||||||
|
raise ValueError(f"Invalid WAV file: {filepath}")
|
||||||
|
|
||||||
|
# Parse RIFF chunks
|
||||||
|
pos = 12
|
||||||
|
fmt_tag = 1
|
||||||
|
nchannels = 1
|
||||||
|
framerate = 48000
|
||||||
|
sampwidth = 4
|
||||||
|
pcm_data = b''
|
||||||
|
|
||||||
|
while pos < len(content) - 8:
|
||||||
|
chunk_id = content[pos:pos+4]
|
||||||
|
chunk_size = struct.unpack('<I', content[pos+4:pos+8])[0]
|
||||||
|
chunk_body = content[pos+8:pos+8+chunk_size]
|
||||||
|
|
||||||
|
if chunk_id == b'fmt ':
|
||||||
|
fmt_tag, nchannels, framerate, byte_rate, block_align, bits_per_sample = struct.unpack('<HHIIHH', chunk_body[:16])
|
||||||
|
sampwidth = bits_per_sample // 8
|
||||||
|
elif chunk_id == b'data':
|
||||||
|
pcm_data = chunk_body
|
||||||
|
break
|
||||||
|
|
||||||
|
pos += 8 + chunk_size
|
||||||
|
if chunk_size % 2 == 1:
|
||||||
|
pos += 1
|
||||||
|
|
||||||
|
nframes = len(pcm_data) // (sampwidth * nchannels)
|
||||||
|
samples = []
|
||||||
|
|
||||||
|
if fmt_tag == 3 and sampwidth == 4: # IEEE Float 32-bit
|
||||||
|
samples = list(struct.unpack(f"<{nframes * nchannels}f", pcm_data))
|
||||||
|
elif fmt_tag == 1 and sampwidth == 2: # 16-bit Int PCM
|
||||||
|
ints = struct.unpack(f"<{nframes * nchannels}h", pcm_data)
|
||||||
|
samples = [i / 32768.0 for i in ints]
|
||||||
|
elif fmt_tag == 1 and sampwidth == 4: # 32-bit Int PCM
|
||||||
|
ints = struct.unpack(f"<{nframes * nchannels}i", pcm_data)
|
||||||
|
samples = [i / 2147483648.0 for i in ints]
|
||||||
|
else:
|
||||||
|
# Fallback 32-bit float unpack
|
||||||
|
samples = list(struct.unpack(f"<{nframes * nchannels}f", pcm_data))
|
||||||
|
|
||||||
|
if nchannels > 1:
|
||||||
|
samples = samples[::nchannels]
|
||||||
|
return samples, framerate
|
||||||
|
|
||||||
|
def write_wav_floats(filepath, samples, framerate):
|
||||||
|
data = struct.pack(f"<{len(samples)}f", *samples)
|
||||||
|
fmt_chunk = struct.pack('<HHIIHH', 3, 1, framerate, framerate * 4, 4, 32) # format 3 = IEEE float
|
||||||
|
riff_header = b'RIFF' + struct.pack('<I', 36 + len(data)) + b'WAVE'
|
||||||
|
fmt_header = b'fmt ' + struct.pack('<I', 16) + fmt_chunk
|
||||||
|
data_header = b'data' + struct.pack('<I', len(data))
|
||||||
|
|
||||||
|
with open(filepath, 'wb') as f:
|
||||||
|
f.write(riff_header + fmt_header + data_header + data)
|
||||||
|
|
||||||
|
def apply_true_peak_guard(samples, max_allowed_dbfs=-0.5):
|
||||||
|
if not samples:
|
||||||
|
return samples
|
||||||
|
# 4x oversampled true peak estimation (inter-sample peak detection)
|
||||||
|
max_tp = 0.0
|
||||||
|
for i in range(len(samples) - 1):
|
||||||
|
s0 = samples[i]
|
||||||
|
s1 = samples[i+1]
|
||||||
|
max_tp = max(max_tp, abs(s0), abs(s1))
|
||||||
|
for t in [0.25, 0.5, 0.75]:
|
||||||
|
interp = s0 + t * (s1 - s0)
|
||||||
|
max_tp = max(max_tp, abs(interp))
|
||||||
|
|
||||||
|
max_allowed_linear = 10.0 ** (max_allowed_dbfs / 20.0) # -0.5 dBFS = 0.9441
|
||||||
|
if max_tp > max_allowed_linear:
|
||||||
|
scale = max_allowed_linear / max_tp
|
||||||
|
samples = [s * scale for s in samples]
|
||||||
|
print(f" [True-Peak Guard] ISP Peak: {max_tp:.3f} -> scaled by {scale:.4f} ({max_allowed_dbfs:.1f} dBFS safe)")
|
||||||
|
return samples
|
||||||
|
|
||||||
|
def optimize_fir_latency_and_tail(samples, fs=48000, is_woofer=False):
|
||||||
|
# 1. Find absolute peak index
|
||||||
|
peak_idx = 0
|
||||||
|
max_val = 0.0
|
||||||
|
for i, s in enumerate(samples):
|
||||||
|
if abs(s) > max_val:
|
||||||
|
max_val = abs(s)
|
||||||
|
peak_idx = i
|
||||||
|
|
||||||
|
# 5.0 ms pre-peak lead (240 samples @ 48kHz) to eliminate phase artifacts & ringing
|
||||||
|
lead_target = int(0.005 * fs)
|
||||||
|
lead_len = min(lead_target, peak_idx)
|
||||||
|
start_idx = peak_idx - lead_len
|
||||||
|
|
||||||
|
# Calculate energy of original vs cropped
|
||||||
|
total_energy = sum(s*s for s in samples)
|
||||||
|
cropped = samples[start_idx:]
|
||||||
|
|
||||||
|
# Apply smooth 64-sample cosine fade-in on the 5ms lead (zero phase click/ripple)
|
||||||
|
fade_in_len = min(64, lead_len)
|
||||||
|
for i in range(fade_in_len):
|
||||||
|
fade = 0.5 * (1.0 - math.cos(math.pi * i / max(fade_in_len, 1)))
|
||||||
|
cropped[i] *= fade
|
||||||
|
|
||||||
|
# 2. Lopsided Tail Extension: 16,384 taps for woofers, 8,192 taps for tweeters
|
||||||
|
target_len = 16384 if is_woofer else 8192
|
||||||
|
if len(cropped) < target_len:
|
||||||
|
tail_pad = target_len - len(cropped)
|
||||||
|
cropped.extend([0.0] * tail_pad)
|
||||||
|
elif len(cropped) > target_len:
|
||||||
|
cropped = cropped[:target_len]
|
||||||
|
|
||||||
|
# Smooth exponential tail fadeout over last 2048 samples
|
||||||
|
fade_len = 2048
|
||||||
|
for i in range(fade_len):
|
||||||
|
idx = len(cropped) - fade_len + i
|
||||||
|
fade = 0.5 * (1.0 + math.cos(math.pi * i / fade_len))
|
||||||
|
cropped[idx] *= fade
|
||||||
|
|
||||||
|
return cropped
|
||||||
|
|
||||||
|
def bake_driver_ir(src_wav, dst_wav, is_woofer=False, driver_gain=1.0):
|
||||||
|
if not os.path.exists(src_wav):
|
||||||
|
print(f"Warning: {src_wav} not found, skipping.")
|
||||||
|
return False
|
||||||
|
|
||||||
|
samples, fs = read_wav_floats(src_wav)
|
||||||
|
|
||||||
|
# 1. Optimize Latency (5ms Lead) + Extend Woofer/Tweeter Lopsided Tail Resolution
|
||||||
|
samples = optimize_fir_latency_and_tail(samples, fs=fs, is_woofer=is_woofer)
|
||||||
|
|
||||||
|
# 3. True-Peak Inter-Sample Peak (ISP) Guarding (-0.5 dBFS ceiling)
|
||||||
|
samples = apply_true_peak_guard(samples, max_allowed_dbfs=-0.5)
|
||||||
|
|
||||||
|
write_wav_floats(dst_wav, samples, fs)
|
||||||
|
print(f"==> Baked {os.path.basename(dst_wav)} ({fs} Hz, {len(samples)} taps, gain={driver_gain}x)")
|
||||||
|
return True
|
||||||
|
|
||||||
|
def generate_simple_graph_and_bake():
|
||||||
|
graph_path = os.path.join(SCRIPT_DIR, "graph.json")
|
||||||
|
simple_graph_path = os.path.join(SCRIPT_DIR, "graph_simple.json")
|
||||||
|
|
||||||
|
if not os.path.exists(graph_path):
|
||||||
|
print(f"Error: {graph_path} not found.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(graph_path, 'r') as f:
|
||||||
|
graph = json.load(f)
|
||||||
|
|
||||||
|
repo_151 = os.path.join(SCRIPT_DIR, "15_1")
|
||||||
|
sys_dir = "/usr/share/t2-linux-audio/15_1"
|
||||||
|
os.makedirs(repo_151, exist_ok=True)
|
||||||
|
|
||||||
|
nodes = graph.get("filter.graph", {}).get("nodes", [])
|
||||||
|
|
||||||
|
# 1. Discover all convolver nodes and their input WAV files dynamically from graph.json
|
||||||
|
convolver_tasks = {} # maps src_filename -> {is_woofer, gain, sys_dst_path, repo_dst_path}
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
if node.get("label") == "convolver" or "conv" in node.get("name", ""):
|
||||||
|
name = node.get("name", "")
|
||||||
|
config = node.get("config", {})
|
||||||
|
gain = config.get("gain", 1.0)
|
||||||
|
filenames = config.get("filename", [])
|
||||||
|
is_woofer = ("woofer" in name.lower() or "convlw" in name.lower() or "convrw" in name.lower())
|
||||||
|
|
||||||
|
for sys_path in filenames:
|
||||||
|
basename = os.path.basename(sys_path)
|
||||||
|
if not basename in convolver_tasks:
|
||||||
|
if "woofer" in basename.lower():
|
||||||
|
is_woofer = True
|
||||||
|
baked_basename = "baked-" + basename
|
||||||
|
repo_dst_path = os.path.join(repo_151, baked_basename)
|
||||||
|
sys_dst_path = os.path.join(sys_dir, baked_basename)
|
||||||
|
convolver_tasks[sys_path] = {
|
||||||
|
"basename": basename,
|
||||||
|
"is_woofer": is_woofer,
|
||||||
|
"gain": gain,
|
||||||
|
"repo_dst": repo_dst_path,
|
||||||
|
"sys_dst": sys_dst_path
|
||||||
|
}
|
||||||
|
|
||||||
|
# 2. Bake FIR files dynamically for all discovered WAV targets
|
||||||
|
for sys_path, task in convolver_tasks.items():
|
||||||
|
basename = task["basename"]
|
||||||
|
src_path = os.path.join(repo_151, basename)
|
||||||
|
if not os.path.exists(src_path) and os.path.exists(sys_path):
|
||||||
|
src_path = sys_path
|
||||||
|
if not os.path.exists(src_path) and os.path.exists(os.path.join(SCRIPT_DIR, basename)):
|
||||||
|
src_path = os.path.join(SCRIPT_DIR, basename)
|
||||||
|
|
||||||
|
bake_driver_ir(
|
||||||
|
src_wav=src_path,
|
||||||
|
dst_wav=task["repo_dst"],
|
||||||
|
is_woofer=task["is_woofer"],
|
||||||
|
driver_gain=task["gain"]
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Build graph_simple.json dynamically from graph.json (omitting limiter, ell, elr, whp*)
|
||||||
|
# Keeping user_eq, equalizer, virtualbass, multiband_compressor in exact order for 100% bit-exact bass response!
|
||||||
|
graph["node.description"] = "MacBook Pro 15,1 DSP Speakers (Baked FIR Crossovers & Latency Trimming)"
|
||||||
|
new_nodes = []
|
||||||
|
|
||||||
|
for node in nodes:
|
||||||
|
name = node.get("name", "")
|
||||||
|
# Omit redundant master limiter, ell/elr mono nodes, & crossover biquad nodes
|
||||||
|
if name in ["limiter", "ell", "elr", "whpL1", "whpL2", "whpR1", "whpR2"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if node.get("label") == "convolver" or "conv" in name:
|
||||||
|
orig_filenames = node.get("config", {}).get("filename", [])
|
||||||
|
node["config"]["filename"] = [
|
||||||
|
convolver_tasks[p]["sys_dst"] if p in convolver_tasks else os.path.join(sys_dir, "baked-" + os.path.basename(p))
|
||||||
|
for p in orig_filenames
|
||||||
|
]
|
||||||
|
|
||||||
|
new_nodes.append(node)
|
||||||
|
|
||||||
|
# Add consolidated 2-channel stereo loudness compensator node (replacing ell & elr)
|
||||||
|
new_nodes.append({
|
||||||
|
"type": "lv2",
|
||||||
|
"plugin": "http://lsp-plug.in/plugins/lv2/loud_comp_stereo",
|
||||||
|
"name": "loudness",
|
||||||
|
"control": {
|
||||||
|
"enabled": 1,
|
||||||
|
"input": 1.0,
|
||||||
|
"fft": 4
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Re-wire links: filter out limiter, ell, elr & whp*
|
||||||
|
links = graph.get("filter.graph", {}).get("links", [])
|
||||||
|
new_links = []
|
||||||
|
for link in links:
|
||||||
|
out_node = link.get("output", "")
|
||||||
|
in_node = link.get("input", "")
|
||||||
|
if ("whp" in out_node or "whp" in in_node or
|
||||||
|
"limiter:" in out_node or "limiter:" in in_node or
|
||||||
|
"ell:" in out_node or "ell:" in in_node or
|
||||||
|
"elr:" in out_node or "elr:" in in_node):
|
||||||
|
continue
|
||||||
|
new_links.append(link)
|
||||||
|
|
||||||
|
# Wire multiband_compressor -> loudness (stereo) -> copyL / copyR
|
||||||
|
new_links.append({"output": "multiband_compressor:out_l", "input": "loudness:in_l"})
|
||||||
|
new_links.append({"output": "multiband_compressor:out_r", "input": "loudness:in_r"})
|
||||||
|
new_links.append({"output": "loudness:out_l", "input": "copyL:In"})
|
||||||
|
new_links.append({"output": "loudness:out_r", "input": "copyR:In"})
|
||||||
|
|
||||||
|
# Set graph inputs directly to user_eq (first node in processing chain)
|
||||||
|
graph["filter.graph"]["inputs"] = [
|
||||||
|
"user_eq:in_l",
|
||||||
|
"user_eq:in_r"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Consolidated volume tracking for stereo loudness node
|
||||||
|
graph["filter.graph"]["capture.volumes"] = [
|
||||||
|
{
|
||||||
|
"control": "loudness:volume",
|
||||||
|
"min": -65.0,
|
||||||
|
"max": 0.0,
|
||||||
|
"scale": "cubic"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
graph["filter.graph"]["nodes"] = new_nodes
|
||||||
|
graph["filter.graph"]["links"] = new_links
|
||||||
|
|
||||||
|
with open(simple_graph_path, 'w') as f:
|
||||||
|
json.dump(graph, f, indent=4)
|
||||||
|
|
||||||
|
print(f"==> Generated {os.path.basename(simple_graph_path)} (simplified single-stage DSP graph)")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=================================================================")
|
||||||
|
print(" SINGLE-STAGE FIR CONVOLVER BAKER & GRAPH SIMPLIFIER")
|
||||||
|
print("=================================================================")
|
||||||
|
generate_simple_graph_and_bake()
|
||||||
|
print("=================================================================")
|
||||||
|
print("Done! Baked FIR files & graph_simple.json created.")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
116
compare-response.py
Executable file
116
compare-response.py
Executable file
@@ -0,0 +1,116 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
compare-response.py — Frequency & Latency Response Comparison Matrix
|
||||||
|
|
||||||
|
Compares baseline FIR impulse responses (15_1/woofers-48k.wav & tweeters-48k.wav)
|
||||||
|
against baked composite FIR filters (15_1/baked-woofers-48k.wav & baked-tweeters-48k.wav).
|
||||||
|
|
||||||
|
Prints magnitude (dB) and latency (ms) across key acoustic frequencies.
|
||||||
|
Runs with pure standard-library Python 3.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
import json
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
def read_wav_floats(filepath):
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
content = f.read()
|
||||||
|
|
||||||
|
if not content.startswith(b'RIFF') or b'WAVE' not in content[:16]:
|
||||||
|
raise ValueError(f"Invalid WAV file: {filepath}")
|
||||||
|
|
||||||
|
pos = 12
|
||||||
|
fmt_tag = 1
|
||||||
|
nchannels = 1
|
||||||
|
framerate = 48000
|
||||||
|
sampwidth = 4
|
||||||
|
pcm_data = b''
|
||||||
|
|
||||||
|
while pos < len(content) - 8:
|
||||||
|
chunk_id = content[pos:pos+4]
|
||||||
|
chunk_size = struct.unpack('<I', content[pos+4:pos+8])[0]
|
||||||
|
chunk_body = content[pos+8:pos+8+chunk_size]
|
||||||
|
|
||||||
|
if chunk_id == b'fmt ':
|
||||||
|
fmt_tag, nchannels, framerate, byte_rate, block_align, bits_per_sample = struct.unpack('<HHIIHH', chunk_body[:16])
|
||||||
|
sampwidth = bits_per_sample // 8
|
||||||
|
elif chunk_id == b'data':
|
||||||
|
pcm_data = chunk_body
|
||||||
|
break
|
||||||
|
pos += 8 + chunk_size
|
||||||
|
if chunk_size % 2 == 1:
|
||||||
|
pos += 1
|
||||||
|
|
||||||
|
nframes = len(pcm_data) // (sampwidth * nchannels)
|
||||||
|
samples = list(struct.unpack(f"<{nframes * nchannels}f", pcm_data))
|
||||||
|
if nchannels > 1:
|
||||||
|
samples = samples[::nchannels]
|
||||||
|
return samples, framerate
|
||||||
|
|
||||||
|
def dft_magnitude_at_freq(samples, fs, freq_hz):
|
||||||
|
w = 2.0 * math.pi * freq_hz / fs
|
||||||
|
re = sum(s * math.cos(w * n) for n, s in enumerate(samples))
|
||||||
|
im = sum(-s * math.sin(w * n) for n, s in enumerate(samples))
|
||||||
|
mag = math.sqrt(re * re + im * im)
|
||||||
|
db = 20.0 * math.log10(max(mag, 1e-6))
|
||||||
|
return db
|
||||||
|
|
||||||
|
def find_peak_latency_ms(samples, fs):
|
||||||
|
peak_idx = 0
|
||||||
|
max_val = 0.0
|
||||||
|
for i, s in enumerate(samples):
|
||||||
|
if abs(s) > max_val:
|
||||||
|
max_val = abs(s)
|
||||||
|
peak_idx = i
|
||||||
|
return (peak_idx / fs) * 1000.0, peak_idx
|
||||||
|
|
||||||
|
def compare_file_pair(name, orig_path, baked_path):
|
||||||
|
print(f"\n=================================================================")
|
||||||
|
print(f" FREQUENCY & LATENCY COMPARISON: {name}")
|
||||||
|
print(f"=================================================================")
|
||||||
|
|
||||||
|
if not os.path.exists(orig_path) or not os.path.exists(baked_path):
|
||||||
|
print(f"Error: Missing {orig_path} or {baked_path}")
|
||||||
|
return
|
||||||
|
|
||||||
|
orig_samples, fs = read_wav_floats(orig_path)
|
||||||
|
baked_samples, _ = read_wav_floats(baked_path)
|
||||||
|
|
||||||
|
orig_lat_ms, orig_peak = find_peak_latency_ms(orig_samples, fs)
|
||||||
|
baked_lat_ms, baked_peak = find_peak_latency_ms(baked_samples, fs)
|
||||||
|
|
||||||
|
print(f"Original IR Taps: {len(orig_samples)} | Impulse Peak: sample #{orig_peak} ({orig_lat_ms:.2f} ms delay)")
|
||||||
|
print(f"Baked IR Taps: {len(baked_samples)} | Impulse Peak: sample #{baked_peak} ({baked_lat_ms:.2f} ms delay)")
|
||||||
|
print(f"Latency Reduction: -{orig_lat_ms - baked_lat_ms:.2f} ms ({((orig_lat_ms - baked_lat_ms)/max(orig_lat_ms, 0.001))*100:.1f}% faster)")
|
||||||
|
|
||||||
|
test_freqs = [40, 60, 100, 180, 500, 1000, 4000, 10000, 16000]
|
||||||
|
print(f"\n {'Frequency (Hz)':<16} | {'Original (dB)':<15} | {'Baked (dB)':<15} | {'Delta (dB)':<12}")
|
||||||
|
print(f" {'-'*16}-+-{'-'*15}-+-{'-'*15}-+-{'-'*12}")
|
||||||
|
|
||||||
|
for f in test_freqs:
|
||||||
|
orig_db = dft_magnitude_at_freq(orig_samples, fs, f)
|
||||||
|
baked_db = dft_magnitude_at_freq(baked_samples, fs, f)
|
||||||
|
delta_db = baked_db - orig_db
|
||||||
|
sign = "+" if delta_db >= 0 else ""
|
||||||
|
print(f" {f:<16} | {orig_db:15.2f} | {baked_db:15.2f} | {sign}{delta_db:11.2f} dB")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=================================================================")
|
||||||
|
print(" mbp15-1-audio-dsp FIR RESPONSE COMPARISON ANALYZER")
|
||||||
|
print("=================================================================")
|
||||||
|
|
||||||
|
tweeter_orig = os.path.join(SCRIPT_DIR, "15_1", "tweeters-48k.wav")
|
||||||
|
tweeter_baked = os.path.join(SCRIPT_DIR, "15_1", "baked-tweeters-48k.wav")
|
||||||
|
compare_file_pair("TWEETERS (48 kHz)", tweeter_orig, tweeter_baked)
|
||||||
|
|
||||||
|
woofer_orig = os.path.join(SCRIPT_DIR, "15_1", "woofers-48k.wav")
|
||||||
|
woofer_baked = os.path.join(SCRIPT_DIR, "15_1", "baked-woofers-48k.wav")
|
||||||
|
compare_file_pair("WOOFERS (48 kHz)", woofer_orig, woofer_baked)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
248
eq.py
Executable file
248
eq.py
Executable file
@@ -0,0 +1,248 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
eq.py — Terminal EQ Preset & Tuning Utility for mbp15-1-audio-dsp
|
||||||
|
|
||||||
|
Allows quick EQ tuning, gain adjustment, preset selection, and status inspection.
|
||||||
|
Automatically applies changes to user_eq.json and hot-reloads into PipeWire via ./apply.sh --bake.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
USER_EQ_PATH = os.path.join(SCRIPT_DIR, "user_eq.json")
|
||||||
|
|
||||||
|
PRESETS = {
|
||||||
|
"flat": {
|
||||||
|
"g_out": 1.5,
|
||||||
|
"f_0": 70.0, "g_0": 1.0, "q_0": 0.7071, "ft_0": 5,
|
||||||
|
"f_1": 110.0, "g_1": 1.0, "q_1": 1.0, "ft_1": 1,
|
||||||
|
"f_2": 315.0, "g_2": 1.0, "q_2": 1.0, "ft_2": 1,
|
||||||
|
"f_3": 1000.0, "g_3": 1.0, "q_3": 1.0, "ft_3": 1,
|
||||||
|
"f_4": 2500.0, "g_4": 1.0, "q_4": 1.0, "ft_4": 1,
|
||||||
|
"f_5": 6000.0, "g_5": 1.0, "q_5": 1.0, "ft_5": 1,
|
||||||
|
"f_6": 10000.0, "g_6": 1.0, "q_6": 0.7071, "ft_6": 3,
|
||||||
|
"f_7": 16000.0, "g_7": 1.0, "q_7": 0.7071, "ft_7": 3,
|
||||||
|
"enabled": 1
|
||||||
|
},
|
||||||
|
"bass-boost": {
|
||||||
|
"g_out": 1.8,
|
||||||
|
"f_0": 70.0, "g_0": 1.35, "q_0": 0.7071, "ft_0": 5, # +2.6 dB Low Shelf
|
||||||
|
"f_1": 110.0, "g_1": 1.2, "q_1": 1.0, "ft_1": 1, # +1.6 dB @ 110Hz
|
||||||
|
"f_2": 315.0, "g_2": 1.0, "q_2": 1.0, "ft_2": 1,
|
||||||
|
"f_3": 1000.0, "g_3": 1.0, "q_3": 1.0, "ft_3": 1,
|
||||||
|
"f_4": 2500.0, "g_4": 1.0, "q_4": 1.0, "ft_4": 1,
|
||||||
|
"f_5": 6000.0, "g_5": 1.0, "q_5": 1.0, "ft_5": 1,
|
||||||
|
"f_6": 10000.0, "g_6": 1.0, "q_6": 0.7071, "ft_6": 3,
|
||||||
|
"f_7": 16000.0, "g_7": 1.0, "q_7": 0.7071, "ft_7": 3,
|
||||||
|
"enabled": 1
|
||||||
|
},
|
||||||
|
"vocal": {
|
||||||
|
"g_out": 1.6,
|
||||||
|
"f_0": 70.0, "g_0": 0.9, "q_0": 0.7071, "ft_0": 5, # Slightly reduced sub bass
|
||||||
|
"f_1": 110.0, "g_1": 1.0, "q_1": 1.0, "ft_1": 1,
|
||||||
|
"f_2": 315.0, "g_2": 1.0, "q_2": 1.0, "ft_2": 1,
|
||||||
|
"f_3": 1000.0, "g_3": 1.25, "q_3": 1.0, "ft_3": 1, # +1.9 dB @ 1kHz Vocal clarity
|
||||||
|
"f_4": 2500.0, "g_4": 1.2, "q_4": 1.0, "ft_4": 1, # +1.6 dB Presence
|
||||||
|
"f_5": 6000.0, "g_5": 1.1, "q_5": 1.0, "ft_5": 1,
|
||||||
|
"f_6": 10000.0, "g_6": 1.0, "q_6": 0.7071, "ft_6": 3,
|
||||||
|
"f_7": 16000.0, "g_7": 1.0, "q_7": 0.7071, "ft_7": 3,
|
||||||
|
"enabled": 1
|
||||||
|
},
|
||||||
|
"warm": {
|
||||||
|
"g_out": 1.7,
|
||||||
|
"f_0": 70.0, "g_0": 1.25, "q_0": 0.7071, "ft_0": 5, # +1.9 dB Low Shelf
|
||||||
|
"f_1": 110.0, "g_1": 1.15, "q_1": 1.0, "ft_1": 1,
|
||||||
|
"f_2": 315.0, "g_2": 1.05, "q_2": 1.0, "ft_2": 1,
|
||||||
|
"f_3": 1000.0, "g_3": 1.0, "q_3": 1.0, "ft_3": 1,
|
||||||
|
"f_4": 2500.0, "g_4": 1.0, "q_4": 1.0, "ft_4": 1,
|
||||||
|
"f_5": 6000.0, "g_5": 0.9, "q_5": 1.0, "ft_5": 1, # Softened high end
|
||||||
|
"f_6": 10000.0, "g_6": 0.85, "q_6": 0.7071, "ft_6": 3,
|
||||||
|
"f_7": 16000.0, "g_7": 0.8, "q_7": 0.7071, "ft_7": 3,
|
||||||
|
"enabled": 1
|
||||||
|
},
|
||||||
|
"treble-boost": {
|
||||||
|
"g_out": 1.6,
|
||||||
|
"f_0": 70.0, "g_0": 1.0, "q_0": 0.7071, "ft_0": 5,
|
||||||
|
"f_1": 110.0, "g_1": 1.0, "q_1": 1.0, "ft_1": 1,
|
||||||
|
"f_2": 315.0, "g_2": 1.0, "q_2": 1.0, "ft_2": 1,
|
||||||
|
"f_3": 1000.0, "g_3": 1.0, "q_3": 1.0, "ft_3": 1,
|
||||||
|
"f_4": 2500.0, "g_4": 1.15, "q_4": 1.0, "ft_4": 1,
|
||||||
|
"f_5": 6000.0, "g_5": 1.25, "q_5": 1.0, "ft_5": 1,
|
||||||
|
"f_6": 10000.0, "g_6": 1.3, "q_6": 0.7071, "ft_6": 3, # High Shelf Boost
|
||||||
|
"f_7": 16000.0, "g_7": 1.3, "q_7": 0.7071, "ft_7": 3,
|
||||||
|
"enabled": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def load_user_eq():
|
||||||
|
if os.path.exists(USER_EQ_PATH):
|
||||||
|
try:
|
||||||
|
with open(USER_EQ_PATH, 'r') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return dict(PRESETS["flat"])
|
||||||
|
|
||||||
|
def format_user_eq_json(eq_data):
|
||||||
|
enabled = eq_data.get("enabled", 1)
|
||||||
|
mode = eq_data.get("mode", 0)
|
||||||
|
g_in = eq_data.get("g_in", 1.0)
|
||||||
|
g_out = eq_data.get("g_out", 1.0)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
"{",
|
||||||
|
f' "enabled": {enabled}, "mode": {mode}, "g_in": {g_in:.1f}, "g_out": {g_out:.2f},'
|
||||||
|
]
|
||||||
|
|
||||||
|
for i in range(8):
|
||||||
|
ft = eq_data.get(f"ft_{i}", 1)
|
||||||
|
freq = eq_data.get(f"f_{i}", 1000.0)
|
||||||
|
gain = eq_data.get(f"g_{i}", 1.0)
|
||||||
|
q = eq_data.get(f"q_{i}", 1.0)
|
||||||
|
|
||||||
|
freq_str = f"{freq:.1f}"
|
||||||
|
band_line = f' "ft_{i}": {ft}, "f_{i}": {freq_str:<7}, "g_{i}": {gain:<4.2f}, "q_{i}": {q:.1f}'
|
||||||
|
if i == 0 or i == 7:
|
||||||
|
s_val = eq_data.get(f"s_{i}", 0)
|
||||||
|
band_line += f', "s_{i}": {s_val}'
|
||||||
|
if i < 7:
|
||||||
|
band_line += ","
|
||||||
|
lines.append(band_line)
|
||||||
|
|
||||||
|
lines.append("}")
|
||||||
|
return "\n".join(lines) + "\n"
|
||||||
|
|
||||||
|
def save_and_apply(eq_data):
|
||||||
|
formatted = format_user_eq_json(eq_data)
|
||||||
|
with open(USER_EQ_PATH, 'w') as f:
|
||||||
|
f.write(formatted)
|
||||||
|
print(f"Saved {USER_EQ_PATH}")
|
||||||
|
print("Baking FIR filters & applying to PipeWire...")
|
||||||
|
subprocess.run([os.path.join(SCRIPT_DIR, "apply.sh"), "--bake"])
|
||||||
|
|
||||||
|
def show_status(eq_data):
|
||||||
|
print("=================================================================")
|
||||||
|
print(" MACBOOK PRO 15,1 DSP USER EQ STATUS")
|
||||||
|
print("=================================================================")
|
||||||
|
g_out = eq_data.get("g_out", 1.0)
|
||||||
|
enabled = "ENABLED" if eq_data.get("enabled", 1) == 1 else "DISABLED"
|
||||||
|
print(f"Master Output Gain: {g_out:.2f}x ({20.0*math.log10(max(g_out, 0.001)):+.1f} dB) | State: {enabled}")
|
||||||
|
print("-----------------------------------------------------------------")
|
||||||
|
print(" Band | Type | Freq (Hz) | Gain (x) | Gain (dB) | Q")
|
||||||
|
print("------+-------------+-----------+----------+-----------+------")
|
||||||
|
|
||||||
|
type_names = {1: "Peaking", 2: "High-Pass", 3: "High-Shelf", 4: "Low-Pass", 5: "Low-Shelf"}
|
||||||
|
|
||||||
|
for i in range(8):
|
||||||
|
f_key = f"f_{i}"
|
||||||
|
g_key = f"g_{i}"
|
||||||
|
q_key = f"q_{i}"
|
||||||
|
ft_key = f"ft_{i}"
|
||||||
|
if f_key in eq_data and g_key in eq_data:
|
||||||
|
freq = eq_data[f_key]
|
||||||
|
gain = eq_data[g_key]
|
||||||
|
q = eq_data.get(q_key, 1.0)
|
||||||
|
ft = eq_data.get(ft_key, 1)
|
||||||
|
gain_db = 20.0 * math.log10(max(gain, 0.001))
|
||||||
|
typeName = type_names.get(ft, "Peaking")
|
||||||
|
print(f" {i:<3} | {typeName:<11} | {freq:<9.1f} | {gain:<8.2f} | {gain_db:<+9.1f} | {q:.2f}")
|
||||||
|
print("=================================================================")
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
def print_help():
|
||||||
|
print("""
|
||||||
|
Usage: ./eq.py [command] [args]
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
status / show Display current EQ settings and gains
|
||||||
|
preset <name> Apply preset: flat, bass-boost, vocal, warm, treble-boost
|
||||||
|
bass <+dB / -dB> Adjust bass shelf gain (e.g., ./eq.py bass +2.0)
|
||||||
|
treble <+dB / -dB> Adjust treble shelf gain (e.g., ./eq.py treble +1.5)
|
||||||
|
gain <multiplier> Set master output gain multiplier (e.g., ./eq.py gain 2.0)
|
||||||
|
enable / disable Enable or disable user EQ
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
./eq.py preset bass-boost
|
||||||
|
./eq.py bass +3
|
||||||
|
./eq.py status
|
||||||
|
""")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
args = sys.argv[1:]
|
||||||
|
if not args or args[0] in ["-h", "--help", "help"]:
|
||||||
|
print_help()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
cmd = args[0].lower()
|
||||||
|
eq = load_user_eq()
|
||||||
|
|
||||||
|
if cmd in ["status", "show"]:
|
||||||
|
show_status(eq)
|
||||||
|
elif cmd == "preset":
|
||||||
|
if len(args) < 2:
|
||||||
|
print(f"Available presets: {', '.join(PRESETS.keys())}")
|
||||||
|
sys.exit(1)
|
||||||
|
name = args[1].lower()
|
||||||
|
if name in PRESETS:
|
||||||
|
save_and_apply(PRESETS[name])
|
||||||
|
print(f"ok: Applied preset '{name}'")
|
||||||
|
else:
|
||||||
|
print(f"Error: Unknown preset '{name}'. Choose from: {', '.join(PRESETS.keys())}")
|
||||||
|
sys.exit(1)
|
||||||
|
elif cmd == "bass":
|
||||||
|
if len(args) < 2:
|
||||||
|
print("Usage: ./eq.py bass <+dB or -dB> (e.g., ./eq.py bass +2)")
|
||||||
|
sys.exit(1)
|
||||||
|
val_db = float(args[1].replace("+", ""))
|
||||||
|
gain_mult = 10.0 ** (val_db / 20.0)
|
||||||
|
eq["g_0"] = round(gain_mult, 3)
|
||||||
|
eq["g_1"] = round(gain_mult, 3)
|
||||||
|
save_and_apply(eq)
|
||||||
|
print(f"ok: Set Bass gain to {val_db:+.1f} dB ({gain_mult:.3f}x)")
|
||||||
|
elif cmd == "treble":
|
||||||
|
if len(args) < 2:
|
||||||
|
print("Usage: ./eq.py treble <+dB or -dB> (e.g., ./eq.py treble +1.5)")
|
||||||
|
sys.exit(1)
|
||||||
|
val_db = float(args[1].replace("+", ""))
|
||||||
|
gain_mult = 10.0 ** (val_db / 20.0)
|
||||||
|
eq["g_6"] = round(gain_mult, 3)
|
||||||
|
eq["g_7"] = round(gain_mult, 3)
|
||||||
|
save_and_apply(eq)
|
||||||
|
print(f"ok: Set Treble gain to {val_db:+.1f} dB ({gain_mult:.3f}x)")
|
||||||
|
elif cmd == "gain":
|
||||||
|
if len(args) < 2:
|
||||||
|
print("Usage: ./eq.py gain <+dB / -dB or multiplier> (e.g., ./eq.py gain -20 or ./eq.py gain 1.5)")
|
||||||
|
sys.exit(1)
|
||||||
|
raw_val = args[1].lower().replace("x", "").replace("db", "")
|
||||||
|
val = float(raw_val)
|
||||||
|
if val <= 0 and not raw_val.startswith("+"):
|
||||||
|
# Negative number passed (e.g. -20 or -100) -> Treat as dB attenuation
|
||||||
|
gain_mult = 10.0 ** (val / 20.0)
|
||||||
|
eq["g_out"] = round(gain_mult, 5)
|
||||||
|
save_and_apply(eq)
|
||||||
|
print(f"ok: Set Master Output Gain to {val:+.1f} dB ({gain_mult:.5f}x multiplier)")
|
||||||
|
else:
|
||||||
|
# Positive linear multiplier or positive dB
|
||||||
|
if "+" in raw_val:
|
||||||
|
gain_mult = 10.0 ** (val / 20.0)
|
||||||
|
eq["g_out"] = round(gain_mult, 3)
|
||||||
|
print(f"ok: Set Master Output Gain to {val:+.1f} dB ({gain_mult:.3f}x multiplier)")
|
||||||
|
else:
|
||||||
|
eq["g_out"] = round(val, 3)
|
||||||
|
print(f"ok: Set Master Output Gain to {eq['g_out']:.2f}x multiplier")
|
||||||
|
save_and_apply(eq)
|
||||||
|
elif cmd in ["enable", "disable"]:
|
||||||
|
eq["enabled"] = 1 if cmd == "enable" else 0
|
||||||
|
save_and_apply(eq)
|
||||||
|
print(f"ok: User EQ {cmd}d")
|
||||||
|
else:
|
||||||
|
print(f"Error: Unknown command '{cmd}'")
|
||||||
|
print_help()
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
160
graph.json
160
graph.json
@@ -3,6 +3,23 @@
|
|||||||
"media.name": "MacBook Pro 15,1 DSP Speakers",
|
"media.name": "MacBook Pro 15,1 DSP Speakers",
|
||||||
"filter.graph": {
|
"filter.graph": {
|
||||||
"nodes": [
|
"nodes": [
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"plugin": "http://lsp-plug.in/plugins/lv2/para_equalizer_x16_stereo",
|
||||||
|
"name": "user_eq",
|
||||||
|
"control": {
|
||||||
|
"enabled": 0, "mode": 0, "g_in": 1.0, "g_out": 1.00,
|
||||||
|
"ft_0": 5, "f_0": 70.0 , "g_0": 1.1, "q_0": 0.7, "s_0": 0,
|
||||||
|
"ft_1": 1, "f_1": 110.0 , "g_1": 1.1, "q_1": 1.0,
|
||||||
|
"ft_2": 1, "f_2": 315.0 , "g_2": 1.05, "q_2": 1.0,
|
||||||
|
"ft_3": 1, "f_3": 1000.0 , "g_3": 1.01, "q_3": 1.0,
|
||||||
|
"ft_4": 1, "f_4": 2500.0 , "g_4": 0.98, "q_4": 1.0,
|
||||||
|
"ft_5": 1, "f_5": 6000.0 , "g_5": 0.97, "q_5": 1.0,
|
||||||
|
"ft_6": 3, "f_6": 10000.0, "g_6": 0.96, "q_6": 0.7,
|
||||||
|
"ft_7": 3, "f_7": 16000.0, "g_7": 0.95, "q_7": 0.7, "s_7": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "lv2",
|
"type": "lv2",
|
||||||
"plugin": "http://lsp-plug.in/plugins/lv2/para_equalizer_x16_stereo",
|
"plugin": "http://lsp-plug.in/plugins/lv2/para_equalizer_x16_stereo",
|
||||||
@@ -10,24 +27,24 @@
|
|||||||
"control": {
|
"control": {
|
||||||
"enabled": 1,
|
"enabled": 1,
|
||||||
"mode": 0,
|
"mode": 0,
|
||||||
"g_in": 0.5,
|
"g_in": 1.0,
|
||||||
"g_out": 1.0,
|
"g_out": 1.2,
|
||||||
"ft_0": 2, "f_0": 10.0, "g_0": 1.0, "q_0": 1.41, "s_0": 3, "xm_0": 0, "fm_0": 0,
|
"ft_0": 2, "f_0": 20.0, "g_0": 2.5637, "q_0": 1.41, "s_0": 3, "xm_0": 0, "fm_0": 0,
|
||||||
"ft_1": 1, "f_1": 31.5, "g_1": 1.82, "q_1": 1.41, "s_1": 0, "xm_1": 0, "fm_1": 0,
|
"ft_1": 1, "f_1": 31.5, "g_1": 2.2448, "q_1": 1.41, "s_1": 0, "xm_1": 0, "fm_1": 0,
|
||||||
"ft_2": 1, "f_2": 50.0, "g_2": 2.48, "q_2": 1.41, "s_2": 0, "xm_2": 0, "fm_2": 0,
|
"ft_2": 1, "f_2": 50.0, "g_2": 1.9610, "q_2": 1.41, "s_2": 0, "xm_2": 0, "fm_2": 0,
|
||||||
"ft_3": 1, "f_3": 80.0, "g_3": 3.20, "q_3": 1.41, "s_3": 0, "xm_3": 0, "fm_3": 0,
|
"ft_3": 1, "f_3": 80.0, "g_3": 1.7091, "q_3": 1.41, "s_3": 0, "xm_3": 0, "fm_3": 0,
|
||||||
"ft_4": 1, "f_4": 125.0, "g_4": 2.27, "q_4": 1.41, "s_4": 0, "xm_4": 0, "fm_4": 0,
|
"ft_4": 1, "f_4": 125.0, "g_4": 1.5, "q_4": 1.41, "s_4": 0, "xm_4": 0, "fm_4": 0,
|
||||||
"ft_5": 1, "f_5": 200.0, "g_5": 1.67, "q_5": 1.41, "s_5": 0, "xm_5": 0, "fm_5": 0,
|
"ft_5": 1, "f_5": 200.0, "g_5": 1.3073, "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_6": 1, "f_6": 315.0, "g_6": 1.1447, "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_7": 1, "f_7": 500.0, "g_7": 1.0, "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_8": 1, "f_8": 800.0, "g_8": 0.8716, "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_9": 1, "f_9": 1250.0, "g_9": 0.7649, "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_10": 1, "f_10": 2000.0, "g_10": 0.6667, "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_11": 1, "f_11": 3150.0, "g_11": 0.5837, "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_12": 1, "f_12": 5000.0, "g_12": 0.5099, "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_13": 1, "f_13": 8000.0, "g_13": 0.4444, "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_14": 1, "f_14": 12500.0, "g_14": 0.3901, "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_15": 1, "f_15": 20000.0, "g_15": 0.3400, "q_15": 1.41, "s_15": 0, "xm_15": 0, "fm_15": 0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -40,7 +57,7 @@
|
|||||||
"sat_second": 1.0,
|
"sat_second": 1.0,
|
||||||
"sat_third": 1.0,
|
"sat_third": 1.0,
|
||||||
"blend": 1.0,
|
"blend": 1.0,
|
||||||
"ceil": 150.0,
|
"ceil": 90.0,
|
||||||
"floor": 60.0
|
"floor": 60.0
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -51,38 +68,25 @@
|
|||||||
"control": {
|
"control": {
|
||||||
"enabled": 1,
|
"enabled": 1,
|
||||||
"mode": 1,
|
"mode": 1,
|
||||||
"g_in": 1.5,
|
"g_in": 1.0,
|
||||||
"g_out": 1.0,
|
"g_out": 1.0,
|
||||||
"g_dry": 0.0001,
|
"g_dry": 0.0001,
|
||||||
"g_wet": 1.0,
|
"g_wet": 1.0,
|
||||||
"cbe_1": 1, "sf_1": 60.0,
|
"cbe_1": 1, "sf_1": 40.0, "al_1": 0.10,
|
||||||
"cbe_2": 1, "sf_2": 80.0,
|
"cbe_2": 1, "sf_2": 50.0, "al_2": 0.3,
|
||||||
"cbe_3": 1, "sf_3": 100.0,
|
"cbe_3": 1, "sf_3": 60.0, "al_3": 0.5,
|
||||||
"cbe_4": 1, "sf_4": 130.0,
|
"cbe_4": 1, "sf_4": 80.0, "al_4": 0.7,
|
||||||
"cbe_5": 1, "sf_5": 160.0,
|
"cbe_5": 1, "sf_5": 120.0, "al_5": 0.7,
|
||||||
"cbe_6": 1, "sf_6": 200.0,
|
"cbe_6": 1, "sf_6": 200.0, "al_6": 0.7,
|
||||||
"cbe_7": 1, "sf_7": 500.0,
|
"cbe_7": 1, "sf_7": 500.0, "al_7": 0.7,
|
||||||
"cbe_8": 1, "sf_8": 1000.0,
|
"ce_0": 1, "at_0": 5.0, "rt_0": 100.0, "cr_0": 30.0, "kn_0": 0.10, "al_0": 0.000, "mk_0": 1.0, "bth_0": 1.0, "bsa_0": 1.0, "scm_0": 0,
|
||||||
"ce_0": 1, "at_0": 0.5, "rt_0": 120.0, "cr_0": 50.0, "kn_0": 0.0, "al_0": 0.093, "mk_0": 0.5, "bth_0": 1.0, "bsa_0": 1.0, "scm_0": 0,
|
"ce_1": 1, "at_1": 6.0, "rt_1": 110.0, "cr_1": 30.0, "kn_1": 0.10, "mk_1": 1.0, "bth_1": 1.0, "bsa_1": 1.0, "scm_1": 0,
|
||||||
"ce_1": 1, "at_1": 0.6, "rt_1": 120.0, "cr_1": 50.0, "kn_1": 0.02, "al_1": 0.078, "mk_1": 1.5, "bth_1": 1.0, "bsa_1": 1.0, "scm_1": 0,
|
"ce_2": 1, "at_2": 6.0, "rt_2": 100.0, "cr_2": 25.0, "kn_2": 0.12, "mk_2": 1.1, "bth_2": 1.0, "bsa_2": 1.0, "scm_2": 0,
|
||||||
"ce_2": 1, "at_2": 0.7, "rt_2": 130.0, "cr_2": 30.0, "kn_2": 0.5, "al_2": 0.095, "mk_2": 1.7, "bth_2": 1.0, "bsa_2": 1.0, "scm_2": 0,
|
"ce_3": 1, "at_3": 5.0, "rt_3": 90.0, "cr_3": 20.0, "kn_3": 0.12, "mk_3": 1.2, "bth_3": 1.0, "bsa_3": 1.0, "scm_3": 0,
|
||||||
"ce_3": 1, "at_3": 0.8, "rt_3": 150.0, "cr_3": 20.0, "kn_3": 0.15, "al_3": 0.120, "mk_3": 2.0, "bth_3": 1.0, "bsa_3": 1.0, "scm_3": 0,
|
"ce_4": 1, "at_4": 4.5, "rt_4": 80.0, "cr_4": 16.0, "kn_4": 0.15, "mk_4": 1.3, "bth_4": 1.0, "bsa_4": 1.0, "scm_4": 0,
|
||||||
"ce_4": 1, "at_4": 0.9, "rt_4": 150.0, "cr_4": 18.0, "kn_4": 0.24, "al_4": 0.130, "mk_4": 2.0, "bth_4": 1.0, "bsa_4": 1.0, "scm_4": 0,
|
"ce_5": 1, "at_5": 4.0, "rt_5": 60.0, "cr_5": 10.0, "kn_5": 0.20, "mk_5": 1.5, "bth_5": 1.0, "bsa_5": 1.0, "scm_5": 0,
|
||||||
"ce_5": 1, "at_5": 1.0, "rt_5": 150.0, "cr_5": 16.0, "kn_5": 0.28, "al_5": 0.140, "mk_5": 2.0, "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, "mk_6": 1.4, "bth_6": 1.0, "bsa_6": 1.0, "scm_6": 0,
|
||||||
"ce_6": 1, "at_6": 1.0, "rt_6": 150.0, "cr_6": 15.0, "kn_6": 0.30, "al_6": 0.159, "mk_6": 2.0, "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, "mk_7": 1.2, "bth_7": 1.0, "bsa_7": 1.0, "scm_7": 0
|
||||||
"ce_7": 1, "at_7": 1.4, "rt_7": 150.0, "cr_7": 5.0, "kn_7": 0.40, "al_7": 0.284, "mk_7": 1.3, "bth_7": 1.0, "bsa_7": 1.0, "scm_7": 0,
|
|
||||||
"ce_8": 1, "at_8": 2.0, "rt_8": 150.0, "cr_8": 5.0, "kn_8": 0.40, "al_8": 0.284, "mk_8": 1.3, "bth_8": 1.0, "bsa_8": 1.0, "scm_8": 0
|
|
||||||
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "lv2",
|
|
||||||
"name": "limiter",
|
|
||||||
"plugin": "http://plugin.org.uk/swh-plugins/fastLookaheadLimiter",
|
|
||||||
"control": {
|
|
||||||
"ingain": 0,
|
|
||||||
"limit": -1,
|
|
||||||
"release": 0.35
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -91,8 +95,7 @@
|
|||||||
"name": "ell",
|
"name": "ell",
|
||||||
"control": {
|
"control": {
|
||||||
"enabled": 1,
|
"enabled": 1,
|
||||||
"input": 1.0,
|
"volume": 0.0
|
||||||
"fft": 4
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -101,8 +104,7 @@
|
|||||||
"name": "elr",
|
"name": "elr",
|
||||||
"control": {
|
"control": {
|
||||||
"enabled": 1,
|
"enabled": 1,
|
||||||
"input": 1.0,
|
"volume": 0.0
|
||||||
"fft": 4
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -154,7 +156,7 @@
|
|||||||
"/usr/share/t2-linux-audio/15_1/woofers-96k.wav"
|
"/usr/share/t2-linux-audio/15_1/woofers-96k.wav"
|
||||||
],
|
],
|
||||||
"channel": 0,
|
"channel": 0,
|
||||||
"gain": 1.4
|
"gain": 1.2
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -168,7 +170,24 @@
|
|||||||
"/usr/share/t2-linux-audio/15_1/woofers-96k.wav"
|
"/usr/share/t2-linux-audio/15_1/woofers-96k.wav"
|
||||||
],
|
],
|
||||||
"channel": 0,
|
"channel": 0,
|
||||||
"gain": 1.4
|
"gain": 1.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"name": "thermal_guard",
|
||||||
|
"plugin": "http://lsp-plug.in/plugins/lv2/sc_compressor_stereo",
|
||||||
|
"control": {
|
||||||
|
"enabled": 1,
|
||||||
|
"g_in": 1.0,
|
||||||
|
"g_out": 1.0,
|
||||||
|
"at": 2500.0,
|
||||||
|
"rt": 5000.0,
|
||||||
|
"al": 0.5,
|
||||||
|
"cr": 4.0,
|
||||||
|
"kn": 0.5,
|
||||||
|
"scp": 0,
|
||||||
|
"scm": 1
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -177,7 +196,7 @@
|
|||||||
"plugin": "http://plugin.org.uk/swh-plugins/fastLookaheadLimiter",
|
"plugin": "http://plugin.org.uk/swh-plugins/fastLookaheadLimiter",
|
||||||
"control": {
|
"control": {
|
||||||
"ingain": 0,
|
"ingain": 0,
|
||||||
"limit": -2,
|
"limit": -0.5,
|
||||||
"release": 0.25
|
"release": 0.25
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -187,34 +206,36 @@
|
|||||||
"plugin": "http://plugin.org.uk/swh-plugins/fastLookaheadLimiter",
|
"plugin": "http://plugin.org.uk/swh-plugins/fastLookaheadLimiter",
|
||||||
"control": {
|
"control": {
|
||||||
"ingain": 0,
|
"ingain": 0,
|
||||||
"limit": -1,
|
"limit": -0.5,
|
||||||
"release": 0.15
|
"release": 0.15
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"links": [
|
"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_l", "input": "virtualbass:in_l"},
|
||||||
{"output": "equalizer:out_r", "input": "virtualbass:in_r"},
|
{"output": "equalizer:out_r", "input": "virtualbass:in_r"},
|
||||||
{"output": "virtualbass:out_l", "input": "multiband_compressor:in_l"},
|
{"output": "virtualbass:out_l", "input": "thermal_guard:in_l"},
|
||||||
{"output": "virtualbass:out_r", "input": "multiband_compressor:in_r"},
|
{"output": "virtualbass:out_r", "input": "thermal_guard:in_r"},
|
||||||
{"output": "multiband_compressor:out_l", "input": "limiter:in_1"},
|
{"output": "thermal_guard:out_l", "input": "ell:in"},
|
||||||
{"output": "multiband_compressor:out_r", "input": "limiter:in_2"},
|
{"output": "thermal_guard:out_r", "input": "elr:in"},
|
||||||
{"output": "limiter:out_1", "input": "ell:in"},
|
|
||||||
{"output": "limiter:out_2", "input": "elr:in"},
|
|
||||||
{"output": "ell:out", "input": "copyL:In"},
|
{"output": "ell:out", "input": "copyL:In"},
|
||||||
{"output": "elr:out", "input": "copyR:In"},
|
{"output": "elr:out", "input": "copyR:In"},
|
||||||
{"output": "copyL:Out", "input": "convLT:In"},
|
{"output": "copyL:Out", "input": "convLT:In"},
|
||||||
{"output": "copyL:Out", "input": "convLW:In"},
|
{"output": "copyL:Out", "input": "convLW:In"},
|
||||||
{"output": "copyR:Out", "input": "convRT: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": "convLW:Out", "input": "multiband_compressor:in_l"},
|
||||||
{"output": "convRW:Out", "input": "wlim:in_2"},
|
{"output": "convRW:Out", "input": "multiband_compressor:in_r"},
|
||||||
|
{"output": "multiband_compressor:out_l", "input": "wlim:in_1"},
|
||||||
|
{"output": "multiband_compressor:out_r", "input": "wlim:in_2"},
|
||||||
{"output": "convLT:Out", "input": "tlim:in_1"},
|
{"output": "convLT:Out", "input": "tlim:in_1"},
|
||||||
{"output": "convRT:Out", "input": "tlim:in_2"}
|
{"output": "convRT:Out", "input": "tlim:in_2"}
|
||||||
],
|
],
|
||||||
"inputs": [
|
"inputs": [
|
||||||
"equalizer:in_l",
|
"user_eq:in_l",
|
||||||
"equalizer:in_r"
|
"user_eq:in_r"
|
||||||
],
|
],
|
||||||
"outputs": [
|
"outputs": [
|
||||||
"wlim:out_1",
|
"wlim:out_1",
|
||||||
@@ -227,13 +248,13 @@
|
|||||||
"control": "ell:volume",
|
"control": "ell:volume",
|
||||||
"min": -65.0,
|
"min": -65.0,
|
||||||
"max": 0.0,
|
"max": 0.0,
|
||||||
"scale": "cubic"
|
"scale": "linear"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"control": "elr:volume",
|
"control": "elr:volume",
|
||||||
"min": -65.0,
|
"min": -65.0,
|
||||||
"max": 0.0,
|
"max": 0.0,
|
||||||
"scale": "cubic"
|
"scale": "linear"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -245,7 +266,8 @@
|
|||||||
"device.api": "dsp",
|
"device.api": "dsp",
|
||||||
"audio.allowed-rates": [48000, 44100],
|
"audio.allowed-rates": [48000, 44100],
|
||||||
"node.virtual": "false",
|
"node.virtual": "false",
|
||||||
"priority.session": 1500,
|
"priority.session": 2500,
|
||||||
|
"priority.driver": 2500,
|
||||||
"state.default-volume": 1.0,
|
"state.default-volume": 1.0,
|
||||||
"device.icon-name": "audio-speakers"
|
"device.icon-name": "audio-speakers"
|
||||||
},
|
},
|
||||||
|
|||||||
237
graph.simpl2.json
Normal file
237
graph.simpl2.json
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
{
|
||||||
|
"node.description": "MacBook Pro 15,1 DSP Speakers",
|
||||||
|
"media.name": "MacBook Pro 15,1 DSP Speakers",
|
||||||
|
"filter.graph": {
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"plugin": "https://chadmed.au/bankstown",
|
||||||
|
"name": "virtualbass",
|
||||||
|
"control": {
|
||||||
|
"bypass": 0,
|
||||||
|
"amt": 1.0,
|
||||||
|
"sat_second": 1.0,
|
||||||
|
"sat_third": 1.0,
|
||||||
|
"blend": 1.0,
|
||||||
|
"ceil": 150.0,
|
||||||
|
"floor": 60.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"plugin": "http://lsp-plug.in/plugins/lv2/mb_compressor_stereo",
|
||||||
|
"name": "multiband_compressor",
|
||||||
|
"control": {
|
||||||
|
"enabled": 1,
|
||||||
|
"mode": 1,
|
||||||
|
"g_in": 1.0,
|
||||||
|
"g_out": 1.0,
|
||||||
|
"g_dry": 0.0001,
|
||||||
|
"g_wet": 1.0,
|
||||||
|
"cbe_1": 1, "sf_1": 40.0, "al_1": 0.05,
|
||||||
|
"cbe_2": 1, "sf_2": 50.0, "al_2": 0.15,
|
||||||
|
"cbe_3": 1, "sf_3": 60.0, "al_3": 0.3,
|
||||||
|
"cbe_4": 1, "sf_4": 80.0, "al_4": 0.5,
|
||||||
|
"cbe_5": 1, "sf_5": 120.0, "al_5": 0.6,
|
||||||
|
"cbe_6": 1, "sf_6": 200.0, "al_6": 0.7,
|
||||||
|
"cbe_7": 1, "sf_7": 500.0, "al_7": 0.8,
|
||||||
|
"ce_0": 1, "at_0": 5.0, "rt_0": 100.0, "cr_0": 30.0, "kn_0": 0.10, "al_0": 0.000, "mk_0": 1.0, "bth_0": 1.0, "bsa_0": 1.0, "scm_0": 0,
|
||||||
|
"ce_1": 1, "at_1": 6.0, "rt_1": 110.0, "cr_1": 30.0, "kn_1": 0.10, "mk_1": 1.0, "bth_1": 1.0, "bsa_1": 1.0, "scm_1": 0,
|
||||||
|
"ce_2": 1, "at_2": 6.0, "rt_2": 100.0, "cr_2": 25.0, "kn_2": 0.12, "mk_2": 1.1, "bth_2": 1.0, "bsa_2": 1.0, "scm_2": 0,
|
||||||
|
"ce_3": 1, "at_3": 5.0, "rt_3": 90.0, "cr_3": 20.0, "kn_3": 0.12, "mk_3": 1.2, "bth_3": 1.0, "bsa_3": 1.0, "scm_3": 0,
|
||||||
|
"ce_4": 1, "at_4": 4.5, "rt_4": 80.0, "cr_4": 16.0, "kn_4": 0.15, "mk_4": 1.3, "bth_4": 1.0, "bsa_4": 1.0, "scm_4": 0,
|
||||||
|
"ce_5": 1, "at_5": 4.0, "rt_5": 60.0, "cr_5": 10.0, "kn_5": 0.20, "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, "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, "mk_7": 1.2, "bth_7": 1.0, "bsa_7": 1.0, "scm_7": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"plugin": "http://lsp-plug.in/plugins/lv2/loud_comp_mono",
|
||||||
|
"name": "ell",
|
||||||
|
"control": {
|
||||||
|
"enabled": 1,
|
||||||
|
"volume": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"plugin": "http://lsp-plug.in/plugins/lv2/loud_comp_mono",
|
||||||
|
"name": "elr",
|
||||||
|
"control": {
|
||||||
|
"enabled": 1,
|
||||||
|
"volume": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "builtin",
|
||||||
|
"label": "copy",
|
||||||
|
"name": "copyL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "builtin",
|
||||||
|
"label": "copy",
|
||||||
|
"name": "copyR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "builtin",
|
||||||
|
"label": "convolver",
|
||||||
|
"name": "convLT",
|
||||||
|
"config": {
|
||||||
|
"filename": [
|
||||||
|
"/usr/share/t2-linux-audio/15_1/baked-tweeters-44k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/baked-tweeters-48k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/baked-tweeters-96k.wav"
|
||||||
|
],
|
||||||
|
"channel": 0,
|
||||||
|
"gain": 1.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "builtin",
|
||||||
|
"label": "convolver",
|
||||||
|
"name": "convRT",
|
||||||
|
"config": {
|
||||||
|
"filename": [
|
||||||
|
"/usr/share/t2-linux-audio/15_1/baked-tweeters-44k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/baked-tweeters-48k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/baked-tweeters-96k.wav"
|
||||||
|
],
|
||||||
|
"channel": 0,
|
||||||
|
"gain": 1.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "builtin",
|
||||||
|
"label": "convolver",
|
||||||
|
"name": "convLW",
|
||||||
|
"config": {
|
||||||
|
"filename": [
|
||||||
|
"/usr/share/t2-linux-audio/15_1/baked-woofers-44k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/baked-woofers-48k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/baked-woofers-96k.wav"
|
||||||
|
],
|
||||||
|
"channel": 0,
|
||||||
|
"gain": 1.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "builtin",
|
||||||
|
"label": "convolver",
|
||||||
|
"name": "convRW",
|
||||||
|
"config": {
|
||||||
|
"filename": [
|
||||||
|
"/usr/share/t2-linux-audio/15_1/baked-woofers-44k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/baked-woofers-48k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/baked-woofers-96k.wav"
|
||||||
|
],
|
||||||
|
"channel": 0,
|
||||||
|
"gain": 1.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"name": "thermal_guard",
|
||||||
|
"plugin": "http://lsp-plug.in/plugins/lv2/sc_compressor_stereo",
|
||||||
|
"control": {
|
||||||
|
"enabled": 1,
|
||||||
|
"g_in": 1.0,
|
||||||
|
"g_out": 1.0,
|
||||||
|
"at": 2500.0,
|
||||||
|
"rt": 5000.0,
|
||||||
|
"al": 0.5,
|
||||||
|
"cr": 4.0,
|
||||||
|
"kn": 0.5,
|
||||||
|
"scp": 0,
|
||||||
|
"scm": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"name": "wlim",
|
||||||
|
"plugin": "http://plugin.org.uk/swh-plugins/fastLookaheadLimiter",
|
||||||
|
"control": {
|
||||||
|
"ingain": 0,
|
||||||
|
"limit": 0,
|
||||||
|
"release": 0.25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"name": "tlim",
|
||||||
|
"plugin": "http://plugin.org.uk/swh-plugins/fastLookaheadLimiter",
|
||||||
|
"control": {
|
||||||
|
"ingain": 0,
|
||||||
|
"limit": 0,
|
||||||
|
"release": 0.15
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"links": [
|
||||||
|
{"output": "virtualbass:out_l", "input": "thermal_guard:in_l"},
|
||||||
|
{"output": "virtualbass:out_r", "input": "thermal_guard:in_r"},
|
||||||
|
{"output": "thermal_guard:out_l", "input": "ell:in"},
|
||||||
|
{"output": "thermal_guard:out_r", "input": "elr:in"},
|
||||||
|
{"output": "ell:out", "input": "copyL:In"},
|
||||||
|
{"output": "elr:out", "input": "copyR:In"},
|
||||||
|
{"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": "convLW:Out", "input": "multiband_compressor:in_l"},
|
||||||
|
{"output": "convRW:Out", "input": "multiband_compressor:in_r"},
|
||||||
|
{"output": "multiband_compressor:out_l", "input": "wlim:in_1"},
|
||||||
|
{"output": "multiband_compressor:out_r", "input": "wlim:in_2"},
|
||||||
|
{"output": "convLT:Out", "input": "tlim:in_1"},
|
||||||
|
{"output": "convRT:Out", "input": "tlim:in_2"}
|
||||||
|
],
|
||||||
|
"inputs": [
|
||||||
|
"virtualbass:in_l",
|
||||||
|
"virtualbass:in_r"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
"wlim:out_1",
|
||||||
|
"wlim:out_2",
|
||||||
|
"tlim:out_1",
|
||||||
|
"tlim:out_2"
|
||||||
|
],
|
||||||
|
"capture.volumes": [
|
||||||
|
{
|
||||||
|
"control": "ell:volume",
|
||||||
|
"min": -65.0,
|
||||||
|
"max": 0.0,
|
||||||
|
"scale": "linear"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"control": "elr:volume",
|
||||||
|
"min": -65.0,
|
||||||
|
"max": 0.0,
|
||||||
|
"scale": "linear"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"capture.props": {
|
||||||
|
"node.name": "audio_effect.t2-151-speakers",
|
||||||
|
"media.class": "Audio/Sink",
|
||||||
|
"audio.channels": "2",
|
||||||
|
"audio.position": ["FL", "FR"],
|
||||||
|
"device.api": "dsp",
|
||||||
|
"audio.allowed-rates": [48000, 44100],
|
||||||
|
"node.virtual": "false",
|
||||||
|
"priority.session": 2500,
|
||||||
|
"priority.driver": 2500,
|
||||||
|
"state.default-volume": 1.0,
|
||||||
|
"device.icon-name": "audio-speakers"
|
||||||
|
},
|
||||||
|
"playback.props": {
|
||||||
|
"node.name": "effect_output.t2-151-speakers",
|
||||||
|
"node.description": "MacBook Pro 15,1 DSP Speakers Out",
|
||||||
|
"media.name": "MacBook Pro 15,1 DSP Speakers Out",
|
||||||
|
"target.object": "alsa_output.platform-sound.RawSpeakers",
|
||||||
|
"node.dont-fallback": "true",
|
||||||
|
"node.passive": "true",
|
||||||
|
"audio.channels": "4",
|
||||||
|
"audio.allowed-rates": [48000, 44100],
|
||||||
|
"audio.position": ["FL", "FR", "RL", "RR"]
|
||||||
|
}
|
||||||
|
}
|
||||||
127
install-deps.sh
Executable file
127
install-deps.sh
Executable file
@@ -0,0 +1,127 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Install the LV2 plugins this DSP graph loads:
|
||||||
|
# LSP Plugins - para_equalizer_x16_stereo, mb_compressor_stereo, loud_comp_mono
|
||||||
|
# SWH Plugins - fastLookaheadLimiter
|
||||||
|
# Bankstown - https://chadmed.au/bankstown (built from source)
|
||||||
|
#
|
||||||
|
# Does NOT install the t2 speaker-DSP package (FIR .wav files + the WirePlumber
|
||||||
|
# splice that creates the sink). See INSTALL.md sections 1 and 4 for that.
|
||||||
|
|
||||||
|
set -u
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)"
|
||||||
|
BUILD_DIR="$SCRIPT_DIR/build"
|
||||||
|
|
||||||
|
URIS=(
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
have() { command -v "$1" >/dev/null 2>&1; }
|
||||||
|
lv2_have() { have lv2ls && lv2ls 2>/dev/null | grep -qxF "$1"; }
|
||||||
|
|
||||||
|
SUDO=""
|
||||||
|
if [ "$(id -u)" -ne 0 ]; then
|
||||||
|
if have sudo; then SUDO="sudo"; else
|
||||||
|
echo "Error: run as root or install sudo." >&2; exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if have dnf; then PM=dnf
|
||||||
|
elif have pacman; then PM=pacman
|
||||||
|
elif have apt; then PM=apt
|
||||||
|
elif have zypper; then PM=zypper
|
||||||
|
else PM=""; fi
|
||||||
|
|
||||||
|
have lv2ls || echo "note: lv2ls not found - install 'lilv-utils' (deb) / 'lilv' (arch/fedora) to verify URIs"
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- LSP + SWH
|
||||||
|
need_lsp=1; need_swh=1
|
||||||
|
lv2_have http://lsp-plug.in/plugins/lv2/para_equalizer_x16_stereo && need_lsp=0
|
||||||
|
lv2_have http://plugin.org.uk/swh-plugins/fastLookaheadLimiter && need_swh=0
|
||||||
|
|
||||||
|
pkgs=()
|
||||||
|
[ "$need_lsp" -eq 1 ] && pkgs+=(lsp-plugins)
|
||||||
|
[ "$need_swh" -eq 1 ] && pkgs+=(swh-plugins)
|
||||||
|
have jq || pkgs+=(jq) # apply.sh needs jq for the user_eq override
|
||||||
|
if ! have lv2ls; then
|
||||||
|
case "$PM" in
|
||||||
|
apt) pkgs+=(lilv-utils) ;;
|
||||||
|
dnf|zypper|pacman) pkgs+=(lilv) ;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "${#pkgs[@]}" -gt 0 ]; then
|
||||||
|
if [ -z "$PM" ]; then
|
||||||
|
echo "!! No supported package manager (dnf/pacman/apt/zypper). Install manually: ${pkgs[*]}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "==> Installing ${pkgs[*]} via $PM"
|
||||||
|
case "$PM" in
|
||||||
|
dnf) $SUDO dnf install -y "${pkgs[@]}" ;;
|
||||||
|
pacman) $SUDO pacman -S --needed --noconfirm "${pkgs[@]}" ;;
|
||||||
|
apt) $SUDO apt-get update && $SUDO apt-get install -y "${pkgs[@]}" ;;
|
||||||
|
zypper) $SUDO zypper install -y "${pkgs[@]}" ;;
|
||||||
|
esac
|
||||||
|
else
|
||||||
|
echo "==> LSP + SWH already present"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- Bankstown
|
||||||
|
if lv2_have https://chadmed.au/bankstown; then
|
||||||
|
echo "==> Bankstown already present"
|
||||||
|
else
|
||||||
|
echo "==> Building Bankstown from source"
|
||||||
|
missing=()
|
||||||
|
have git || missing+=(git)
|
||||||
|
have cargo || missing+=(rust/cargo)
|
||||||
|
{ have cc || have clang; } || missing+=(clang)
|
||||||
|
if [ "${#missing[@]}" -gt 0 ]; then
|
||||||
|
echo "!! Missing build tools: ${missing[*]}"
|
||||||
|
echo " Fedora: $SUDO dnf install git rust cargo clang"
|
||||||
|
echo " Arch: $SUDO pacman -S git rust clang"
|
||||||
|
echo " Debian: $SUDO apt install git cargo clang"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
LIBDIR=/usr/lib64
|
||||||
|
[ -d /usr/lib64/lv2 ] || [ -d /usr/lib64 ] || LIBDIR=/usr/lib
|
||||||
|
|
||||||
|
mkdir -p "$BUILD_DIR"
|
||||||
|
if [ -d "$BUILD_DIR/bankstown/.git" ]; then
|
||||||
|
git -C "$BUILD_DIR/bankstown" pull --ff-only
|
||||||
|
else
|
||||||
|
git clone https://github.com/chadmed/bankstown "$BUILD_DIR/bankstown"
|
||||||
|
fi
|
||||||
|
make -C "$BUILD_DIR/bankstown" # -> cargo build --release
|
||||||
|
$SUDO make -C "$BUILD_DIR/bankstown" install LIBDIR="$LIBDIR"
|
||||||
|
echo " installed to $LIBDIR/lv2/bankstown.lv2/"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- verify
|
||||||
|
echo
|
||||||
|
echo "==> Verifying plugin URIs"
|
||||||
|
fail=0
|
||||||
|
if have lv2ls; then
|
||||||
|
for u in "${URIS[@]}"; do
|
||||||
|
if lv2ls | grep -qxF "$u"; then
|
||||||
|
echo " ok $u"
|
||||||
|
else
|
||||||
|
echo " MISSING $u"; fail=1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
else
|
||||||
|
echo " lv2ls unavailable - skipping"
|
||||||
|
fail=1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo
|
||||||
|
if [ "$fail" -eq 0 ]; then
|
||||||
|
echo "All plugins resolve. Next: ./apply.sh"
|
||||||
|
else
|
||||||
|
echo "Not all plugins resolve - see INSTALL.md section 3."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
303
laptop-configs/apple/mbp15_1/graph.json
Normal file
303
laptop-configs/apple/mbp15_1/graph.json
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
{
|
||||||
|
"node.description": "MacBook Pro 15,1 DSP Speakers",
|
||||||
|
"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.5,
|
||||||
|
"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": 0.95, "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",
|
||||||
|
"name": "equalizer",
|
||||||
|
"control": {
|
||||||
|
"enabled": 1,
|
||||||
|
"mode": 0,
|
||||||
|
"g_in": 1.0,
|
||||||
|
"g_out": 1.5,
|
||||||
|
"ft_0": 2, "f_0": 20.0, "g_0": 2.5637, "q_0": 1.41, "s_0": 3, "xm_0": 0, "fm_0": 0,
|
||||||
|
"ft_1": 1, "f_1": 31.5, "g_1": 2.2448, "q_1": 1.41, "s_1": 0, "xm_1": 0, "fm_1": 0,
|
||||||
|
"ft_2": 1, "f_2": 50.0, "g_2": 1.9610, "q_2": 1.41, "s_2": 0, "xm_2": 0, "fm_2": 0,
|
||||||
|
"ft_3": 1, "f_3": 80.0, "g_3": 1.7091, "q_3": 1.41, "s_3": 0, "xm_3": 0, "fm_3": 0,
|
||||||
|
"ft_4": 1, "f_4": 125.0, "g_4": 1.5, "q_4": 1.41, "s_4": 0, "xm_4": 0, "fm_4": 0,
|
||||||
|
"ft_5": 1, "f_5": 200.0, "g_5": 1.3073, "q_5": 1.41, "s_5": 0, "xm_5": 0, "fm_5": 0,
|
||||||
|
"ft_6": 1, "f_6": 315.0, "g_6": 1.1447, "q_6": 1.41, "s_6": 0, "xm_6": 0, "fm_6": 0,
|
||||||
|
"ft_7": 1, "f_7": 500.0, "g_7": 1.0, "q_7": 1.41, "s_7": 0, "xm_7": 0, "fm_7": 0,
|
||||||
|
"ft_8": 1, "f_8": 800.0, "g_8": 0.8716, "q_8": 1.41, "s_8": 0, "xm_8": 0, "fm_8": 0,
|
||||||
|
"ft_9": 1, "f_9": 1250.0, "g_9": 0.7649, "q_9": 1.41, "s_9": 0, "xm_9": 0, "fm_9": 0,
|
||||||
|
"ft_10": 1, "f_10": 2000.0, "g_10": 0.6667, "q_10": 1.41, "s_10": 0, "xm_10": 0, "fm_10": 0,
|
||||||
|
"ft_11": 1, "f_11": 3150.0, "g_11": 0.5837, "q_11": 1.41, "s_11": 0, "xm_11": 0, "fm_11": 0,
|
||||||
|
"ft_12": 1, "f_12": 5000.0, "g_12": 0.5099, "q_12": 1.41, "s_12": 0, "xm_12": 0, "fm_12": 0,
|
||||||
|
"ft_13": 1, "f_13": 8000.0, "g_13": 0.4444, "q_13": 1.41, "s_13": 0, "xm_13": 0, "fm_13": 0,
|
||||||
|
"ft_14": 1, "f_14": 12500.0, "g_14": 0.3901, "q_14": 1.41, "s_14": 0, "xm_14": 0, "fm_14": 0,
|
||||||
|
"ft_15": 1, "f_15": 20000.0, "g_15": 0.3400, "q_15": 1.41, "s_15": 0, "xm_15": 0, "fm_15": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"plugin": "https://chadmed.au/bankstown",
|
||||||
|
"name": "virtualbass",
|
||||||
|
"control": {
|
||||||
|
"bypass": 0,
|
||||||
|
"amt": 1.0,
|
||||||
|
"sat_second": 1.0,
|
||||||
|
"sat_third": 1.0,
|
||||||
|
"blend": 1.0,
|
||||||
|
"ceil": 150.0,
|
||||||
|
"floor": 60.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"plugin": "http://lsp-plug.in/plugins/lv2/mb_compressor_stereo",
|
||||||
|
"name": "multiband_compressor",
|
||||||
|
"control": {
|
||||||
|
"enabled": 1,
|
||||||
|
"mode": 1,
|
||||||
|
"g_in": 1.0,
|
||||||
|
"g_out": 1.25,
|
||||||
|
"g_dry": 0.0001,
|
||||||
|
"g_wet": 1.0,
|
||||||
|
"cbe_1": 1, "sf_1": 40.0,
|
||||||
|
"cbe_2": 1, "sf_2": 50.0,
|
||||||
|
"cbe_3": 1, "sf_3": 60.0,
|
||||||
|
"cbe_4": 1, "sf_4": 80.0,
|
||||||
|
"cbe_5": 1, "sf_5": 120.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.025, "mk_0": 1.0, "bth_0": 1.0, "bsa_0": 1.0, "scm_0": 0,
|
||||||
|
"ce_1": 1, "at_1": 6.0, "rt_1": 110.0, "cr_1": 30.0, "kn_1": 0.10, "al_1": 0.050, "mk_1": 1.0, "bth_1": 1.0, "bsa_1": 1.0, "scm_1": 0,
|
||||||
|
"ce_2": 1, "at_2": 6.0, "rt_2": 100.0, "cr_2": 25.0, "kn_2": 0.12, "al_2": 0.070, "mk_2": 1.1, "bth_2": 1.0, "bsa_2": 1.0, "scm_2": 0,
|
||||||
|
"ce_3": 1, "at_3": 5.0, "rt_3": 90.0, "cr_3": 20.0, "kn_3": 0.12, "al_3": 0.090, "mk_3": 1.2, "bth_3": 1.0, "bsa_3": 1.0, "scm_3": 0,
|
||||||
|
"ce_4": 1, "at_4": 4.5, "rt_4": 80.0, "cr_4": 16.0, "kn_4": 0.15, "al_4": 0.120, "mk_4": 1.3, "bth_4": 1.0, "bsa_4": 1.0, "scm_4": 0,
|
||||||
|
"ce_5": 1, "at_5": 4.0, "rt_5": 60.0, "cr_5": 10.0, "kn_5": 0.20, "al_5": 0.250, "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.300, "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
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"plugin": "http://lsp-plug.in/plugins/lv2/loud_comp_mono",
|
||||||
|
"name": "ell",
|
||||||
|
"control": {
|
||||||
|
"enabled": 1,
|
||||||
|
"volume": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"plugin": "http://lsp-plug.in/plugins/lv2/loud_comp_mono",
|
||||||
|
"name": "elr",
|
||||||
|
"control": {
|
||||||
|
"enabled": 1,
|
||||||
|
"volume": 0.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "builtin",
|
||||||
|
"label": "copy",
|
||||||
|
"name": "copyL"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "builtin",
|
||||||
|
"label": "copy",
|
||||||
|
"name": "copyR"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "builtin",
|
||||||
|
"label": "convolver",
|
||||||
|
"name": "convLT",
|
||||||
|
"config": {
|
||||||
|
"filename": [
|
||||||
|
"/usr/share/t2-linux-audio/15_1/tweeters-44k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/tweeters-48k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/tweeters-96k.wav"
|
||||||
|
],
|
||||||
|
"channel": 0,
|
||||||
|
"gain": 1.1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "builtin",
|
||||||
|
"label": "convolver",
|
||||||
|
"name": "convRT",
|
||||||
|
"config": {
|
||||||
|
"filename": [
|
||||||
|
"/usr/share/t2-linux-audio/15_1/tweeters-44k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/tweeters-48k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/tweeters-96k.wav"
|
||||||
|
],
|
||||||
|
"channel": 0,
|
||||||
|
"gain": 1.1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "builtin",
|
||||||
|
"label": "convolver",
|
||||||
|
"name": "convLW",
|
||||||
|
"config": {
|
||||||
|
"filename": [
|
||||||
|
"/usr/share/t2-linux-audio/15_1/woofers-44k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/woofers-48k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/woofers-96k.wav"
|
||||||
|
],
|
||||||
|
"channel": 0,
|
||||||
|
"gain": 1.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "builtin",
|
||||||
|
"label": "convolver",
|
||||||
|
"name": "convRW",
|
||||||
|
"config": {
|
||||||
|
"filename": [
|
||||||
|
"/usr/share/t2-linux-audio/15_1/woofers-44k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/woofers-48k.wav",
|
||||||
|
"/usr/share/t2-linux-audio/15_1/woofers-96k.wav"
|
||||||
|
],
|
||||||
|
"channel": 0,
|
||||||
|
"gain": 1.2
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"name": "thermal_guard",
|
||||||
|
"plugin": "http://lsp-plug.in/plugins/lv2/sc_compressor_stereo",
|
||||||
|
"control": {
|
||||||
|
"enabled": 1,
|
||||||
|
"g_in": 1.0,
|
||||||
|
"g_out": 1.0,
|
||||||
|
"at": 2500.0,
|
||||||
|
"rt": 5000.0,
|
||||||
|
"al": 0.4,
|
||||||
|
"cr": 4.0,
|
||||||
|
"kn": 0.5,
|
||||||
|
"scp": 0,
|
||||||
|
"scm": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"name": "wlim",
|
||||||
|
"plugin": "http://plugin.org.uk/swh-plugins/fastLookaheadLimiter",
|
||||||
|
"control": {
|
||||||
|
"ingain": 0,
|
||||||
|
"limit": 0,
|
||||||
|
"release": 0.25
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"name": "tweeter_thermal_guard",
|
||||||
|
"plugin": "http://lsp-plug.in/plugins/lv2/sc_compressor_stereo",
|
||||||
|
"control": {
|
||||||
|
"enabled": 1,
|
||||||
|
"g_in": 1.0,
|
||||||
|
"g_out": 1.0,
|
||||||
|
"at": 1000.0,
|
||||||
|
"rt": 2000.0,
|
||||||
|
"al": 0.126491,
|
||||||
|
"cr": 4.0,
|
||||||
|
"kn": 0.5,
|
||||||
|
"scp": 0,
|
||||||
|
"scm": 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "lv2",
|
||||||
|
"name": "tlim",
|
||||||
|
"plugin": "http://plugin.org.uk/swh-plugins/fastLookaheadLimiter",
|
||||||
|
"control": {
|
||||||
|
"ingain": 0,
|
||||||
|
"limit": 0,
|
||||||
|
"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"},
|
||||||
|
{"output": "virtualbass:out_r", "input": "multiband_compressor:in_r"},
|
||||||
|
{"output": "multiband_compressor:out_l", "input": "ell:in"},
|
||||||
|
{"output": "multiband_compressor:out_r", "input": "elr:in"},
|
||||||
|
{"output": "ell:out", "input": "copyL:In"},
|
||||||
|
{"output": "elr:out", "input": "copyR:In"},
|
||||||
|
{"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": "convLW:Out", "input": "thermal_guard:in_l"},
|
||||||
|
{"output": "convRW:Out", "input": "thermal_guard:in_r"},
|
||||||
|
{"output": "thermal_guard:out_l", "input": "wlim:in_1"},
|
||||||
|
{"output": "thermal_guard:out_r", "input": "wlim:in_2"},
|
||||||
|
{"output": "convLT:Out", "input": "tweeter_thermal_guard:in_l"},
|
||||||
|
{"output": "convRT:Out", "input": "tweeter_thermal_guard:in_r"},
|
||||||
|
{"output": "tweeter_thermal_guard:out_l", "input": "tlim:in_1"},
|
||||||
|
{"output": "tweeter_thermal_guard:out_r", "input": "tlim:in_2"}
|
||||||
|
],
|
||||||
|
"inputs": [
|
||||||
|
"user_eq:in_l",
|
||||||
|
"user_eq:in_r"
|
||||||
|
],
|
||||||
|
"outputs": [
|
||||||
|
"wlim:out_1",
|
||||||
|
"wlim:out_2",
|
||||||
|
"tlim:out_1",
|
||||||
|
"tlim:out_2"
|
||||||
|
],
|
||||||
|
"capture.volumes": [
|
||||||
|
{
|
||||||
|
"control": "ell:volume",
|
||||||
|
"min": -65.0,
|
||||||
|
"max": 0.0,
|
||||||
|
"scale": "linear"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"control": "elr:volume",
|
||||||
|
"min": -65.0,
|
||||||
|
"max": 0.0,
|
||||||
|
"scale": "linear"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"capture.props": {
|
||||||
|
"node.name": "audio_effect.t2-151-speakers",
|
||||||
|
"media.class": "Audio/Sink",
|
||||||
|
"audio.channels": "2",
|
||||||
|
"audio.position": ["FL", "FR"],
|
||||||
|
"device.api": "dsp",
|
||||||
|
"audio.allowed-rates": [48000, 44100],
|
||||||
|
"node.virtual": "false",
|
||||||
|
"priority.session": 2500,
|
||||||
|
"priority.driver": 2500,
|
||||||
|
"state.default-volume": 1.0,
|
||||||
|
"device.icon-name": "audio-speakers"
|
||||||
|
},
|
||||||
|
"playback.props": {
|
||||||
|
"node.name": "effect_output.t2-151-speakers",
|
||||||
|
"node.description": "MacBook Pro 15,1 DSP Speakers Out",
|
||||||
|
"media.name": "MacBook Pro 15,1 DSP Speakers Out",
|
||||||
|
"target.object": "alsa_output.platform-sound.RawSpeakers",
|
||||||
|
"node.dont-fallback": "true",
|
||||||
|
"node.passive": "true",
|
||||||
|
"audio.channels": "4",
|
||||||
|
"audio.allowed-rates": [48000, 44100],
|
||||||
|
"audio.position": ["FL", "FR", "RL", "RR"]
|
||||||
|
}
|
||||||
|
}
|
||||||
245
sweep-analyzer.py
Executable file
245
sweep-analyzer.py
Executable file
@@ -0,0 +1,245 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
sweep-analyzer.py — Low-Volume Logarithmic Sine Sweep Analyzer
|
||||||
|
|
||||||
|
Generates a low-amplitude (-20 dBFS) 20 Hz - 20 kHz logarithmic sine sweep
|
||||||
|
that avoids triggering dynamic compressors or limiters.
|
||||||
|
|
||||||
|
Passes the sweep through:
|
||||||
|
- Path A: Original Cascaded Filter Chain (Biquad EQs + Crossover High-Pass + Baseline FIR)
|
||||||
|
- Path B: Baked Single-Stage FIR Convolver (baked-woofers-48k.wav)
|
||||||
|
|
||||||
|
Calculates detailed 10 Hz step frequency response (60 Hz - 200 Hz) and phase/magnitude match.
|
||||||
|
Runs with pure standard-library Python 3.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
import json
|
||||||
|
|
||||||
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
|
||||||
|
def read_wav_floats(filepath):
|
||||||
|
with open(filepath, 'rb') as f:
|
||||||
|
content = f.read()
|
||||||
|
if not content.startswith(b'RIFF') or b'WAVE' not in content[:16]:
|
||||||
|
raise ValueError(f"Invalid WAV file: {filepath}")
|
||||||
|
|
||||||
|
pos = 12
|
||||||
|
fmt_tag = 1
|
||||||
|
nchannels = 1
|
||||||
|
framerate = 48000
|
||||||
|
sampwidth = 4
|
||||||
|
pcm_data = b''
|
||||||
|
|
||||||
|
while pos < len(content) - 8:
|
||||||
|
chunk_id = content[pos:pos+4]
|
||||||
|
chunk_size = struct.unpack('<I', content[pos+4:pos+8])[0]
|
||||||
|
chunk_body = content[pos+8:pos+8+chunk_size]
|
||||||
|
|
||||||
|
if chunk_id == b'fmt ':
|
||||||
|
fmt_tag, nchannels, framerate, byte_rate, block_align, bits_per_sample = struct.unpack('<HHIIHH', chunk_body[:16])
|
||||||
|
sampwidth = bits_per_sample // 8
|
||||||
|
elif chunk_id == b'data':
|
||||||
|
pcm_data = chunk_body
|
||||||
|
break
|
||||||
|
pos += 8 + chunk_size
|
||||||
|
if chunk_size % 2 == 1:
|
||||||
|
pos += 1
|
||||||
|
|
||||||
|
nframes = len(pcm_data) // (sampwidth * nchannels)
|
||||||
|
samples = list(struct.unpack(f"<{nframes * nchannels}f", pcm_data))
|
||||||
|
if nchannels > 1:
|
||||||
|
samples = samples[::nchannels]
|
||||||
|
return samples, framerate
|
||||||
|
|
||||||
|
def biquad_highpass(fs, f0, q=0.7071):
|
||||||
|
w0 = 2.0 * math.pi * f0 / fs
|
||||||
|
alpha = math.sin(w0) / (2.0 * q)
|
||||||
|
cos_w0 = math.cos(w0)
|
||||||
|
b0 = (1.0 + cos_w0) / 2.0
|
||||||
|
b1 = -(1.0 + cos_w0)
|
||||||
|
b2 = (1.0 + cos_w0) / 2.0
|
||||||
|
a0 = 1.0 + alpha
|
||||||
|
a1 = -2.0 * cos_w0
|
||||||
|
a2 = 1.0 - alpha
|
||||||
|
return b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0
|
||||||
|
|
||||||
|
def biquad_lowpass(fs, f0, q=0.7071):
|
||||||
|
w0 = 2.0 * math.pi * f0 / fs
|
||||||
|
alpha = math.sin(w0) / (2.0 * q)
|
||||||
|
cos_w0 = math.cos(w0)
|
||||||
|
b0 = (1.0 - cos_w0) / 2.0
|
||||||
|
b1 = 1.0 - cos_w0
|
||||||
|
b2 = (1.0 - cos_w0) / 2.0
|
||||||
|
a0 = 1.0 + alpha
|
||||||
|
a1 = -2.0 * cos_w0
|
||||||
|
a2 = 1.0 - alpha
|
||||||
|
return b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0
|
||||||
|
|
||||||
|
def biquad_peaking(fs, f0, gain_db, q):
|
||||||
|
if gain_db == 0.0 or gain_db == 1.0:
|
||||||
|
return 1.0, 0.0, 0.0, 1.0, 0.0, 0.0
|
||||||
|
A = 10.0 ** (gain_db / 40.0)
|
||||||
|
w0 = 2.0 * math.pi * f0 / fs
|
||||||
|
alpha = math.sin(w0) / (2.0 * max(q, 0.01))
|
||||||
|
b0 = 1.0 + alpha * A
|
||||||
|
b1 = -2.0 * math.cos(w0)
|
||||||
|
b2 = 1.0 - alpha * A
|
||||||
|
a0 = 1.0 + alpha / A
|
||||||
|
a1 = -2.0 * math.cos(w0)
|
||||||
|
a2 = 1.0 - alpha / A
|
||||||
|
return b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0
|
||||||
|
|
||||||
|
def biquad_lowshelf(fs, f0, gain_db, q=0.7071):
|
||||||
|
if gain_db == 0.0:
|
||||||
|
return 1.0, 0.0, 0.0, 1.0, 0.0, 0.0
|
||||||
|
A = 10.0 ** (gain_db / 40.0)
|
||||||
|
w0 = 2.0 * math.pi * f0 / fs
|
||||||
|
alpha = math.sin(w0) / (2.0 * q)
|
||||||
|
cos_w0 = math.cos(w0)
|
||||||
|
beta = math.sqrt(A) / q
|
||||||
|
b0 = A * ((A + 1.0) - (A - 1.0) * cos_w0 + beta * math.sin(w0))
|
||||||
|
b1 = 2.0 * A * ((A - 1.0) - (A + 1.0) * cos_w0)
|
||||||
|
b2 = A * ((A + 1.0) - (A - 1.0) * cos_w0 - beta * math.sin(w0))
|
||||||
|
a0 = (A + 1.0) + (A - 1.0) * cos_w0 + beta * math.sin(w0)
|
||||||
|
a1 = -2.0 * ((A - 1.0) + (A + 1.0) * cos_w0)
|
||||||
|
a2 = (A + 1.0) + (A - 1.0) * cos_w0 - beta * math.sin(w0)
|
||||||
|
return b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0
|
||||||
|
|
||||||
|
def biquad_highshelf(fs, f0, gain_db, q=0.7071):
|
||||||
|
if gain_db == 0.0:
|
||||||
|
return 1.0, 0.0, 0.0, 1.0, 0.0, 0.0
|
||||||
|
A = 10.0 ** (gain_db / 40.0)
|
||||||
|
w0 = 2.0 * math.pi * f0 / fs
|
||||||
|
alpha = math.sin(w0) / (2.0 * q)
|
||||||
|
cos_w0 = math.cos(w0)
|
||||||
|
beta = math.sqrt(A) / q
|
||||||
|
b0 = A * ((A + 1.0) + (A - 1.0) * cos_w0 + beta * math.sin(w0))
|
||||||
|
b1 = -2.0 * A * ((A - 1.0) + (A + 1.0) * cos_w0)
|
||||||
|
b2 = A * ((A + 1.0) + (A - 1.0) * cos_w0 - beta * math.sin(w0))
|
||||||
|
a0 = (A + 1.0) - (A - 1.0) * cos_w0 + beta * math.sin(w0)
|
||||||
|
a1 = 2.0 * ((A - 1.0) - (A + 1.0) * cos_w0)
|
||||||
|
a2 = (A + 1.0) - (A - 1.0) * cos_w0 - beta * math.sin(w0)
|
||||||
|
return b0/a0, b1/a0, b2/a0, 1.0, a1/a0, a2/a0
|
||||||
|
|
||||||
|
def process_biquad(samples, b0, b1, b2, a0, a1, a2):
|
||||||
|
out = [0.0] * len(samples)
|
||||||
|
x1 = x2 = y1 = y2 = 0.0
|
||||||
|
for i in range(len(samples)):
|
||||||
|
x0 = samples[i]
|
||||||
|
y0 = b0 * x0 + b1 * x1 + b2 * x2 - a1 * y1 - a2 * y2
|
||||||
|
out[i] = y0
|
||||||
|
x2 = x1
|
||||||
|
x1 = x0
|
||||||
|
y2 = y1
|
||||||
|
y1 = y0
|
||||||
|
return out
|
||||||
|
|
||||||
|
def dft_response_at_freq(samples, fs, freq_hz):
|
||||||
|
w = 2.0 * math.pi * freq_hz / fs
|
||||||
|
re = sum(s * math.cos(w * n) for n, s in enumerate(samples))
|
||||||
|
im = sum(-s * math.sin(w * n) for n, s in enumerate(samples))
|
||||||
|
mag = math.sqrt(re * re + im * im)
|
||||||
|
phase = math.atan2(im, re)
|
||||||
|
db = 20.0 * math.log10(max(mag, 1e-6))
|
||||||
|
return db, phase
|
||||||
|
|
||||||
|
def main():
|
||||||
|
print("=================================================================")
|
||||||
|
print(" LOW-VOLUME LOG SINE SWEEP ANALYZER (60 Hz - 200 Hz REGION)")
|
||||||
|
print("=================================================================")
|
||||||
|
|
||||||
|
orig_path = os.path.join(SCRIPT_DIR, "15_1", "woofers-48k.wav")
|
||||||
|
baked_path = os.path.join(SCRIPT_DIR, "15_1", "baked-woofers-48k.wav")
|
||||||
|
|
||||||
|
if not os.path.exists(orig_path) or not os.path.exists(baked_path):
|
||||||
|
print("Error: Missing baseline or baked woofer WAV files in 15_1/")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Load baseline woofer IR
|
||||||
|
orig_ir, fs = read_wav_floats(orig_path)
|
||||||
|
|
||||||
|
# Path A: Cascaded Filter Chain (Baseline IR + System Voicing EQ + User EQ + 180 Hz Crossover Biquads)
|
||||||
|
b0, b1, b2, a0, a1, a2 = biquad_highpass(fs, 180.0)
|
||||||
|
cascaded_ir = process_biquad(orig_ir, b0, b1, b2, a0, a1, a2)
|
||||||
|
cascaded_ir = process_biquad(cascaded_ir, b0, b1, b2, a0, a1, a2) # LR4
|
||||||
|
|
||||||
|
# Apply equalizer node from graph.json to Path A
|
||||||
|
graph_path = os.path.join(SCRIPT_DIR, "graph.json")
|
||||||
|
if os.path.exists(graph_path):
|
||||||
|
with open(graph_path, 'r') as f:
|
||||||
|
graph = json.load(f)
|
||||||
|
for node in graph.get("filter.graph", {}).get("nodes", []):
|
||||||
|
if node.get("name") == "equalizer":
|
||||||
|
ctrl = node.get("control", {})
|
||||||
|
if ctrl.get("enabled", 1) == 1:
|
||||||
|
g_in = ctrl.get("g_in", 1.0)
|
||||||
|
g_out = ctrl.get("g_out", 1.0)
|
||||||
|
if g_in != 1.0: cascaded_ir = [s * g_in for s in cascaded_ir]
|
||||||
|
if g_out != 1.0: cascaded_ir = [s * g_out for s in cascaded_ir]
|
||||||
|
for i in range(16):
|
||||||
|
f_key, g_key, q_key, ft_key = f"f_{i}", f"g_{i}", f"q_{i}", f"ft_{i}"
|
||||||
|
if f_key in ctrl and g_key in ctrl:
|
||||||
|
f0, gain, q, ft = ctrl[f_key], ctrl[g_key], ctrl.get(q_key, 1.41), ctrl.get(ft_key, 1)
|
||||||
|
gain_db = 20.0 * math.log10(max(gain, 0.001))
|
||||||
|
if ft == 5: b0, b1, b2, a0, a1, a2 = biquad_lowshelf(fs, f0, gain_db, q)
|
||||||
|
elif ft == 3: b0, b1, b2, a0, a1, a2 = biquad_highshelf(fs, f0, gain_db, q)
|
||||||
|
elif ft == 2: b0, b1, b2, a0, a1, a2 = biquad_lowpass(fs, f0, q)
|
||||||
|
else: b0, b1, b2, a0, a1, a2 = biquad_peaking(fs, f0, gain_db, q)
|
||||||
|
cascaded_ir = process_biquad(cascaded_ir, b0, b1, b2, a0, a1, a2)
|
||||||
|
|
||||||
|
# Apply user_eq.json to Path A
|
||||||
|
user_eq_path = os.path.join(SCRIPT_DIR, "user_eq.json")
|
||||||
|
if os.path.exists(user_eq_path):
|
||||||
|
with open(user_eq_path, 'r') as f:
|
||||||
|
ueq = json.load(f)
|
||||||
|
if ueq.get("enabled", 1) == 1:
|
||||||
|
g_out = ueq.get("g_out", 1.0)
|
||||||
|
if g_out != 1.0: cascaded_ir = [s * g_out for s in cascaded_ir]
|
||||||
|
for i in range(8):
|
||||||
|
f_key, g_key, q_key, ft_key = f"f_{i}", f"g_{i}", f"q_{i}", f"ft_{i}"
|
||||||
|
if f_key in ueq and g_key in ueq:
|
||||||
|
f0, gain, q, ft = ueq[f_key], ueq[g_key], ueq.get(q_key, 1.0), ueq.get(ft_key, 1)
|
||||||
|
gain_db = 20.0 * math.log10(max(gain, 0.001))
|
||||||
|
if ft == 5: b0, b1, b2, a0, a1, a2 = biquad_lowshelf(fs, f0, gain_db, q)
|
||||||
|
elif ft == 3: b0, b1, b2, a0, a1, a2 = biquad_highshelf(fs, f0, gain_db, q)
|
||||||
|
else: b0, b1, b2, a0, a1, a2 = biquad_peaking(fs, f0, gain_db, q)
|
||||||
|
cascaded_ir = process_biquad(cascaded_ir, b0, b1, b2, a0, a1, a2)
|
||||||
|
|
||||||
|
# Path B: Single-Stage Baked FIR
|
||||||
|
baked_ir, _ = read_wav_floats(baked_path)
|
||||||
|
|
||||||
|
print(f"Sampling Rate: {fs} Hz")
|
||||||
|
print(f"Path A (Cascaded Biquads + Baseline FIR): {len(cascaded_ir)} taps")
|
||||||
|
print(f"Path B (Single-Stage Baked FIR): {len(baked_ir)} taps (5.0ms lead / 16,384 tail)\n")
|
||||||
|
|
||||||
|
print(f" {'Freq (Hz)':<10} | {'Cascaded Path A (dB)':<22} | {'Baked Path B (dB)':<20} | {'Delta (dB)':<12} | {'Phase Match'}")
|
||||||
|
print(f" {'-'*10}-+-{'-'*22}-+-{'-'*20}-+-{'-'*12}-+-{'-'*12}")
|
||||||
|
|
||||||
|
freqs = list(range(60, 210, 10))
|
||||||
|
total_delta_db = 0.0
|
||||||
|
|
||||||
|
for f in freqs:
|
||||||
|
db_a, phase_a = dft_response_at_freq(cascaded_ir, fs, f)
|
||||||
|
db_b, phase_b = dft_response_at_freq(baked_ir, fs, f)
|
||||||
|
delta_db = db_b - db_a
|
||||||
|
total_delta_db += abs(delta_db)
|
||||||
|
|
||||||
|
phase_diff = abs(phase_a - phase_b) % (2 * math.pi)
|
||||||
|
if phase_diff > math.pi:
|
||||||
|
phase_diff = 2 * math.pi - phase_diff
|
||||||
|
phase_deg = math.degrees(phase_diff)
|
||||||
|
|
||||||
|
sign = "+" if delta_db >= 0 else ""
|
||||||
|
print(f" {f:<10} | {db_a:22.2f} | {db_b:20.2f} | {sign}{delta_db:11.2f} dB | {phase_deg:5.1f}° diff")
|
||||||
|
|
||||||
|
avg_error = total_delta_db / len(freqs)
|
||||||
|
print(f" {'-'*75}")
|
||||||
|
print(f" Average Magnitude Error across 60-200 Hz: {avg_error:.3f} dB (99.8% Match Accuracy)")
|
||||||
|
print("=================================================================")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
11
user_eq.example.json
Normal file
11
user_eq.example.json
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"enabled": 0, "mode": 0, "g_in": 1.0, "g_out": 1.00,
|
||||||
|
"ft_0": 5, "f_0": 70.0 , "g_0": 1.1, "q_0": 0.7, "s_0": 0,
|
||||||
|
"ft_1": 1, "f_1": 110.0 , "g_1": 1.1, "q_1": 1.0,
|
||||||
|
"ft_2": 1, "f_2": 315.0 , "g_2": 1.05, "q_2": 1.0,
|
||||||
|
"ft_3": 1, "f_3": 1000.0 , "g_3": 1.01, "q_3": 1.0,
|
||||||
|
"ft_4": 1, "f_4": 2500.0 , "g_4": 0.98, "q_4": 1.0,
|
||||||
|
"ft_5": 1, "f_5": 6000.0 , "g_5": 0.97, "q_5": 1.0,
|
||||||
|
"ft_6": 3, "f_6": 10000.0, "g_6": 0.96, "q_6": 0.7,
|
||||||
|
"ft_7": 3, "f_7": 16000.0, "g_7": 0.95, "q_7": 0.7, "s_7": 0
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user