diff --git a/bake-graph.py b/bake-graph.py index e0833af..6091172 100755 --- a/bake-graph.py +++ b/bake-graph.py @@ -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 diff --git a/eq.py b/eq.py new file mode 100755 index 0000000..0e183fb --- /dev/null +++ b/eq.py @@ -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 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 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 (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()