Files
cinema-spatial/生成配置.py
T

191 lines
9.1 KiB
Python
Raw 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 subprocess
_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"]
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 ""
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"):
gains = " ".join(f'"Gain {i}" = 1' 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(" ")
L += [" ]", " links = ["]
L += hrir_links(" ")
L += [" ]",
' inputs = [ "cpFL:In" "cpFR:In" "cpFC:In" "cpLFE:In" "cpBL:In" "cpBR:In" ]',
' outputs = [ "mixL:Out" "mixR:Out" ]', " }",
" capture.props = { 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}'
"audio.channels = 2 audio.position = [ FL FR ] }",
" }", " }"]
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 = [",
" # ---- 第一段: 立体声矩阵上混成 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(" ")
L += [" ]", " links = [",
' { 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(" ")
L += [" ]",
' inputs = [ "copyFL:In" "copyFR:In" ]',
' outputs = [ "mixL:Out" "mixR:Out" ]', " }",
" capture.props = { node.name = cinema_spatial_up_sink media.class = Audio/Sink "
"audio.channels = 2 audio.position = [ FL FR ] }",
f' playback.props = {{ node.name = cinema_spatial_up_out {TARGET_PROP}'
"audio.channels = 2 audio.position = [ FL FR ] }",
" }", " }"]
return L
def main() -> int:
os.makedirs(os.path.dirname(OUT), exist_ok=True)
body: list[str] = ["# 电影院空间音频 自动生成, 改 IR/增益请改 生成配置.py 重跑", "context.modules = ["]
body += sink_51()
body += sink_upmix()
body += ["]", ""]
with open(OUT, "w", encoding="utf-8") as fh:
fh.write("\n".join(body))
print(f"已生成 {OUT} ({len(body)} 行)")
print(f"输出目标: {TARGET_PROP.replace('node.target = ', '').strip() or '自动(WirePlumber 路由到默认输出)'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())