Files
cinema-spatial/组件配置单.md
edgevoid d8c3a27821 v1.3.0: 离线渲染(音频/视频→空间音频FLAC, 渲染锁+文件就绪检查) + 组件看门狗(30s核对期望状态并自愈)
- 离线渲染.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(离线渲染要卷积的素材)
2026-09-13 23:24:22 +08:00

12 KiB
Raw Permalink Blame History

电影院空间音频 —— 组件配置单

2026-09-13 全链路修复后实测的稳定状态。机器可读版本在 config/组件配置单.json音频守护.py30 秒一扫,扫到就修)逐项核对。

本文档的目的:说清"什么必须是什么" —— 当晚 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 默认 sinkpriority.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.120output_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=96000allowed-rates=[96000] 不提 96k → HRTF IR 白换
面板 HTTP 服务 8788 端口 200web/webui.py 常驻 电平表/音量桥/诊断都在这里
归一化 DSP 参数 env 默认值 LN_MAX_BOOST=12LN_PEAK_CEIL=0.985LN_FADE_BLOCKS=4LN_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,量纲还不同(01.5 vs 01
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

输出:<原名>.空间音频.flac96 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 离线对应
上混矩阵(生成配置.pyFC=(FL+FR)×0.707、LFE 同源、BL/BR 反相) pan=5.1|...(同系数)
12 个 HRTF 卷积 + mixL/mixR6 路各 1/√6 sofalizer(同一个 SOFA 源,H4 96k
混响湿路(wetL/wetR + 房间 IR afir + reverb/房间混响IR-96k.wav
响度归一化 DSP loudnormI=目标, TP=-1.5

已经是 5.1 / 7.1 的片源不做上混,直接进 HRTF。

★★ 两道锁(老板要求:"有文件还在内存里没下载下来就不要继续扔进去了")

作用 表现
渲染锁/tmp/collaplex-render.lockflock 一轮任务期间不接受新任务,不排队堆积 再扔 → ⛔ 已有一个渲染任务在跑(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