fix: 音量持久化(node.volume 进配置 + 开时补写) -- 根因是 filter-chain 重建后音量回 1.0 导致爆音反复; feat: 电平贝塞尔图 + Web 控制台

This commit is contained in:
edgevoid
2026-09-13 07:10:53 +08:00
parent cddb174ee3
commit 06f8680e63
5 changed files with 186 additions and 3 deletions
+6
View File
@@ -124,6 +124,12 @@ python3 web/webui.py --port 8788 --bind 127.0.0.1 # 也可以直接跑
设备 `node.name` 随之后缀改变 → 配置里记的 `node.target` 失效 → WirePlumber 回落到其它输出
(实测落到 HDMI,表现为"没声音"或"声音从显示器出来")。
本项目的 `空间音频 开` 已带自检:设备名失效时自动重新探测并重建配置。
12. **★★ 虚拟声卡的音量不会被保留**(2026-09-13,爆音反复的根因):
filter-chain 每次重建(重载 PipeWire、开机、切设备)都是**全新的节点**,
没有历史音量 → 回到默认 **1.0** → 整链直接削波。
用户看到的现象是"我明明调低了,怎么又炸了"——**而且调低的那一刻音量可能刚被重置**。
**修法两处**:① `capture.props` 里写 `node.volume = 0.25`(重建时的默认值)
② 切换命令每次补写一遍(兜底)。
13. **判定设备是否存在不能用 `pw-cli info`**:它对不存在的名字也返回成功。要用 `pw-dump`
`node.name` 精确比对。
+89
View File
@@ -31,6 +31,10 @@ button.primary:hover{background:#1e4028}
button.fix{background:#3a2418;border-color:#5c3a26}
button.fix:hover{background:#4a2e1e}
#msg{margin-top:12px;font-size:13px;min-height:20px;color:var(--warn)}
#lv{width:100%;height:120px;display:block;border:1px solid var(--bd);
border-radius:6px;background:#121212}
.lvn{display:flex;gap:18px;margin-top:8px;font-size:12px;flex-wrap:wrap}
.lvn b{font-variant-numeric:tabular-nums;font-weight:600}
.nums{display:flex;align-items:center;gap:10px;margin-top:6px}
#volval{font-variant-numeric:tabular-nums;min-width:52px;text-align:right;color:var(--ok)}
input[type=range]{flex:1;accent-color:#4ade80;height:4px}
@@ -60,6 +64,15 @@ input[type=range]{flex:1;accent-color:#4ade80;height:4px}
想更响请改 IR 增益(见 README)。</div>
</div>
<div class="card"><h2>电平(物理输出)</h2>
<canvas id="lv"></canvas>
<div class="lvn">
<span class="k">峰值 <b id="pk" class="v">-</b></span>
<span class="k">RMS <b id="rm" class="v">-</b></span>
<span class="k">余量 <b id="hd" class="v">-</b></span>
</div>
</div>
<div id="msg"></div>
<script>
@@ -129,10 +142,86 @@ async function doFix() {
setTimeout(refresh, 5000);
}
const cv = document.getElementById('lv');
const cx = cv.getContext('2d');
const DB_LO = -60, DB_HI = 6;
function dbY(db, h) {
const t = Math.max(0, Math.min(1, (db - DB_LO) / (DB_HI - DB_LO)));
return h - t * h;
}
// Catmull-Rom 插值转三次贝塞尔 —— 得到平滑曲线(而不是折线)
function smooth(pts) {
if (pts.length < 2) return '';
let d = 'M ' + pts[0].x + ' ' + pts[0].y;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] || pts[i], p1 = pts[i];
const p2 = pts[i + 1], p3 = pts[i + 2] || p2;
d += ' C ' + (p1.x + (p2.x - p0.x) / 6) + ' ' + (p1.y + (p2.y - p0.y) / 6) +
', ' + (p2.x - (p3.x - p1.x) / 6) + ' ' + (p2.y - (p3.y - p1.y) / 6) +
', ' + p2.x + ' ' + p2.y;
}
return d;
}
function drawLevel(pts) {
const w = cv.width = cv.clientWidth * 2;
const h = cv.height = 240;
cx.setTransform(1, 0, 0, 1, 0, 0);
cx.clearRect(0, 0, w, h);
cx.font = '18px system-ui';
[-60, -48, -24, -12, -6, -3].forEach(function (db) {
const y = dbY(db, h);
cx.strokeStyle = '#242424'; cx.lineWidth = 1;
cx.beginPath(); cx.moveTo(0, y); cx.lineTo(w, y); cx.stroke();
cx.fillStyle = '#4a4a4a';
cx.fillText(db + ' dB', 8, y - 5);
});
cx.strokeStyle = '#7f1d1d'; cx.lineWidth = 2; // 0dBFS 削波线
cx.beginPath(); cx.moveTo(0, dbY(0, h)); cx.lineTo(w, dbY(0, h)); cx.stroke();
if (!pts.length) return;
const n = pts.length;
const X = function (i) { return n === 1 ? w : i * w / (n - 1); };
function line(key, color, lw) {
const P = pts.map(function (p, i) { return { x: X(i), y: dbY(p[key], h) }; });
cx.strokeStyle = color; cx.lineWidth = lw;
cx.lineJoin = 'round'; cx.lineCap = 'round';
cx.stroke(new Path2D(smooth(P)));
return P;
}
const rp = line('rms', '#4ade80', 4);
line('peak', '#fbbf24', 2);
const last = rp[rp.length - 1];
cx.fillStyle = '#4ade80';
cx.beginPath(); cx.arc(last.x, last.y, 5, 0, Math.PI * 2); cx.fill();
if (pts[n - 1].peak > -0.1) { // 削波整体闪红
cx.fillStyle = 'rgba(248,113,113,0.22)';
cx.fillRect(0, 0, w, h);
}
}
async function level() {
const d = await api('/api/level');
const pts = (d && d.points) || [];
if (pts.length) {
const p = pts[pts.length - 1];
document.getElementById('pk').textContent = p.peak.toFixed(1) + ' dBFS';
document.getElementById('rm').textContent = p.rms.toFixed(1) + ' dBFS';
const hd = -p.peak;
const el = document.getElementById('hd');
el.textContent = hd.toFixed(1) + ' dB';
el.className = 'v ' + (hd < 3 ? 'bad' : hd < 6 ? 'warn' : 'ok');
}
drawLevel(pts);
}
async function refresh() { render(await api('/api/status')); }
refresh();
setInterval(refresh, 5000);
setInterval(level, 150);
level();
</script>
</body>
</html>
+71 -1
View File
@@ -4,11 +4,16 @@
from __future__ import annotations
import argparse
import array
import json
import math
import os
import re
import subprocess
import sys
import threading
import time
from collections import deque
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
@@ -19,6 +24,67 @@ CONF = os.path.expanduser("~/.config/pipewire/pipewire.conf.d/90-cinema-spatial.
SINK_51 = "cinema_spatial_sink"
SINK_UP = "cinema_spatial_up_sink"
# 电平表: 从物理输出的 monitor 取样(耳机真正在放的信号), 每 100ms 一个点。
# 指标是 dBFS —— 0dBFS 为数字满刻度, 余量 = -peak(dB)。
# 注意: 96k 重采样/多路叠加会产生 intersample peak, 峰值超过 0 也算削波。
LEVEL: deque[dict] = deque(maxlen=240)
LEVEL_LOCK = threading.Lock()
CHUNK_MS = 100
def monitor_name() -> str:
"""当前物理输出设备的 monitor 源(录到的就是耳机在放的东西)。"""
p = phys_sink()
return p + ".monitor" if p else ""
def _level_worker() -> None:
"""持续录物理输出, 按 CHUNK_MS 切块算 RMS/Peak(dBFS)。"""
rate = 48000
per = rate * CHUNK_MS // 1000 * 4 # f32 每样本 4 字节
while True:
mon = monitor_name()
if not mon:
time.sleep(2)
continue
proc = None
try:
proc = subprocess.Popen(
["pw-record", "--target", mon, "--rate", str(rate),
"--channels", "1", "--format", "f32", "-"],
stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, env=_env())
while True:
raw = proc.stdout.read(per) if proc.stdout else b""
if not raw or len(raw) < per:
break
a = array.array("f")
a.frombytes(raw)
pk = max(abs(v) for v in a)
rms = math.sqrt(sum(v * v for v in a) / len(a))
with LEVEL_LOCK:
LEVEL.append({
"peak": round(20 * math.log10(pk), 2) if pk > 1e-9 else -120.0,
"rms": round(20 * math.log10(rms), 2) if rms > 1e-9 else -120.0,
})
except Exception:
pass
finally:
if proc is not None:
try:
proc.kill()
except Exception:
pass
time.sleep(0.5)
def start_level_worker() -> None:
threading.Thread(target=_level_worker, daemon=True).start()
def level_points() -> list[dict]:
with LEVEL_LOCK:
return list(LEVEL)
def _env() -> dict[str, str]:
e = dict(os.environ)
@@ -118,7 +184,8 @@ def status() -> dict:
devs = [n for n in nodes("Audio/Sink") if "cinema_spatial" not in n["name"]]
dv = next((round(vol(d["id"]), 3) for d in devs if d["name"] == t), None)
return {
"default": ds,
"default": next((n["desc"] for n in nodes("Audio/Sink") if n["name"] == ds), ds),
"default_name": ds,
"default_is_virtual": "cinema_spatial" in ds,
"sinks": ss,
"clock": clock_rate(),
@@ -199,6 +266,8 @@ class Handler(BaseHTTPRequestHandler):
self._send(404, b"index.html not found", "text/plain; charset=utf-8")
elif u.path == "/api/status":
self._json(status())
elif u.path == "/api/level":
self._json({"points": level_points(), "monitor": monitor_name()})
else:
self._json({"error": "not found"}, 404)
@@ -237,6 +306,7 @@ def main() -> int:
ap.add_argument("--port", type=int, default=8788)
ap.add_argument("--bind", default="127.0.0.1")
a = ap.parse_args()
start_level_worker()
srv = ThreadingHTTPServer((a.bind, a.port), Handler)
print("电影院空间音频控制台: http://%s:%d/ (Ctrl-C 退出)" % (a.bind, a.port))
sys.stdout.flush()
+8 -2
View File
@@ -77,6 +77,12 @@ def default_sink_name() -> str:
_TARGET = os.environ.get("CINEMA_SPATIAL_TARGET") or default_sink_name()
TARGET_PROP = f'node.target = "{_TARGET}" ' if _TARGET else ""
# 虚拟声卡的音量。★ 必须写进配置, 否则重建后不保留:
# 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 ""
def hrir_block(indent: str) -> list[str]:
"""12 个卷积器 + 6 个分发 + 左右耳混音。输入端口: cp<dir>:In"""
@@ -115,7 +121,7 @@ def sink_51() -> list[str]:
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 "
f" capture.props = {{ {VOL_PROP}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 ] }",
@@ -165,7 +171,7 @@ def sink_upmix() -> list[str]:
L += [" ]",
' inputs = [ "copyFL:In" "copyFR:In" ]',
' outputs = [ "mixL:Out" "mixR:Out" ]', " }",
" capture.props = { node.name = cinema_spatial_up_sink media.class = Audio/Sink "
f" capture.props = {{ {VOL_PROP}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 ] }",
+12
View File
@@ -67,6 +67,17 @@ sys.exit(1)
' "$t"
}
# 目标失效 -> 重新探测并重建配置
# 虚拟声卡音量。★ 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
}
# 目标失效 -> 重新探测并重建配置
rebuild_if_stale() {
target_alive && return 0
@@ -96,6 +107,7 @@ if [ -n "$VNAME" ]; then
cur=$(cur_id)
[ -n "$cur" ] && echo "$cur" > "$STATE"
wpctl set-default "$v"
apply_volume
# 耳机自身的音量若被压过, 这里不动它(用户自己的选择)
echo "已切到「电影院空间音频 ($LABEL)」(id $v)"
echo " 开之前是「$(nick "${cur:-0}")」"