前端: 归一化加输入电平与动态增益显示(读归一化 DSP 状态); 修总音量滑块回弹(activeElement 判断不准 -> 显式 busy 标志 + 松手静默期), CDP 真鼠标拖动实测 4->10.5 停住不回弹

This commit is contained in:
lou
2026-09-19 19:47:11 +08:00
parent 6f5729baf2
commit 00c9ced2c0
4 changed files with 155 additions and 4 deletions
+29 -1
View File
@@ -21,7 +21,8 @@ import struct
EQ_BANDS = 32 EQ_BANDS = 32
CHANNELS = 2 CHANNELS = 2
EQ_PATH = "/dev/shm/collaplex-eq" 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 GAIN_LIMIT = 15.0
VOLUME_LIMIT = 12.0 VOLUME_LIMIT = 12.0
@@ -39,6 +40,7 @@ _PARAMS = struct.Struct("<34d") # 32 段增益 + 总音量 + 湿量
_VER = struct.Struct("<I") # 参数版本号 _VER = struct.Struct("<I") # 参数版本号
_CH = struct.Struct("<6d") # in_rms in_peak out_rms out_peak gain_db 保留 _CH = struct.Struct("<6d") # in_rms in_peak out_rms out_peak gain_db 保留
_STATE = struct.Struct("<12d") # 2 声道 _STATE = struct.Struct("<12d") # 2 声道
_LOUD_F = struct.Struct("<f") # 归一化 DSP 状态里的单精度字段
PARAMS_OFF = 0 PARAMS_OFF = 0
VER_OFF = _PARAMS.size VER_OFF = _PARAMS.size
@@ -124,3 +126,29 @@ def claim_channel() -> int:
def defaults() -> tuple[list[float], float, float]: def defaults() -> tuple[list[float], float, float]:
"""默认参数: 全平直 + 0 dB 总音量 + 0.3 混响湿量。""" """默认参数: 全平直 + 0 dB 总音量 + 0.3 混响湿量。"""
return [0.0] * EQ_BANDS, 0.0, 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),
}
+53 -3
View File
@@ -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)} 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} .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 ---- */
.eq{display:flex;align-items:flex-end;gap:3px;height:190px;padding-top:6px} .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%} .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)}
<input type="range" id="loud" min="-30" max="-6" step="0.5"> <input type="range" id="loud" min="-30" max="-6" step="0.5">
<span class="num" id="loud-num">—</span> <span class="num" id="loud-num">—</span>
</div> </div>
<div class="viz">
<span class="lbl">输入电平</span>
<div class="bar"><i id="bar-loud"></i></div>
<span class="num" id="loud-in">-∞</span>
</div>
<div class="viz">
<span class="lbl">动态增益</span>
<div class="gauge"><i id="loud-gain" style="left:60%"></i></div>
<span class="num" id="loud-gain-num">—</span>
</div>
<div class="row"> <div class="row">
<span class="name">混响湿度</span> <span class="name">混响湿度</span>
<input type="range" id="wet" min="0" max="1" step="0.01"> <input type="range" id="wet" min="0" max="1" step="0.01">
@@ -134,6 +154,22 @@ function bindWheel(input, onDelta, onDone) {
/* ---------- 三个控制滑块 ---------- */ /* ---------- 三个控制滑块 ---------- */
const loud = el("loud"), wet = el("wet"), vol = el("vol"); 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"); loud.addEventListener("input", () => el("loud-num").textContent = (+loud.value).toFixed(1) + " dB");
wet.addEventListener("input", () => el("wet-num").textContent = (+wet.value).toFixed(2)); 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"); 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() { async function tick() {
try { try {
@@ -219,10 +268,11 @@ async function tick() {
v.textContent = val === 0 ? "" : (val > 0 ? "+" : "") + val.toFixed(1); 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 (!isBusy("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 (!isBusy("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("loud") && d.loudness) { loud.value = d.loudness.target_db; el("loud-num").textContent = (+d.loudness.target_db).toFixed(1) + " dB"; }
updateMeters(d.levels); updateMeters(d.levels);
updateLoud(d.loudness_levels);
state = d; state = d;
} catch (e) { say("面板与 DSP 失联"); } } catch (e) { say("面板与 DSP 失联"); }
} }
+1
View File
@@ -108,6 +108,7 @@ class Handler(BaseHTTPRequestHandler):
levels = common.read_state(self.store) levels = common.read_state(self.store)
self._send_json({ self._send_json({
"freqs": common.EQ_FREQS, "freqs": common.EQ_FREQS,
"loudness_levels": common.read_loudness_state(),
"gains": gains, "gains": gains,
"volume_db": volume_db, "volume_db": volume_db,
"wet": wet, "wet": wet,
+72
View File
@@ -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()