'use strict'; // ============================================================ // CONSTANTES VISUELLES // ============================================================ const COLORS = { idle: { r: 0, g: 212, b: 255 }, listening: { r: 0, g: 255, b: 136 }, thinking: { r: 255, g: 170, b: 0 }, speaking: { r: 120, g: 210, b: 255 }, }; const rgb = (c, a = 1) => `rgba(${c.r},${c.g},${c.b},${a})`; const RINGS = [ { count: 48, radius: 185, speed: 0.35, dotSize: 2.2 }, { count: 32, radius: 135, speed: -0.55, dotSize: 2.8 }, { count: 20, radius: 85, speed: 0.90, dotSize: 3.2 }, { count: 10, radius: 40, speed: -1.50, dotSize: 2.6 }, ]; const LISTEN_RADII = [50, 34, 22, 12]; const NODE_COUNT = 22; const LINK_DIST = 135; // distance max pour qu'un lien se dessine // ============================================================ // VISUALIZER (inchangé — fonctionne déjà) // ============================================================ class JarvisVisualizer { constructor(canvas) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.state = 'idle'; this.time = 0; this.lastTs = 0; this.statusText = ''; this.subText = ''; this.subAlpha = 1; this._initParticles(); this._resize(); window.addEventListener('resize', () => this._resize()); requestAnimationFrame(ts => this._frame(ts)); } _resize() { this.canvas.width = window.innerWidth; this.canvas.height = window.innerHeight; this.cx = window.innerWidth / 2; this.cy = window.innerHeight / 2 - 25; } _initParticles() { this.particles = []; RINGS.forEach((ring, ri) => { for (let i = 0; i < ring.count; i++) { const baseAngle = (i / ring.count) * Math.PI * 2; this.particles.push({ ring: ri, baseRadius: ring.radius, radius: ring.radius, targetRadius: ring.radius, angle: baseAngle, baseAngle, speed: ring.speed, dotSize: ring.dotSize, phaseOff: Math.random() * Math.PI * 2, wave: 0, }); } }); // Nœuds du réseau neuronal (orbites irrégulières) this.nodes = []; for (let i = 0; i < NODE_COUNT; i++) { const shell = 60 + Math.random() * 140; this.nodes.push({ baseRadius: shell, radius: shell, angle: Math.random() * Math.PI * 2, speed: (Math.random() - 0.5) * 0.35, wobbleAmp: 6 + Math.random() * 10, wobbleFreq: 0.5 + Math.random() * 1.2, phase: Math.random() * Math.PI * 2, size: 1.6 + Math.random() * 1.8, pulsePhase: Math.random() * Math.PI * 2, x: 0, y: 0, }); } // Pulses voyageant le long des liens : {from, to, t, speed} this.pulses = []; } setState(state) { this.state = state; this.particles.forEach(p => { switch (state) { case 'idle': p.targetRadius = p.baseRadius; break; case 'listening': p.targetRadius = LISTEN_RADII[p.ring]; break; case 'thinking': p.targetRadius = p.baseRadius * 0.55; break; case 'speaking': p.targetRadius = p.baseRadius; break; } }); } setStatus(main, sub = '') { this.statusText = main; this.subText = sub; this.subAlpha = 1; } _update(dt) { this.time += dt; const speedMult = { idle:1.0, listening:6.0, thinking:3.0, speaking:1.8 }[this.state] || 1; const lerpK = { idle:1.5, listening:6.0, thinking:3.0, speaking:2.0 }[this.state] || 2; this.particles.forEach(p => { const diff = p.targetRadius - p.radius; p.radius += diff * Math.min(1, lerpK * dt); p.angle += RINGS[p.ring].speed * dt * speedMult; if (this.state === 'speaking') { p.wave = Math.sin(this.time * 5 + p.baseAngle * 4 + p.phaseOff) * 18; } else { p.wave *= Math.max(0, 1 - dt * 4); } }); // Nœuds du réseau const nodeSpeedMult = { idle:1, listening:1.6, thinking:2.2, speaking:1.3 }[this.state] || 1; this.nodes.forEach(n => { n.angle += n.speed * dt * nodeSpeedMult; const wobble = Math.sin(this.time * n.wobbleFreq + n.phase) * n.wobbleAmp; n.radius = n.baseRadius + wobble; n.x = this.cx + Math.cos(n.angle) * n.radius; n.y = this.cy + Math.sin(n.angle) * n.radius; }); // Spawn pulses aléatoires sur les liens proches const spawnRate = { idle:0.8, listening:2.2, thinking:3.5, speaking:1.8 }[this.state] || 1; if (Math.random() < spawnRate * dt) { const i = Math.floor(Math.random() * this.nodes.length); const a = this.nodes[i]; // Trouver un voisin proche let best = -1, bestD = LINK_DIST; for (let j = 0; j < this.nodes.length; j++) { if (j === i) continue; const b = this.nodes[j]; const d = Math.hypot(a.x - b.x, a.y - b.y); if (d < bestD) { bestD = d; best = j; } } if (best >= 0) this.pulses.push({ from: i, to: best, t: 0, speed: 1.5 + Math.random() * 1.5 }); } // Avancer + nettoyer pulses for (const p of this.pulses) p.t += dt * p.speed; this.pulses = this.pulses.filter(p => p.t < 1); } _draw() { const { ctx, cx, cy, state, time } = this; const W = this.canvas.width, H = this.canvas.height; const col = COLORS[state]; ctx.clearRect(0, 0, W, H); const bgR = 225; const bgGrad = ctx.createRadialGradient(cx, cy, 0, cx, cy, bgR); bgGrad.addColorStop(0, 'rgba(4, 10, 22, 0.95)'); bgGrad.addColorStop(0.65, 'rgba(2, 6, 15, 0.90)'); bgGrad.addColorStop(0.88, 'rgba(2, 6, 15, 0.55)'); bgGrad.addColorStop(1, 'rgba(0, 0, 0, 0)'); ctx.beginPath(); ctx.arc(cx, cy, bgR, 0, Math.PI * 2); ctx.fillStyle = bgGrad; ctx.fill(); ctx.save(); RINGS.forEach(ring => { ctx.beginPath(); ctx.arc(cx, cy, ring.radius, 0, Math.PI * 2); ctx.strokeStyle = rgb(col, 0.06); ctx.lineWidth = 1; ctx.stroke(); }); ctx.restore(); // ─── RÉSEAU NEURONAL : liens ─────────────────────────── ctx.save(); ctx.lineWidth = 1; for (let i = 0; i < this.nodes.length; i++) { const a = this.nodes[i]; for (let j = i + 1; j < this.nodes.length; j++) { const b = this.nodes[j]; const d = Math.hypot(a.x - b.x, a.y - b.y); if (d > LINK_DIST) continue; const alpha = (1 - d / LINK_DIST) * 0.35; ctx.strokeStyle = rgb(col, alpha); ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); } } ctx.restore(); // ─── PULSES le long des liens ────────────────────────── ctx.save(); ctx.shadowBlur = 12; ctx.shadowColor = rgb(col, 0.9); for (const p of this.pulses) { const a = this.nodes[p.from], b = this.nodes[p.to]; if (!a || !b) continue; const x = a.x + (b.x - a.x) * p.t; const y = a.y + (b.y - a.y) * p.t; const fade = Math.sin(p.t * Math.PI); ctx.fillStyle = rgb(col, 0.95 * fade); ctx.beginPath(); ctx.arc(x, y, 2.2, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); // ─── NŒUDS du réseau ─────────────────────────────────── ctx.save(); ctx.shadowBlur = 8; ctx.shadowColor = rgb(col, 0.85); for (const n of this.nodes) { const pulse = 1 + Math.sin(time * 2.5 + n.pulsePhase) * 0.35; ctx.fillStyle = rgb(col, 0.95); ctx.beginPath(); ctx.arc(n.x, n.y, n.size * pulse, 0, Math.PI * 2); ctx.fill(); } ctx.restore(); if (state === 'thinking') { ctx.save(); ctx.shadowBlur = 12; ctx.shadowColor = rgb(col, 0.9); ctx.strokeStyle = rgb(col, 0.85); ctx.lineWidth = 2; const prog = (time * 0.9) % 1; const start = prog * Math.PI * 2; ctx.beginPath(); ctx.arc(cx, cy, 192, start, start + Math.PI * 0.55); ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, 192, start + Math.PI, start + Math.PI * 1.55); ctx.stroke(); ctx.restore(); } ctx.save(); ctx.shadowBlur = 9; ctx.shadowColor = rgb(col, 0.75); ctx.fillStyle = rgb(col, 0.97); this.particles.forEach(p => { const r = p.radius + (p.wave || 0); const x = cx + Math.cos(p.angle) * r; const y = cy + Math.sin(p.angle) * r; ctx.beginPath(); ctx.arc(x, y, p.dotSize, 0, Math.PI * 2); ctx.fill(); }); ctx.restore(); // ─── ARC REACTOR CORE ────────────────────────────────── const pulseFreq = state === 'listening' ? 9 : 2.5; const pulse = 1 + Math.sin(time * pulseFreq) * 0.4; // Rayons lumineux qui tournent ctx.save(); ctx.translate(cx, cy); ctx.rotate(time * 0.6); for (let i = 0; i < 6; i++) { ctx.rotate((Math.PI * 2) / 6); const grad = ctx.createLinearGradient(0, 0, 26, 0); grad.addColorStop(0, rgb(col, 0.7)); grad.addColorStop(1, rgb(col, 0)); ctx.strokeStyle = grad; ctx.lineWidth = 2.5; ctx.beginPath(); ctx.moveTo(6, 0); ctx.lineTo(26, 0); ctx.stroke(); } ctx.restore(); // Arcs concentriques contra-rotatifs ctx.save(); ctx.lineWidth = 2; ctx.shadowBlur = 10; ctx.shadowColor = rgb(col, 0.85); ctx.strokeStyle = rgb(col, 0.9); const a1 = time * 1.4; ctx.beginPath(); ctx.arc(cx, cy, 15, a1, a1 + Math.PI * 0.85); ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, 15, a1 + Math.PI, a1 + Math.PI * 1.85); ctx.stroke(); const a2 = -time * 2.1; ctx.strokeStyle = rgb(col, 0.7); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(cx, cy, 22, a2, a2 + Math.PI * 0.6); ctx.stroke(); ctx.beginPath(); ctx.arc(cx, cy, 22, a2 + Math.PI, a2 + Math.PI * 1.6); ctx.stroke(); ctx.restore(); // Cœur lumineux + halo ctx.save(); ctx.shadowBlur = 30; ctx.shadowColor = rgb(col, 1); ctx.fillStyle = rgb(col, 1); ctx.beginPath(); ctx.arc(cx, cy, 5 * pulse, 0, Math.PI * 2); ctx.fill(); const halo = ctx.createRadialGradient(cx, cy, 0, cx, cy, 34 * pulse); halo.addColorStop(0, rgb(col, 0.45)); halo.addColorStop(1, 'rgba(0,0,0,0)'); ctx.beginPath(); ctx.arc(cx, cy, 34 * pulse, 0, Math.PI * 2); ctx.fillStyle = halo; ctx.fill(); ctx.restore(); if (state === 'idle' || state === 'speaking') { ctx.save(); ctx.strokeStyle = rgb(col, 0.12); ctx.lineWidth = 1.5; ctx.beginPath(); ctx.arc(cx, cy, 193, 0, Math.PI * 2); ctx.stroke(); ctx.strokeStyle = rgb(col, 0.25); ctx.lineWidth = 2; for (let i = 0; i < 12; i++) { const a = (i / 12) * Math.PI * 2; ctx.beginPath(); ctx.moveTo(cx + Math.cos(a) * 189, cy + Math.sin(a) * 189); ctx.lineTo(cx + Math.cos(a) * 196, cy + Math.sin(a) * 196); ctx.stroke(); } ctx.restore(); } ctx.save(); ctx.font = '10px "SF Mono", "Courier New", monospace'; ctx.fillStyle = rgb(col, 0.38); ctx.textAlign = 'center'; const labels = { idle:'● STANDBY', listening:'◉ LISTENING', thinking:'◈ PROCESSING', speaking:'◎ RESPONDING', }; ctx.fillText(labels[state] || '', cx, cy - 200); ctx.restore(); if (this.statusText) { ctx.save(); ctx.font = 'bold 12px "SF Mono", "Courier New", monospace'; ctx.fillStyle = rgb(col, 0.92); ctx.textAlign = 'center'; ctx.shadowBlur = 6; ctx.shadowColor = rgb(col, 0.5); ctx.fillText(this.statusText, cx, cy + 208); ctx.restore(); } if (this.subText) { ctx.save(); ctx.font = '10px "SF Mono", "Courier New", monospace'; ctx.fillStyle = `rgba(160,210,240,${0.72 * this.subAlpha})`; ctx.textAlign = 'center'; this._wrapText(ctx, this.subText, cx, cy + 228, 340, 14, 3); ctx.restore(); } } _wrapText(ctx, text, x, y, maxW, lh, maxLines) { const words = text.split(' '); let line = '', curY = y, count = 0; for (const word of words) { const test = line + word + ' '; if (ctx.measureText(test).width > maxW && line) { if (count >= maxLines) break; ctx.fillText(line.trim(), x, curY); line = word + ' '; curY += lh; count++; } else { line = test; } } if (line && count < maxLines + 1) ctx.fillText(line.trim(), x, curY); } _frame(ts) { const dt = Math.min((ts - (this.lastTs || ts)) / 1000, 0.05); this.lastTs = ts; this._update(dt); this._draw(); requestAnimationFrame(ts => this._frame(ts)); } } // ============================================================ // UTILS AUDIO // ============================================================ const SAMPLE_RATE = 16000; /** Encode Float32 PCM mono en WAV 16-bit */ function encodeWAV(samples, sampleRate = SAMPLE_RATE) { const int16 = new Int16Array(samples.length); for (let i = 0; i < samples.length; i++) { const s = Math.max(-1, Math.min(1, samples[i])); int16[i] = s < 0 ? s * 0x8000 : s * 0x7fff; } const dataLen = int16.byteLength; const buf = new ArrayBuffer(44 + dataLen); const view = new DataView(buf); const ws = (off, s) => { for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i)); }; ws(0, 'RIFF'); view.setUint32(4, 36 + dataLen, true); ws(8, 'WAVE'); ws(12, 'fmt '); view.setUint32(16, 16, true); view.setUint16(20, 1, true); view.setUint16(22, 1, true); view.setUint32(24, sampleRate, true); view.setUint32(28, sampleRate * 2, true); view.setUint16(32, 2, true); view.setUint16(34, 16, true); ws(36, 'data'); view.setUint32(40, dataLen, true); new Int16Array(buf, 44).set(int16); return new Blob([buf], { type: 'audio/wav' }); } function rms(frame) { let sum = 0; for (let i = 0; i < frame.length; i++) sum += frame[i] * frame[i]; return Math.sqrt(sum / frame.length); } function concatFloat32(chunks) { const total = chunks.reduce((s, c) => s + c.length, 0); const r = new Float32Array(total); let off = 0; for (const c of chunks) { r.set(c, off); off += c.length; } return r; } /** Envoie un WAV à whisper-server et renvoie le texte */ async function transcribe(url, samples, lang = 'auto') { const wav = encodeWAV(samples); const fd = new FormData(); fd.append('file', wav, 'audio.wav'); fd.append('temperature', '0'); fd.append('response_format', 'text'); fd.append('language', lang); try { const res = await fetch(url, { method: 'POST', body: fd }); if (!res.ok) { console.warn('[whisper] HTTP', res.status); return ''; } const text = await res.text(); return (text || '').replace(/\[.*?\]/g, '').trim(); } catch (e) { console.error('[whisper]', e.message); return ''; } } // ============================================================ // PIPELINE AUDIO (micro → Float32 16kHz) // ============================================================ class AudioPipeline { constructor(onFrame) { this.onFrame = onFrame; } async start() { this.stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true, autoGainControl: true, }, }); this.ctx = new AudioContext(); this.hwRate = this.ctx.sampleRate; this.ratio = this.hwRate / SAMPLE_RATE; const src = this.ctx.createMediaStreamSource(this.stream); const proc = this.ctx.createScriptProcessor(4096, 1, 1); proc.onaudioprocess = (e) => { const input = e.inputBuffer.getChannelData(0); const outLen = Math.floor(input.length / this.ratio); const out = new Float32Array(outLen); for (let i = 0; i < outLen; i++) { const startF = i * this.ratio; const endF = Math.min(input.length, startF + this.ratio); const s = Math.floor(startF), e2 = Math.ceil(endF); let sum = 0, n = 0; for (let j = s; j < e2; j++) { sum += input[j]; n++; } out[i] = n ? sum / n : 0; } this.onFrame(out); }; // Node muet pour garder le graphe actif sans réinjecter le micro dans les HP const mute = this.ctx.createGain(); mute.gain.value = 0; src.connect(proc); proc.connect(mute); mute.connect(this.ctx.destination); this._src = src; this._proc = proc; } } // ============================================================ // CONTROLLER : état + wake word + écoute + commande // ============================================================ class JarvisController { constructor() { this.viz = new JarvisVisualizer(document.getElementById('canvas')); this.state = 'boot'; // boot | idle | listening | thinking | speaking this.whisperUrl = null; // Buffer circulaire pour la détection du mot-clé this.ringBuffer = new Float32Array(SAMPLE_RATE * 3); // 3s this.ringWrite = 0; this.ringFilled = 0; // Accumulation pendant la commande this.cmdChunks = []; this.cmdStartTime = 0; this.listenStart = 0; this.lastVoiceTime = 0; this.speechStarted = false; this.inlineText = ''; // Boucle wake-word this.wakeTimer = null; // Paramètres this.SILENCE_RMS = 0.010; this.WAKE_SILENCE_MS = 500; // silence qui clôt un segment wake this.WAKE_MAX_MS = 4000; // cap dur sur un segment wake this.WAKE_MIN_MS = 250; // un segment trop court = ignoré this.SILENCE_DURATION = 1500; this.MAX_CMD_DURATION = 12000; this.MAX_WAIT_SPEECH = 6000; // État du VAD wake this.wakeChunks = []; this.wakeActive = false; this.wakeStartTime = 0; this.wakeLastVoice = 0; this.wakeBusy = false; // Détecteur de clap this.CLAP_PEAK_RMS = 0.22; // amplitude mini pour qu'un pic soit un clap this.CLAP_QUIET_MS = 120; // silence requis avant/après this.CLAP_WINDOW_MS = 600; // 2 claps doivent tomber dans cette fenêtre this.lastClapTime = 0; this.lastLoudTime = 0; this.lastQuietTime = performance.now(); } async init() { this.viz.setState('idle'); this.viz.setStatus('INITIALISATION...', 'Connexion au moteur de transcription'); this.whisperUrl = await window.jarvis.whisperUrl(); // Attendre que whisper-server réponde (il charge le modèle ~2s) const ok = await this._waitForWhisper(); if (!ok) { this.viz.setStatus('WHISPER INDISPONIBLE', 'Modèle introuvable — voir console'); return; } // Audio pipeline try { this.audio = new AudioPipeline((f) => this._onAudio(f)); await this.audio.start(); } catch (e) { console.error(e); this.viz.setStatus('MICRO REFUSÉ', 'Autorisez le micro dans Préférences Système'); return; } window.addEventListener('contextmenu', (e) => { e.preventDefault(); window.jarvis.showContextMenu(); }); document.addEventListener('keydown', (e) => { if (e.code === 'Escape') this._goIdle(); }); this._setState('speaking'); this.viz.setStatus('JARVIS', 'Opérationnel'); await window.jarvis.speak('À votre service.'); this._goIdle(); } async _waitForWhisper() { for (let i = 0; i < 30; i++) { try { // ping: GET sur / renvoie 404/400 mais prouve que le serveur écoute const res = await fetch(this.whisperUrl, { method: 'OPTIONS' }) .catch(() => fetch(this.whisperUrl.replace('/inference', '/'), { method: 'GET' })); if (res) return true; } catch (_) {} await new Promise(r => setTimeout(r, 500)); } return false; } // ── STATE ─────────────────────────────────────────────── _setState(s) { this.state = s; this.viz.setState(s === 'boot' ? 'idle' : s); } // ── PIPELINE AUDIO ────────────────────────────────────── _onAudio(frame) { // Ne jamais capturer sa propre voix if (this.state === 'thinking' || this.state === 'speaking') return; // Détection clap → wake (en état idle uniquement) if (this.state === 'idle' && this._detectClap(frame)) { console.log('[JARVIS] double clap détecté → listening'); this.wakeActive = false; this.wakeChunks = []; this._enterListening(''); return; } // VAD-driven wake detection (en état idle) if (this.state === 'idle') this._onAudioWake(frame); // Accumulation de la commande if (this.state === 'listening') { this.cmdChunks.push(frame); const r = rms(frame); const now = performance.now(); if (r > this.SILENCE_RMS) { this.lastVoiceTime = now; if (!this.speechStarted) { this.speechStarted = true; this.cmdStartTime = now; } } if (this.speechStarted) { const silentFor = now - this.lastVoiceTime; const totalDur = now - this.cmdStartTime; if (silentFor > this.SILENCE_DURATION || totalDur > this.MAX_CMD_DURATION) { this._finalize(); } } else if (now - this.listenStart > this.MAX_WAIT_SPEECH) { // L'utilisateur ne dit rien après le wake-word if (this.inlineText) this._finalize(); else this._goIdle(); } } } _detectClap(frame) { const r = rms(frame); const now = performance.now(); const loud = r > this.CLAP_PEAK_RMS; if (loud) { const quietFor = now - this.lastLoudTime; if (quietFor > this.CLAP_QUIET_MS) { if (now - this.lastClapTime < this.CLAP_WINDOW_MS && now - this.lastClapTime > 80) { this.lastClapTime = 0; return true; } this.lastClapTime = now; } this.lastLoudTime = now; } return false; } _onAudioWake(frame) { if (this.wakeBusy) return; const r = rms(frame); const now = performance.now(); const voiced = r > this.SILENCE_RMS; if (this.wakeActive) { this.wakeChunks.push(frame); if (voiced) this.wakeLastVoice = now; const silentFor = now - this.wakeLastVoice; const totalDur = now - this.wakeStartTime; if (totalDur > this.WAKE_MAX_MS || silentFor > this.WAKE_SILENCE_MS) { this._processWakeSegment(); } } else if (voiced) { this.wakeActive = true; this.wakeChunks = [frame]; this.wakeStartTime = now; this.wakeLastVoice = now; } } async _processWakeSegment() { const chunks = this.wakeChunks; this.wakeActive = false; this.wakeChunks = []; const dur = performance.now() - this.wakeStartTime; if (dur < this.WAKE_MIN_MS) return; this.wakeBusy = true; try { const samples = concatFloat32(chunks); const text = await transcribe(this.whisperUrl, samples, 'fr'); if (this.state !== 'idle') return; if (!text) return; console.log('[JARVIS wake heard]', JSON.stringify(text)); const lower = text.toLowerCase(); const cleaned = lower.replace(/[',.!?:;]/g, ' ').replace(/\s+/g, ' ').trim(); const match = cleaned.match(/\b(jarvis|jarvice|javis|jervis|charvis|sharvis|darvis|garvis|j\s*arvis|j\s*avis[e]?|j\s*arrive|j\s*ai\s*avis[e]?|j\s*ai\s*revis[e]?|j\s*ai\s*arrive)\b\s*(.*)/); if (!match) return; console.log('[JARVIS] wake-word détecté →', text); const inline = (match[2] || '').trim(); this._enterListening(inline); } finally { this.wakeBusy = false; } } _pushRing(frame) { const len = this.ringBuffer.length; for (let i = 0; i < frame.length; i++) { this.ringBuffer[this.ringWrite] = frame[i]; this.ringWrite = (this.ringWrite + 1) % len; } this.ringFilled = Math.min(len, this.ringFilled + frame.length); } _ringSnapshot(seconds) { const want = Math.min(this.ringFilled, Math.floor(seconds * SAMPLE_RATE)); const out = new Float32Array(want); const len = this.ringBuffer.length; const start = (this.ringWrite - want + len) % len; if (start + want <= len) { out.set(this.ringBuffer.subarray(start, start + want)); } else { const first = len - start; out.set(this.ringBuffer.subarray(start), 0); out.set(this.ringBuffer.subarray(0, want - first), first); } return out; } _clearRing() { this.ringWrite = 0; this.ringFilled = 0; } // ── WAKE-WORD LOOP ────────────────────────────────────── _startWakeLoop() { this.viz.setStatus('JARVIS READY', 'Dites "Jarvis [commande]"'); this.wakeActive = false; this.wakeChunks = []; this.wakeBusy = false; } // ── LISTENING ─────────────────────────────────────────── _enterListening(inlineText = '') { clearInterval(this.wakeTimer); this.wakeTimer = null; this._setState('listening'); this.viz.setStatus('ÉCOUTE...', inlineText || 'Parlez'); this.cmdChunks = []; this.inlineText = inlineText; this.speechStarted = false; this.listenStart = performance.now(); this.cmdStartTime = performance.now(); this.lastVoiceTime = performance.now(); this._clearRing(); // évite que "jarvis" reste dans le buffer } async _finalize() { if (this.state !== 'listening') return; this._setState('thinking'); const samples = concatFloat32(this.cmdChunks); this.cmdChunks = []; let command = this.inlineText || ''; if (samples.length > SAMPLE_RATE * 0.3) { this.viz.setStatus('TRANSCRIPTION...', ''); const text = await transcribe(this.whisperUrl, samples, 'fr'); if (text) command = (command + ' ' + text).trim(); } command = command.replace(/^(jarvis[,!.:]?\s*)+/i, '').trim(); console.log('[JARVIS] commande :', command); if (!command) { this._goIdle(); return; } this.viz.setStatus('PROCESSING...', command.slice(0, 120)); const result = await window.jarvis.askClaude(command); this._setState('speaking'); if (!result.success) { this.viz.setStatus('ERROR', (result.error || '').slice(0, 120)); await window.jarvis.speak("Une erreur s'est produite."); } else { this.viz.setStatus('JARVIS', result.response.slice(0, 120)); await window.jarvis.speak(result.response); } this._goIdle(); } _goIdle() { clearInterval(this.wakeTimer); this.wakeTimer = null; this.cmdChunks = []; this.inlineText = ''; this.speechStarted = false; this._clearRing(); this._setState('idle'); // Petit délai pour éviter de capter la fin de notre propre réponse setTimeout(() => this._startWakeLoop(), 400); } } // ============================================================ // BOOT // ============================================================ function generateDockIcon() { const S = 512; const c = document.createElement('canvas'); c.width = S; c.height = S; const g = c.getContext('2d'); const cx = S / 2, cy = S / 2; // Fond radial sombre const bg = g.createRadialGradient(cx, cy, 0, cx, cy, S / 2); bg.addColorStop(0, '#0a1826'); bg.addColorStop(0.6, '#030812'); bg.addColorStop(1, '#000000'); g.fillStyle = bg; g.beginPath(); g.arc(cx, cy, S / 2 - 4, 0, Math.PI * 2); g.fill(); // Anneau externe g.strokeStyle = 'rgba(0,212,255,0.85)'; g.lineWidth = 6; g.beginPath(); g.arc(cx, cy, S / 2 - 18, 0, Math.PI * 2); g.stroke(); // Ticks sur l'anneau g.strokeStyle = 'rgba(0,212,255,0.9)'; g.lineWidth = 4; for (let i = 0; i < 24; i++) { const a = (i / 24) * Math.PI * 2; const r1 = S / 2 - 30, r2 = S / 2 - 44; g.beginPath(); g.moveTo(cx + Math.cos(a) * r1, cy + Math.sin(a) * r1); g.lineTo(cx + Math.cos(a) * r2, cy + Math.sin(a) * r2); g.stroke(); } // Arcs concentriques façon réacteur g.shadowBlur = 20; g.shadowColor = 'rgba(0,212,255,1)'; g.strokeStyle = 'rgba(0,212,255,1)'; g.lineWidth = 8; g.beginPath(); g.arc(cx, cy, 150, 0.3, 0.3 + Math.PI * 0.8); g.stroke(); g.beginPath(); g.arc(cx, cy, 150, 0.3 + Math.PI, 0.3 + Math.PI * 1.8); g.stroke(); g.lineWidth = 5; g.beginPath(); g.arc(cx, cy, 108, -0.5, -0.5 + Math.PI * 0.7); g.stroke(); g.beginPath(); g.arc(cx, cy, 108, -0.5 + Math.PI, -0.5 + Math.PI * 1.7); g.stroke(); // Rayons en étoile for (let i = 0; i < 6; i++) { const a = (i / 6) * Math.PI * 2; const grad = g.createLinearGradient( cx + Math.cos(a) * 30, cy + Math.sin(a) * 30, cx + Math.cos(a) * 95, cy + Math.sin(a) * 95 ); grad.addColorStop(0, 'rgba(120,220,255,1)'); grad.addColorStop(1, 'rgba(0,180,255,0)'); g.strokeStyle = grad; g.lineWidth = 10; g.beginPath(); g.moveTo(cx + Math.cos(a) * 30, cy + Math.sin(a) * 30); g.lineTo(cx + Math.cos(a) * 95, cy + Math.sin(a) * 95); g.stroke(); } // Cœur g.shadowBlur = 40; const core = g.createRadialGradient(cx, cy, 0, cx, cy, 50); core.addColorStop(0, 'rgba(255,255,255,1)'); core.addColorStop(0.3, 'rgba(180,240,255,1)'); core.addColorStop(1, 'rgba(0,212,255,0)'); g.fillStyle = core; g.beginPath(); g.arc(cx, cy, 50, 0, Math.PI * 2); g.fill(); g.shadowBlur = 0; g.fillStyle = '#ffffff'; g.beginPath(); g.arc(cx, cy, 16, 0, Math.PI * 2); g.fill(); return c.toDataURL('image/png'); } window.addEventListener('DOMContentLoaded', () => { try { const icon = generateDockIcon(); window.jarvis.setDockIcon(icon); } catch (e) { console.error('[JARVIS] icon:', e); } new JarvisController().init(); });