固化链路: 虚拟声卡 + 响度归一化 -> 数字输出 (安装脚本可复现部署)
This commit is contained in:
@@ -0,0 +1,324 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collaplex 响度归一化器 — PipeWire filter-chain `pipe` 插件载荷
|
||||
|
||||
★ 运行环境: 由 filter-chain 的 pipe 插件 fork, stdin=音频进, stdout=音频出。
|
||||
★ 关键机制: filter-chain 会**按声道复制 graph**, 所以左右声道各起一个本进程实例,
|
||||
每个实例只看到**单声道**数据。为保持立体声声像不漂移, 两个实例通过 /dev/shm
|
||||
共享各自的能量值, 用"两声道能量和"作为联合响度, 算出**同一个增益**。
|
||||
|
||||
★ 铁律: 本进程**绝不能退出** —— pipe 插件的外部程序一旦退出, 音频立刻变静音。
|
||||
所有异常都吃掉, 出错时退回纯透传。
|
||||
|
||||
算法(简化版 EBU R128 思路, 纯 numpy 向量化, 零逐样本循环):
|
||||
块 RMS -> 能量 -> 指数滑动平均(≈400ms 短时响度) -> 门控 ->
|
||||
目标电平求增益(dB) -> 限速平滑 -> 峰值保护 -> 施加
|
||||
|
||||
环境变量调参(改配置时注入):
|
||||
LN_TARGET_DB 目标响度(dBFS), 默认 -16
|
||||
LN_GATE_DB 静音门限(dBFS), 默认 -50
|
||||
LN_MAX_BOOST 最大提升(dB), 默认 +12
|
||||
LN_MAX_CUT 最大衰减(dB), 默认 -18
|
||||
LN_SLEW_DB_S 增益变化速率上限(dB/s), 默认 6
|
||||
LN_WINDOW_S 响度平滑窗口(s), 默认 0.4
|
||||
LN_RATE 采样率, 默认 96000
|
||||
LN_BLOCK 每次读取字节数, 默认 4096
|
||||
"""
|
||||
import ctypes
|
||||
import fcntl
|
||||
import json
|
||||
import math
|
||||
import mmap
|
||||
import os
|
||||
import select
|
||||
import signal
|
||||
import struct
|
||||
import sys
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
|
||||
# ---------------- 参数 ----------------
|
||||
TARGET_DB = float(os.environ.get("LN_TARGET_DB", "-16")) # 兜底默认(状态文件优先)
|
||||
# ★ 目标电平放状态文件: 面板/CLI 改它, DSP 每秒重读一次 -> 实时生效, 不用重建链路。
|
||||
LOUDNESS_STATE = os.path.expanduser("~/.local/state/cinema-spatial/loudness.json")
|
||||
GATE_DB = float(os.environ.get("LN_GATE_DB", "-50"))
|
||||
MAX_BOOST = abs(float(os.environ.get("LN_MAX_BOOST", "12")))
|
||||
MAX_CUT = -abs(float(os.environ.get("LN_MAX_CUT", "18"))) # ★ 必须为负, 传正数也自动取负
|
||||
SLEW_DB_S = float(os.environ.get("LN_SLEW_DB_S", "6"))
|
||||
WINDOW_S = float(os.environ.get("LN_WINDOW_S", "0.4"))
|
||||
RATE = float(os.environ.get("LN_RATE", "96000"))
|
||||
BLOCK = int(os.environ.get("LN_BLOCK", "4096"))
|
||||
PEAK_CEIL = float(os.environ.get("LN_PEAK_CEIL", "0.985"))
|
||||
FADE_BLOCKS = int(os.environ.get("LN_FADE_BLOCKS", "4")) # 起播淡入块数(~170ms @96k/4096)
|
||||
FADE_GAP_BLOCKS = int(os.environ.get("LN_FADE_GAP_BLOCKS", "24")) # 连续静音多少块算"重新起播"
|
||||
IDLE_LIMIT = int(os.environ.get("LN_IDLE_LIMIT", "10")) # 连续无数据多少秒后自行退出
|
||||
DEBUG = os.environ.get("LN_DEBUG", "") not in ("", "0")
|
||||
|
||||
_ERR = sys.stderr
|
||||
|
||||
|
||||
def dbg(msg: str) -> None:
|
||||
if DEBUG:
|
||||
try:
|
||||
_ERR.write(msg + "\n")
|
||||
_ERR.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
EPS = 1e-12
|
||||
GATE_E = 10.0 ** (GATE_DB / 10.0)
|
||||
|
||||
|
||||
def read_target() -> float:
|
||||
"""当前目标电平(dBFS) —— 状态文件优先, 读不到回落到环境变量/默认.
|
||||
|
||||
★ 面板"响度归一化"推子写的就是这个值; 主循环每秒重读一次, 所以改完立即生效。
|
||||
"""
|
||||
try:
|
||||
with open(LOUDNESS_STATE, encoding="utf-8") as fh:
|
||||
d = json.load(fh)
|
||||
if isinstance(d, dict) and d.get("target_db") is not None:
|
||||
return max(-40.0, min(-6.0, float(d["target_db"])))
|
||||
except (OSError, ValueError, TypeError):
|
||||
pass
|
||||
return TARGET_DB
|
||||
|
||||
# ---------------- 共享区(两声道交换状态 + 面板读取) ----------------
|
||||
# ★ web 面板也读这块内存, 所以**字段只增不改顺序**。
|
||||
# 每声道 32 字节, 两声道; 其后是全局参数。
|
||||
# +0 f32 e_smooth 平滑能量(响度判据, ~400ms) —— 归一化的"输入电平"
|
||||
# +4 f32 e_block 本块能量(电平表用, 快响应)
|
||||
# +8 f32 peak 本块峰值(电平表用)
|
||||
# +12 f32 gain_db 本块**实际**施加的增益 dB(含峰值保护) -> 面板推子
|
||||
# +16 f64 t 时间戳(monotonic)
|
||||
# +24 u32 n 已处理块数(判活)
|
||||
# +28 f32 peak_out ★本块**输出**峰值(2026-09-13 加; 面板"处理后电平"用它,
|
||||
# 旧版这里是 _pad=0, 面板会回退到估算值并截顶到 PEAK_CEIL)
|
||||
# +64 f32 target_db 目标响度(面板推子的基准线)
|
||||
# +68 u32 rate 采样率
|
||||
SHM_PATH = "/dev/shm/collaplex-loudness"
|
||||
CH_STRIDE = 32
|
||||
SHM_SIZE = 80
|
||||
TARGET_OFF = 64
|
||||
RATE_OFF = 68
|
||||
|
||||
|
||||
def set_pdeathsig() -> None:
|
||||
"""父进程(pipewire filter-chain)一旦消失, 本进程立即被内核杀掉。
|
||||
★ 否则本进程会变成孤儿: 继续持有槽位锁, 让新实例拿不到槽位。"""
|
||||
try:
|
||||
libc = ctypes.CDLL("libc.so.6", use_errno=True)
|
||||
libc.prctl(1, signal.SIGKILL) # PR_SET_PDEATHSIG=1
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def claim_slot() -> int:
|
||||
"""用 flock 抢一个声道槽位(0/1), 锁随进程退出自动释放。"""
|
||||
for i in (0, 1):
|
||||
try:
|
||||
fd = os.open("%s.lock%d" % (SHM_PATH, i), os.O_CREAT | os.O_RDWR, 0o600)
|
||||
fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
return i # fd 不关, 锁一直持有
|
||||
except OSError:
|
||||
try:
|
||||
os.close(fd)
|
||||
except Exception:
|
||||
pass
|
||||
# ★★★ 2026-09-13: 两个槽**都**被占 = 已经有一对 DSP 在跑, 本进程必须退出。
|
||||
# 旧版这里 `return 0` —— 第 3、4 个实例会**一起写槽0**(没有锁保护), 数据被劈开,
|
||||
# 整链静音(老板报"没声音了", 实测 4 个进程抢 2 个槽, 槽1 僵死在 152 秒前)。
|
||||
# 注意 DSP 的父进程是 **pipewire 主进程**而不是 filter-chain 实例, 所以重建链路
|
||||
# 时 pdeathsig 不触发 → 每重建一次就攒一对孤儿, 必须靠这里自我了断。
|
||||
sys.stderr.write("[loudness] 两个槽位都被占用(已有实例在跑) -> 本进程退出\n")
|
||||
sys.stderr.flush()
|
||||
raise SystemExit(0)
|
||||
|
||||
|
||||
def open_shm() -> mmap.mmap:
|
||||
fd = os.open(SHM_PATH, os.O_CREAT | os.O_RDWR, 0o600)
|
||||
if os.fstat(fd).st_size < SHM_SIZE:
|
||||
os.ftruncate(fd, SHM_SIZE)
|
||||
return mmap.mmap(fd, SHM_SIZE)
|
||||
|
||||
|
||||
STALE_S = 1.0 # 对方声道超过这么久没更新, 视为不存在(防残留值污染联合响度)
|
||||
|
||||
|
||||
def read_peer_energy(mm: mmap.mmap, other: int) -> float:
|
||||
"""读另一声道的平滑能量; 陈旧(NaN/负/超时)一律当 0。"""
|
||||
base = other * CH_STRIDE
|
||||
try:
|
||||
e = struct.unpack_from("<f", mm, base)[0]
|
||||
t = struct.unpack_from("<d", mm, base + 16)[0]
|
||||
except Exception:
|
||||
return 0.0
|
||||
if e != e or e < 0.0: # NaN
|
||||
return 0.0
|
||||
if abs(time.monotonic() - t) > STALE_S: # 陈旧
|
||||
return 0.0
|
||||
return e
|
||||
|
||||
|
||||
def main() -> None:
|
||||
stdin = sys.stdin.buffer
|
||||
stdout = sys.stdout.buffer
|
||||
|
||||
set_pdeathsig()
|
||||
slot = claim_slot()
|
||||
mm = open_shm()
|
||||
other = 1 - slot
|
||||
|
||||
# 全局参数写进共享区(面板推子的基准线)
|
||||
target_db = read_target()
|
||||
try:
|
||||
struct.pack_into("<f", mm, TARGET_OFF, target_db)
|
||||
struct.pack_into("<I", mm, RATE_OFF, int(RATE))
|
||||
except Exception:
|
||||
pass
|
||||
next_target_check = time.monotonic() + 1.0
|
||||
|
||||
# 平滑状态
|
||||
energy_smooth = 0.0
|
||||
gain_db = 0.0
|
||||
_n: int = 0
|
||||
|
||||
block_seconds = BLOCK / 4.0 / RATE # 每块时长(单声道 float32)
|
||||
alpha = 1.0 - math.exp(-block_seconds / WINDOW_S) if WINDOW_S > 0 else 1.0
|
||||
max_step_db = SLEW_DB_S * block_seconds
|
||||
|
||||
carry = b"" # 不足 4 字节的尾巴
|
||||
idle = 0 # 连续无数据的秒数
|
||||
# ★ 起播淡入(2026-09-13): 从"长时间静音"回到有声的头几块, 上混的 6 路 HRTF 卷积
|
||||
# 会产生大幅过冲 —— 实测瞬间冲到 +7.8 dBFS(超满刻度 8dB, 削波), 之后指数衰减,
|
||||
# 面板的峰值栏于是"顶满"。淡入让信号平滑爬升即可压掉。
|
||||
# 只在**长静音**后触发(连续 FADE_GAP_BLOCKS 块无信号), 免得音乐里的安静段
|
||||
# 被反复淡入听出"呼吸"。
|
||||
fade_left = 0
|
||||
silent_blocks = 0
|
||||
|
||||
while True:
|
||||
# ★ 用 select 带超时读。为什么必须这样:
|
||||
# 1) filter-chain 死后管道写端不一定关闭, 所以 read() 不一定返回 EOF;
|
||||
# 2) 孤儿进程会被 systemd(subreaper) 收养, ppid 变成 session manager 而不是 1,
|
||||
# 所以 "ppid==1" 也不可靠。
|
||||
# 唯一可靠的判据 = 连续若干秒收不到任何数据(链路已断)。到时自行退出,
|
||||
# 否则每重启一次音频就会攒一对僵尸进程、霸占左右槽位锁。
|
||||
try:
|
||||
ready, _, _ = select.select([stdin], [], [], 1.0)
|
||||
except Exception:
|
||||
ready = [stdin]
|
||||
if not ready:
|
||||
idle += 1
|
||||
if idle >= IDLE_LIMIT or os.getppid() == 1:
|
||||
break
|
||||
continue
|
||||
idle = 0
|
||||
|
||||
# 目标电平热更新: 面板/CLI 改了 loudness.json, 这里每秒跟上(不用重建链路)
|
||||
_now_t = time.monotonic()
|
||||
if _now_t >= next_target_check:
|
||||
next_target_check = _now_t + 1.0
|
||||
_t = read_target()
|
||||
if _t != target_db:
|
||||
target_db = _t
|
||||
try:
|
||||
struct.pack_into("<f", mm, TARGET_OFF, target_db)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
raw = stdin.read(BLOCK)
|
||||
except Exception:
|
||||
break
|
||||
if not raw:
|
||||
break
|
||||
|
||||
buf = carry + raw
|
||||
n = len(buf) - (len(buf) % 4)
|
||||
carry = buf[n:]
|
||||
if n == 0:
|
||||
continue
|
||||
|
||||
try:
|
||||
x = np.frombuffer(buf[:n], dtype="<f4")
|
||||
if x.size == 0:
|
||||
continue
|
||||
|
||||
# ---- 1) 块能量 + 滑窗平滑 ----
|
||||
energy = float(np.mean(x.astype(np.float64) ** 2))
|
||||
energy_smooth += alpha * (energy - energy_smooth)
|
||||
if energy_smooth < 0.0:
|
||||
energy_smooth = 0.0
|
||||
|
||||
# ---- 2) 跨声道联合响度(两声道能量和) ----
|
||||
now = time.monotonic()
|
||||
base = slot * CH_STRIDE
|
||||
struct.pack_into("<f", mm, base, energy_smooth)
|
||||
struct.pack_into("<d", mm, base + 16, now)
|
||||
joint = energy_smooth + read_peer_energy(mm, other)
|
||||
|
||||
# ---- 3) 目标增益(dB) ----
|
||||
if joint > GATE_E:
|
||||
if silent_blocks > FADE_GAP_BLOCKS: # 长静音后重新起播 → 淡入
|
||||
fade_left = FADE_BLOCKS
|
||||
silent_blocks = 0
|
||||
level_db = 10.0 * math.log10(joint + EPS)
|
||||
want_db = target_db - level_db
|
||||
want_db = max(MAX_CUT, min(MAX_BOOST, want_db))
|
||||
else:
|
||||
silent_blocks += 1
|
||||
want_db = gain_db # 静音段保持当前增益
|
||||
|
||||
# ---- 4) 限速平滑(避免抽气/爆音) ----
|
||||
delta = want_db - gain_db
|
||||
if delta > max_step_db:
|
||||
delta = max_step_db
|
||||
elif delta < -max_step_db:
|
||||
delta = -max_step_db
|
||||
gain_db += delta
|
||||
|
||||
# ---- 5) 峰值保护(快降, 只作用于本块) ----
|
||||
g = 10.0 ** (gain_db / 20.0)
|
||||
peak = float(np.max(np.abs(x))) if x.size else 0.0
|
||||
if peak * g > PEAK_CEIL > 0.0:
|
||||
g = PEAK_CEIL / (peak + EPS)
|
||||
gain_db = 20.0 * math.log10(g + EPS) # 状态跟随, 下块再按限速恢复
|
||||
|
||||
# ---- 6) 施加增益 ----
|
||||
y = x * g
|
||||
if fade_left > 0: # 起播淡入: 线性爬升, 压掉卷积过冲
|
||||
y = y * ((FADE_BLOCKS - fade_left + 1) / float(FADE_BLOCKS))
|
||||
fade_left -= 1
|
||||
out_peak = float(np.max(np.abs(y))) if y.size else 0.0 # ★ 输出峰值(面板用, 真实值)
|
||||
if y.dtype != np.float32:
|
||||
y = y.astype("<f4")
|
||||
|
||||
# ---- 7) 写回 + 状态(web 面板读这块内存画 4 路电平/推子) ----
|
||||
_n += 1
|
||||
struct.pack_into("<f", mm, base + 4, energy) # 本块能量(快响应)
|
||||
struct.pack_into("<f", mm, base + 8, peak) # 本块峰值(输入)
|
||||
struct.pack_into("<f", mm, base + 12, gain_db) # 实际增益 -> 推子
|
||||
struct.pack_into("<f", mm, base + 28, out_peak) # ★ 本块输出峰值 -> 面板"处理后电平"
|
||||
struct.pack_into("<I", mm, base + 24, _n & 0xFFFFFFFF)
|
||||
if DEBUG and _n % 40 == 0:
|
||||
dbg("slot=%d blk=%d raw=%.6f smooth=%.8f joint=%.8f lvl=%.2f want=%.2f g=%.2f peak=%.4f" % (
|
||||
slot, _n, energy, energy_smooth, joint,
|
||||
10.0 * math.log10(joint + EPS) if joint > 0 else -999.0,
|
||||
want_db, gain_db, peak))
|
||||
stdout.write(y.tobytes())
|
||||
stdout.flush()
|
||||
except Exception:
|
||||
# ★ 任何异常都不能让进程退出(退出=静音), 退回纯透传
|
||||
try:
|
||||
stdout.write(buf[:n] if n else raw)
|
||||
stdout.flush()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
main()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -0,0 +1,128 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user