2026-09-13 06:45:14 +08:00
|
|
|
#!/bin/bash
|
2026-09-13 16:38:00 +08:00
|
|
|
# 电影院空间音频 开关 2026-09-12 建 / 2026-09-13 修"响度怪"
|
|
|
|
|
# 用法: 空间音频 开 | 开5.1 | 关 | 状态 | 诊断 | 数字 | 模拟
|
|
|
|
|
# 数字/模拟 = 直接切物理输出的 route(数字 S/PDIF 实测音质更好, 推荐), 切完自动保 profile+验连线
|
2026-09-13 06:45:14 +08:00
|
|
|
# 开 = 立体声上混版(全局推荐; 浏览器/音乐/在线视频等立体声源也会有环绕)
|
|
|
|
|
# 开5.1 = 5.1 直通版(本地多声道片源; 也可以用 mpv-影院)
|
|
|
|
|
#
|
2026-09-13 16:38:00 +08:00
|
|
|
# ★ 为什么"开"的时候要做设备自检:
|
2026-09-13 06:45:14 +08:00
|
|
|
# filter-chain 的输出端必须显式指向真实设备(node.target)。但 USB 耳机重启/重插后
|
|
|
|
|
# profile 可能变(如 iec958-stereo <-> analog-stereo), 设备名随之改变, 配置里的
|
|
|
|
|
# 旧名字就失效 -> WirePlumber 回落到其它设备(实测落到 HDMI), 声音就跑了。
|
|
|
|
|
# 所以每次"开"都检查一遍: 目标不存在就重新探测并重建配置。
|
2026-09-13 16:38:00 +08:00
|
|
|
#
|
|
|
|
|
# ★★ 2026-09-13 修的三个"响度怪"根因(顺序点"数字输出/模拟输出"响度不一样):
|
|
|
|
|
# ① 重建里那句 restart pipewire 会让 WirePlumber 用它存档的 default-profile 覆盖
|
|
|
|
|
# 当前 profile —— 于是"按当前设备名写好的 target"重启后当场失效。修法: 重建前记下
|
|
|
|
|
# 物理卡的 profile, 重启后按回去, 再校验连线。
|
|
|
|
|
# ② 音量每次"开"都硬写 0.25, 用户在面板上调的值被抹掉。修法: 音量存
|
|
|
|
|
# ~/.local/state/cinema-spatial/volume, 每次"开"先 capture(记下用户改过的值)再套用。
|
|
|
|
|
# ③ 两条 route 音质不同: 数字(S/PDIF) 实测 96kHz/24bit(S24LE), 老板实测"音质比模拟好一大截"
|
|
|
|
|
# -> 数字优先; 它的硬件增益基数(1.0)比模拟(1.11428)低 0.94 dB(无感, 需要可补偿)。
|
|
|
|
|
# 默认**跟随你在面板上选的那条**, 也可以用 `空间音频 数字|模拟` 直接切。
|
2026-09-13 06:45:14 +08:00
|
|
|
set -u
|
|
|
|
|
STATE="${XDG_RUNTIME_DIR:-/tmp}/cinema-spatial.prev"
|
2026-09-13 16:38:00 +08:00
|
|
|
STATEF="${HOME}/.local/state/cinema-spatial"
|
|
|
|
|
VOLFILE="${STATEF}/volume"
|
2026-09-13 06:45:14 +08:00
|
|
|
CONF="${HOME}/.config/pipewire/pipewire.conf.d/90-cinema-spatial.conf"
|
2026-09-13 16:38:00 +08:00
|
|
|
REBUILT=0 # 这次"开"是否刚重建过(重建后音量是新节点默认值, 不能拿去覆盖存档)
|
|
|
|
|
|
2026-09-13 06:45:14 +08:00
|
|
|
for g in "${HOME}/桌面/工作区/实验/空间音频/生成配置.py" "/usr/share/cinema-spatial/生成配置.py"; do
|
|
|
|
|
[ -f "$g" ] && GEN="$g" && break
|
|
|
|
|
done
|
|
|
|
|
GEN="${GEN:-}"
|
2026-09-13 16:38:00 +08:00
|
|
|
for h in "${HOME}/桌面/工作区/实验/空间音频/音频状态.py" "/usr/share/cinema-spatial/音频状态.py"; do
|
|
|
|
|
[ -f "$h" ] && HELPER="$h" && break
|
|
|
|
|
done
|
|
|
|
|
HELPER="${HELPER:-}"
|
2026-09-13 06:45:14 +08:00
|
|
|
|
|
|
|
|
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; }
|
2026-09-13 16:38:00 +08:00
|
|
|
vol_of_id() { wpctl get-volume "$1" 2>/dev/null | grep -oP 'Volume:\s*\K[0-9.]+'; }
|
|
|
|
|
conf_target() { grep -oP 'node\.target = "\K[^"]+' "$CONF" 2>/dev/null | head -1; }
|
|
|
|
|
helper() { [ -n "$HELPER" ] && python3 "$HELPER" "$@" 2>/dev/null || true; }
|
2026-09-13 23:24:22 +08:00
|
|
|
# 同名节点份数(>1 = 有两份同名 conf 被 PipeWire **同时加载**, 音频会进错那一份)
|
|
|
|
|
# ★ PipeWire 对 conf.d/*.conf 不做"用户级覆盖系统级", 两份都加载 —— 2026-09-13 实测
|
|
|
|
|
# "deb 装的 /usr/share 那份 + 开发目录用户级那份"并存 = 6 个节点各 2 份 → 没声音。
|
|
|
|
|
inst_count() {
|
|
|
|
|
unset PIPEWIRE_CONFIG_DIR
|
|
|
|
|
pw-dump 2>/dev/null | python3 -c '
|
|
|
|
|
import json,sys
|
|
|
|
|
n=sys.argv[1]
|
|
|
|
|
d=json.load(sys.stdin)
|
|
|
|
|
print(sum(1 for x in d if (x.get("info",{}).get("props",{}) or {}).get("node.name")==n))' "$1"
|
|
|
|
|
}
|
2026-09-13 06:45:14 +08:00
|
|
|
|
2026-09-13 16:38:00 +08:00
|
|
|
# ---- 音量持久化 ----------------------------------------------------------
|
|
|
|
|
read_vol() {
|
|
|
|
|
if [ -f "$VOLFILE" ]; then cat "$VOLFILE"; else echo "${CINEMA_SPATIAL_VOLUME:-0.25}"; fi
|
2026-09-13 06:45:14 +08:00
|
|
|
}
|
2026-09-13 16:38:00 +08:00
|
|
|
save_vol() { mkdir -p "$STATEF"; printf '%s\n' "$1" > "$VOLFILE"; }
|
2026-09-13 06:45:14 +08:00
|
|
|
|
2026-09-13 16:38:00 +08:00
|
|
|
# 把用户手动调过的音量记下来(下次重建/开机沿用), 差异 >2% 才算"他调过"
|
|
|
|
|
capture_vol() {
|
|
|
|
|
local id cur saved
|
|
|
|
|
id=$(id_of cinema_spatial_up_sink); [ -n "$id" ] || return 0
|
|
|
|
|
cur=$(vol_of_id "$id"); [ -n "$cur" ] || return 0
|
|
|
|
|
saved=$(read_vol)
|
|
|
|
|
if awk -v a="$cur" -v b="$saved" 'BEGIN{exit !(a > b * 1.02 || a < b * 0.98)}'; then
|
|
|
|
|
save_vol "$cur"
|
|
|
|
|
echo " · 记下你调过的音量 $cur (下次重建/开机沿用)"
|
|
|
|
|
fi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 给两个虚拟声卡套用持久化音量; 数字输出(S/PDIF)硬件增益基数更低 -> 警告 + 单独补偿
|
|
|
|
|
apply_volume() {
|
|
|
|
|
local vol; vol="$(read_vol)"
|
|
|
|
|
case "$(conf_target)" in
|
|
|
|
|
*iec958*|*spdif*)
|
|
|
|
|
vol="${CINEMA_SPATIAL_VOLUME_DIGITAL:-$vol}"
|
|
|
|
|
echo " · 输出走数字(S/PDIF): 实测音质更好(96k/24bit), 硬件增益基数比模拟低 0.94 dB(无感)"
|
|
|
|
|
echo " (要补偿: export CINEMA_SPATIAL_VOLUME_DIGITAL=0.6 这类; 会吃动态余量, 注意别削波)"
|
|
|
|
|
;;
|
|
|
|
|
esac
|
|
|
|
|
local id
|
|
|
|
|
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 23:24:22 +08:00
|
|
|
# ★★ 链中间级必须是**单位增益**: 归一化输出(up_norm)与上混图输入口(up_raw)不归用户调。
|
|
|
|
|
# 2026-09-13 实测: up_raw 被写成 0.2068(-13.7 dB) → 全链从 -8.1 dB 掉到 -29.9 dB,
|
|
|
|
|
# 耳机听着"一点声音没有", 而旧版音量账只算首尾两级、账面显示一切正常。
|
|
|
|
|
# 每次"开"都把它们纠回 1.0。
|
|
|
|
|
for id in $(wpctl status 2>/dev/null | sed -n '/Filters:/,/Streams:/p' \
|
|
|
|
|
| grep -oE '[0-9]+\. cinema_spatial_up_(norm|raw)' | grep -oE '^[0-9]+'); do
|
|
|
|
|
wpctl set-volume "$id" 1.0 >/dev/null 2>&1
|
|
|
|
|
done
|
2026-09-13 16:38:00 +08:00
|
|
|
save_vol "$vol"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ---- 等目标设备回来(重启 PipeWire 后蓝牙/USB 通常要 3~8 秒重连) -----------
|
|
|
|
|
relink_to() { # $1=node.name; 0=运行期重连成功(没重启 PipeWire)
|
|
|
|
|
# 链的 playback 节点本质是 stream, 端口可以运行期改连 —— 于是切换设备不必重建,
|
|
|
|
|
# 也就不必重启 PipeWire(蓝牙设备不会被带掉)。仅声道数一致时可行;
|
|
|
|
|
# 单声道设备要改图(filter.graph 输出端口数) → return 1 交回调用方走重建。
|
|
|
|
|
local dev="$1" out="" n
|
|
|
|
|
for n in cinema_spatial_up_out cinema_spatial_out; do
|
|
|
|
|
[ -n "$(id_of "$n")" ] && { out="$n"; break; }
|
|
|
|
|
done
|
|
|
|
|
[ -n "$out" ] || return 1
|
|
|
|
|
local ch
|
|
|
|
|
ch=$(pw-dump 2>/dev/null | python3 -c '
|
|
|
|
|
import json,sys
|
|
|
|
|
w={"'"$dev"'":2,"'"$out"'":2}
|
|
|
|
|
for o in json.load(sys.stdin):
|
|
|
|
|
pr=((o.get("info") or {}).get("props") or {})
|
|
|
|
|
if pr.get("node.name") in w: w[pr["node.name"]]=int(pr.get("audio.channels") or 2)
|
|
|
|
|
print(w["'"$dev"'"],w["'"$out"'"])')
|
|
|
|
|
set -- $ch
|
|
|
|
|
[ -n "${1:-}" ] && [ "${1:-0}" = "${2:-0}" ] || { echo " · 目标 ${1:-?} 声道 / 链输出 ${2:-?} 声道, 不一致 → 需要重建"; return 1; }
|
|
|
|
|
# 拆旧连线(pw-link -l 格式: 来源在上一行, |-> 去向在下一行)
|
|
|
|
|
pw-link -l 2>/dev/null | awk -v o="$out" '
|
|
|
|
|
/^[^ \t].*:/ { src=$0 }
|
|
|
|
|
/\|->/ { t=$NF; if (src ~ ("^" o ":output_")) print src "\t" t }' |
|
|
|
|
|
while IFS=$'\t' read -r s t; do
|
|
|
|
|
case "$t" in *:playback_*) pw-link -d "$s" "$t" >/dev/null 2>&1 ;; esac
|
|
|
|
|
done
|
|
|
|
|
local p
|
|
|
|
|
for p in FL FR; do pw-link "$out:output_$p" "$dev:playback_$p" >/dev/null 2>&1; done
|
|
|
|
|
if pw-link -l 2>/dev/null | grep -q "$dev:playback_"; then
|
|
|
|
|
echo " · 运行期重连成功(没重启 PipeWire) → $dev"; return 0
|
|
|
|
|
fi
|
|
|
|
|
echo " ! 运行期重连失败, 回落重建"; return 1
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 23:24:22 +08:00
|
|
|
# ★★ 2026-09-13: DSP 进程的父进程是 **pipewire 主进程**而不是 filter-chain 实例,
|
|
|
|
|
# 所以重建/重启时 pdeathsig 不触发 —— 旧 DSP 会一直活着并占着 /dev/shm 的共享槽位,
|
|
|
|
|
# 新实例抢不到槽位会自我了断(claim_slot 已改) → 链里没有 DSP = **完全静音**
|
|
|
|
|
# (老板报"没声音了", 实测攒了 4 个进程抢 2 个槽, 槽1 僵死在 152 秒前)。
|
|
|
|
|
# 所以**每次重启 PipeWire 之前必须先收掉旧的**。
|
|
|
|
|
kill_old_dsp() {
|
|
|
|
|
local pid cmd killed=0
|
|
|
|
|
for pid in /proc/[0-9]*; do
|
|
|
|
|
pid=${pid#/proc/}
|
|
|
|
|
[ -r "/proc/$pid/cmdline" ] || continue
|
|
|
|
|
cmd=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)
|
|
|
|
|
case "$cmd" in
|
|
|
|
|
*loudness_norm.py*) kill -9 "$pid" 2>/dev/null && killed=$((killed + 1)) ;;
|
|
|
|
|
esac
|
|
|
|
|
done
|
|
|
|
|
[ "$killed" -gt 0 ] && echo " · 清掉 $killed 个旧 DSP 实例(防占共享槽位)"
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
restart_pipewire() {
|
|
|
|
|
kill_old_dsp
|
|
|
|
|
systemctl --user restart pipewire pipewire-pulse >/dev/null 2>&1
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-13 16:38:00 +08:00
|
|
|
wait_target() { # $1 = 最多等多少秒; 0 = 回来了, 1 = 超时
|
|
|
|
|
local i=0 n=${1:-15}
|
|
|
|
|
while [ "$i" -lt "$n" ]; do
|
|
|
|
|
target_alive && return 0
|
|
|
|
|
i=$((i + 1)); sleep 1
|
|
|
|
|
done
|
|
|
|
|
return 1
|
|
|
|
|
}
|
2026-09-13 06:45:14 +08:00
|
|
|
|
|
|
|
|
# 设备是否真的存在(按 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 16:38:00 +08:00
|
|
|
# ---- 重建 ---------------------------------------------------------------
|
|
|
|
|
# 目标失效 -> 重新探测并重建配置. 关键顺序(踩过的坑都在这):
|
|
|
|
|
# 1) 先记下"当前活着的物理输出"与其 profile —— 这就是用户选的那个
|
|
|
|
|
# 2) 用 CINEMA_SPATIAL_TARGET 明确告诉生成器写哪个 node.target(别让它去猜默认设备)
|
|
|
|
|
# 3) 重启后把 profile 按回去(否则 WirePlumber 用存档 profile 覆盖, target 当场失效)
|
|
|
|
|
# 4) 校验 node.target 存在 + 连线真的接上(以前这步缺失: 静默不接也当成功)
|
2026-09-13 06:45:14 +08:00
|
|
|
rebuild_if_stale() {
|
|
|
|
|
target_alive && return 0
|
|
|
|
|
[ -n "$GEN" ] || { echo " ! 配置里的输出设备已失效, 且找不到 生成配置.py, 无法自动修复"; return 1; }
|
2026-09-13 16:38:00 +08:00
|
|
|
|
|
|
|
|
local sink card prof
|
|
|
|
|
sink=$(helper phys-sink)
|
|
|
|
|
card=$(helper card-of "$sink")
|
|
|
|
|
[ -n "$card" ] && prof=$(helper profile "$card" | awk -F'\t' '{print $2}')
|
|
|
|
|
echo " · 输出设备已变化(配置里是 $(conf_target)), 重新探测并重建配置..."
|
|
|
|
|
[ -n "$sink" ] && echo " · 重建目标: $sink"
|
|
|
|
|
CINEMA_SPATIAL_TARGET="${sink}" python3 "$GEN" >/dev/null 2>&1 || { echo " ! 重建失败"; return 1; }
|
|
|
|
|
# ★ 重建必须重启 PipeWire(滤波链是配置里的模块, 没有运行时热加载: pw-cli load-module
|
|
|
|
|
# 在本机一律 "Could not load module")。而 WirePlumber 是 PipeWire 客户端, 会跟着退出+
|
|
|
|
|
# 被 systemd 拉起 —— 于是蓝牙/USB 设备要几秒才重新出现。原来固定 sleep 4 就判"目标失效"
|
|
|
|
|
# → 回退把目标换成别的设备 → 蓝牙音响再也连不上(老板 2026-09-13 报的)。
|
|
|
|
|
# 所以真正的修法是 wait_target: 轮询等目标回来, 最多 20 秒。
|
2026-09-13 23:24:22 +08:00
|
|
|
restart_pipewire
|
2026-09-13 16:38:00 +08:00
|
|
|
wait_target 20 || echo " · 目标 $(conf_target) 暂未回来, 继续走"
|
|
|
|
|
|
|
|
|
|
if [ -n "$card" ] && [ -n "$prof" ]; then
|
|
|
|
|
local now; now=$(helper profile "$card" | awk -F'\t' '{print $2}')
|
|
|
|
|
if [ "$now" != "$prof" ]; then
|
|
|
|
|
echo " · profile 被 WirePlumber 拉回 $now, 按回 $prof"
|
|
|
|
|
helper set-profile "$card" "$prof" >/dev/null
|
|
|
|
|
sleep 2
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
|
|
|
|
|
if target_alive; then
|
|
|
|
|
echo " · 已修复, 输出指向: $(conf_target)"
|
|
|
|
|
REBUILT=1
|
|
|
|
|
if [ -n "$HELPER" ]; then python3 "$HELPER" check | sed 's/^/ /'; fi
|
|
|
|
|
else
|
|
|
|
|
echo " ! 重建后 target 仍不存在: $(conf_target)"
|
|
|
|
|
echo " 检查: 耳机是否插好 / 是否被切到别的 profile; 手动指定: export CINEMA_SPATIAL_TARGET=<node.name>"
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
2026-09-13 06:45:14 +08:00
|
|
|
}
|
|
|
|
|
|
2026-09-13 16:38:00 +08:00
|
|
|
# 切物理输出的 route(数字/模拟): 比在 GNOME 面板点更稳 —— 切完重建目标、把 profile 按回去、验连线
|
|
|
|
|
switch_route() {
|
|
|
|
|
local kind="$1" card cur target v
|
|
|
|
|
if [ -z "$HELPER" ]; then echo "缺 音频状态.py, 无法切 route"; return 1; fi
|
|
|
|
|
card=$(python3 "$HELPER" card-of "$(python3 "$HELPER" phys-sink)")
|
|
|
|
|
[ -n "$card" ] || { echo "找不到物理卡"; return 1; }
|
|
|
|
|
cur=$(python3 "$HELPER" route-kind)
|
|
|
|
|
if [ "$cur" = "$kind" ]; then
|
|
|
|
|
echo " · 输出已经在「$LABEL」路上"
|
|
|
|
|
else
|
|
|
|
|
echo " · 切 route: ${cur:-未知} -> $kind"
|
|
|
|
|
python3 "$HELPER" set-route "$card" "$kind" | sed 's/^/ /'
|
|
|
|
|
sleep 2
|
|
|
|
|
fi
|
|
|
|
|
target=$(python3 "$HELPER" phys-sink)
|
|
|
|
|
if [ "$(conf_target)" != "$target" ]; then
|
|
|
|
|
echo " · 输出目标变了, 重建配置(target=$target)"
|
|
|
|
|
CINEMA_SPATIAL_TARGET="$target" python3 "$GEN" >/dev/null 2>&1 || { echo " ! 重建失败"; return 1; }
|
2026-09-13 23:24:22 +08:00
|
|
|
restart_pipewire
|
2026-09-13 16:38:00 +08:00
|
|
|
wait_target 20
|
|
|
|
|
python3 "$HELPER" set-route "$card" "$kind" >/dev/null 2>&1 # 重启可能被拉回, 再按一次
|
|
|
|
|
sleep 2
|
|
|
|
|
REBUILT=1
|
|
|
|
|
fi
|
|
|
|
|
v=$(id_of cinema_spatial_up_sink)
|
|
|
|
|
if [ -z "$v" ]; then echo "虚拟声卡没加载, 检查 $CONF"; return 1; fi
|
|
|
|
|
[ "$REBUILT" = "1" ] || capture_vol
|
|
|
|
|
wpctl set-default "$v"
|
|
|
|
|
apply_volume
|
|
|
|
|
echo " · 输出: $(conf_target)"
|
|
|
|
|
python3 "$HELPER" check | sed 's/^/ /'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 列出可用输出设备(★=当前配置里的目标)
|
|
|
|
|
list_devices() {
|
|
|
|
|
local cur; cur=$(conf_target)
|
|
|
|
|
python3 "$HELPER" sinks | while IFS=$'\t' read -r id name desc is_cur; do
|
|
|
|
|
[ -n "$name" ] || continue
|
|
|
|
|
if [ "$name" = "$cur" ]; then
|
|
|
|
|
printf ' ★ %s\n %s\n' "$desc" "$name"
|
|
|
|
|
else
|
|
|
|
|
printf ' %s\n %s\n' "$desc" "$name"
|
|
|
|
|
fi
|
|
|
|
|
done
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 切输出设备(按关键词匹配 node.name/描述): 重写 node.target -> 重建 -> 校验 -> 实装(复用"开"的收尾)
|
|
|
|
|
# 实测: 目标写错/设备不存在时必须回退, 否则全屋没声音
|
|
|
|
|
switch_device() {
|
|
|
|
|
local want="$1" cur matched v
|
|
|
|
|
cur=$(conf_target)
|
|
|
|
|
if [ -z "$want" ]; then
|
|
|
|
|
echo "可用输出设备(★=当前配置目标):"
|
|
|
|
|
list_devices
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
matched=$(python3 "$HELPER" sinks | awk -F'\t' -v w="$want" \
|
|
|
|
|
'index(tolower($2),tolower(w)) || index(tolower($3),tolower(w)) {print $2; exit}')
|
|
|
|
|
if [ -z "$matched" ]; then
|
|
|
|
|
echo " ! 没有匹配「$want」的输出设备, 现有:"
|
|
|
|
|
list_devices
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
if [ "$matched" = "$cur" ]; then
|
|
|
|
|
echo " · 输出本来就指向 $matched"
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
echo " · 切输出: ${cur:-自动} → $matched"
|
|
|
|
|
# ★ 先试运行期重连(不重启 PipeWire → 蓝牙设备不会被带掉), 失败才回落重建
|
|
|
|
|
if relink_to "$matched"; then
|
|
|
|
|
CINEMA_SPATIAL_TARGET="$matched" python3 "$GEN" >/dev/null 2>&1 # 写进配置供下次重启
|
|
|
|
|
REBUILT=1
|
|
|
|
|
apply_volume
|
|
|
|
|
echo " · 输出: $(conf_target)"
|
|
|
|
|
[ -n "$HELPER" ] && python3 "$HELPER" check | sed 's/^/ /'
|
|
|
|
|
return 0
|
|
|
|
|
fi
|
|
|
|
|
CINEMA_SPATIAL_TARGET="$matched" python3 "$GEN" >/dev/null 2>&1 \
|
|
|
|
|
|| { echo " ! 生成配置失败, 目标未改"; return 1; }
|
2026-09-13 23:24:22 +08:00
|
|
|
restart_pipewire
|
2026-09-13 16:38:00 +08:00
|
|
|
# ★ 不是"睡 4 秒就判死": 蓝牙/USB 重连要 3~8 秒, 原来没等够就宣布失效 → 目标被换掉
|
|
|
|
|
if ! wait_target 20; then
|
|
|
|
|
echo " ! 目标 $(conf_target) 等了 20 秒没回来, 回退到自动探测(跳过显卡/HDMI)"
|
|
|
|
|
CINEMA_SPATIAL_TARGET="$(helper phys-sink)" python3 "$GEN" >/dev/null 2>&1
|
2026-09-13 23:24:22 +08:00
|
|
|
restart_pipewire
|
2026-09-13 16:38:00 +08:00
|
|
|
wait_target 12
|
|
|
|
|
return 1
|
|
|
|
|
fi
|
|
|
|
|
REBUILT=1
|
|
|
|
|
echo " · 已切到: $(conf_target)"
|
|
|
|
|
v=$(id_of "$VNAME")
|
|
|
|
|
if [ -n "$v" ]; then
|
|
|
|
|
wpctl set-default "$v"
|
|
|
|
|
apply_volume
|
|
|
|
|
else
|
|
|
|
|
echo " ! 虚拟声卡 $VNAME 没加载, 检查 $CONF"
|
|
|
|
|
fi
|
|
|
|
|
echo " · 输出: $(conf_target)"
|
|
|
|
|
[ -n "$HELPER" ] && python3 "$HELPER" check | sed 's/^/ /'
|
|
|
|
|
return 0
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ---- 房间混响(镜像法算出的真实房间 IR) -----------------------------------
|
|
|
|
|
# 用法: 空间音频 混响 [开|关|<湿量 0~1>] 不带参数=翻转当前状态
|
|
|
|
|
# 湿量是生成期常量, 所以每次改都要重建滤波链(regen_chain)
|
|
|
|
|
REVERB_IR="${CINEMA_SPATIAL_REVERB_IR:-$(dirname "$GEN")/reverb/房间混响IR-96k.wav}"
|
|
|
|
|
|
|
|
|
|
read_reverb() {
|
|
|
|
|
python3 -c 'import json,os
|
|
|
|
|
p=os.path.expanduser("~/.local/state/cinema-spatial/reverb.json")
|
|
|
|
|
try: d=json.load(open(p,encoding="utf-8"))
|
|
|
|
|
except Exception: d={}
|
|
|
|
|
print(1 if d.get("on") else 0, d.get("wet", 0.25))'
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
save_reverb() {
|
|
|
|
|
python3 -c 'import json,os,sys
|
|
|
|
|
p=os.path.expanduser("~/.local/state/cinema-spatial/reverb.json")
|
|
|
|
|
os.makedirs(os.path.dirname(p),exist_ok=True)
|
|
|
|
|
json.dump({"on":sys.argv[1]=="1","wet":round(float(sys.argv[2]),4)},open(p,"w",encoding="utf-8"),ensure_ascii=False,indent=2)' "$1" "$2"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# 强制重建滤波链: 记 profile -> 生成 -> 重启 -> 按回 profile -> 验连线
|
|
|
|
|
regen_chain() {
|
|
|
|
|
local sink card prof now v
|
|
|
|
|
[ -n "$GEN" ] || { echo " ! 找不到 生成配置.py"; return 1; }
|
|
|
|
|
sink=$(conf_target); card=$(helper card-of "$sink")
|
|
|
|
|
[ -n "$card" ] && prof=$(helper profile "$card" | awk -F'\t' '{print $2}')
|
|
|
|
|
# ★ 必须显式传目标: 不传的话 生成配置.py 会回落到"第一个物理设备",
|
|
|
|
|
# 而那往往是显卡的 pro-output(没接屏就是没声的输出) —— 一旦写进配置就固化,
|
|
|
|
|
# 之后每次重建都指向死输出(2026-09-13 实测: 链挂在 pro-output-3 的 AUX0 上)。
|
|
|
|
|
local tgt; tgt=$(conf_target)
|
|
|
|
|
case "$tgt" in
|
|
|
|
|
""|*pro-output*|*hdmi*) tgt=$(helper phys-sink) ;; # 探测会跳过显卡 Pro 输出
|
|
|
|
|
esac
|
|
|
|
|
[ -n "$tgt" ] || tgt=$(conf_target)
|
|
|
|
|
CINEMA_SPATIAL_TARGET="$tgt" python3 "$GEN" >/dev/null 2>&1 || { echo " ! 生成配置失败"; return 1; }
|
|
|
|
|
[ -n "$tgt" ] && [ "$tgt" != "$sink" ] && echo " · 目标改用: $tgt"
|
|
|
|
|
# ★ 同 regen_chain: 重启后等目标回来(蓝牙/USB 要 3~8 秒), 别睡 4 秒就判死
|
2026-09-13 23:24:22 +08:00
|
|
|
restart_pipewire
|
2026-09-13 16:38:00 +08:00
|
|
|
wait_target 20 || echo " · 目标 $(conf_target) 暂未回来, 继续走"
|
|
|
|
|
# ★ 会话管理器可能被连续重建搞崩(实测 2026-09-13: GLib 断言 core-dump)。
|
|
|
|
|
# 它一死, monitor 的新连接就建不起来 —— 表现是电平表卡 -120 dBFS 而链路看着全对。
|
|
|
|
|
# 所以重建后必查, 不活就拉起来(自愈)。
|
|
|
|
|
if ! systemctl --user is-active --quiet wireplumber; then
|
|
|
|
|
echo " · wireplumber 不在(上次重启崩过), 拉起"
|
|
|
|
|
systemctl --user restart wireplumber >/dev/null 2>&1
|
|
|
|
|
sleep 5
|
|
|
|
|
fi
|
|
|
|
|
if [ -n "$card" ] && [ -n "$prof" ]; then
|
|
|
|
|
now=$(helper profile "$card" | awk -F'\t' '{print $2}')
|
|
|
|
|
if [ "$now" != "$prof" ]; then
|
|
|
|
|
echo " · profile 被拉回 $now, 按回 $prof"
|
|
|
|
|
helper set-profile "$card" "$prof" >/dev/null
|
|
|
|
|
sleep 2
|
|
|
|
|
fi
|
|
|
|
|
fi
|
|
|
|
|
target_alive || { echo " ! 重建后目标失效: $(conf_target)"; return 1; }
|
|
|
|
|
v=$(id_of "$VNAME")
|
|
|
|
|
if [ -n "$v" ]; then wpctl set-default "$v"; apply_volume; else echo " ! 虚拟声卡 $VNAME 没加载, 检查 $CONF"; fi
|
|
|
|
|
echo " · 输出: $(conf_target)"
|
|
|
|
|
python3 "$HELPER" check | sed 's/^/ /'
|
2026-09-13 23:24:22 +08:00
|
|
|
# ★ 重建收尾必须查"同名实例只有一份": 两份同名 conf 会被 PipeWire 同时加载 →
|
|
|
|
|
# 节点重名、音频进错实例、整条链静默(2026-09-13 实测"开了两个都没声音")。
|
|
|
|
|
local nd; nd=$(inst_count "$VNAME")
|
|
|
|
|
if [ "${nd:-1}" -gt 1 ] 2>/dev/null; then
|
|
|
|
|
echo " ! $nd 份 $VNAME —— 有两份同名 conf 被同时加载, 音频会进错那一份"
|
|
|
|
|
echo " 另一份多半是 deb 装到系统级的。修:"
|
|
|
|
|
echo " sudo mv /usr/share/pipewire/pipewire.conf.d/90-cinema-spatial.conf{,.disabled-dup}"
|
|
|
|
|
echo " systemctl --user restart pipewire pipewire-pulse"
|
|
|
|
|
fi
|
2026-09-13 16:38:00 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ---- 采样参数(HRTF 模型 / tap 数 / 采样率) --------------------------------
|
|
|
|
|
# 用法: 空间音频 参数 [hrtf <模型> | taps <N> | rate <Hz>] 不带参数=看当前值+可选项
|
|
|
|
|
# 三者都是生成期常量 → 改完必须重建链路
|
|
|
|
|
read_params() {
|
|
|
|
|
python3 -c 'import json,os
|
|
|
|
|
p=os.path.expanduser("~/.local/state/cinema-spatial/params.json")
|
|
|
|
|
try: d=json.load(open(p,encoding="utf-8"))
|
|
|
|
|
except Exception: d={}
|
|
|
|
|
print(json.dumps(d,ensure_ascii=False) if d else "(默认: hrir/current 软链, 未写参数)")'
|
|
|
|
|
}
|
|
|
|
|
save_param() { # $1=key $2=value
|
|
|
|
|
python3 -c 'import json,os,sys
|
|
|
|
|
p=os.path.expanduser("~/.local/state/cinema-spatial/params.json")
|
|
|
|
|
os.makedirs(os.path.dirname(p),exist_ok=True)
|
|
|
|
|
try: d=json.load(open(p,encoding="utf-8"))
|
|
|
|
|
except Exception: d={}
|
|
|
|
|
k,v=sys.argv[1],sys.argv[2]
|
|
|
|
|
d[k]=int(v) if k in ("taps","rate") else v
|
|
|
|
|
json.dump(d,open(p,"w",encoding="utf-8"),ensure_ascii=False,indent=2)
|
|
|
|
|
print(" · 参数 "+k+" = "+v)' "$1" "$2"
|
|
|
|
|
}
|
|
|
|
|
taps_of() { # 打印某个 HRIR 目录的 (帧率 tap)
|
|
|
|
|
python3 -c "
|
|
|
|
|
import struct
|
|
|
|
|
b=open('$1/FL_L.wav','rb').read();pos=12;rate=0
|
|
|
|
|
while pos+8<=len(b):
|
|
|
|
|
cid=b[pos:pos+4];sz=struct.unpack('<I',b[pos+4:pos+8])[0]
|
|
|
|
|
if cid==b'fmt ': rate=struct.unpack('<I',b[pos+12:pos+16])[0]
|
|
|
|
|
if cid==b'data': print('%dHz %dtap'%(rate,sz//4));break
|
|
|
|
|
pos+=8+sz+(sz&1)"
|
|
|
|
|
}
|
|
|
|
|
show_params() {
|
|
|
|
|
echo " 当前参数: $(read_params)"
|
|
|
|
|
echo " 可用 HRTF 模型:"
|
|
|
|
|
local d n
|
|
|
|
|
for d in "$(dirname "$GEN")"/hrir/*/; do
|
|
|
|
|
n=$(basename "$d")
|
|
|
|
|
[ "$n" = "gain" ] && continue
|
|
|
|
|
[ -f "$d/FL_L.wav" ] && echo " $n $(taps_of "$d")"
|
|
|
|
|
done
|
|
|
|
|
echo " 已裁 tap : $(ls -d "$(dirname "$GEN")"/hrir/taps-* 2>/dev/null | sed 's#.*/taps-##' | tr '\n' ' ')"
|
|
|
|
|
echo " 采样率 : 96000 / 48000 / 44100 (改它会重写用户级 91-clock.conf, 不用 sudo)"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
# ---- 单声道(单声道蓝牙音响/耳机: L/R 合并成 1 声道) ----------------------
|
|
|
|
|
# 用法: 空间音频 单声道 [自动|开|关] 不带参数 = 看当前 + 用法
|
|
|
|
|
MONOF="$HOME/.local/state/cinema-spatial/mono.json"
|
|
|
|
|
read_mono() {
|
|
|
|
|
python3 -c 'import json,os
|
|
|
|
|
p=os.path.expanduser("~/.local/state/cinema-spatial/mono.json")
|
|
|
|
|
try: d=json.load(open(p,encoding="utf-8"))
|
|
|
|
|
except Exception: d={}
|
|
|
|
|
v=d.get("on")
|
|
|
|
|
print("自动(按目标设备声道数判)" if v is None else ("强制开" if v else "强制关"))'
|
|
|
|
|
}
|
|
|
|
|
save_mono() {
|
|
|
|
|
python3 -c 'import json,os,sys
|
|
|
|
|
p=os.path.expanduser("~/.local/state/cinema-spatial/mono.json")
|
|
|
|
|
v=sys.argv[1]
|
|
|
|
|
os.makedirs(os.path.dirname(p),exist_ok=True)
|
|
|
|
|
json.dump({} if v=="自动" else {"on": v=="开"}, open(p,"w",encoding="utf-8"), ensure_ascii=False)
|
|
|
|
|
print(" · 单声道 = "+v)' "$1"
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-09-13 06:45:14 +08:00
|
|
|
case "${1:-状态}" in
|
|
|
|
|
开|on) VNAME=cinema_spatial_up_sink; LABEL="立体声上混" ;;
|
|
|
|
|
开5.1|on51) VNAME=cinema_spatial_sink; LABEL="5.1 直通" ;;
|
|
|
|
|
关|off) VNAME=""; LABEL="关" ;;
|
2026-09-13 16:38:00 +08:00
|
|
|
数字|digital) ROUTE=digital; LABEL="数字输出" ;;
|
|
|
|
|
模拟|analog) ROUTE=analog; LABEL="模拟输出" ;;
|
|
|
|
|
诊断|check) LABEL="诊断" ;;
|
|
|
|
|
设备|device) VNAME=cinema_spatial_up_sink; LABEL="设备"; DEV="${2:-}" ;;
|
|
|
|
|
混响|reverb) VNAME=cinema_spatial_up_sink; LABEL="混响" ;;
|
|
|
|
|
重建|regen) VNAME=cinema_spatial_up_sink; LABEL="重建" ;;
|
|
|
|
|
单声道|mono) VNAME=cinema_spatial_up_sink; LABEL="单声道" ;;
|
|
|
|
|
参数|params) VNAME=cinema_spatial_up_sink; LABEL="参数"; PKEY="${2:-}"; PVAL="${3:-}" ;;
|
2026-09-13 06:45:14 +08:00
|
|
|
状态|"") VNAME=""; LABEL="状态" ;;
|
2026-09-13 16:38:00 +08:00
|
|
|
*) echo "用法: 空间音频 {开|开5.1|关|状态|诊断|数字|模拟|设备 [关键词]|混响 [开|关|湿量]|参数 [hrtf|taps|rate]|单声道 [自动|开|关]|重建}"; exit 1 ;;
|
2026-09-13 06:45:14 +08:00
|
|
|
esac
|
|
|
|
|
|
2026-09-13 16:38:00 +08:00
|
|
|
if [ "$LABEL" = "诊断" ]; then
|
|
|
|
|
if [ -n "$HELPER" ]; then python3 "$HELPER" check; else echo "缺 音频状态.py"; fi
|
|
|
|
|
elif [ -n "${ROUTE:-}" ]; then
|
|
|
|
|
switch_route "$ROUTE"
|
|
|
|
|
elif [ "$LABEL" = "设备" ]; then
|
|
|
|
|
switch_device "${DEV:-}"
|
|
|
|
|
elif [ "$LABEL" = "混响" ]; then
|
|
|
|
|
want="${2:-}"
|
|
|
|
|
read -r cur_on cur_wet <<<"$(read_reverb)"
|
|
|
|
|
case "$want" in
|
|
|
|
|
开|on|1) on=1; wet="${3:-$cur_wet}" ;;
|
|
|
|
|
关|off|0) on=0; wet="$cur_wet" ;;
|
|
|
|
|
"") on=$(( 1 - cur_on )); wet="$cur_wet" ;;
|
|
|
|
|
*) on=1; wet="$want" ;;
|
|
|
|
|
esac
|
|
|
|
|
awk -v w="$wet" 'BEGIN{exit !(w > 0 && w <= 1)}' || wet=0.25
|
|
|
|
|
if [ "$on" = "1" ] && [ ! -f "$REVERB_IR" ]; then
|
|
|
|
|
echo " ! 缺少房间 IR: $REVERB_IR"
|
|
|
|
|
echo " 先生成: cd ~/桌面/工作区/实验/声学追踪引擎 && .venv/bin/python src/镜像法IR.py"
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
|
|
|
|
save_reverb "$on" "$wet"
|
|
|
|
|
if [ "$on" = "1" ]; then echo " · 混响 开 (湿量 $wet)"; else echo " · 混响 关"; fi
|
|
|
|
|
regen_chain
|
|
|
|
|
exit $?
|
|
|
|
|
elif [ "$LABEL" = "重建" ]; then
|
|
|
|
|
regen_chain
|
|
|
|
|
exit $?
|
|
|
|
|
elif [ "$LABEL" = "单声道" ]; then
|
|
|
|
|
case "${2:-}" in
|
|
|
|
|
"") echo " 单声道: $(read_mono)"
|
|
|
|
|
echo " 用法: 空间音频 单声道 [自动|开|关]"
|
|
|
|
|
echo " 开 = 链输出把 L/R 各 -6dB 相加成 1 声道(单声道蓝牙音响/耳机连不上就是缺这个)"
|
|
|
|
|
echo " 自动 = 按目标设备声道数判(1 声道就自动合并)"
|
|
|
|
|
exit 0 ;;
|
|
|
|
|
自动|开|关) save_mono "$2" ;;
|
|
|
|
|
*) echo " ! 只接受 自动/开/关"; exit 1 ;;
|
|
|
|
|
esac
|
|
|
|
|
regen_chain
|
|
|
|
|
exit $?
|
|
|
|
|
elif [ "$LABEL" = "参数" ]; then
|
|
|
|
|
if [ -z "${PKEY:-}" ]; then show_params; exit 0; fi
|
|
|
|
|
case "$PKEY" in
|
|
|
|
|
hrtf|HRTF|模型)
|
|
|
|
|
[ -f "$(dirname "$GEN")/hrir/$PVAL/FL_L.wav" ] || { echo " ! 没有这个模型: $PVAL"; show_params; exit 1; }
|
|
|
|
|
save_param hrtf "$PVAL"
|
|
|
|
|
save_param taps 0 ;; # 模型与截断二选一: 选模型就清掉 taps
|
|
|
|
|
taps|tap|截断)
|
|
|
|
|
case "$PVAL" in ''|*[!0-9]*) echo " ! taps 要给数字"; exit 1 ;; esac
|
|
|
|
|
# taps=0 = 不裁(回到 hrir/current 软链那份), 不做截断
|
|
|
|
|
if [ "$PVAL" != "0" ] && [ ! -d "$(dirname "$GEN")/hrir/taps-$PVAL" ]; then
|
|
|
|
|
echo " · 先裁一份 $PVAL tap:"
|
|
|
|
|
python3 "$(dirname "$GEN")/work/裁HRIR.py" "$PVAL" | sed 's/^/ /' || exit 1
|
|
|
|
|
fi
|
|
|
|
|
save_param taps "$PVAL"
|
|
|
|
|
save_param hrtf "" ;; # 选截断就清掉模型名(taps-<N> 优先)
|
|
|
|
|
rate|采样率)
|
|
|
|
|
case "$PVAL" in 96000|48000|44100) ;; *) echo " ! rate 只支持 96000/48000/44100"; exit 1 ;; esac
|
|
|
|
|
save_param rate "$PVAL" ;;
|
|
|
|
|
*) echo " ! 未知参数: $PKEY (可用 hrtf/taps/rate)"; exit 1 ;;
|
|
|
|
|
esac
|
|
|
|
|
regen_chain
|
|
|
|
|
exit $?
|
|
|
|
|
elif [ -n "$VNAME" ]; then
|
2026-09-13 06:45:14 +08:00
|
|
|
rebuild_if_stale
|
|
|
|
|
v=$(id_of "$VNAME")
|
|
|
|
|
if [ -z "$v" ]; then
|
|
|
|
|
echo "虚拟声卡 $VNAME 没加载。检查 $CONF"
|
|
|
|
|
exit 1
|
|
|
|
|
fi
|
2026-09-13 16:38:00 +08:00
|
|
|
# 先记用户改过的音量, 再套用(顺序很重要: 反了就把他的值覆盖掉)
|
|
|
|
|
# 但刚重建过的新节点音量是默认值, 不能拿它覆盖存档 —— 那会丢掉用户调的音量
|
|
|
|
|
[ "$REBUILT" = "1" ] || capture_vol
|
2026-09-13 06:45:14 +08:00
|
|
|
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)"
|
2026-09-13 16:38:00 +08:00
|
|
|
prev="$(cat "$STATE" 2>/dev/null)"
|
|
|
|
|
[ -n "$prev" ] && echo " 开之前是「$(nick "$prev")」"
|
2026-09-13 06:45:14 +08:00
|
|
|
echo " 输出设备 : $(conf_target)"
|
2026-09-13 16:38:00 +08:00
|
|
|
if [ -n "$HELPER" ]; then python3 "$HELPER" check | sed 's/^/ /'; fi
|
2026-09-13 06:45:14 +08:00
|
|
|
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)"
|
2026-09-13 16:38:00 +08:00
|
|
|
echo "持久化音量: $(read_vol) (${VOLFILE})"
|
|
|
|
|
if [ -n "$HELPER" ]; then python3 "$HELPER" check | sed 's/^/ /'; fi
|
2026-09-13 06:45:14 +08:00
|
|
|
fi
|
2026-09-13 16:38:00 +08:00
|
|
|
# ---- 房间混响(镜像法算出的真实房间 IR) -----------------------------------
|