diff --git a/dsp/common.py b/dsp/common.py index 145a95e..1d9db55 100644 --- a/dsp/common.py +++ b/dsp/common.py @@ -21,7 +21,8 @@ import struct EQ_BANDS = 32 CHANNELS = 2 EQ_PATH = "/dev/shm/collaplex-eq" -LOCK_PATH = "/dev/shm/collaplex-eq.ch" +LOCK_PATH = "/dev/shm/collaplex-eq.lock" +LOUDNESS_SHM = "/dev/shm/collaplex-loudness" GAIN_LIMIT = 15.0 VOLUME_LIMIT = 12.0 @@ -39,6 +40,7 @@ _PARAMS = struct.Struct("<34d") # 32 段增益 + 总音量 + 湿量 _VER = struct.Struct(" int: def defaults() -> tuple[list[float], float, float]: """默认参数: 全平直 + 0 dB 总音量 + 0.3 混响湿量。""" return [0.0] * EQ_BANDS, 0.0, 0.3 + + +def read_loudness_state() -> dict[str, object]: + """读响度归一化 DSP 的状态: 它看到的电平与当前施加的增益。 + + DSP 侧布局(每声道 32 字节, 见 dsp/loudness_norm.py): + +0 平滑能量 +4 块能量 +8 输入峰值 +12 当前增益(dB) + +64 目标响度(dB) + peak 是线性幅度, 前端自己转 dBFS。 + """ + try: + with open(LOUDNESS_SHM, "rb") as fh: + mm = mmap.mmap(fh.fileno(), 0, prot=mmap.PROT_READ) + try: + peaks = [_LOUD_F.unpack_from(mm, ch * 32 + 8)[0] for ch in range(CHANNELS)] + gains = [_LOUD_F.unpack_from(mm, ch * 32 + 12)[0] for ch in range(CHANNELS)] + target = _LOUD_F.unpack_from(mm, 64)[0] + finally: + mm.close() + except (OSError, ValueError): + return {"peak": [-120.0, -120.0], "gain_db": [0.0, 0.0], "target_db": -16.0} + return { + "peak": [float(p) for p in peaks], + "gain_db": [float(g) for g in gains], + "target_db": float(target), + } diff --git a/web/index.html b/web/index.html index 06f89fb..8d9b5ac 100644 --- a/web/index.html +++ b/web/index.html @@ -43,6 +43,16 @@ input[type=range]::-moz-range-track{height:4px;background:var(--bd-hi);border-ra input[type=range]::-moz-range-thumb{width:14px;height:14px;border:none;border-radius:50%;background:var(--ok)} .hint{color:var(--dim);font-size:10px;margin-top:8px} +/* ---- 归一化的电平与动态增益 ---- */ +.viz{display:flex;align-items:center;gap:9px;margin-top:9px;padding-left:110px} +.viz .lbl{width:70px;flex:none;color:var(--mut);font-size:11px} +.viz .bar{flex:1;height:10px;border-radius:3px} +.viz .gauge{position:relative;flex:1;height:10px;background:#111;border:1px solid var(--bd);border-radius:3px} +.viz .gauge::before{content:"";position:absolute;left:60%;top:-2px;width:1px;height:12px;background:var(--dim)} +.viz .gauge i{position:absolute;top:-3px;width:6px;height:14px;border-radius:2px;background:var(--ok); + transform:translateX(-50%);transition:left .12s linear} +.viz .num{width:84px;flex:none;text-align:right;color:var(--fg);font-variant-numeric:tabular-nums} + /* ---- EQ ---- */ .eq{display:flex;align-items:flex-end;gap:3px;height:190px;padding-top:6px} .band{flex:1;display:flex;flex-direction:column;align-items:center;gap:4px;height:100%} @@ -87,6 +97,16 @@ button:hover{border-color:var(--ok);color:var(--ok)} — +
+ 输入电平 +
+ -∞ +
+
+ 动态增益 +
+ — +
混响湿度 @@ -134,6 +154,22 @@ function bindWheel(input, onDelta, onDone) { /* ---------- 三个控制滑块 ---------- */ const loud = el("loud"), wet = el("wet"), vol = el("vol"); + +/* 正在拖动/滚动的控件不能再用轮询结果覆盖 —— 否则每 250ms 弹回一次, 表现为 + "推上去自己弹下来, 要试好几次才到位"。原先用 document.activeElement 判断, + 但鼠标拖动时焦点常常不在滑块上, 所以改用显式标志 + 松手后的短暂静默期。 */ +const busy = {}; +const markBusy = (k, ms) => { busy[k] = Date.now() + ms; }; +const isBusy = (k) => (busy[k] || 0) > Date.now(); +[["loud", loud], ["wet", wet], ["vol", vol]].forEach(([k, s]) => { + s.addEventListener("pointerdown", () => markBusy(k, 1e7)); // 拖动期间一直忙 + const release = () => markBusy(k, 600); // 松手后 0.6s 内别覆盖 + s.addEventListener("pointerup", release); + s.addEventListener("pointercancel", release); + s.addEventListener("blur", release); + s.addEventListener("keydown", () => markBusy(k, 600)); + s.addEventListener("wheel", () => markBusy(k, 700), {passive:true}); +}); loud.addEventListener("input", () => el("loud-num").textContent = (+loud.value).toFixed(1) + " dB"); wet.addEventListener("input", () => el("wet-num").textContent = (+wet.value).toFixed(2)); vol.addEventListener("input", () => el("vol-num").textContent = (+vol.value >= 0 ? "+" : "") + (+vol.value).toFixed(1) + " dB"); @@ -204,6 +240,19 @@ function updateMeters(levels) { } } +/* ---------- 归一化的电平与动态增益 ---------- */ +function updateLoud(ll) { + if (!ll) return; + const pk = Math.max(ll.peak[0], ll.peak[1]); + const pkDb = 20 * Math.log10(Math.max(pk, 1e-6)); + el("bar-loud").style.width = Math.max(0, Math.min(100, (pkDb + 60) / 60 * 100)) + "%"; + el("loud-in").textContent = (pkDb <= -119 ? "-∞" : pkDb.toFixed(1)) + " dBFS"; + const g = (ll.gain_db[0] + ll.gain_db[1]) / 2; // 两声道共用同一增益 + el("loud-gain-num").textContent = (g >= 0 ? "+" : "") + g.toFixed(1) + " dB"; + // 推子: 量程 -18 ~ +12 dB, 0 dB 落在 60% 处(与刻度线对齐) + el("loud-gain").style.left = Math.max(0, Math.min(100, (g + 18) / 30 * 100)) + "%"; +} + /* ---------- 轮询 ---------- */ async function tick() { try { @@ -219,10 +268,11 @@ async function tick() { v.textContent = val === 0 ? "" : (val > 0 ? "+" : "") + val.toFixed(1); }); } - if (document.activeElement !== vol) { vol.value = d.volume_db; el("vol-num").textContent = (d.volume_db >= 0 ? "+" : "") + d.volume_db.toFixed(1) + " dB"; } - if (document.activeElement !== wet) { wet.value = d.wet; el("wet-num").textContent = (+d.wet).toFixed(2); } - if (document.activeElement !== loud && d.loudness) { loud.value = d.loudness.target_db; el("loud-num").textContent = (+d.loudness.target_db).toFixed(1) + " dB"; } + if (!isBusy("vol")) { vol.value = d.volume_db; el("vol-num").textContent = (d.volume_db >= 0 ? "+" : "") + d.volume_db.toFixed(1) + " dB"; } + if (!isBusy("wet")) { wet.value = d.wet; el("wet-num").textContent = (+d.wet).toFixed(2); } + if (!isBusy("loud") && d.loudness) { loud.value = d.loudness.target_db; el("loud-num").textContent = (+d.loudness.target_db).toFixed(1) + " dB"; } updateMeters(d.levels); + updateLoud(d.loudness_levels); state = d; } catch (e) { say("面板与 DSP 失联"); } } diff --git a/web/server.py b/web/server.py index da9b479..62a0cfc 100644 --- a/web/server.py +++ b/web/server.py @@ -108,6 +108,7 @@ class Handler(BaseHTTPRequestHandler): levels = common.read_state(self.store) self._send_json({ "freqs": common.EQ_FREQS, + "loudness_levels": common.read_loudness_state(), "gains": gains, "volume_db": volume_db, "wet": wet, diff --git a/测试/cdp_拖动验证.py b/测试/cdp_拖动验证.py new file mode 100644 index 0000000..b6126cb --- /dev/null +++ b/测试/cdp_拖动验证.py @@ -0,0 +1,72 @@ +"""用 CDP 发真实鼠标拖动, 验证总音量滑块不再被轮询弹回。 + +修前: 拖动中每 250ms 被 /api/state 覆盖 -> 松手后跳回旧值 +修后: busy 标志挡住覆盖 -> 停在拖到的位置并生效 +""" +from __future__ import annotations + +import json +import subprocess +import time +import urllib.request + +import websocket + +API = "http://127.0.0.1:8789" + +page = subprocess.Popen([ + "google-chrome", "--headless=new", "--disable-gpu", + "--remote-debugging-port=9333", "--remote-allow-origins=*", + "--user-data-dir=/tmp/cxchrome3", API], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) +time.sleep(4) + +tabs = json.load(urllib.request.urlopen("http://127.0.0.1:9333/json/list", timeout=15)) +ws_url = [t for t in tabs if t.get("type") == "page"][0]["webSocketDebuggerUrl"] +ws = websocket.create_connection(ws_url, timeout=25) +_mid = [0] + + +def cmd(method: str, **params: object) -> dict: + _mid[0] += 1 + ws.send(json.dumps({"id": _mid[0], "method": method, "params": params})) + while True: + msg = json.loads(ws.recv()) + if msg.get("id") == _mid[0]: + return msg.get("result") or {} + + +def js(expr: str) -> object: + res = cmd("Runtime.evaluate", expression=expr, returnByValue=True) + return (res.get("result") or {}).get("value") + + +box = js("(() => { const r = document.getElementById('vol').getBoundingClientRect();" + " return [r.left, r.top + r.height/2, r.width, r.height]; })()") +before = js("document.getElementById('vol').value") +print("滑块 box =", box, " 拖动前 vol =", before) + +x1 = box[0] + box[2] * 0.5 +x2 = box[0] + box[2] * 0.92 +y = box[1] +cmd("Input.dispatchMouseEvent", type="mousePressed", x=x1, y=y, button="left", clickCount=1) +for i in range(1, 11): + cmd("Input.dispatchMouseEvent", type="mouseMoved", + x=x1 + (x2 - x1) * i / 10.0, y=y, button="left") + time.sleep(0.09) +dragging = js("document.getElementById('vol').value") +cmd("Input.dispatchMouseEvent", type="mouseReleased", x=x2, y=y, button="left", clickCount=1) +time.sleep(0.15) +just_up = js("document.getElementById('vol').value") +print("拖动中 vol =", dragging, " 刚松手 vol =", just_up) + +time.sleep(1.5) +after = js("document.getElementById('vol').value") +srv = json.load(urllib.request.urlopen(API + "/api/state", timeout=15)) +print("1.5 秒后 vol =", after, " 服务端 volume_db =", srv["volume_db"]) + +same = abs(float(after) - float(just_up)) < 0.01 +synced = abs(float(after) - float(srv["volume_db"])) < 0.51 +print("结果:", "滑块停住且已生效 OK" if (same and synced) else "仍有回弹/未同步 FAIL") +ws.close() +page.terminate()