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、
|
③ 房间 IR 低频校平 −21 dB + 湿路 gain 0.4 → 0.61 补偿; ④ EQ 微笑曲线(20~63 Hz +2.5 dB、
|
||||||
高频 +3 dB)改回平直。低频过量收回 ~13 dB 之后, 响度目标回到 −14 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
|
```sh
|
||||||
|
|||||||
+3
-1
@@ -42,7 +42,9 @@ stop)
|
|||||||
# 面板按相对路径起过(如 .venv/bin/python web/server.py), 只匹配绝对路径会漏, 用后段匹配
|
# 面板按相对路径起过(如 .venv/bin/python web/server.py), 只匹配绝对路径会漏, 用后段匹配
|
||||||
for pid in $(pids "web/server.py"); do kill "$pid" 2>/dev/null || true; done
|
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 /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
|
systemctl --user restart pipewire pipewire-pulse wireplumber
|
||||||
sleep 1
|
sleep 1
|
||||||
echo "已停止。默认输出: $(wpctl inspect @DEFAULT_SINK@ 2>/dev/null | sed -n 's/.*node.description = //p' | head -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