Files
carinha-control-panel/carinha.html
julioandClaude Opus 5.5 628db8492f feat(rosto): maos (esconder, espiar, acenar, pensar) e faixa de baixo
- rosto-motor.js desenha luvinhas na cor dos olhos que sobem da borda da
  tela, sincronizadas com as sequencias de rosto de mesmo nome
- faixa de baixo: legenda da resposta da conversa com efeito de digitacao,
  tres pontinhos enquanto a IA pensa, equalizador ao dancar e indicador de
  carga (raio e porcentagem) quando o celular esta no carregador
- gestos novos no servidor, no painel e na tela Rosto; a IA (Qwen/Gemini)
  pode responder acenando, se escondendo ou pensando
- a previa do painel tambem mostra a legenda da conversa

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-06 02:39:00 -03:00

1128 lines
51 KiB
HTML

<!doctype html>
<html lang="pt-BR">
<meta charset="utf-8">
<title>Eilik</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<style>
html,body{margin:0;padding:0;width:100%;height:100%;overflow:hidden;background:#000}
svg{display:block;width:100%;height:100%}
.corpo{fill-rule:evenodd}
.traco{fill:none;stroke-linecap:round}
.corpo,.traco{filter:drop-shadow(0 0 2px currentColor)}
#o1,#o2,#boca,#sobrancelha1,#sobrancelha2{color:#70cadd}
.iconeOlho,.figurinhaDinamica{animation:aparecerFigura .28s ease-out}
@keyframes aparecerFigura{from{opacity:0}to{opacity:1}}
@media(prefers-reduced-motion:reduce){.iconeOlho,.figurinhaDinamica{animation:none}}
#fps{position:fixed;left:4px;top:2px;color:#2b3a4a;font:12px monospace;display:none}
</style>
<body>
<svg id="tela" viewBox="0 0 480 320" preserveAspectRatio="xMidYMid meet">
<rect x="0" y="0" width="480" height="320" fill="rgb(0,0,0)"/>
<g id="o1"><path class="corpo"/><path class="traco"/><ellipse class="brilho" fill="rgb(245,250,255)"/></g>
<g id="o2"><path class="corpo"/><path class="traco"/><ellipse class="brilho" fill="rgb(245,250,255)"/></g>
<path id="boca" class="traco"/>
<path id="sobrancelha1" class="traco" style="display:none"/>
<path id="sobrancelha2" class="traco" style="display:none"/>
</svg>
<div id="fps"></div>
<script src="rosto-motor.js"></script>
<script>
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
(function () {
const token = new URLSearchParams(location.search).get('t');
window.carinhaToken = token || '';
if (!token || !window.fetch) return;
const original = window.fetch.bind(window);
window.fetch = (recurso, opcoes = {}) => {
try {
const url = new URL(typeof recurso === 'string' ? recurso : recurso.url, location.href);
if (url.origin === location.origin) {
const cabecalhos = new Headers(opcoes.headers || {});
cabecalhos.set('Authorization', 'Bearer ' + token);
opcoes = Object.assign({}, opcoes, { headers: cabecalhos });
}
} catch (e) { /* URL estranha: segue sem token */ }
return original(recurso, opcoes);
};
})();
"use strict";
/* -------------------------------------------------------------------------
Carinha estilo Eilik em SVG — porte fiel do eilik.py (Python/framebuffer).
Mesmas medidas, mesmas cores, mesmos gestos e o mesmo comportamento ocioso.
Roda em qualquer navegador, inclusive num kiosk (cage + Epiphany) na telinha.
------------------------------------------------------------------------- */
// Medidas de projeto (as mesmas do eilik.py) — a tela de verdade e 480x320.
const LW = 480, LH = 320;
const E1P = [150, 166], E2P = [330, 166]; // centros dos olhos
const RXP = 64, RYP = 76; // raio base
const BOCAP = [240, 284], BOCALP = 96; // centro e largura da boca
// A area util nem sempre e 480x320 (o navegador pode reservar uma barra de
// titulo), entao tudo e escalado junto: preenche a ALTURA mantendo a
// proporcao dos olhos, e as posicoes acompanham a largura e a altura.
let W = LW, H = LH, ESC = 1;
let E1 = E1P.slice(), E2 = E2P.slice(), RX = RXP, RY = RYP;
let BOCA = BOCAP.slice(), BOCA_L = BOCALP;
function ajustar() {
W = Math.max(80, window.innerWidth);
H = Math.max(60, window.innerHeight);
ESC = H / LH;
// O rosto fica CENTRADO e o afastamento dos olhos vem da altura (ESC), nao
// da largura: a cara mantem a mesma proporcao em qualquer tela — inclusive
// nas compridas, como a do Galaxy S10 (19:9). A sobra vira margem.
const meio = W / 2;
E1 = [meio - (LW / 2 - E1P[0]) * ESC, H * (E1P[1] / LH)];
E2 = [meio + (E2P[0] - LW / 2) * ESC, H * (E2P[1] / LH)];
RX = RXP * ESC;
RY = RYP * ESC;
BOCA = [W * (BOCAP[0] / LW), H * (BOCAP[1] / LH)];
BOCA_L = BOCALP * ESC;
const svg = document.getElementById("tela");
svg.setAttribute("viewBox", "0 0 " + W + " " + H);
const fundo = svg.querySelector("rect");
fundo.setAttribute("width", W);
fundo.setAttribute("height", H);
}
ajustar();
addEventListener("resize", ajustar);
const PRETO = [6, 8, 14];
const CIANO = [92, 214, 255];
const EXTRAS = { boca: true, sobrancelhas: false };
const CIANO_E = [150, 232, 255];
const VERM = [255, 78, 78];
const ROSA = [255, 112, 176];
const AMARELO = [255, 218, 96];
const AZUL = [130, 170, 255];
const VERDE = [120, 255, 160];
const BRANCO = [245, 250, 255];
// -------------------------------------------------------------------- rostos
// A "biblioteca de rostos": cada um diz se tem boca e o quanto os olhos
// crescem. Trocar de rosto pela rede: curl "http://orangepi:8099/face/miudo"
const ROSTOS = {
vivo: { rotulo: "Carinha Viva", boca: true, escala: 1.00, motor: "vivo" },
bloco: { rotulo: "Bloco", boca: false, escala: 1.00, motor: "bloco" },
matriz: { rotulo: "Matriz", boca: false, escala: 1.00, motor: "matriz" },
lente: { rotulo: "Lente", boca: false, escala: 1.00, motor: "lente" },
eilik: { rotulo: "Eilik", boca: false, escala: 1.00 },
carinha: { rotulo: "Carinha", boca: true, escala: 1.00 },
miudo: { rotulo: "Miudo", boca: true, escala: 0.80 },
};
// Formato do olho nos rostos novos (rosto-motor.js): padrao, redondo, pilula, quadrado, largo.
const FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"];
let FORMATO = "padrao";
let ROSTO_NOME = "carinha";
let ROSTO = ROSTOS[ROSTO_NOME];
// ------------------------------------------------------------------ utilidades
const suave = (x) => x * x * x * (x * (x * 6 - 15) + 10); // smoothstep
const lerp = (a, b, e) => a + (b - a) * e;
const lerpCor = (a, b, e) => [0, 1, 2].map((i) => a[i] + (b[i] - a[i]) * e);
const rgb = (c) => "rgb(" + c.map((v) => Math.round(v)).join(",") + ")";
// ------------------------------------------------------------------ geometria
function caminhoElipse(cx, cy, rx, ry) {
return "M " + (cx - rx) + "," + cy +
" a " + rx + "," + ry + " 0 1 0 " + (2 * rx) + ",0" +
" a " + rx + "," + ry + " 0 1 0 " + (-2 * rx) + ",0 Z";
}
function caminhoArco(cx, cy, rx, ry) { // 196 -> 344 graus ( "^" )
const a0 = 196 * Math.PI / 180, a1 = 344 * Math.PI / 180;
const x0 = cx + rx * Math.cos(a0), y0 = cy + ry * Math.sin(a0);
const x1 = cx + rx * Math.cos(a1), y1 = cy + ry * Math.sin(a1);
return "M " + x0 + "," + y0 + " A " + rx + "," + ry + " 0 0 1 " + x1 + "," + y1;
}
function caminhoCoracao(cx, cy, s) {
return "M " + cx + "," + (cy + s * 0.72) +
" C " + (cx - s * 1.30) + "," + (cy - s * 0.30) + " " +
(cx - s * 0.80) + "," + (cy - s * 1.18) + " " +
cx + "," + (cy - s * 0.30) +
" C " + (cx + s * 0.80) + "," + (cy - s * 1.18) + " " +
(cx + s * 1.30) + "," + (cy - s * 0.30) + " " +
cx + "," + (cy + s * 0.72) + " Z";
}
function caminhoLinha(x0, y0, x1, y1) {
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
}
/* formas da boca (o humor escolhe qual) */
function caminhoBoca(tipo) {
const cx = BOCA[0], cy = BOCA[1], l = BOCA_L, meia = l / 2;
if (tipo === "aberta") { // surpresa: um "O"
const rx = l * 0.17, ry = l * 0.22;
return caminhoElipse(cx, cy, rx, ry);
}
if (tipo === "brava") { // canto para baixo
return "M " + (cx - meia) + "," + (cy + l * 0.16) +
" Q " + cx + "," + (cy - l * 0.26) + " " + (cx + meia) + "," + (cy + l * 0.16);
}
if (tipo === "triste") {
return "M " + (cx - l * 0.40) + "," + (cy + l * 0.16) +
" Q " + cx + "," + (cy - l * 0.20) + " " + (cx + l * 0.40) + "," + (cy + l * 0.16);
}
if (tipo === "sono") { // dormindo: um tracinho torto
return "M " + (cx - l * 0.24) + "," + cy +
" q " + (l * 0.24) + "," + (l * 0.16) + " " + (l * 0.48) + ",0";
}
if (tipo === "sorriso") { // feliz: sorriso grande
return "M " + (cx - meia) + "," + (cy - l * 0.10) +
" Q " + cx + "," + (cy + l * 0.42) + " " + (cx + meia) + "," + (cy - l * 0.10);
}
return "M " + (cx - l * 0.30) + "," + cy + // repouso: leve
" q " + (l * 0.30) + "," + (l * 0.18) + " " + (l * 0.60) + ",0";
}
// ------------------------------------------------------------------ desenho
const OLHOS = [document.getElementById("o1"), document.getElementById("o2")];
const PE = OLHOS.map((g) => ({
corpo: g.querySelector(".corpo"),
traco: g.querySelector(".traco"),
brilho: g.querySelector(".brilho"),
}));
function esconderTudo(p) {
p.corpo.style.display = "none";
p.traco.style.display = "none";
p.brilho.style.display = "none";
}
const elBoca = document.getElementById("boca");
/* A boca so aparece nos rostos que tem boca; a forma segue o humor. */
function desenharBoca(tipo) {
if (!EXTRAS.boca) {
elBoca.style.display = "none";
return;
}
const forma = tipo || r.boca;
elBoca.setAttribute("d", caminhoBoca(forma));
elBoca.setAttribute("stroke-width", BOCA_L * 0.15);
elBoca.setAttribute("stroke", rgb(r.cor));
elBoca.style.color=rgb(r.cor);
elBoca.setAttribute("fill", forma === "aberta" ? rgb(r.cor) : "none");
elBoca.style.display = "";
}
/* Desenha um olho igual ao _olho() do eilik.py. */
function olho(indice, cx, cy, rx, ry, ab, forma, cor, lado, lx, ly) {
const p = PE[indice];
document.getElementById('o'+(indice+1)).style.color=rgb(cor);
esconderTudo(p);
if (!p.icone) {
p.icone = document.createElementNS('http://www.w3.org/2000/svg','text');
p.icone.setAttribute('class','iconeOlho');p.icone.setAttribute('text-anchor','middle');
p.icone.setAttribute('dominant-baseline','central');document.getElementById('o'+(indice+1)).append(p.icone);
}
const desenho = !r.dormindo && window.carinhaOlhosDesenho;
const icone = !r.dormindo && (window.carinhaOlhosIcone || desenho);
p.icone.style.display = icone && !desenho ? '' : 'none';
if (p.figurinha) p.figurinha.style.display=desenho?'':'none';
if(desenho && window.carinhaConstruirSVG){
if(p.desenho!==desenho){if(p.figurinha)p.figurinha.remove();p.figurinha=window.carinhaConstruirSVG(desenho);p.desenho=desenho;if(p.figurinha)document.getElementById('o'+(indice+1)).append(p.figurinha);}
if(p.figurinha){p.figurinha.style.display='';p.figurinha.setAttribute('x',cx-rx*.65);p.figurinha.setAttribute('y',cy-ry*.65);p.figurinha.setAttribute('width',rx*1.3);p.figurinha.setAttribute('height',ry*1.3);}
}
if (icone) {
forma='aberto';p.icone.textContent=window.carinhaOlhosIcone||'';
p.icone.setAttribute('x',cx);p.icone.setAttribute('y',cy);
p.icone.setAttribute('font-size',Math.min(rx*1.1,ry*1.1));
}
if (forma === "aberto" || forma === "surpreso") {
const rr = Math.max(2, ry * ab);
p.corpo.setAttribute("d", caminhoElipse(cx, cy, rx, rr));
p.corpo.setAttribute("fill", rgb(cor));
p.corpo.style.display = "";
if (ab > 0.62 && !icone) {
// o brilho ("pupila") anda dentro do olho: e o que da a sensacao de olhar
let hx = cx - rx * 0.30 + lx * 1.2 * ESC;
let hy = cy - rr * 0.36 + ly * 1.2 * ESC;
hx = Math.max(cx - rx * 0.46, Math.min(cx + rx * 0.46, hx));
hy = Math.max(cy - rr * 0.46, Math.min(cy + rr * 0.46, hy));
p.brilho.setAttribute("cx", hx);
p.brilho.setAttribute("cy", hy);
p.brilho.setAttribute("rx", rx * 0.20);
p.brilho.setAttribute("ry", rr * 0.18);
p.brilho.style.display = "";
}
} else if (forma === "feliz") {
p.traco.setAttribute("d", caminhoArco(cx, cy + ry * 0.22, rx * 1.0, ry * 0.80));
p.traco.setAttribute("stroke-width", rx * 0.34);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "dormir") {
p.traco.setAttribute("d", caminhoLinha(cx - rx * 0.86, cy + ry * 0.14,
cx + rx * 0.86, cy + ry * 0.14));
p.traco.setAttribute("stroke-width", rx * 0.22);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "bravo") {
p.traco.setAttribute("d", caminhoLinha(cx - lado * rx * 0.9, cy - ry * 0.42,
cx + lado * rx * 0.9, cy + ry * 0.26));
p.traco.setAttribute("stroke-width", rx * 0.26);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "triste") {
p.traco.setAttribute("d", caminhoLinha(cx - lado * rx * 0.9, cy + ry * 0.22,
cx + lado * rx * 0.9, cy - ry * 0.46));
p.traco.setAttribute("stroke-width", rx * 0.26);
p.traco.setAttribute("stroke", rgb(cor));
p.traco.style.display = "";
} else if (forma === "coracao") {
p.corpo.setAttribute("d", caminhoCoracao(cx, cy, Math.min(rx, ry) * 0.98));
p.corpo.setAttribute("fill", rgb(cor));
p.corpo.style.display = "";
} else {
p.corpo.setAttribute("d", caminhoElipse(cx, cy, rx, ry));
p.corpo.setAttribute("fill", rgb(cor));
p.corpo.style.display = "";
}
}
// ------------------------------------------------------------------ gestos
function sequencias() {
const piscar = [[{ ab: 0.0 }, 0.06], [{ ab: 0.0 }, 0.04], [{ ab: 1.0 }, 0.11]];
const piscar2 = piscar.concat([[{}, 0.09]], piscar);
const olhar_esq = [[{ dx: -24, dy: 3 }, 0.16], [{}, 0.55], [{ dx: 0, dy: 0 }, 0.2]];
const olhar_dir = [[{ dx: 24, dy: 3 }, 0.16], [{}, 0.55], [{ dx: 0, dy: 0 }, 0.2]];
const olhar_baixo = [[{ dy: 11 }, 0.16], [{}, 0.45], [{ dy: 0 }, 0.2]];
const feliz = [[{ forma: "feliz", esc: 1.05, boca: "sorriso" }, 0.16], [{ esc: 1.16 }, 0.18],
[{ esc: 1.02 }, 0.16], [{ esc: 1.13 }, 0.16], [{ esc: 1.0 }, 0.2]];
const surpreso = [[{ forma: "aberto", esc: 1.3, ab: 1.12, dy: -8, cor: CIANO_E, boca: "aberta" }, 0.09],
[{ dy: -10 }, 0.5],
[{ esc: 1.0, ab: 1.0, dy: 0, cor: CIANO }, 0.25]];
const bravo = [[{ forma: "bravo", cor: VERM, esc: 0.98, boca: "brava" }, 0.1],
[{ dx: -7 }, 0.06], [{ dx: 7 }, 0.06], [{ dx: -7 }, 0.06], [{ dx: 0 }, 0.06],
[{ forma: "aberto", cor: CIANO }, 0.32]];
const coracao = [[{ forma: "coracao", cor: ROSA, boca: "sorriso" }, 0.12], [{ esc: 1.18 }, 0.15],
[{ esc: 1.0 }, 0.15], [{ esc: 1.18 }, 0.15], [{ esc: 1.0 }, 0.15],
[{ forma: "aberto", cor: CIANO }, 0.3]];
const triste = [[{ forma: "triste", cor: AZUL, dy: 5, boca: "triste" }, 0.18], [{ dy: 7 }, 0.5],
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
// Gestos com mãos: o motor desenha as mãos com a mesma duração (rosto-motor.js, DUR_MAOS).
const esconder = [[{ forma: "aberto", esc: 0.95, dy: 4 }, 0.3], [{}, 1.6], [{ forma: "feliz", boca: "sorriso", esc: 1.05, dy: 0 }, 0.3], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const espiar = [[{ esc: 0.96 }, 0.4], [{}, 0.6], [{ dx: -14, forma: "aberto", ab: 1.08 }, 0.25], [{}, 0.9], [{ dx: 0, ab: 1.0 }, 0.35], [{ forma: "feliz", boca: "sorriso" }, 0.4], [{ forma: "aberto" }, 0.3]];
const acenar = [[{ forma: "feliz", boca: "sorriso", esc: 1.05 }, 0.3], [{}, 1.7], [{ forma: "aberto", esc: 1.0 }, 0.4]];
const pensar = [[{ dy: -10, dx: 14, boca: "neutra" }, 0.4], [{}, 2.0], [{ dy: 0, dx: 0 }, 0.4]];
const musica = [[{ forma: "feliz", boca: "sorriso", dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25],
[{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ dy: -5 }, 0.25], [{ dy: 5 }, 0.25], [{ forma: "aberto", dy: 0 }, 0.3]];
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
return { esconder, espiar, acenar, pensar, musica,
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
bravo, coracao, triste, piscadinha, alegre };
}
const r = {
ab: 1.0, ab2: null, esc: 1.0, dx: 0.0, dy: 0.0,
forma: "aberto", forma2: null, cor: CIANO.slice(), dormindo: false,
boca: "neutra",
fila: [], kf: null, kf_ini: null, kf_t: 0, cor_ini: CIANO.slice(),
};
const SEQS = sequencias();
const LIVRES = ["feliz", "olhar_esq", "olhar_dir", "olhar_baixo",
"surpreso", "coracao", "piscadinha", "bravo", "triste", "alegre"];
function proximo() {
if (!r.fila.length) { r.kf = null; return; }
const [alvo, dur] = r.fila[0];
r.kf_ini = { ab: r.ab, ab2: r.ab2, esc: r.esc, dx: r.dx, dy: r.dy };
r.cor_ini = r.cor.slice();
if ("forma" in alvo) r.forma = alvo.forma;
if ("forma2" in alvo) r.forma2 = alvo.forma2;
if ("boca" in alvo) r.boca = alvo.boca;
r.kf = [alvo, dur];
r.kf_t = 0;
}
function animar(nome) {
if (SEQS[nome]) {
r.fila = SEQS[nome].map(([a, d]) => [Object.assign({}, a), d]);
proximo();
tocarSom(nome); // trilha sonora do gesto (se houver)
return true;
}
return false;
}
function passo(dt) { // avanca a fila; true se ha gesto em curso
if (!r.fila.length || !r.kf) return false;
const [alvo, dur] = r.kf;
r.kf_t += dt;
const e = suave(dur <= 0 ? 1 : Math.min(1, r.kf_t / dur));
for (const k of ["ab", "ab2", "esc", "dx", "dy"]) {
if (k in alvo && alvo[k] !== null && r.kf_ini[k] !== null) r[k] = lerp(r.kf_ini[k], alvo[k], e);
else if (k in alvo && alvo[k] === null) r[k] = null;
}
if ("cor" in alvo) r.cor = lerpCor(r.cor_ini, alvo.cor, e);
if (e >= 1) { r.fila.shift(); proximo(); }
return true;
}
function repouso(t) { // pose calma: olha devagar, "respira"
if (r.dormindo) {
r.forma = "dormir"; r.forma2 = "dormir"; r.boca = "sono";
r.cor = lerpCor(AZUL, [40, 60, 120], 0.5 + 0.5 * Math.sin(t * 1.2));
r.ab = 0; r.ab2 = 0;
r.esc = 1.0 + 0.03 * Math.sin(t * 1.4);
r.dx = 0;
r.dy = 3.0 * Math.sin(t * 1.4);
return;
}
r.forma = "aberto"; r.forma2 = null;
r.boca = "neutra";
r.cor = CIANO.slice();
r.ab = 1.0; r.ab2 = null; r.esc = 1.0;
// o vaivem de sempre + a direcao de quem se mexeu na frente (se houver)
r.dx = 8.0 * Math.sin(t * 0.55) + 3.0 * Math.sin(t * 1.7) + MOV.olharX;
r.dy = 3.5 * Math.sin(t * 0.9) + 1.5 * Math.sin(t * 2.3) + MOV.olharY;
}
function desenhar() {
const esc = r.esc * ROSTO.escala, ab = r.ab;
const ab2 = r.ab2 === null ? r.ab : r.ab2;
const f2 = r.forma2 === null ? r.forma : r.forma2;
// durante o repouso o corpo do olho fica parado e so o brilho se move
// mirando: algo se mexeu ha pouco, entao o olho inteiro (nao so o brilho)
// desliza na direcao do movimento; parado, volta ao repouso discreto
const tAgora = performance.now() / 1000;
const seguindo = TRACK.alvo && tAgora - TRACK.visto < .35 && !r.dormindo;
const mirando = MOV.ativo && (tAgora - MOV.ultimoMov) < 1.2;
const mover = seguindo ? .55 : (r.kf !== null || r.dormindo) ? 0.25 : (mirando ? 0.25 : 0.0);
const dx = seguindo ? MOV.olharX : r.dx, dy = seguindo ? MOV.olharY : r.dy;
const olhos = [[E1, ab, r.forma, +1], [E2, ab2, f2, -1]];
const comMotor = ROSTO.motor && window.MotorRosto;
OLHOS.forEach((g) => { g.style.display = comMotor ? "none" : ""; });
if (comMotor) {
window.MotorRosto.desenhar({
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
carga: window.carinhaBateria || null,
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
parado: !r.kf && !seguindo && !r.dormindo,
olhos: olhos.map(([c, abx, fx, lado]) => ({
cx: c[0] + dx * mover * ESC, cy: c[1] + dy * mover * ESC, ry: RY * esc,
ab: abx, forma: fx, lado, lx: dx, ly: dy * 0.6,
})),
});
} else if (window.MotorRosto) window.MotorRosto.esconder();
for (let i = 0; i < 2 && !comMotor; i++) {
const [c, abx, fx, lado] = olhos[i];
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
abx, fx, r.cor, lado, dx, dy * 0.6);
}
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none";
else desenharBoca();
for (let i = 0; i < 2; i++) {
const brow = document.getElementById('sobrancelha' + (i + 1));
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
const c = i === 0 ? E1 : E2;
const lado = i === 0 ? 1 : -1;
const largura = RX * esc * 0.85;
const y = c[1] - RY * esc - 20 * ESC;
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
const elevacao = r.esc > 1.1 ? -15 * ESC : 0;
brow.setAttribute('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
brow.setAttribute('stroke', rgb(r.cor));
brow.setAttribute('stroke-width', 7 * ESC);
}
}
// ------------------------------------------------------------------ controle
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
function trocarRosto(nome) {
const alvo = ROSTOS[(nome || "").trim().toLowerCase()];
if (!alvo) return false;
ROSTO_NOME = (nome || "").trim().toLowerCase();
ROSTO = alvo;
r.fila = []; r.kf = null;
r.boca = "neutra";
desenhar();
relatar(); // conta ao servidor qual rosto ficou
return true;
}
function trata(ordem) {
ordem = (ordem || "").trim().toLowerCase();
if (!ordem) return;
if(ordem==='recarregar') {
if(new URLSearchParams(location.search).get('aparelho')==='1') {
fetch('/app/status',{cache:'no-store'}).then(r=>r.json()).then(s=>{const url=new URL(location.href);url.searchParams.set('atualizacao',s.recarga?.id||Date.now());location.replace(url.href);}).catch(()=>{});
}
return;
}
window.carinhaUltimoComando = Date.now();
if (ordem.indexOf("rosto ") === 0) {
trocarRosto(ordem.slice(6).trim());
} else if (ROSTOS[ordem]) {
trocarRosto(ordem); // aceita so o nome: "miudo"
} else if (ordem.indexOf("formato ") === 0) {
const f = ordem.slice(8).trim();
if (FORMATOS.includes(f)) FORMATO = f;
} else if (ordem.indexOf("cor ") === 0) {
const v = ordem.slice(4).split(",").map(Number);
if (v.length === 3 && v.every((n) => !isNaN(n))) {
CIANO.splice(0, 3, ...v); r.cor = v;
}
} else if (ordem === "dormir" || ordem === "sono") {
r.dormindo = true;
} else if (ordem === "acordar") {
r.dormindo = false; animar("surpreso");
} else if (ordem === "aleatorio") {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
} else if (SEQS[ordem]) {
if (window.MotorRosto) {
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
else window.MotorRosto.maos(ordem);
}
animar(ordem);
} else if (ordem === "calma") {
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
r.fila = []; r.kf = null; r.boca = "neutra"; r.dormindo = false;
} else if (ordem === "parar") {
try { window.close(); } catch (e) { r.fila = []; }
}
}
let congelado = false; // util para conferir poses (ver eilik.pose)
/* Mostra a pose caracteristica de um gesto e congela — serve para tirar
"foto" de cada gesto e conferir o desenho. eilik.soltar() volta ao normal. */
function pose(nome) {
const seq = SEQS[nome];
if (!seq) return false;
const alvo = seq[0][0];
r.fila = []; r.kf = null;
for (const k of ["forma", "forma2", "boca", "esc", "dx", "dy", "ab", "ab2"]) {
if (k in alvo && alvo[k] !== undefined) r[k] = alvo[k];
}
if ("cor" in alvo) r.cor = alvo.cor.slice();
congelado = true;
desenhar();
return true;
}
window.eilik = {
animar, trata, pose, trocarRosto, som, tocarSom, movimento, rastrear, face: r,
gestos: Object.keys(SEQS), rostos: Object.keys(ROSTOS),
rostoAtual: () => ROSTO_NOME,
soltar: () => { congelado = false; r.dormindo = false; },
presenca: () => MOV, // estado do detector de movimento (para conferir)
ouvido: () => MIC, // estado do microfone (para conferir)
ligarCamera, desligarCamera, decidirCameraOculta, // camera oculta (sensor)
cameraTick: detectarCamera, // um passo do detector (conferencia/sem laco)
};
// busca ordens no servidorzinho da placa (/fila) — assim da para mandar gesto
// do PC com um curl, igual ao FIFO de antes
async function buscarOrdens() {
if (new URLSearchParams(location.search).get('espelho') === '1' || window.carinhaSSE) return;
try {
const resp = await fetch("fila"+(par.get("aparelho")==="1"?"?aparelho=1":""), { cache: "no-store" });
if (!resp.ok) return;
const lista = await resp.json();
for (const nome of lista) trata(nome);
} catch (e) { /* sem servidor (file://) — ignora */ }
}
setInterval(buscarOrdens, 500);
// A aparencia guardada no servidor (rosto + cor) e aplicada ao carregar: o
// painel de controle personaliza e a carinha ja abre do jeito escolhido. Sem
// servidor (file://) isso falha calado e vale o padrao.
async function aplicarAparencia() {
try {
const resp = await fetch("config", { cache: "no-store" });
if (!resp.ok) return;
const c = await resp.json();
if (c.rosto && ROSTOS[c.rosto]) trocarRosto(c.rosto);
if (FORMATOS.includes(c.formato)) FORMATO = c.formato;
if (Array.isArray(c.cor) && c.cor.length === 3 && c.cor.every((n) => !isNaN(n)))
{ CIANO.splice(0, 3, ...c.cor.map(Number)); r.cor = c.cor.map(Number); }
} catch (e) { /* sem servidor — segue com o padrao */ }
}
setTimeout(aplicarAparencia, 200);
// ?painel=1: prévia dentro do painel novo. Nada de polling; o painel repassa os eventos
// do servidor (SSE) por postMessage.
const MODO_PAINEL = new URLSearchParams(location.search).get('painel') === '1';
if (MODO_PAINEL) {
window.carinhaAtiva = false;
addEventListener('message', (e) => {
if (e.origin !== location.origin || e.source !== parent || !e.data) return;
const m = e.data;
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
else if (m.tipo === 'config' && m.config) configurarApp(m.config);
else if (m.tipo === 'legenda' && typeof m.texto === 'string' && window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
// toque na tela: cutucar dá susto e depois risada; deslizar o dedo é carinho.
// O toque longo (menu do app) passa direto: só reagimos a toques curtos.
(function () {
let inicio = null;
addEventListener('pointerdown', (e) => { inicio = { x: e.clientX, y: e.clientY, t: performance.now() }; });
addEventListener('pointerup', (e) => {
if (!inicio) return;
const dx = e.clientX - inicio.x, dy = e.clientY - inicio.y, dur = performance.now() - inicio.t;
inicio = null;
if (r.dormindo) { trata('acordar'); return; }
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy) && dur < 900) { animar('coracao'); return; }
if (dur < 350 && Math.hypot(dx, dy) < 15) {
animar('surpreso');
setTimeout(() => animar('feliz'), 700);
}
});
})();
// teclado (util para testar no PC)
const TECLAS = { p: "piscar", f: "feliz", c: "coracao", s: "surpreso", b: "bravo",
t: "triste", a: "alegre", o: "olhar_esq", i: "olhar_dir",
k: "olhar_baixo", w: "piscadinha" };
addEventListener("keydown", (ev) => {
const k = ev.key.toLowerCase();
if (TECLAS[k]) animar(TECLAS[k]);
else if (k === "d") { r.dormindo = !r.dormindo; if (!r.dormindo) animar("surpreso"); }
else if (k === " ") { animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]); }
});
// ------------------------------------------------------------------ laço
const par = new URLSearchParams(location.search);
const FPS = parseFloat(par.get("fps") || "30"); // 0 = sem limite
let AUTO = par.get("auto") !== "0"; // gestos sozinhos
const COMPORTAMENTO = { ouvir: true, reagir: true };
const passoFixo = FPS > 0 ? 1 / FPS : 0;
// ----------------------------------------------------------- ouvido (microfone)
/* A ponte nativa do APK chama window.eilik.som(nivel) ~20x/s, com 0=silencio e
1=bem alto. Quem reage e a propria pagina, entao a telinha do Pi (sem
microfone) continua igual: sem chamadas, nada acontece. Ajustes por URL:
?somlim= ?somaltolim= ?sompausa= ?somcooldown= */
const MIC = {
ativo: false, // virou true na primeira leitura recebida
visto: 0, // t da ultima leitura recebida (s)
nivel: 0,
ultimoSom: 0, // t do ultimo som audivel (s)
estavaAlto: false,
proxSurpresa: 0,
ultimoLog: 0,
piso: null, // piso de ruido do ambiente (segue devagar)
ganho: parseFloat(par.get("somganho") || "0.40"), // quanto acima do piso = susto
limiarSom: parseFloat(par.get("somlim") || "0.35"), // acima: "tem som"
limiarAlto: parseFloat(par.get("somaltolim") || "0.55"), // minimo absoluto p/ susto
segSilencio: parseFloat(par.get("sompausa") || "20"), // s calado -> dormir
cooldown: parseFloat(par.get("somcooldown") || "5"), // s entre sustos
};
function som(nivel) {
const n = Math.max(0, Math.min(1, Number(nivel) || 0));
const agora = performance.now() / 1000;
MIC.ativo = true;
MIC.visto = agora;
MIC.nivel = n;
if (!COMPORTAMENTO.ouvir) return;
// piso de ruido: desce rapido e sobe devagar, acompanhando o ambiente. Assim
// o "susto" e sempre relativo ao barulho de fundo e nao dispara sem parar
// num lugar barulhento. Ajuste por URL: ?somganho=
if (MIC.piso === null) MIC.piso = n;
else MIC.piso += (n - MIC.piso) * (n < MIC.piso ? 0.05 : 0.0015);
const temSom = n >= MIC.limiarSom; // ruido audivel (absoluto)
const alto = n >= MIC.limiarAlto && (n - MIC.piso) >= MIC.ganho;
if (temSom) MIC.ultimoSom = agora;
// registro periodico (aparece no logcat do app: prova que o ouvido chegou)
if (agora - MIC.ultimoLog >= 2) {
MIC.ultimoLog = agora;
console.log("ouvido nivel=" + n.toFixed(2) + " piso=" + (MIC.piso || 0).toFixed(2));
}
if (r.dormindo && temSom) { // acordou: tinha barulho
r.dormindo = false;
animar("surpreso");
MIC.proxSurpresa = agora + MIC.cooldown;
console.log("ouvido: acordou nivel=" + n.toFixed(2));
relatarSom("acordou", n);
} else if (alto && !MIC.estavaAlto && agora >= MIC.proxSurpresa) {
MIC.proxSurpresa = agora + MIC.cooldown; // susto: barulho alto e repentino
animar("surpreso");
console.log("ouvido: surpreso nivel=" + n.toFixed(2));
relatarSom("surpreso", n);
}
MIC.estavaAlto = alto;
}
/* Sem barulho por tempo demais, adormece (so quando o microfone esta ativo). */
function checarSilencio(agora) {
if (!COMPORTAMENTO.ouvir) return;
if (!MIC.ativo) return; // telinha do Pi: sem ouvido
if (agora - MIC.visto > 2) return; // o ouvido parou de mandar dado
if (r.dormindo) return;
// movimento tambem conta como "tem alguem aqui": nao adormece com gente passando
const atividade = Math.max(MIC.ultimoSom, MOV.ativo ? MOV.ultimoMov : 0);
if (agora - atividade >= MIC.segSilencio) {
r.dormindo = true;
console.log("ouvido: adormeceu (" + MIC.segSilencio + "s sem som nem movimento)");
relatarSom("adormeceu", 0);
}
}
/* Conta ao servidor o que o ouvido percebeu — da para conferir de longe no
journal do carinha-web (so quando o microfone esta ativo). */
function relatarSom(evento, n) {
try { fetch("diag/mic=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
}
// ------------------------------------------------------ presenca (movimento)
/* A ponte nativa do APK abre a camera FRONTAL, mede o movimento (diferenca de
luminancia entre quadros) e chama window.eilik.movimento(vel, cx, cy) umas
12x/s: vel vai de 0 (parado) a 1 (muito movimento) e cx,cy e o centro do que
se mexeu, de 0 a 1 na imagem. A carinha acorda quando algo se mexe na frente
dela e, acordada, os olhos acompanham o movimento. Sem a ponte nada acontece
(a telinha do Pi continua igual, sem camera). Ajustes por URL:
?movlim= ?movforte= ?movcooldown= ?movolhar= */
const MOV = {
ativo: false, // virou true na primeira leitura recebida
vel: 0, // ultimo movimento (0 a 1)
cx: 0.5, cy: 0.5, // ultimo centro do movimento (0 a 1)
visto: 0, // t da ultima leitura (s)
ultimoMov: 0, // t do ultimo movimento percebido (s)
proxSurpresa: 0, // t a partir do qual um novo susto pode disparar
estavaForte: false, // ja vinha em movimento forte (so o INICIO assusta)
ultimoLog: 0,
olharX: 0, olharY: 0, // deslocamento dos olhos na direcao do movimento
limiar: parseFloat(par.get("movlim") || "0.05"), // acima: houve movimento
limiarForte: parseFloat(par.get("movforte") || "0.12"), // acima: susto (medido: mao ~0,1)
cooldown: parseFloat(par.get("movcooldown") || "6"), // s entre sustos
olharMax: parseFloat(par.get("movolhar") || "40"), // quanto os olhos seguem
};
const TRACK = {alvo:null,visto:-Infinity};
function rastrear(t) {
if (!t) { TRACK.alvo=null; return; }
if (!Number.isFinite(t.x) || !Number.isFinite(t.y) || t.x<0 || t.x>1 || t.y<0 || t.y>1 || !['mao','rosto'].includes(t.tipo)) return;
if (!COMPORTAMENTO.reagir || configRecebida?.seguirRastreamento===false || configRecebida?.sensorCamera===false ||
(t.tipo==='mao' && configRecebida?.rastrearMaos===false) || (t.tipo==='rosto' && configRecebida?.rastrearRosto===false) ||
(r.dormindo && !configRecebida?.cameraSono)) return;
const agora=performance.now()/1000;
TRACK.alvo=t; TRACK.visto=agora; MOV.ativo=true;MOV.visto=agora;MOV.cx=t.x;MOV.cy=t.y;
MOV.olharX+=((t.x-.5)*2*MOV.olharMax-MOV.olharX)*.65;
MOV.olharY+=((t.y-.5)*2*MOV.olharMax*.6-MOV.olharY)*.65;
if(t.atividade===true) {
MOV.ultimoMov=agora;
if(r.dormindo) { r.dormindo=false;animar('surpreso'); }
}
}
function movimento(vel, cx, cy) {
const v = Math.max(0, Math.min(1, Number(vel) || 0));
const agora = performance.now() / 1000;
MOV.ativo = true;
MOV.vel = v;
MOV.visto = agora;
if (typeof cx === "number" && !isNaN(cx)) MOV.cx = Math.max(0, Math.min(1, cx));
if (typeof cy === "number" && !isNaN(cy)) MOV.cy = Math.max(0, Math.min(1, cy));
if (!COMPORTAMENTO.reagir) { MOV.olharX = MOV.olharY = 0; return; }
if (TRACK.alvo && agora-TRACK.visto<.35) return;
const mexeu = v >= MOV.limiar;
if (mexeu) MOV.ultimoMov = agora;
// olhar: desliza na direcao de quem se mexeu; sem movimento, volta ao centro
const alvoX = mexeu ? (MOV.cx - 0.5) * 2 * MOV.olharMax : agora-MOV.ultimoMov<.2 ? MOV.olharX : 0;
const alvoY = mexeu ? (MOV.cy - 0.5) * 2 * MOV.olharMax * 0.6 : agora-MOV.ultimoMov<.2 ? MOV.olharY : 0;
MOV.olharX += (alvoX - MOV.olharX) * 0.65;
MOV.olharY += (alvoY - MOV.olharY) * 0.65;
if (agora - MOV.ultimoLog >= 2) {
MOV.ultimoLog = agora;
console.log("presenca nivel=" + v.toFixed(2) + " centro=" +
MOV.cx.toFixed(2) + "," + MOV.cy.toFixed(2));
}
const forte = v >= MOV.limiarForte;
if (r.dormindo && mexeu) { // acordou: algo se mexeu na frente
r.dormindo = false;
animar("surpreso");
MOV.proxSurpresa = agora + MOV.cooldown;
console.log("presenca: acordou nivel=" + v.toFixed(2));
relatarMov("acordou", v);
} else if (forte && !MOV.estavaForte && agora >= MOV.proxSurpresa) {
// susto so no INICIO do movimento forte: se continuar se mexendo, nao repete
MOV.proxSurpresa = agora + MOV.cooldown;
animar("surpreso");
console.log("presenca: surpreso nivel=" + v.toFixed(2));
relatarMov("surpreso", v);
}
MOV.estavaForte = forte;
}
/* Conta ao servidor o que a presenca percebeu (aparece no journal do carinha-web). */
function relatarMov(evento, n) {
try { fetch("diag/mov=" + evento + " nivel=" + n.toFixed(2)).catch(() => {}); } catch (e) {}
}
// ------------------------------------------------- camera oculta (sensor)
/* A propria pagina abre a camera para SENTIR o movimento sem mostrar a imagem:
o video fica escondido e a tela continua so com os olhos do robo. Serve para
o kiosk do Pi com uma webcam (a pagina e servida por 127.0.0.1, que conta
como contexto seguro). No celular quem faz isso e a ponte nativa, que tambem
nao mostra imagem — entao, se ela ja estiver mandando movimento, nao abrimos
outra camera (dois acessos juntos costumam falhar).
Para ver a imagem em um cantinho (so para conferir): ?campreview=1
Para desligar de vez: ?camera=0 */
const CAM = {
querer: par.get("camera") !== "0"
&& typeof window.isSecureContext !== "undefined" && window.isSecureContext,
ativo: false,
erro: null,
fluxo: null,
video: null,
cv: null,
ctx: null,
lumaAnt: null,
quadros: 0, // aquecimento: a exposicao demora a assentar
CW: 160, CH: 120,
limite: 26, // salto de luminancia (igual a camera.html)
mostrar: par.get("campreview") === "1",
atraso: parseInt(par.get("camatraso") || "2000", 10), // espera antes de abrir
};
async function ligarCamera() {
if (CAM.ativo) return true;
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
CAM.erro = "sem getUserMedia (contexto inseguro)";
return false;
}
try {
const fluxo = await navigator.mediaDevices.getUserMedia({
video: { facingMode: { ideal: "user" }, width: { ideal: 640 }, height: { ideal: 480 } },
audio: false,
});
CAM.fluxo = fluxo;
const v = document.createElement("video");
v.autoplay = true; v.muted = true; v.setAttribute("playsinline", "");
// escondido de verdade (fora da tela); so aparece com ?campreview=1
v.style.cssText = CAM.mostrar
? "position:fixed;right:8px;bottom:8px;width:160px;z-index:9;border:1px solid #24405a;border-radius:6px"
: "position:fixed;left:-9999px;top:0;width:2px;height:2px;opacity:0;pointer-events:none";
v.srcObject = fluxo;
document.body.appendChild(v);
try { await v.play(); } catch (e) { /* segue */ }
CAM.video = v;
CAM.cv = document.createElement("canvas");
CAM.cv.width = CAM.CW; CAM.cv.height = CAM.CH;
CAM.ctx = CAM.cv.getContext("2d", { willReadFrequently: true });
CAM.quadros = 0;
CAM.ativo = true;
const f = fluxo.getVideoTracks()[0];
console.log("camera: sensor oculto ligado (" + ((f && f.label) || "camera") + ")");
requestAnimationFrame(lacoCamera);
return true;
} catch (e) {
CAM.erro = e.name;
console.log("camera: sensor oculto falhou — " + e.name);
return false;
}
}
function desligarCamera() {
CAM.ativo = false;
if (CAM.fluxo) { try { CAM.fluxo.getTracks().forEach((t) => t.stop()); } catch (e) {} }
if (CAM.video && CAM.video.parentNode) CAM.video.parentNode.removeChild(CAM.video);
CAM.fluxo = null; CAM.video = null; CAM.lumaAnt = null;
}
function detectarCamera() {
const v = CAM.video;
if (!v || !v.videoWidth) return;
CAM.ctx.drawImage(v, 0, 0, CAM.CW, CAM.CH);
const d = CAM.ctx.getImageData(0, 0, CAM.CW, CAM.CH).data;
if (!CAM.lumaAnt) CAM.lumaAnt = new Float32Array(CAM.CW * CAM.CH);
let mexidos = 0, sx = 0, sy = 0;
for (let i = 0; i < CAM.CW * CAM.CH; i++) {
const l = d[i * 4] * 0.299 + d[i * 4 + 1] * 0.587 + d[i * 4 + 2] * 0.114;
if (Math.abs(l - CAM.lumaAnt[i]) > CAM.limite) { mexidos++; sx += i % CAM.CW; sy += (i / CAM.CW) | 0; }
CAM.lumaAnt[i] = l;
}
CAM.quadros++;
if (CAM.quadros <= 6) return; // ignora o pico de entrada
const vel = Math.min(1, mexidos / (CAM.CW * CAM.CH * 0.25));
const cx = mexidos > 0 ? (sx / mexidos) / CAM.CW : 0.5;
const cy = mexidos > 0 ? (sy / mexidos) / CAM.CH : 0.5;
movimento(vel, cx, cy);
}
function lacoCamera() {
if (!CAM.ativo) return;
try { detectarCamera(); } catch (e) { CAM.erro = e.name; }
requestAnimationFrame(lacoCamera);
}
/* Decisao de abrir (ou nao) a camera escondida. Fica numa funcao a parte para
dar para conferir sem depender do relogio. */
function decidirCameraOculta() {
if (!CAM.querer) { if (!CAM.erro) CAM.erro = "desligada"; return; }
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
CAM.erro = "sem getUserMedia (contexto inseguro)";
console.log("camera: sem sensor na pagina — " + CAM.erro);
return;
}
// se a ponte nativa ja esta mandando movimento, deixa a camera com ela
// (dois acessos a camera ao mesmo tempo costumam falhar)
if (MOV.ativo) { CAM.erro = "ponte nativa no comando"; return; }
ligarCamera();
}
setTimeout(decidirCameraOculta, CAM.atraso);
// -------------------------------------------------------------- voz (saida)
/* Sons curtos sintetizados com Web Audio — sem arquivos, sem permissao e sem
rede. Cada gesto tem o seu (piscar e olhar ficam mudos de proposito). Para
silenciar: ?audio=0 ; volume: ?volume=0.5 */
const AUDIO = {
ligado: par.get("audio") !== "0",
volume: parseFloat(par.get("volume") || "0.35"),
ctx: null,
};
if (window.eilik) window.eilik.audio = AUDIO; // so depois do const: sem TDZ
if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
let configRecebida = null;
function configurarApp(c) {
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true;
AUTO = c.auto;
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
MIC.limiarSom = c.somlim; MIC.ganho = c.somganho;
MIC.limiarAlto = c.somaltolim; MIC.segSilencio = c.sompausa; MIC.cooldown = c.somcooldown;
MOV.limiar = c.movlim; MOV.limiarForte = c.movforte;
MOV.cooldown = c.movcooldown; MOV.olharMax = c.movolhar;
if (!c.reagir) { MOV.olharX = MOV.olharY = 0; MOV.ultimoMov = 0; }
configRecebida = c;
if(!c.seguirRastreamento || !c.sensorCamera || !c.reagir) TRACK.alvo=null;
}
let sincronizando = false;
async function sincronizarApp() {
if (sincronizando || location.protocol === 'file:' || window.carinhaAtiva === false) return;
sincronizando = true;
try {
if (!window.carinhaSSE) {
const resp = await fetch('/app/config', { cache: 'no-store', signal: AbortSignal.timeout(2500) });
if (resp.ok) configurarApp(await resp.json());
}
if (par.get('aparelho') === '1') {
const agora = performance.now() / 1000;
await fetch('/app/status', { method: 'POST', headers: {'Content-Type': 'application/json'},
signal: AbortSignal.timeout(2500), body: JSON.stringify({
tela_atualizada: par.get('atualizacao'), rosto: ROSTO_NOME, cor: r.cor, dormindo: r.dormindo,
microfone: MIC.nivel, microfone_ativo: window.carinhaSensores ? window.carinhaSensores.microfone : MIC.ativo && agora - MIC.visto < 2,
movimento: MOV.vel, camera_ativa: window.carinhaSensores ? window.carinhaSensores.camera : MOV.ativo && agora - MOV.visto < 2,
centro: [MOV.cx, MOV.cy], config: configRecebida,
brilho: window.carinhaBrilho,
bateria: window.carinhaBateria,
aparelho: window.carinhaAparelho,
video: window.carinhaVideo,
}) });
}
} catch (e) { /* servidor ausente: animação continua */ }
finally { sincronizando = false; }
}
setTimeout(sincronizarApp, 300);
setInterval(sincronizarApp, 1000);
function contextoAudio() {
if (AUDIO.ctx) return AUDIO.ctx;
const AC = window.AudioContext || window.webkitAudioContext;
if (!AC) return null;
try { AUDIO.ctx = new AC(); } catch (e) { return null; }
return AUDIO.ctx;
}
/* Uma nota simples: freq -> freqFim (se houver), com subida e queda suaves. */
function nota(c, freq, t0, dur, tipo, vol, freqFim) {
const osc = c.createOscillator();
const ganho = c.createGain();
osc.type = tipo || "sine";
osc.frequency.setValueAtTime(freq, t0);
if (freqFim) osc.frequency.exponentialRampToValueAtTime(freqFim, t0 + dur);
ganho.gain.setValueAtTime(0.0001, t0);
ganho.gain.exponentialRampToValueAtTime(vol, t0 + 0.012);
ganho.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
osc.connect(ganho);
ganho.connect(c.destination);
osc.start(t0);
osc.stop(t0 + dur + 0.03);
}
const SONS = {
feliz: (c, t, v) => { nota(c, 660, t, 0.12, "sine", v); nota(c, 990, t + 0.10, 0.16, "sine", v); },
alegre: (c, t, v) => [523, 659, 784, 1047].forEach((f, i) => nota(c, f, t + i * 0.07, 0.12, "triangle", v)),
coracao: (c, t, v) => { nota(c, 784, t, 0.5, "sine", v * 0.9); nota(c, 988, t + 0.04, 0.5, "sine", v * 0.7); },
piscadinha: (c, t, v) => { nota(c, 880, t, 0.09, "square", v * 0.6); nota(c, 1175, t + 0.09, 0.12, "square", v * 0.5); },
surpreso: (c, t, v) => nota(c, 300, t, 0.28, "sawtooth", v * 0.6, 1400), // "uou!"
bravo: (c, t, v) => nota(c, 220, t, 0.35, "sawtooth", v * 0.7, 90), // resmungo
triste: (c, t, v) => { nota(c, 500, t, 0.22, "sine", v * 0.8); nota(c, 330, t + 0.20, 0.34, "sine", v * 0.8); },
dormir: (c, t, v) => { nota(c, 440, t, 0.25, "sine", v * 0.6); nota(c, 330, t + 0.22, 0.5, "sine", v * 0.5); },
};
function tocarSom(gesto) {
if (!AUDIO.ligado) return false;
const f = SONS[gesto];
if (!f) return false;
const c = contextoAudio();
if (!c) return false;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try {
f(c, c.currentTime + 0.02, AUDIO.volume);
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
console.log("voz: " + gesto + " ctx=" + c.state);
return true;
} catch (e) { return false; }
}
let anterior = performance.now();
let descanso = 0;
let agora = performance.now();
let proxPiscar = agora + 1500 + Math.random() * 2500;
let proxGesto = agora + 6000 + Math.random() * 6000;
const elFps = document.getElementById("fps");
if (par.get("fpsmostrar") === "1") elFps.style.display = "block";
let contador = 0, marco = performance.now();
function laco(t) {
const dt = Math.min(0.25, (t - anterior) / 1000);
anterior = t;
// se a janela mudou de tamanho (ou so ficou pronta depois do primeiro
// quadro), refaz a geometria — assim a carinha nunca fica enquadrada errado
if (window.innerWidth !== W || window.innerHeight !== H) ajustar();
checarSilencio(t / 1000);
const ocupado = congelado ? true : passo(dt);
if (!ocupado) {
descanso += dt;
repouso(descanso);
if (r.dormindo) {
proxPiscar = proxGesto = t + 3600000;
} else if (t >= proxPiscar) {
animar("piscar");
proxPiscar = t + 1600 + Math.random() * 3500;
} else if (AUTO && t >= proxGesto) {
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
proxGesto = t + 7000 + Math.random() * 8000;
}
} else if (t >= proxGesto) {
proxGesto = t + 7000 + Math.random() * 8000;
}
desenhar();
contador++;
if (t - marco >= 1000) {
if (elFps.style.display !== "none") elFps.textContent = contador + " fps";
contador = 0; marco = t;
}
if (passoFixo > 0) {
const folga = passoFixo * 1000 - (performance.now() - t);
if (folga > 1) { setTimeout(() => requestAnimationFrame(laco), folga); return; }
}
requestAnimationFrame(laco);
}
requestAnimationFrame(laco);
// gesto inicial por URL: carinha.html?gesto=feliz
if (par.get("gesto")) setTimeout(() => trata(par.get("gesto")), 300);
// Relatorio de diagnostico: conta ao servidor o tamanho da janela e qual rosto
// esta em uso. Aparece no journal do servico carinha-web. Tambem e chamado
// quando alguem troca de rosto, para dar para conferir de longe.
function relatar() {
try {
const g = document.getElementById("tela").getBoundingClientRect();
const info = "rosto=" + ROSTO_NOME +
" janela=" + innerWidth + "x" + innerHeight +
" tela=" + screen.width + "x" + screen.height +
" svg=" + Math.round(g.width) + "x" + Math.round(g.height);
fetch("diag/" + encodeURIComponent(info)).catch(() => {});
} catch (e) {}
}
setTimeout(relatar, 1500);
/* Respostas escritas ficam na aba Conversar. No aparelho, só expressões. */
(function(){
if(location.protocol==='file:')return;
let seen=null,fetching=false;
async function poll(){
if(fetching||window.carinhaAtiva===false||window.carinhaSSE)return;fetching=true;
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
if(window.MotorRosto)window.MotorRosto.faixa('legenda',{texto:m.texto});
}catch(e){}finally{fetching=false;}
}
setInterval(poll,1000);poll();
})();
</script>
<script src="/reacoes-render.js"></script>
<script>
if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchParams(location.search).has('atualizacao')) {
const aviso=document.createElement('div');aviso.id='avisoRecarga';aviso.textContent='Tela atualizada';aviso.style.cssText='position:fixed;bottom:8%;left:0;right:0;text-align:center;color:#fff;font:24px sans-serif;z-index:100;pointer-events:none';document.body.append(aviso);setTimeout(()=>aviso.remove(),4000);
}
/* Tempo real: um EventSource em /eventos substitui os pollings de /fila, /app/config,
/conversa/status e /treinamento/expressao. Se cair, os pollings voltam sozinhos
(window.carinhaSSE=false) até o EventSource reconectar. */
(function () {
const p = new URLSearchParams(location.search);
if (location.protocol === 'file:' || p.get('espelho') === '1' || !window.EventSource) return;
const aparelho = p.get('aparelho') === '1';
const t = window.carinhaToken;
const fonte = new EventSource('/eventos' + (t ? '?t=' + encodeURIComponent(t) : ''));
let ultimaConversa = null, ultimaReacao = null, primeira = true;
const ouvir = (tipo, fn) => fonte.addEventListener(tipo, (e) => { try { fn(JSON.parse(e.data)); } catch (err) {} });
fonte.addEventListener('pronto', () => {
// Ordens que chegaram antes da conexão ficam na fila: executa uma vez e passa ao SSE.
if (primeira) { primeira = false; buscarOrdens(); }
window.carinhaSSE = true;
// Foto dos ajustes a cada (re)conexão; depois só mudanças chegam por 'app_config'.
fetch('/app/config', { cache: 'no-store' }).then((r) => r.ok && r.json()).then((c) => c && configurarApp(c)).catch(() => {});
});
fonte.onerror = () => {
if (!window.carinhaSSE) return;
window.carinhaSSE = false;
// Volta ao polling sem repetir o que o SSE já entregou.
fetch('fila' + (aparelho ? '?aparelho=1' : ''), { cache: 'no-store' }).catch(() => {});
};
ouvir('expressao', (d) => trata(d.ordem));
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
ouvir('app_config', (c) => configurarApp(c));
ouvir('conversa', (s) => {
if (window.MotorRosto) {
if (s.ocupado) window.MotorRosto.faixa('digitando');
else window.MotorRosto.faixa(null, { so: 'digitando' });
}
const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id;
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});
ouvir('reacao', (d) => {
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
ultimaReacao = d.id;
if (window.eilik && window.eilik.receita) window.eilik.receita(d.receita);
});
})();
</script>
</body>
</html>