167 lines
6.4 KiB
Python
167 lines
6.4 KiB
Python
"""直出 vs 经链路 —— 量化"为什么经过链路听起来更好"。
|
|
|
|
听感(老板 2026-09-20): "直出有闷感; 经链路干净、有层次、有空间感但不糊。"
|
|
|
|
同一个粉噪素材, 分别投 **硬件 sink**(= 直出, 绕过链路) 与 **collaplex_vsink**(= 经链路),
|
|
都从硬件的 monitor 抓回来, 量三件事:
|
|
|
|
1. **响度 / 峰值因子** —— 归一化把内容提到恒定电平。听音电平变了, 人耳等响曲线下的
|
|
"主观频段平衡"跟着变(小声听什么都薄), 这是"有层次"的一部分来源。
|
|
2. **1/3 倍频程(相对 1 kHz)** —— 直出 = 素材原样; 链路里 HRTF 做了**同相合并响应均衡**
|
|
(-7.2 dB 低频) + 房间 IR 低频校平。低频不过量 -> 不再**掩蔽**中高频细节 = "不糊"。
|
|
3. **L/R 相关系数** —— HRTF 双耳化把两耳去相关(1.0 -> ~0.7), 声音从"头内"移到"头外",
|
|
这就是"空间感"的客观对应量。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import math
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import time
|
|
import warnings
|
|
|
|
import numpy as np
|
|
|
|
warnings.filterwarnings("ignore")
|
|
from scipy.io import wavfile # noqa: E402
|
|
|
|
RATE = 96000
|
|
PROJ = "/home/lou/桌面/工作区/实验/collaplex音效"
|
|
TMP = "/tmp/cx_ab"
|
|
SRC = "/tmp/cx_ab/粉噪.wav"
|
|
|
|
sys.path.insert(0, os.path.join(PROJ, "dsp"))
|
|
import common # noqa: E402
|
|
|
|
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]
|
|
|
|
_sink = ""
|
|
|
|
|
|
def ensure_source() -> None:
|
|
"""没有素材就生成一段粉噪(峰值 -12 dBFS)。"""
|
|
if os.path.exists(SRC):
|
|
return
|
|
os.makedirs(os.path.dirname(SRC), exist_ok=True)
|
|
rng = np.random.default_rng(20260920)
|
|
x = rng.normal(0.0, 1.0, RATE * 8)
|
|
spec = np.fft.rfft(x)
|
|
f = np.fft.rfftfreq(x.size, 1.0 / RATE)
|
|
spec[1:] /= np.sqrt(f[1:])
|
|
y = np.fft.irfft(spec, x.size)
|
|
y = y / float(np.max(np.abs(y))) * 0.25
|
|
wavfile.write(SRC, RATE, y.astype(np.float32))
|
|
print("已生成素材:", SRC)
|
|
|
|
|
|
def find_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 capture(target: str, out: str, settle: float = 3.0, dur: float = 2.4) -> None:
|
|
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) + 4), "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 analyse(path: str) -> tuple[list[float], float, float, float, float, float]:
|
|
"""-> (1/3oct 相对 1k, 峰值, RMS, 峰值因子, 超阈值占比, L/R 相关)"""
|
|
_sr, data = wavfile.read(path)
|
|
x = data.astype(np.float64)
|
|
mono = x[:, 0] if x.ndim > 1 else x
|
|
peak = float(np.max(np.abs(mono)))
|
|
rms = float(np.sqrt(np.mean(mono ** 2)))
|
|
crest = 20 * math.log10(peak / (rms + 1e-15) + 1e-15)
|
|
over = float(np.mean(np.abs(mono) > 0.93))
|
|
corr = 1.0
|
|
if x.ndim > 1:
|
|
left = np.asarray(x[:, 0], dtype=np.float64)
|
|
right = np.asarray(x[:, -1], dtype=np.float64) # 单列时取同一列, 相关恒为 1
|
|
n = min(left.size, right.size)
|
|
left, right = left[:n], right[:n]
|
|
if float(np.std(left)) > 1e-12 and float(np.std(right)) > 1e-12:
|
|
corr = float(np.corrcoef(left, right)[0, 1])
|
|
seg = mono[: 65536 * 3].copy()
|
|
seg = seg - float(np.mean(seg))
|
|
spec = np.abs(np.fft.rfft(seg * np.hanning(seg.size))) ** 2
|
|
freq = np.fft.rfftfreq(seg.size, 1.0 / RATE)
|
|
vals: list[float] = []
|
|
for fc in BANDS:
|
|
m = (freq >= fc * 2 ** (-1 / 6)) & (freq <= fc * 2 ** (1 / 6))
|
|
vals.append(float(np.sum(spec[m])) if bool(np.any(m)) else 1e-30)
|
|
ref = vals[BANDS.index(1000.0)] + 1e-30
|
|
return ([10 * math.log10(v / ref) for v in vals],
|
|
20 * math.log10(peak + 1e-15), 20 * math.log10(rms + 1e-15),
|
|
crest, over, corr)
|
|
|
|
|
|
def main() -> None:
|
|
global _sink
|
|
os.makedirs(TMP, exist_ok=True)
|
|
ensure_source()
|
|
_sink = find_sink()
|
|
if not _sink:
|
|
print("没找到数字输出 sink")
|
|
return
|
|
print("硬件 sink:", _sink)
|
|
|
|
st = common.open_store()
|
|
gains, vol, wet_now, _v = common.read_params(st)
|
|
print("面板当前: 湿量 %.2f / 总音量 %+.1f dB / EQ 非零 %d 段"
|
|
% (wet_now, vol, sum(1 for g in gains if abs(g) > 1e-9)))
|
|
print()
|
|
|
|
cases = [("A 直出(绕链路)", _sink), ("B 经链路", "collaplex_vsink")]
|
|
rows = []
|
|
for label, target in cases:
|
|
out = os.path.join(TMP, label.split()[0] + ".wav")
|
|
capture(target, out)
|
|
rows.append((label, *analyse(out)))
|
|
time.sleep(0.4)
|
|
|
|
print("%-18s %8s %8s %10s %12s %12s" %
|
|
("条件", "峰值", "RMS", "峰值因子", "超阈值", "L/R相关"))
|
|
for label, _lv, pk, rms, crest, over, corr in rows:
|
|
print("%-18s %7.1f %8.1f %9.1fdB %11.2f%% %12.3f"
|
|
% (label, pk, rms, crest, over * 100, corr))
|
|
|
|
print()
|
|
print("1/3 倍频程(相对 1 kHz, dB)")
|
|
print("%8s %12s %12s %10s" % ("Hz", "A 直出", "B 经链路", "差(B-A)"))
|
|
for i, fc in enumerate(BANDS):
|
|
a, b = rows[0][1][i], rows[1][1][i]
|
|
print("%8.0f %12.1f %12.1f %+10.1f" % (fc, a, b, b - a))
|
|
|
|
print()
|
|
for lo, hi, tag in ((20, 200, "低频"), (250, 1000, "中低"), (1250, 3150, "中高"), (5000, 12500, "高频")):
|
|
a = float(np.mean([rows[0][1][i] for i, f in enumerate(BANDS) if lo <= f <= hi]))
|
|
b = float(np.mean([rows[1][1][i] for i, f in enumerate(BANDS) if lo <= f <= hi]))
|
|
print("%-6s %5g~%-6g 直出 %+6.1f 链路 %+6.1f 差 %+5.1f dB" % (tag, lo, hi, a, b, b - a))
|
|
print()
|
|
print("响度差(B-A) = %+.1f dB L/R 相关: 直出 %.3f -> 链路 %.3f"
|
|
% (rows[1][3] - rows[0][3], rows[0][6], rows[1][6]))
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|