3 Commits

Author SHA1 Message Date
mynameisdeleted
8e50c6a130 fix: reduce limiter release time for improved audio response 2026-08-31 07:48:23 -04:00
mynameisdeleted
5dbd9e0bb4 fix: adjust limiter threshold to improve audio output quality 2026-08-31 07:35:25 -04:00
mynameisdeleted
d78dfe80ac fix: increase output gain for user equalizer to enhance audio performance 2026-08-31 07:22:31 -04:00
11 changed files with 122 additions and 1811 deletions

9
.gitignore vendored
View File

@@ -3,12 +3,3 @@
# 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__/

View File

@@ -1,8 +1,5 @@
# MacBook Pro 15,1 — Warm, Natural Audio DSP for t2linux
based on https://github.com/lemmyg/t2-apple-audio-dsp/tree/master
[![GitHub Repository](https://img.shields.io/badge/Repository-GitHub-181717.svg?logo=github)](https://github.com/mynameisdeleted/mbp15-1-audio-dsp) [![Fairfax Media Git](https://img.shields.io/badge/Repository-Fairfax%20Media-003366.svg)](https://git.fairfaxmedia.net/t2linux/mbp15-1-audio-dsp.git) [![t2linux](https://img.shields.io/badge/Platform-t2linux-blue.svg)](https://wiki.t2linux.org/) [![Asahi Audio Ecosystem](https://img.shields.io/badge/Ecosystem-Asahi%20Audio-orange.svg)](https://github.com/AsahiLinux/asahi-audio) [![PipeWire](https://img.shields.io/badge/Audio-PipeWire%20%2F%20WirePlumber-red.svg)](https://pipewire.org/) [![Target Hardware](https://img.shields.io/badge/Hardware-MacBook%20Pro%2015%2C1-black.svg)]()
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.
@@ -22,7 +19,6 @@ Upstream graphs (`asahi-audio` / `t2-apple-audio-dsp`) target a measurement-flat
| 💥 **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 |
---
@@ -41,7 +37,8 @@ flowchart TD
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
MBComp --> Limiter["🛡️ Main Limiter (Broadband Lookahead)"]:::limiter
Limiter --> LoudComp["👂 Loudness Comp (ISO-226 Equal Loudness)"]:::dynamics
end
subgraph Stage3 ["3. Crossover & Driver FIR Correction"]
@@ -50,23 +47,22 @@ flowchart TD
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"]
subgraph Stage4 ["4. Driver Safety Backstops & Output"]
ConvLT --> TLim["🛡️ Tweeter Limiter (-1 dB Ceiling)"]:::limiter
ConvRT --> TLim
ConvLW --> WLim["🛡️ Woofer Limiter (-2 dB Ceiling)"]:::limiter
ConvRW --> WLim
TLim --> Out["🔈 RawSpeakers Sink"]:::input
WGuard --> Out
WLim --> Out
end
classDef input fill:#2d3748,stroke:#4a5568,color:#fff;
@@ -78,29 +74,6 @@ flowchart TD
---
## 🛡️ Dual-Driver Virtual Thermal Guards & Power Caps
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:
$$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}}}$$
### Thermal Guard Configurations
* **Woofer Thermal Guard (`thermal_guard` @ Post-`wlim`):**
* **Continuous Power Cap:** **$1.0\text{ W}$ ($1,000\text{ mW}$)** per channel ($16\%$ of max $6.25\text{ W}$ peak power).
* **Linear RMS Threshold (`al`):** `0.4000` ($-7.96\text{ dBFS}$ RMS).
* **Heating Tau ($\tau_{\text{heat}}$):** $2,500\text{ ms}$ (2.5 seconds).
* **Cooling Tau ($\tau_{\text{cool}}$):** $5,000\text{ ms}$ (5.0 seconds).
* **Tweeter Thermal Guard (`tweeter_thermal_guard` @ Pre-`tlim`):**
* **Continuous Power Cap:** **$100\text{ mW}$ ($0.100\text{ W}$)** per channel ($1.6\%$ of max $6.25\text{ W}$ peak power).
* **Linear RMS Threshold (`al`):** `0.126491` ($-17.96\text{ dBFS}$ RMS).
* **Heating Tau ($\tau_{\text{heat}}$):** $1,000\text{ ms}$ (1.0 second).
* **Cooling Tau ($\tau_{\text{cool}}$):** $2,000\text{ ms}$ (2.0 seconds, fast decay recovery from Zoom/WebRTC glitches).
> **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.
---
## 🚀 Quick Start
### 1. Install Dependencies
@@ -145,7 +118,7 @@ Customize tone settings without touching calibrated internal DSP nodes. `user_eq
| **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` |
| **Late-Night (Low Level)** | `0.63` | `0.79` | `1.00` | `1.00` | `1.06` | `1.12` | `1.00` | `0.94` |
*(Note: Gain values are linear multipliers: `1.0` = 0 dB, `1.41` ≈ +3 dB boost, `0.71` ≈ -3 dB cut)*
@@ -155,28 +128,12 @@ Customize tone settings without touching calibrated internal DSP nodes. `user_eq
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)`).
* **Woofer Ceiling:** Edit `wlim.control.limit` in `graph.json` (Default: `-2` dB. Lower to `-3` / `-4` dB for stricter mechanical protection).
* **Bass Drive:** Adjust `equalizer.control` (`g_1` through `g_5`).
* **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

145
apply.sh
View File

@@ -19,55 +19,38 @@ MERGED="$HOME/.audiograph.json"
GRAPH_DST="/usr/share/t2-linux-audio/15_1/graph.json"
FORCE=0
SIMPLE=0
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
case "${1:-}" in -f|--force|--no-check) FORCE=1 ;; esac
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
# --- json validation helper -------------------------------------------
# 0 = valid, 1 = invalid, 2 = no validator available
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
if have python3; then python3 -m json.tool "$1" >/dev/null 2>&1
elif have jq; then jq -e . "$1" >/dev/null 2>&1
else return 2; fi
}
[ -f "$GRAPH_SRC" ] || die "$GRAPH_SRC not found"
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
if [ -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"
|| die "graph.json 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"
echo "ok: no user_eq.json - graph.json as-is -> $MERGED"
fi
json_ok "$MERGED"; rc=$?
@@ -76,113 +59,39 @@ json_ok "$MERGED"; rc=$?
# --- 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?)"
"$(dirname "$GRAPH_DST") is missing - install the t2 speaker-DSP package first (INSTALL.md section 1)"
# 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
miss=0
while IFS= read -r w; do
[ -f "$w" ] || { echo " missing FIR: $w"; miss=1; }
done < <(grep -oE '/[^" ]+\.wav' "$MERGED" | sort -u)
[ "$miss" -eq 0 ] && echo "ok: FIR .wav files present" \
|| die "FIR files missing - INSTALL.md section 4"
# 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"
if have lv2ls; then
miss=0
while IFS= read -r u; do
lv2ls | grep -qxF "$u" || { echo " missing plugin: $u"; miss=1; }
done < <(grep '"plugin"' "$MERGED" | grep -oE 'https?://[^"]+' | sort -u)
[ "$miss" -eq 0 ] && echo "ok: all LV2 plugins resolve" \
|| die "LV2 plugins missing - run ./install-deps.sh (INSTALL.md section 3)"
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"
sudo cp "$MERGED" "$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
sleep 1.2
# --- 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 --------------------------------------------------------
sleep 1
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 "Done - graph installed, WirePlumber restarted."
echo "If no 'DSP Speakers' sink shows: journalctl --user -u wireplumber -b -e"
fi

View File

@@ -1,390 +0,0 @@
#!/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()

View File

@@ -1,116 +0,0 @@
#!/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
View File

@@ -1,248 +0,0 @@
#!/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()

View File

@@ -8,17 +8,16 @@
"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
"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": 1.0, "q_2": 1.0,
"ft_3": 1, "f_3": 450.0, "g_3": 1.0, "q_3": 1.0,
"ft_4": 1, "f_4": 1000.0, "g_4": 1.0, "q_4": 1.0,
"ft_5": 1, "f_5": 2500.0, "g_5": 1.0, "q_5": 1.0,
"ft_6": 1, "f_6": 6000.0, "g_6": 1.0, "q_6": 1.0,
"ft_7": 3, "f_7": 10000.0, "g_7": 1.0, "q_7": 0.7, "s_7": 0
}
},
{
"type": "lv2",
@@ -27,24 +26,24 @@
"control": {
"enabled": 1,
"mode": 0,
"g_in": 1.0,
"g_out": 1.2,
"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
"g_in": 0.5,
"g_out": 1.0,
"ft_0": 2, "f_0": 20.0, "g_0": 1.0, "q_0": 1.41, "s_0": 3, "xm_0": 0, "fm_0": 0,
"ft_1": 1, "f_1": 31.5, "g_1": 2.51, "q_1": 1.41, "s_1": 0, "xm_1": 0, "fm_1": 0,
"ft_2": 1, "f_2": 50.0, "g_2": 2.24, "q_2": 1.41, "s_2": 0, "xm_2": 0, "fm_2": 0,
"ft_3": 1, "f_3": 80.0, "g_3": 2.00, "q_3": 1.41, "s_3": 0, "xm_3": 0, "fm_3": 0,
"ft_4": 1, "f_4": 125.0, "g_4": 1.78, "q_4": 1.41, "s_4": 0, "xm_4": 0, "fm_4": 0,
"ft_5": 1, "f_5": 200.0, "g_5": 1.58, "q_5": 1.41, "s_5": 0, "xm_5": 0, "fm_5": 0,
"ft_6": 1, "f_6": 315.0, "g_6": 1.41, "q_6": 1.41, "s_6": 0, "xm_6": 0, "fm_6": 0,
"ft_7": 1, "f_7": 500.0, "g_7": 1.26, "q_7": 1.41, "s_7": 0, "xm_7": 0, "fm_7": 0,
"ft_8": 1, "f_8": 800.0, "g_8": 1.12, "q_8": 1.41, "s_8": 0, "xm_8": 0, "fm_8": 0,
"ft_9": 1, "f_9": 1250.0, "g_9": 1.06, "q_9": 1.41, "s_9": 0, "xm_9": 0, "fm_9": 0,
"ft_10": 1, "f_10": 2000.0, "g_10": 1.0, "q_10": 1.41, "s_10": 0, "xm_10": 0, "fm_10": 0,
"ft_11": 1, "f_11": 3150.0, "g_11": 1.0, "q_11": 1.41, "s_11": 0, "xm_11": 0, "fm_11": 0,
"ft_12": 1, "f_12": 5000.0, "g_12": 0.94, "q_12": 1.41, "s_12": 0, "xm_12": 0, "fm_12": 0,
"ft_13": 1, "f_13": 8000.0, "g_13": 0.94, "q_13": 1.41, "s_13": 0, "xm_13": 0, "fm_13": 0,
"ft_14": 1, "f_14": 12500.0, "g_14": 1.0, "q_14": 1.41, "s_14": 0, "xm_14": 0, "fm_14": 0,
"ft_15": 1, "f_15": 20000.0, "g_15": 1.0, "q_15": 1.41, "s_15": 0, "xm_15": 0, "fm_15": 0
}
},
{
@@ -57,7 +56,7 @@
"sat_second": 1.0,
"sat_third": 1.0,
"blend": 1.0,
"ceil": 90.0,
"ceil": 150.0,
"floor": 60.0
}
},
@@ -69,24 +68,34 @@
"enabled": 1,
"mode": 1,
"g_in": 1.0,
"g_out": 1.0,
"g_out": 1.15,
"g_dry": 0.0001,
"g_wet": 1.0,
"cbe_1": 1, "sf_1": 40.0, "al_1": 0.10,
"cbe_2": 1, "sf_2": 50.0, "al_2": 0.3,
"cbe_3": 1, "sf_3": 60.0, "al_3": 0.5,
"cbe_4": 1, "sf_4": 80.0, "al_4": 0.7,
"cbe_5": 1, "sf_5": 120.0, "al_5": 0.7,
"cbe_6": 1, "sf_6": 200.0, "al_6": 0.7,
"cbe_7": 1, "sf_7": 500.0, "al_7": 0.7,
"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
"cbe_1": 1, "sf_1": 60.0,
"cbe_2": 1, "sf_2": 80.0,
"cbe_3": 1, "sf_3": 100.0,
"cbe_4": 1, "sf_4": 130.0,
"cbe_5": 1, "sf_5": 160.0,
"cbe_6": 1, "sf_6": 200.0,
"cbe_7": 1, "sf_7": 500.0,
"ce_0": 1, "at_0": 5.0, "rt_0": 100.0, "cr_0": 30.0, "kn_0": 0.10, "al_0": 0.090, "mk_0": 1.0, "bth_0": 1.0, "bsa_0": 1.0, "scm_0": 0,
"ce_1": 1, "at_1": 5.0, "rt_1": 90.0, "cr_1": 20.0, "kn_1": 0.12, "al_1": 0.095, "mk_1": 1.2, "bth_1": 1.0, "bsa_1": 1.0, "scm_1": 0,
"ce_2": 1, "at_2": 4.0, "rt_2": 80.0, "cr_2": 16.0, "kn_2": 0.15, "al_2": 0.105, "mk_2": 1.3, "bth_2": 1.0, "bsa_2": 1.0, "scm_2": 0,
"ce_3": 1, "at_3": 4.0, "rt_3": 70.0, "cr_3": 12.0, "kn_3": 0.18, "al_3": 0.115, "mk_3": 1.4, "bth_3": 1.0, "bsa_3": 1.0, "scm_3": 0,
"ce_4": 1, "at_4": 4.0, "rt_4": 60.0, "cr_4": 10.0, "kn_4": 0.20, "al_4": 0.125, "mk_4": 1.5, "bth_4": 1.0, "bsa_4": 1.0, "scm_4": 0,
"ce_5": 1, "at_5": 3.5, "rt_5": 50.0, "cr_5": 8.0, "kn_5": 0.25, "al_5": 0.140, "mk_5": 1.5, "bth_5": 1.0, "bsa_5": 1.0, "scm_5": 0,
"ce_6": 1, "at_6": 3.0, "rt_6": 40.0, "cr_6": 6.0, "kn_6": 0.30, "al_6": 0.165, "mk_6": 1.4, "bth_6": 1.0, "bsa_6": 1.0, "scm_6": 0,
"ce_7": 1, "at_7": 2.5, "rt_7": 30.0, "cr_7": 4.0, "kn_7": 0.40, "al_7": 0.300, "mk_7": 1.2, "bth_7": 1.0, "bsa_7": 1.0, "scm_7": 0
}
},
{
"type": "lv2",
"name": "limiter",
"plugin": "http://plugin.org.uk/swh-plugins/fastLookaheadLimiter",
"control": {
"ingain": 0,
"limit": -1,
"release": 0.35
}
},
{
@@ -95,7 +104,8 @@
"name": "ell",
"control": {
"enabled": 1,
"volume": 0.0
"input": 1.0,
"fft": 4
}
},
{
@@ -104,7 +114,8 @@
"name": "elr",
"control": {
"enabled": 1,
"volume": 0.0
"input": 1.0,
"fft": 4
}
},
{
@@ -128,7 +139,7 @@
"/usr/share/t2-linux-audio/15_1/tweeters-96k.wav"
],
"channel": 0,
"gain": 1.2
"gain": 1.1
}
},
{
@@ -142,7 +153,7 @@
"/usr/share/t2-linux-audio/15_1/tweeters-96k.wav"
],
"channel": 0,
"gain": 1.2
"gain": 1.1
}
},
{
@@ -173,31 +184,14 @@
"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.5,
"release": 0.25
"limit": 0,
"release": 0.10
}
},
{
@@ -206,7 +200,7 @@
"plugin": "http://plugin.org.uk/swh-plugins/fastLookaheadLimiter",
"control": {
"ingain": 0,
"limit": -0.5,
"limit": -1,
"release": 0.15
}
}
@@ -216,20 +210,20 @@
{"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": "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": "virtualbass:out_l", "input": "multiband_compressor:in_l"},
{"output": "virtualbass:out_r", "input": "multiband_compressor:in_r"},
{"output": "multiband_compressor:out_l", "input": "limiter:in_1"},
{"output": "multiband_compressor:out_r", "input": "limiter:in_2"},
{"output": "limiter:out_1", "input": "ell:in"},
{"output": "limiter:out_2", "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": "convLW:Out", "input": "wlim:in_1"},
{"output": "convRW:Out", "input": "wlim:in_2"},
{"output": "convLT:Out", "input": "tlim:in_1"},
{"output": "convRT:Out", "input": "tlim:in_2"}
],
@@ -248,13 +242,13 @@
"control": "ell:volume",
"min": -65.0,
"max": 0.0,
"scale": "linear"
"scale": "cubic"
},
{
"control": "elr:volume",
"min": -65.0,
"max": 0.0,
"scale": "linear"
"scale": "cubic"
}
]
},
@@ -266,8 +260,7 @@
"device.api": "dsp",
"audio.allowed-rates": [48000, 44100],
"node.virtual": "false",
"priority.session": 2500,
"priority.driver": 2500,
"priority.session": 1500,
"state.default-volume": 1.0,
"device.icon-name": "audio-speakers"
},

View File

@@ -1,237 +0,0 @@
{
"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"]
}
}

View File

@@ -1,303 +0,0 @@
{
"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"]
}
}

View File

@@ -1,245 +0,0 @@
#!/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()

View File

@@ -1,11 +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
"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": 1.0, "q_2": 1.0,
"ft_3": 1, "f_3": 450.0, "g_3": 1.0, "q_3": 1.0,
"ft_4": 1, "f_4": 1000.0, "g_4": 1.0, "q_4": 1.0,
"ft_5": 1, "f_5": 2500.0, "g_5": 1.0, "q_5": 1.0,
"ft_6": 1, "f_6": 6000.0, "g_6": 1.0, "q_6": 1.0,
"ft_7": 3, "f_7": 10000.0, "g_7": 1.0, "q_7": 0.7, "s_7": 0
}