diff --git a/README.md b/README.md index ae92e66..4bcdd21 100644 --- a/README.md +++ b/README.md @@ -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` 精确比对。 diff --git a/web/index.html b/web/index.html index 93e1dab..8cf9987 100644 --- a/web/index.html +++ b/web/index.html @@ -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)。 +

电平(物理输出)

+ +
+ 峰值 - + RMS - + 余量 - +
+
+
diff --git a/web/webui.py b/web/webui.py index ed8b565..f4d1214 100644 --- a/web/webui.py +++ b/web/webui.py @@ -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() diff --git a/生成配置.py b/生成配置.py index e8228af..ec0e1ae 100644 --- a/生成配置.py +++ b/生成配置.py @@ -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: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 ] }", diff --git a/空间音频 b/空间音频 index 4f7c67b..8bc7bf8 100755 --- a/空间音频 +++ b/空间音频 @@ -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}")」"