Files
Mark-LIV/dashboard/static/app.html
T
2026-06-21 13:23:06 +03:00

560 lines
26 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>JARVIS Dashboard</title>
<script src="/static/crypto.js"></script>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #07090f;
--surface: rgba(255,255,255,0.04);
--border: rgba(255,255,255,0.07);
--accent: #6366f1;
--adim: rgba(99,102,241,0.14);
--text: #dde3ed;
--muted: #5e6a7e;
--green: #22c55e;
--gdim: rgba(34,197,94,0.14);
}
html, body {
height: 100%; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
display: flex; flex-direction: column; overflow: hidden;
}
/* ── HEADER ── */
header {
flex-shrink: 0; display: flex; align-items: center; gap: 10px;
padding: 12px 16px; border-bottom: 1px solid var(--border);
background: rgba(7,9,15,0.95);
}
.logo { font-size: 15px; font-weight: 700; letter-spacing: 6px; }
.logo em { color: var(--accent); font-style: normal; }
.pill {
display: flex; align-items: center; gap: 6px; padding: 4px 11px;
border-radius: 99px; background: var(--surface); border: 1px solid var(--border);
font-size: 10px; font-weight: 700; letter-spacing: 1.5px; text-transform: uppercase;
color: var(--muted); transition: all 0.3s;
}
.pill.on { background: var(--gdim); border-color: rgba(34,197,94,0.3); color: var(--green); }
.dot { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); transition: background 0.3s; }
.pill.on .dot { background: var(--green); animation: blink 2s infinite; }
@keyframes blink { 0%,100%{opacity:1} 50%{opacity:0.4} }
.spacer { flex: 1; }
.enc-badge {
font-size: 9px; font-weight: 700; letter-spacing: 1.5px; padding: 3px 8px;
border-radius: 4px; display: none;
}
.enc-badge.on { display: block; background: rgba(34,197,94,0.1); border: 1px solid rgba(34,197,94,0.25); color: var(--green); }
.enc-badge.off { display: block; background: rgba(255,100,100,0.08); border: 1px solid rgba(255,100,100,0.2); color: #f87171; }
.url { font-size: 10px; font-family: monospace; color: var(--muted); padding: 4px 9px; background: var(--surface); border: 1px solid var(--border); border-radius: 6px; }
/* ── FEED ── */
main { flex: 1; overflow-y: auto; padding: 14px 16px 8px; display: flex; flex-direction: column; gap: 10px; }
main::-webkit-scrollbar { width: 3px; }
main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
.msg { max-width: 82%; padding: 10px 14px; border-radius: 14px; font-size: 14px; line-height: 1.55; animation: pop 0.18s ease; }
@keyframes pop { from { opacity:0; transform:translateY(4px); } }
.msg-j { align-self:flex-start; background:var(--adim); border:1px solid rgba(99,102,241,0.18); border-bottom-left-radius:4px; }
.msg-u { align-self:flex-end; background:var(--surface); border:1px solid var(--border); border-bottom-right-radius:4px; }
.msg-sys { align-self:center; font-size:11px; color:var(--muted); padding:2px 0; background:transparent; border:none; letter-spacing:0.3px; }
.lbl { font-size:9px; font-weight:700; letter-spacing:2px; text-transform:uppercase; opacity:0.45; margin-bottom:4px; }
.msg-j .lbl { color:var(--accent); }
.msg-u .lbl { color:var(--muted); text-align:right; }
/* ── FILE CARD ── */
.msg-file {
align-self: flex-end;
background: var(--surface); border: 1px solid var(--border);
border-radius: 14px; border-bottom-right-radius: 4px;
padding: 11px 14px; display: flex; align-items: center; gap: 12px;
max-width: 82%; animation: pop 0.18s ease;
}
.fc-icon { font-size: 26px; flex-shrink: 0; }
.fc-meta { flex: 1; min-width: 0; }
.fc-lbl { font-size:9px; font-weight:700; letter-spacing:2px; text-transform:uppercase; opacity:0.45; color:var(--muted); margin-bottom:4px; }
.fc-name { font-size: 13px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.fc-size { font-size: 11px; color: var(--muted); margin-top: 2px; }
.fc-bar-wrap { height: 3px; background: var(--border); border-radius: 2px; margin-top: 7px; overflow: hidden; }
.fc-bar { height: 100%; width: 0%; background: var(--accent); border-radius: 2px; transition: width 0.12s linear; }
.fc-status { font-size: 10px; font-weight: 700; letter-spacing: 1px; flex-shrink: 0; color: var(--muted); }
.fc-status.done { color: var(--green); }
.fc-status.err { color: #f87171; }
.fc-dl { display:block; margin-top:6px; font-size:11px; color:var(--accent); text-decoration:none; font-weight:600; }
.fc-dl:hover { opacity: 0.8; }
/* ── FOOTER ── */
footer { flex-shrink:0; padding:10px 14px; border-top:1px solid var(--border); background:rgba(7,9,15,0.95); display:flex; gap:8px; align-items:center; }
#inp { flex:1; padding:11px 14px; background:var(--surface); border:1.5px solid var(--border); border-radius:12px; color:var(--text); font-size:14px; outline:none; transition:border-color 0.15s; }
#inp:focus { border-color: var(--accent); }
#inp::placeholder { color: var(--muted); }
.btn { padding:11px 15px; border:none; border-radius:12px; font-size:11px; font-weight:700; letter-spacing:1.5px; cursor:pointer; transition:opacity 0.15s,transform 0.1s; }
.btn:active { transform: scale(0.96); }
.btn:hover { opacity: 0.82; }
.btn-send { background: var(--accent); color: #fff; }
.btn-wake { background:var(--surface); border:1px solid var(--border); color:var(--muted); display:none; }
.btn-wake.show { display:block; color:var(--green); border-color:rgba(34,197,94,0.35); }
.btn-attach {
background: var(--surface); border: 1px solid var(--border); color: var(--muted);
padding: 11px 13px; font-size: 16px; line-height: 1; border-radius: 12px;
cursor: pointer; transition: opacity 0.15s, transform 0.1s; display: flex;
align-items: center; justify-content: center; flex-shrink: 0;
}
.btn-attach:hover { opacity: 0.82; border-color: var(--accent); color: var(--accent); }
.btn-attach:active { transform: scale(0.96); }
/* ── MIC BUTTON ── */
#mic-btn {
background: var(--surface); border: 1px solid var(--border); color: var(--muted);
padding: 11px 13px; font-size: 16px; line-height: 1; border-radius: 12px;
cursor: pointer; transition: opacity 0.15s, transform 0.1s, background 0.2s, border-color 0.2s;
display: flex; align-items: center; justify-content: center; flex-shrink: 0;
}
#mic-btn:hover { opacity: 0.82; border-color: var(--accent); color: var(--accent); }
#mic-btn:active { transform: scale(0.96); }
#mic-btn.recording {
background: rgba(239,68,68,0.15); border-color: rgba(239,68,68,0.45); color: #ef4444;
animation: mic-pulse 1s ease-in-out infinite;
}
#mic-btn.unsupported { opacity: 0.3; cursor: not-allowed; }
@keyframes mic-pulse { 0%,100%{opacity:1;transform:scale(1)} 50%{opacity:0.65;transform:scale(0.97)} }
/* ── TOAST ── */
#toast {
position:fixed; top:66px; left:50%; transform:translateX(-50%) translateY(-8px);
background:rgba(34,197,94,0.16); border:1px solid rgba(34,197,94,0.4);
color:var(--green); font-size:12px; font-weight:700; letter-spacing:1px;
padding:7px 18px; border-radius:99px;
opacity:0; transition:opacity 0.22s,transform 0.22s;
pointer-events:none; white-space:nowrap;
}
#toast.show { opacity:1; transform:translateX(-50%) translateY(0); }
</style>
</head>
<body>
<div id="toast"></div>
<header>
<div class="logo"><em>J</em>ARVIS</div>
<div class="pill" id="pill"><span class="dot"></span><span id="st">Connecting</span></div>
<div class="enc-badge" id="enc">AES-256</div>
<div class="spacer"></div>
<div class="url">__IP__:__PORT__</div>
</header>
<main id="feed"></main>
<footer>
<input id="inp" placeholder="Send a command to JARVIS…" autocomplete="off">
<label class="btn-attach" for="file-inp" title="Send file">📎</label>
<input type="file" id="file-inp" multiple accept="*/*" style="display:none">
<button id="mic-btn" onclick="doMic()" title="Voice command">🎤</button>
<button class="btn btn-wake" id="wake" onclick="doWake()">WAKE</button>
<button class="btn btn-send" onclick="doSend()">SEND</button>
</footer>
<script>
// ── Auth — bearer token ───────────────────────────────────────────────────
const _authToken = sessionStorage.getItem('jarvis_token');
const _sessionKey = sessionStorage.getItem('jarvis_key');
if (!_authToken) {
location.replace('/login');
}
function _authHeader() { return { 'Authorization': `Bearer ${_authToken}` }; }
function _authFetch(url, opts = {}) {
opts.headers = Object.assign({}, opts.headers, _authHeader());
return fetch(url, opts);
}
// ── AES-256-CBC encryption (CryptoJS) ────────────────────────────────────
const _AES_SALT = 'JARVIS-DASHBOARD-v1';
let _aesKey = null;
function _initCrypto(key) {
if (typeof CryptoJS === 'undefined') return false;
_aesKey = CryptoJS.SHA256(key + _AES_SALT);
return true;
}
function _encrypt(plaintext) {
if (!_aesKey || typeof CryptoJS === 'undefined') return null;
try {
const iv = CryptoJS.lib.WordArray.random(16);
const enc = CryptoJS.AES.encrypt(
CryptoJS.enc.Utf8.parse(plaintext), _aesKey,
{ iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }
);
const ivHex = iv.toString(CryptoJS.enc.Hex);
const ctHex = enc.ciphertext.toString(CryptoJS.enc.Hex);
const hex = ivHex + ctHex;
const bytes = new Uint8Array(hex.length / 2);
for (let i = 0; i < bytes.length; i++)
bytes[i] = parseInt(hex.substr(i * 2, 2), 16);
return btoa(String.fromCharCode(...bytes));
} catch { return null; }
}
const _encReady = _sessionKey ? _initCrypto(_sessionKey) : false;
const encBadge = document.getElementById('enc');
if (_encReady) { encBadge.className = 'enc-badge on'; }
else { encBadge.className = 'enc-badge off'; encBadge.textContent = 'NO ENC'; }
// ── WebSocket ─────────────────────────────────────────────────────────────
const feed = document.getElementById('feed');
const pill = document.getElementById('pill');
const stTxt = document.getElementById('st');
const wake = document.getElementById('wake');
const _wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
const _wsUrl = `${_wsProto}://${location.host}/ws?token=${encodeURIComponent(_authToken)}`;
const ws = new WebSocket(_wsUrl);
ws.onopen = () => { sys('Remote session active.'); toast('Connected to JARVIS'); };
ws.onclose = () => sys('Connection lost — refresh to reconnect.');
ws.onerror = () => sys('Connection error.');
ws.onmessage = (e) => {
const m = JSON.parse(e.data);
if (m.type === 'log') append(m.speaker, m.text);
if (m.type === 'status') setStatus(m.state);
if (m.type === 'wake') sys('Wake word detected — connecting…');
if (m.type === 'sys') sys(m.text);
if (m.type === 'file_received') _onFileReceived(m);
};
function setStatus(s) {
if (s === 'active') {
pill.className = 'pill on'; stTxt.textContent = 'Active'; wake.classList.remove('show');
} else {
pill.className = 'pill'; stTxt.textContent = 'Sleeping'; wake.classList.add('show');
}
}
function append(speaker, text) {
const d = document.createElement('div');
if (speaker === 'jarvis') {
d.className = 'msg msg-j';
d.innerHTML = `<div class="lbl">Jarvis</div>${esc(text)}`;
} else {
d.className = 'msg msg-u';
d.innerHTML = `<div class="lbl">You</div>${esc(text)}`;
}
feed.appendChild(d);
feed.scrollTop = feed.scrollHeight;
}
function sys(text) {
const d = document.createElement('div');
d.className = 'msg msg-sys'; d.textContent = text;
feed.appendChild(d); feed.scrollTop = feed.scrollHeight;
}
function esc(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// ── File helpers ──────────────────────────────────────────────────────────
function _fileIcon(name) {
const ext = (name.split('.').pop() || '').toLowerCase();
if (['jpg','jpeg','png','gif','webp','heic','heif','avif','bmp','svg'].includes(ext)) return '🖼️';
if (['mp4','mov','avi','mkv','webm','m4v','3gp'].includes(ext)) return '🎬';
if (['mp3','m4a','wav','flac','aac','ogg','opus'].includes(ext)) return '🎵';
if (['pdf'].includes(ext)) return '📋';
if (['doc','docx','txt','md','rtf','odt'].includes(ext)) return '📝';
if (['xls','xlsx','csv','ods'].includes(ext)) return '📊';
if (['ppt','pptx','odp'].includes(ext)) return '📑';
if (['zip','rar','7z','tar','gz','bz2','xz'].includes(ext)) return '📦';
if (['apk','exe','dmg','deb','rpm','msi'].includes(ext)) return '⚙️';
return '📎';
}
function _fmtSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
if (bytes < 1024 ** 3) return (bytes / 1024 / 1024).toFixed(1) + ' MB';
return (bytes / 1024 ** 3).toFixed(2) + ' GB';
}
// ── File upload (phone → computer) ────────────────────────────────────────
function _uploadFile(file) {
const id = 'fc_' + Date.now() + '_' + Math.random().toString(36).slice(2);
// Create file card in feed
const card = document.createElement('div');
card.className = 'msg-file';
card.id = id;
card.innerHTML = `
<div class="fc-icon">${_fileIcon(file.name)}</div>
<div class="fc-meta">
<div class="fc-lbl">Sending</div>
<div class="fc-name">${esc(file.name)}</div>
<div class="fc-size">${_fmtSize(file.size)}</div>
<div class="fc-bar-wrap"><div class="fc-bar" id="${id}_bar"></div></div>
</div>
<div class="fc-status" id="${id}_st">0%</div>
`;
feed.appendChild(card);
feed.scrollTop = feed.scrollHeight;
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/upload');
xhr.setRequestHeader('Authorization', `Bearer ${_authToken}`);
xhr.upload.onprogress = (e) => {
if (!e.lengthComputable) return;
const pct = Math.round(e.loaded / e.total * 100);
const bar = document.getElementById(`${id}_bar`);
const st = document.getElementById(`${id}_st`);
if (bar) bar.style.width = pct + '%';
if (st) st.textContent = pct + '%';
};
xhr.onload = () => {
const bar = document.getElementById(`${id}_bar`);
const st = document.getElementById(`${id}_st`);
const meta = card.querySelector('.fc-meta');
const lbl = card.querySelector('.fc-lbl');
if (xhr.status === 200) {
let savedName = file.name;
try { savedName = JSON.parse(xhr.responseText).name || file.name; } catch {}
const dlUrl = `/uploads/${encodeURIComponent(savedName)}?token=${encodeURIComponent(_authToken)}`;
if (bar) { bar.style.width = '100%'; bar.style.background = 'var(--green)'; }
if (st) { st.textContent = '✓'; st.className = 'fc-status done'; }
if (lbl) lbl.textContent = 'Sent';
if (meta) meta.insertAdjacentHTML('beforeend',
`<a class="fc-dl" href="${dlUrl}" download="${esc(savedName)}">⬇ Download back</a>`);
toast('File sent!');
} else {
let errMsg = 'Upload failed';
try { errMsg = JSON.parse(xhr.responseText).error || errMsg; } catch {}
if (bar) bar.style.background = '#f87171';
if (st) { st.textContent = 'ERR'; st.className = 'fc-status err'; }
if (lbl) lbl.textContent = errMsg;
}
};
xhr.onerror = () => {
const st = document.getElementById(`${id}_st`);
const lbl = card.querySelector('.fc-lbl');
if (st) { st.textContent = 'ERR'; st.className = 'fc-status err'; }
if (lbl) lbl.textContent = 'Connection error';
};
const fd = new FormData();
fd.append('file', file);
xhr.send(fd);
}
// File received by another WebSocket client (e.g., second open tab)
function _onFileReceived(m) {
const dlUrl = `/uploads/${encodeURIComponent(m.name)}?token=${encodeURIComponent(_authToken)}`;
const card = document.createElement('div');
card.className = 'msg-file';
card.innerHTML = `
<div class="fc-icon">${_fileIcon(m.name)}</div>
<div class="fc-meta">
<div class="fc-lbl">Received</div>
<div class="fc-name">${esc(m.name)}</div>
<div class="fc-size">${_fmtSize(m.size)}</div>
<a class="fc-dl" href="${dlUrl}" download="${esc(m.name)}">⬇ Download</a>
</div>
<div class="fc-status done">✓</div>
`;
feed.appendChild(card);
feed.scrollTop = feed.scrollHeight;
toast('File received on computer!');
}
// File input change handler
document.getElementById('file-inp').addEventListener('change', function () {
Array.from(this.files).forEach(_uploadFile);
this.value = ''; // reset so the same file can be re-sent
});
// ── Commands ──────────────────────────────────────────────────────────────
function doSend() {
const inp = document.getElementById('inp');
const txt = inp.value.trim();
if (!txt) return;
inp.value = '';
append('user', txt);
const enc = _encrypt(txt);
const body = enc ? { enc } : { text: txt };
_authFetch('/api/command', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
}
function doWake() {
sys('Sending wake signal…');
_authFetch('/api/wake', { method: 'POST' });
}
document.getElementById('inp').addEventListener('keydown', e => {
if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); doSend(); }
});
// ── Toast ─────────────────────────────────────────────────────────────────
let _toastTimer;
function toast(msg) {
const t = document.getElementById('toast');
t.textContent = msg; t.classList.add('show');
clearTimeout(_toastTimer);
_toastTimer = setTimeout(() => t.classList.remove('show'), 2800);
}
// ── Real-time Voice — PCM16 WebSocket → Gemini Live ──────────────────────
const micBtn = document.getElementById('mic-btn');
let _voiceWs = null;
let _audioCtx = null;
let _micStm = null;
let _audioNd = null;
function _micIdle() {
micBtn.innerHTML = '🎤';
micBtn.title = 'Voice — tap to speak';
micBtn.classList.remove('recording');
}
// Float32 PCM → resample to 16 kHz + convert to Int16
function _f32toPcm16(f32, srcRate) {
let s = f32;
if (srcRate !== 16000) {
const ratio = srcRate / 16000;
const len = Math.round(f32.length / ratio);
s = new Float32Array(len);
for (let i = 0; i < len; i++)
s[i] = f32[Math.min(Math.round(i * ratio), f32.length - 1)];
}
const out = new Int16Array(s.length);
for (let i = 0; i < s.length; i++)
out[i] = Math.max(-32768, Math.min(32767, Math.round(s[i] * 32768)));
return out.buffer;
}
function _showVoiceSetup() {
if (document.getElementById('voice-setup')) return; // show once
const origin = location.origin;
const card = document.createElement('div');
card.id = 'voice-setup';
card.style.cssText = [
'align-self:stretch', 'background:rgba(99,102,241,0.07)',
'border:1px solid rgba(99,102,241,0.22)', 'border-radius:14px',
'padding:16px 18px', 'font-size:13px', 'line-height:1.8',
'animation:pop 0.18s ease'
].join(';');
card.innerHTML =
'<div style="font-weight:700;color:#818cf8;letter-spacing:.5px;margin-bottom:10px">🎤 One-time voice setup</div>' +
'<ol style="padding-left:18px;color:#8899aa;margin:0;display:flex;flex-direction:column;gap:4px">' +
'<li>Open a new tab → go to <span style="background:rgba(255,255,255,0.07);padding:1px 6px;border-radius:4px;font-family:monospace;font-size:12px">chrome://flags</span></li>' +
'<li>Search: <strong style="color:#dde3ed">Insecure origins treated as secure</strong></li>' +
'<li>Paste this URL into the box:<br><span id="flags-url" style="background:rgba(255,255,255,0.07);padding:2px 8px;border-radius:4px;font-family:monospace;font-size:11px;user-select:all;cursor:pointer">' + esc(origin) + '</span></li>' +
'<li>Set to <strong style="color:#22c55e">Enabled</strong> → tap <strong style="color:#dde3ed">Relaunch</strong></li>' +
'<li>Return here and tap 🎤</li>' +
'</ol>';
feed.appendChild(card);
feed.scrollTop = feed.scrollHeight;
// tap to copy
card.querySelector('#flags-url').addEventListener('click', () => {
navigator.clipboard?.writeText(origin).then(() => toast('URL copied!')).catch(() => {});
});
}
async function doMic() {
if (_voiceWs) { _stopVoice(); return; }
if (!navigator.mediaDevices?.getUserMedia) {
_showVoiceSetup();
return;
}
let stream;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true },
});
} catch (e) {
sys(e.name === 'NotAllowedError'
? 'Microphone denied — tap the address bar lock and allow microphone.'
: 'Mic error: ' + e.message);
return;
}
// 16 kHz context (fall back to native rate — _f32toPcm16 resamples)
let ctx;
try { ctx = new AudioContext({ sampleRate: 16000 }); }
catch (_) { ctx = new AudioContext(); }
if (ctx.state === 'suspended') await ctx.resume();
const wsProto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(
`${wsProto}://${location.host}/ws/phone-audio?token=${encodeURIComponent(_authToken)}`
);
ws.binaryType = 'arraybuffer';
ws.onopen = async () => {
const rate = ctx.sampleRate;
const src = ctx.createMediaStreamSource(stream);
// AudioWorklet (modern) — runs in dedicated audio thread
const wCode = `class J extends AudioWorkletProcessor{process(i){const c=i[0]?.[0];if(c)this.port.postMessage(c.slice());return true;}}registerProcessor('j',J);`;
try {
const burl = URL.createObjectURL(new Blob([wCode], { type: 'application/javascript' }));
await ctx.audioWorklet.addModule(burl);
URL.revokeObjectURL(burl);
const nd = new AudioWorkletNode(ctx, 'j');
// Buffer to 1024 samples (64 ms at 16 kHz) before sending —
// matches PC mic chunk size, prevents queue flooding on the server.
let _pbuf = [], _plen = 0;
nd.port.onmessage = e => {
const chunk = new Int16Array(_f32toPcm16(e.data, rate));
_pbuf.push(chunk); _plen += chunk.length;
if (_plen >= 1024) {
const out = new Int16Array(_plen); let off = 0;
for (const c of _pbuf) { out.set(c, off); off += c.length; }
if (ws.readyState === 1) ws.send(out.buffer);
_pbuf = []; _plen = 0;
}
};
src.connect(nd);
_audioNd = nd;
} catch (_) {
// ScriptProcessor fallback (deprecated, still works everywhere)
const sp = ctx.createScriptProcessor(4096, 1, 1);
sp.onaudioprocess = e => {
if (ws.readyState === 1) ws.send(_f32toPcm16(e.inputBuffer.getChannelData(0), rate));
};
src.connect(sp); sp.connect(ctx.destination);
_audioNd = sp;
}
micBtn.innerHTML = '⏹';
micBtn.title = 'Tap to stop';
micBtn.classList.add('recording');
sys('Voice live — speak now');
toast('🎤 Live');
};
ws.onclose = () => { _stopVoice(); };
ws.onerror = () => { sys('Voice connection failed.'); _stopVoice(); };
_voiceWs = ws;
_audioCtx = ctx;
_micStm = stream;
}
function _stopVoice() {
if (_audioNd) { try { _audioNd.disconnect(); } catch (_) {}; _audioNd = null; }
if (_audioCtx) { try { _audioCtx.close(); } catch (_) {}; _audioCtx = null; }
if (_micStm) { _micStm.getTracks().forEach(t => t.stop()); _micStm = null; }
if (_voiceWs) {
const w = _voiceWs; _voiceWs = null;
if (w.readyState < 2) w.close();
}
_micIdle();
}
</script>
</body>
</html>