Files
糖糖 d331128e88 输出设备不写死: 面板设备选择 + 自动挂载守护(治开机后"双重混响")
- 问题: 30-collaplex-eq.conf 的 node.target 是安装时探测一次写死的。开机时设备若还没枚举
  (USB 耳机晚到很常见: 09-24 实测开机 08:57、耳机 09:19:43 才出现; 09-23 是 21:01 → 21:03),
  建链时目标不存在 → 末段连到别处或干脆不连, 设备出现后不会自己改回来 → 听感"双重混响",
  只能 stop + start
- 新增 web/output_route.py: 设备清单(pw-dump) + 选择落盘(output.json) + 守护线程(每 3 秒核对
  collaplex_eq_out 只连选中设备, 不对就拆错的、按声道补对的) → 设备一出现自动挂上
- web/server.py: GET /api/devices, POST /api/device(落盘 + 立刻挂载), 快照带 output 段,
  main() 起守护线程
- web/index.html: 「输出设备」卡片(下拉 + 应用 + 状态 + 守护备注; 设备表变化才重建选项, 免得
  60 Hz 重建打断点击)
- 脚本/安装.sh: 优先用记住的设备(不在位也照样按它建链), 不再"猜第一个 digital"
- 实测: 面板真点应用切走再切回 ✓ / 连错 2 秒自动纠回 ✓ / 全拆 3 秒自动挂回 ✓ / start 自检
  打印 [在位] ✓ / 面板像素验收 ✓; pyright strict 0 errors 0 warnings
- 坑: pw-dump 的 Link 键名是 output-port-id / input-port-id(写 output-port 一条连线都读不到);
  面板服务 enable --now 不会重启已在运行的实例(改 web/*.py 必须 restart)
2026-09-24 09:36:36 +08:00

488 lines
28 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Collaplex 音效
把系统音频接成一条**耳机空间音效链**(PipeWire + 自研 DSP), 带本地控制面板。
**第二代(2026-09-19)**: 对第一代 `edgevoid/cinema-spatial` **推倒重构** —— 从"一张 40+ 节点的大图、
改个参数要重装重载"改成"每个效果级独立实例 + 自研 DSP、参数实时生效"。
| | 第一代 (cinema-spatial) | 第二代 (本项目) |
|---|---|---|
| 架构 | filter-chain 大图(40+ 节点), 串联调试 | **每个效果级独立实例** + 自研 DSP, 靠 `node.target` 串链, 可单独推流取证 |
| EQ | `param_eq` 三段, 改配置要重装 | **32 段 1/3 倍频程**(实时) + 10 个预设 |
| 响度 | 无 | **响度归一化**(能量滑窗 + 6 dB/s 限速, 左右 flock 共享增益防声像漂) |
| 混响 | 混在图里, 湿量写死 | convolver(3 秒长 IR) + **独立湿量级**(滑块实时可调) |
| 控制 | 简单开关 | 本地面板 **62 Hz 推送**: 4 个双声道电平表 / 归一化电平与动态增益 / 湿度与总音量 / 32 段 EQ |
| 分发 | deb | deb + 主命令 `collaplex-audio start/stop/web`(装完不动系统音频) |
第二代把"能跑"升级成**"可调 + 可验"**: 每个效果级都能单独推流取证, 参数走共享内存立即生效,
失真 / 削波 / 底噪 / 混响尾巴全部拿实测数据说话(见文末「实测」与「测试脚本」)。
```
全局音频(应用输出)
→ collaplex_vsink 虚拟声卡(全局默认输出)
→ 响度归一化 pipe DSP ×2(左右各一进程, flock 共享增益)
→ collaplex_hrtf_in 双耳 HRTF(SADIE II / H4, 4 条卷积)
→ 房间混响 convolver(3 秒 IR) + 湿量级(pipe DSP ×2)
→ collaplex_eq_in 32 段 EQ + 总音量 + 软限幅(pipe DSP ×2)
→ 数字输出(S/PDIF)
```
**6 个节点 + 6 个 DSP 进程**。全链 96 kHz。
## 安装
**源码树里(开发用):**
```sh
collaplex-audio start # 等价于 bash 脚本/安装.sh
```
**deb 包:**
```sh
sudo dpkg -i collaplex-audio_1.0.0_all.deb
collaplex-audio start # 装完不会自动接管, 必须手动这一步
```
依赖 `python3-numpy` / `python3-scipy` / `pipewire` / `wireplumber`
**装完不动你的系统音频**: 不写 pipewire 配置、不做开机自启, 手动 `start` 才接管,
`stop` 即还原(默认输出回到硬件设备)。
## 主命令
日常就三个动作:
```sh
collaplex-audio start # 启动: 接管系统音频(部署配置 + 重载 PipeWire + 自检)
collaplex-audio stop # 停止: 恢复系统默认输出, 同时关掉面板
collaplex-audio web # 面板: 起控制面板并打开浏览器
```
`start` 做的事: 探测数字输出 → 部署 pipe 插件入口 → 渲染 4 份配置 → **清掉残留 DSP**
(占住共享槽位会让新实例起不来) → 重载 PipeWire → 自检。
`WET=0.4 bash 脚本/安装.sh` 可指定初始湿量(默认 0.3)。
面板端口可换: `WEB_PORT=8899 collaplex-audio web`
### 开机自启
**链路不需要服务** —— 4 份 conf 部署在 `~/.config/pipewire/pipewire.conf.d/`, PipeWire 一起来
就自动加载, 虚拟节点与 DSP 进程跟着起; 默认输出由 WirePlumber 记忆, 开机仍是 `collaplex_vsink`
所以 `start` 跑过一次就长期有效。
**面板**用 systemd 用户服务 `collaplex-audio-panel.service` 自启(登录即起, `Restart=no`),
`start` 时会自动装上并 enable。不要了:
```sh
systemctl --user disable --now collaplex-audio-panel.service # 取消自启并停掉面板
collaplex-audio stop # 连链路一起还原成系统默认输出
```
音频设备与 PipeWire 都是**用户级**的, 所以"开机自启"实际是**登录自启**。
## 控制面板
`collaplex-audio web` → http://127.0.0.1:8789
| 区域 | 内容 |
|---|---|
| **电平** | 双声道 4 个表: 入 L / 出 L / 入 R / 出 R。条长 = 实时 RMS(60~0 dBFS), 小字 = 本块峰值 |
| **电平归一化** | 响度目标滑块 + **输入电平条** + **动态增益推子**(0 dB 在 60% 刻度线上, 量程 18~+12 dB) |
| **混响湿度** | 0 ~ 1, 实时生效 |
| **总音量** | ±12 dB, 实时生效 |
| **输出设备** | 下拉选输出到哪台硬件设备(HDMI / 内建模拟 / USB 耳机 …) + 「应用」; 右侧显示是否已挂载 |
| **32 段 EQ** | 1/3 倍频程(20 Hz ~ 25 kHz), 每段 ±15 dB,**10 个预设** |
- 所有滑块: **滚轮调节 · Shift+滚轮微调 · 双击归零**
- 面板刷新 **62 Hz**(SSE 推送), 表头带**快起慢落 + 峰值保持 1.6 秒**
- 预设: 平直 / 流行 / 摇滚 / 爵士 / 古典 / 人声 / 低音 / 高音 / 深夜 / 柔化
—— 每个只写 4~5 个「频率+增益」控制点, 在对数频率轴上插值成 32 段
- 参数落点: `/dev/shm/collaplex-eq`(前端写、DSP 读, 版本号保证不会读到半更新);
响度目标写 `~/.local/state/cinema-spatial/loudness.json`(归一化 DSP 每秒重读)
- **改参数不用重装** —— 只有改 DSP 代码或 PipeWire 图才需要重跑 start
## 空间数据来源
| 用途 | 数据 | 来源 |
|---|---|---|
| 双耳 HRTF | **SADIE II** 受试者 **H4**, 96 kHz / 24 bit, 每条 4800 tap(约 50 ms) | University of York 音频实验室, [Zenodo DOI 10.5281/zenodo.12092466](https://doi.org/10.5281/zenodo.12092466) |
| 房间混响 | 自家房间模型程序化生成的双耳 IR(3 秒) | 本项目 `reverb/` |
- `hrir/` 的 12 条 = H4 的 6 个方向 × 左右耳(BL / BR / FC / FL / FR / LFE);
链路实际用**前左 + 前右**共 4 条(`FL_L` / `FL_R` / `FR_L` / `FR_R`)做双耳化
- SADIE II 为**真人受试者**测量: 2818 个方向, 仰角 ±90°, 测距 1.2 m
## 实现要点与踩过的坑
### 结构
- **每个效果级做成独立实例**: 一个接入 sink + 一个输出 target, 靠 `node.target` 串成链。
可以在任意一级单独 `pw-play` 推流验证, 不用在主链路上串联调试。
- **`pipe` 插件的 command 只吃「程序 + 一个参数」**(shebang 式 exec), 参数全写进 wrapper;
且 wrapper 路径必须是**纯 ASCII**(中文路径静默失效)。
- **湿量为什么抽成独立 DSP**: 混响交给 PipeWire 的 convolver(能跑 3 秒长 IR), 但图里的
mixer 增益是**加载时定死**的, 改它必须重载 = 断音。所以只把"湿量"抽出来做 DSP 增益级。
- 左右声道各 fork 一个 DSP 进程、命令相同, 靠 **flock 抢声道槽位**区分(进程死锁自动释放)。
### DSP
- **块长不能假设固定**: `quantum` 在 32 ~ 2048 之间浮动, 按实际读到的长度一对一处理。
`np.resize` 补到固定长度会**重复填充** → 周期性伪影(听感"沙沙")。
- **增益要块内插值**: 整块共用一个增益会在块边界留台阶(zipper 噪声)。改为
「上一块增益 → 本块增益」的逐样本斜坡后, 谐波(相对基频) +0/+6 dB 由 12.1 / 15.9 dB
降到 **90.1 / 70.6 dB**。
- **软限幅阈值要贴近 0**: 上限 `ceiling=0.99 / knee=0.06`(介入点 0.63 dBFS)。
曾用 3.9 dBFS, 而**音乐**的峰值(4 ~ 0 dBFS)长期落在里面被压 → 中低频谐波。
- 归一化用**能量滑窗 + 限速**(目标 14 dBFS, 6 dB/s), 左右进程 flock 共享同一增益 → 声像不漂。
### 陷阱
- **`convolver``gain` 是线性倍数, 不是 dB** —— 写 `-18.0` 会变成"反相 ×18", 电平反而更高。
房间 IR 未归一化(频响在 100~200 Hz 高 20 dB), 所以要标定成 `gain = 0.126`
- **`param_eq` 端口名带编号**(`In 1` / `Out 1`), 写成 `:In` 会让**整张图加载失败**, 模块静默降级成直通。
- **峰值相关的失真, 纯音测不出来** —— 纯音峰值因子仅 3 dB, 音乐是 10~14 dB。
必须用高峰值因子素材测, 见 `测试/test_limit.py`
- **改完 `watchdog` / 常驻脚本要重启巡检** —— 旧定义还在内存里按旧参数拉服务。
- **按命令行找进程要按"后段"匹配** —— 面板若用相对路径起过(`.venv/bin/python web/server.py`),
只匹配绝对路径会漏杀 → 端口被占、新服务起不来。写 `*web/server.py*` 这种够特异的后段。
- 清进程时匹配串要够特异: 写 `*dsp/eq.py*` 会匹配到**自己的命令行**而自杀,
至少写成 `*python*dsp/eq.py*`
- **房间 IR 当混响用之前, 必须清掉里面的直达声 / 传播延迟** —— 否则就是**回声**。
原 IR 前 20 ms 是静音、主峰在 **30 ms**(= 声源到麦克风 10.3 m 的声程), 前两次反射还跟
直达声**同量级**(30 ms 处 0 dB、40 ms 处 1.6 dB)。干声(HRTF)是零延迟的, 两路一相加
就成了"湿声晚 30 ms 还很响" = 明显的回声。
**脉冲取证**: 主峰后 120 ms 处一个 26 dBFS 孤立峰(比周围高 12 dB); 湿量设 0 则完全没有。
**修法**(`工具/重做混响IR.py`, 原文件备份为 `.orig.wav`): 裁掉前 22 ms 纯静音 →
前 55 ms 余弦渐入(直达声段 0 → −23.6 dB) → 只留混响尾; 再把 **`gain` 重标** 0.126 → 0.4
(剪掉直达声后湿声整体掉 ~19 dB, 不补就是"回声没了、混响也没了")。
- **房间 IR 必须保持双声道, 并在 conf 里用 `channel` 分别取左右** —— 2026-09-19 版
`工具/重做混响IR.py` 里写了 `x = x[:, 0]`, 把立体声 IR 的右声道直接丢掉, 写出去的 IR
成了单声道; 而 20-conf 里 `revL` / `revR` 读的是同一个文件 —— 两路湿声完全同源,
混响糊在正中央、没有宽度(原 IR 本身是**双声道真立体声**, 左右相关 0.884)。
**修法**: 处理时逐声道做、双声道写回; conf 里 `revL``channel = 0``revR``channel = 1`
PipeWire 的 convolver **支持 `channel` 参数**(`libspa-filter-graph-plugin-builtin.so`
里的合法配置键: `filename` / `channel` / `gain` / `blocksize` / `delay` / `latency` /
`length` / `offset` / `tailsize` / `resample_quality`), 从多声道 IR 文件里按索引取声道。
- **验证"混响左右是否真的独立", 别用尾巴左右相关系数** —— 混响尾巴能量集中在低频,
而 HRTF 左右耳在低频几乎没有头影差异, 所以 IR 无论单声道还是立体声, 尾巴左右相关
都接近 **0.9996**, 判据完全不敏感, 会把"已修好"误判成"没生效"(2026-09-20 实测踩过)。
**敏感判据 = 通道探针**: 把一个声道指向"左声道全零、右声道为真数据"的双声道文件,
再配 `channel = 1`; 若该声道输出**没有**塌掉, 就说明真的按索引取到了第 2 声道
(实测左右差仅 1.1 dB; 读错声道会掉 40 dB 以上)。
- **HRIR 的频响校正要用"同相合并响应", 不是标准 DF-EQ** —— 标准漫反射场均衡按各方向
**功率平均**取参考(假设各方向互不相关), 这套 HRIR 按它算只需要压 1.5 dB; 但真实内容
(人声/低频居中, L ≈ R)两耳信号是**相加**的, 同相合并响应实测低频高 **7.5 dB**, 这才
是用户实际听到的量。按同相合并设计后低频压 7.2 dB, 端到端复测低频落到 0(见下)。
- **参数必须落盘, 不能只活在 `/dev/shm` 里** —— 共享内存是 tmpfs, 重启即空。如果
`open_store()` 只是"不存在就零填充创建", 重启后参数全变 0 —— 听感上不是"参数没保存",
而是**"混响突然没了"**(湿量 0), 极易被当成链路故障去查(2026-09-20 实测复现)。
修法: 改成"文件是**新建的** → 从落盘恢复", 落盘由面板在每次改参数时写(见「参数持久化」)。
## IR 校正(2026-09-20)
两处校正都改**文件**、不改 conf(数据干净、conf 简洁、任何用到这些 IR 的地方都受益),
工具 `工具/均衡IR.py` 可重跑, 原文件一律备份为 `.orig.wav`。曲线由 `工具/分析均衡曲线.py`
量出来。都用一个**共用的** lowshelf, 所以 HRTF 的方向线索(ILD/ITD)不受影响。
| 对象 | 问题 | 校正 | 效果 |
|---|---|---|---|
| 12 条 HRIR | 同相输入时低频比 1 kHz 高 7.5 dB | lowshelf 500 Hz **7.2 dB** Q=0.707 | 端到端低频 **+7.5 → 0.1 dB** |
| 房间 IR | 低频比中频高 24.5 dB, 湿路灌低频 | lowshelf 300 Hz **21 dB** Q=0.70 | 低频 **+24.5 → +5.8 dB**(留一点厚度) |
配套改动:
- `LN_MAX_BOOST` **20 → 12**(默认值) —— 20 dB 的提升上限会把任何轻内容顶穿满刻度,
是"软限幅常年介入"的直接原因。
- 房间 IR 被压低频后峰值掉 3.7 dB, 湿路 `gain` 相应 **0.4 → 0.61** 补偿, 这样听感差异
纯粹来自"低频变干净"而不是"混响变小"。
## EQ 预设的设计依据(2026-09-20)
前 10 个是常见调音口味; 后面两个「电影院」「歌剧院」是**按现实场所的声学特性**设计的,
不是随手画的微笑曲线。
### 电影院
- **高频按 X-curve**(ISO 2969 国际版): 2 kHz 以上每倍频程约 **-1.5 dB**
(北美 SMPTE 版是 -3 dB/oct, 听感更暗)。本预设实测 2k→4k→8k→16k = **-1.5 / -1.5 / -1.5 dB/oct**。
再叠一层**银幕透声损失**(穿孔幕布在 4~10 kHz 的衰减), 所以中高频起点比纯 X-curve 再低一点。
- **低频抬 +2.5~3 dB**: 影院有 **sub / LFE 通道**(标定电平比主声道高 10 dB)+ 厅内增益,
25~60 Hz 是"体感"的来源。
- **中低频 200~400 Hz 略降**: 银幕后扬声器 + 厅堂增益让这里容易浑, 让出一点。
- 听感目标: **厚, 暗, 有力**
### 歌剧院
- **中低频 150~400 Hz 抬 +2 dB**: 木质地板/墙面的厅堂温暖感(bloom), 也是大提琴/男中的厚度来源。
- **2~5 kHz 抬 +0.5~1 dB**: 歌剧院**没有扩声系统**, 歌手和乐器的"投射"全靠这个 presence 区 ——
这里**不能削**(削了就闷), 也正是和影院最大的分野。
- **极低频 20~40 Hz 略降 -1 dB**: 没有电声强化, 自然厅堂里这一段本就不强。
- **高频缓降**: 只有大空间的空气吸收, 10 kHz 以上 -0.5~-2 dB(**比影院缓得多**)。
- 听感目标: **暖, 亮, 有细节**
### 两者差异(实测平均)
| 频段 | 电影院 | 歌剧院 | 差 |
|---|---|---|---|
| 25~60 Hz | +2.75 | -0.62 | 影院重 **+3.4 dB** |
| 150~400 Hz | -0.10 | +1.90 | 歌剧院足 **+2.0 dB** |
| 1~2 kHz | -0.25 | +0.25 | 基本持平 |
| 3.15~5 kHz | -2.00 | +1.00 | 歌剧院足 **+3.0 dB** |
| 8~16 kHz | -4.25 | -0.75 | 歌剧院足 **+3.5 dB** |
### 面板上的「场所」按钮 —— 一键切 EQ + 混响
**混响时间**才是这两个场所最本质的差别, 影响比 EQ 更大, 所以面板把这两件事做成了
**一个按钮**(在 EQ 预设下一行, 单独一排「场所」):
| 场所 | 实际混响时间 | 湿量 |
|---|---|---|
| 电影院(带银幕的放映厅) | 0.4~0.8 s | **0.35** |
| 歌剧院 | 1.4~1.8 s | **0.78** |
点一下: **EQ 32 段 + 混响湿量** 一起切到位, 按钮亮绿框; 之后再手动拖 EQ 滑块或湿量推子
会自动取消这个高亮(表示已经不在纯预设状态)。
**总音量不在这套里** —— 那是个人听音响度, 不该切个场所就被改掉。
## 参数持久化(2026-09-20)
面板上拖出来的 32 段 EQ / 总音量 / 湿量, 现在会**落盘**; 共享内存被清空后(开机、重启、
`systemctl restart pipewire`)由 DSP 自动恢复。
**为什么需要**: `/dev/shm` 是 tmpfs, 重启就没了。修之前 `dsp/common.py``open_store()`
是"文件不存在就零填充创建" —— 参数全变 0, 听感上就是**混响直接消失**(湿量 0), 得重新调一遍。
**机制**:
| 环节 | 做什么 |
|---|---|
| 面板改参数 | `POST /api/eq` `/api/volume` `/api/wet``common.save_params()` 落盘(值没变不写) |
| DSP 启动 | `common.open_store()` 判断文件是**新建的** → `restore_params()` 从落盘恢复(带上下限夹取) |
| 落盘缺失/损坏 | 退回 `defaults()`(全平直 / 0 dB / 0.3) |
落盘在 `~/.local/state/cinema-spatial/params.json`(与响度归一化的 `loudness.json` 同目录):
```json
{"eq": [2.5, 2.5, "... 32 段 ..."], "volume_db": 2.0, "wet": 0.6}
```
**实测**(`rm /dev/shm/collaplex-eq*` 后重启 pipewire): 参数自动恢复, 与落盘逐值一致 ——
总音量 +2.0 / 湿量 0.600 / EQ 非零 30 段 ✓
**注意**: 落盘是"最后一次改动的快照", 不是"出厂默认"。要回到出厂默认就删掉 `params.json`
并清掉共享内存(`rm /dev/shm/collaplex-eq*`), 再重启链路。
## 输出设备选择与自动挂载(2026-09-24)
**问题**: 链路末段的输出设备原来**写死**在 `30-collaplex-eq.conf``node.target`(安装时探测
"第一个 iec958/digital")。开机时那台设备要是还没枚举出来, 会话管理器建链时目标不存在,
末段就连到别处或者干脆不连; 设备出现了也**不会**自己改回来。听感上就是"双重混响 / 声音
不对", 只有 `collaplex-audio stop` + `start` 才恢复。
**实测的时序证据**(两次都是同一个模式 —— 链路先建好、设备后到):
| 日期 | 开机 | 输出设备(EDIFIER USB)枚举 | 备注 |
|---|---|---|---|
| 2026-09-23 | 21:01:17 | **21:03:01**(+1 分 44 秒) | 那次 DSP 进程号也对得上: 归一化/湿量在 3997~4034, EQ 到 8924 才起 |
| 2026-09-24 | 08:57:07 | **09:19:43**(+22 分 36 秒) | `dmesg`: `usb 3-2.2: Product: EDIFIER Fit900NB` |
**现在两件事一起做**:
| 机制 | 说明 |
|---|---|
| **面板按钮** | 「输出设备」卡片: 下拉选硬件输出 + 「应用」→ 立刻重挂 + 落盘 |
| **选择落盘** | `~/.local/state/cinema-spatial/output.json`: `{"node_name": "...", "description": "..."}`; `脚本/安装.sh` 优先用它, 不再"猜第一个数字输出" |
| **自动挂载守护** | 面板进程里的守护线程(`web/output_route.py`), 每 3 秒核对"选中的设备在位 且 `collaplex_eq_out` **只**连到它"; 不对就拆掉错的、补上对的 —— **设备一插上就自动挂上, 不用重启链路** |
**为什么设备名可以记住、挂载不能写死**: ALSA 节点名带 USB 序列号
(`alsa_output.usb-EDIFIER_..._4250315939393214-00.iec958-stereo`), 跨重插/重开机稳定,
所以"选哪台"能记住; 不稳定的是**建链那一刻设备在不在**, 那正是守护要兜的事。
**实测**(2026-09-24, 全部真跑):
| 场景 | 结果 |
|---|---|
| 面板真点「应用」切到内建模拟输出 | `routed` 立刻变成模拟输出 ✓ |
| 再选回耳机点「应用」 | `routed` 变回 EDIFIER, 提示行 `已挂到 EDIFIER Fit900NB 数字立体声(IEC958)` ✓ |
| 手动把末段连到错的设备, 等守护 | **2 秒**自动纠回 ✓ |
| 把末段连线全拆掉, 等守护 | **3 秒**自动挂回 ✓ |
| `collaplex-audio start` 自检 | 打印 `输出设备: … (EDIFIER Fit900NB 数字立体声(IEC958)) [在位]` ✓ |
**接口**: `GET /api/devices`(设备清单 + 当前挂载 + 守护备注)、
`POST /api/device {"name": "alsa_output.xxx"}`(落盘 + 立刻挂载)。
**注意**: 面板服务单元是 `enable --now` —— 已经在跑时 `--now` **不会**重启, 所以改完
`web/*.py` 必须 `systemctl --user restart collaplex-audio-panel.service`(否则新代码只落在
磁盘上, 内存里还是旧的: 实测 `GET /api/devices` 会 404)。
## 实测(2026-09-19)
| 指标 | 数据 |
|---|---|
| 响度归一化 | 输入差 20.00 dB → 输出端差 **0.00 dB** |
| HRTF 双耳化 | 出口左右相关系数 **0.679**(直通 = 1.000) |
| 混响 | 湿量 0 → 无尾巴; 湿量 1 → **3 秒平滑衰减尾巴**(11 → 129 dBFS 实测序列) |
| 总音量线性 | +0/+6/+12 dB → RMS 提升 +0.00 / +5.99 / +11.20 dB |
| 软限幅 | +12 dB 输出峰值 **1.11 dBFS**, 贴顶样本 0% |
| 面板刷新 | **62 Hz**(SSE 推送, 2 秒实测 124 帧) |
| 失真 | 纯音谐波(相对基频) +0/+6 dB: **91.1 / 91.1 dB** |
| 混响回声 | 修前: 脉冲后 120 ms 有 **26 dBFS 孤立峰**(比周围高 12 dB); 修后: 包络单调衰减 `14 21 28 33 31 32 35 36`, 无孤立峰 |
| 白噪底噪 | 旁路整条链 vs 经链路: 均为 **240 dBFS**(链路不自造噪声) |
## 实测(2026-09-20) —— 低频脏 / 沙沙诊断
**结论**: 链路的**线性**部分干净(无驻波、失真 −116 dB); 脏与沙沙都来自
**电平被顶穿之后软限幅常年介入** —— 宽频内容一削, 互调产物堆在低频(= 脏), 高频碎屑(= 沙沙)。
| 条件(同一粉噪素材, 只改电平) | 峰值 | RMS | 峰值因子 | 超软限幅阈值样本 |
|---|---|---|---|---|
| 直通(不过链, 参考) | 17.3 | 31.4 | 14.1 dB | 0% |
| 绕归一化 湿 0 | 14.8 | 28.3 | 13.5 dB | 0% |
| 绕归一化 湿 0.3 | 0.1 | 9.4 | 9.3 dB | 0.05% |
| **经归一化 湿 0.3** | **0.1** | **2.1** | **2.0 dB** | **50.49%** |
| 低频量(20~200 Hz 相对 1 kHz, 线性条件) | 值 |
|---|---|
| HRTF + EQ(湿 0) | **+7.5 dB** |
| 再加混响(湿 0.3) | **+13.6 dB** |
- **驻波: 无**。对数扫频 + Farina 反卷积(扣掉播放/录音通路)实测 20~330 Hz 峰谷仅 **2.1 dB**
- 低频抬升的构成: HRTF 对侧耳 IR 低频比自身 1 kHz 高 **+7.7 / +8.3 dB**(同侧耳只有
0.5 / +1.4 dB, 即这套 HRIR 未做漫反射场均衡) + EQ 微笑曲线(20~63 Hz 各 +2.5 dB)
+ 混响湿路(其 IR 低频比中高频高 20~25 dB)。
- 元凶参数: `bin/collaplex-loudness-norm``LN_MAX_BOOST=20`(默认 12), 且归一化在
**混响之前**, 管不到后面 HRTF/混响再叠的那 ~13 dB。
-**测量方法上的两个坑**(都实际踩到并作废了对应数据):
1. **反卷积后不能加 hanning 窗** —— 它在起点为 0, 而 IR 的直达峰正好在起点, 主峰被抹掉
后算出来的是窗函数的谱(表现为一条 +130 dB 的假斜线)。用矩形窗 + 尾部线性渐出。
2. **别用粉噪做比值谱求传递函数** —— 随机信号 + 链路延迟 = 相位抵消, 会得到 −179 dB 的
假深谷、以及一堆假的"窄峰"。确定性信号(扫频)才能拿真频响。
- 取证脚本: `测试/扫频测频响.py`(扫频 + Farina 反卷积)、`测试/电平与失真诊断.py`
(只改电平的对照实验)、`测试/验证混响声道.py`(混响声道配置检查)、
`测试/诊断低频沙沙.py`(逐级旁路, 注: 其频响口径受电平影响, 已由前两个脚本取代)。
### 修复后复测(同素材同脚本)
| 指标 | 修复前 | 修复后 |
|---|---|---|
| HRTF + EQ 的低频(20~200 Hz 相对 1 kHz) | +7.5 dB | **0.1 dB** |
| 再加混响 | +13.6 dB | **+0.2 dB** |
| 整链(经归一化)低频 | +12.7 dB | **+0.1 dB** |
| 整链峰值因子 | **2.0 dB** | **12.2 dB** |
| **超软限幅阈值样本** | **50.49%** | **0.00%** |
| 整链峰值 / RMS | 0.1 / 2.1 dBFS | **6.2 / 18.4 dBFS** |
做完的四件事(全部落地): ① `LN_MAX_BOOST` 20 → 12; ② HRIR 同相合并响应均衡 7.2 dB;
③ 房间 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
pw-play --target collaplex_hrtf_in 某个.wav # 只走 HRTF 核心
pw-play --target collaplex_vsink 某个.wav # 走整条链
```
全系统: 默认输出就是 `collaplex_vsink`, Chrome / 播放器 / 游戏直接出声即走整条链。
## 开发
```sh
uvx pyright --project pyrightconfig.json # 类型检查(要求 0 errors)
bash 打包deb.sh # 出 deb 到 发布/
uv run --with websocket-client python 测试/cdp_拖动验证.py # CDP 真鼠标验证
```
`测试/` 里是取证脚本(逐级频谱、谐波、削波、湿度尾巴、预设、拖动), **都用真实数据说话**:
| 脚本 | 验什么 |
|---|---|
| `test_eq.py` / `test_e2e_eq.py` | EQ 端到端(1 kHz +12 dB、总音量 +6 dB) |
| `test_limit.py` | 软限幅(高峰值因子素材, 常规段增益应精确) |
| `test_thd.py` | 谐波失真随总音量变化 |
| `test_stage_spec.py` | 逐级旁路频谱(定位是哪一级脏) |
| `test_wet.py` | 湿度 → 混响尾巴能量 |
| `test_echo.py` | 脉冲测回声(主峰后包络: 有没有孤立峰) |
| `cdp_拖动验证.py` / `cdp_预设验证.py` | 前端真点击/真拖动(不看 DOM, 看实际写入) |
## 文件
| 路径 | 作用 |
|---|---|
| `dsp/common.py` | 前端 ↔ DSP 共享内存协议(参数区 + 版本号 + 双声道状态区) |
| `dsp/loudness_norm.py` | 响度归一化 DSP |
| `dsp/eq.py` | 32 段 EQ + 总音量 + 软限幅 DSP |
| `dsp/wet_gain.py` | 混响湿量增益级 |
| `dsp/初始化.py` | 写共享内存默认值(安装脚本调用) |
| `dsp/状态.py` | 命令行看归一化的实时推子/电平 |
| `bin/collaplex-audio` | 主命令(start / stop / web) |
| `bin/*` | pipe 插件入口模板(ASCII 路径, 安装时渲染) |
| `pipewire/10,20,30-*.conf` | 虚拟声卡+归一化 / HRTF+混响+湿量级 / EQ |
| `pipewire/91-collaplex-clock.conf` | 强制 96 kHz(不然 IR 会被按 48k 降采样) |
| `web/server.py` `web/index.html` | 控制面板(纯标准库 + 单页, SSE 推送) |
| `web/output_route.py` | 输出设备选择(落盘)+ 自动挂载守护(设备晚到时把末段挂上去) |
| `hrir/` `reverb/` | HRIR(12 条)与房间 IR |
| `脚本/安装.sh` `脚本/面板.sh` | 部署 / 起面板 |
| `打包deb.sh` | 打 deb 到 `发布/` |
| `systemd/collaplex-audio-panel.service` | 面板自启模板(`start` 时装到 `~/.config/systemd/user/`) |
| `工具/重做混响IR.py` | 剪掉房间 IR 的直达声(裁静音 + 渐入), 原文件备份 `.orig.wav` |