v1.2.0: 全系统响度归一化(pipe 插件挂自研 DSP) + 目标响度可调 + 控制台 4 路电平/增益历史

新增功能
- 响度归一化/: 挂未文档化的 filter-chain `pipe` 插件跑自研 DSP, 把任何往虚拟声卡
  推流的软件(Chrome/播放器/短视频)响度拉平到同一目标。ITU-R BS.1770 滑窗能量 ->
  联合响度 -> 目标增益(6 dB/s 限速 + 峰值保护), 左右声道共享同一增益防声像漂移。
  实测: 输入 20 dB 差 -> 输出 0.02 dB 差; 开销约 30% 单核; 延迟 +11 ms
- 目标电平可调(热生效, 不重建链路): 面板「目标响度」滑块(-40 ~ -6 dBFS) /
  POST /api/loudness {"target_db"} / 直接改 ~/.local/state/cinema-spatial/loudness.json,
  DSP 每秒重读该文件 -> 1 秒内跟上
- 控制台: 电平表拆成 4 路(原始 L/R + 处理后 L/R, 绿/蓝两条线), 新增增益历史曲线
  (看得到"锁住 -> 换段 -> 追赶"的全过程); 修表头被长数值挤扁

关键实现决定(都是实测撞出来的)
- 归一化必须做成**独立 filter-chain 实例**串在上混图之前 —— pipe 节点塞进那个
  40+ 节点的大图里(图入口/copy 之后/输出末端都试过)会被正常 fork、数据也在流,
  但收到的永远是恒定 -43 dBFS 空数据, 音频绕过, 零报错
- pipe 的 command 必须是**纯 ASCII 路径**: 传中文路径时静默失效(节点照建、音频
  绕过、DSP 不 fork、无日志) -> 入口固定为 /usr/bin/collaplex-loudness-norm
- 整条链必须有下游消费者, 否则图不被驱动, DSP 收到全 0
- 系统级(/usr/share)与用户级(~/.config)的**同名 conf 会被同时加载**(不是覆盖),
  会建出两份同名节点让音频进错实例 -> 铁律: 同名只留一份

打包
- 补入 loudness/loudness_norm.py 与 /usr/bin/collaplex-loudness-norm(ASCII 路径)
- 生成配置时用 CINEMA_SPATIAL_LN 指定包内入口(开发机是 ~/.local/bin)
- postinst: 装完自动启动控制台并打开浏览器(su 到登录用户 + 补 XDG_RUNTIME_DIR)
- 依赖加 xdg-utils
This commit is contained in:
edgevoid
2026-09-13 18:06:52 +08:00
parent 3d3ce35507
commit 18cc030cac
11 changed files with 1085 additions and 55 deletions
+102 -7
View File
@@ -37,6 +37,94 @@ CLOCK_OUT = os.environ.get("CINEMA_SPATIAL_CLOCK") or os.path.expanduser(
# 单声道模式(单声道蓝牙音响/耳机): 状态文件三态, 缺省自动判目标设备声道数
MONO_STATE = os.path.expanduser("~/.local/state/cinema-spatial/mono.json")
# 响度归一化(2026-09-13): 在图的最前端插一级 pipe 插件, 挂自研 DSP 做全系统响度统一。
# 状态: ~/.local/state/cinema-spatial/loudness.json {"on": bool, "target_db": float}
LOUDNESS_STATE = os.path.expanduser("~/.local/state/cinema-spatial/loudness.json")
# ★ LN_ENTRY 必须是**纯 ASCII 路径**: pipe 插件的 command 走 shebang 式 exec, 实测传中文
# 路径时它一声不吭 —— 节点建得出来、音频直接绕过去、DSP 根本不 fork、日志无任何错。
# wrapper 内部再去 exec 中文路径的 DSP 是没问题的。
LN_ENTRY = (os.environ.get("CINEMA_SPATIAL_LN")
or os.path.expanduser("~/.local/bin/collaplex-loudness-norm"))
def loudness_cfg() -> tuple[bool, float]:
"""响度归一化开关 + 目标电平(dBFS).
判定顺序: 环境变量 CINEMA_SPATIAL_LOUDNESS(0/1) > 状态文件 > 默认**开**。
★ 只插在**立体声上混**图里 —— 那才是抖音/网页/音乐走的路; 5.1 图放的是专业混音
片源, 响度本来就规矩, 不掺和。
"""
on = True
target = -16.0
try:
with open(LOUDNESS_STATE, encoding="utf-8") as fh:
d = json.load(fh)
if isinstance(d, dict):
on = bool(d.get("on", True))
target = float(d.get("target_db") or -16.0)
except (OSError, ValueError, TypeError):
pass
env = os.environ.get("CINEMA_SPATIAL_LOUDNESS")
if env in ("0", "1"):
on = env == "1"
return on, target
def loudness_instance() -> list[str]:
"""独立的响度归一化 filter-chain 实例(**串在上混图前面**)。
★ 为什么单开一级, 而不是把 pipe 节点塞进上混图:
实测塞进那个 40+ 节点的大图里 —— 不管放图入口、copy 之后、还是输出末端 ——
pipe 都会被正常 fork、数据也在稳定流动(每 0.5s 47 个块), 但它收到的永远是
恒定 -43 dBFS 的"空数据", 音频直接绕过去, 日志里没有任何报错。同一个 pipe
放在 copy->pipe->copy 的小图里完全正常(实测收到 -12.04 dBFS 的真实节目电平)。
拆成两级串联绕开这个坑; 归一化本来就是独立一级, 开关/调参也更干净
(改它不用重建那张大图)。
★ command 只能是"程序 + **一个**参数" —— pipe 走 shebang 式 exec, 多一个就炸
(env A=B python3 x.py 会报 "use -[v]S to pass options in shebang lines")。
参数全在 wrapper 里, 这里只给脚本路径, 而且**必须是纯 ASCII 路径**:
实测传中文路径时 pipe 一声不吭 —— 节点照建、音频绕过、无任何日志。
★ capture 用原来的 sink 名(cinema_spatial_up_sink): 用户/CLI 视角不变,
所有软件照样往这个默认 sink 推流, 归一化在它后面悄悄做掉。
"""
return [
" { name = libpipewire-module-filter-chain",
" flags = [ nofail ]",
" args = {",
' node.description = "电影院空间音频 (响度归一化)"',
' media.name = "Cinema Spatial Loudness"',
" filter.graph = {",
" nodes = [",
" { type = builtin label = copy name = c1L }",
" { type = builtin label = copy name = c1R }",
" { type = builtin label = pipe name = lnL",
f' config = {{ command = "{LN_ENTRY}" }} }}',
" { type = builtin label = pipe name = lnR",
f' config = {{ command = "{LN_ENTRY}" }} }}',
" { type = builtin label = copy name = c2L }",
" { type = builtin label = copy name = c2R }",
" ]",
" links = [",
' { output = "c1L:Out" input = "lnL:In" }',
' { output = "c1R:Out" input = "lnR:In" }',
' { output = "lnL:Out" input = "c2L:In" }',
' { output = "lnR:Out" input = "c2R:In" }',
" ]",
' inputs = [ "c1L:In" "c1R:In" ]',
' outputs = [ "c2L:Out" "c2R:Out" ]',
" }",
f" capture.props = {{ {VOL_PROP}node.name = cinema_spatial_up_sink "
"media.class = Audio/Sink audio.channels = 2 audio.position = [ FL FR ] }",
" playback.props = { node.name = cinema_spatial_up_norm "
'node.target = "cinema_spatial_up_raw" audio.channels = 2 '
"audio.position = [ FL FR ] }",
" }",
" }",
]
def mono_cfg() -> bool:
"""是否把链的输出合并成单声道.
@@ -305,9 +393,11 @@ def sink_upmix() -> list[str]:
L = [" { name = libpipewire-module-filter-chain", " flags = [ nofail ]", " args = {",
' node.description = "电影院空间音频 (立体声上混)"',
' media.name = "Cinema Spatial Upmix"', " filter.graph = {", " nodes = [",
" # ---- 第一段: 立体声矩阵上混成 5.1 (照抄官方 upmix) ----",
" { type = builtin label = copy name = copyFL }",
' media.name = "Cinema Spatial Upmix"', " filter.graph = {", " nodes = ["]
# 响度归一化**不在**这张图里做 —— 它跑在独立的 filter-chain 实例上(见
# loudness_instance), 由 cinema_spatial_up_sink 收流、再把结果推给这张图。
L += [" # ---- 第一段: 立体声矩阵上混成 5.1 (照抄官方 upmix) ----",
" { type = builtin label = copy name = copyFL }",
" { type = builtin label = copy name = copyFR }",
" { type = builtin label = copy name = copyOFL }",
" { type = builtin label = copy name = copyOFR }",
@@ -332,8 +422,8 @@ def sink_upmix() -> list[str]:
if mono:
L += mono_block(" ")
outs = '"monoOut:Out"'
L += [" ]", " links = [",
' { output = "copyFL:Out" input = "mixF:In 1" }',
L += [" ]", " links = ["]
L += [' { output = "copyFL:Out" input = "mixF:In 1" }',
' { output = "copyFR:Out" input = "mixF:In 2" }',
' { output = "copyFL:Out" input = "copyOFR:In" }',
' { output = "copyFR:Out" input = "copyOFL:In" }',
@@ -358,8 +448,10 @@ def sink_upmix() -> list[str]:
L += [" ]",
' inputs = [ "copyFL:In" "copyFR:In" ]',
f' outputs = [ {outs} ]', " }",
f" capture.props = {{ {VOL_PROP}node.name = cinema_spatial_up_sink media.class = Audio/Sink "
"audio.channels = 2 audio.position = [ FL FR ] }",
# ★ 这一级不再是默认 sink —— sink 名让给前面的归一化实例, 由它的
# node.target = "cinema_spatial_up_raw" 把流推到这里。
" capture.props = { node.name = cinema_spatial_up_raw "
"media.class = Audio/Sink audio.channels = 2 audio.position = [ FL FR ] }",
f' playback.props = {{ node.name = cinema_spatial_up_out {TARGET_PROP}'
+ out_props(mono),
" }", " }"]
@@ -379,6 +471,9 @@ def main() -> int:
os.makedirs(os.path.dirname(OUT), exist_ok=True)
body: list[str] = ["# 电影院空间音频 自动生成, 改 IR/增益请改 生成配置.py 重跑", "context.modules = ["]
body += sink_51()
# 响度归一化: 独立一级, 串在上混图前面(开关见 ~/.local/state/cinema-spatial/loudness.json)
if loudness_cfg()[0]:
body += loudness_instance()
body += sink_upmix()
body += ["]", ""]
with open(OUT, "w", encoding="utf-8") as fh: