diff --git a/README.md b/README.md index 294a87d..1f5b0d9 100644 --- a/README.md +++ b/README.md @@ -331,6 +331,66 @@ collaplex-audio stop # 连链路一起 ③ 房间 IR 低频校平 −21 dB + 湿路 gain 0.4 → 0.61 补偿; ④ EQ 微笑曲线(20~63 Hz +2.5 dB、 高频 +3 dB)改回平直。低频过量收回 ~13 dB 之后, 响度目标回到 −14 dB 也不会再顶穿。 +## 和商业虚拟器(Dolby Atmos for Headphones 等)的差异(2026-09-20) + +用户听感: "感觉比杜比音效 Windows 版强。" 这个判断在**同一段立体声内容**下是站得住的, +但先要看清两边到底在比什么 —— 它们不是同一类东西。 + +### 能确定的差异(架构层面) + +| | Dolby Atmos for Headphones | 本链路 | +|---|---|---| +| 输入 | **多声道 / Atmos 对象** → 双耳渲染 | **仅立体声** → 双耳化 + 房间混响 | +| HRTF | 专有平均模型, **不可查、不可换、不可测** | SADIE II H4 真人测量 96k/512tap, **可换** | +| 可调性 | 几个固定预设(电影/音乐/游戏/语音) | 32 段 EQ + 湿量 + 总音量 + **场所联动**, 全部实时可调 | +| 每级可否测量 | ✗ 黑盒 | ✓ 本轮所有结论(低频 +13.6→+0.2 dB、限幅 50%→0%、L/R 相关、频响)全是量出来的 | +| 动态 | 通常带响度管理, 可能压动态 | 归一化 + **不压动态**(峰值因子保留 12.2 dB) | + +★ 杜比的内部实现(HRTF 数据集、处理参数、采样率)是**专有的, 公开查不到技术规格** —— +这不是"我们查得不够", 而是它作为商业黑盒的固有性质。 + +### ★ 我们真正的优势: 可测量 = 可修 + +杜比的问题不是"做得差", 而是**你没法定位问题**: 觉得不对只能整个关掉, 不知道它做了什么、 +更没法只改一处。本链路每一级都有量 —— 低频过量能算出 -7.2 dB、限幅介入能数出 50.49%、 +空间感能读成 L/R 相关 1.00→0.85。**这是"能继续改"的前提**, 也是这套东西的真实价值所在。 + +### 我们的边界(别自夸过头) + +**只能吃立体声。** 杜比的看家本领是**多声道 / Atmos 对象音频 → 双耳** —— 真正的逐方向 +定位。本链路的空间感来自 HRTF 外化 + 房间混响, 不是多声道渲染; 片源本身是 5.1/Atmos 时, +它的定位会明显更强。这是真差距, 不是谦虚。 + +### ⚠️ 对比前必须先对齐响度 + +本链路比直出响 **+15.1 dB**。**音量不对齐时, 更响的那个几乎总是显得"更好"**(等响曲线 + +心理声学偏好)。公平比法: 先把两边调到一样响再听 —— 否则比的是音量, 不是音质。 + +### 顺带: 手机厂商的"杜比音效"为什么常常不如直出 + +(用户 2026-09-20: "手机上的那个厂商杜比音效有时候还不如直出") + +**它和 Dolby Atmos for Headphones 不是一回事。** 手机上那个是**厂商买授权后自己集成的 +音效**, 主要干三件事, 全都是**为手机小喇叭 + 嘈杂环境**准备的: + +1. **抬低频** —— 小喇叭物理上出不来低频, 用 EQ 硬补 +2. **压动态**(DRC) —— 嘈杂环境里要让对白/细节听得清 +3. **提响度** —— 顺便听起来"更厉害" + +**戴耳机听时, 这三件事全是减分**: + +- 耳机**不需要**小喇叭的低频补偿 → 抬低频 = 低频过量 → 掩蔽中高频 = **闷 / 糊** +- 压动态 = 音乐失去起伏 = **平、吵、没层次** +- 提响度 = 更容易撞上后面的限幅 + +而"全景声"在**外放**上基本无效(两个小喇叭间距 < 15 cm, 物理上做不出空间感)。真正的双耳 +渲染需要串扰消除 + HRTF —— 那是 Dolby Atmos for Headphones 那种独立渲染器干的事, 不是 +手机音效模块。 + +**一条可推广的原则**: 对耳机来说, 任何**"固定假设"的音效都是减分**(耳机自己的频响已经是 +一种处理了), 只有**基于测量的校准**才是加分。这也是本链路和它们的根本区别 —— 我们不是 +套一个"音效", 而是量出这条链路(以及这只耳机)的问题再针对性修。 + ## 试听 ```sh diff --git a/bin/collaplex-audio b/bin/collaplex-audio index 7c13271..7958bd7 100755 --- a/bin/collaplex-audio +++ b/bin/collaplex-audio @@ -42,7 +42,9 @@ stop) # 面板按相对路径起过(如 .venv/bin/python web/server.py), 只匹配绝对路径会漏, 用后段匹配 for pid in $(pids "web/server.py"); do kill "$pid" 2>/dev/null || true; done rm -f /dev/shm/collaplex-eq /dev/shm/collaplex-loudness - rm -f /dev/shm/collaplex-eq.lock /dev/shm/collaplex-loudness.lock + # 锁文件是每声道一份: <名>.lock0 / <名>.lock1(common.py 按声道编号), + # 只删基名会留下残留 → 用通配 + rm -f /dev/shm/collaplex-eq.lock* /dev/shm/collaplex-loudness.lock* systemctl --user restart pipewire pipewire-pulse wireplumber sleep 1 echo "已停止。默认输出: $(wpctl inspect @DEFAULT_SINK@ 2>/dev/null | sed -n 's/.*node.description = //p' | head -1)" diff --git a/测试/直出对比.py b/测试/直出对比.py new file mode 100644 index 0000000..2697821 --- /dev/null +++ b/测试/直出对比.py @@ -0,0 +1,166 @@ +"""直出 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()