fix zombie leak, add quiet-hours toggle, improve query reply, configurable router threshold, JS dashboard

This commit is contained in:
kami
2026-07-03 00:42:35 +02:00
parent 612583d59a
commit e00cb07658
26 changed files with 3652 additions and 15 deletions
+242
View File
@@ -0,0 +1,242 @@
(() => {
const btn = document.getElementById("btn");
const status = document.getElementById("status");
const log = document.getElementById("log");
let mediaRecorder = null;
let recordingChunks = [];
let isRecording = false;
let recordingCancel = false;
let isBusy = false;
function setBtnIdle() { btn.classList.remove("active"); btn.innerHTML = "🎙"; btn.disabled = false; }
function setBtnActive() { btn.classList.add("active"); btn.innerHTML = "■"; }
function startRecording() {
if (isRecording || isBusy) return;
isRecording = true;
recordingCancel = false;
recordingChunks = [];
status.textContent = "recording... tap to stop";
setBtnActive();
navigator.mediaDevices.getUserMedia({
audio: { sampleRate: 48000, channelCount: 1, echoCancellation: true, noiseSuppression: true }
})
.then((stream) => {
if (recordingCancel) {
stream.getTracks().forEach(t => t.stop());
return;
}
const mr = new MediaRecorder(stream, { mimeType: "audio/webm;codecs=opus" });
mediaRecorder = mr;
mr.ondataavailable = (e) => { if (e.data.size > 0) recordingChunks.push(e.data); };
mr.onstop = () => {
stream.getTracks().forEach(t => t.stop());
processRecording();
};
mr.start(100);
})
.catch((err) => {
status.textContent = "mic error: " + err.message;
isRecording = false;
setBtnIdle();
});
}
function stopRecording() {
if (!isRecording) return;
isRecording = false;
if (!mediaRecorder) {
recordingCancel = true;
setBtnIdle();
return;
}
status.textContent = "stopping...";
mediaRecorder.stop();
mediaRecorder = null;
}
function processRecording() {
status.textContent = "processing...";
const blob = new Blob(recordingChunks, { type: "audio/webm" });
if (blob.size < 200) { status.textContent = "too short"; setBtnIdle(); return; }
const ac = new (window.AudioContext || window.webkitAudioContext)({ sampleRate: 16000 });
blob.arrayBuffer().then((buf) => ac.decodeAudioData(buf))
.then((audioBuffer) => {
const srcRate = audioBuffer.sampleRate;
const srcChan = audioBuffer.numberOfChannels;
const srcLen = audioBuffer.length;
const ratio = 16000 / srcRate;
const dstLen = Math.floor(srcLen * ratio);
const srcData = new Float32Array(srcLen);
for (let i = 0; i < srcLen; i++) {
let s = 0;
for (let c = 0; c < srcChan; c++) s += audioBuffer.getChannelData(c)[i];
srcData[i] = s / srcChan;
}
const dstData = new Int16Array(dstLen);
for (let i = 0; i < dstLen; i++) {
const srcIdx = i / ratio;
const lo = Math.floor(srcIdx);
const hi = Math.min(lo + 1, srcLen - 1);
const frac = srcIdx - lo;
const sample = srcData[lo] + (srcData[hi] - srcData[lo]) * frac;
const clamped = Math.max(-1, Math.min(1, sample));
dstData[i] = clamped < 0 ? clamped * 32768 : clamped * 32767;
}
ac.close();
sendPCM(new Uint8Array(dstData.buffer));
})
.catch((err) => {
status.textContent = "decode error: " + err.message;
setBtnIdle();
});
}
function testFetch() {
fetch("/api/ping").then(r => r.text()).then(t => {
if (t === "pong") appendLog("server reachable", "");
else appendLog("unexpected ping: " + t, "error");
}).catch(e => appendLog("fetch failed: " + e.message, "error"));
}
function sendPCM(pcm) {
isBusy = true;
btn.disabled = true;
status.textContent = "sending...";
appendLog("sending " + pcm.length + " bytes", "");
fetch("/api/ptt", { method: "POST", body: pcm })
.then(async (res) => {
if (!res.ok) {
const txt = await res.text();
throw new Error(txt);
}
const replyText = res.headers.get("X-Reply-Text");
if (replyText) {
const txt = decodeURIComponent(replyText);
appendLog(txt, "reply");
}
const audioData = await res.arrayBuffer();
if (audioData.byteLength > 0) {
playPCM(new Uint8Array(audioData));
} else {
status.textContent = "no reply audio";
setBtnIdle();
isBusy = false;
}
})
.catch((err) => {
status.textContent = "error: " + err.message;
setBtnIdle();
isBusy = false;
});
}
function playPCM(pcm) {
const sampleRate = 16000;
const bitsPerSample = 16;
const channels = 1;
const dataLen = pcm.length;
const headerLen = 44;
const wav = new Uint8Array(headerLen + dataLen);
const dv = (i, v) => { wav[i] = v & 255; wav[i+1] = (v>>8) & 255; wav[i+2] = (v>>16) & 255; wav[i+3] = (v>>24) & 255; };
const sv = (i, v) => { wav[i] = v & 255; wav[i+1] = (v>>8) & 255; };
wav[0] = 0x52; wav[1] = 0x49; wav[2] = 0x46; wav[3] = 0x46;
dv(4, 36 + dataLen);
wav[8] = 0x57; wav[9] = 0x41; wav[10] = 0x56; wav[11] = 0x45;
wav[12] = 0x66; wav[13] = 0x6d; wav[14] = 0x74; wav[15] = 0x20;
dv(16, 16);
sv(20, 1);
sv(22, channels);
dv(24, sampleRate);
dv(28, sampleRate * channels * bitsPerSample / 8);
sv(32, channels * bitsPerSample / 8);
sv(34, bitsPerSample);
wav[36] = 0x64; wav[37] = 0x61; wav[38] = 0x74; wav[39] = 0x61;
dv(40, dataLen);
wav.set(pcm, 44);
const blob = new Blob([wav], { type: "audio/wav" });
const audio = new Audio();
audio.src = URL.createObjectURL(blob);
status.textContent = "playing...";
audio.onended = () => { status.textContent = "ready"; setBtnIdle(); isBusy = false; };
audio.play().catch(() => { isBusy = false; setBtnIdle(); });
}
function appendLog(msg, cls) {
const el = document.createElement("div");
el.className = cls;
el.textContent = msg;
log.appendChild(el);
log.scrollTop = log.scrollHeight;
}
btn.addEventListener("click", () => {
if (isBusy) return;
if (isRecording) { stopRecording(); }
else { startRecording(); }
});
// ---- ntfy WS subscribe: proactive nudges land in-app -------------------
// The PWA connects straight to ntfy's WebSocket (mavweb serves only the URL,
// token included). ntfy streams one JSON object per frame; we care about
// event:"message". Reconnects with backoff — ntfy drops idle sockets and the
// phone sleeps. A missed nudge while disconnected is non-loss: sev>=3 also
// hit the native ntfy push, this is the in-app mirror, not the only channel.
function subscribeNtfy() {
fetch("/api/ntfy").then((r) => (r.status === 204 ? "" : r.text())).then((url) => {
if (!url) return; // not configured
if ("Notification" in window && Notification.permission === "default") {
Notification.requestPermission();
}
connectNtfy(url, 1000);
}).catch(() => {}); // no ntfy config endpoint → stay voice-only
}
function connectNtfy(url, backoff) {
let ws;
try { ws = new WebSocket(url); } catch (e) { scheduleReconnect(url, backoff); return; }
ws.onopen = () => { backoff = 1000; appendLog("nudges connected", ""); };
ws.onmessage = (ev) => {
let m;
try { m = JSON.parse(ev.data); } catch (e) { return; }
if (m.event !== "message") return; // skip open/keepalive/poll_request
const text = (m.title ? m.title + ": " : "") + (m.message || "");
appendLog(text, "reply");
if ("Notification" in window && Notification.permission === "granted") {
new Notification(m.title || "maven", { body: m.message || "" });
}
};
ws.onclose = () => scheduleReconnect(url, backoff);
ws.onerror = () => { try { ws.close(); } catch (e) {} };
}
function scheduleReconnect(url, backoff) {
const next = Math.min(backoff * 2, 30000); // cap at 30s
setTimeout(() => connectNtfy(url, next), backoff);
}
// ---- presence: page heartbeat -----------------------------------------
// A surface you have open + alive is a weak presence signal (τ=4min). Ping
// every 30s; the fact's fresh timestamp is what the scorer reads. Fire-and-
// forget — a dropped ping just decays, non-loss. Disabled server-side (503)
// when mavweb has no -core; we ignore the failure and stop pinging isn't
// needed (the scorer just never sees the key).
function heartbeat() {
fetch("/api/signal?key=page_heartbeat", { method: "POST" }).catch(() => {});
}
heartbeat();
setInterval(heartbeat, 30000);
status.textContent = "ready";
testFetch();
subscribeNtfy();
})();
+63
View File
@@ -0,0 +1,63 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#111">
<link rel="manifest" href="/manifest.json">
<script defer src="/app.js"></script>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{height:100%;background:#111;color:#ddd;font-family:system-ui,-apple-system,sans-serif}
body{display:flex;flex-direction:column}
nav{display:flex;background:#1a1a2e;border-bottom:1px solid #333;flex-shrink:0}
nav button{flex:1;padding:.6rem;background:none;border:none;color:#666;font-size:.85rem;cursor:pointer;font-family:inherit;letter-spacing:.05em;text-transform:uppercase;transition:color .15s;-webkit-tap-highlight-color:transparent}
nav button.active{color:#00aaff;border-bottom:2px solid #00aaff}
nav button:hover{color:#ddd}
.tab{display:none;flex-direction:column;align-items:center;flex:1;overflow:auto;padding:1rem}
.tab.active{display:flex}
#tab-voice{gap:2rem}
#tab-dash{padding:0}
h1{font-size:1.2rem;font-weight:400;color:#888;letter-spacing:.1em;text-transform:uppercase}
#status{font-size:.85rem;color:#666;min-height:1.2em}
#btn{width:140px;height:140px;border-radius:50%;border:4px solid #00aaff;background:#1a1a2e;color:#00aaff;font-size:1rem;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:all .15s;user-select:none;-webkit-tap-highlight-color:transparent;touch-action:manipulation}
#btn:active,#btn.active{background:#00aaff22;border-color:#00ff88;color:#00ff88;transform:scale(1.05)}
#btn:disabled{opacity:.3;border-color:#444}
#log{width:100%;max-width:480px;max-height:40vh;overflow-y:auto;font-size:.8rem;color:#666;line-height:1.6;padding:.5rem;border-top:1px solid #222;margin-top:1rem}
#log .reply{color:#8f8}
#log .error{color:#f88}
#log .push{color:#88f}
#dash-frame{width:100%;flex:1;border:none;background:#111;min-height:0}
</style>
</head>
<body>
<nav>
<button class="active" data-tab="voice">Voice</button>
<button data-tab="dash">Dash</button>
</nav>
<div id="tab-voice" class="tab active">
<h1>Maven Voice</h1>
<div id="status">tap &amp; hold to speak</div>
<button id="btn" type="button">&#x1f399;</button>
<div id="log"></div>
</div>
<div id="tab-dash" class="tab">
<iframe id="dash-frame" src="/dash"></iframe>
</div>
<script>
document.querySelectorAll('nav button').forEach(function(btn) {
btn.addEventListener('click', function() {
document.querySelectorAll('nav button').forEach(function(b) { b.classList.remove('active'); });
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
btn.classList.add('active');
var tab = document.getElementById('tab-' + btn.dataset.tab);
if (tab) tab.classList.add('active');
});
});
</script>
</body>
</html>
+10
View File
@@ -0,0 +1,10 @@
{
"name": "Maven",
"short_name": "Maven",
"start_url": "/",
"display": "standalone",
"background_color": "#111",
"theme_color": "#00aaff",
"icons": [],
"description": "Voice client and dashboard for Maven"
}
+16
View File
@@ -0,0 +1,16 @@
const CACHE = "maven-v2";
self.addEventListener("install", e => {
e.waitUntil(caches.open(CACHE).then(c => c.addAll(["/", "/manifest.json", "/dash"])));
self.skipWaiting();
});
self.addEventListener("activate", e => {
e.waitUntil(
caches.keys().then(keys => Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k))))
);
clients.claim();
});
self.addEventListener("fetch", e => {
e.respondWith(
fetch(e.request).catch(() => caches.match(e.request))
);
});