From d3dfa73b7b6018f3b21c806316c14b1629c624ac Mon Sep 17 00:00:00 2001 From: edgevoid Date: Sun, 13 Sep 2026 07:04:44 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Web=20=E6=8E=A7=E5=88=B6=E5=8F=B0(web/)?= =?UTF-8?q?=20--=20=E7=8A=B6=E6=80=81=E6=80=BB=E8=A7=88/=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E5=88=87=E6=8D=A2/=E9=9F=B3=E9=87=8F/=E4=B8=80=E9=94=AE?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=A4=B1=E5=A3=B0,=20=E9=9B=B6=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=20stdlib?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 15 ++ web/__pycache__/webui.cpython-314.pyc | Bin 0 -> 20258 bytes web/index.html | 138 ++++++++++++++ web/webui.py | 251 ++++++++++++++++++++++++++ web/启动.sh | 18 ++ 5 files changed, 422 insertions(+) create mode 100644 web/__pycache__/webui.cpython-314.pyc create mode 100644 web/index.html create mode 100644 web/webui.py create mode 100755 web/启动.sh diff --git a/README.md b/README.md index 7bda166..ae92e66 100644 --- a/README.md +++ b/README.md @@ -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 # 也可以直接跑 +``` + +打开 。菜单里也有「空间音频控制台」快捷方式。 + +界面会实时显示:默认输出、链路采样率、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` | 开发期排查工具 | diff --git a/web/__pycache__/webui.cpython-314.pyc b/web/__pycache__/webui.cpython-314.pyc new file mode 100644 index 0000000000000000000000000000000000000000..089839c5f2697a0818c771eb9a100e9a35651ce3 GIT binary patch literal 20258 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 literal 0 HcmV?d00001 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..93e1dab --- /dev/null +++ b/web/index.html @@ -0,0 +1,138 @@ + + + + + +电影院空间音频 + + + +

电影院空间音频

+
PipeWire 虚拟声卡 · SADIE-II 真人 HRTF
+ +

状态

+ +

模式

+
+ + + + +
+
+ +

虚拟声卡音量

+
+ + - +
+
降音量是拿响度换安全;削波爆音就往下拉, + 想更响请改 IR 增益(见 README)。
+
+ +
+ + + + diff --git a/web/webui.py b/web/webui.py new file mode 100644 index 0000000..ed8b565 --- /dev/null +++ b/web/webui.py @@ -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()) diff --git a/web/启动.sh b/web/启动.sh new file mode 100755 index 0000000..3d2b4ba --- /dev/null +++ b/web/启动.sh @@ -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