18cc030cac
新增功能
- 响度归一化/: 挂未文档化的 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
129 lines
4.3 KiB
Python
129 lines
4.3 KiB
Python
#!/usr/bin/env python3
|
|
"""读 Collaplex 响度归一化的共享状态(诊断 / web 面板数据源)
|
|
|
|
共享区布局(见 loudness_norm.py 顶部注释):
|
|
每声道 32 字节, 两声道:
|
|
+0 f32 e_smooth 平滑能量(响度判据) —— 归一化的"输入电平"
|
|
+4 f32 e_block 本块能量(快响应)
|
|
+8 f32 peak 本块峰值
|
|
+12 f32 gain_db 本块实际施加的增益 dB -> 推子
|
|
+16 f64 t 时间戳(monotonic)
|
|
+24 u32 n 已处理块数(判活)
|
|
+64 f32 target_db 目标响度
|
|
+68 u32 rate 采样率
|
|
|
|
用法:
|
|
python3 read_state.py 打印一次
|
|
python3 read_state.py --watch 每 0.5 秒刷新(诊断动态平衡过程)
|
|
"""
|
|
import json
|
|
import math
|
|
import mmap
|
|
import os
|
|
import struct
|
|
import sys
|
|
import time
|
|
|
|
SHM_PATH = "/dev/shm/collaplex-loudness"
|
|
CH_STRIDE = 32
|
|
SHM_SIZE = 80
|
|
TARGET_OFF = 64
|
|
RATE_OFF = 68
|
|
STALE_S = 1.0
|
|
|
|
EPS = 1e-12
|
|
|
|
|
|
def db(x: float) -> float:
|
|
"""线性能量/幅度 -> dBFS(0 以下)。"""
|
|
return 10.0 * math.log10(x + EPS)
|
|
|
|
|
|
def read_slot(mm: mmap.mmap, i: int) -> dict[str, float]:
|
|
base = i * CH_STRIDE
|
|
e_smooth = struct.unpack_from("<f", mm, base)[0]
|
|
e_block = struct.unpack_from("<f", mm, base + 4)[0]
|
|
peak = struct.unpack_from("<f", mm, base + 8)[0]
|
|
gain = struct.unpack_from("<f", mm, base + 12)[0]
|
|
t = struct.unpack_from("<d", mm, base + 16)[0]
|
|
n = struct.unpack_from("<I", mm, base + 24)[0]
|
|
age = time.monotonic() - t
|
|
return {
|
|
"e_smooth": e_smooth,
|
|
"e_block": e_block,
|
|
"peak": peak,
|
|
"gain_db": gain,
|
|
"n": n,
|
|
"age": age,
|
|
"alive": age < STALE_S,
|
|
}
|
|
|
|
|
|
def snapshot() -> dict[str, object]:
|
|
if not os.path.exists(SHM_PATH):
|
|
return {"ok": False, "err": "共享区不存在(响度归一化未启用)"}
|
|
fd = os.open(SHM_PATH, os.O_RDONLY)
|
|
try:
|
|
mm = mmap.mmap(fd, SHM_SIZE, prot=mmap.PROT_READ)
|
|
try:
|
|
target = struct.unpack_from("<f", mm, TARGET_OFF)[0]
|
|
rate = struct.unpack_from("<I", mm, RATE_OFF)[0]
|
|
ch = [read_slot(mm, 0), read_slot(mm, 1)]
|
|
finally:
|
|
mm.close()
|
|
finally:
|
|
os.close(fd)
|
|
|
|
out: dict[str, object] = {
|
|
"ok": bool(ch[0]["alive"] or ch[1]["alive"]),
|
|
"target_db": round(target, 2),
|
|
"rate": rate,
|
|
}
|
|
for i, name in ((0, "L"), (1, "R")):
|
|
c = ch[i]
|
|
# 原始输入电平 = 快响应块能量; 处理后 = 原始 + 实际增益
|
|
raw_db = db(c["e_block"])
|
|
raw_pk = 20.0 * math.log10(c["peak"] + EPS) if c["peak"] > 0 else -120.0
|
|
out["raw_" + name] = round(raw_db, 2) if c["alive"] else None
|
|
out["raw_peak_" + name] = round(raw_pk, 2) if c["alive"] else None
|
|
out["post_" + name] = round(raw_db + c["gain_db"], 2) if c["alive"] else None
|
|
out["gain_db_" + name] = round(c["gain_db"], 2) if c["alive"] else None
|
|
# 平滑响度(归一化真正依据的那条)
|
|
out["loud_" + name] = round(db(c["e_smooth"]), 2) if c["alive"] else None
|
|
if ch[0]["alive"] and ch[1]["alive"]:
|
|
joint = ch[0]["e_smooth"] + ch[1]["e_smooth"]
|
|
out["loud_joint"] = round(db(joint), 2)
|
|
out["deviation_db"] = round(target - db(joint), 2) # >0 = 还要提, <0 = 还要压
|
|
return out
|
|
|
|
|
|
def main() -> None:
|
|
watch = "--watch" in sys.argv
|
|
try:
|
|
if not watch:
|
|
print(json.dumps(snapshot(), ensure_ascii=False, indent=2))
|
|
return
|
|
print("每 0.5 秒刷新 (Ctrl-C 退出)")
|
|
print("%8s %9s %9s %9s %9s %8s" % (
|
|
"时间", "原始L", "原始R", "处理后L", "处理后R", "增益dB"))
|
|
t0 = time.time()
|
|
while time.time() - t0 < 3600:
|
|
s = snapshot()
|
|
if not s.get("ok"):
|
|
print(" (无数据: %s)" % s.get("err", "声道未在跑"))
|
|
else:
|
|
fmt = lambda k: ("%9.2f" % s[k]) if s.get(k) is not None else "%9s" % "-"
|
|
_g = s.get("gain_db_L")
|
|
gv = float(_g) if isinstance(_g, (int, float)) else 0.0
|
|
print("%8.1f %s %s %s %s %8.2f" % (
|
|
time.time() - t0, fmt("raw_L"), fmt("raw_R"),
|
|
fmt("post_L"), fmt("post_R"), gv))
|
|
sys.stdout.flush()
|
|
time.sleep(0.5)
|
|
except KeyboardInterrupt:
|
|
pass
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|