191 lines
9.1 KiB
Python
191 lines
9.1 KiB
Python
#!/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())
|