面板刷新率 4Hz -> 62Hz: 服务端加 SSE 推送 /api/stream(FRAME_SEC=16ms), 前端改用 EventSource; 电平表加'上升立刻跟 + 下落每帧最多 0.9dB 平滑 + 峰值保持 1.6 秒', 归一化电平/增益同套平滑, 增益显示两位小数

This commit is contained in:
lou
2026-09-19 19:54:11 +08:00
parent 00c9ced2c0
commit bd9238e8b0
2 changed files with 102 additions and 54 deletions
+59 -40
View File
@@ -226,60 +226,79 @@ const METERS = [
]; ];
const PK = [["pk-inL","in_peak",0],["pk-outL","out_peak",0],["pk-inR","in_peak",1],["pk-outR","out_peak",1]]; const PK = [["pk-inL","in_peak",0],["pk-outL","out_peak",0],["pk-inR","in_peak",1],["pk-outR","out_peak",1]];
function fmt(db) { return db <= -119 ? "-∞" : db.toFixed(1); } function fmt(db) { return db <= -119 ? "-∞" : db.toFixed(1); }
/* 表头手感: 上升立刻跟(不丢瞬态), 下落按每帧固定 dB 平滑(不抖),
峰值保持 1.6 秒再回落。配上 60 Hz 推送, 数字才是"细腻"而不是乱跳。 */
const FALL_DB = 0.9;
const HOLD_SEC = 1.6;
const disp = {}, pkh = {}, pkt = {};
function meterSet(id, db) {
const prev = disp[id];
const cur = (prev === undefined || db >= prev) ? db : Math.max(db, prev - FALL_DB);
disp[id] = cur;
el("bar-" + id).style.width = Math.max(0, Math.min(100, (cur + 60) / 60 * 100)) + "%";
el("val-" + id).textContent = fmt(cur);
}
function peakSet(id, db) {
const now = Date.now();
if (pkh[id] === undefined || db >= pkh[id]) {
pkh[id] = db;
pkt[id] = now + HOLD_SEC * 1000;
} else if (now > (pkt[id] || 0)) {
pkh[id] = Math.max(db, pkh[id] - FALL_DB);
}
el(id).textContent = pkh[id] <= -119 ? "" : fmt(pkh[id]);
}
function updateMeters(levels) { function updateMeters(levels) {
if (!levels) return; if (!levels) return;
for (const [id, key, ch] of METERS) { for (const [id, key, ch] of METERS) meterSet(id, (levels[key] || [-120, -120])[ch]);
const db = (levels[key] || [-120, -120])[ch]; for (const [id, key, ch] of PK) peakSet(id, (levels[key] || [-120, -120])[ch]);
const pct = Math.max(0, Math.min(100, (db + 60) / 60 * 100));
el("bar-" + id).style.width = pct + "%";
el("val-" + id).textContent = fmt(db);
}
for (const [id, key, ch] of PK) {
const db = (levels[key] || [-120, -120])[ch];
el(id).textContent = db <= -119 ? "" : fmt(db);
}
} }
/* ---------- 归一化的电平与动态增益 ---------- */ /* ---------- 归一化的电平与动态增益 ---------- */
function updateLoud(ll) { function updateLoud(ll) {
if (!ll) return; if (!ll) return;
const pk = Math.max(ll.peak[0], ll.peak[1]); const pkDb = 20 * Math.log10(Math.max(Math.max(ll.peak[0], ll.peak[1]), 1e-6));
const pkDb = 20 * Math.log10(Math.max(pk, 1e-6)); const prev = disp["loud"];
el("bar-loud").style.width = Math.max(0, Math.min(100, (pkDb + 60) / 60 * 100)) + "%"; const cur = (prev === undefined || pkDb >= prev) ? pkDb : Math.max(pkDb, prev - FALL_DB);
el("loud-in").textContent = (pkDb <= -119 ? "-∞" : pkDb.toFixed(1)) + " dBFS"; disp["loud"] = cur;
el("bar-loud").style.width = Math.max(0, Math.min(100, (cur + 60) / 60 * 100)) + "%";
el("loud-in").textContent = fmt(cur) + " dBFS";
const g = (ll.gain_db[0] + ll.gain_db[1]) / 2; // 两声道共用同一增益 const g = (ll.gain_db[0] + ll.gain_db[1]) / 2; // 两声道共用同一增益
el("loud-gain-num").textContent = (g >= 0 ? "+" : "") + g.toFixed(1) + " dB"; el("loud-gain-num").textContent = (g >= 0 ? "+" : "") + g.toFixed(2) + " dB";
// 推子: 量程 -18 ~ +12 dB, 0 dB 落在 60% 处(与刻度线对齐) // 推子: 量程 -18 ~ +12 dB, 0 dB 落在 60% 处(与刻度线对齐)
el("loud-gain").style.left = Math.max(0, Math.min(100, (g + 18) / 30 * 100)) + "%"; el("loud-gain").style.left = Math.max(0, Math.min(100, (g + 18) / 30 * 100)) + "%";
} }
/* ---------- 轮询 ---------- */ /* ---------- 实时推送(约 60 Hz) ---------- */
async function tick() { /* DSP 的状态本来就每块更新一次(1024/96000 = 10.7 ms)。原来 250 ms 轮询把表和
try { 数字拖成了幻灯片, 换成 SSE 推送后按帧渲染。 */
const r = await fetch("/api/state"); const es = new EventSource("/api/stream");
const d = await r.json(); es.onmessage = (ev) => {
if (state === null) { try { applyState(JSON.parse(ev.data)); } catch (err) { /* 坏帧忽略 */ }
buildEq(d.freqs); };
gains = d.gains.slice(); es.onerror = () => say("面板与 DSP 失联, 重连中");
eqBox.querySelectorAll("input").forEach((inp) => {
inp.value = gains[+inp.dataset.index]; function applyState(d) {
const v = inp.previousElementSibling; if (state === null) {
const val = parseFloat(inp.value); buildEq(d.freqs);
v.textContent = val === 0 ? "" : (val > 0 ? "+" : "") + val.toFixed(1); gains = d.gains.slice();
}); eqBox.querySelectorAll("input").forEach((inp) => {
} inp.value = gains[+inp.dataset.index];
if (!isBusy("vol")) { vol.value = d.volume_db; el("vol-num").textContent = (d.volume_db >= 0 ? "+" : "") + d.volume_db.toFixed(1) + " dB"; } const v = inp.previousElementSibling;
if (!isBusy("wet")) { wet.value = d.wet; el("wet-num").textContent = (+d.wet).toFixed(2); } const val = parseFloat(inp.value);
if (!isBusy("loud") && d.loudness) { loud.value = d.loudness.target_db; el("loud-num").textContent = (+d.loudness.target_db).toFixed(1) + " dB"; } v.textContent = val === 0 ? "" : (val > 0 ? "+" : "") + val.toFixed(1);
updateMeters(d.levels); });
updateLoud(d.loudness_levels); }
state = d; if (!isBusy("vol")) { vol.value = d.volume_db; el("vol-num").textContent = (d.volume_db >= 0 ? "+" : "") + d.volume_db.toFixed(1) + " dB"; }
} catch (e) { say("面板与 DSP 失联"); } 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;
} }
el("flat").addEventListener("click", () => { gains = new Array(32).fill(0); commitEq(); buildEq(state ? state.freqs : []); }); el("flat").addEventListener("click", () => { gains = new Array(32).fill(0); commitEq(); buildEq(state ? state.freqs : []); });
el("reset").addEventListener("click", () => { state = null; tick(); }); el("reset").addEventListener("click", () => { state = null; fetch("/api/state").then(r => r.json()).then(applyState).catch(() => say("面板与 DSP 失联")); });
tick(); fetch("/api/state").then(r => r.json()).then(applyState).catch(() => say("面板与 DSP 失联"));
setInterval(tick, 250);
</script> </script>
</body> </body>
</html> </html>
+43 -14
View File
@@ -16,6 +16,7 @@ import json
import os import os
import sys import sys
import threading import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from typing import cast from typing import cast
@@ -24,6 +25,7 @@ sys.path.insert(0, os.path.join(os.path.dirname(os.path.abspath(__file__)), os.p
import common # noqa: E402 import common # noqa: E402
PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8789 PORT = int(sys.argv[1]) if len(sys.argv) > 1 else 8789
FRAME_SEC = 0.016 # 面板推送间隔(约 60 Hz)
UI_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html") UI_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "index.html")
LOUDNESS_JSON = os.path.expanduser("~/.local/state/cinema-spatial/loudness.json") LOUDNESS_JSON = os.path.expanduser("~/.local/state/cinema-spatial/loudness.json")
@@ -88,6 +90,43 @@ class Handler(BaseHTTPRequestHandler):
return {} return {}
return cast(dict[str, object], data) if isinstance(data, dict) else {} return cast(dict[str, object], data) if isinstance(data, dict) else {}
def _snapshot(self) -> dict[str, object]:
"""面板一帧数据: 参数 + 双声道电平 + 归一化状态 + 响度设置。"""
with self.lock:
gains, volume_db, wet, _version = common.read_params(self.store)
levels = common.read_state(self.store)
return {
"freqs": common.EQ_FREQS,
"loudness_levels": common.read_loudness_state(),
"gains": gains,
"volume_db": volume_db,
"wet": wet,
"gain_limit": common.GAIN_LIMIT,
"volume_limit": common.VOLUME_LIMIT,
"levels": levels,
"loudness": read_loudness(),
}
def _stream(self) -> None:
"""SSE 推送, 约 60 Hz。
DSP 的状态本来就每块更新一次(1024/96000 = 10.7 ms), 原来卡在面板 250 ms
轮询上, 电平表看着很跳。改成推送后前端能跟到帧率, 数字和条都细腻起来。
"""
self.send_response(200)
self.send_header("Content-Type", "text/event-stream; charset=utf-8")
self.send_header("Cache-Control", "no-cache")
self.send_header("Connection", "keep-alive")
self.end_headers()
try:
while True:
body = json.dumps(self._snapshot(), ensure_ascii=False)
self.wfile.write(("data: %s\n\n" % body).encode("utf-8"))
self.wfile.flush()
time.sleep(FRAME_SEC)
except (BrokenPipeError, ConnectionResetError, OSError):
return
def do_GET(self) -> None: def do_GET(self) -> None:
if self.path in ("/", "/index.html"): if self.path in ("/", "/index.html"):
try: try:
@@ -103,20 +142,10 @@ class Handler(BaseHTTPRequestHandler):
self.wfile.write(body) self.wfile.write(body)
return return
if self.path == "/api/state": if self.path == "/api/state":
with self.lock: self._send_json(self._snapshot())
gains, volume_db, wet, _version = common.read_params(self.store) return
levels = common.read_state(self.store) if self.path == "/api/stream":
self._send_json({ self._stream()
"freqs": common.EQ_FREQS,
"loudness_levels": common.read_loudness_state(),
"gains": gains,
"volume_db": volume_db,
"wet": wet,
"gain_limit": common.GAIN_LIMIT,
"volume_limit": common.VOLUME_LIMIT,
"levels": levels,
"loudness": read_loudness(),
})
return return
self.send_error(404) self.send_error(404)