4bc381604b
老板: "我觉的应该给缩放加点平均和变速算法, 这样就不会出现一下出现一下拉满了"
根因: 之前纯按瞬时极值(最近2秒的 max/min)定量程, 单帧瞬态就能把量程猛地张开/
收缩, 观感"一时全空一时拉满"。
两层平滑(思路同压缩器 attack/release):
① 平均: 峰值/谷值各走 EMA —— 峰值涨得快(.28)落得慢(.08), 谷值反过来
-> 单帧毛刺不再定调
② 变速: 目标量程走**非对称** EMA —— 张开快(.30, 大声音进来立刻扩不削顶),
收缩慢(.07, 安静段不急着缩), 避免波形忽大忽小
- 中心走 EMA(.12) 保证"居中"不跳
- 首帧直接就位, 不让开机时慢慢爬
- 显示仍 6dB 步进
模拟实测(安静 -> 一帧 -6dBFS 突发 -> 中等 -> 安静, 帧内跨度 30dB):
最大单帧量程跳变仅 6dB; 突发帧量程 54->60(而不是跳到 90+); 之后 60->66->60 缓慢回落。
562 lines
35 KiB
Markdown
562 lines
35 KiB
Markdown
# 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 m(V = 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 dB,0 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 York,Zenodo DOI [10.5281/zenodo.12092466](https://doi.org/10.5281/zenodo.12092466)),
|
||
使用其中真人受试者 **H4** 的 `96K / 24bit / 512tap` 版本。引用方式见数据集页面。
|
||
|
||
数据集体积较大,**不随本仓库分发**,请从 Zenodo 自行下载。
|
||
|
||
本项目以 **MIT** 许可发布。
|