Files

334 lines
14 KiB
Python

#!/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", "600")) # 兜底: 连续无数据这么多秒才退出
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
g_prev = 1.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
# 父进程(filter-chain)死后怎么退出: 用"启动时的 ppid"做基准比对。
# 为什么不用别的判据:
# 1) filter-chain 死后管道写端不一定关闭, read() 不一定返回 EOF;
# 2) 孤儿会被 systemd(subreaper)收养成 session manager 的子进程, ppid 不一定是 1,
# 所以写死 "ppid==1" 不可靠 —— 但**跟启动时的 ppid 比**可靠。
# ★ 绝不用"若干秒无数据"当判据: 音频流本来就有静默期(视频间隙/暂停/切歌),
# 按它退出会在正常使用中自断链路, 表现为"刷几条视频后没声音, 刷新才恢复"。
_ppid0 = os.getppid()
while True:
try:
ready, _, _ = select.select([stdin], [], [], 1.0)
except Exception:
ready = [stdin]
if not ready:
idle += 1
if os.getppid() != _ppid0:
break
if idle >= IDLE_LIMIT: # 兜底(默认 600s), 防止极长静默留下孤儿
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) 施加增益 ----
# 块内逐样本线性过渡: 整块共用一个增益会在块边界留下台阶(zipper 噪声),
# 表现为中低频的微弱谐波。用"上一块增益 -> 本块增益"的斜坡把它抹平。
y = x * np.linspace(g_prev, g, x.size, dtype=np.float64)
g_prev = 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