Files
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
..

响度归一化(全系统自动拉平响度)

把 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 核心 DSPITU-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.pyuse -[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.confdeb 装的旧版) 和 ~/.config/pipewire/pipewire.conf.d/90-cinema-spatial.conf(新版)不会互相覆盖 而是两个都加载 → 同名节点出现两份 → 音频进了没有归一化的旧实例, DSP 一直收 0。

症状pw-dumpcinema_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.jsontarget_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