Files
edgevoid d8c3a27821 v1.3.0: 离线渲染(音频/视频→空间音频FLAC, 渲染锁+文件就绪检查) + 组件看门狗(30s核对期望状态并自愈)
- 离线渲染.py: pan上混5.1 → sofalizer(H4 SOFA) → 房间IR(afir) → loudnorm → 96k FLAC
  视频自动取音轨; 已是5.1的片源不重上混; 实测约 4.4 倍速
  两道锁: (1)渲染锁 —— 一轮任务期间拒绝新任务, 不排队堆积
          (2)文件就绪检查 —— 相隔1.2s两次stat大小/时间不变且ffprobe能读, 未落盘的跳过
  三个入口: 命令行 / 面板「离线渲染」区(拖拽或贴路径, 实时进度+日志+取消) / 右键「打开方式」
- 音频守护.py + config/组件配置单.json: 30s 核对 DSP进程数与槽位新鲜度/链输出连线/
  默认设备/虚拟声卡优先级/中间级音量/节点份数/面板/物理设备静音, 扫到就修
  (能温和就温和, 链路级才重建), 同类问题 90s 冷却, 连续 3 次修不好转只报警
- 组件配置单.md: 链路拓扑 + 组件表 + 六个必须 + 自愈表
- 面板: 控制滑块从图表卡片独立成「控制」卡片; 数字/模拟输出音量条拉长到各占一半
  (合计 = 虚拟声卡开关卡片总高); 新增离线渲染区
- 修: DSP 孤儿抢共享槽位导致重建后无声(claim_slot 抢不到槽位即退出 + 重建前收旧 DSP)
- 修: 打包会把正在用的 PipeWire 配置移开(prune_duplicate_confs 只在自己写到生效目录时清理)
- 修: 离线渲染的 SOFA/混响IR 改为多路径探测(开发目录与 deb 包内布局不同)
- .gitignore: 保留 reverb/ 房间IR(离线渲染要卷积的素材)
2026-09-13 23:24:22 +08:00

553 lines
26 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""生成 PipeWire filter-chain 配置(2026-09-12
两个虚拟声卡:
1) cinema_spatial_sink 5.1 输入 -> 双耳 HRTF (多声道片源)
2) cinema_spatial_up_sink 立体声输入 -> 上混5.1 -> HRTF (浏览器/音乐等, 全局默认用这个)
上混链照抄 PipeWire 自带的 sink-upmix-5.1-filter.conf(矩阵式):
FL/FR 直通; FC = (FL+FR)*0.707 低通; LFE = 同源再低通+衰减;
RL/RR = (FL-FR)*0.707 反相 + 希尔伯特卷积(产生相位差/延迟)
"""
import os
import math
import subprocess
import json
_ROOT = os.path.dirname(os.path.abspath(__file__))
# IR 目录: 环境变量优先(打包/安装场景指 /usr/share/cinema-spatial/hrir),
# 否则用项目内的软链 hrir/current (开发时换 HRTF 只改软链)。
IR = os.environ.get("CINEMA_SPATIAL_IR") or os.path.join(_ROOT, "hrir", "current")
OUT = os.environ.get("CINEMA_SPATIAL_OUT") or os.path.expanduser(
"~/.config/pipewire/pipewire.conf.d/90-cinema-spatial.conf"
)
DIRS: list[str] = ["FL", "FR", "FC", "LFE", "BL", "BR"]
# 房间混响(镜像法生成的真实房间 IR; 生成器在 声学追踪引擎/src/镜像法IR.py)
REVERB_IR = os.environ.get("CINEMA_SPATIAL_REVERB_IR") or os.path.join(
_ROOT, "reverb", "房间混响IR-96k.wav")
REVERB_STATE = os.path.expanduser("~/.local/state/cinema-spatial/reverb.json")
# 采样参数(HRTF 模型 / tap 数 / 采样率) —— 运行时状态, 由 CLI「参数」写
PARAMS = os.path.expanduser("~/.local/state/cinema-spatial/params.json")
CLOCK_OUT = os.environ.get("CINEMA_SPATIAL_CLOCK") or os.path.expanduser(
"~/.config/pipewire/pipewire.conf.d/91-clock.conf")
# 单声道模式(单声道蓝牙音响/耳机): 状态文件三态, 缺省自动判目标设备声道数
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}{PRIO_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:
"""是否把链的输出合并成单声道.
为什么需要: 单声道蓝牙音响只有 1 个 MONO 端口, 链若坚持输出 FL/FR 就链接失败 →
设备掉线 → 回退守卫滚回 -> 之后一直连不上(2026-09-13 老板实测)。
判定顺序: 环境变量 CINEMA_SPATIAL_MONO > 状态文件 {"on": bool} > 自动(目标设备 1 声道)。
"""
env = os.environ.get("CINEMA_SPATIAL_MONO")
if env in ("0", "1"):
return env == "1"
try:
with open(MONO_STATE, encoding="utf-8") as fh:
d = json.load(fh)
if isinstance(d, dict) and isinstance(d.get("on"), bool):
return bool(d["on"])
except (OSError, ValueError):
pass
return target_channels() == 1
def target_name() -> str:
"""node.target 里的设备名(为空 = 交给 WirePlumber 路由)."""
return TARGET_PROP.split('"')[1] if '"' in TARGET_PROP else ""
def target_channels() -> int:
"""目标设备有几个声道(pw-dump 读 audio.channels; 读不到按 2)."""
tgt = target_name()
if not tgt:
return 2
try:
raw = subprocess.run(["pw-dump"], capture_output=True, text=True, timeout=8).stdout
for o in json.loads(raw):
pr = ((o.get("info") or {}).get("props") or {})
if pr.get("node.name") == tgt:
return int(pr.get("audio.channels") or 2)
except (OSError, ValueError, subprocess.SubprocessError):
pass
return 2
def mono_block(indent: str) -> list[str]:
"""单声道合并级: L/R 各 -6dB 相加(避免相关信号 +6dB 溢出)."""
return [f'{indent}{{ type = builtin label = mixer name = monoOut '
'control = { "Gain 1" = 0.5 "Gain 2" = 0.5 } }']
def mono_links(indent: str, srcs: tuple[str, str]) -> list[str]:
a, b = srcs
return [f'{indent}{{ output = "{a}" input = "monoOut:In 1" }}',
f'{indent}{{ output = "{b}" input = "monoOut:In 2" }}']
def out_props(mono: bool) -> str:
"""链输出节点的声道属性."""
if mono:
return "audio.channels = 1 audio.position = [ MONO ] }"
return "audio.channels = 2 audio.position = [ FL FR ] }"
def params_cfg() -> dict[str, object]:
"""读采样参数; 缺省/坏文件都当空(→ 回落到 hrir/current 软链)."""
try:
with open(PARAMS, encoding="utf-8") as fh:
d = json.load(fh)
except (OSError, ValueError):
return {}
return d if isinstance(d, dict) else {}
def ir_dir() -> str:
"""选 IR 目录: 环境变量 > 参数里的 taps-<N> > 参数里的模型名 > 软链 hrir/current."""
env = os.environ.get("CINEMA_SPATIAL_IR")
if env:
return env
p = params_cfg()
taps = int(p.get("taps") or 0) # type: ignore[arg-type]
cut = os.path.join(_ROOT, "hrir", "taps-%d" % taps)
if taps and os.path.isdir(cut):
return cut
name = str(p.get("hrtf") or "")
if name and os.path.isdir(os.path.join(_ROOT, "hrir", name)):
return os.path.join(_ROOT, "hrir", name)
return os.path.join(_ROOT, "hrir", "current")
def write_clock(rate: int) -> str:
"""写用户级主时钟(同名覆盖 deb 装的 /usr/share 那份, 不用 sudo)."""
txt = (
"# 主时钟 %d Hz —— 由 空间音频 参数 rate 生成\n"
"#\n"
"# 为什么必须显式写: PipeWire 的 convolver 按\"当前时钟率\"重采样 IR,\n"
"# 时钟若低于 IR 的采样率, 等于白换高采样率 IR。\n"
"context.properties = {\n"
" default.clock.rate = %d\n"
" default.clock.allowed-rates = [ %d ]\n"
"}\n" % (rate, rate, rate))
os.makedirs(os.path.dirname(CLOCK_OUT), exist_ok=True)
with open(CLOCK_OUT, "w", encoding="utf-8") as fh:
fh.write(txt)
return CLOCK_OUT
def reverb_cfg() -> tuple[bool, float]:
"""(是否开混响, 湿量) —— 读运行时状态文件; 缺省关, 没有 IR 也当关."""
if not os.path.exists(REVERB_IR):
return False, 0.0
try:
with open(REVERB_STATE, encoding="utf-8") as fh:
d = json.load(fh)
except (OSError, ValueError):
return False, 0.0
wet = float(d.get("wet") or 0.0)
on = bool(d.get("on")) and wet > 0.001
return on, max(0.0, min(wet, 1.0))
def reverb_block(indent: str, wet: float) -> list[str]:
"""混响级: HRTF 输出 -> 卷积房间 IR -> 与干信号按湿量混合.
dry 走 Gain 1(恒 1.0), wet 走 Gain 2(湿量) —— 湿量是生成期常量, 改湿量=重新生成.
"""
return [
f'{indent}{{ type = builtin label = convolver name = revL config = {{ filename = "{REVERB_IR}" }} }}',
f'{indent}{{ type = builtin label = convolver name = revR config = {{ filename = "{REVERB_IR}" }} }}',
f'{indent}{{ type = builtin label = mixer name = wetL control = {{ "Gain 1" = 1.0 "Gain 2" = {wet:.4f} }} }}',
f'{indent}{{ type = builtin label = mixer name = wetR control = {{ "Gain 1" = 1.0 "Gain 2" = {wet:.4f} }} }}',
]
def reverb_links(indent: str) -> list[str]:
"""干路 mixL/mixR 直入混音器, 湿路经房间 IR 卷积入混音器."""
return [
f'{indent}{{ output = "mixL:Out" input = "revL:In" }}',
f'{indent}{{ output = "mixR:Out" input = "revR:In" }}',
f'{indent}{{ output = "mixL:Out" input = "wetL:In 1" }}',
f'{indent}{{ output = "revL:Out" input = "wetL:In 2" }}',
f'{indent}{{ output = "mixR:Out" input = "wetR:In 1" }}',
f'{indent}{{ output = "revR:Out" input = "wetR:In 2" }}',
]
def default_sink_name() -> str:
"""当前默认物理输出的 node.name —— 必须是物理设备。
切换到虚拟声卡之后 @DEFAULT_AUDIO_SINK@ 会指向虚拟声卡自己, 那会让
node.target 变成自我循环, 所以这种情况要去 Sinks 列表里挑第一个物理输出。
"""
def props(cmd: list[str]) -> str:
try:
out = subprocess.run(cmd, capture_output=True, text=True, timeout=10).stdout
except Exception:
return ""
for line in out.splitlines():
if "node.name" in line and '"' in line:
return line.split('"')[1]
return ""
cur = props(["wpctl", "inspect", "@DEFAULT_AUDIO_SINK@"])
if cur and "cinema_spatial" not in cur:
return cur
try:
status = subprocess.run(["wpctl", "status"], capture_output=True, text=True, timeout=10).stdout
except Exception:
return ""
in_sinks = False
first = ""
for line in status.splitlines():
if "Sinks:" in line:
in_sinks = True
continue
if "Sources:" in line:
break
if not in_sinks or "cinema_spatial" in line:
continue
for tok in line.split():
if tok.endswith(".") and tok[:-1].isdigit():
name = props(["wpctl", "inspect", tok[:-1]])
if not name or "cinema_spatial" in name:
continue
if "usb-" in name:
return name # USB 设备优先(耳机/外置声卡)
if not first:
first = name
return first
# node.target —— filter-chain 的输出端必须送到"真实设备":
# - @DEFAULT_SINK@ 这类 pactl 占位符 PipeWire 不认(会当成设备名, 结果连到别的设备)
# - 完全不写则 WirePlumber 可能连错(实测连到 HDMI)
# 所以默认探测物理输出(优先 USB), 换设备后重跑本脚本即可。
# 也可 export CINEMA_SPATIAL_TARGET=<node.name> 强制指定。
_TARGET = os.environ.get("CINEMA_SPATIAL_TARGET") or default_sink_name()
TARGET_PROP = f'node.target = "{_TARGET}" ' if _TARGET else ""
# 虚拟声卡的音量。★ 必须写进配置, 否则重建后不保留:
# filter-chain 重建时新节点没有历史音量, 会回到 1.0 → 整链直接削波爆音。
# 0.25 ≈ -12dB, 给电影那种高峰值因子瞬态留足余量(见 README 第 10、11 条)。
VOLUME = os.environ.get("CINEMA_SPATIAL_VOLUME", "0.25")
VOL_PROP = f"node.volume = {VOLUME} " if VOLUME else ""
# ★★ 虚拟声卡必须带**高于所有物理设备**的 priority.session, 否则默认节点会被抢走:
# 实测本机 HDMI(pro-output-3) = 1196、USB EDIFIER = 1108、板载模拟 = 1009,
# 而虚拟声卡原本**没有这个属性**(None) → 每次"重新选择默认节点"(显示器唤醒、
# 设备插拔、WirePlumber 重启)都是 HDMI 赢; 从 GNOME 手动选回来, 下一个事件又抢走
# (老板报"虚拟声卡老是被 hdmi 抢走, 我点都点不回来")。设成 2000 就压过全部物理口。
SINK_PRIO = 2000
PRIO_PROP = f"priority.session = {SINK_PRIO} "
def hrir_block(indent: str) -> list[str]:
"""12 个卷积器 + 6 个分发 + 左右耳混音。输入端口: cp<dir>:In"""
L: list[str] = []
for d in DIRS:
for e in ("L", "R"):
L.append(f'{indent}{{ type = builtin label = convolver name = c{d}_{e} '
f'config = {{ filename = "{IR}/{d}_{e}.wav" }} }}')
for d in DIRS:
L.append(f'{indent}{{ type = builtin label = copy name = cp{d} }}')
for e in ("L", "R"):
# ★★ 6 路相加必须做**能量归一**(1/sqrt(6) = -7.8 dB), 不能每路都 1。
# 2026-09-13 实测: 每路 gain=1 时上混链净增益 +6 dB —— 归一化 DSP 已经把
# 输出压在峰值保护线(-0.13 dBFS), 这里再抬 6 dB 就顶穿满刻度(起播瞬间实测
# +4.2 ~ +7.8 dBFS, 就是老板看到的"电平怎么都顶满")。
# 1/sqrt(6) 是"不相关信号相加"的能量守恒系数, 净增益落到 -1.8 dB, 留出余量。
g6 = "%.4f" % (1.0 / math.sqrt(6.0))
gains = " ".join(f'"Gain {i}" = {g6}' for i in range(1, 7))
L.append(f'{indent}{{ type = builtin label = mixer name = mix{e} control = {{ {gains} }} }}')
return L
def hrir_links(indent: str) -> list[str]:
L: list[str] = []
for d in DIRS:
for e in ("L", "R"):
L.append(f'{indent}{{ output = "cp{d}:Out" input = "c{d}_{e}:In" }}')
for i, d in enumerate(DIRS, start=1):
L.append(f'{indent}{{ output = "c{d}_L:Out" input = "mixL:In {i}" }}')
for i, d in enumerate(DIRS, start=1):
L.append(f'{indent}{{ output = "c{d}_R:Out" input = "mixR:In {i}" }}')
return L
def sink_51() -> list[str]:
L = [" { name = libpipewire-module-filter-chain", " flags = [ nofail ]", " args = {",
' node.description = "电影院空间音频 (5.1 虚拟环绕)"',
' media.name = "Cinema Spatial 5.1"', " filter.graph = {", " nodes = ["]
L += hrir_block(" ")
on, wet = reverb_cfg()
outs = '"mixL:Out" "mixR:Out"'
srcs = ("mixL:Out", "mixR:Out")
mono = mono_cfg()
if on:
L += reverb_block(" ", wet)
outs = '"wetL:Out" "wetR:Out"'
srcs = ("wetL:Out", "wetR:Out")
if mono:
L += mono_block(" ")
outs = '"monoOut:Out"'
L += [" ]", " links = ["]
L += hrir_links(" ")
if on:
L += reverb_links(" ")
if mono:
L += mono_links(" ", srcs)
L += [" ]",
' inputs = [ "cpFL:In" "cpFR:In" "cpFC:In" "cpLFE:In" "cpBL:In" "cpBR:In" ]',
f' outputs = [ {outs} ]', " }",
f" capture.props = {{ {VOL_PROP}{PRIO_PROP}node.name = cinema_spatial_sink media.class = Audio/Sink "
"audio.channels = 6 audio.position = [ FL FR FC LFE BL BR ] }",
f' playback.props = {{ node.name = cinema_spatial_out {TARGET_PROP}'
+ out_props(mono),
" }", " }"]
return L
def sink_upmix() -> list[str]:
L = [" { name = libpipewire-module-filter-chain", " flags = [ nofail ]", " args = {",
' node.description = "电影院空间音频 (立体声上混)"',
' 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 }",
' { type = builtin label = mixer name = mixF control = { "Gain 1" = 0.707 "Gain 2" = 0.707 } }',
" { type = builtin label = param_eq name = eq_FC_LFE config = {",
" filters1 = [ { type = bq_lowpass freq = 12000 } { type = bq_lowpass freq = 12000 } ]",
" filters2 = [ { type = bq_highshelf freq = 0 gain = -20.0 } "
"{ type = bq_lowpass freq = 120 } { type = bq_lowpass freq = 120 } ] } }",
' { type = builtin label = mixer name = subR control = { "Gain 1" = 0.707 "Gain 2" = -0.707 } }',
' { type = builtin label = convolver name = convRL config = { gain = 1.0 delay = 0.012 filename = "/hilbert" length = 33 } }',
' { type = builtin label = convolver name = convRR config = { gain = -1.0 delay = 0.012 filename = "/hilbert" length = 33 } }',
" # ---- 第二段: 5.1 -> 双耳 HRTF ----"]
L += hrir_block(" ")
on, wet = reverb_cfg()
outs = '"mixL:Out" "mixR:Out"'
srcs = ("mixL:Out", "mixR:Out")
mono = mono_cfg()
if on:
L += reverb_block(" ", wet)
outs = '"wetL:Out" "wetR:Out"'
srcs = ("wetL:Out", "wetR:Out")
if mono:
L += mono_block(" ")
outs = '"monoOut:Out"'
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" }',
' { output = "mixF:Out" input = "eq_FC_LFE:In 1" }',
' { output = "mixF:Out" input = "eq_FC_LFE:In 2" }',
' { output = "copyFL:Out" input = "subR:In 1" }',
' { output = "copyFR:Out" input = "subR:In 2" }',
' { output = "subR:Out" input = "convRL:In" }',
' { output = "subR:Out" input = "convRR:In" }',
" # 上混产出的 6 路接进 HRTF 的分发",
' { output = "copyOFL:Out" input = "cpFL:In" }',
' { output = "copyOFR:Out" input = "cpFR:In" }',
' { output = "eq_FC_LFE:Out 1" input = "cpFC:In" }',
' { output = "eq_FC_LFE:Out 2" input = "cpLFE:In" }',
' { output = "convRL:Out" input = "cpBL:In" }',
' { output = "convRR:Out" input = "cpBR:In" }']
L += hrir_links(" ")
if on:
L += reverb_links(" ")
if mono:
L += mono_links(" ", srcs)
L += [" ]",
' inputs = [ "copyFL:In" "copyFR:In" ]',
f' outputs = [ {outs} ]', " }",
# ★ 这一级不再是默认 sink —— sink 名让给前面的归一化实例, 由它的
# node.target = "cinema_spatial_up_raw" 把流推到这里。
" capture.props = { node.volume = 1.0 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),
" }", " }"]
return L
# ★★ PipeWire 对每个 conf.d 目录里的 *.conf **全都加载**, 不做"用户级覆盖系统级" ——
# 所以"deb 装了一份到 /usr/share/pipewire/pipewire.conf.d/ 之后, 开发时又写了一份
# 用户级" = 同名实例建两遍、节点重名(实测 6 个节点各 2 份), 音频进哪一份由调度决定,
# 常常进了没有下游消费者的那一份 → 整条链静默。症状: "两边看着都在, 就是没声音"。
# (2026-09-13 实测) 写入后顺手把**别处**的同名 conf 挪开: 能改名就改名, root 的只告警。
CONF_DIRS: list[str] = [
"/usr/share/pipewire/pipewire.conf.d",
"/etc/pipewire/pipewire.conf.d",
os.path.expanduser("~/.config/pipewire/pipewire.conf.d"),
]
def _prune_one(name: str, keep: str) -> None:
"""把 keep 之外的同名 conf 挪成 .disabled-dup(root 的挪不动就告警)."""
mine = os.path.realpath(keep)
for d in CONF_DIRS:
p = os.path.join(d, name)
if not os.path.isfile(p) or os.path.realpath(p) == mine:
continue
try:
os.rename(p, p + ".disabled-dup")
print(f" · 移开重复配置 {p} -> {name}.disabled-dup")
except OSError as e:
print(f" ! 重复配置挪不动: {p} ({e.strerror})")
print(" 它会与本配置建出两份同名声卡 = 音频进错实例(没声音)。请执行一次:")
print(f" sudo mv {p} {p}.disabled-dup")
def prune_duplicate_confs() -> None:
"""移开 OUT/CLOCK_OUT 之外的同名 conf.
★ 91-clock.conf 同理: 改采样率写的是用户级那份, 而 deb 装在系统级也有一份 →
两个 default.clock.rate 同时加载 = 时钟不确定。所以两份都要"同名只留一份"。
★★ 但**只有在写真正生效的位置时**才该清理: 若 OUT 落在 CONF_DIRS 之外(打包时
写到临时构建目录、或测试时写到 /tmp), 那用户目录/系统目录里的配置一份都不该动。
2026-09-13 打包正是栽在这里 —— 生成包内 conf 时把用户级和系统级两份都移开,
虚拟声卡整个消失(看门狗先报"链输出没有任何连线, 且找不到物理设备")。
需要强制跳过时设 CINEMA_SPATIAL_KEEP_CONFS=1。
"""
if os.environ.get("CINEMA_SPATIAL_KEEP_CONFS") == "1":
return
live = {os.path.realpath(d) for d in CONF_DIRS}
if os.path.dirname(os.path.realpath(OUT)) not in live:
return
_prune_one(os.path.basename(OUT), OUT)
if os.path.isfile(CLOCK_OUT):
_prune_one(os.path.basename(CLOCK_OUT), CLOCK_OUT)
def main() -> int:
global IR
IR = ir_dir()
_p = params_cfg()
_rate = int(_p.get("rate") or 0) # type: ignore[arg-type]
if _rate:
write_clock(_rate)
print("IR 目录: " + IR)
if _p:
print("采样参数: " + json.dumps(_p, ensure_ascii=False))
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:
fh.write("\n".join(body))
prune_duplicate_confs()
print(f"已生成 {OUT} ({len(body)} 行)")
print(f"输出目标: {TARGET_PROP.replace('node.target = ', '').strip() or '自动(WirePlumber 路由到默认输出)'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())