feat: add single-stage FIR baking with 5ms low-latency lead, lopsided tail extension, and sweep analyzer

This commit is contained in:
mynameisdeleted
2026-09-01 08:10:26 -04:00
parent ec75582cd6
commit f2a467996a
5 changed files with 346 additions and 24 deletions

1
.gitignore vendored
View File

@@ -5,6 +5,7 @@
/user_eq.json
# Generated baked single-stage FIR files & simple graph
/15_1/baked-*.wav
/baked-*.wav
/graph_simple.json

View File

@@ -96,10 +96,6 @@ if [ "$FORCE" -eq 0 ]; then
fi
# --- install ----------------------------------------------------------
if [ "$SIMPLE" -eq 1 ]; then
echo "Installing baked FIR WAV files -> /usr/share/t2-linux-audio/15_1/"
sudo cp "$SCRIPT_DIR"/baked-*.wav /usr/share/t2-linux-audio/15_1/
fi
echo "Installing $MERGED -> $GRAPH_DST"
sudo cp "$MERGED" "$GRAPH_DST"

View File

@@ -136,20 +136,72 @@ def write_wav_floats(filepath, samples, framerate):
with open(filepath, 'wb') as f:
f.write(riff_header + fmt_header + data_header + data)
def bake_driver_ir(src_wav, dst_wav, is_woofer=False, hp_freq=180.0):
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
# Energy compensation: Scale post-lead tail energy to preserve 100% total acoustic energy
cropped_energy = sum(s*s for s in cropped)
if cropped_energy > 0 and total_energy > 0:
boost_factor = math.sqrt(total_energy / cropped_energy)
for i in range(lead_len, len(cropped)):
cropped[i] *= boost_factor
# 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, hp_freq=180.0, 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. Apply Driver Gain Multiplier (e.g., 1.1 for tweeters, 1.2 for woofers)
if driver_gain != 1.0:
samples = [s * driver_gain for s in samples]
# 1. Apply Crossover Filter if Woofer
# 2. Apply Crossover Filter if Woofer
if is_woofer:
b0, b1, b2, a0, a1, a2 = biquad_highpass(fs, hp_freq)
samples = process_biquad(samples, b0, b1, b2, a0, a1, a2)
samples = process_biquad(samples, b0, b1, b2, a0, a1, a2) # LR4 (2-stage)
# 2. Apply User EQ Boosts (if user_eq.json exists)
# 3. Apply User EQ Boosts (if user_eq.json exists)
user_eq_path = os.path.join(SCRIPT_DIR, "user_eq.json")
if os.path.exists(user_eq_path):
try:
@@ -170,8 +222,11 @@ def bake_driver_ir(src_wav, dst_wav, is_woofer=False, hp_freq=180.0):
except Exception as e:
print(f"User EQ processing note: {e}")
# 4. Optimize Latency (5ms Lead) + Extend Woofer/Tweeter Lopsided Tail Resolution
samples = optimize_fir_latency_and_tail(samples, fs=fs, is_woofer=is_woofer)
write_wav_floats(dst_wav, samples, fs)
print(f"==> Baked {os.path.basename(dst_wav)} ({fs} Hz, {len(samples)} taps)")
print(f"==> Baked {os.path.basename(dst_wav)} ({fs} Hz, {len(samples)} taps, gain={driver_gain}x)")
return True
def generate_simple_graph():
@@ -198,21 +253,22 @@ def generate_simple_graph():
if name in ["user_eq", "equalizer", "whpL1", "whpL2", "whpR1", "whpR2"]:
continue
repo_151 = os.path.join(SCRIPT_DIR, "15_1")
if name in ["convLT", "convRT"]:
node["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"
os.path.join(repo_151, "baked-tweeters-44k.wav"),
os.path.join(repo_151, "baked-tweeters-48k.wav"),
os.path.join(repo_151, "baked-tweeters-96k.wav")
]
elif name in ["convLW", "convRW"]:
node["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"
os.path.join(repo_151, "baked-woofers-44k.wav"),
os.path.join(repo_151, "baked-woofers-48k.wav"),
os.path.join(repo_151, "baked-woofers-96k.wav")
]
new_nodes.append(node)
# Re-wire links directly from copy/delay to convolvers
# Re-wire links: filter out references to omitted nodes (user_eq, equalizer, whp*)
links = graph.get("filter.graph", {}).get("links", [])
new_links = []
for link in links:
@@ -222,9 +278,11 @@ def generate_simple_graph():
continue
new_links.append(link)
# Directly link spkdly to convolvers
new_links.append({"output": "spkdlyL:Out", "input": "convLW:In"})
new_links.append({"output": "spkdlyR:Out", "input": "convRW:In"})
# Set graph inputs to virtualbass (first remaining processing node)
graph["filter.graph"]["inputs"] = [
"virtualbass:in_l",
"virtualbass:in_r"
]
graph["filter.graph"]["nodes"] = new_nodes
graph["filter.graph"]["links"] = new_links
@@ -239,25 +297,31 @@ def main():
print(" SINGLE-STAGE FIR CONVOLVER BAKER & GRAPH SIMPLIFIER")
print("=================================================================")
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)
rates = ["44k", "48k", "96k"]
for r in rates:
tw_name = f"tweeters-{r}.wav"
tw_src = os.path.join(SCRIPT_DIR, tw_name)
tw_src = os.path.join(repo_151, tw_name)
if not os.path.exists(tw_src) and os.path.exists(os.path.join(sys_dir, tw_name)):
tw_src = os.path.join(sys_dir, tw_name)
if not os.path.exists(tw_src) and os.path.exists(os.path.join(SCRIPT_DIR, tw_name)):
tw_src = os.path.join(SCRIPT_DIR, tw_name)
tw_dst = os.path.join(SCRIPT_DIR, f"baked-tweeters-{r}.wav")
bake_driver_ir(tw_src, tw_dst, is_woofer=False)
tw_dst = os.path.join(repo_151, f"baked-tweeters-{r}.wav")
bake_driver_ir(tw_src, tw_dst, is_woofer=False, driver_gain=1.1)
wf_name = f"woofers-{r}.wav"
wf_src = os.path.join(SCRIPT_DIR, wf_name)
wf_src = os.path.join(repo_151, wf_name)
if not os.path.exists(wf_src) and os.path.exists(os.path.join(sys_dir, wf_name)):
wf_src = os.path.join(sys_dir, wf_name)
if not os.path.exists(wf_src) and os.path.exists(os.path.join(SCRIPT_DIR, tw_name)):
wf_src = os.path.join(SCRIPT_DIR, wf_name)
wf_dst = os.path.join(SCRIPT_DIR, f"baked-woofers-{r}.wav")
bake_driver_ir(wf_src, wf_dst, is_woofer=True, hp_freq=180.0)
wf_dst = os.path.join(repo_151, f"baked-woofers-{r}.wav")
bake_driver_ir(wf_src, wf_dst, is_woofer=True, hp_freq=180.0, driver_gain=1.2)
generate_simple_graph()
print("=================================================================")

116
compare-response.py Executable file
View File

@@ -0,0 +1,116 @@
#!/usr/bin/env python3
"""
compare-response.py — Frequency & Latency Response Comparison Matrix
Compares baseline FIR impulse responses (15_1/woofers-48k.wav & tweeters-48k.wav)
against baked composite FIR filters (15_1/baked-woofers-48k.wav & baked-tweeters-48k.wav).
Prints magnitude (dB) and latency (ms) across key acoustic frequencies.
Runs with pure standard-library Python 3.
"""
import os
import sys
import math
import struct
import json
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
def read_wav_floats(filepath):
with open(filepath, 'rb') as f:
content = f.read()
if not content.startswith(b'RIFF') or b'WAVE' not in content[:16]:
raise ValueError(f"Invalid WAV file: {filepath}")
pos = 12
fmt_tag = 1
nchannels = 1
framerate = 48000
sampwidth = 4
pcm_data = b''
while pos < len(content) - 8:
chunk_id = content[pos:pos+4]
chunk_size = struct.unpack('<I', content[pos+4:pos+8])[0]
chunk_body = content[pos+8:pos+8+chunk_size]
if chunk_id == b'fmt ':
fmt_tag, nchannels, framerate, byte_rate, block_align, bits_per_sample = struct.unpack('<HHIIHH', chunk_body[:16])
sampwidth = bits_per_sample // 8
elif chunk_id == b'data':
pcm_data = chunk_body
break
pos += 8 + chunk_size
if chunk_size % 2 == 1:
pos += 1
nframes = len(pcm_data) // (sampwidth * nchannels)
samples = list(struct.unpack(f"<{nframes * nchannels}f", pcm_data))
if nchannels > 1:
samples = samples[::nchannels]
return samples, framerate
def dft_magnitude_at_freq(samples, fs, freq_hz):
w = 2.0 * math.pi * freq_hz / fs
re = sum(s * math.cos(w * n) for n, s in enumerate(samples))
im = sum(-s * math.sin(w * n) for n, s in enumerate(samples))
mag = math.sqrt(re * re + im * im)
db = 20.0 * math.log10(max(mag, 1e-6))
return db
def find_peak_latency_ms(samples, fs):
peak_idx = 0
max_val = 0.0
for i, s in enumerate(samples):
if abs(s) > max_val:
max_val = abs(s)
peak_idx = i
return (peak_idx / fs) * 1000.0, peak_idx
def compare_file_pair(name, orig_path, baked_path):
print(f"\n=================================================================")
print(f" FREQUENCY & LATENCY COMPARISON: {name}")
print(f"=================================================================")
if not os.path.exists(orig_path) or not os.path.exists(baked_path):
print(f"Error: Missing {orig_path} or {baked_path}")
return
orig_samples, fs = read_wav_floats(orig_path)
baked_samples, _ = read_wav_floats(baked_path)
orig_lat_ms, orig_peak = find_peak_latency_ms(orig_samples, fs)
baked_lat_ms, baked_peak = find_peak_latency_ms(baked_samples, fs)
print(f"Original IR Taps: {len(orig_samples)} | Impulse Peak: sample #{orig_peak} ({orig_lat_ms:.2f} ms delay)")
print(f"Baked IR Taps: {len(baked_samples)} | Impulse Peak: sample #{baked_peak} ({baked_lat_ms:.2f} ms delay)")
print(f"Latency Reduction: -{orig_lat_ms - baked_lat_ms:.2f} ms ({((orig_lat_ms - baked_lat_ms)/max(orig_lat_ms, 0.001))*100:.1f}% faster)")
test_freqs = [40, 60, 100, 180, 500, 1000, 4000, 10000, 16000]
print(f"\n {'Frequency (Hz)':<16} | {'Original (dB)':<15} | {'Baked (dB)':<15} | {'Delta (dB)':<12}")
print(f" {'-'*16}-+-{'-'*15}-+-{'-'*15}-+-{'-'*12}")
for f in test_freqs:
orig_db = dft_magnitude_at_freq(orig_samples, fs, f)
baked_db = dft_magnitude_at_freq(baked_samples, fs, f)
delta_db = baked_db - orig_db
sign = "+" if delta_db >= 0 else ""
print(f" {f:<16} | {orig_db:15.2f} | {baked_db:15.2f} | {sign}{delta_db:11.2f} dB")
def main():
print("=================================================================")
print(" mbp15-1-audio-dsp FIR RESPONSE COMPARISON ANALYZER")
print("=================================================================")
tweeter_orig = os.path.join(SCRIPT_DIR, "15_1", "tweeters-48k.wav")
tweeter_baked = os.path.join(SCRIPT_DIR, "15_1", "baked-tweeters-48k.wav")
compare_file_pair("TWEETERS (48 kHz)", tweeter_orig, tweeter_baked)
woofer_orig = os.path.join(SCRIPT_DIR, "15_1", "woofers-48k.wav")
woofer_baked = os.path.join(SCRIPT_DIR, "15_1", "baked-woofers-48k.wav")
compare_file_pair("WOOFERS (48 kHz)", woofer_orig, woofer_baked)
if __name__ == "__main__":
main()

145
sweep-analyzer.py Executable file
View File

@@ -0,0 +1,145 @@
#!/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 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 + 180 Hz Crossover High-Pass 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
# 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()