Files
糖糖 d331128e88 输出设备不写死: 面板设备选择 + 自动挂载守护(治开机后"双重混响")
- 问题: 30-collaplex-eq.conf 的 node.target 是安装时探测一次写死的。开机时设备若还没枚举
  (USB 耳机晚到很常见: 09-24 实测开机 08:57、耳机 09:19:43 才出现; 09-23 是 21:01 → 21:03),
  建链时目标不存在 → 末段连到别处或干脆不连, 设备出现后不会自己改回来 → 听感"双重混响",
  只能 stop + start
- 新增 web/output_route.py: 设备清单(pw-dump) + 选择落盘(output.json) + 守护线程(每 3 秒核对
  collaplex_eq_out 只连选中设备, 不对就拆错的、按声道补对的) → 设备一出现自动挂上
- web/server.py: GET /api/devices, POST /api/device(落盘 + 立刻挂载), 快照带 output 段,
  main() 起守护线程
- web/index.html: 「输出设备」卡片(下拉 + 应用 + 状态 + 守护备注; 设备表变化才重建选项, 免得
  60 Hz 重建打断点击)
- 脚本/安装.sh: 优先用记住的设备(不在位也照样按它建链), 不再"猜第一个 digital"
- 实测: 面板真点应用切走再切回 ✓ / 连错 2 秒自动纠回 ✓ / 全拆 3 秒自动挂回 ✓ / start 自检
  打印 [在位] ✓ / 面板像素验收 ✓; pyright strict 0 errors 0 warnings
- 坑: pw-dump 的 Link 键名是 output-port-id / input-port-id(写 output-port 一条连线都读不到);
  面板服务 enable --now 不会重启已在运行的实例(改 web/*.py 必须 restart)
2026-09-24 09:36:36 +08:00

499 lines
24 KiB
HTML
Raw Permalink 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}
.presets-title{font-size:10px;color:var(--dim);margin:10px 0 6px;letter-spacing:.5px}
/* 场所预设 = EQ + 混响一起切, 用偏绿的边框跟普通 EQ 预设区分 */
.presets.venues button{border-color:#2f4a38}
.presets.venues 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)}
select{background:var(--card-hi);color:var(--fg);border:1px solid var(--bd-hi);border-radius:6px;
padding:5px 8px;font:inherit;font-size:11px;flex:1;min-width:0;cursor:pointer}
select:hover{border-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>输出设备</h2>
<div class="row">
<span class="name">输出到</span>
<select id="outdev"></select>
<button id="outapply">应用</button>
<span class="num" id="outstate"></span>
</div>
<div class="hint" id="outhint">换设备: 选一下再点「应用」。开机时设备晚到(USB 耳机常见)会由守护自动挂上, 不用重启链路。</div>
</div>
<div class="card">
<h2>32 段 EQ1/3 倍频程 · ±15 dB</h2>
<div class="presets" id="presets"></div>
<div class="presets-title">场所 · 一键切 EQ + 混响</div>
<div class="presets venues" id="venues"></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; };
/* applyVenue 提交湿量时会走 wet 的 change handler; 那个 handler 是 async 的,
它里面的 markVenue(null) 会在 applyVenue 点亮之后才跑, 把高亮又清掉。
所以在 dispatch 那一刻把意图记下来, handler 据此决定要不要清。 */
let venueApplying = false;
/* ---------- 滚轮捕获 ---------- */
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 () => {
const fromVenue = venueApplying; // dispatch 是同步的, 这里能读到 applyVenue 的意图
await post("/api/wet", {wet:+wet.value});
say("湿度已生效");
if (!fromVenue) markVenue(null); // 只有手动改湿量才取消场所高亮
});
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); // 手动改过就不是任何预设了
markVenue(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]], // 削刺耳高频
};
/* ---------- 场所预设: 一键同时切 EQ + 混响湿量 ---------- */
/* 影院和歌剧院最本质的差别是**混响时间**(0.4~0.8 s vs 1.4~1.8 s), 光切 EQ 不够,
所以连湿量一起切。EQ 依据见 README「EQ 预设的设计依据」。
总音量不在这里动 —— 那是个人听音响度, 不该被场所覆盖。 */
const VENUES = {
// 影院: 高频按 X-curve(ISO 2969 国际版, 2 kHz 以上约 -1.5 dB/oct; 北美 SMPTE 是 -3),
// 再叠银幕透声损失(4~10 kHz); 低频是影院 sub / LFE 通道的体感 -- 厚, 暗, 有力.
"电影院": {wet: 0.35, eq: [[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 投射,
// 所以这里要抬不能削; 极高频只随空气吸收缓降 -- 暖, 亮, 有细节.
"歌剧院": {wet: 0.78, eq: [[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;
});
}
/* 把一组控制点写进 32 段滑块并提交(EQ 预设与场所预设共用)。 */
function applyEqPoints(pts) {
const freqs = state ? state.freqs : [];
if (!freqs.length) return;
const vals = curveTo(pts, 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();
}
/* 点了哪个按钮要看得见(不然选没选中没反馈); 手动改滑块即取消高亮。 */
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 markVenue(name) {
const box = el("venues");
if (!box) return;
box.querySelectorAll("button").forEach((b) => {
b.classList.toggle("on", name !== null && b.textContent === name);
});
}
function applyPreset(name) {
applyEqPoints(PRESETS[name]);
markVenue(null);
markPreset(name);
say("EQ 预设: " + name);
}
/* 场所: EQ + 混响湿量一起切。湿量走 change 事件提交(和手动拖推子同一条路),
那个 handler 里会 markVenue(null), 所以要在它之后才点亮本按钮。 */
function applyVenue(name) {
const spec = VENUES[name];
if (!spec) return;
applyEqPoints(spec.eq);
wet.value = spec.wet;
el("wet-num").textContent = spec.wet.toFixed(2);
markBusy("wet", 1500); // 别让 SSE 轮询把刚设的湿量弹回旧值
venueApplying = true;
wet.dispatchEvent(new Event("change")); // handler 在同步阶段就把意图读走了
venueApplying = false;
markPreset(null);
markVenue(name);
say("场所: " + name + " · 混响 " + spec.wet.toFixed(2) + " · EQ 已切");
}
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);
});
}
function buildVenues() {
const box = el("venues");
if (!box) return;
box.innerHTML = "";
Object.keys(VENUES).forEach((name) => {
const b = document.createElement("button");
b.textContent = name;
b.title = "同时切 EQ + 混响 " + VENUES[name].wet.toFixed(2);
b.addEventListener("click", () => applyVenue(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();
buildVenues();
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);
updateOutput(d.output);
state = d;
}
/* ---------- 输出设备 ---------- */
/* 设备表只在变化时重建选项: SSE 是 60 Hz, 每帧重建 <select> 会把正在点的下拉打断。 */
let outSig = "";
function updateOutput(o) {
if (!o) return;
const sel = el("outdev");
const devs = o.devices || [];
const sig = JSON.stringify(devs.map((d) => [d.name, d.present]));
if (sig !== outSig) {
outSig = sig;
const keep = sel.dataset.touched ? sel.value : (o.device || "");
sel.innerHTML = "";
for (const d of devs) {
const op = document.createElement("option");
op.value = d.name;
op.textContent = (d.description || d.name) + (d.present ? "" : "(不在位)");
sel.appendChild(op);
}
if (keep) sel.value = keep;
}
if (!sel.dataset.touched && o.device) sel.value = o.device;
el("outstate").textContent = (o.routed && o.routed === o.device) ? "已挂载" : (o.routed ? "挂在别处" : "未挂载");
el("outhint").textContent = o.note || "";
}
el("outdev").addEventListener("change", () => { el("outdev").dataset.touched = "1"; });
el("outapply").addEventListener("click", async () => {
const name = el("outdev").value;
if (!name) return;
const r = await fetch("/api/device", {
method: "POST", headers: {"Content-Type": "application/json"}, body: JSON.stringify({name})});
const d = await r.json().catch(() => ({}));
say(d.ok ? ("输出设备: " + (d.message || name)) : ("切换失败: " + (d.error || "未知错误")));
el("outdev").dataset.touched = "";
if (d.output) updateOutput(d.output);
});
el("flat").addEventListener("click", () => { gains = new Array(32).fill(0); commitEq(); markPreset("平直"); markVenue(null); 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>