前端: 归一化加输入电平与动态增益显示(读归一化 DSP 状态); 修总音量滑块回弹(activeElement 判断不准 -> 显式 busy 标志 + 松手静默期), CDP 真鼠标拖动实测 4->10.5 停住不回弹
This commit is contained in:
+29
-1
@@ -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
@@ -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 失联"); }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user