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:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user