2026-09-19 18:34:56 +08:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="zh-CN">
|
|
|
|
|
|
<head>
|
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
|
<title>Collaplex 音效</title>
|
|
|
|
|
|
<style>
|
|
|
|
|
|
:root{
|
|
|
|
|
|
--bg:#0f0f0f; --card:#161616; --card-hi:#1c1c1c; --bd:#262626; --bd-hi:#343434;
|
2026-09-19 19:37:54 +08:00
|
|
|
|
--fg:#e8e8e8; --mut:#8a8a8a; --dim:#666; --ok:#4ade80; --warn:#fbbf24; --hot:#f87171;
|
2026-09-19 18:34:56 +08:00
|
|
|
|
}
|
2026-09-19 19:37:54 +08:00
|
|
|
|
*{box-sizing:border-box;margin:0;padding:0}
|
|
|
|
|
|
body{background:var(--bg);color:var(--fg);font-family:"JetBrains Mono",ui-monospace,Menlo,Consolas,monospace;
|
|
|
|
|
|
font-size:13px;padding:18px 20px 26px}
|
|
|
|
|
|
h1{font-size:16px;font-weight:600;letter-spacing:.5px}
|
|
|
|
|
|
h1 small{color:var(--mut);font-size:11px;font-weight:400;margin-left:10px}
|
|
|
|
|
|
.head{display:flex;align-items:baseline;justify-content:space-between;margin-bottom:14px;gap:12px}
|
|
|
|
|
|
.status{color:var(--mut);font-size:11px}
|
|
|
|
|
|
.card{background:var(--card);border:1px solid var(--bd);border-radius:12px;padding:14px 16px;margin-bottom:14px}
|
|
|
|
|
|
.card h2{font-size:11px;font-weight:500;color:var(--mut);letter-spacing:1px;margin-bottom:12px;text-transform:uppercase}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---- 电平表 ---- */
|
|
|
|
|
|
.meters{display:grid;grid-template-columns:1fr 1fr;gap:10px 22px}
|
|
|
|
|
|
.meter{display:flex;align-items:center;gap:9px}
|
|
|
|
|
|
.meter .lbl{width:30px;color:var(--mut);font-size:11px;flex:none}
|
|
|
|
|
|
.bar{position:relative;flex:1;height:12px;background:#111;border:1px solid var(--bd);border-radius:3px;overflow:hidden}
|
|
|
|
|
|
.bar i{position:absolute;left:0;top:0;bottom:0;width:0%;
|
|
|
|
|
|
background:linear-gradient(90deg,#166534,#4ade80 72%,#fbbf24 90%,#f87171);transition:width .08s linear}
|
|
|
|
|
|
.meter .val{width:56px;text-align:right;font-size:11px;color:var(--fg);flex:none;font-variant-numeric:tabular-nums}
|
|
|
|
|
|
.meter .pk{font-size:10px;color:var(--dim);width:52px;text-align:right;flex:none}
|
|
|
|
|
|
|
|
|
|
|
|
/* ---- 滑块 ---- */
|
|
|
|
|
|
.row{display:flex;align-items:center;gap:14px;margin-bottom:12px}
|
|
|
|
|
|
.row:last-child{margin-bottom:0}
|
|
|
|
|
|
.row .name{width:96px;flex:none;color:var(--fg);font-size:12px}
|
|
|
|
|
|
.row .num{width:84px;text-align:right;flex:none;color:var(--ok);font-variant-numeric:tabular-nums}
|
|
|
|
|
|
input[type=range]{-webkit-appearance:none;appearance:none;flex:1;height:22px;background:transparent;cursor:ns-resize}
|
2026-09-19 18:34:56 +08:00
|
|
|
|
input[type=range]::-webkit-slider-runnable-track{height:4px;background:var(--bd-hi);border-radius:2px}
|
2026-09-19 19:37:54 +08:00
|
|
|
|
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;margin-top:-5px;
|
|
|
|
|
|
border-radius:50%;background:var(--ok);border:none}
|
|
|
|
|
|
input[type=range]:hover::-webkit-slider-thumb{background:#86efac}
|
2026-09-19 18:34:56 +08:00
|
|
|
|
input[type=range]::-moz-range-track{height:4px;background:var(--bd-hi);border-radius:2px}
|
2026-09-19 19:37:54 +08:00
|
|
|
|
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}
|
|
|
|
|
|
|
2026-09-19 19:47:11 +08:00
|
|
|
|
/* ---- 归一化的电平与动态增益 ---- */
|
|
|
|
|
|
.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}
|
|
|
|
|
|
|
2026-09-19 19:56:58 +08:00
|
|
|
|
/* ---- EQ 预设 ---- */
|
|
|
|
|
|
.presets{display:flex;flex-wrap:wrap;gap:6px;margin-bottom:12px}
|
|
|
|
|
|
.presets button{padding:4px 11px;font-size:11px}
|
|
|
|
|
|
.presets button.on{border-color:var(--ok);color:var(--ok);background:#12211a}
|
|
|
|
|
|
|
2026-09-19 19:37:54 +08:00
|
|
|
|
/* ---- 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%}
|
|
|
|
|
|
.band .v{font-size:9px;color:var(--dim);height:11px;font-variant-numeric:tabular-nums}
|
|
|
|
|
|
.band input{writing-mode:vertical-lr;direction:rtl;width:100%;height:100%;
|
|
|
|
|
|
-webkit-appearance:none;appearance:none;background:transparent;cursor:ns-resize}
|
|
|
|
|
|
.band input::-webkit-slider-runnable-track{width:4px;height:100%;background:var(--bd-hi);border-radius:2px}
|
|
|
|
|
|
.band input::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:9px;margin-left:-5px;
|
|
|
|
|
|
border-radius:2px;background:var(--ok)}
|
|
|
|
|
|
.band:hover input::-webkit-slider-thumb{background:#86efac}
|
|
|
|
|
|
.band input::-moz-range-track{width:4px;background:var(--bd-hi);border-radius:2px}
|
|
|
|
|
|
.band input::-moz-range-thumb{width:14px;height:9px;border:none;border-radius:2px;background:var(--ok)}
|
|
|
|
|
|
.band .f{font-size:9px;color:var(--mut);transform:rotate(-60deg);transform-origin:center;height:20px;
|
|
|
|
|
|
white-space:nowrap;margin-top:2px}
|
|
|
|
|
|
.btns{display:flex;gap:8px;margin-top:12px}
|
|
|
|
|
|
button{background:var(--card-hi);color:var(--fg);border:1px solid var(--bd-hi);border-radius:6px;
|
|
|
|
|
|
padding:5px 12px;font:inherit;font-size:11px;cursor:pointer}
|
2026-09-19 18:34:56 +08:00
|
|
|
|
button:hover{border-color:var(--ok);color:var(--ok)}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
2026-09-19 19:37:54 +08:00
|
|
|
|
<div class="head">
|
|
|
|
|
|
<h1>Collaplex 音效<small>滚轮调节 · Shift+滚轮微调 · 双击归零</small></h1>
|
|
|
|
|
|
<div class="status" id="status">就绪</div>
|
|
|
|
|
|
</div>
|
2026-09-19 18:34:56 +08:00
|
|
|
|
|
2026-09-19 19:37:54 +08:00
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>电平</h2>
|
|
|
|
|
|
<div class="meters">
|
|
|
|
|
|
<div class="meter"><span class="lbl">入 L</span><div class="bar"><i id="bar-inL"></i></div><span class="val" id="val-inL">-∞</span><span class="pk" id="pk-inL"></span></div>
|
|
|
|
|
|
<div class="meter"><span class="lbl">出 L</span><div class="bar"><i id="bar-outL"></i></div><span class="val" id="val-outL">-∞</span><span class="pk" id="pk-outL"></span></div>
|
|
|
|
|
|
<div class="meter"><span class="lbl">入 R</span><div class="bar"><i id="bar-inR"></i></div><span class="val" id="val-inR">-∞</span><span class="pk" id="pk-inR"></span></div>
|
|
|
|
|
|
<div class="meter"><span class="lbl">出 R</span><div class="bar"><i id="bar-outR"></i></div><span class="val" id="val-outR">-∞</span><span class="pk" id="pk-outR"></span></div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="hint">条长 = 实时 RMS(-60 ~ 0 dBFS),右侧小字 = 本块峰值</div>
|
|
|
|
|
|
</div>
|
2026-09-19 18:34:56 +08:00
|
|
|
|
|
2026-09-19 19:37:54 +08:00
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>控制</h2>
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
|
<span class="name">电平归一化</span>
|
|
|
|
|
|
<input type="range" id="loud" min="-30" max="-6" step="0.5">
|
|
|
|
|
|
<span class="num" id="loud-num">—</span>
|
|
|
|
|
|
</div>
|
2026-09-19 19:47:11 +08:00
|
|
|
|
<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>
|
2026-09-19 19:37:54 +08:00
|
|
|
|
<div class="row">
|
|
|
|
|
|
<span class="name">混响湿度</span>
|
|
|
|
|
|
<input type="range" id="wet" min="0" max="1" step="0.01">
|
|
|
|
|
|
<span class="num" id="wet-num">—</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="row">
|
|
|
|
|
|
<span class="name">总音量</span>
|
|
|
|
|
|
<input type="range" id="vol" min="-12" max="12" step="0.5">
|
|
|
|
|
|
<span class="num" id="vol-num">—</span>
|
2026-09-19 18:34:56 +08:00
|
|
|
|
</div>
|
2026-09-19 19:37:54 +08:00
|
|
|
|
</div>
|
2026-09-19 18:34:56 +08:00
|
|
|
|
|
2026-09-19 19:37:54 +08:00
|
|
|
|
<div class="card">
|
|
|
|
|
|
<h2>32 段 EQ(1/3 倍频程 · ±15 dB)</h2>
|
2026-09-19 19:56:58 +08:00
|
|
|
|
<div class="presets" id="presets"></div>
|
2026-09-19 19:37:54 +08:00
|
|
|
|
<div class="eq" id="eq"></div>
|
|
|
|
|
|
<div class="btns">
|
|
|
|
|
|
<button id="flat">全部归零</button>
|
|
|
|
|
|
<button id="reset">重新读取</button>
|
2026-09-19 18:34:56 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<script>
|
2026-09-19 19:37:54 +08:00
|
|
|
|
const el = (id) => document.getElementById(id);
|
2026-09-19 18:34:56 +08:00
|
|
|
|
let gains = new Array(32).fill(0);
|
2026-09-19 19:37:54 +08:00
|
|
|
|
let state = null;
|
|
|
|
|
|
const post = async (path, body) => {
|
|
|
|
|
|
const r = await fetch(path, {method:"POST", headers:{"Content-Type":"application/json"}, body:JSON.stringify(body)});
|
|
|
|
|
|
return r.ok;
|
|
|
|
|
|
};
|
|
|
|
|
|
const say = (t) => { el("status").textContent = t; };
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------- 滚轮捕获 ---------- */
|
|
|
|
|
|
function bindWheel(input, onDelta, onDone) {
|
|
|
|
|
|
input.addEventListener("wheel", (e) => {
|
2026-09-19 18:34:56 +08:00
|
|
|
|
e.preventDefault();
|
2026-09-19 19:37:54 +08:00
|
|
|
|
const step = parseFloat(input.step) || 1;
|
|
|
|
|
|
const fine = e.shiftKey ? step / 5 : step;
|
|
|
|
|
|
const next = Math.min(parseFloat(input.max), Math.max(parseFloat(input.min),
|
|
|
|
|
|
parseFloat(input.value) + (e.deltaY < 0 ? fine : -fine)));
|
|
|
|
|
|
input.value = next;
|
|
|
|
|
|
onDelta(next);
|
|
|
|
|
|
if (onDone) clearTimeout(input._t), input._t = setTimeout(onDone, 90);
|
|
|
|
|
|
}, {passive:false});
|
2026-09-19 18:34:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 19:37:54 +08:00
|
|
|
|
/* ---------- 三个控制滑块 ---------- */
|
|
|
|
|
|
const loud = el("loud"), wet = el("wet"), vol = el("vol");
|
2026-09-19 19:47:11 +08:00
|
|
|
|
|
|
|
|
|
|
/* 正在拖动/滚动的控件不能再用轮询结果覆盖 —— 否则每 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});
|
|
|
|
|
|
});
|
2026-09-19 19:37:54 +08:00
|
|
|
|
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");
|
|
|
|
|
|
loud.addEventListener("change", async () => { await post("/api/loudness", {target_db:+loud.value}); say("归一化已生效"); });
|
|
|
|
|
|
wet.addEventListener("change", async () => { await post("/api/wet", {wet:+wet.value}); say("湿度已生效"); });
|
|
|
|
|
|
vol.addEventListener("change", async () => { await post("/api/volume", {db:+vol.value}); say("总音量已生效"); });
|
|
|
|
|
|
bindWheel(loud, v => el("loud-num").textContent = v.toFixed(1) + " dB", () => loud.dispatchEvent(new Event("change")));
|
|
|
|
|
|
bindWheel(wet, v => el("wet-num").textContent = v.toFixed(2), () => wet.dispatchEvent(new Event("change")));
|
|
|
|
|
|
bindWheel(vol, v => el("vol-num").textContent = (v >= 0 ? "+" : "") + v.toFixed(1) + " dB", () => vol.dispatchEvent(new Event("change")));
|
|
|
|
|
|
[loud, wet, vol].forEach(s => s.addEventListener("dblclick", () => {
|
|
|
|
|
|
s.value = s.dataset.def; s.dispatchEvent(new Event("input")); s.dispatchEvent(new Event("change"));
|
|
|
|
|
|
}));
|
|
|
|
|
|
loud.dataset.def = -16; wet.dataset.def = 0.3; vol.dataset.def = 0;
|
|
|
|
|
|
|
|
|
|
|
|
/* ---------- 32 段 EQ ---------- */
|
|
|
|
|
|
const eqBox = el("eq");
|
|
|
|
|
|
let commitTimer = null;
|
|
|
|
|
|
function commitEq() {
|
|
|
|
|
|
clearTimeout(commitTimer);
|
|
|
|
|
|
commitTimer = setTimeout(async () => {
|
|
|
|
|
|
const ok = await post("/api/eq", {gains});
|
|
|
|
|
|
say(ok ? "EQ 已生效" : "EQ 写入失败");
|
|
|
|
|
|
}, 90);
|
2026-09-19 18:34:56 +08:00
|
|
|
|
}
|
2026-09-19 19:37:54 +08:00
|
|
|
|
state = null;
|
|
|
|
|
|
function buildEq(freqs) {
|
|
|
|
|
|
eqBox.innerHTML = "";
|
|
|
|
|
|
freqs.forEach((f, i) => {
|
2026-09-19 18:34:56 +08:00
|
|
|
|
const band = document.createElement("div");
|
|
|
|
|
|
band.className = "band";
|
2026-09-19 19:37:54 +08:00
|
|
|
|
const v = document.createElement("div"); v.className = "v"; v.textContent = "0";
|
|
|
|
|
|
const input = document.createElement("input");
|
|
|
|
|
|
input.type = "range"; input.min = -15; input.max = 15; input.step = 0.5; input.value = 0;
|
|
|
|
|
|
input.dataset.index = i;
|
|
|
|
|
|
const label = document.createElement("div"); label.className = "f";
|
|
|
|
|
|
label.textContent = f >= 1000 ? (f / 1000) + "k" : f;
|
|
|
|
|
|
input.addEventListener("input", () => {
|
|
|
|
|
|
const val = parseFloat(input.value);
|
|
|
|
|
|
v.textContent = val === 0 ? "" : (val > 0 ? "+" : "") + val.toFixed(1);
|
|
|
|
|
|
gains[i] = val;
|
|
|
|
|
|
commitEq();
|
2026-09-19 18:34:56 +08:00
|
|
|
|
});
|
2026-09-19 19:37:54 +08:00
|
|
|
|
bindWheel(input, (val) => { input.value = val; input.dispatchEvent(new Event("input")); });
|
|
|
|
|
|
input.addEventListener("dblclick", () => { input.value = 0; input.dispatchEvent(new Event("input")); });
|
|
|
|
|
|
band.append(v, input, label);
|
|
|
|
|
|
eqBox.appendChild(band);
|
|
|
|
|
|
});
|
2026-09-19 18:34:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 19:56:58 +08:00
|
|
|
|
/* ---------- EQ 预设 ---------- */
|
|
|
|
|
|
/* 控制点 = [频率 Hz, 增益 dB], 在对数频率轴上插值成 32 段 —— 比写死 32 个数好维护。 */
|
|
|
|
|
|
const PRESETS = {
|
|
|
|
|
|
"平直": [],
|
|
|
|
|
|
"流行": [[60, 2.5], [250, -1], [1000, 1], [4000, 2.5], [12000, 3]],
|
|
|
|
|
|
"摇滚": [[60, 5], [250, -2], [1000, -1], [3500, 3], [12000, 5]],
|
|
|
|
|
|
"爵士": [[60, 4], [200, 1.5], [800, -1], [3000, 1.5], [12000, 3]],
|
|
|
|
|
|
"古典": [[60, 3], [400, -1], [2000, 0.5], [8000, 2], [16000, 3]],
|
|
|
|
|
|
"人声": [[100, -3], [500, -1], [2000, 4], [6000, 2], [12000, 1]],
|
|
|
|
|
|
"低音": [[40, 7], [120, 5], [300, 2], [1000, 0], [4000, -1]],
|
|
|
|
|
|
"高音": [[200, -1], [2000, 1], [6000, 4], [14000, 6]],
|
|
|
|
|
|
"深夜": [[60, 4], [200, 3], [1000, 0], [4000, 1], [12000, 3]], // 小音量等响度补偿
|
|
|
|
|
|
"柔化": [[3000, -2], [6000, -4], [12000, -5], [16000, -3]], // 削刺耳高频
|
|
|
|
|
|
};
|
|
|
|
|
|
function curveTo(pts, freqs) {
|
|
|
|
|
|
if (!pts.length) return freqs.map(() => 0);
|
|
|
|
|
|
const first = pts[0], last = pts[pts.length - 1];
|
|
|
|
|
|
return freqs.map((f) => {
|
|
|
|
|
|
if (f <= first[0]) return first[1];
|
|
|
|
|
|
if (f >= last[0]) return last[1];
|
|
|
|
|
|
for (let i = 0; i < pts.length - 1; i++) {
|
|
|
|
|
|
const a = pts[i], b = pts[i + 1];
|
|
|
|
|
|
if (f >= a[0] && f <= b[0]) {
|
|
|
|
|
|
const t = Math.log(f / a[0]) / Math.log(b[0] / a[0]);
|
|
|
|
|
|
return a[1] + (b[1] - a[1]) * t;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return 0;
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
function applyPreset(name) {
|
|
|
|
|
|
const freqs = state ? state.freqs : [];
|
|
|
|
|
|
if (!freqs.length) return;
|
|
|
|
|
|
const vals = curveTo(PRESETS[name], freqs).map((g) => Math.round(g * 2) / 2); // 对齐 0.5 步进
|
|
|
|
|
|
gains = vals.slice();
|
|
|
|
|
|
eqBox.querySelectorAll("input").forEach((inp, i) => {
|
|
|
|
|
|
inp.value = vals[i];
|
|
|
|
|
|
const v = inp.previousElementSibling;
|
|
|
|
|
|
v.textContent = vals[i] === 0 ? "" : (vals[i] > 0 ? "+" : "") + vals[i].toFixed(1);
|
|
|
|
|
|
});
|
|
|
|
|
|
commitEq();
|
|
|
|
|
|
say("EQ 预设: " + name);
|
|
|
|
|
|
}
|
|
|
|
|
|
function buildPresets() {
|
|
|
|
|
|
const box = el("presets");
|
|
|
|
|
|
box.innerHTML = "";
|
|
|
|
|
|
Object.keys(PRESETS).forEach((name) => {
|
|
|
|
|
|
const b = document.createElement("button");
|
|
|
|
|
|
b.textContent = name;
|
|
|
|
|
|
b.addEventListener("click", () => applyPreset(name));
|
|
|
|
|
|
box.appendChild(b);
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 19:37:54 +08:00
|
|
|
|
/* ---------- 电平表 ---------- */
|
|
|
|
|
|
const METERS = [
|
|
|
|
|
|
["inL", "in_rms", 0], ["outL", "out_rms", 0],
|
|
|
|
|
|
["inR", "in_rms", 1], ["outR", "out_rms", 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); }
|
2026-09-19 19:54:11 +08:00
|
|
|
|
/* 表头手感: 上升立刻跟(不丢瞬态), 下落按每帧固定 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]);
|
|
|
|
|
|
}
|
2026-09-19 19:37:54 +08:00
|
|
|
|
function updateMeters(levels) {
|
|
|
|
|
|
if (!levels) return;
|
2026-09-19 19:54:11 +08:00
|
|
|
|
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]);
|
2026-09-19 18:34:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 19:47:11 +08:00
|
|
|
|
/* ---------- 归一化的电平与动态增益 ---------- */
|
|
|
|
|
|
function updateLoud(ll) {
|
|
|
|
|
|
if (!ll) return;
|
2026-09-19 19:54:11 +08:00
|
|
|
|
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";
|
2026-09-19 19:47:11 +08:00
|
|
|
|
const g = (ll.gain_db[0] + ll.gain_db[1]) / 2; // 两声道共用同一增益
|
2026-09-19 19:54:11 +08:00
|
|
|
|
el("loud-gain-num").textContent = (g >= 0 ? "+" : "") + g.toFixed(2) + " dB";
|
2026-09-19 19:47:11 +08:00
|
|
|
|
// 推子: 量程 -18 ~ +12 dB, 0 dB 落在 60% 处(与刻度线对齐)
|
|
|
|
|
|
el("loud-gain").style.left = Math.max(0, Math.min(100, (g + 18) / 30 * 100)) + "%";
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-19 19:54:11 +08:00
|
|
|
|
/* ---------- 实时推送(约 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) {
|
2026-09-19 19:56:58 +08:00
|
|
|
|
buildPresets();
|
2026-09-19 19:54:11 +08:00
|
|
|
|
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;
|
2026-09-19 19:37:54 +08:00
|
|
|
|
}
|
|
|
|
|
|
el("flat").addEventListener("click", () => { gains = new Array(32).fill(0); commitEq(); buildEq(state ? state.freqs : []); });
|
2026-09-19 19:54:11 +08:00
|
|
|
|
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 失联"));
|
2026-09-19 18:34:56 +08:00
|
|
|
|
</script>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>
|