diff --git a/README.md b/README.md index 08f1dc2..8fbe8b4 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,58 @@ # Collaplex 音效 -耳机虚拟环绕(HRTF)音效链路。 +耳机方向的空间音效链路(PipeWire)。 + +当前链路(2026-09-19 固化): + +``` +全局音频(应用输出) → collaplex_vsink(虚拟声卡) → 响度归一化(pipe DSP) → 数字输出(S/PDIF) +``` + +## 安装 + +把项目里的定义部署到 PipeWire 用户级: + +```sh +bash 脚本/安装.sh +``` + +脚本会:探测数字输出设备 → 渲染并安装链路配置到 `~/.config/pipewire/pipewire.conf.d/` → +装 pipe 插件入口到 `~/.local/bin/` → 重启 PipeWire → 自检(节点 / DSP 进程 / 默认输出)。 + +**设备名变化或项目挪了位置**,重跑这个脚本即可 —— 定义在项目里,部署可复现。 + +## 开关与调参 + +- 实时状态(推子 / 响度 / 输出电平):`python3 dsp/状态.py` +- 目标电平(DSP 每秒重读,改完即时生效,不用重建链路): + +```sh +echo '{"on": true, "target_db": -16}' > ~/.local/state/cinema-spatial/loudness.json +``` + +## 文件 + +| 路径 | 作用 | +|---|---| +| `dsp/loudness_norm.py` | 响度归一化 DSP(pipe 插件载荷;左右各一进程,flock 共享能量算同一增益,声像不漂) | +| `dsp/状态.py` | 读共享内存看实时推子 / 电平 | +| `bin/collaplex-loudness-norm` | pipe 插件入口模板(纯 ASCII 路径;参数写在这里,配置里只给路径) | +| `pipewire/10-collaplex-vsink.conf` | 链路定义模板(`__TARGET__` 数字输出、`__WRAPPER__` 入口) | +| `脚本/安装.sh` | 渲染 + 部署 + 重启 + 自检 | + +## 实测(2026-09-19 固化后复测) + +| 输入素材 | DSP 推子 | 数字输出端实测 | +|---|---|---| +| −12 dBFS | −8.37 dB | −19.31 dBFS | +| −32 dBFS | −3.58 dB | −18.97 dBFS | + +输入差 20.00 dB → 输出端实测差 **0.34 dB**;推子方向相反(响的压、轻的抬), +目标 −16 dBFS,限速 6 dB/s。 + +## 注意 + +- pipe 插件的 `command` 只吃「程序 + 一个参数」,且**不能用中文路径**(否则节点照建、 + 音频绕过、零日志)。所以配置里给的是 `bin/` 里的 ASCII wrapper,由它转发到中文项目路径。 +- `node.target` 写的是设备名,设备重连/换口后可能变;重跑 `脚本/安装.sh` 修正。 +- 播放中的实测要抓 sink 的 monitor:`pw-record --target -P '{ stream.capture.sink = true }'`。 diff --git a/bin/collaplex-loudness-norm b/bin/collaplex-loudness-norm new file mode 100644 index 0000000..fe74c2d --- /dev/null +++ b/bin/collaplex-loudness-norm @@ -0,0 +1,15 @@ +#!/bin/sh +# Collaplex 响度归一化 — pipe 插件入口 (本文件由 脚本/安装.sh 生成, 占位符 __DSP__) +# +# ★ pipe 插件的 command 只支持「程序 + 一个参数」(shebang 式 exec): +# `env A=B python3 x.py` 会报 "use -[v]S to pass options in shebang lines", +# 所以参数全部写在这里, 配置里只给本脚本的路径。 +# ★ 本脚本路径必须**纯 ASCII**: pipe 插件给中文路径会一声不吭(节点照建、 +# 音频绕过、零日志)。脚本内部再去 exec 中文路径的 DSP 是没问题的。 +export LN_TARGET_DB=-16 +export LN_GATE_DB=-50 +export LN_MAX_BOOST=20 +export LN_MAX_CUT=20 +export LN_SLEW_DB_S=6 +export LN_RATE=48000 +exec /usr/bin/python3 "__DSP__" diff --git a/dsp/loudness_norm.py b/dsp/loudness_norm.py new file mode 100644 index 0000000..ede138f --- /dev/null +++ b/dsp/loudness_norm.py @@ -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(" 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(" 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(" 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(" 推子 + struct.pack_into(" 面板"处理后电平" + struct.pack_into(" 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 diff --git a/dsp/状态.py b/dsp/状态.py new file mode 100644 index 0000000..8d07400 --- /dev/null +++ b/dsp/状态.py @@ -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(" 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(" 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() diff --git a/pipewire/10-collaplex-vsink.conf b/pipewire/10-collaplex-vsink.conf new file mode 100644 index 0000000..eb426b2 --- /dev/null +++ b/pipewire/10-collaplex-vsink.conf @@ -0,0 +1,41 @@ +# Collaplex 虚拟声卡 + 响度归一化 —— 本文件由 脚本/安装.sh 生成(占位符: __TARGET__ / __WRAPPER__) +# 链路: 全局音频(应用输出) -> collaplex_vsink(虚拟声卡) -> 响度归一化(pipe DSP) -> 数字输出(S/PDIF) +context.modules = [ + { name = libpipewire-module-filter-chain + flags = [ nofail ] + args = { + node.description = "Collaplex 虚拟声卡 (响度归一化)" + media.name = "Collaplex Virtual Sink" + filter.graph = { + nodes = [ + { type = builtin label = copy name = c1L } + { type = builtin label = copy name = c1R } + { type = builtin label = pipe name = lnL + config = { command = "__WRAPPER__" } } + { type = builtin label = pipe name = lnR + config = { command = "__WRAPPER__" } } + { 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" ] + } + capture.props = { + media.class = Audio/Sink + node.name = collaplex_vsink + audio.position = [ FL FR ] + } + playback.props = { + node.name = collaplex_vsink_out + audio.position = [ FL FR ] + node.target = "__TARGET__" + } + } + } +] diff --git a/脚本/安装.sh b/脚本/安装.sh new file mode 100755 index 0000000..8cfac70 --- /dev/null +++ b/脚本/安装.sh @@ -0,0 +1,93 @@ +#!/bin/bash +# Collaplex 音效 — 把当前链路固化装到 PipeWire(用户级) +# +# 链路: 全局音频(应用输出) -> collaplex_vsink(虚拟声卡) -> 响度归一化(pipe DSP) -> 数字输出 +# +# 装什么: +# $HOME/.config/pipewire/pipewire.conf.d/10-collaplex-vsink.conf 链路定义(由本脚本渲染) +# $HOME/.local/bin/collaplex-loudness-norm pipe 插件入口(ASCII 路径) +# 设备名换了 / 项目挪了位置 -> 重跑本脚本即可(这就是"固化"的意义: 定义在项目里, 部署可复现) +set -eu + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +CONF_DST="$HOME/.config/pipewire/pipewire.conf.d/10-collaplex-vsink.conf" +WRAP_DST="$HOME/.local/bin/collaplex-loudness-norm" +PY=/usr/bin/python3 + +echo "项目根: $ROOT" + +# ---- 1. 探测数字输出设备(跳过 HDMI/显卡) ---- +TARGET="$("$PY" - <<'EOF' +import json, subprocess +d = json.loads(subprocess.run(["pw-dump"], capture_output=True, text=True).stdout) +sinks = [] +for n in d: + if n.get("type") != "PipeWire:Interface:Node": + continue + p = n.get("info", {}).get("props", {}) or {} + if p.get("media.class") != "Audio/Sink": + continue + nm = p.get("node.name") or "" + if "hdmi" in nm.lower() or "gb206" in nm.lower() or "radeon" in nm.lower(): + continue # 显卡 HDMI, 不是我们要的数字输出 + sinks.append(nm) +dig = [s for s in sinks if "iec958" in s or "spdif" in s.lower()] +print(dig[0] if dig else "") +EOF +)" +if [ -z "$TARGET" ]; then + echo "✗ 没找到数字输出(S/PDIF)设备。当前可用 sink:" + wpctl status | sed -n '/Sinks:/,/Sources:/p' + exit 1 +fi +echo "数字输出: $TARGET" + +# ---- 2. 渲染并安装链路定义 ---- +mkdir -p "$(dirname "$CONF_DST")" "$(dirname "$WRAP_DST")" +sed -e "s|__TARGET__|$TARGET|" -e "s|__WRAPPER__|$WRAP_DST|" \ + "$ROOT/pipewire/10-collaplex-vsink.conf" > "$CONF_DST" +echo "已装配置: $CONF_DST" + +# ---- 3. 渲染并安装 pipe 插件入口(指向项目内的 DSP) ---- +sed -e "s|__DSP__|$ROOT/dsp/loudness_norm.py|" \ + "$ROOT/bin/collaplex-loudness-norm" > "$WRAP_DST" +chmod 755 "$WRAP_DST" +echo "已装入口: $WRAP_DST -> $ROOT/dsp/loudness_norm.py" + +# ---- 4. 让 PipeWire 重新加载 ---- +systemctl --user restart pipewire pipewire-pulse wireplumber +sleep 6 + +# ---- 5. 自检 ---- +echo +echo "=== 自检 ===" +N=$("$PY" - <<'EOF' +import json, subprocess +d = json.loads(subprocess.run(["pw-dump"], capture_output=True, text=True).stdout) +n = [x for x in d if x.get("type") == "PipeWire:Interface:Node" + and (x.get("info", {}).get("props", {}) or {}).get("node.name") == "collaplex_vsink"] +print(len(n)) +EOF +) +[ "$N" = "1" ] && echo " ✓ 虚拟声卡节点 collaplex_vsink 在" || echo " ✗ 虚拟声卡节点缺失(个数=$N)" + +DP=$(for p in /proc/[0-9]*; do c=$(tr '\0' ' ' < "$p/cmdline" 2>/dev/null) || continue; case "$c" in *dsp/loudness_norm.py*) echo "$p";; esac; done | wc -l) +[ "$DP" = "2" ] && echo " ✓ 响度归一化 DSP 进程 2 个(左右各一)" || echo " ✗ DSP 进程数=$DP(应为 2)" + +ID=$("$PY" - <<'EOF' +import json, subprocess +d = json.loads(subprocess.run(["pw-dump"], capture_output=True, text=True).stdout) +for x in d: + if x.get("type") == "PipeWire:Interface:Node": + if (x.get("info", {}).get("props", {}) or {}).get("node.name") == "collaplex_vsink": + print(x["id"]) +EOF +) +if [ -n "$ID" ]; then + wpctl set-default "$ID" >/dev/null 2>&1 || true + echo " ✓ 默认输出已设为 collaplex_vsink(id $ID)" +fi + +echo +echo "开关/目标: python3 \"$ROOT/dsp/状态.py\" # 看实时推子" +echo "改目标电平: echo '{\"on\": true, \"target_db\": -16}' > ~/.local/state/cinema-spatial/loudness.json"