- 离线渲染.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(离线渲染要卷积的素材)
响度归一化(全系统自动拉平响度)
把 Chrome / 抖音 / 播放器 / 任何往 cinema_spatial_up_sink 推流的软件,全部自动拉到
同一个目标电平 —— 小声的拉起来、大声的压下去,切换视频不再一响一轻。
2026-09-13 端到端实测通过并已上生产。
结论(实测数据)
| 阶段 | 输入电平 | 输出电平 | 增益(推子) |
|---|---|---|---|
| 响段 | -12.07 dBFS | -19.04 dBFS | -6.97 dB |
| 换轻段瞬间 | -32.07 dBFS | -32.90 dBFS | -0.83 dB |
| 追赶中 | -32.07 dBFS | -26.76 dBFS | +5.31 dB |
| 追赶中 | -32.07 dBFS | -20.62 dBFS | +11.46 dB |
| 稳定 | -32.07 dBFS | -19.06 dBFS | +13.02 dB |
- 20 dB 的节目差异 → 0.02 dB(左右声道读数完全一致,声像不漂)
- 追赶速度 6 dB/s 限速(每 0.5 秒走 3 dB,分毫不差)
- 开销:2 个进程共 ~30% 单核;整链延迟 ~11 ms
怎么实现的
PipeWire 本机(1.6.2)没编译 ebur128 / ffmpeg 这两类原生响度节点,所以走
未文档化的 pipe 插件:它 fork 一个外部程序,用 stdin/stdout 搬运音频。
任何软件 → cinema_spatial_up_sink (Audio/Sink)
↓
【响度归一化实例】c1L/c1R → lnL/lnR (pipe→DSP) → c2L/c2R
↓ (node.target)
cinema_spatial_up_raw (Audio/Sink)
↓
【上混图】上混 5.1 → HRTF → 房间混响
↓
cinema_spatial_up_out → 声卡
独立的 filter-chain 实例,不是塞进上混图里(原因见下面的坑 #1)。
文件
| 文件 | 作用 |
|---|---|
loudness_norm.py |
核心 DSP:ITU-R BS.1770 滑窗能量 → 联合响度 → 目标增益 → 限速 + 峰值保护 |
run_norm.sh |
pipe 插件入口 wrapper。部署:cp 到 ~/.local/bin/collaplex-loudness-norm && chmod +x |
read_state.py |
读共享内存看实时状态(诊断 / 面板数据源),--watch 持续刷新 |
生成配置.py |
改这里 → 重跑。loudness_instance() 生成那一级 |
关键机制
- 左右两个进程 + 共享内存协调:filter-chain 给 pipe 的是一个单声道节点,所以左右
各起一个 pipe 实例(各 fork 一个进程),两个进程用
flock抢 0/1 槽位,通过/dev/shm/collaplex-loudness交换能量读数、算同一个增益 —— 否则各自算各自的, 声像会漂。 - 共享内存布局:每声道 32 字节 × 2,之后是全局参数。字段只增不改顺序 (web 面板直接读这块内存画电平表和推子)。
- 目标电平热更新:DSP 每秒重读
~/.local/state/cinema-spatial/loudness.json({"on": bool, "target_db": float}),面板/CLI 改完立即生效,不用重建链路。 - 自愈退出:连续 10 秒没有数据就自杀(
LN_IDLE_LIMIT)。因为 PDEATHSIG 在 pipe 隔了一层 shell 时不生效,孤儿会被 systemd 收养(ppid不是 1),靠管道 EOF 也判不出来。
踩过的坑(都是实测撞出来的)
1. pipe 节点塞进大图里收不到音频 ★最坑
放进那个 40+ 节点的上混图里 —— 不管放图入口、copy 之后、还是输出末端 —— pipe 都会被正常 fork、数据也在稳定流动(每 0.5 秒 47 个块),但它收到的永远是恒定 -43 dBFS 的空数据,音频直接从旁边绕过去,日志里没有任何报错。
同一个 pipe 放在 copy → pipe → copy 这种小图里完全正常(实测收到 -12.04 dBFS 的
真实节目电平)。
解法:拆成两级串联的独立 filter-chain 实例(现在就是这么做的)。
2. pipe 的 command 不支持中文路径 ★隐蔽
传中文路径(比如 ~/桌面/工作区/.../run_norm.sh)时它一声不吭:节点照建、
音频绕过、DSP 完全不执行、日志无任何输出。
解法:wrapper 放纯 ASCII 路径(~/.local/bin/collaplex-loudness-norm)。
wrapper 内部再去 exec 中文路径的 DSP 没问题。
3. pipe 的 command 只能写"程序 + 一个参数"
它走 shebang 式 exec,多一个参数就炸:
env A=B python3 x.py → use -[v]S to pass options in shebang lines。
解法:所有环境变量/参数都塞进 wrapper 脚本,配置里只给脚本路径。
4. 整条链必须有下游消费者,否则图不被驱动
playback.props 上加了 node.autoconnect = false、又没手动接下游时,图不跑,
DSP 收到的是全零(raw=0.000000,不是底噪)。
判据:看 pw-link -l 里最终输出有没有 |-> 目标。
5. ★系统级 + 用户级同名配置会被同时加载
/usr/share/pipewire/pipewire.conf.d/90-cinema-spatial.conf(deb 装的旧版)
和 ~/.config/pipewire/pipewire.conf.d/90-cinema-spatial.conf(新版)不会互相覆盖,
而是两个都加载 → 同名节点出现两份 → 音频进了没有归一化的旧实例,
DSP 一直收 0。
症状:pw-dump 里 cinema_spatial_up_sink 等节点重复 2 次。
解法:只留一份。已删掉系统级那份,保留用户级(生成配置.py 默认就写这里)。
系统级备份在 /tmp/sys-90-cinema-spatial.conf.bak-*。
6. pipe 的端口名是 In / Out(单声道),不是 In 1 / In 2
In 1..In 8 是别的插件的端口。写错的话 filter-chain 静默把那一级丢掉。
7. 面板加上第三个框后 .flow 高度不够
.flow 固定 260px,塞进响度归一化框(150px)后两个电平表被压成 10px 的缝。
已抬到 430px。
8. 用 write_file 重写 shell 脚本会冲掉可执行位
wrapper 不可执行 → pipe 起不来(DSP 进程 0)。写完记得 chmod +x。
Web 面板(../web/,:8788)
- 4 路电平:原始 L/R(绿/蓝)+ 处理后 L/R,各自的淡色面积是 RMS、实线是 Peak、 横线是峰值保持(黄/红),表头带实时读数
- 响度归一化框:推子(±20 dB 刻度,实时位置 = 当前增益)+ 目标/判据读数 + 黄色增益历史曲线(能看到"锁住 → 换段 → 追赶"的全过程)
- 「目标响度」滑块(-40 ~ -6 dBFS,步长 1 dB)—— 用户自己定"拉平到多大声"。
拖完写进
~/.local/state/cinema-spatial/loudness.json的target_db, DSP 每秒热读 → 1 秒内跟上,不用重建链路(实测:改成 -20 后 3 秒 DSP 已锁到 -20)。 滑块位置会跟 DSP 的实际目标同步(CLI 改过也反映);用户正在拖时不回写,免得抢输入。 ★ 写接口复用/api/loudness({"target_db": "-20.0"}),但只有 target_db 一个参数时 不调apply_loudness()—— 那个是「响度统一」(数字/模拟输出路增益补偿)的逻辑,两码事。 - 后端
/api/meters多返回一个loud字段(ok/target_db/gain_db/raw_L..post_R/loud), 数据源是 DSP 的共享内存;DSP 没在跑时ok=false,前端显示"未启用"
参数
| 环境变量 | 默认 | 说明 |
|---|---|---|
LN_TARGET_DB |
-16 | 目标电平(dBFS)。状态文件优先,见上 |
LN_MAX_BOOST |
20 | 最大提升量(dB,自动取绝对值) |
LN_MAX_CUT |
20 | 最大压低量(dB,代码自动取负,传正数也行) |
LN_RATE |
96000 | 采样率,要跟 91-clock.conf 一致 |
LN_PEAK_CEIL |
0.985 | 峰值保护上限 |
LN_IDLE_LIMIT |
10 | 连续多少秒没数据就退出 |
LN_DEBUG |
0 | 1 = 每块打印一行到 stderr |
隔离测试方法
不想动生产时,另起一个 PipeWire 实例(要用同一份 XDG_RUNTIME_DIR,
并且 unset XDG_CONFIG_HOME,否则会把生产的 90-cinema-spatial.conf 一起加载):
export XDG_RUNTIME_DIR=/run/user/1000 PIPEWIRE_RUNTIME_DIR=/run/user/1000
export PIPEWIRE_CONFIG_DIR=/tmp/pw-lab/prod
unset XDG_CONFIG_HOME
pipewire -c labtest.conf
注意:查状态前也要 unset PIPEWIRE_CONFIG_DIR,否则 pw-dump / wpctl
连不上主 daemon,会给你假阴性(看着像节点全没了,其实好好的)。
诊断探针 probe3.py 很好用:透传 stdin→stdout,每 0.5 秒把块数/RMS/峰值写日志,
一眼看出 pipe 到底收到什么、跟播放时间轴对不对得上。
开关 / 调参
# 关掉(生成器会跳过这一级,然后重建)
echo '{"on": false, "target_db": -16}' > ~/.local/state/cinema-spatial/loudness.json
cd ~/桌面/工作区/实验/空间音频 && python3 生成配置.py
systemctl --user restart pipewire pipewire-pulse wireplumber # 音频断 2~3 秒
# 只改目标电平(热生效,不用重建)
echo '{"on": true, "target_db": -20}' > ~/.local/state/cinema-spatial/loudness.json