// LuZa remoto: WebSocket per stato/registro/audio, microfono via MediaRecorder, audio via AudioContext. const q = new URLSearchParams(location.search); if (q.get('k')) { try { localStorage.setItem('luza_key', q.get('k')); } catch (e) {} history.replaceState(null, '', location.pathname); } let KEY = ''; try { KEY = localStorage.getItem('luza_key') || ''; } catch (e) {} const $ = id => document.getElementById(id); let ws, actx, playing = false, queue = [], rec, chunks = [], recording = false, currentAva = null; async function savePair() { const v = $('key').value.trim(); if (!v) return; if (/^\d{6}$/.test(v)) { // PIN mostrato da Remote Control: lo scambio con la chiave try { const r = await fetch(`/pair?pin=${v}`, {cache: 'no-store'}); const j = await r.json(); if (!r.ok || !j.token) { addMsg(j.error || 'PIN rifiutato', 'err'); return; } KEY = j.token; } catch (e) { addMsg('rete: ' + e.message, 'err'); return; } } else KEY = v; try { localStorage.setItem('luza_key', KEY); } catch (e) {} $('key').value = ''; connect(); } function setConn(t, ok) { if (ok) document.querySelectorAll('#log .m.err').forEach(e => e.remove()); $('conn').textContent = t + (mode === 'sse' ? ' · eventi' : mode === 'poll' ? ' · interrogazione' : ''); $('conn').style.color = ok ? '#00ff88' : '#ff3366'; } const HUD = {state: 'LISTENING', level: 0, rec: false}; function setState(s) { const base = s.split(' ')[0], detail = s.includes(' · ') ? s.split(' · ').slice(1).join(' · ') : ''; HUD.state = base; updateStop(); $('stbase').textContent = base; $('stdetail').textContent = detail ? ' · ' + detail : ({LISTENING: ' · in ascolto', THINKING: ' · penso', PROCESSING: ' · elaboro', SPEAKING: ' · parlo', SLEEPING: ' · a riposo'}[base] || ''); } function addMsg(text, cls) { const d = document.createElement('div'); d.className = 'm ' + cls; d.textContent = text; $('log').appendChild(d); while ($('log').children.length > 200) $('log').removeChild($('log').firstChild); $('log').scrollTop = $('log').scrollHeight; return d; } function onLog(line) { const m = line.match(/^(You(?: \(telefono\))?|SYS|ERR|FILE|[^:]{1,24}): ?([\s\S]*)$/); if (!m) { addMsg(line, 'sys'); return; } const who = m[1], txt = m[2]; if (who.startsWith('You')) addMsg(txt, 'you'); else if (who === 'SYS' || who === 'FILE') addMsg(txt, 'sys'); else if (who === 'ERR') addMsg(txt, 'err'); else addMsg(txt, 'ava'); } let wsFailures = 0, es = null, mode = 'ws'; function handle(d) { if (d.type === 'state') setState(d.state); else if (d.type === 'log') onLog(d.text); else if (d.type === 'audio') enqueueAudio(d.wav); else if (d.type === 'confirm') { $('ct').textContent = d.title; $('cd').textContent = d.detail || ''; $('confirm').style.display = 'block'; } else if (d.type === 'confirm_hide') $('confirm').style.display = 'none'; else if (d.type === 'image') addImage(d.url, d.name); else if (d.type === 'file') { const m = addMsg('', 'ava file'); const a = document.createElement('a'); a.href = d.url; a.target = '_blank'; a.textContent = '📄 ' + d.name; m.appendChild(a); } } function addImage(url, name) { const d = document.createElement('div'); d.className = 'm ava img'; const img = document.createElement('img'); img.src = url; img.alt = name || 'immagine'; img.loading = 'lazy'; img.onclick = () => { const v = document.createElement('div'); v.className = 'viewer'; const big = document.createElement('img'); big.src = url; v.appendChild(big); v.onclick = () => v.remove(); document.body.appendChild(v); }; d.appendChild(img); $('log').appendChild(d); $('log').scrollTop = $('log').scrollHeight; } function unpair(msg) { KEY = ''; try { localStorage.removeItem('luza_key'); } catch (e) {} $('pair').style.display = 'flex'; setConn(msg || 'chiave rifiutata', false); addMsg('Chiave non valida: inquadra di nuovo il QR dal Mac oppure incolla la chiave.', 'err'); } async function connect() { if (!KEY) { $('pair').style.display = 'flex'; setConn('non abbinato', false); return; } $('pair').style.display = 'none'; try { // verifica della chiave con una richiesta breve, prima di aprire i canali const r = await fetch(`/auth?k=${encodeURIComponent(KEY)}`, {cache: 'no-store'}); if (r.status === 401) return unpair('chiave rifiutata'); } catch (e) { setConn('rete non raggiungibile, riprovo…', false); return setTimeout(connect, 3000); } if (mode === 'poll') return startPoll(); if (mode === 'sse') return connectSSE(); try { ws && ws.close(); } catch (e) {} let opened = false; ws = new WebSocket(`wss://${location.host}/ws?k=${encodeURIComponent(KEY)}`); ws.onopen = () => { opened = true; wsFailures = 0; setConn('collegato', true); }; ws.onclose = ev => { if (!opened) wsFailures++; if (ev.code === 1008) return unpair(); if (wsFailures >= 2) { mode = 'sse'; return connectSSE(); } // WebSocket bloccato (app sulla Home di iOS): canale di riserva setConn('scollegato, riprovo…', false); setTimeout(connect, 2000); }; ws.onerror = () => {}; ws.onmessage = ev => handle(JSON.parse(ev.data)); } let sseFailures = 0, CID = Math.random().toString(36).slice(2), polling = false; function connectSSE() { try { es && es.close(); } catch (e) {} es = new EventSource(`/events?k=${encodeURIComponent(KEY)}`); es.onopen = () => { sseFailures = 0; setConn('collegato', true); }; es.onmessage = ev => handle(JSON.parse(ev.data)); es.onerror = () => { es.close(); sseFailures++; if (sseFailures >= 2) { mode = 'poll'; return startPoll(); } // anche gli eventi sono bloccati: interrogazione ripetuta setConn('scollegato, riprovo…', false); setTimeout(connectSSE, 2500); }; } async function startPoll() { if (polling) return; polling = true; setConn('collegato', true); while (mode === 'poll') { try { const r = await fetch(`/poll?k=${encodeURIComponent(KEY)}&cid=${CID}`, {cache: 'no-store'}); if (r.status === 401) { polling = false; return unpair(); } const j = await r.json(); (j.events || []).forEach(handle); setConn('collegato', true); } catch (e) { setConn('scollegato, riprovo…', false); await new Promise(res => setTimeout(res, 2500)); } } polling = false; } function send(obj) { if (mode === 'ws' && ws && ws.readyState === 1) { ws.send(JSON.stringify(obj)); return; } fetch(`/cmd?k=${encodeURIComponent(KEY)}`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(obj)}) .then(r => { if (r.status === 401) unpair(); }) .catch(() => addMsg('non collegato', 'err')); } function sendText() { const t = $('txt').value.trim(); if (!t) return; unlockAudio(); send({type: 'say', text: t}); $('txt').value = ''; onTyping(); } function onTyping() { $('sendb').classList.toggle('on', $('txt').value.trim().length > 0); } function updateStop() { const busy = playing || ['THINKING', 'PROCESSING', 'SPEAKING'].includes(HUD.state); $('stopb').classList.toggle('on', busy); } function doConfirm(ok) { send({type: 'confirm', ok}); $('confirm').style.display = 'none'; } // ── audio in uscita: coda di frasi WAV riprodotte in sequenza ───────────── let analyser = null, adata = null, micAnalyser = null, mdata = null; function unlockAudio() { if (!actx) { actx = new (window.AudioContext || window.webkitAudioContext)(); analyser = actx.createAnalyser(); analyser.fftSize = 256; analyser.connect(actx.destination); adata = new Uint8Array(analyser.frequencyBinCount); } if (actx.state === 'suspended') actx.resume(); } function enqueueAudio(b64) { queue.push(b64); if (!playing) playNext(); } async function playNext() { if (!queue.length) { playing = false; updateStop(); return; } playing = true; updateStop(); try { unlockAudio(); const b64 = queue.shift(); const bin = atob(b64); const buf = new Uint8Array(bin.length); for (let i = 0; i < bin.length; i++) buf[i] = bin.charCodeAt(i); const audio = await actx.decodeAudioData(buf.buffer); const src = actx.createBufferSource(); src.buffer = audio; src.connect(analyser); src.onended = playNext; src.start(); } catch (e) { addMsg('audio non riproducibile: tocca lo schermo e riprova', 'err'); playing = false; } } // ── microfono: tocca per registrare, tocca di nuovo per inviare ──────────── async function toggleMic() { unlockAudio(); if (recording) { recording = false; rec.stop(); $('mic').classList.remove('rec'); return; } try { const stream = await navigator.mediaDevices.getUserMedia({audio: true}); const mime = ['audio/mp4', 'audio/webm;codecs=opus', 'audio/webm'].find(m => window.MediaRecorder && MediaRecorder.isTypeSupported(m)) || ''; try { const msrc = actx.createMediaStreamSource(stream); micAnalyser = actx.createAnalyser(); micAnalyser.fftSize = 256; msrc.connect(micAnalyser); mdata = new Uint8Array(micAnalyser.frequencyBinCount); } catch (e) { micAnalyser = null; } rec = new MediaRecorder(stream, mime ? {mimeType: mime} : undefined); chunks = []; rec.ondataavailable = e => { if (e.data.size) chunks.push(e.data); }; rec.onstop = async () => { stream.getTracks().forEach(t => t.stop()); micAnalyser = null; HUD.rec = false; const blob = new Blob(chunks, {type: rec.mimeType || 'audio/webm'}); $('stbase').textContent = 'PROCESSING'; $('stdetail').textContent = ' · trascrivo'; try { const r = await fetch(`/stt?k=${encodeURIComponent(KEY)}&run=1`, {method: 'POST', body: blob, headers: {'Content-Type': blob.type || 'application/octet-stream'}}); const j = await r.json(); if (!j.text) addMsg(j.error || 'non ho capito, riprova', 'err'); } catch (e) { addMsg('invio audio fallito: ' + e, 'err'); } }; rec.start(); recording = true; HUD.rec = true; $('mic').classList.add('rec'); $('stbase').textContent = 'REC'; $('stdetail').textContent = ' · registro, tocca di nuovo per inviare'; } catch (e) { addMsg('microfono non disponibile: serve HTTPS con certificato accettato (' + e.message + ')', 'err'); } } if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {}); document.addEventListener('touchstart', unlockAudio, {once: true, passive: true}); if (navigator.standalone) addMsg('app sulla Home', 'sys'); if (q.get('demo')) { setTimeout(() => { addMsg('Ciao LuZa, che tempo fa oggi a Pescara?', 'you'); addMsg('Lavoro sul Mac (meteo)…', 'sys'); addMsg('Oggi a Pescara è prevalentemente sereno, con una massima di 22 gradi. Domani un po\' di nuvole.', 'ava'); setState('THINKING · genero la risposta · 120 token · 59/s'); HUD.level = 0.55; }, 300); } fetch('/health').then(r => r.ok ? null : addMsg('server risponde ' + r.status, 'err')).catch(e => addMsg('rete: ' + e.message, 'err')); connect(); // ── impostazioni: motore, modello, ragionamento, voce ───────────────────── const LABELS = {provider: 'Motore', effort: 'Profondità (Claude)', mlx_model: 'Modello interno', mlx_thinking: 'Ragionamento (interno)', claudecode_model: 'Modello Claude Code', tts_engine: 'Voce', kokoro_voice: 'Voce Kokoro', qwen_voce: 'Voce clonata', voicebox_profile_id: 'Profilo Voicebox', immagini_famiglia: 'Immagini: famiglia', immagini_modello: 'Immagini: modello'}; const SHOW_IF = {mlx_model: v => v.provider === 'mlx', mlx_thinking: v => v.provider === 'mlx', claudecode_model: v => v.provider === 'claudecode', effort: v => v.provider !== 'mlx' && v.provider !== 'local', kokoro_voice: v => v.tts_engine === 'kokoro', qwen_voce: v => v.tts_engine === 'qwen', voicebox_profile_id: v => v.tts_engine === 'voicebox'}; let sdata = null; async function openSettings() { $('settings').style.display = 'block'; $('shint').textContent = 'carico…'; try { const r = await fetch(`/settings?k=${encodeURIComponent(KEY)}`, {cache: 'no-store'}); sdata = await r.json(); } catch (e) { $('shint').textContent = 'errore: ' + e.message; return; } renderSettings(); $('shint').textContent = ''; } function renderSettings() { const f = $('sform'); f.innerHTML = ''; const v = sdata.values; for (const k of Object.keys(LABELS)) { const opts = sdata.options[k]; if (!opts || (SHOW_IF[k] && !SHOW_IF[k](v))) continue; const lab = document.createElement('label'); lab.textContent = LABELS[k]; f.appendChild(lab); const sel = document.createElement('select'); sel.dataset.key = k; let found = false; for (const [val, name] of opts) { const o = document.createElement('option'); o.value = val; o.textContent = name; if (String(val) === String(v[k] ?? '')) { o.selected = true; found = true; } sel.appendChild(o); } if (!found && v[k]) { const o = document.createElement('option'); o.value = v[k]; o.textContent = v[k] + ' (attuale)'; o.selected = true; sel.appendChild(o); } sel.onchange = () => { v[k] = sel.value; renderSettings(); }; f.appendChild(sel); } } async function saveSettings() { if (!sdata) return; const out = {}; document.querySelectorAll('#sform select').forEach(s => out[s.dataset.key] = s.value); $('shint').textContent = 'applico…'; try { const r = await fetch(`/settings?k=${encodeURIComponent(KEY)}`, {method: 'POST', headers: {'Content-Type': 'application/json'}, body: JSON.stringify(out)}); const j = await r.json(); $('shint').textContent = j.ok ? 'applicato: il Mac ricarica motore e voce' : ('errore: ' + j.error); } catch (e) { $('shint').textContent = 'errore: ' + e.message; } } // ── HUD: nucleo con archi rotanti e forma d'onda ────────────────────────── const COL = {LISTENING: '#00ff88', THINKING: '#ffcc00', PROCESSING: '#ffcc00', SPEAKING: '#ff6b00', SLEEPING: '#3a8a9a', REC: '#ff3366'}; const ringC = $('ring'), waveC = $('wave'); function fitCanvas(c) { const r = window.devicePixelRatio || 1, w = c.clientWidth, h = c.clientHeight; if (c.width !== w * r || c.height !== h * r) { c.width = w * r; c.height = h * r; } return [c.getContext('2d'), w, h, r]; } function level() { if (HUD.rec && micAnalyser) { micAnalyser.getByteTimeDomainData(mdata); let m = 0; for (const v of mdata) m = Math.max(m, Math.abs(v - 128)); return Math.min(1, m / 60); } if (playing && analyser) { analyser.getByteTimeDomainData(adata); let m = 0; for (const v of adata) m = Math.max(m, Math.abs(v - 128)); return Math.min(1, m / 50); } return 0; } let t0 = performance.now(); function drawHUD(now) { const t = (now - t0) / 1000; const lv = level(); HUD.level += (lv - HUD.level) * 0.35; const st = HUD.rec ? 'REC' : (playing ? 'SPEAKING' : HUD.state); const col = COL[st] || '#00d4ff'; const busy = st === 'THINKING' || st === 'PROCESSING'; // nucleo let [g, w, h, r] = fitCanvas(ringC); g.setTransform(r, 0, 0, r, 0, 0); g.clearRect(0, 0, w, h); const cx = w / 2, cy = h / 2 - 8, R = Math.min(w, h) * 0.34; const speed = busy ? 2.2 : st === 'SPEAKING' ? 1.6 : 0.5; g.lineCap = 'round'; const arcs = [[1.0, 0.9, 1, 1.4], [0.86, -0.6, 2, 1.1], [0.72, 1.3, 1, 0.8], [0.58, -1.9, 3, 0.6]]; for (const [rr, dir, n, span] of arcs) { for (let i = 0; i < n; i++) { const a0 = t * speed * dir + i * (Math.PI * 2 / n); g.beginPath(); g.arc(cx, cy, R * rr, a0, a0 + span); g.strokeStyle = col; g.globalAlpha = 0.85 - (1 - rr) * 0.9; g.lineWidth = 2; g.shadowColor = col; g.shadowBlur = 10; g.stroke(); } } g.globalAlpha = 1; g.shadowBlur = 0; // tacche g.strokeStyle = 'rgba(0,212,255,.35)'; g.lineWidth = 1; for (let i = 0; i < 60; i++) { const a = i / 60 * Math.PI * 2 + t * 0.15; const l = i % 5 === 0 ? 8 : 4; g.beginPath(); g.moveTo(cx + Math.cos(a) * (R * 1.12), cy + Math.sin(a) * (R * 1.12)); g.lineTo(cx + Math.cos(a) * (R * 1.12 + l), cy + Math.sin(a) * (R * 1.12 + l)); g.stroke(); } // sfera centrale che respira con l'audio const rad = R * 0.42 * (1 + HUD.level * 0.35 + (busy ? Math.sin(t * 6) * 0.04 : Math.sin(t * 1.5) * 0.02)); const grad = g.createRadialGradient(cx, cy, rad * 0.1, cx, cy, rad); grad.addColorStop(0, col); grad.addColorStop(0.55, col + '66'); grad.addColorStop(1, 'rgba(0,0,0,0)'); g.fillStyle = grad; g.beginPath(); g.arc(cx, cy, rad, 0, Math.PI * 2); g.fill(); g.strokeStyle = col; g.lineWidth = 1.5; g.shadowColor = col; g.shadowBlur = 16; g.beginPath(); g.arc(cx, cy, rad * 0.9, 0, Math.PI * 2); g.stroke(); g.shadowBlur = 0; // forma d'onda [g, w, h, r] = fitCanvas(waveC); g.setTransform(r, 0, 0, r, 0, 0); g.clearRect(0, 0, w, h); const N = 42, bw = w / N; for (let i = 0; i < N; i++) { const env = Math.pow(1 - Math.abs(i - (N - 1) / 2) / ((N - 1) / 2), 0.7); const idle = 2 + 2 * Math.sin(t * 2.2 + i * 0.6); const hh = Math.max(2, Math.min(h - 4, idle + HUD.level * (h - 6) * env * (0.6 + 0.4 * Math.sin(t * 9 + i)))); g.fillStyle = HUD.level > 0.05 ? col : 'rgba(0,212,255,.25)'; g.fillRect(i * bw + 1, (h - hh) / 2, bw - 2, hh); } requestAnimationFrame(drawHUD); } requestAnimationFrame(drawHUD);