2026-09-12 23:52:43 +08:00
|
|
|
|
#!/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
|
2026-09-13 23:24:22 +08:00
|
|
|
|
import math
|
2026-09-12 23:52:43 +08:00
|
|
|
|
import subprocess
|
2026-09-13 16:38:00 +08:00
|
|
|
|
import json
|
2026-09-12 23:52:43 +08:00
|
|
|
|
|
|
|
|
|
|
_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")
|
2026-09-13 00:00:46 +08:00
|
|
|
|
|
2026-09-12 23:52:43 +08:00
|
|
|
|
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"]
|
|
|
|
|
|
|
2026-09-13 16:38:00 +08:00
|
|
|
|
# 房间混响(镜像法生成的真实房间 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 18:06:52 +08:00
|
|
|
|
# 响度归一化(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" ]',
|
|
|
|
|
|
" }",
|
2026-09-13 23:24:22 +08:00
|
|
|
|
f" capture.props = {{ {VOL_PROP}{PRIO_PROP}node.name = cinema_spatial_up_sink "
|
2026-09-13 18:06:52 +08:00
|
|
|
|
"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 ] }",
|
|
|
|
|
|
" }",
|
|
|
|
|
|
" }",
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-09-13 16:38:00 +08:00
|
|
|
|
|
|
|
|
|
|
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" }}',
|
|
|
|
|
|
]
|
|
|
|
|
|
|
2026-09-12 23:52:43 +08:00
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-13 00:00:46 +08:00
|
|
|
|
# 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 ""
|
|
|
|
|
|
|
2026-09-13 07:10:53 +08:00
|
|
|
|
# 虚拟声卡的音量。★ 必须写进配置, 否则重建后不保留:
|
|
|
|
|
|
# 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 ""
|
2026-09-13 23:24:22 +08:00
|
|
|
|
# ★★ 虚拟声卡必须带**高于所有物理设备**的 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} "
|
2026-09-13 07:10:53 +08:00
|
|
|
|
|
2026-09-13 00:00:46 +08:00
|
|
|
|
|
2026-09-12 23:52:43 +08:00
|
|
|
|
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"):
|
2026-09-13 23:24:22 +08:00
|
|
|
|
# ★★ 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))
|
2026-09-12 23:52:43 +08:00
|
|
|
|
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(" ")
|
2026-09-13 16:38:00 +08:00
|
|
|
|
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"'
|
2026-09-12 23:52:43 +08:00
|
|
|
|
L += [" ]", " links = ["]
|
|
|
|
|
|
L += hrir_links(" ")
|
2026-09-13 16:38:00 +08:00
|
|
|
|
if on:
|
|
|
|
|
|
L += reverb_links(" ")
|
|
|
|
|
|
if mono:
|
|
|
|
|
|
L += mono_links(" ", srcs)
|
2026-09-12 23:52:43 +08:00
|
|
|
|
L += [" ]",
|
|
|
|
|
|
' inputs = [ "cpFL:In" "cpFR:In" "cpFC:In" "cpLFE:In" "cpBL:In" "cpBR:In" ]',
|
2026-09-13 16:38:00 +08:00
|
|
|
|
f' outputs = [ {outs} ]', " }",
|
2026-09-13 23:24:22 +08:00
|
|
|
|
f" capture.props = {{ {VOL_PROP}{PRIO_PROP}node.name = cinema_spatial_sink media.class = Audio/Sink "
|
2026-09-12 23:52:43 +08:00
|
|
|
|
"audio.channels = 6 audio.position = [ FL FR FC LFE BL BR ] }",
|
2026-09-13 00:00:46 +08:00
|
|
|
|
f' playback.props = {{ node.name = cinema_spatial_out {TARGET_PROP}'
|
2026-09-13 16:38:00 +08:00
|
|
|
|
+ out_props(mono),
|
2026-09-12 23:52:43 +08:00
|
|
|
|
" }", " }"]
|
|
|
|
|
|
return L
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def sink_upmix() -> list[str]:
|
2026-09-13 00:00:46 +08:00
|
|
|
|
|
2026-09-12 23:52:43 +08:00
|
|
|
|
L = [" { name = libpipewire-module-filter-chain", " flags = [ nofail ]", " args = {",
|
|
|
|
|
|
' node.description = "电影院空间音频 (立体声上混)"',
|
2026-09-13 18:06:52 +08:00
|
|
|
|
' 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 }",
|
2026-09-12 23:52:43 +08:00
|
|
|
|
" { 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(" ")
|
2026-09-13 16:38:00 +08:00
|
|
|
|
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"'
|
2026-09-13 18:06:52 +08:00
|
|
|
|
L += [" ]", " links = ["]
|
|
|
|
|
|
L += [' { output = "copyFL:Out" input = "mixF:In 1" }',
|
2026-09-12 23:52:43 +08:00
|
|
|
|
' { 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(" ")
|
2026-09-13 16:38:00 +08:00
|
|
|
|
if on:
|
|
|
|
|
|
L += reverb_links(" ")
|
|
|
|
|
|
if mono:
|
|
|
|
|
|
L += mono_links(" ", srcs)
|
2026-09-12 23:52:43 +08:00
|
|
|
|
L += [" ]",
|
|
|
|
|
|
' inputs = [ "copyFL:In" "copyFR:In" ]',
|
2026-09-13 16:38:00 +08:00
|
|
|
|
f' outputs = [ {outs} ]', " }",
|
2026-09-13 18:06:52 +08:00
|
|
|
|
# ★ 这一级不再是默认 sink —— sink 名让给前面的归一化实例, 由它的
|
|
|
|
|
|
# node.target = "cinema_spatial_up_raw" 把流推到这里。
|
2026-09-13 23:24:22 +08:00
|
|
|
|
" capture.props = { node.volume = 1.0 node.name = cinema_spatial_up_raw "
|
2026-09-13 18:06:52 +08:00
|
|
|
|
"media.class = Audio/Sink audio.channels = 2 audio.position = [ FL FR ] }",
|
2026-09-13 00:00:46 +08:00
|
|
|
|
f' playback.props = {{ node.name = cinema_spatial_up_out {TARGET_PROP}'
|
2026-09-13 16:38:00 +08:00
|
|
|
|
+ out_props(mono),
|
2026-09-12 23:52:43 +08:00
|
|
|
|
" }", " }"]
|
|
|
|
|
|
return L
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-13 23:24:22 +08:00
|
|
|
|
# ★★ 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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-09-12 23:52:43 +08:00
|
|
|
|
def main() -> int:
|
2026-09-13 16:38:00 +08:00
|
|
|
|
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))
|
2026-09-12 23:52:43 +08:00
|
|
|
|
os.makedirs(os.path.dirname(OUT), exist_ok=True)
|
|
|
|
|
|
body: list[str] = ["# 电影院空间音频 自动生成, 改 IR/增益请改 生成配置.py 重跑", "context.modules = ["]
|
|
|
|
|
|
body += sink_51()
|
2026-09-13 18:06:52 +08:00
|
|
|
|
# 响度归一化: 独立一级, 串在上混图前面(开关见 ~/.local/state/cinema-spatial/loudness.json)
|
|
|
|
|
|
if loudness_cfg()[0]:
|
|
|
|
|
|
body += loudness_instance()
|
2026-09-12 23:52:43 +08:00
|
|
|
|
body += sink_upmix()
|
|
|
|
|
|
body += ["]", ""]
|
|
|
|
|
|
with open(OUT, "w", encoding="utf-8") as fh:
|
|
|
|
|
|
fh.write("\n".join(body))
|
2026-09-13 23:24:22 +08:00
|
|
|
|
prune_duplicate_confs()
|
2026-09-12 23:52:43 +08:00
|
|
|
|
print(f"已生成 {OUT} ({len(body)} 行)")
|
2026-09-13 00:00:46 +08:00
|
|
|
|
print(f"输出目标: {TARGET_PROP.replace('node.target = ', '').strip() or '自动(WirePlumber 路由到默认输出)'}")
|
2026-09-12 23:52:43 +08:00
|
|
|
|
return 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
|
raise SystemExit(main())
|