d8c3a27821
- 离线渲染.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(离线渲染要卷积的素材)
207 lines
12 KiB
Markdown
207 lines
12 KiB
Markdown
# 电影院空间音频 —— 组件配置单
|
||
|
||
> 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) | 离线对应 |
|
||
|---|---|
|
||
| 上混矩阵(生成配置.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. 常用命令
|
||
|
||
```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`。
|