修低频过量的四条: HRIR 均衡 + 房间 IR 校平 + LN_MAX_BOOST + EQ 平直

诊断见 README「实测(2026-09-20)」: 链路的**线性**部分干净(无驻波、失真 −116 dB),
"低频脏 + 沙沙"来自电平被顶穿之后软限幅常年介入 —— 归一化把内容提到满刻度, 宽频信号
一削, 互调产物堆在低频(听感"脏")、高频碎屑(听感"沙沙")。实测超软限幅阈值样本 50.49%、
峰值因子被从 14 dB 压到 2.0 dB。

四条修复:
1. LN_MAX_BOOST 20 -> 12(默认值): 20 dB 的提升上限让任何轻内容都被顶穿满刻度
2. HRIR 同相合并响应均衡(lowshelf 500Hz −7.2dB Q=0.707, 12 条共用一条曲线,
   方向线索 ILD/ITD 不受影响): 这套 HRIR 是原始测量数据、没做过漫反射场均衡。
   ★ 口径注意: 标准 DF-EQ 用各方向**功率平均**做参考, 只要求压 1.5 dB; 但真实内容
   L≈R 时两耳信号是**相加**的, 同相合并响应才对应实测的 7.5 dB
3. 房间 IR 低频校平(lowshelf 300Hz −21dB Q=0.70): 低频比中频高 24.5 dB -> 压到 +5.8 dB
   (留一点厚度); IR 峰值随之掉 3.7 dB, 湿路 gain 0.4 -> 0.61 补偿, 保持混响响度不变
4. EQ 微笑曲线(20~63Hz +2.5 / 高频 +3)改回平直

复测(同素材同脚本): 低频 20~200Hz **+13.6 -> +0.2 dB**; 整链峰值因子 **2.0 -> 12.2 dB**;
超阈值样本 **50.49% -> 0.00%**; 峰值/RMS −0.1/−2.1 -> −6.2/−18.4 dBFS。
响度目标回到 −14 dB 也不会再顶穿。

新增: 工具/分析均衡曲线.py(量曲线)、工具/均衡IR.py(施加校正, 可重跑);
README 加「IR 校正」节 + 修复前后对照 + 两条测量方法上的坑。
IR 备份: hrir/*.pre-eq.wav 与 reverb/*.wav.pre-eq.wav(频响校正前)、
reverb/*.orig.wav(剪直达声前)。重跑顺序: 先 重做混响IR.py, 再 均衡IR.py。
This commit is contained in:
lou
2026-09-20 13:05:10 +08:00
parent c9e9a5ac28
commit 5c6a982210
31 changed files with 329 additions and 3 deletions
+36
View File
@@ -167,6 +167,27 @@ collaplex-audio stop # 连链路一起
**敏感判据 = 通道探针**: 把一个声道指向"左声道全零、右声道为真数据"的双声道文件,
再配 `channel = 1`; 若该声道输出**没有**塌掉, 就说明真的按索引取到了第 2 声道
(实测左右差仅 1.1 dB; 读错声道会掉 40 dB 以上)。
- **HRIR 的频响校正要用"同相合并响应", 不是标准 DF-EQ** —— 标准漫反射场均衡按各方向
**功率平均**取参考(假设各方向互不相关), 这套 HRIR 按它算只需要压 1.5 dB; 但真实内容
(人声/低频居中, L ≈ R)两耳信号是**相加**的, 同相合并响应实测低频高 **7.5 dB**, 这才
是用户实际听到的量。按同相合并设计后低频压 7.2 dB, 端到端复测低频落到 0(见下)。
## IR 校正(2026-09-20)
两处校正都改**文件**、不改 conf(数据干净、conf 简洁、任何用到这些 IR 的地方都受益),
工具 `工具/均衡IR.py` 可重跑, 原文件一律备份为 `.orig.wav`。曲线由 `工具/分析均衡曲线.py`
量出来。都用一个**共用的** lowshelf, 所以 HRTF 的方向线索(ILD/ITD)不受影响。
| 对象 | 问题 | 校正 | 效果 |
|---|---|---|---|
| 12 条 HRIR | 同相输入时低频比 1 kHz 高 7.5 dB | lowshelf 500 Hz **−7.2 dB** Q=0.707 | 端到端低频 **+7.5 → −0.1 dB** |
| 房间 IR | 低频比中频高 24.5 dB, 湿路灌低频 | lowshelf 300 Hz **−21 dB** Q=0.70 | 低频 **+24.5 → +5.8 dB**(留一点厚度) |
配套改动:
- `LN_MAX_BOOST` **20 → 12**(默认值) —— 20 dB 的提升上限会把任何轻内容顶穿满刻度,
是"软限幅常年介入"的直接原因。
- 房间 IR 被压低频后峰值掉 3.7 dB, 湿路 `gain` 相应 **0.4 → 0.61** 补偿, 这样听感差异
纯粹来自"低频变干净"而不是"混响变小"。
## 实测(2026-09-19)
@@ -214,6 +235,21 @@ collaplex-audio stop # 连链路一起
(只改电平的对照实验)、`测试/验证混响声道.py`(混响声道配置检查)、
`测试/诊断低频沙沙.py`(逐级旁路, 注: 其频响口径受电平影响, 已由前两个脚本取代)。
### 修复后复测(同素材同脚本)
| 指标 | 修复前 | 修复后 |
|---|---|---|
| HRTF + EQ 的低频(20~200 Hz 相对 1 kHz) | +7.5 dB | **−0.1 dB** |
| 再加混响 | +13.6 dB | **+0.2 dB** |
| 整链(经归一化)低频 | +12.7 dB | **+0.1 dB** |
| 整链峰值因子 | **2.0 dB** | **12.2 dB** |
| **超软限幅阈值样本** | **50.49%** | **0.00%** |
| 整链峰值 / RMS | −0.1 / −2.1 dBFS | **−6.2 / −18.4 dBFS** |
做完的四件事(全部落地): ① `LN_MAX_BOOST` 20 → 12; ② HRIR 同相合并响应均衡 −7.2 dB;
③ 房间 IR 低频校平 −21 dB + 湿路 gain 0.4 → 0.61 补偿; ④ EQ 微笑曲线(20~63 Hz +2.5 dB、
高频 +3 dB)改回平直。低频过量收回 ~13 dB 之后, 响度目标回到 −14 dB 也不会再顶穿。
## 试听
```sh
+1 -1
View File
@@ -8,7 +8,7 @@
# 音频绕过、零日志)。脚本内部再去 exec 中文路径的 DSP 是没问题的。
export LN_TARGET_DB=-12
export LN_GATE_DB=-50
export LN_MAX_BOOST=20
export LN_MAX_BOOST=12
export LN_MAX_CUT=20
export LN_SLEW_DB_S=6
export LN_RATE=96000
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
BIN
View File
Binary file not shown.
Binary file not shown.
+2 -2
View File
@@ -20,8 +20,8 @@ context.modules = [
{ type = builtin label = convolver name = cFRR config = { filename = "__HRIR__/FR_R.wav" } }
{ type = builtin label = mixer name = hL control = { "Gain 1" = 1.0 "Gain 2" = 1.0 } }
{ type = builtin label = mixer name = hR control = { "Gain 1" = 1.0 "Gain 2" = 1.0 } }
{ type = builtin label = convolver name = revL config = { filename = "__REVERB__" channel = 0 gain = 0.4 } }
{ type = builtin label = convolver name = revR config = { filename = "__REVERB__" channel = 1 gain = 0.4 } }
{ type = builtin label = convolver name = revL config = { filename = "__REVERB__" channel = 0 gain = 0.61 } }
{ type = builtin label = convolver name = revR config = { filename = "__REVERB__" channel = 1 gain = 0.61 } }
{ type = builtin label = pipe name = wetgL config = { command = "__WET_WRAPPER__" } }
{ type = builtin label = pipe name = wetgR config = { command = "__WET_WRAPPER__" } }
{ type = builtin label = mixer name = wetL control = { "Gain 1" = 1.0 "Gain 2" = 1.0 } }
Binary file not shown.
Binary file not shown.
+153
View File
@@ -0,0 +1,153 @@
"""分析出一条"该补多少"的曲线, 供设计均衡滤波器用。
两件事:
① HRIR 漫反射场均衡(DF-EQ): 这套 HRIR 是原始测量数据, 没做过漫反射场均衡,
表现为"对侧耳低频比自身 1 kHz 高 7~8 dB、所有 IR 高频高 4~5 dB" -> 链路低频过量。
标准做法: 用所有方向 IR 的**功率平均**当参考, 取 1/sqrt(P_avg) 作为均衡曲线。
② 房间 IR 的频响校平: 混响 IR 低频(20~200 Hz)比中高频高 20~25 dB, 湿路灌低频。
只做分析、打印曲线, 不写任何文件。
"""
from __future__ import annotations
import glob
import os
import warnings
import numpy as np
from scipy.io import wavfile
warnings.filterwarnings("ignore")
RATE = 96000
PROJ = "/home/lou/桌面/工作区/实验/collaplex音效"
N = 1 << 15 # FFT 长度 -> 频率分辨率 2.93 Hz
BANDS: list[float] = []
_f = 20.0
while _f <= 20000.0:
BANDS.append(round(_f, 2))
_f *= 2 ** (1 / 6) # 1/6 倍频程
def read_mono(path: str) -> np.ndarray:
_sr, data = wavfile.read(path)
a = np.asarray(data, dtype=np.float64)
if a.ndim > 1:
a = a[:, 0]
return a
def smooth_log(freq: np.ndarray, curve: np.ndarray, frac: float = 1 / 6) -> np.ndarray:
"""在 log 频率轴上按 1/frac 倍频程做移动平均(曲线要平滑, 不然是把噪声也均衡了)。"""
out = np.empty_like(curve)
for i, fc in enumerate(freq):
if fc <= 0:
out[i] = curve[i]
continue
m = (freq >= fc * 2 ** (-frac / 2)) & (freq <= fc * 2 ** (frac / 2))
out[i] = float(np.mean(curve[m])) if bool(np.any(m)) else curve[i]
return out
def main() -> None:
freq = np.fft.rfftfreq(N, 1.0 / RATE)
files = sorted(glob.glob(os.path.join(PROJ, "hrir", "*.wav")))
power = np.zeros(N // 2 + 1)
for path in files:
a = read_mono(path)
spec = np.fft.rfft(a, N)
power += np.abs(spec) ** 2
power /= len(files)
# 漫反射场均衡曲线: 1/sqrt(功率平均), 归一到 1 kHz
eq = 1.0 / np.sqrt(power + 1e-30)
eq = smooth_log(freq, eq, 1 / 6)
ref_mask = (freq >= 900) & (freq <= 1120)
eq /= float(np.mean(eq[ref_mask])) if bool(np.any(ref_mask)) else 1.0
eq_db = 20 * np.log10(eq + 1e-30)
print(f"HRIR 文件 {len(files)} 条, 功率平均后取 1/sqrt(P) 得均衡曲线")
print("① HRIR 所需均衡(相对 1 kHz, dB) —— 负值 = 要压掉")
line: list[str] = []
for fc in BANDS:
if fc > 12000:
break
m = (freq >= fc * 2 ** (-1 / 12)) & (freq <= fc * 2 ** (1 / 12))
v = float(np.mean(eq_db[m])) if bool(np.any(m)) else 0.0
line.append(f"{fc:>7.0f}Hz:{v:+5.1f}")
for i in range(0, len(line), 5):
print(" " + " ".join(line[i:i + 5]))
print()
for lo, hi, tag in ((20, 200, "低频 20-200"), (200, 1000, "中低 200-1k"),
(1000, 3000, "中高 1k-3k"), (3000, 12000, "高频 3k-12k")):
m = (freq >= lo) & (freq <= hi)
print(f" 均值 {tag:>14}: {float(np.mean(eq_db[m])):+6.2f} dB")
# ---- ② 房间 IR 的频响校平需求 ----
ir = read_mono(os.path.join(PROJ, "reverb", "房间混响IR-96k.wav"))
n_ir = 1 << 18 # 2.73s IR -> 用大 FFT
spec_ir = np.abs(np.fft.rfft(ir, n_ir)) ** 2
f_ir = np.fft.rfftfreq(n_ir, 1.0 / RATE)
# 用 1/6 倍频程平滑
sm = np.zeros_like(spec_ir)
for i in range(len(f_ir)):
fc = f_ir[i]
if fc <= 0:
continue
m = (f_ir >= fc * 2 ** (-1 / 12)) & (f_ir <= fc * 2 ** (1 / 12))
sm[i] = float(np.mean(spec_ir[m])) if bool(np.any(m)) else spec_ir[i]
ir_db = 10 * np.log10(sm + 1e-30)
ref_m = (f_ir >= 700) & (f_ir <= 1500)
ir_db -= float(np.mean(ir_db[ref_m]))
print()
print("② 房间 IR 自己的频响(相对 0.7-1.5 kHz 均值, dB) —— 正值 = 该频段过强")
line = []
for fc in BANDS:
if fc > 12000:
break
m = (f_ir >= fc * 2 ** (-1 / 12)) & (f_ir <= fc * 2 ** (1 / 12))
v = float(np.mean(ir_db[m])) if bool(np.any(m)) else 0.0
line.append(f"{fc:>7.0f}Hz:{v:+6.1f}")
for i in range(0, len(line), 5):
print(" " + " ".join(line[i:i + 5]))
for lo, hi, tag in ((20, 200, "低频 20-200"), (200, 1000, "中低 200-1k"),
(1000, 3000, "中高 1k-3k"), (3000, 12000, "高频 3k-12k")):
m = (f_ir >= lo) & (f_ir <= hi)
print(f" 均值 {tag:>14}: {float(np.mean(ir_db[m])):+6.2f} dB")
# ---- ③ 同相合并响应: 实际内容大多是相关的(人声/低频居中), 两耳信号是**相加** ----
# 功率平均(①)假设各方向互不相关, 会低估低频; 同相合并才是复核实测的那个口径。
def merge_power(a_name: str, b_name: str) -> np.ndarray:
xa = np.fft.rfft(read_mono(os.path.join(PROJ, "hrir", a_name)), N)
xb = np.fft.rfft(read_mono(os.path.join(PROJ, "hrir", b_name)), N)
return np.abs(xa + xb) ** 2
p_merge = (merge_power("FL_L.wav", "FR_L.wav") + merge_power("FL_R.wav", "FR_R.wav")) / 2.0
eq_m = 1.0 / np.sqrt(p_merge + 1e-30)
eq_m = smooth_log(freq, eq_m, 1 / 6)
eq_m /= float(np.mean(eq_m[ref_mask])) if bool(np.any(ref_mask)) else 1.0
eq_m_db = 20 * np.log10(eq_m + 1e-30)
print()
print("③ 同相合并响应所需的均衡(左耳 = FL_L+FR_L, 右耳 = FL_R+FR_R 取平均)")
line = []
for fc in BANDS:
if fc > 12000:
break
m = (freq >= fc * 2 ** (-1 / 12)) & (freq <= fc * 2 ** (1 / 12))
v = float(np.mean(eq_m_db[m])) if bool(np.any(m)) else 0.0
line.append(f"{fc:>7.0f}Hz:{v:+5.1f}")
for i in range(0, len(line), 5):
print(" " + " ".join(line[i:i + 5]))
for lo, hi, tag in ((20, 200, "低频 20-200"), (200, 1000, "中低 200-1k"),
(1000, 3000, "中高 1k-3k"), (3000, 12000, "高频 3k-12k")):
m = (freq >= lo) & (freq <= hi)
print(f" 均值 {tag:>14}: {float(np.mean(eq_m_db[m])):+6.2f} dB")
if __name__ == "__main__":
main()
+137
View File
@@ -0,0 +1,137 @@
"""给 IR 做频响校正 —— HRIR 漫反射场均衡 / 房间 IR 低频校平。
用法:
python 工具/均衡IR.py 分析 只看曲线, 不写文件
python 工具/均衡IR.py hrir 给 12 条 HRIR 做均衡(备份 .orig.wav)
python 工具/均衡IR.py reverb 给房间 IR 做低频校平(备份 .orig.wav)
python 工具/均衡IR.py 全部
为什么用 biquad 而不是频域均衡:
低频搁架需要很长的 FIR 才能表达(42ms 的 FIR 只有 23Hz 分辨率), 而 IIR 搁架几个
参数就够、且**因果**、不会把 IR 推出非因果的"前兆"。曲线本身是平滑的搁架型,
biquad 的表征能力完全够。
★ 为什么 HRIR 要用"同相合并响应"而不是标准 DF-EQ:
标准 DF-EQ 用各方向**功率平均**做参考(假设各方向不相关), 实测只要求压 1.5 dB;
但真实内容(人声/低频居中, L≈R)两耳信号是**相加**的 —— 同相合并响应实测低频
高 7.5 dB, 这才是用户听到的那个量。所以这里按同相合并来设计。
"""
from __future__ import annotations
import math
import os
import shutil
import sys
import warnings
import numpy as np
from scipy.io import wavfile
from scipy.signal import sosfilt, sosfreqz
warnings.filterwarnings("ignore")
RATE = 96000
PROJ = "/home/lou/桌面/工作区/实验/collaplex音效"
HRIR_DIR = os.path.join(PROJ, "hrir")
REVERB = os.path.join(PROJ, "reverb", "房间混响IR-96k.wav")
# 校正参数(由 工具/分析均衡曲线.py 的曲线量出来)
HRIR_SHELF = (500.0, -7.2, 0.707) # freq, gain_dB, Q —— 整族 HRIR 共用同一条,
# 方向线索(ILD/ITD)因此不受影响
REVERB_SHELF = (300.0, -21.0, 0.70) # 房间 IR 低频比中频高 24.5 dB, 压到约 +5 dB,
# 留一点厚度(真实房间的低频混响本就偏强)
def lowshelf(freq: float, gain_db: float, q: float, fs: int) -> list[float]:
"""RBJ cookbook 的低频搁架。"""
a = 10.0 ** (gain_db / 40.0)
w0 = 2.0 * math.pi * freq / fs
alpha = math.sin(w0) / (2.0 * q)
cos_w0 = math.cos(w0)
sqrt_a = math.sqrt(a)
b0 = a * ((a + 1) - (a - 1) * cos_w0 + 2 * sqrt_a * alpha)
b1 = 2 * a * ((a - 1) - (a + 1) * cos_w0)
b2 = a * ((a + 1) - (a - 1) * cos_w0 - 2 * sqrt_a * alpha)
a0 = (a + 1) + (a - 1) * cos_w0 + 2 * sqrt_a * alpha
a1 = -2 * ((a - 1) + (a + 1) * cos_w0)
a2 = (a + 1) + (a - 1) * cos_w0 - 2 * sqrt_a * alpha
return [b0 / a0, b1 / a0, b2 / a0, 1.0, a1 / a0, a2 / a0]
def band_report(freq: np.ndarray, db: np.ndarray, label: str) -> None:
out: list[str] = []
for lo, hi, tag in ((20, 200, "20-200"), (200, 1000, "200-1k"),
(1000, 3000, "1k-3k"), (3000, 12000, "3k-12k")):
m = (freq >= lo) & (freq <= hi)
out.append(f"{tag}:{float(np.mean(db[m])):+5.1f}")
print(f" {label:<12} " + " ".join(out))
def process(path: str, shelf: tuple[float, float, float], label: str,
n_fft: int, write: bool) -> None:
sr, data = wavfile.read(path)
a = np.asarray(data, dtype=np.float64)
stereo = a.ndim > 1
if not stereo:
a = a[:, None]
n0, n_ch = a.shape
spec_before = np.abs(np.fft.rfft(a[:, 0], n_fft)) ** 2
freq = np.fft.rfftfreq(n_fft, 1.0 / sr)
sos = np.array([lowshelf(shelf[0], shelf[1], shelf[2], sr)], dtype=np.float64)
y = np.empty_like(a)
for ch in range(n_ch):
y[:, ch] = sosfilt(sos, a[:, ch])
spec_after = np.abs(np.fft.rfft(y[:, 0], n_fft)) ** 2
w_curve, h = sosfreqz(sos, worN=n_fft, fs=sr)
curve_db = 20 * np.log10(np.abs(h) + 1e-12)
curve_freq = np.asarray(w_curve, dtype=np.float64)
print(f"[{label}] {os.path.basename(path)} {n0} 样 x {n_ch} 声道")
print(f" 搁架: freq={shelf[0]:.0f}Hz gain={shelf[1]:+.1f}dB Q={shelf[2]}")
band_report(curve_freq, curve_db, "施加的曲线")
b_db = 10 * np.log10(spec_before + 1e-30)
a_db = 10 * np.log10(spec_after + 1e-30)
ref = (freq >= 900) & (freq <= 1100)
s = float(np.mean(a_db[ref] - b_db[ref]))
band_report(freq, b_db - float(np.mean(b_db[ref])), "改前")
band_report(freq, a_db - float(np.mean(a_db[ref])) - s, "改后")
peak_b = float(np.max(np.abs(a)))
peak_a = float(np.max(np.abs(y)))
print(f" 峰值 {peak_b:.5f} -> {peak_a:.5f} ({20 * math.log10(peak_a / peak_b + 1e-12):+.2f} dB)")
if write:
# ★ 用独立的备份名。reverb 的 `.orig.wav` 是"剪直达声之前"的版本(那一步建的),
# 这里存的是"频响校正之前"的版本, 两者不能混用同一个名字 —— 否则重跑一步
# 会把另一步的基线覆盖掉。重跑顺序: 先 重做混响IR.py, 再 均衡IR.py。
bak = path + ".pre-eq.wav"
if not os.path.exists(bak):
shutil.copy2(path, bak)
print(f" 已备份 -> {os.path.basename(bak)}")
wavfile.write(path, sr, y.astype(np.float32))
print(" 已写回")
def main() -> None:
mode = sys.argv[1] if len(sys.argv) > 1 else "分析"
write = mode != "分析"
if mode in ("分析", "hrir", "全部"):
print("=== HRIR 漫反射场均衡(同相合并响应口径) ===")
for name in sorted(os.listdir(HRIR_DIR)):
if name.endswith(".wav") and not name.endswith(".orig.wav"):
process(os.path.join(HRIR_DIR, name), HRIR_SHELF, "HRIR",
1 << 15, write and mode in ("hrir", "全部"))
print()
if mode in ("分析", "reverb", "全部"):
print("=== 房间 IR 低频校平 ===")
process(REVERB, REVERB_SHELF, "房间IR", 1 << 18,
write and mode in ("reverb", "全部"))
if __name__ == "__main__":
main()