Files
collaplex-audio/测试/诊断低频沙沙.py
lou c9e9a5ac28 混响立体声丢失: 重做 IR 的工具把双声道压成了单声道 -> conf 改用 convolver 的 channel 按声道取
- 工具/重做混响IR.py 原来写 `x = x[:, 0]`, 只取左声道、把右声道丢掉, 写出去的 IR 成了
  单声道; 而 20-conf 里 revL/revR 读的是同一个文件 -> 两路湿声完全同源, 混响糊在正中央、
  没有宽度(原 IR 本身是双声道真立体声, 左右相关 0.884)
- 改为逐声道处理 + 双声道写回; 重新生成 reverb/房间混响IR-96k.wav (285888 x 2, 相关 0.9492)
- 20-conf: revL 用 channel = 0 / revR 用 channel = 1 —— PipeWire 的 convolver 支持该键,
  从多声道 IR 文件里按索引取声道。实测(通道探针: 左零右真的文件 + channel = 1)右耳尾巴
  不塌, 左右差仅 1.1 dB; 若读错声道会掉 40 dB 以上
- 新增取证脚本: 扫频测频响(Farina 反卷积, 确定性信号才测得准)、电平与失真诊断(只改电平的
  对照实验)、验证混响声道(文件级 + 物理级检查)、诊断低频沙沙(逐级旁路)
- 扫频脚本修了一个 bug: 湿量只在第一轮写, 后面几轮沿用被改过的值, 导致 B/C 两轮测的是同一配置
- README: 坑表加两条(双声道 IR + 验证判据的坑) + 2026-09-20 低频/沙沙诊断实测段
2026-09-20 12:55:51 +08:00

181 lines
6.5 KiB
Python

"""低频脏 / 沙沙 —— 逐级录音诊断。
素材两种: 粉噪(宽频, 暴露频响) + 200 Hz 纯音(暴露低频谐波 = 沙沙)。
逐级旁路, 每级单独喂同一素材, 录下来对比:
归一化后 -> collaplex_vsink 的监听(整链第一级的输出)
干声(湿量0) -> HRTF+混响 级的输出, 但混响湿量临时置 0
含混响 -> 同上, 湿量恢复 0.3(当前值)
整链最终 -> 数字输出 sink 的监听(送到耳机的真实信号)
对比口径: 各 1/3 倍频程相对 1 kHz 的能量(dB) —— 低频被抬了多少一眼看出来。
"""
from __future__ import annotations
import json
import math
import os
import subprocess
import sys
import time
import numpy as np
from scipy.io import wavfile
RATE = 96000
PROJ = "/home/lou/桌面/工作区/实验/collaplex音效"
TMP = "/tmp/cx_diag"
sys.path.insert(0, os.path.join(PROJ, "dsp"))
import common # noqa: E402
NOISE = os.path.join(TMP, "粉噪.wav")
TONE = os.path.join(TMP, "200Hz纯音.wav")
BANDS = [20.0, 25.0, 31.5, 40.0, 50.0, 63.0, 80.0, 100.0, 125.0, 160.0, 200.0,
250.0, 315.0, 400.0, 500.0, 630.0, 800.0, 1000.0, 1250.0, 1600.0,
2000.0, 3150.0, 5000.0, 8000.0, 12500.0]
def find_digital_sink() -> str:
raw = subprocess.run(["pw-dump"], capture_output=True, text=True).stdout
for node in json.loads(raw):
props = (node.get("info") or {}).get("props") or {}
name = str(props.get("node.name", ""))
if props.get("media.class") == "Audio/Sink" and "iec958" in name:
return name
return ""
def make_material() -> None:
os.makedirs(TMP, exist_ok=True)
rng = np.random.default_rng(20260920)
n = RATE * 8
white = rng.standard_normal(n)
spec = np.fft.rfft(white)
f = np.fft.rfftfreq(n, 1.0 / RATE)
spec[1:] /= np.sqrt(f[1:] / 1000.0) # 粉噪: 能量 ∝ 1/f
spec[0] = 0.0
pink = np.fft.irfft(spec, n)
pink *= 0.15 / (np.max(np.abs(pink)) + 1e-12)
stereo = np.stack([pink, pink], axis=1).astype(np.float32)
wavfile.write(NOISE, RATE, stereo)
t = np.arange(RATE * 8) / RATE
tone = 0.25 * np.sin(2 * np.pi * 200.0 * t)
wavfile.write(TONE, RATE, np.stack([tone, tone], axis=1).astype(np.float32))
def capture(target: str, src: str, out: str, settle: float = 3.2, dur: float = 2.0) -> None:
"""往 target 投素材, 从数字输出监听录 out。"""
player = subprocess.Popen(["pw-play", "--target", target, src],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
try:
time.sleep(settle)
rec = subprocess.Popen(
["timeout", str(int(dur) + 3), "pw-record", "--target", SINK,
"-P", "{ stream.capture.sink = true }",
"--rate", str(RATE), "--channels", "2", "--format", "f32", out],
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
rec.wait(timeout=dur + 8)
finally:
player.terminate()
player.wait(timeout=5)
def read_mono(path: str) -> np.ndarray:
_rate, data = wavfile.read(path)
x = data.astype(np.float64)
if x.ndim > 1:
x = x[:, 0]
return x
def band_levels(x: np.ndarray) -> tuple[list[float], float]:
"""每 1/3 倍频程的带内能量(dB, 相对 1 kHz 带), 以及整体峰值。"""
seg = x[: 65536 * 3]
seg = seg - float(np.mean(seg))
spec = np.abs(np.fft.rfft(seg * np.hanning(seg.size))) ** 2
freqs = np.fft.rfftfreq(seg.size, 1.0 / RATE)
vals: list[float] = []
for fc in BANDS:
m = (freqs >= fc * 2 ** (-1 / 6)) & (freqs <= fc * 2 ** (1 / 6))
vals.append(float(np.sum(spec[m])) if bool(np.any(m)) else 0.0)
ref = vals[BANDS.index(1000.0)] + 1e-30
return [10.0 * math.log10(v / ref + 1e-30) for v in vals], float(np.max(np.abs(x)))
def harmonics(x: np.ndarray, f0: float = 200.0) -> list[float]:
seg = x[: 65536 * 2]
spec = np.abs(np.fft.rfft(seg * np.hanning(seg.size))) ** 2
freqs = np.fft.rfftfreq(seg.size, 1.0 / RATE)
def at(f: float) -> float:
m = (freqs >= f - 20) & (freqs <= f + 20)
return float(np.max(spec[m])) if bool(np.any(m)) else 1e-30
base = at(f0)
return [10.0 * math.log10(at(f0 * k) / (base + 1e-30) + 1e-30) for k in (2, 3, 4, 5)]
def stats(x: np.ndarray) -> tuple[float, float, int]:
peak = float(np.max(np.abs(x)))
rms = float(np.sqrt(np.mean(x ** 2)))
clipped = int(np.sum(np.abs(x) > 0.999))
return 20 * math.log10(peak + 1e-12), 20 * math.log10(rms + 1e-12), clipped
SINK = ""
def main() -> None:
global SINK
SINK = find_digital_sink()
if not SINK:
print("找不到数字输出 sink")
return
make_material()
st = common.open_store()
gains, vol, wet_now, _ver = common.read_params(st)
print(f"数字输出 = {SINK}")
print(f"当前参数: 总音量 {vol:+.1f} dB, 混响湿量 {wet_now:.3f}")
print()
for tag, src in (("粉噪", NOISE), ("200Hz纯音", TONE)):
print("=" * 78)
print(f"素材: {tag}")
# 参考: 直接打进数字输出(不过链路)
ref = os.path.join(TMP, "ref.wav")
capture(SINK, src, ref)
xr = read_mono(ref)[RATE // 2:]
pk, rms, cl = stats(xr)
lv, _ = band_levels(xr)
print(f" {'直通(不过链)':<14} 峰值{pk:6.1f} RMS{rms:6.1f} dBFS 削波{cl:5d}")
print(f" {' '.join('%6.0f' % BANDS[i] for i in range(0, 13))}")
print(f" {' '.join('%6.1f' % lv[i] for i in range(0, 13))}")
# 湿量 0 -> 湿量 原值, 各录一次(在 HRTF+混响 级的输出 = collaplex_eq_in 的监听)
for wet_try, label in ((0.0, "湿量0(纯HRTF)"), (wet_now, "湿量当前")):
common.write_params(st, gains, vol, wet_try)
time.sleep(0.4)
out = os.path.join(TMP, "stage_%s_%s.wav" % (tag, label))
capture("collaplex_vsink", src, out)
x = read_mono(out)[RATE // 2:]
pk, rms, cl = stats(x)
lv, _ = band_levels(x)
print(f" {label:<12} 峰值{pk:6.1f} RMS{rms:6.1f} dBFS 削波{cl:5d}")
print(f" {' '.join('%6.1f' % lv[i] for i in range(0, 13))}")
if tag == "200Hz纯音":
h = harmonics(x)
print(" 谐波(相对基频): 2f %+.1f 3f %+.1f 4f %+.1f 5f %+.1f dB" % tuple(h))
common.write_params(st, gains, vol, wet_now)
print()
print("=" * 78)
print("频响读法: 每列 = 该频段能量相对 1 kHz 的 dB(0 = 与 1 kHz 齐平)")
if __name__ == "__main__":
main()