- 离线渲染.py: pan上混5.1 → sofalizer(H4 SOFA) → 房间IR(afir) → loudnorm → 96k FLAC
视频自动取音轨; 已是5.1的片源不重上混; 实测约 4.4 倍速
两道锁: (1)渲染锁 —— 一轮任务期间拒绝新任务, 不排队堆积
(2)文件就绪检查 —— 相隔1.2s两次stat大小/时间不变且ffprobe能读, 未落盘的跳过
三个入口: 命令行 / 面板「离线渲染」区(拖拽或贴路径, 实时进度+日志+取消) / 右键「打开方式」
- 音频守护.py + config/组件配置单.json: 30s 核对 DSP进程数与槽位新鲜度/链输出连线/
默认设备/虚拟声卡优先级/中间级音量/节点份数/面板/物理设备静音, 扫到就修
(能温和就温和, 链路级才重建), 同类问题 90s 冷却, 连续 3 次修不好转只报警
- 组件配置单.md: 链路拓扑 + 组件表 + 六个必须 + 自愈表
- 面板: 控制滑块从图表卡片独立成「控制」卡片; 数字/模拟输出音量条拉长到各占一半
(合计 = 虚拟声卡开关卡片总高); 新增离线渲染区
- 修: DSP 孤儿抢共享槽位导致重建后无声(claim_slot 抢不到槽位即退出 + 重建前收旧 DSP)
- 修: 打包会把正在用的 PipeWire 配置移开(prune_duplicate_confs 只在自己写到生效目录时清理)
- 修: 离线渲染的 SOFA/混响IR 改为多路径探测(开发目录与 deb 包内布局不同)
- .gitignore: 保留 reverb/ 房间IR(离线渲染要卷积的素材)
12 KiB
电影院空间音频 —— 组件配置单
2026-09-13 全链路修复后实测的稳定状态。机器可读版本在
config/组件配置单.json, 由音频守护.py(30 秒一扫,扫到就修)逐项核对。本文档的目的:说清"什么必须是什么" —— 当晚 6 个坑每一个都是"某处偏离了这里的期望值"。
1. 链路拓扑
应用 (Google Chrome / Chromium / mpv-影院 / 任何软件)
│ ↑ 默认输出 = 虚拟声卡, 应用不用配
▼
cinema_spatial_up_sink Audio/Sink priority.session=2000
│ ← 媒体键/GNOME 顶栏改的就是它的音量(= 链的输入增益 / 削波余量)
│ ← 面板「输入增益」滑块也改它; 菜单音量桥会把按键折算到「扬声器电平」
│
├─(capture)─ 归一化实例 (独立 filter-chain, 与上混图分开)
│ copy → pipe → 自研 DSP → copy
│ ↑ 左右声道各一个进程 (响度归一化/loudness_norm.py ×2)
│ 共享 /dev/shm/collaplex-loudness 的两个槽位
│
▼ up_norm (必须 = 1.000, 单位增益, 不归用户调)
cinema_spatial_up_raw Audio/Sink
│ ← 上混图的入口; 与 up_norm 一起构成"中间两级", 被写坏就是"耳机没声音"的元凶
│
├─ 上混实例 ── FL/FR 上混成 6 路虚拟扬声器(以听者为中心, 含正上方)
│ 每路各自 HRTF 卷积 (SADIE-II H4, 96k / 512tap)
│ + 混响 (镜像法早期反射 + Sabine 尾音)
│ → mixL / mixR ★ 6 路 Gain 必须各 1/√6 = 0.4082 (能量归一)
│
▼ up_out (1.120 = 链的输出端音量)
物理设备 EDIFIER USB (iec958-stereo / 模拟, 96kHz / 24bit S24LE)
↑ 它的音量 = 「扬声器电平」= 老板实际听到的音量 (= 菜单音量桥的目标)
还有第二条并行的 5.1 链(cinema_spatial_sink → cinema_spatial_out),默认不用;
两条链共用同一个物理输出,只有被设为默认 sink 的那条在收数据。
2. 组件清单
| 组件 | 类型 | 期望值 | 作用 / 失效后果 |
|---|---|---|---|
cinema_spatial_up_sink |
Audio/Sink | 默认 sink、priority.session=2000、vol 由老板定(削波余量) |
应用入口。被 HDMI 抢走 → 声音跑去电视;被写成很低 → DSP 顶格 |
cinema_spatial_up_norm |
链内部 | vol = 1.000(单位增益) | 归一化 DSP 的输出口。被写低 → 全链凭空掉十几 dB |
cinema_spatial_up_raw |
Audio/Sink | vol = 1.000(单位增益) | 上混图入口。同上,就是"耳机一点声音没有"的元凶 |
cinema_spatial_up_out |
链输出端 | vol = 1.120;output_FL/FR 必须连到物理设备 playback_FL/FR | 断连 → 声音进不去设备(看着全对却静音) |
cinema_spatial_sink / cinema_spatial_out |
Audio/Sink / 链输出 | 各 1 份,priority.session=2000 |
并行的 5.1 链,备用 |
| DSP 进程 | python3 loudness_norm.py |
恰好 2 个 | 多 = 孤儿抢槽位 → 整链静音;0 个 = 没消费者 → 静音 |
/dev/shm/collaplex-loudness |
共享内存 | 两个槽都在 5 秒内更新过;槽内 +16 是 f64 时刻 | 有槽僵死 → pipe 同步管道卡住 → 静音 |
| 默认输出时钟 | settings metadata | clock.rate=96000、allowed-rates=[96000] |
不提 96k → HRTF IR 白换 |
| 面板 | HTTP 服务 | 8788 端口 200;web/webui.py 常驻 |
电平表/音量桥/诊断都在这里 |
| 归一化 DSP 参数 | env 默认值 | LN_MAX_BOOST=12、LN_PEAK_CEIL=0.985、LN_FADE_BLOCKS=4、LN_FADE_GAP_BLOCKS=24 |
输出峰值被锁在 −0.13 dBFS;长静音后起播淡入 170ms |
状态文件(~/.local/state/cinema-spatial/)
| 文件 | 当前值 | 含义 |
|---|---|---|
loudness.json |
unified=true, target=0.361, digital=1.005, analog=1.005, target_db=-14.0 |
响度统一 + 归一化目标 |
reverb.json |
on=true, wet=0.3 |
混响 |
params.json |
taps=0, hrtf=H4-96k |
HRTF 与混响 taps |
mono.json |
on=false |
单声道 |
volume |
1.000 |
「输入增益」落盘值(= 链的削波余量) |
3. 六个"必须"(当晚的坑,每条都对应一个不变量)
| # | 不变量 | 违背时的症状 | 根因 |
|---|---|---|---|
| 1 | 同名 conf 只能有一份(真源 = ~/.config/pipewire/pipewire.conf.d/) |
开了两个都没声音 | PipeWire 把每个 conf.d 目录里的 *.conf 全部加载,不做用户级覆盖系统级 |
| 2 | up_norm / up_raw 必须 1.000 |
耳机一点声音没有 | 面板按"名字含 cinema_spatial"一把写节点,把中间级一起改了;而音量账只算首尾两级,账面 −8.1 dB 实际 −29.9 dB |
| 3 | 一个量只能有一个写入入口 | 调着调着跳到其他值 | 两个滑块写同一个 target,量纲还不同(0 |
| 4 | 上混 6 路必须 1/√6 能量归一 | 电平顶满(+7.8 dBFS) | 每路 gain=1 → 净增益 +6 dB,把 DSP 的峰值保护吃掉 |
| 5 | 虚拟声卡 priority.session ≥ 2000 |
被 HDMI 抢走、点都点不回来 | 虚拟声卡原来没有这个属性(None),HDMI 是 1196 |
| 6 | DSP 进程恰好 2 个 | 重建后静音 | claim_slot() 抢不到槽时 return 0(假装抢到)→ 孤儿挤在槽0;而 DSP 的父进程是 pipewire 主进程,重建时 pdeathsig 不触发 → 每次重建攒一对 |
4. 看门狗(音频守护.py)检查与自愈
30 秒一轮,扫到问题直接修(不是只报警)。同类问题 90 秒冷却;连续 3 次修不好就只报警。
| 严重度 | 检查 | 判定 | 自愈动作 |
|---|---|---|---|
| 🔴 | DSP 进程数 | ≠ 2 | >2 → SIGKILL 掉多余的(保留最新一对);=0 → 重建链路 |
| 🔴 | 槽位新鲜度 | 任一槽 > 5 秒未更新 | 重建链路 |
| 🔴 | 链输出连线 | up_out:output_FL/FR 没连到物理设备 |
先试运行期 pw-link 重连,失败则重建 |
| 🔴 | 默认 sink | ≠ cinema_spatial_up_sink |
wpctl set-default |
| 🟡 | 虚拟声卡优先级 | < 2000 | 重建配置(生成器已带 SINK_PRIO) |
| 🟡 | 中间级音量 | up_norm/up_raw ≠ 1.0 |
wpctl set-volume <id> 1.0 |
| 🟡 | 节点份数 | 任一名字 > 1 份 | 重建链路(生成器会 prune 同名 conf) |
| 🟢 | 面板 | 8788 不通 | 后台拉起 web/启动.sh |
| 🟢 | 物理设备静音 | 物理 sink MUTED |
解除静音 |
5. 看门狗(音频守护.py)
30 秒一轮,按 config/组件配置单.json 的期望值逐项核对,扫到问题直接修。
python3 音频守护.py # 常驻 30 秒循环(前台, 实时输出)
python3 音频守护.py --once # 只跑一轮(给 cron 用)
python3 音频守护.py --check # 干跑: 只报不动手
tail -f ~/.local/state/cinema-spatial/guardian.log # 日志
日志文件:~/.local/state/cinema-spatial/guardian.log(正常时静默,每 10 分钟一条心跳)。
行为:能温和就温和(set-default/set-volume/pw-link 重连),链路级问题才重建;
同类问题 90 秒冷却;连续 3 次修不好 → 只报警。详见 组件配置单.md 第 4 节。
6. 离线渲染(离线渲染.py)
把音频/视频离线处理成空间音频 FLAC —— 走的是和实时虚拟声卡同一条链, 但用 ffmpeg 滤镜图跑,速度是实时的几十倍,不占用声卡。
python3 离线渲染.py 片子.mp4 歌.mp3 # 可多个, 串行处理
python3 离线渲染.py --状态 # 是否在跑 / 上次结果
python3 离线渲染.py --混响 0.3 歌.flac # 混响湿量(0 = 关)
python3 离线渲染.py --目标 -16 歌.mp3 # 响度目标 dBFS
python3 离线渲染.py --无HRTF 歌.flac # 只上混 5.1, 不做双耳空间化
python3 离线渲染.py --输出目录 ~/音乐/空间 --覆盖 歌.mp3
输出:<原名>.空间音频.flac(96 kHz / 立体声 / FLAC)。
三个入口
| 入口 | 用法 |
|---|---|
| 命令行 | python3 离线渲染.py 文件... |
| 面板 | http://127.0.0.1:8788/ 最下面「离线渲染」区:把文件拖进去(会自动上传 —— 浏览器拿不到本地路径),或直接贴路径(一行一个,大文件推荐)→ 开始渲染。有实时进度条、速度、日志,可取消 |
| 文件管理器右键 | 选中音频/视频 → 右键「打开方式」→ Collaplex 离线渲染:自动开终端跑,看得到进度。可一次多选 |
菜单项:~/.local/share/applications/collaplex-render.desktop → 调 离线渲染-打开.sh。
面板后端接口:GET /api/render(状态+进度)、POST /api/render(启动)、POST /api/render/upload(拖拽上传)、POST /api/render/cancel(取消)。
链路与实时的对应关系
立体声 --pan 上混--> 5.1 --sofalizer(H4 SOFA)--> 双耳立体声 --[afir 混响]--> loudnorm --> flac
| 实时链(PipeWire filter-chain) | 离线对应 |
|---|---|
| 上混矩阵(生成配置.py:FC=(FL+FR)×0.707、LFE 同源、BL/BR 反相) | pan=5.1|...(同系数) |
| 12 个 HRTF 卷积 + mixL/mixR(6 路各 1/√6) | sofalizer(同一个 SOFA 源,H4 96k) |
| 混响湿路(wetL/wetR + 房间 IR) | afir + reverb/房间混响IR-96k.wav |
| 响度归一化 DSP | loudnorm(I=目标, TP=-1.5) |
已经是 5.1 / 7.1 的片源不做上混,直接进 HRTF。
★★ 两道锁(老板要求:"有文件还在内存里没下载下来就不要继续扔进去了")
| 锁 | 作用 | 表现 |
|---|---|---|
渲染锁(/tmp/collaplex-render.lock,flock) |
一轮任务期间不接受新任务,不排队堆积 | 再扔 → ⛔ 已有一个渲染任务在跑(PID x), 退出码 2 |
| 文件就绪检查 | 每个文件进队列前:① 非空 ② 相隔 1.2 秒两次 stat,大小/时间都不变 ③ ffprobe 能读出音轨 | 正在下载/写入的文件 → ⏭ 跳过: 文件还在写入/下载中(大小还在变), 等它传完再扔 |
实测(2026-09-13)
| 场景 | 结果 |
|---|---|
| 8 秒立体声音频 | 1.8 s 完成(≈4.4 倍速)→ flac 96k 立体声 |
| 视频(mp4) | 自动取音轨,同样处理,1.8 s |
| 渲染中再扔一个 | 被锁挡下,退出码 2 ✓ |
| 90 MB 且持续增长的文件 | 跳过:"还在写入/下载中" ✓ |
已知坑:ffmpeg 的 out_time_ms 单位其实是微秒(历史遗留命名)—— 解析进度要用
out_time_us,否则 8 秒素材会显示成 4779 秒、进度条瞬间冲满。
7. 常用命令
# 手动看状态(面板)
http://127.0.0.1:8788/
# 看门狗
python3 音频守护.py # 30 秒循环(前台, 可实时看)
python3 音频守护.py --once # 只跑一轮(给 cron 用)
python3 音频守护.py --check # 只检查不修
tail -f ~/.local/state/cinema-spatial/guardian.log
# 链路
bash 空间音频 开 | 关 | 状态 | 诊断 | 重建
bash 空间音频 数字 | 模拟 # 切输出 route
bash 空间音频 混响 on 0.3 # 混响
# 一分钟自查(静音时第一件事)
ps -eo pid,etimes,cmd | grep -E "[l]oudness_norm" # 必须是 2 行
python3 音频状态.py dup # 应为空(无重复节点)
⚠️ 杀 DSP 时不要用
pgrep -f loudness_norm.py—— 它会匹配到你自己那条命令行, 把自己的 shell 杀掉(实测 exit −15)。用/proc/<pid>/cmdline判断,或字符类[l]oudness_norm。