fix: 音量持久化(node.volume 进配置 + 开时补写) -- 根因是 filter-chain 重建后音量回 1.0 导致爆音反复; feat: 电平贝塞尔图 + Web 控制台
This commit is contained in:
@@ -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` 精确比对。
|
||||
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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 ] }",
|
||||
|
||||
@@ -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}")」"
|
||||
|
||||
Reference in New Issue
Block a user