322 lines
10 KiB
Python
322 lines
10 KiB
Python
#!/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 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
|
|
|
|
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"
|
|
|
|
# 电平表: 从物理输出的 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)
|
|
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": 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(),
|
|
"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())
|
|
elif u.path == "/api/level":
|
|
self._json({"points": level_points(), "monitor": monitor_name()})
|
|
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()
|
|
start_level_worker()
|
|
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())
|