Files
edgevoid 4bc381604b v1.3.6: 电平表缩放加"平均+变速" —— 消除量程一跳一跳
老板: "我觉的应该给缩放加点平均和变速算法, 这样就不会出现一下出现一下拉满了"

根因: 之前纯按瞬时极值(最近2秒的 max/min)定量程, 单帧瞬态就能把量程猛地张开/
收缩, 观感"一时全空一时拉满"。

两层平滑(思路同压缩器 attack/release):
① 平均: 峰值/谷值各走 EMA —— 峰值涨得快(.28)落得慢(.08), 谷值反过来
         -> 单帧毛刺不再定调
② 变速: 目标量程走**非对称** EMA —— 张开快(.30, 大声音进来立刻扩不削顶),
        收缩慢(.07, 安静段不急着缩), 避免波形忽大忽小
- 中心走 EMA(.12) 保证"居中"不跳
- 首帧直接就位, 不让开机时慢慢爬
- 显示仍 6dB 步进

模拟实测(安静 -> 一帧 -6dBFS 突发 -> 中等 -> 安静, 帧内跨度 30dB):
最大单帧量程跳变仅 6dB; 突发帧量程 54->60(而不是跳到 90+); 之后 60->66->60 缓慢回落。
2026-09-14 07:59:42 +08:00

562 lines
35 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 Cinema Spatial
**耳机上的电影院空间音频** —— 用真人测量的头相关传输函数(HRTF)把多声道音轨渲染成双耳信号,
在普通耳机上还原电影院 C 位附近的环绕包围感;立体声源会先经矩阵上混再渲染,因此浏览器与音乐播放器
同样具备空间感。
- 版本:**1.1.1** · 许可:MIT · 平台:Linux / PipeWire
- 全链路 **96 kHz / 24 bit**,纯脚本与数据,无编译,`Architecture: all`
- 开源地址:<http://8.136.202.225:3000/edgevoid/cinema-spatial>
---
## 1. 概述
系统在 PipeWire 中建立两个虚拟声卡:程序把音频送入虚拟声卡,虚拟声卡内部完成空间渲染,
再输出到真实回放设备。虚拟声卡是系统级默认输出,因此对任何播放器均生效。
| 虚拟声卡 | 用途 |
|---|---|
| `cinema_spatial_up_sink` | 立体声上混版(默认,推荐)—— 立体声源经矩阵上混成 5.1 再渲染 |
| `cinema_spatial_sink` | 5.1 直通版 —— 多声道片源直接渲染,不做上混 |
输出端为 `cinema_spatial_up_out` / `cinema_spatial_out`,通过 `node.target` 指向选定的物理设备。
```
多声道源 ──┐
├─> [矩阵上混 5.1] ─> 6 路 ─> [逐路 HRTF 卷积] ─> 双耳混音 ─> [房间混响] ─> 物理输出
立体声源 ──┘ (SADIE-II H4 真人 HRIR)
```
### 1.1 基本原理
- **HRTF(头相关传输函数)**:同一方向的声音到达两耳时会被人头与耳廓滤波,产生耳间时间差、
强度差与频谱染色,听觉系统据此判断方位。将这些滤波记为脉冲响应(IR),与声道信号卷积,
即可在耳机上重建方位线索。
- **虚拟声卡**:借助 PipeWire 的 `filter-chain` 模块建立 `Audio/Sink`,渲染全部在声卡内部完成,
对上层播放器透明。
---
## 2. 核心技术
### 2.1 镜像法房间声学追踪(image-source
**0–80 ms 早期反射**按镜像法精确求解:声源在房间三维空间周期展开,每条镜像源对应一条真实的
"声源 → 若干次反射 → 听者"路径。当前房间模型下共 **8473 条镜面路径**,逐条计算:
- 传播时延
- 按距离的球面扩散(1/r
- 逐频带吸声系数(碰墙次数累乘)
- 空气吸收
- 左右耳分别求距离,得到耳间时间差(ITD)
### 2.2 Sabine 统计尾音
精确求解至 1.4 s 需约 240 万个镜像源(阶数 66),计算上不可行。因此 80 ms 之后改为统计模型:
按逐频带 Sabine RT60 生成指数衰减噪声尾音,两耳部分相干系数 0.4,并在 **80 ms 交叉点做能量对齐**
### 2.3 真实房间脉冲响应卷积
早期反射与统计尾音混合后输出 **96 kHz / 32 bit 双耳房间 IR**`reverb/房间混响IR-96k.wav`
时长 2.00 s),在滤波链中以卷积级接入。
- 归一化方式:**能量归一(Σir² = 1)**。此方式下湿量语义清晰:湿量 G 时,湿路电平 = 干路 + 20log₁₀G。
- 湿量是生成期常量,修改后需重建链路。
> **与算法混响的区别**:本项目不使用 Freeverb / Schroeder 反馈网络一类算法混响,
> 而是"先求解声传播、再做卷积"的房间物理模型。
### 2.4 HRTF 双耳化
采用 **SADIE II** 数据集真人受试者 **H4**`96K / 24bit / 512tap` 版本:
2818 个测量方向,仰角覆盖 ±90°,测距 1.2 m。每条 HRIR 截取 **4800 tap @ 96 kHz**(约 50 ms)。
### 2.5 房间与听音位模型
| 参数 | 取值 |
|---|---|
| 房间尺寸 | 20 × 12 × 7 mV = 1680 m³,S = 928 m²) |
| 银幕 | 10.5 × 5.2 m,底高 1.0 m,穿孔幕吸声系数 0.45 |
| 吸声系数 | 墙板 0.80 / 吊顶 0.85 / 地毯 0.45 |
| 听音位 | 中轴距银幕 8 m,耳高 1.2 m,双耳间距 0.175 m |
| 声速 | 343 m/s |
全部参数存于 `声学追踪引擎/config/房间.json`,可由配置改写。
---
## 3. 安装
### 3.1 deb 包
```sh
sudo dpkg -i collaplex-cinema-spatial_1.1.1_all.deb
# 依赖: pipewire libmysofa1 mpv
```
安装内容:可执行命令写入 `/usr/bin/`,数据与 Web 控制台写入 `/usr/share/cinema-spatial/`
配置写入 `/usr/share/pipewire/pipewire.conf.d/`
> **★★ 同名配置只留一份 —— 两份并存 = 没声音**(2026-09-13 实测)
> PipeWire 对 `conf.d/*.conf` **不做"用户级覆盖系统级",两份都加载**。装完 deb 之后,
> 若 `~/.config/pipewire/pipewire.conf.d/` 里还有一份开发时生成的同名配置,
> 就会建出**两套同名节点**(实测 6 个节点各 2 份)—— 音频进哪一份由调度决定,
> 常常进了没有下游消费者的那一份 → 整条链静默。
> 同一时刻还有 4 个归一化 DSP 进程抢同一对槽位锁、两条链都往同一个物理设备推流。
> 自检:`python3 音频状态.py dup`(空 = 正常)/`空间音频 诊断`。
> 修法:把另一处同名文件改名 `.disabled-dup` 后重启 pipewire。本机定**用户级为唯一真源**,
> 系统级那两份已改名 `/usr/share/pipewire/pipewire.conf.d/{90-cinema-spatial,91-clock}.conf.disabled-dup`。
**生效**`systemctl --user restart pipewire pipewire-pulse`(或注销重新登录)。
### 3.2 从源码
```sh
# 1. 获取 HRTF 数据集(不随仓库分发)
mkdir -p sofa && cd sofa
curl -L -O https://zenodo.org/records/12092466/files/H4_HRIR_SOFA.zip
unzip H4_HRIR_SOFA.zip && cd ..
# 2. 从 SOFA 提取脉冲响应(12 个 = 6 方向 × 左右耳)
bash 换HRTF.sh "$PWD/sofa/H4_HRIR_SOFA/H4_HRIR_SOFA/H4_96K_24bit_512tap_FIR_SOFA.sofa" H4-96k -16
ln -sfn H4-96k hrir/current
# 3. 生成 PipeWire 配置并生效
python3 生成配置.py
systemctl --user restart pipewire pipewire-pulse
```
---
## 4. 使用
### 4.1 命令行
| 命令 | 作用 |
|---|---|
| `空间音频 开` | 全局切换到立体声上混版(推荐) |
| `空间音频 开5.1` | 全局切换到 5.1 直通版 |
| `空间音频 关` | 切回物理设备,链路停用 |
| `空间音频 状态` | 查看两个虚拟声卡与当前默认输出 |
| `空间音频 诊断` | 输出目标 / profile / route 硬件增益基数 / 音量账 / 连线状态 |
| `空间音频 数字` \| `模拟` | 切换输出的数字(S/PDIF)或模拟通道 |
| `空间音频 设备 [关键词]` | 列出或切换输出设备 |
| `空间音频 混响 [开\|关\|湿量]` | 房间混响开关与湿量(不带参数表示翻转) |
| `空间音频 参数 [hrtf\|taps\|rate]` | 查看/修改采样参数(采样率、HRTF 模型、HRIR 长度) |
| `空间音频 单声道 [自动\|开\|关]` | 单声道输出合并(见 §6.4) |
| `空间音频 重建` | 按当前参数与配置重新生成链路 |
| `mpv-影院 影片` | 仅对 mpv 生效的 HRTF 播放包装,不改变全局设置(适合 A/B 对比) |
也可以在桌面环境的"声音设置"中直接选择 **电影院空间音频**
> **输出通道的选择**:实测耳机的**数字输出(S/PDIF)音质明显优于模拟输出**(该通道以 96 kHz / 24 bit 运行),
> 推荐使用。`空间音频 数字` 会自动完成"设置重建目标 + 保留 profile + 校验连线"
> 比在 GNOME 面板上点击更可靠 —— 后者会改变 profile,使配置中的目标节点名失效。
### 4.2 Web 控制台
见 §7。菜单中也有「Collaplex 音效」快捷方式。
---
## 5. 参数与配置
### 5.1 状态文件
| 文件 | 内容 |
|---|---|
| `~/.local/state/cinema-spatial/volume` | 音量持久化值 |
| `~/.local/state/cinema-spatial/params.json` | 采样率 / HRTF 模型 / HRIR 长度 |
| `~/.local/state/cinema-spatial/reverb.json` | 混响开关与湿量 |
| `~/.local/state/cinema-spatial/mono.json` | 单声道模式(`{}` 表示自动) |
| `~/.local/state/cinema-spatial/loudness.json` | 响度统一开关与目标值 |
### 5.2 采样参数
- 面板「采样,各参数调整选项」中的三个下拉可直接修改:**采样率 / HRTF 模型 / HRIR 长度**
改完自动重建(约 10 s)。
- 命令:`空间音频 参数`(查看现值与可选项)、`空间音频 参数 hrtf H4-48k``参数 taps 1024``参数 rate 48000`
- IR 目录选择优先级:`hrir/taps-<N>` > 模型名 > `hrir/current` 软链;模型与长度截断二者互斥。
- 长度截断:`work/裁HRIR.py <N>` 生成 `hrir/taps-<N>/`(末尾 3 ms 淡出,可选 512 / 1024 / 2048)。
- 采样率写入**用户级** `~/.config/pipewire/pipewire.conf.d/91-clock.conf`**无需 sudo**
★ 它**不会覆盖**系统级那份,两份会被同时加载 → 生成器写完后会自动把别处的同名 conf
改名 `.disabled-dup`(见 `生成配置.py``prune_duplicate_confs()`)。
- 三者均为生成期常量,**修改后必须重建链路**。
- 实测结论:开启混响时 **HRIR 长度不是主要 CPU 成本** —— 2 秒房间 IR 的等效长度是 HRIR 的 40 倍,
截断 HRIR 既省不下多少 CPU,又会损失 HRTF 尾部使声音变干。因此**默认保持 H4-96k / 4800 tap**。
### 5.3 混响
- 命令:`空间音频 混响 [开|关|湿量]`;面板提供开关与湿量滑块。
- 关闭时整个混响级不生成,不占用 CPU。
- 湿量为生成期常量,修改后需重建。
### 5.4 单声道输出
单声道蓝牙音响 / 耳机仅提供 1 个 `MONO` 端口,而播放节点默认声明 2 声道 `[ FL FR ]`
声道数不匹配会导致链接建立失败、设备掉线。系统提供三态开关(默认**自动**):
| 取值 | 行为 |
|---|---|
| 自动 | 读取目标设备的 `audio.channels`,为 1 时自动合并 |
| 开 | 强制合并 |
| 关 | 强制不合并 |
合并实现:链路中增加一级 `monoOut` 混音器,左右声道各乘 −6 dB 后相加(避免相关信号叠加溢出 6 dB),
图输出改为单端口,播放节点改为 `audio.channels = 1 audio.position = [ MONO ]`
### 5.5 输出设备
- `音频状态.py sinks` 列出全部物理输出(USB → PCI → 显卡 Pro/HDMI),并标记当前目标。
- `空间音频 设备 [关键词]``node.name` 或描述匹配,重写配置中的 `node.target`
- **设备切换优先采用运行期重连**:播放节点本质是 PipeWire stream,其输出端口可在运行期改连,
因此切换设备无需重建链路、**无需重启 PipeWire**,蓝牙设备不会被中断。
仅当新设备声道数与链路当前输出不一致时(如单声道设备)才回落为"重建 + 等待目标就绪"。
- 自动探测会跳过显卡 `pro-output` / HDMI 一类在默认状态下不发声的输出。
### 5.6 音量与响度
- **扬声器电平**:面板竖滑块 → 物理输出音量(即实际听到的音量)。写入为**闭环自校正**
(按实测比例计算后写入、回读、残差修正,最多 3 轮),因为硬件增益基数因设备而异,
不可写死(本机实测数字通道 1.003;部分设备会返回 0.001 的无效基数)。
- **输入增益**:链的输入电平,同时是**削波余量旋钮**,修改立即生效、无需重建。
注意:**键盘媒体键调节的正是这一级**(虚拟声卡是应用默认输出)。
★ 它与「响度归一化」是**同一段链上的两个旋钮**:你压小输入,DSP 就把增益补回来,
净效果趋近不变;补到 `MAX_BOOST = +20 dB` 上限(面板推子"顶格")之后再压小,才会真的变小声。
**要更大声请调「扬声器电平」,不要去压输入增益。**
- **统一响度音量**(「响度统一」那一行的横滑块):与「扬声器电平」是**同一个量**,
走**同一个端点** `/api/speaker`
★ 2026-09-13 修:它原本走 `/api/loudness{value}` 直写内部 `target`,而竖滑块走
`/api/speaker` 按硬件基数换算后也写同一个 `target`**两个滑块互相覆盖**
(现场复现:拖扬声器到 0.60,target 存成 0.5354,横滑块立刻显示 0.5354 看着像"跳")。
现在两根滑块同源、同量纲(0~1.5),显示的数字永远一致。
- **响度统一**:按硬件增益基数补偿,使两条输出通道响度一致。
**物理音量只有 `/api/speaker` 一个写入入口**`/api/loudness` 已不再接受 `value`)。
### 5.7 电平表的口径与"顶满"
- 「原始电平」= 本块**输入峰值**;「处理后电平」= DSP 上报的**输出峰值**(共享区 `+28` 字段,真实值,
`PEAK_CEIL = 0.985` 限制)。数字上的"峰值"取**最近 1.5 秒**,峰值**线**另按 12 dB/s 回落。
- ★ 2026-09-13 修:旧版"处理后" = 块能量开方(RMS) + 增益(快慢时标混用)→ 瞬态时会算出 >0 dBFS 的
不可能值,看着"顶满";旧版数字取整个 12 秒窗口最大值 → 一次冲顶会"顶"很久。
- ★ 上混图 **6 路相加做了能量归一(每路 1/√6 = 0.4082,净增益 1.8 dB**。改动在
`生成配置.py`(改 conf 无效,重建会覆盖)。改前每路 =1 → 净增益 **+6 dB**,正好把
DSP 的峰值保护吃掉 → 起播瞬间顶穿满刻度(实测 +7.83 dBFS)。
- ★ 起播淡入:长静音(>24 块≈1s)后重新起播的前 4 块(≈170ms)线性爬升,压掉卷积器冷启动过冲。
参数 `LN_FADE_BLOCKS` / `LN_FADE_GAP_BLOCKS`
### 5.8 重建之后静音?先数 DSP 进程
**正常情况 DSP 进程必须正好 2 个**(左右声道各一个 `响度归一化/loudness_norm.py`):
```bash
ps -eo pid,etimes,cmd | grep -E "[l]oudness_norm"
```
多于 2 个 = 有孤儿在抢 `/dev/shm/collaplex-loudness` 的两个槽位 → pipe 插件的
同步管道卡死 → **整链静音**(链路看着全对:节点在、连线在、音量正常)。
★ 2026-09-13 修(老板报"没声音了"):
- `claim_slot()` 原来在两个槽都被占时 `return 0`(假装抢到)→ 第 3、4 个实例
一起写槽0,数据被劈开。现改为**打印一行并自我退出**。
- CLI 新增 `kill_old_dsp()`:所有重启 PipeWire 的地方统一走 `restart_pipewire()`
**先收掉旧 DSP 再重启**。因为 DSP 的父进程是 pipewire 主进程,重建时
`pdeathsig` 不触发,不主动收就会每次攒一对。
验证:连续重建 3 次,每次都打印"清掉 2 个旧 DSP 实例",进程数恒为 2 ✓
### 5.9 默认设备为什么不会被 HDMI 抢走
虚拟声卡(`cinema_spatial_up_sink` / `cinema_spatial_sink`)带 `priority.session = 2000`
**高于所有物理输出**(本机 HDMI 1196 / USB 1108 / 板载 1009)—— 见 `SINK_PRIO`
★ 2026-09-13 修:原来虚拟声卡**没有这个属性**(`None`),所以每次 WirePlumber
"重新选择默认节点"(显示器唤醒、分辨率变化、设备插拔、服务重启)都被 HDMI 抢走;
从 GNOME 手动选回来,下一个事件又抢走("点都点不回来")。
验证:`wpctl set-default <HDMI>``systemctl --user restart wireplumber`
默认应自动回到 `cinema_spatial_up_sink`
想改用 HDMI 输出时手动 `wpctl set-default <HDMI>` 即可(但下一次重选会回到虚拟声卡)。
### 5.10 菜单音量桥(系统音量键 → 扬声器电平)
系统音量键 / GNOME 顶栏滑块 / 系统设置里的"输出音量"改的都是 `@DEFAULT_AUDIO_SINK@`
也就是**虚拟声卡音量** = 链的**输入增益** —— 那一级会被响度归一化自动补回来,**按键几乎没效果**,
压太低还会把 DSP 顶到 +20 dB 上限。所以面板内置一个**音量桥**(随面板启动,200ms 轮询):
1. 把虚拟声卡的改动读走,按**比例**折算到**物理输出**(即「扬声器电平」,你听到的音量)
2. 再把虚拟声卡**复位到参考值**(= 「输入增益」滑块的值,存 `~/.local/state/cinema-spatial/volume`
3. 静音键 → 物理输出归 0 并记住原值;解除 → 恢复
实测:按音量+ 物理 0.24→0.26→0.27,按− 回落,静音→0.000、解除恢复,
**虚拟声卡始终稳在参考值**(削波余量不被吃)。
> ⚠ 桥**随面板运行**(面板关掉,音量键就恢复成"改输入增益"的老行为)。
> 面板从菜单「Collaplex 音效」打开:<http://127.0.0.1:8788/>
> ⚠ 桥**只能有一份**(就是面板里这个)—— 不要另外再跑一个 CLI 版,两份会互相抢物理音量。
---
## 6. 信号链与架构
### 6.1 时钟
主时钟统一为 **96 kHz**`default.clock.rate = 96000`),以保证 96 kHz HRIR 不被降采样。
### 6.2 增益记账
矩阵上混产生的 6 路信号在混音器中相加,**整链净增益约 +10.5 dB**。因此:
- 增益必须按整链校准,只按单路 IR 峰值校准会在真实内容上削波。
- 链路上每一级音量(虚拟声卡、滤波链输出、物理设备、耳机自身)串联共享动态余量,
**修改任意一级都必须重新记账**
- 校准素材必须接近满刻度,且**峰值因子需与真实内容相当**(影视内容的瞬态峰值可比稳态测试信号高 10 dB 以上)。
### 6.3 约束
- `node.passive = true` 必须与 `node.target` 同时设置,否则输出端不连接任何设备。
- 混响路径:`mixL/R → 房间 IR 卷积(revL/revR) → 湿混音(wetL/wetR) → 输出`;关闭时不生成该级。
- 卷积使用 `type=time`(时域,保真);默认的 `type=freq` 会损失高频。
- `convolver` 按当前时钟率重采样脉冲响应,时钟未提升到 96 kHz 时换用高采样率 IR 无意义。
---
## 7. Web 控制台(Collaplex 音效 · :8788
页面布局对应设计稿 `~/桌面/collaplex web设计稿.drawio`:顶栏 / 原始电平与处理后电平 /
三个信号开关 / 音量滑块 / 数字·模拟输出选择 / 采样参数 / 响度统一 / 5.1 直通·混响·单声道·设备选择 /
状态信息 / 核心技术说明。
```sh
bash web/启动.sh # 启动(默认端口 8788,重复执行不会起第二个)
python3 web/webui.py --port 8788 --bind 127.0.0.1 # 或直接运行后端
```
访问 <http://127.0.0.1:8788/>。后端零依赖(标准库),接口列表:
| 接口 | 作用 |
|---|---|
| `/api/state` | 全量状态(设备、链路、参数、音量、混响、单声道) |
| `/api/meters` | 两路电平(原始 / 处理后) |
| `/api/mode` | 开 / 开 5.1 / 关 |
| `/api/volume` | 输入增益 |
| `/api/speaker` | 扬声器电平(闭环自校正) |
| `/api/route` | 数字 / 模拟 |
| `/api/switch` | 三个信号开关 |
| `/api/loudness` | 响度统一 / 归一化目标(`target_db`)★ 不再接受 `value`:物理音量统一走 `/api/speaker` |
| `/api/device` | 输出设备 |
| `/api/reverb` | 混响开关与湿量 |
| `/api/mono` | 单声道模式 |
| `/api/fix` | 检测并修复(设备名失效时重建) |
### 7.1 电平表的采集口径
| 表 | 采集源 |
|---|---|
| 原始电平 | `cinema_spatial_up_sink:monitor_FL/FR` —— 进入链路的音频 |
| 处理后电平 | `cinema_spatial_up_out:output_FL/FR` —— 链路输出的处理后信号 |
采集进程以 `--target 0` 启动(**禁止自动连线**)并使用唯一节点名,
再由程序按**端口 id** 显式连线。如此可确保:
- 采集目标始终是上述信号本身,**绝不回退到麦克风**;
- 两个采集进程同名导致的端口寻址混淆不会发生;
- 目标信号不在图中时**保持空表**,不进行任何降级采集。
电平表采用**每表自动量程**:按最近 2 秒的最大峰值向上取整到 6 dB 作为顶线,轴标签标注真实 dBFS 值。
---
## 8. 已知问题与工程结论
以下均为实测结论,供后续维护参考。
### 8.1 PipeWire 模块与配置
1. **滤波链没有运行时热加载**`pw-cli load-module` 在本机对任意模块(含 `libpipewire-module-null-sink`
均返回 `Error: "Could not load module"`,多种写法与全路径均已尝试。
→ 修改滤波链配置**必须重启 PipeWire**。
2. **重启 PipeWire 会连带重启 WirePlumber**(后者是其客户端,退出后由 systemd 拉起),
因此蓝牙 / USB 设备需要数秒重新出现。**判目标失效前必须轮询等待**(当前实现最多等待 20 秒),
不能在固定延时后直接判定,否则会把输出目标错误地切换到其它设备。
3. **`pw-link -l` 的输出格式**为:端口名独占一行,其后以 `|-> <对端>` 表示去向,`|<- <对端>` 表示来源。
解析时必须按行配对。
4. **同名节点不可用名字寻址**。多个 `pw-record` 进程的 `node.name` 相同、`application.process.id`
`None`,用 `pw-record:input_FL` 之类的名字会连到其它进程的端口上。涉及多实例时必须按**端口 id** 寻址。
5. **判定节点是否存在不能用 `pw-cli info`**(对不存在的名字同样返回成功),应使用 `pw-dump`
`node.name` 精确比对。
6. **重建不要连续触发**:短时间内反复重启 PipeWire 会使 WirePlumber 崩溃(GLib 断言 → core-dump),
表现为监听端口无法建立新连接。当前 CLI 在重建后会检查 `wireplumber` 是否存活并自动拉起。
### 8.2 卷积素材与归一化
1. **任何卷积素材(房间 IR / HRIR)都必须按能量归一,不能按峰值归一**
以房间混响为例:2 秒密集尾音按峰值归一时,卷积的能量增益可达 **+11 ~ +13 dB**
即使湿量仅 0.05(−26 dB)仍会把整链推高约 11 dB,导致削波。改为能量归一后,
实测整链净增益由 +11 dB 降至 +0.4 dB0 dBFS 输入时输出为 1.17 dBFS(余量 1.17 dB)。
2. **不同 SOFA 数据集的电平差异极大**(同一数据集内,假头 D1 峰值 −11.6 dB、真人 H4 3.1 dB),
更换模型必须重新量测峰值,目标压至约 −12 dB。
3. **`sofalizer` 按 SOFA 自身采样率输出**SOFA 为 44.1 kHz 时输出会降到 44.1 kHz。
需在链尾 `aresample` 拉回,或使用原生高采样率数据集。
### 8.3 设备与路由
1. **USB 音频设备重启后 profile 会变化**`iec958-stereo``analog-stereo`),
设备 `node.name` 的后缀随之改变,配置中的 `node.target` 失效,WirePlumber 会回落到其它输出
(实测曾落到 HDMI,表现为无声或声音从显示器输出)。`空间音频 开` 自带自检与重建。
2. **虚拟声卡音量不会自动保留**:滤波链每次重建都是全新节点,音量回到默认值,会导致整链削波。
当前实现于 `capture.props` 写入默认音量,并在每次切换命令中补写。
3. **链路输出是 stream**,当目标设备消失时 WirePlumber 会按系统默认将其改连到其它设备
(实测曾被改连到显卡 `pro-output-3`)。重建会按配置中的目标拉回。
如需彻底固定,可对播放节点设置 `node.autoconnect = false`,仅由本工具显式连线。
4. **运行时构造的节点存活期有限**(例如通过 `pw-cli create-node` 建立的 null sink 无法在重启后存活),
因此不可作为长期目标设备。设备失效时应回退到自动探测。
### 8.4 其它
1. **ffmpeg 滤镜表达式中的 `,` 与 `|`** 必须用单引号包裹,否则会被当作分隔符解析。
2. **`adelay` + `join` 合成多声道素材不可靠**(曾静默产出 0.25 秒单声道文件),建议改用 `aevalsrc`
3. **测试素材必须匹配真实内容的峰值因子**:稳态粉噪无法暴露影视瞬态的削波风险,校准应使用真实片源
或至少混入冲击型素材。
---
## 9. 实测数据
### 9.1 房间混响逐频带 RT60
| 频段 | Sabine 理论 | 早期反射实测 | 最终 IR 实测 |
|---|---|---|---|
| 125 Hz | 1.363 s | 1.542 s | 1.338 s |
| 250 Hz | 0.722 s | 0.779 s | 0.749 s |
| 500 Hz | 0.451 s | 0.639 s | 0.524 s |
| 1 kHz | 0.364 s | 0.871 s | 0.460 s |
| 2 kHz | 0.331 s | 1.153 s | 0.427 s |
| 4 kHz | 0.332 s | 0.809 s | 0.362 s |
测量工具:`work/测链路冲激响应.py`(馈入脉冲 → 录制 monitor → 计算包络与 T60)。
运行中的两个 filter-chain 模块内 `revL / revR / wetL / wetR` 均已加载(`pw-cli info` 实测),
处理后峰值 −22.2 dBFS(未削波),设备噪声地板 −56 dBFS。
### 9.2 削波问题定位(能量归一前后)
| 状态 | 输入 | 输出 | 链净增益 |
|---|---|---|---|
| 关闭混响 | 6.64 dBFS | 6.4 dBFS | +0.3 dB |
| 混响 0.05(峰值归一,修复前) | 6.64 dBFS | — | **约 +11 dB** |
| 混响 0.05(能量归一,修复后) | 6.64 dBFS | 6.2 dBFS | +0.4 dB |
| 0 dBFS 输入(修复后) | 1.04 dBFS | 1.17 dBFS | 余量 1.17 dB |
### 9.3 电平表显示验证
| 表 | 墨点数 | 墨点重心 y | 结论 |
|---|---|---|---|
| 原始 | 45353 → 44985 | 41.4 → 41.4 | 变化正常 |
| 处理后 | 34950 → 36016 | 47.4 → 46.0 | 变化正常 |
---
## 10. 文件说明
| 文件 | 作用 |
|---|---|
| `空间音频` | 主命令(开关、诊断、参数、设备、混响、单声道) |
| `音频状态.py` | 状态后端:设备枚举、profile、route、硬件增益基数、连线校验 |
| `生成配置.py` | 生成 PipeWire filter-chain 配置 |
| `mpv-影院` | mpv 专用的 HRTF 播放包装 |
| `换HRTF.sh` | 从 SOFA 数据集提取脉冲响应 |
| `打包deb.sh` | 打包 `collaplex-cinema-spatial` |
| `发布release.sh` | 构建并发布到 Gitea Release |
| `离线渲染.py` | 离线批量渲染:音频/视频 → 空间音频 FLAC(含渲染锁 + 文件就绪检查) |
| `离线渲染-打开.sh` | 文件管理器右键「打开方式」入口(包内装为 `/usr/bin/collaplex-render` |
| `音频守护.py` | 组件看门狗:30 秒一轮核对期望状态并自愈 |
| `组件配置单.md` / `config/组件配置单.json` | 链路组件的期望状态(人读版 / 机器可读版) |
| `桌面项/` | 包内版桌面项模板(打包时原样装进 `/usr/share/applications/` |
| `web/` | Collaplex 音效 Web 控制台(`webui.py` + `index.html` + `启动.sh`,零依赖;含离线渲染区) |
| `reverb/` | 房间脉冲响应(由 `声学追踪引擎/` 生成) |
| `hrir/` | HRIR 数据与 `current` 软链 |
| `声学追踪引擎/` | 房间声学追踪与 IR 生成器(镜像法 + Sabine 尾音) |
| `work/` | 开发期验证工具(含 `裁HRIR.py``测链路冲激响应.py` |
| `验收测试.sh` | 安装后自动验证 |
| `demo.sh` / `响度校准.sh` / `方向测试.sh` / `对比分析.py` / `排查.sh` | 开发期工具 |
---
## 11. 版本记录
### 1.3.6
- **电平表缩放加「平均 + 变速」**(老板:"给缩放加点平均和变速算法,这样就不会出现一下出现一下拉满了")—— 纯按瞬时极值定量程,遇到单帧瞬态就猛地张开/收缩,观感一跳一跳(一时全空一时拉满)。现在两层平滑,思路同压缩器的 attack/release
- **① 平均**:峰值/谷值先各走 EMA(峰值涨得快 0.28 / 落得慢 0.08,谷值反过来),不让单帧毛刺定调
- **② 变速**:目标量程再走**非对称** EMA —— **张开快(0.30,大声音进来立刻扩,不削顶)**、**收缩慢(0.07,安静段不急着缩,免得波形忽大忽小)**
- 中心同样走 EMA(0.12),保证"波形居中"不跳
- 首帧直接就位(不让开机时量程慢慢爬)
- 模拟测试(安静 → 一帧 −6dBFS 突发 → 中等 → 安静,帧内跨度 30dB):**最大单帧量程跳变仅 6 dB**,突发帧量程 54→60 而非跳到 90+,之后 60→66→60 缓慢回落
### 1.3.5
- **两个电平表波形"放大"** —— 1.3.4 只把波峰压到中线(确实不再被压住),但波形只占上半区、下半区全空,显得又小又浪费(老板:"你只改了一半,现在确实压低了,波形再放大一点就好看了")。现在**量程跟着信号的实际幅度走**:`量程 = 信号跨度 × 1.45`(上下各留一半余量 → 波形垂直居中且占约 2/3),跨度下限 18 dB(信号平直时不放大噪声)、上限 72 dB,6 dB 步进不逐帧抖。CDP 实测四档电平(跨度 10~24 dB)波形占画布 **42%~60%**,上下留白对称
- 附带修一个自己引入的 bug`mn` 初值曾取 `fallback(-60)`,把底线拖到 −66 导致波形反而更小(21%);谷值现在只认真实数据
### 1.3.4
- **两个电平表(原始 / 处理后)波形改为垂直居中** —— 原来顶线 = 「最近 2 秒峰值 + 3 dB」,于是**波峰永远贴在画布最上沿**,看着像被上面那行标题压住(老板原话"太靠上波形被盖住了")。现改为 **顶线 = 峰值 + SPAN/2**,波峰正好落在画布垂直中线,上下各留半屏;顶线**允许超过 0 dBFS**(那只是绘图范围,多出来的是留白),0 dBFS 单独画一条亮基准线保住刻度参照。CDP 注入三档电平(−6 / −12 / −30 dBFS)实测**波峰偏离中线均为 0 px**
- 上一版(1.3.3)误把动态量程加在了「响度归一化」的增益曲线上 —— 老板要的是这两个电平表。归一化那边的动态量程保留(同样是"波峰居中"的需求),两处现在都居中
### 1.3.3
- 响度归一化曲线改为**动态量程**:原来量程固定 ±20 dB,而增益常年偏一边(实测 +4.6 dB 整条压在上半区)。现在按最近 120 个样本的 min/max 定中心与半幅,**让曲线的波峰自动落在图表垂直正中**;两处细节:① 半幅下限 2 dB(曲线接近平直时别把噪声放大成锯齿),② 中心/半幅都用一阶低通(每帧挪 12%)平滑,避免曲线"呼吸"抖动。左侧量程标签同步改成**跟随数据的真实 dB 值**(不再固定 ±20/0),0 dB 基准线只在可见范围内才画
- CDP 实测三个场景(波峰+12/波谷+2、波峰−4/波谷−16、几乎平直+4.6)**中值偏离画布正中线均为 0 px**
### 1.3.2
- 面板补 **favicon**(内联 SVG:深底 + 五根声波柱)—— 之前没有图标,浏览器标签页显示默认破图标
- 修复:**响度归一化卡片里的曲线被压** —— 卡片高 184px 装不下(标题 22 + 推子 22 + 文字行 20 + 间距 ≈ 70px),而 `#ln` 只扣了 46px → canvas 溢出、上沿被「目标/判据」那行文字盖住。改:卡片 210px、`#ln` 扣 74px、网格 430→456(把这 26px 补给上面两个电平表,免得它们被压矮)。CDP 量尺实测**无重叠**、`#ln` 126px
-**纠正一个错误假设**:**物理输出音量是一个「档位」,不是"越大越响"** —— 响度由 DSP 归一化保证(实测 0.35 就很好)。1.3.1 曾错误地把它"对齐"到虚拟声卡(1.0),结果进削波保护、动态被压扁:**面板波形顶满而听感反而更小**(老 bug 复现)。已撤掉三处相关逻辑,看门狗现在只兜「静音 / 近乎归零(<0.02)」,**绝不改正常档位**
### 1.3.1
- 修复:**断电隔夜重启后"又没声音"** —— 物理输出的音量会从 WirePlumber 存档恢复成旧值(实测 0.35,而虚拟声卡是 1.0),而菜单音量桥只"监视虚拟声卡变化"、虚拟没变就永远不动物理 → 声音凭空小 9 dB。**两处兜底**:① 桥启动时把物理输出对齐一次;② 看门狗新增第 9 项检查「物理输出音量 ≠ 虚拟声卡」并自动纠回(实测 22 秒纠回)
- 修复:**隔夜无人看守** —— 看门狗不做开机自启(既有约定),重启后链路就没有守护。现在 `web/启动.sh` 起来时**顺带把看门狗拉起**(已在跑则不重复起);看门狗用 `setsid` 脱离会话,挂在 `systemd --user` 下,Hermes 关了它照样跑
### 1.3.0
- **离线渲染**`离线渲染.py`):音频 / 视频 → 空间音频 FLAC(96 kHz 双耳)。链路与实时虚拟声卡同参数:`pan` 上混 5.1 → `sofalizer`(同一个 H4 SOFA)→ 房间 IR 卷积(`afir`)→ `loudnorm`。视频自动取音轨;已是 5.1 的片源不重上混
- **两道锁**:渲染锁(一轮任务期间**不接受新任务**,直接拒绝而非排队堆积);文件就绪检查(相隔 1.2 秒两次 stat 的大小/时间不变 + ffprobe 能读出音轨,防止把还在下载 / 写入的文件扔进渲染)
- 三个入口:命令行 / 面板「离线渲染」区(拖拽或贴路径,实时进度条 + 日志 + 取消)/ 文件管理器右键「打开方式」
- 实测:8 秒立体声 1.8 s 完成(≈4.4 倍速)
- **组件看门狗**`音频守护.py` + `config/组件配置单.json`):30 秒一轮核对整条链的期望状态(DSP 进程数与槽位新鲜度 / 连线 / 默认设备 / 虚拟声卡优先级 / 中间级音量 / 节点份数 / 面板 / 物理设备静音),扫到问题就修 —— 能温和就温和(改音量、切默认、重连),只有链路级问题才重建;同类问题 90 秒冷却,连续 3 次修不好转只报警
- 面板:控制滑块从图表卡片里独立成「控制」卡片(图表归图表、控件归控件);数字 / 模拟输出音量条拉长到各占一半,合计等于虚拟声卡开关卡片的总高
- 修复:DSP 孤儿抢共享槽位导致重建后无声(`claim_slot()` 抢不到槽位即自我了断 + 重建前收掉旧 DSP)
- 修复:**打包动作会把正在用的 PipeWire 配置移开**`prune_duplicate_confs()` 在 OUT 落到"非生效目录"时不再清理,另加 `CINEMA_SPATIAL_KEEP_CONFS=1` 双保险)
### 1.2.0
- 全系统响度归一化(filter-chain `pipe` 插件挂自研 DSP):各软件推进来的响度拉平到同一目标,目标可调(−40 ~ −6 dBFS),6 dB/s 限速 + 峰值保护,左右声道共享增益不漂声像
- 控制台拆 4 路电平(原始 L/R + 处理后 L/R),新增增益历史曲线
- 架构:归一化做成**独立 filter-chain 实例**串在上混图之前(塞进 40+ 节点大图会收到恒定 −43 dBFS 空数据、音频绕过)
### 1.1.1
- README 重写为正式文档,并同步至当前功能状态
### 1.1.0
- 新增单声道输出支持(三态:自动 / 开 / 关),解决单声道设备因声道数不匹配导致的掉线
- 新增运行期重连:切换输出设备不再重启 PipeWire,蓝牙设备不再被中断
- 修复"重启后目标失效":改为轮询等待目标设备就绪(最多 20 秒),并在自动探测中跳过显卡 / HDMI 输出
- 修复削波:房间 IR 改为能量归一(此前按峰值归一致使整链 +11 dB)
- 电平表改为显式连线采集链路输出,杜绝回退到麦克风
- 面板新增"核心技术"说明区
- 打包修复:`control` 版本号跟随构建版本(此前恒为 1.0.0,导致 dpkg 无法升级);
补入 `音频状态.py``web/` 控制台
### 1.0.x
- 首个公开发布:双虚拟声卡、SADIE-II HRTF 渲染、deb 打包
- 修复"响度怪":详见 [`响度怪-根因与修复.md`](响度怪-根因与修复.md);新增 `空间音频 诊断`
音量改为持久化,重建时保留 profile 并校验连线
---
## 12. 数据来源与许可
HRTF 数据采用 **[SADIE II](https://www.york.ac.uk/sadie-project/database.html)**
University of YorkZenodo DOI [10.5281/zenodo.12092466](https://doi.org/10.5281/zenodo.12092466)),
使用其中真人受试者 **H4**`96K / 24bit / 512tap` 版本。引用方式见数据集页面。
数据集体积较大,**不随本仓库分发**,请从 Zenodo 自行下载。
本项目以 **MIT** 许可发布。