(() => { 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(); })();