diff --git a/web/index.html b/web/index.html index 8d9b5ac..a19fc2e 100644 --- a/web/index.html +++ b/web/index.html @@ -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]]; 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) { if (!levels) return; - for (const [id, key, ch] of METERS) { - const db = (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); - } + for (const [id, key, ch] of METERS) meterSet(id, (levels[key] || [-120, -120])[ch]); + for (const [id, key, ch] of PK) peakSet(id, (levels[key] || [-120, -120])[ch]); } /* ---------- 归一化的电平与动态增益 ---------- */ 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 pkDb = 20 * Math.log10(Math.max(Math.max(ll.peak[0], ll.peak[1]), 1e-6)); + const prev = disp["loud"]; + const cur = (prev === undefined || pkDb >= prev) ? pkDb : Math.max(pkDb, prev - FALL_DB); + 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; // 两声道共用同一增益 - 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% 处(与刻度线对齐) el("loud-gain").style.left = Math.max(0, Math.min(100, (g + 18) / 30 * 100)) + "%"; } -/* ---------- 轮询 ---------- */ -async function tick() { - try { - const r = await fetch("/api/state"); - const d = await r.json(); - if (state === null) { - buildEq(d.freqs); - gains = d.gains.slice(); - eqBox.querySelectorAll("input").forEach((inp) => { - inp.value = gains[+inp.dataset.index]; - const v = inp.previousElementSibling; - const val = parseFloat(inp.value); - v.textContent = val === 0 ? "" : (val > 0 ? "+" : "") + val.toFixed(1); - }); - } - 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 失联"); } +/* ---------- 实时推送(约 60 Hz) ---------- */ +/* DSP 的状态本来就每块更新一次(1024/96000 = 10.7 ms)。原来 250 ms 轮询把表和 + 数字拖成了幻灯片, 换成 SSE 推送后按帧渲染。 */ +const es = new EventSource("/api/stream"); +es.onmessage = (ev) => { + try { applyState(JSON.parse(ev.data)); } catch (err) { /* 坏帧忽略 */ } +}; +es.onerror = () => say("面板与 DSP 失联, 重连中"); + +function applyState(d) { + if (state === null) { + buildEq(d.freqs); + gains = d.gains.slice(); + eqBox.querySelectorAll("input").forEach((inp) => { + inp.value = gains[+inp.dataset.index]; + const v = inp.previousElementSibling; + const val = parseFloat(inp.value); + v.textContent = val === 0 ? "" : (val > 0 ? "+" : "") + val.toFixed(1); + }); + } + 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; } el("flat").addEventListener("click", () => { gains = new Array(32).fill(0); commitEq(); buildEq(state ? state.freqs : []); }); -el("reset").addEventListener("click", () => { state = null; tick(); }); -tick(); -setInterval(tick, 250); +el("reset").addEventListener("click", () => { state = null; fetch("/api/state").then(r => r.json()).then(applyState).catch(() => say("面板与 DSP 失联")); }); +fetch("/api/state").then(r => r.json()).then(applyState).catch(() => say("面板与 DSP 失联")); diff --git a/web/server.py b/web/server.py index 62a0cfc..fc9aa05 100644 --- a/web/server.py +++ b/web/server.py @@ -16,6 +16,7 @@ import json import os import sys import threading +import time from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer 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 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") LOUDNESS_JSON = os.path.expanduser("~/.local/state/cinema-spatial/loudness.json") @@ -88,6 +90,43 @@ class Handler(BaseHTTPRequestHandler): return {} 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: if self.path in ("/", "/index.html"): try: @@ -103,20 +142,10 @@ class Handler(BaseHTTPRequestHandler): self.wfile.write(body) return if self.path == "/api/state": - with self.lock: - gains, volume_db, wet, _version = common.read_params(self.store) - 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, - "gain_limit": common.GAIN_LIMIT, - "volume_limit": common.VOLUME_LIMIT, - "levels": levels, - "loudness": read_loudness(), - }) + self._send_json(self._snapshot()) + return + if self.path == "/api/stream": + self._stream() return self.send_error(404)