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)
488 lines
28 KiB
Markdown
488 lines
28 KiB
Markdown
# 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` |
|