feat: Web 控制台(web/) -- 状态总览/模式切换/音量/一键修复失声, 零依赖 stdlib

This commit is contained in:
edgevoid
2026-09-13 07:04:44 +08:00
parent 38e2e2f68a
commit d3dfa73b7b
5 changed files with 422 additions and 0 deletions
+15
View File
@@ -66,6 +66,20 @@ mpv-影院 电影.mkv # 只给 mpv 用, 不依赖全局设置(适合 A/B 对比
也可以直接在桌面环境的"声音设置"里选 **电影院空间音频**
## Web 控制台
浏览器里看状态、切模式、调音量、**一键修复"失声"**(设备名失效时自动重建)。
```sh
bash web/启动.sh # 启动(默认端口 8788,重复执行不会起第二个)
python3 web/webui.py --port 8788 --bind 127.0.0.1 # 也可以直接跑
```
打开 <http://127.0.0.1:8788/>。菜单里也有「空间音频控制台」快捷方式。
界面会实时显示:默认输出、链路采样率、HRTF 模型、**配置输出设备 vs 当前物理输出(不一致会标红)**、
输出端实际连到哪个设备、以及两个虚拟声卡和设备的音量。检测到设备名失效时直接点修复即可。
## 文件说明
| 文件 | 作用 |
@@ -77,6 +91,7 @@ mpv-影院 电影.mkv # 只给 mpv 用, 不依赖全局设置(适合 A/B 对比
| `提取HRIR.sh` | 早期版本(已被 `换HRTF.sh` 取代)|
| `打包deb.sh` | 打包 `collaplex-cinema-spatial` |
| `验收测试.sh` | 安装后自动验证 |
| `web/` | Web 控制台(`webui.py` 后端 + `index.html` 前端 + `启动.sh`),零依赖 |
| `demo.sh` / `响度校准.sh` / `方向测试.sh` | 开发期验证工具 |
| `对比分析.py` / `排查.sh` | 开发期排查工具 |
Binary file not shown.
+138
View File
@@ -0,0 +1,138 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>电影院空间音频</title>
<style>
:root{--fg:#e6e6e6;--mut:#8a8a8a;--bg:#0f0f0f;--card:#161616;--bd:#262626;
--ok:#4ade80;--bad:#f87171;--warn:#fbbf24}
*{box-sizing:border-box}
body{margin:0;padding:20px;background:var(--bg);color:var(--fg);
font:14px/1.6 system-ui,-apple-system,"Noto Sans CJK SC",sans-serif}
h1{font-size:16px;font-weight:600;margin:0 0 4px}
.sub{color:var(--mut);font-size:12px;margin-bottom:16px}
.card{background:var(--card);border:1px solid var(--bd);border-radius:8px;
padding:14px 16px;margin-bottom:12px}
.card h2{font-size:13px;font-weight:600;color:var(--mut);margin:0 0 10px;letter-spacing:.05em}
.row{display:flex;justify-content:space-between;gap:12px;padding:6px 0;
border-bottom:1px solid var(--bd);font-size:13px}
.row:last-child{border-bottom:none}
.k{color:var(--mut);flex:0 0 auto}
.v{font-variant-numeric:tabular-nums;text-align:right;word-break:break-all}
.ok{color:var(--ok)}.bad{color:var(--bad)}.warn{color:var(--warn)}
.bar{display:flex;gap:8px;flex-wrap:wrap}
button{background:#1f1f1f;color:var(--fg);border:1px solid var(--bd);border-radius:6px;
padding:8px 14px;font-size:13px;cursor:pointer;font-family:inherit;
transition:background .15s,border-color .15s}
button:hover{background:#2a2a2a;border-color:#3a3a3a}
button.primary{background:#17321f;border-color:#2d5a3f}
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)}
.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}
</style>
</head>
<body>
<h1>电影院空间音频</h1>
<div class="sub">PipeWire 虚拟声卡 · SADIE-II 真人 HRTF</div>
<div class="card"><h2>状态</h2><div id="st"></div></div>
<div class="card"><h2>模式</h2>
<div class="bar">
<button class="primary" onclick="mode('on')">立体声上混</button>
<button onclick="mode('on51')">5.1 直通</button>
<button onclick="mode('off')">关闭(走物理设备)</button>
<button class="fix" onclick="doFix()">检测并修复失声</button>
</div>
</div>
<div class="card"><h2>虚拟声卡音量</h2>
<div class="nums">
<input type="range" id="vol" min="0" max="1.5" step="0.01" oninput="onVol(this.value)">
<span id="volval">-</span>
</div>
<div class="sub" style="margin:8px 0 0">降音量是拿响度换安全;削波爆音就往下拉,
想更响请改 IR 增益(见 README)。</div>
</div>
<div id="msg"></div>
<script>
const $ = (s) => document.querySelector(s);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
async function api(path, opts) {
try { const r = await fetch(path, opts || {}); return await r.json(); }
catch (e) { return { error: String(e) }; }
}
function row(k, v, cls) {
return '<div class="row"><span class="k">' + esc(k) + '</span>' +
'<span class="v ' + (cls || '') + '">' + esc(v) + '</span></div>';
}
function render(s) {
if (!s || s.error) {
$('#st').innerHTML = row('错误', (s && s.error) || '取不到状态', 'bad');
return;
}
let h = '';
h += row('默认输出', s.default || '(无)', s.default_is_virtual ? 'ok' : 'warn');
h += row('链路采样率', (s.clock || '?') + ' Hz', s.clock === '96000' ? 'ok' : 'warn');
h += row('HRTF 模型', s.hrtf || '-');
h += row('配置输出设备', s.target || '(空)', s.target_ok ? 'ok' : 'bad');
h += row('当前物理输出', s.physical || '(找不到)', s.physical ? 'ok' : 'bad');
h += row('实际连接', (s.route && s.route.length) ? s.route.join(', ') : '(未连接 → 无声)',
(s.route && s.route.length) ? 'ok' : 'bad');
if (s.device_vol != null) h += row('设备音量', s.device_vol.toFixed(2));
if (s.device_desc) h += row('设备', s.device_desc);
(s.sinks || []).forEach(function (k) {
h += row('声卡 ' + k.key + ' (id ' + k.id + ')', '音量 ' + k.vol.toFixed(2));
});
$('#st').innerHTML = h;
if (s.default_vol != null) {
$('#vol').value = s.default_vol;
$('#volval').textContent = s.default_vol.toFixed(2);
}
if (!s.target_ok) msg('配置里的输出设备已失效 —— 点「检测并修复失声」', true);
}
function msg(t, keep) {
$('#msg').textContent = t;
if (!keep) setTimeout(function () {
if ($('#msg').textContent === t) $('#msg').textContent = '';
}, 4000);
}
async function mode(m) {
const r = await api('/api/mode?mode=' + m, { method: 'POST' });
msg(r.message || '完成');
if (r.status) render(r.status);
}
async function onVol(v) {
$('#volval').textContent = parseFloat(v).toFixed(2);
const r = await api('/api/volume?value=' + v, { method: 'POST' });
if (r.message) msg(r.message);
}
async function doFix() {
msg('正在检测并修复…(约 6 秒)', true);
const r = await api('/api/fix', { method: 'POST' });
msg(r.message || '完成');
setTimeout(refresh, 5000);
}
async function refresh() { render(await api('/api/status')); }
refresh();
setInterval(refresh, 5000);
</script>
</body>
</html>
+251
View File
@@ -0,0 +1,251 @@
#!/usr/bin/env python3
# 电影院空间音频 Web 控制台 (2026-09-13) -- 零依赖, 仅 stdlib
# 用法: python3 webui.py [--port 8788] [--bind 127.0.0.1]
from __future__ import annotations
import argparse
import json
import os
import re
import subprocess
import sys
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs, urlparse
HERE = os.path.dirname(os.path.abspath(__file__))
INDEX = os.path.join(HERE, "index.html")
ROOT = os.path.dirname(HERE)
CONF = os.path.expanduser("~/.config/pipewire/pipewire.conf.d/90-cinema-spatial.conf")
SINK_51 = "cinema_spatial_sink"
SINK_UP = "cinema_spatial_up_sink"
def _env() -> dict[str, str]:
e = dict(os.environ)
e.setdefault("XDG_RUNTIME_DIR", "/run/user/%d" % os.getuid())
return e
def sh(*args: str, timeout: int = 10) -> str:
try:
p = subprocess.run(list(args), capture_output=True, text=True, timeout=timeout, env=_env())
return (p.stdout or "") + (p.stderr or "")
except Exception:
return ""
def dump() -> list[dict]:
try:
return json.loads(sh("pw-dump") or "[]")
except Exception:
return []
def nodes(kind: str) -> list[dict]:
out: list[dict] = []
for o in dump():
if o.get("type") != "PipeWire:Interface:Node":
continue
p = (o.get("info") or {}).get("props") or {}
if p.get("media.class") == kind:
out.append({
"id": o.get("id"),
"name": p.get("node.name", ""),
"desc": p.get("node.description", ""),
})
return out
def vol(nid: object) -> float:
m = re.search(r"Volume:\s*([0-9.]+)", sh("wpctl", "get-volume", str(nid)))
return float(m.group(1)) if m else -1.0
def default_sink_name() -> str:
m = re.search(r'node\.name = "([^"]+)"', sh("wpctl", "inspect", "@DEFAULT_AUDIO_SINK@"))
return m.group(1) if m else ""
def conf_target() -> str:
try:
with open(CONF, encoding="utf-8") as f:
m = re.search(r'node\.target = "([^"]+)"', f.read())
return m.group(1) if m else ""
except Exception:
return ""
def phys_sink() -> str:
c = [s["name"] for s in nodes("Audio/Sink")
if s["name"] and "cinema_spatial" not in s["name"] and "pro-output" not in s["name"]]
return (sorted(c, key=lambda x: (0 if "usb" in x else 1, x)) or [""])[0]
def route_of(out_name: str) -> list[str]:
res: list[str] = []
cap = False
for line in sh("pw-link", "-l").splitlines():
if line.strip().startswith(out_name + ":output_"):
cap = True
continue
if cap:
m = re.match(r"\s*\|->\s*(\S+)", line)
if m:
res.append(m.group(1))
else:
cap = False
return res
def hrtf_name() -> str:
link = os.path.join(ROOT, "hrir", "current")
return os.path.basename(os.path.realpath(link)) if os.path.islink(link) else "-"
def clock_rate() -> str:
m = re.search(r"key:'clock\.rate'\s+value:'(\d+)'", sh("pw-metadata", "-n", "settings"))
return m.group(1) if m else "?"
def status() -> dict:
ds = default_sink_name()
ss: list[dict] = []
for n in nodes("Audio/Sink"):
if "cinema_spatial" in n["name"]:
key = "5.1 直通" if n["name"] == SINK_51 else "立体声上混"
ss.append({"key": key, "id": n["id"], "vol": round(vol(n["id"]), 3)})
t, p = conf_target(), phys_sink()
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_is_virtual": "cinema_spatial" in ds,
"sinks": ss,
"clock": clock_rate(),
"hrtf": hrtf_name(),
"target": t,
"target_ok": bool(t) and t == p,
"physical": p,
"route": route_of("cinema_spatial_up_out"),
"default_vol": next((round(vol(n["id"]), 3) for n in nodes("Audio/Sink")
if n["name"] == ds), None),
"device_vol": dv,
"device_desc": next((d["desc"] for d in devs if d["name"] == t), ""),
}
def set_volume(val: float) -> str:
for n in nodes("Audio/Sink"):
if "cinema_spatial" in n["name"]:
sh("wpctl", "set-volume", str(n["id"]), "%.3f" % val)
return "已设为 %.2f" % val
def set_mode(mode: str) -> str:
if mode == "off":
p = phys_sink()
nid = next((n["id"] for n in nodes("Audio/Sink") if n["name"] == p), None)
if nid:
sh("wpctl", "set-default", str(nid))
return "已切到物理输出"
return "找不到物理输出"
want = SINK_UP if mode == "on" else SINK_51
n = next((x for x in nodes("Audio/Sink") if x["name"] == want), None)
if not n:
return "虚拟声卡不存在"
sh("wpctl", "set-default", str(n["id"]))
return "已切到 %s" % ("立体声上混" if mode == "on" else "5.1 直通")
def fix() -> str:
gen = os.path.join(ROOT, "生成配置.py")
if not os.path.exists(gen):
return "找不到 生成配置.py"
t, p = conf_target(), phys_sink()
if t and t == p:
return "配置已正确, 无需修复"
if not p:
return "找不到物理输出设备"
e = _env()
e["CINEMA_SPATIAL_IR"] = os.path.join(ROOT, "hrir", "current")
subprocess.run([sys.executable, gen], capture_output=True, text=True, timeout=30, env=e)
sh("systemctl", "--user", "restart", "pipewire", "pipewire-pulse", "wireplumber", timeout=20)
return "已重新探测设备并重建配置"
class Handler(BaseHTTPRequestHandler):
def log_message(self, format: str, *args: object) -> None: # noqa: A002
pass
def _send(self, code: int, body: bytes, ctype: str) -> None:
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.end_headers()
self.wfile.write(body)
def _json(self, obj: object, code: int = 200) -> None:
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"),
"application/json; charset=utf-8")
def do_GET(self) -> None:
u = urlparse(self.path)
if u.path in ("/", "/index.html"):
try:
with open(INDEX, "rb") as f:
self._send(200, f.read(), "text/html; charset=utf-8")
except FileNotFoundError:
self._send(404, b"index.html not found", "text/plain; charset=utf-8")
elif u.path == "/api/status":
self._json(status())
else:
self._json({"error": "not found"}, 404)
def do_POST(self) -> None:
u = urlparse(self.path)
q = parse_qs(u.query)
try:
n = int(self.headers.get("Content-Length") or 0)
raw = self.rfile.read(n).decode("utf-8") if n else ""
if raw:
for k, v in json.loads(raw).items():
q[k] = [str(v)]
except Exception:
pass
def g(k: str, d: str = "") -> str:
return (q.get(k) or [d])[0]
if u.path == "/api/mode":
msg = set_mode(g("mode", "on"))
elif u.path == "/api/volume":
try:
msg = set_volume(max(0.0, min(1.5, float(g("value", "1.0")))))
except ValueError:
msg = "音量参数无效"
elif u.path == "/api/fix":
msg = fix()
else:
self._json({"error": "not found"}, 404)
return
self._json({"message": msg, "status": status()})
def main() -> int:
ap = argparse.ArgumentParser(description="电影院空间音频 Web 控制台")
ap.add_argument("--port", type=int, default=8788)
ap.add_argument("--bind", default="127.0.0.1")
a = ap.parse_args()
srv = ThreadingHTTPServer((a.bind, a.port), Handler)
print("电影院空间音频控制台: http://%s:%d/ (Ctrl-C 退出)" % (a.bind, a.port))
sys.stdout.flush()
try:
srv.serve_forever()
except KeyboardInterrupt:
print()
return 0
if __name__ == "__main__":
sys.exit(main())
Executable
+18
View File
@@ -0,0 +1,18 @@
#!/bin/bash
# 启动空间音频 Web 控制台(后台运行, 重复执行不会起第二个)
PORT="${1:-8788}"
P=$(ss -ltnp 2>/dev/null | grep ":$PORT" | grep -oP 'pid=\K[0-9]+' | head -1)
if [ -n "$P" ]; then
echo "已在运行 (PID $P): http://127.0.0.1:$PORT/"
exit 0
fi
cd "$(dirname "$(readlink -f "$0")")" || exit 1
nohup python3 webui.py --port "$PORT" >/tmp/cinema-webui.log 2>&1 &
sleep 1
if ss -ltn 2>/dev/null | grep -q ":$PORT"; then
echo "已启动: http://127.0.0.1:$PORT/"
else
echo "启动失败, 见 /tmp/cinema-webui.log"
cat /tmp/cinema-webui.log
exit 1
fi