2026-09-13 06:45:14 +08:00
|
|
|
#!/bin/bash
|
|
|
|
|
# 电影院空间音频 开关 2026-09-12
|
|
|
|
|
# 用法: 空间音频 开 | 开5.1 | 关 | 状态
|
|
|
|
|
# 开 = 立体声上混版(全局推荐; 浏览器/音乐/在线视频等立体声源也会有环绕)
|
|
|
|
|
# 开5.1 = 5.1 直通版(本地多声道片源; 也可以用 mpv-影院)
|
|
|
|
|
#
|
|
|
|
|
# ★ 为什么"开"的时候要做设备自检:
|
|
|
|
|
# filter-chain 的输出端必须显式指向真实设备(node.target)。但 USB 耳机重启/重插后
|
|
|
|
|
# profile 可能变(如 iec958-stereo <-> analog-stereo), 设备名随之改变, 配置里的
|
|
|
|
|
# 旧名字就失效 -> WirePlumber 回落到其它设备(实测落到 HDMI), 声音就跑了。
|
|
|
|
|
# 所以每次"开"都检查一遍: 目标不存在就重新探测并重建配置。
|
|
|
|
|
set -u
|
|
|
|
|
STATE="${XDG_RUNTIME_DIR:-/tmp}/cinema-spatial.prev"
|
|
|
|
|
CONF="${HOME}/.config/pipewire/pipewire.conf.d/90-cinema-spatial.conf"
|
|
|
|
|
for g in "${HOME}/桌面/工作区/实验/空间音频/生成配置.py" "/usr/share/cinema-spatial/生成配置.py"; do
|
|
|
|
|
[ -f "$g" ] && GEN="$g" && break
|
|
|
|
|
done
|
|
|
|
|
GEN="${GEN:-}"
|
|
|
|
|
|
|
|
|
|
num_of() { awk '{for (i = 1; i <= NF; i++) if ($i ~ /^[0-9]+\.$/) {print substr($i, 1, length($i) - 1); exit}}'; }
|
|
|
|
|
id_of() { wpctl status 2>/dev/null | awk -v n="$1" '$0 ~ n {print; exit}' | num_of; }
|
|
|
|
|
cur_id() { wpctl status 2>/dev/null | sed -n '/Sinks:/,/Sources:/p' | grep ' \* ' | num_of; }
|
|
|
|
|
nick() { wpctl inspect "$1" 2>/dev/null | grep -oP 'node\.nick = "\K[^"]+' | head -1; }
|
|
|
|
|
|
|
|
|
|
# 物理输出设备名: 优先 USB, 跳过虚拟声卡与 HDMI/DP 专业输出
|
|
|
|
|
phys_sink() {
|
|
|
|
|
pw-dump 2>/dev/null | python3 -c '
|
|
|
|
|
import sys, json
|
|
|
|
|
try:
|
|
|
|
|
d = json.load(sys.stdin)
|
|
|
|
|
except Exception:
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
c = []
|
|
|
|
|
for o in d:
|
|
|
|
|
if o.get("type") != "PipeWire:Interface:Node":
|
|
|
|
|
continue
|
|
|
|
|
p = o.get("info", {}).get("props", {})
|
|
|
|
|
if p.get("media.class") != "Audio/Sink":
|
|
|
|
|
continue
|
|
|
|
|
n = p.get("node.name", "")
|
|
|
|
|
if not n or "cinema_spatial" in n or "pro-output" in n:
|
|
|
|
|
continue
|
|
|
|
|
c.append(n)
|
|
|
|
|
print((sorted(c, key=lambda x: (0 if "usb" in x else 1, x)) or [""])[0])
|
|
|
|
|
' 2>/dev/null
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
conf_target() { grep -oP 'node\.target = "\K[^"]+' "$CONF" 2>/dev/null | head -1; }
|
|
|
|
|
|
|
|
|
|
# 设备是否真的存在(按 node.name 精确匹配)
|
|
|
|
|
# ★ 别用 pw-cli info: 它对不存在的名字也返回成功, 会误判成"设备还在"
|
|
|
|
|
target_alive() {
|
|
|
|
|
local t; t=$(conf_target)
|
|
|
|
|
[ -n "$t" ] || return 1
|
|
|
|
|
pw-dump 2>/dev/null | python3 -c '
|
|
|
|
|
import sys, json
|
|
|
|
|
t = sys.argv[1]
|
|
|
|
|
try:
|
|
|
|
|
d = json.load(sys.stdin)
|
|
|
|
|
except Exception:
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
for o in d:
|
|
|
|
|
p = o.get("info", {}).get("props", {})
|
|
|
|
|
if p.get("node.name") == t:
|
|
|
|
|
sys.exit(0)
|
|
|
|
|
sys.exit(1)
|
|
|
|
|
' "$t"
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 07:10:53 +08:00
|
|
|
# 目标失效 -> 重新探测并重建配置
|
|
|
|
|
# 虚拟声卡音量。★ filter-chain 每次重建后音量都会丢(新节点没有历史音量, 回到 1.0
|
|
|
|
|
# → 整链削波爆音), 所以每次"开"都补写一次。改默认: export CINEMA_SPATIAL_VOLUME=0.3
|
|
|
|
|
apply_volume() {
|
|
|
|
|
local vol="${CINEMA_SPATIAL_VOLUME:-0.25}"
|
|
|
|
|
for id in $(wpctl status 2>/dev/null | sed -n '/Filters:/,/Streams:/p' \
|
|
|
|
|
| grep -oE '[0-9]+\. cinema_spatial[a-z_]*_sink' | grep -oE '^[0-9]+'); do
|
|
|
|
|
wpctl set-volume "$id" "$vol" >/dev/null 2>&1
|
|
|
|
|
done
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 06:45:14 +08:00
|
|
|
# 目标失效 -> 重新探测并重建配置
|
|
|
|
|
rebuild_if_stale() {
|
|
|
|
|
target_alive && return 0
|
|
|
|
|
[ -n "$GEN" ] || { echo " ! 配置里的输出设备已失效, 且找不到 生成配置.py, 无法自动修复"; return 1; }
|
|
|
|
|
echo " · 输出设备已变化, 正在重新探测并重建配置..."
|
|
|
|
|
python3 "$GEN" >/dev/null 2>&1 || { echo " ! 重建失败"; return 1; }
|
|
|
|
|
systemctl --user restart pipewire pipewire-pulse wireplumber >/dev/null 2>&1
|
|
|
|
|
sleep 4
|
|
|
|
|
target_alive && echo " · 已修复, 输出指向: $(conf_target)"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
case "${1:-状态}" in
|
|
|
|
|
开|on) VNAME=cinema_spatial_up_sink; LABEL="立体声上混" ;;
|
|
|
|
|
开5.1|on51) VNAME=cinema_spatial_sink; LABEL="5.1 直通" ;;
|
|
|
|
|
关|off) VNAME=""; LABEL="关" ;;
|
|
|
|
|
状态|"") VNAME=""; LABEL="状态" ;;
|
|
|
|
|
*) echo "用法: 空间音频 {开|开5.1|关|状态}"; exit 1 ;;
|
|
|
|
|
esac
|
|
|
|
|
|
|
|
|
|
if [ -n "$VNAME" ]; then
|
|
|
|
|
rebuild_if_stale
|
|
|
|
|
v=$(id_of "$VNAME")
|
|
|
|
|
if [ -z "$v" ]; then
|
|
|
|
|
echo "虚拟声卡 $VNAME 没加载。检查 $CONF"
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
cur=$(cur_id)
|
|
|
|
|
[ -n "$cur" ] && echo "$cur" > "$STATE"
|
|
|
|
|
wpctl set-default "$v"
|
2026-09-13 07:10:53 +08:00
|
|
|
apply_volume
|
2026-09-13 06:45:14 +08:00
|
|
|
# 耳机自身的音量若被压过, 这里不动它(用户自己的选择)
|
|
|
|
|
echo "已切到「电影院空间音频 ($LABEL)」(id $v)"
|
|
|
|
|
echo " 开之前是「$(nick "${cur:-0}")」"
|
|
|
|
|
echo " 输出设备 : $(conf_target)"
|
|
|
|
|
elif [ "$LABEL" = "关" ]; then
|
|
|
|
|
prev=$(cat "$STATE" 2>/dev/null || true)
|
|
|
|
|
if [ -n "${prev:-}" ] && wpctl inspect "$prev" >/dev/null 2>&1; then
|
|
|
|
|
wpctl set-default "$prev"
|
|
|
|
|
echo "已切回「$(nick "$prev")」(id $prev)"
|
|
|
|
|
else
|
|
|
|
|
fb=$(wpctl status 2>/dev/null | sed -n '/Sinks:/,/Sources:/p' | grep '\.' | head -1 | num_of)
|
|
|
|
|
wpctl set-default "$fb"
|
|
|
|
|
echo "切换回第一个物理输出「$(nick "$fb")」(id $fb)"
|
|
|
|
|
fi
|
|
|
|
|
else
|
|
|
|
|
echo "虚拟声卡:"
|
|
|
|
|
echo " 5.1 直通 : id $(id_of cinema_spatial_sink || true)"
|
|
|
|
|
echo " 立体声上混: id $(id_of cinema_spatial_up_sink || true)"
|
|
|
|
|
echo "当前默认 : $(wpctl inspect @DEFAULT_AUDIO_SINK@ 2>/dev/null | grep -oP 'node\.description = "\K[^"]+' | head -1)"
|
|
|
|
|
t=$(conf_target)
|
|
|
|
|
echo "输出设备 : ${t:-<未设置>}"
|
|
|
|
|
if target_alive; then echo " ✓ 设备存在"; else echo " ✗ 设备已失效(下次'开'会自动重建)"; fi
|
|
|
|
|
echo "物理输出 : $(phys_sink)"
|
|
|
|
|
fi
|