stop: 清锁改用通配 —— 锁文件是每声道一份(.lock0/.lock1), 只删基名会留残留
This commit is contained in:
@@ -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
|
||||
|
||||
+3
-1
@@ -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)"
|
||||
|
||||
+166
@@ -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()
|
||||
Reference in New Issue
Block a user