228 lines
8.4 KiB
Plaintext
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 =>
|
|
({'&':'&','<':'<','>':'>','"':'"'}[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>
|