feat: implement True-Peak ISP guarding, stereo loudness node consolidation, crossover phase alignment, and eq.py CLI utility
This commit is contained in:
@@ -165,6 +165,26 @@ def write_wav_floats(filepath, samples, framerate):
|
||||
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
|
||||
@@ -323,6 +343,9 @@ def bake_driver_ir(src_wav, dst_wav, is_woofer=False, hp_freq=180.0, driver_gain
|
||||
# 3. Optimize Latency (5ms Lead) + Extend Woofer/Tweeter Lopsided Tail Resolution
|
||||
samples = optimize_fir_latency_and_tail(samples, fs=fs, is_woofer=is_woofer)
|
||||
|
||||
# 4. 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
|
||||
@@ -388,14 +411,14 @@ def generate_simple_graph_and_bake():
|
||||
driver_gain=task["gain"]
|
||||
)
|
||||
|
||||
# 3. Build graph_simple.json dynamically from graph.json (omitting user_eq, equalizer, whp*)
|
||||
# 3. Build graph_simple.json dynamically from graph.json (omitting user_eq, equalizer, limiter, ell, elr)
|
||||
graph["node.description"] = "MacBook Pro 15,1 DSP Speakers (Single-Stage Baked FIR)"
|
||||
new_nodes = []
|
||||
|
||||
for node in nodes:
|
||||
name = node.get("name", "")
|
||||
# Omit user_eq, static system voicing equalizer, redundant master limiter & crossover biquad nodes
|
||||
if name in ["user_eq", "equalizer", "limiter", "whpL1", "whpL2", "whpR1", "whpR2"]:
|
||||
# Omit user_eq, static system voicing equalizer, redundant master limiter, ell/elr mono nodes, & crossover biquad nodes
|
||||
if name in ["user_eq", "equalizer", "limiter", "ell", "elr", "whpL1", "whpL2", "whpR1", "whpR2"]:
|
||||
continue
|
||||
|
||||
if node.get("label") == "convolver" or "conv" in name:
|
||||
@@ -407,7 +430,19 @@ def generate_simple_graph_and_bake():
|
||||
|
||||
new_nodes.append(node)
|
||||
|
||||
# Re-wire links: filter out user_eq, equalizer, limiter & whp*
|
||||
# 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 user_eq, equalizer, limiter, ell, elr & whp*
|
||||
links = graph.get("filter.graph", {}).get("links", [])
|
||||
new_links = []
|
||||
for link in links:
|
||||
@@ -416,13 +451,17 @@ def generate_simple_graph_and_bake():
|
||||
if ("whp" in out_node or "whp" in in_node or
|
||||
"equalizer" in out_node or "equalizer" in in_node or
|
||||
"user_eq" in out_node or "user_eq" in in_node or
|
||||
"limiter:" in out_node or "limiter:" in in_node):
|
||||
"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 directly to ell and elr (bypassing redundant master limiter)
|
||||
new_links.append({"output": "multiband_compressor:out_l", "input": "ell:in"})
|
||||
new_links.append({"output": "multiband_compressor:out_r", "input": "elr:in"})
|
||||
# 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 virtualbass (first active DSP processing node)
|
||||
graph["filter.graph"]["inputs"] = [
|
||||
@@ -430,6 +469,16 @@ def generate_simple_graph_and_bake():
|
||||
"virtualbass: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
|
||||
|
||||
|
||||
203
eq.py
Executable file
203
eq.py
Executable file
@@ -0,0 +1,203 @@
|
||||
#!/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 save_and_apply(eq_data):
|
||||
with open(USER_EQ_PATH, 'w') as f:
|
||||
json.dump(eq_data, f, indent=4)
|
||||
print(f"Saved {USER_EQ_PATH}")
|
||||
print("Baking FIR filters and 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 <multiplier> (e.g., ./eq.py gain 2.0)")
|
||||
sys.exit(1)
|
||||
eq["g_out"] = float(args[1])
|
||||
save_and_apply(eq)
|
||||
print(f"ok: Set Master Output Gain to {eq['g_out']:.2f}x")
|
||||
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()
|
||||
Reference in New Issue
Block a user