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

207 lines
12 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.
# 电影院空间音频 —— 组件配置单
> 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~1.5 vs 0~1 |
| 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` 的期望值逐项核对,**扫到问题直接修**。
```bash
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 滤镜图跑,速度是实时的几十倍,不占用声卡。
```bash
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 | 离线对应 |
|---|---|
| 上混矩阵(生成配置.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 | `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. 常用命令
```bash
# 手动看状态(面板)
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`。