jarvis/renderer.js
2026-04-13 22:01:33 +02:00

860 lines
29 KiB
JavaScript

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