Files
collaplex-audio/web/index.html
T
lou 2566307e6c EQ 预设加「电影院」「歌剧院」+ 预设按钮选中高亮
两个新预设按现实场所的声学设计, 不是随手画的微笑曲线:
- 电影院: 高频按 X-curve(ISO 2969 国际版, 2 kHz 以上约 -1.5 dB/oct, 实测 2k->4k->8k->16k
  逐段 -1.5)再叠银幕透声损失; 低频 25~60 Hz +2.5~3 dB(影院 sub / LFE 通道的体感)
  —— 厚, 暗, 有力
- 歌剧院: 中低频 150~400 Hz +2 dB(木质厅堂的温暖); 2~5 kHz +0.5~1 dB(没有扩声系统,
  歌手靠 presence 区投射, 这里不能削); 极低频 20~40 Hz -1 dB; 高频只随大空间空气吸收
  缓降 —— 暖, 亮, 有细节

两者实测差: 25~60 Hz 影院重 3.4 dB / 3.15~5 kHz 歌剧院足 3.0 dB / 8~16 kHz 歌剧院足 3.5 dB。

顺带修: `.presets button.on` 的样式一直定义了但没有任何代码加这个类 —— 点了预设没有选中
反馈。加 markPreset(): 点预设高亮对应按钮, 手动拖滑块 / 全部归零时同步更新。

验收(CDP 真实点击): 两个预设的 32 段值与设计逐位一致; 高亮 = 对应按钮名; 手动拖滑块后
高亮清空。README 加「EQ 预设的设计依据」节(含影院/歌剧院的混响湿量建议)。
2026-09-20 13:51:25 +08:00

384 lines
19 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 预设 ---- */
.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}
/* ---- 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 段 EQ1/3 倍频程 · ±15 dB</h2>
<div class="presets" id="presets"></div>
<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();
markPreset(null); // 手动改过就不是任何预设了
});
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);
});
}
/* ---------- 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]], // 削刺耳高频
// 影院: 高频按 X-curve(ISO 2969 国际版, 2 kHz 以上约 -1.5 dB/oct; 北美 SMPTE 是 -3),
// 再叠一层银幕透声损失(4~10 kHz); 低频是影院 sub / LFE 通道的体感 -- 厚, 暗, 有力.
"电影院": [[25, 2.5], [60, 3], [150, 0.5], [400, -0.5], [1000, 0],
[2000, -0.5], [4000, -2], [8000, -3.5], [16000, -5], [25000, -5.5]],
// 歌剧院: 木质厅堂的温暖落在 150~400 Hz; 歌手不用麦, 全靠 2~5 kHz 的 presence 投射,
// 所以这里要抬不能削; 大空间的空气吸收只削极高频 -- 暖, 亮, 有细节, 没有 sub 轰鸣.
"歌剧院": [[30, -1], [80, 0.5], [160, 2], [300, 2], [700, 1], [1500, 0],
[3000, 1], [5000, 1], [9000, 0], [16000, -1.5], [25000, -2]],
};
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();
markPreset(name);
say("EQ 预设: " + name);
}
/* 点了哪个预设要看得见(不然选没选中没反馈); 手动改滑块即取消高亮。 */
function markPreset(name) {
const box = el("presets");
if (!box) return;
box.querySelectorAll("button").forEach((b) => {
b.classList.toggle("on", name !== null && b.textContent === 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);
});
}
/* ---------- 电平表 ---------- */
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) {
buildPresets();
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(); markPreset("平直"); 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>