Files
collaplex-audio/web/index.html
T

305 lines
15 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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;
--fg:#e8e8e8; --mut:#8a8a8a; --dim:#666; --ok:#4ade80; --warn:#fbbf24; --hot:#f87171;
}
*{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}
input[type=range]::-webkit-slider-runnable-track{height:4px;background:var(--bd-hi);border-radius:2px}
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}
input[type=range]::-moz-range-track{height:4px;background:var(--bd-hi);border-radius:2px}
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}
/* ---- 归一化的电平与动态增益 ---- */
.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{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}
button:hover{border-color:var(--ok);color:var(--ok)}
</style>
</head>
<body>
<div class="head">
<h1>Collaplex 音效<small>滚轮调节 · Shift+滚轮微调 · 双击归零</small></h1>
<div class="status" id="status">就绪</div>
</div>
<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>
<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>
<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">
<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>
</div>
</div>
<div class="card">
<h2>32 段 EQ(1/3 倍频程 · ±15 dB)</h2>
<div class="eq" id="eq"></div>
<div class="btns">
<button id="flat">全部归零</button>
<button id="reset">重新读取</button>
</div>
</div>
<script>
const el = (id) => document.getElementById(id);
let gains = new Array(32).fill(0);
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) => {
e.preventDefault();
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});
}
/* ---------- 三个控制滑块 ---------- */
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");
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);
}
state = null;
function buildEq(freqs) {
eqBox.innerHTML = "";
freqs.forEach((f, i) => {
const band = document.createElement("div");
band.className = "band";
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();
});
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);
});
}
/* ---------- 电平表 ---------- */
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); }
/* 表头手感: 上升立刻跟(不丢瞬态), 下落按每帧固定 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) 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 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(2) + " dB";
// 推子: 量程 -18 ~ +12 dB, 0 dB 落在 60% 处(与刻度线对齐)
el("loud-gain").style.left = Math.max(0, Math.min(100, (g + 18) / 30 * 100)) + "%";
}
/* ---------- 实时推送(约 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; fetch("/api/state").then(r => r.json()).then(applyState).catch(() => say("面板与 DSP 失联")); });
fetch("/api/state").then(r => r.json()).then(applyState).catch(() => say("面板与 DSP 失联"));
</script>
</body>
</html>