Files
cinema-spatial/web/index.html.设计稿前备份

228 lines
8.4 KiB
Plaintext

<!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{--fg:#e6e6e6;--mut:#8a8a8a;--bg:#0f0f0f;--card:#161616;--bd:#262626;
--ok:#4ade80;--bad:#f87171;--warn:#fbbf24}
*{box-sizing:border-box}
body{margin:0;padding:20px;background:var(--bg);color:var(--fg);
font:14px/1.6 system-ui,-apple-system,"Noto Sans CJK SC",sans-serif}
h1{font-size:16px;font-weight:600;margin:0 0 4px}
.sub{color:var(--mut);font-size:12px;margin-bottom:16px}
.card{background:var(--card);border:1px solid var(--bd);border-radius:8px;
padding:14px 16px;margin-bottom:12px}
.card h2{font-size:13px;font-weight:600;color:var(--mut);margin:0 0 10px;letter-spacing:.05em}
.row{display:flex;justify-content:space-between;gap:12px;padding:6px 0;
border-bottom:1px solid var(--bd);font-size:13px}
.row:last-child{border-bottom:none}
.k{color:var(--mut);flex:0 0 auto}
.v{font-variant-numeric:tabular-nums;text-align:right;word-break:break-all}
.ok{color:var(--ok)}.bad{color:var(--bad)}.warn{color:var(--warn)}
.bar{display:flex;gap:8px;flex-wrap:wrap}
button{background:#1f1f1f;color:var(--fg);border:1px solid var(--bd);border-radius:6px;
padding:8px 14px;font-size:13px;cursor:pointer;font-family:inherit;
transition:background .15s,border-color .15s}
button:hover{background:#2a2a2a;border-color:#3a3a3a}
button.primary{background:#17321f;border-color:#2d5a3f}
button.primary:hover{background:#1e4028}
button.fix{background:#3a2418;border-color:#5c3a26}
button.fix:hover{background:#4a2e1e}
#msg{margin-top:12px;font-size:13px;min-height:20px;color:var(--warn)}
#lv{width:100%;height:120px;display:block;border:1px solid var(--bd);
border-radius:6px;background:#121212}
.lvn{display:flex;gap:18px;margin-top:8px;font-size:12px;flex-wrap:wrap}
.lvn b{font-variant-numeric:tabular-nums;font-weight:600}
.nums{display:flex;align-items:center;gap:10px;margin-top:6px}
#volval{font-variant-numeric:tabular-nums;min-width:52px;text-align:right;color:var(--ok)}
input[type=range]{flex:1;accent-color:#4ade80;height:4px}
</style>
</head>
<body>
<h1>Collaplex 音效</h1>
<div class="sub">PipeWire 虚拟声卡 · SADIE-II 真人 HRTF</div>
<div class="card"><h2>状态</h2><div id="st"></div></div>
<div class="card"><h2>模式</h2>
<div class="bar">
<button class="primary" onclick="mode('on')">立体声上混</button>
<button onclick="mode('on51')">5.1 直通</button>
<button onclick="mode('off')">关闭(走物理设备)</button>
<button class="fix" onclick="doFix()">检测并修复失声</button>
</div>
</div>
<div class="card"><h2>虚拟声卡音量</h2>
<div class="nums">
<input type="range" id="vol" min="0" max="1.5" step="0.01" oninput="onVol(this.value)">
<span id="volval">-</span>
</div>
<div class="sub" style="margin:8px 0 0">降音量是拿响度换安全;削波爆音就往下拉,
想更响请改 IR 增益(见 README)。</div>
</div>
<div class="card"><h2>电平(物理输出)</h2>
<canvas id="lv"></canvas>
<div class="lvn">
<span class="k">峰值 <b id="pk" class="v">-</b></span>
<span class="k">RMS <b id="rm" class="v">-</b></span>
<span class="k">余量 <b id="hd" class="v">-</b></span>
</div>
</div>
<div id="msg"></div>
<script>
const $ = (s) => document.querySelector(s);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"]/g, c =>
({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
async function api(path, opts) {
try { const r = await fetch(path, opts || {}); return await r.json(); }
catch (e) { return { error: String(e) }; }
}
function row(k, v, cls) {
return '<div class="row"><span class="k">' + esc(k) + '</span>' +
'<span class="v ' + (cls || '') + '">' + esc(v) + '</span></div>';
}
function render(s) {
if (!s || s.error) {
$('#st').innerHTML = row('错误', (s && s.error) || '取不到状态', 'bad');
return;
}
let h = '';
h += row('默认输出', s.default || '(无)', s.default_is_virtual ? 'ok' : 'warn');
h += row('链路采样率', (s.clock || '?') + ' Hz', s.clock === '96000' ? 'ok' : 'warn');
h += row('HRTF 模型', s.hrtf || '-');
h += row('配置输出设备', s.target || '(空)', s.target_ok ? 'ok' : 'bad');
h += row('当前物理输出', s.physical || '(找不到)', s.physical ? 'ok' : 'bad');
h += row('实际连接', (s.route && s.route.length) ? s.route.join(', ') : '(未连接 → 无声)',
(s.route && s.route.length) ? 'ok' : 'bad');
if (s.device_vol != null) h += row('设备音量', s.device_vol.toFixed(2));
if (s.device_desc) h += row('设备', s.device_desc);
(s.sinks || []).forEach(function (k) {
h += row('声卡 ' + k.key + ' (id ' + k.id + ')', '音量 ' + k.vol.toFixed(2));
});
$('#st').innerHTML = h;
if (s.default_vol != null) {
$('#vol').value = s.default_vol;
$('#volval').textContent = s.default_vol.toFixed(2);
}
if (!s.target_ok) msg('配置里的输出设备已失效 —— 点「检测并修复失声」', true);
}
function msg(t, keep) {
$('#msg').textContent = t;
if (!keep) setTimeout(function () {
if ($('#msg').textContent === t) $('#msg').textContent = '';
}, 4000);
}
async function mode(m) {
const r = await api('/api/mode?mode=' + m, { method: 'POST' });
msg(r.message || '完成');
if (r.status) render(r.status);
}
async function onVol(v) {
$('#volval').textContent = parseFloat(v).toFixed(2);
const r = await api('/api/volume?value=' + v, { method: 'POST' });
if (r.message) msg(r.message);
}
async function doFix() {
msg('正在检测并修复…(约 6 秒)', true);
const r = await api('/api/fix', { method: 'POST' });
msg(r.message || '完成');
setTimeout(refresh, 5000);
}
const cv = document.getElementById('lv');
const cx = cv.getContext('2d');
const DB_LO = -60, DB_HI = 6;
function dbY(db, h) {
const t = Math.max(0, Math.min(1, (db - DB_LO) / (DB_HI - DB_LO)));
return h - t * h;
}
// Catmull-Rom 插值转三次贝塞尔 —— 得到平滑曲线(而不是折线)
function smooth(pts) {
if (pts.length < 2) return '';
let d = 'M ' + pts[0].x + ' ' + pts[0].y;
for (let i = 0; i < pts.length - 1; i++) {
const p0 = pts[i - 1] || pts[i], p1 = pts[i];
const p2 = pts[i + 1], p3 = pts[i + 2] || p2;
d += ' C ' + (p1.x + (p2.x - p0.x) / 6) + ' ' + (p1.y + (p2.y - p0.y) / 6) +
', ' + (p2.x - (p3.x - p1.x) / 6) + ' ' + (p2.y - (p3.y - p1.y) / 6) +
', ' + p2.x + ' ' + p2.y;
}
return d;
}
function drawLevel(pts) {
const w = cv.width = cv.clientWidth * 2;
const h = cv.height = 240;
cx.setTransform(1, 0, 0, 1, 0, 0);
cx.clearRect(0, 0, w, h);
cx.font = '18px system-ui';
[-60, -48, -24, -12, -6, -3].forEach(function (db) {
const y = dbY(db, h);
cx.strokeStyle = '#242424'; cx.lineWidth = 1;
cx.beginPath(); cx.moveTo(0, y); cx.lineTo(w, y); cx.stroke();
cx.fillStyle = '#4a4a4a';
cx.fillText(db + ' dB', 8, y - 5);
});
cx.strokeStyle = '#7f1d1d'; cx.lineWidth = 2; // 0dBFS 削波线
cx.beginPath(); cx.moveTo(0, dbY(0, h)); cx.lineTo(w, dbY(0, h)); cx.stroke();
if (!pts.length) return;
const n = pts.length;
const X = function (i) { return n === 1 ? w : i * w / (n - 1); };
function line(key, color, lw) {
const P = pts.map(function (p, i) { return { x: X(i), y: dbY(p[key], h) }; });
cx.strokeStyle = color; cx.lineWidth = lw;
cx.lineJoin = 'round'; cx.lineCap = 'round';
cx.stroke(new Path2D(smooth(P)));
return P;
}
const rp = line('rms', '#4ade80', 4);
line('peak', '#fbbf24', 2);
const last = rp[rp.length - 1];
cx.fillStyle = '#4ade80';
cx.beginPath(); cx.arc(last.x, last.y, 5, 0, Math.PI * 2); cx.fill();
if (pts[n - 1].peak > -0.1) { // 削波整体闪红
cx.fillStyle = 'rgba(248,113,113,0.22)';
cx.fillRect(0, 0, w, h);
}
}
async function level() {
const d = await api('/api/level');
const pts = (d && d.points) || [];
if (pts.length) {
const p = pts[pts.length - 1];
document.getElementById('pk').textContent = p.peak.toFixed(1) + ' dBFS';
document.getElementById('rm').textContent = p.rms.toFixed(1) + ' dBFS';
const hd = -p.peak;
const el = document.getElementById('hd');
el.textContent = hd.toFixed(1) + ' dB';
el.className = 'v ' + (hd < 3 ? 'bad' : hd < 6 ? 'warn' : 'ok');
}
drawLevel(pts);
}
async function refresh() { render(await api('/api/status')); }
refresh();
setInterval(refresh, 5000);
setInterval(level, 150);
level();
</script>
</body>
</html>