860 lines
29 KiB
JavaScript
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();
|
|
});
|