Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2dab45604c | ||
|
|
982bf56cd0 | ||
|
|
1029b5fa99 | ||
|
|
a67c5ea9af | ||
|
|
e0dd03411d | ||
|
|
74ae2863a7 | ||
|
|
f7c4793dc2 | ||
|
|
319e63cc42 | ||
|
|
f550930f56 | ||
|
|
230ca991a2 | ||
|
|
628db8492f |
@@ -1,8 +1,8 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
package="br.carinha.rosto"
|
package="br.carinha.rosto"
|
||||||
android:versionCode="9"
|
android:versionCode="10"
|
||||||
android:versionName="0.8.1">
|
android:versionName="0.8.2">
|
||||||
|
|
||||||
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
<uses-sdk android:minSdkVersion="21" android:targetSdkVersion="34" />
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<div id="fps"></div>
|
<div id="fps"></div>
|
||||||
<script src="rosto-motor.js"></script>
|
<script src="rosto-motor.js"></script>
|
||||||
|
<script src="voz-robo.js"></script>
|
||||||
<script>
|
<script>
|
||||||
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
|
/* 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. */
|
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
|
||||||
@@ -91,7 +92,7 @@ addEventListener("resize", ajustar);
|
|||||||
|
|
||||||
const PRETO = [6, 8, 14];
|
const PRETO = [6, 8, 14];
|
||||||
const CIANO = [92, 214, 255];
|
const CIANO = [92, 214, 255];
|
||||||
const EXTRAS = { boca: true, sobrancelhas: false };
|
const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
|
||||||
const CIANO_E = [150, 232, 255];
|
const CIANO_E = [150, 232, 255];
|
||||||
const VERM = [255, 78, 78];
|
const VERM = [255, 78, 78];
|
||||||
const ROSA = [255, 112, 176];
|
const ROSA = [255, 112, 176];
|
||||||
@@ -149,8 +150,18 @@ function caminhoLinha(x0, y0, x1, y1) {
|
|||||||
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
|
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
|
||||||
}
|
}
|
||||||
/* formas da boca (o humor escolhe qual) */
|
/* formas da boca (o humor escolhe qual) */
|
||||||
|
function deslocamentoBoca() {
|
||||||
|
const auto = ROSTO && ROSTO.motor ? 22 : 0; // rostos novos têm olhos mais baixos
|
||||||
|
return -(auto + (EXTRAS.subirBoca || 0)) * ESC;
|
||||||
|
}
|
||||||
|
/* Topo do olho atual (para a sobrancelha ficar perto dele, em qualquer estilo/formato). */
|
||||||
|
function metadeOlho(esc) {
|
||||||
|
const m = ROSTO && ROSTO.motor && window.MotorRosto && window.MotorRosto.metadeAltura
|
||||||
|
? window.MotorRosto.metadeAltura(ROSTO.motor, FORMATO) : 1;
|
||||||
|
return m * RY * esc;
|
||||||
|
}
|
||||||
function caminhoBoca(tipo) {
|
function caminhoBoca(tipo) {
|
||||||
const cx = BOCA[0], cy = BOCA[1], l = BOCA_L, meia = l / 2;
|
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca(), l = BOCA_L, meia = l / 2;
|
||||||
if (tipo === "aberta") { // surpresa: um "O"
|
if (tipo === "aberta") { // surpresa: um "O"
|
||||||
const rx = l * 0.17, ry = l * 0.22;
|
const rx = l * 0.17, ry = l * 0.22;
|
||||||
return caminhoElipse(cx, cy, rx, ry);
|
return caminhoElipse(cx, cy, rx, ry);
|
||||||
@@ -304,9 +315,17 @@ function sequencias() {
|
|||||||
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
|
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
|
||||||
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
||||||
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
|
[{ 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],
|
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
|
||||||
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
|
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
|
||||||
return { piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
|
return { esconder, espiar, acenar, pensar, musica,
|
||||||
|
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
|
||||||
bravo, coracao, triste, piscadinha, alegre };
|
bravo, coracao, triste, piscadinha, alegre };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,6 +413,9 @@ function desenhar() {
|
|||||||
window.MotorRosto.desenhar({
|
window.MotorRosto.desenhar({
|
||||||
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
|
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
|
||||||
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
|
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
|
||||||
|
carga: window.carinhaBateria || null,
|
||||||
|
iris: EXTRAS.iris,
|
||||||
|
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : null,
|
||||||
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
|
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
|
||||||
parado: !r.kf && !seguindo && !r.dormindo,
|
parado: !r.kf && !seguindo && !r.dormindo,
|
||||||
olhos: olhos.map(([c, abx, fx, lado]) => ({
|
olhos: olhos.map(([c, abx, fx, lado]) => ({
|
||||||
@@ -407,16 +429,17 @@ function desenhar() {
|
|||||||
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
|
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);
|
abx, fx, r.cor, lado, dx, dy * 0.6);
|
||||||
}
|
}
|
||||||
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none";
|
const matriz = comMotor && ROSTO.motor === "matriz";
|
||||||
|
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
|
||||||
else desenharBoca();
|
else desenharBoca();
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
const brow = document.getElementById('sobrancelha' + (i + 1));
|
const brow = document.getElementById('sobrancelha' + (i + 1));
|
||||||
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
|
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
|
||||||
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
|
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
|
||||||
const c = i === 0 ? E1 : E2;
|
const c = i === 0 ? E1 : E2;
|
||||||
const lado = i === 0 ? 1 : -1;
|
const lado = i === 0 ? 1 : -1;
|
||||||
const largura = RX * esc * 0.85;
|
const largura = RX * esc * 0.85;
|
||||||
const y = c[1] - RY * esc - 20 * ESC;
|
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
|
||||||
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
|
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
|
||||||
const elevacao = r.esc > 1.1 ? -15 * 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('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
|
||||||
@@ -425,6 +448,27 @@ function desenhar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
|
||||||
|
function tracosDoRosto(esc) {
|
||||||
|
const tracos = [];
|
||||||
|
if (EXTRAS.boca) {
|
||||||
|
const forma = r.boca;
|
||||||
|
if (r.dormindo) { // dormindo na Matriz: "– _ –"
|
||||||
|
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca() + BOCA_L * 0.08, l = BOCA_L * 0.32;
|
||||||
|
tracos.push({ d: 'M ' + (cx - l) + ',' + cy + ' L ' + (cx + l) + ',' + cy, largura: BOCA_L * 0.16 });
|
||||||
|
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
|
||||||
|
}
|
||||||
|
if (EXTRAS.sobrancelhas && !r.dormindo) {
|
||||||
|
[E1, E2].forEach((c, i) => {
|
||||||
|
const lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
|
||||||
|
const inc = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
|
||||||
|
const ele = r.esc > 1.1 ? -15 * ESC : 0;
|
||||||
|
tracos.push({ d: 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele), largura: 9 * ESC });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tracos;
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ controle
|
// ------------------------------------------------------------------ controle
|
||||||
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
|
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
|
||||||
function trocarRosto(nome) {
|
function trocarRosto(nome) {
|
||||||
@@ -468,6 +512,10 @@ function trata(ordem) {
|
|||||||
} else if (ordem === "aleatorio") {
|
} else if (ordem === "aleatorio") {
|
||||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||||
} else if (SEQS[ordem]) {
|
} else if (SEQS[ordem]) {
|
||||||
|
if (window.MotorRosto) {
|
||||||
|
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
|
||||||
|
else window.MotorRosto.maos(ordem);
|
||||||
|
}
|
||||||
animar(ordem);
|
animar(ordem);
|
||||||
} else if (ordem === "calma") {
|
} else if (ordem === "calma") {
|
||||||
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
||||||
@@ -543,6 +591,7 @@ if (MODO_PAINEL) {
|
|||||||
const m = e.data;
|
const m = e.data;
|
||||||
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
|
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 === '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);
|
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
|
||||||
|
|
||||||
@@ -879,7 +928,9 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
|
|||||||
|
|
||||||
let configRecebida = null;
|
let configRecebida = null;
|
||||||
function configurarApp(c) {
|
function configurarApp(c) {
|
||||||
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true;
|
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
|
||||||
|
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
|
||||||
|
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
|
||||||
AUTO = c.auto;
|
AUTO = c.auto;
|
||||||
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
||||||
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
|
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
|
||||||
@@ -955,15 +1006,31 @@ const SONS = {
|
|||||||
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); },
|
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); },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
|
||||||
|
os sons dos gestos ficam quietos para não embolar. */
|
||||||
|
function falar(texto) {
|
||||||
|
if (!AUDIO.ligado || !window.VozRobo) return;
|
||||||
|
const c = contextoAudio();
|
||||||
|
if (!c) return;
|
||||||
|
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||||
|
try {
|
||||||
|
const dur = window.VozRobo.balbuciar(c, texto, AUDIO.volume * 0.85);
|
||||||
|
AUDIO.mudoAte = performance.now() + dur * 1000;
|
||||||
|
} catch (e) { /* segue sem voz */ }
|
||||||
|
}
|
||||||
|
|
||||||
function tocarSom(gesto) {
|
function tocarSom(gesto) {
|
||||||
if (!AUDIO.ligado) return false;
|
if (!AUDIO.ligado) return false;
|
||||||
|
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
|
||||||
|
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
|
||||||
const f = SONS[gesto];
|
const f = SONS[gesto];
|
||||||
if (!f) return false;
|
if (!voz && !f) return false;
|
||||||
const c = contextoAudio();
|
const c = contextoAudio();
|
||||||
if (!c) return false;
|
if (!c) return false;
|
||||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||||
try {
|
try {
|
||||||
f(c, c.currentTime + 0.02, AUDIO.volume);
|
if (voz) window.VozRobo.tocar(c, gesto, AUDIO.volume); // voz de robô sintetizada
|
||||||
|
else f(c, c.currentTime + 0.02, AUDIO.volume); // bipes antigos (reserva)
|
||||||
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
|
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
|
||||||
console.log("voz: " + gesto + " ctx=" + c.state);
|
console.log("voz: " + gesto + " ctx=" + c.state);
|
||||||
return true;
|
return true;
|
||||||
@@ -1050,7 +1117,9 @@ setTimeout(relatar, 1500);
|
|||||||
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
|
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;}
|
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;
|
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
|
||||||
|
falar(m.texto);
|
||||||
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
|
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;}
|
}catch(e){}finally{fetching=false;}
|
||||||
}
|
}
|
||||||
setInterval(poll,1000);poll();
|
setInterval(poll,1000);poll();
|
||||||
@@ -1091,10 +1160,16 @@ if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchPa
|
|||||||
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
|
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
|
||||||
ouvir('app_config', (c) => configurarApp(c));
|
ouvir('app_config', (c) => configurarApp(c));
|
||||||
ouvir('conversa', (s) => {
|
ouvir('conversa', (s) => {
|
||||||
|
if (window.MotorRosto) {
|
||||||
|
if (s.ocupado) window.MotorRosto.faixa('digitando');
|
||||||
|
else window.MotorRosto.faixa(null, { so: 'digitando' });
|
||||||
|
}
|
||||||
const m = s.ultima;
|
const m = s.ultima;
|
||||||
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
|
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
|
||||||
ultimaConversa = m.id;
|
ultimaConversa = m.id;
|
||||||
|
falar(m.texto);
|
||||||
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
|
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
|
||||||
|
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
|
||||||
});
|
});
|
||||||
ouvir('reacao', (d) => {
|
ouvir('reacao', (d) => {
|
||||||
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
|
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
|
||||||
|
|||||||
@@ -118,6 +118,11 @@
|
|||||||
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
|
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
|
||||||
const x = p.cx - w / 2, y = p.cy - h / 2;
|
const x = p.cx - w / 2, y = p.cy - h / 2;
|
||||||
c.fillStyle = css(o.cor);
|
c.fillStyle = css(o.cor);
|
||||||
|
if (o.dormindo && !temConteudo) { // dormindo: "–" (olho fechado)
|
||||||
|
const esp = Math.max(2, p.ry * 0.2);
|
||||||
|
caixa(c, p.cx - w * 0.5, p.cy + h * 0.08 - esp / 2, w, esp, esp / 2); c.fill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
|
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
|
||||||
caixa(c, x, y, w, h, est.r * p.ry * respira);
|
caixa(c, x, y, w, h, est.r * p.ry * respira);
|
||||||
c.fill();
|
c.fill();
|
||||||
@@ -132,7 +137,21 @@
|
|||||||
c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill();
|
c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill();
|
||||||
c.globalCompositeOperation = 'source-over';
|
c.globalCompositeOperation = 'source-over';
|
||||||
if (temConteudo && fecha < 0.6) conteudo(c, o, p.cx, p.cy, Math.min(w, h) * 0.74);
|
if (temConteudo && fecha < 0.6) conteudo(c, o, p.cx, p.cy, Math.min(w, h) * 0.74);
|
||||||
else if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar
|
else if (o.iris && fecha < 0.6 && e.bot < 0.35) {
|
||||||
|
const R = Math.min(w, h) * 0.3;
|
||||||
|
const ix = Math.max(x + R * 1.1, Math.min(x + w - R * 1.1, p.cx + (p.lx + sacada.x) * w * 0.016));
|
||||||
|
const iy = Math.max(y + R * 1.1, Math.min(y + h - R * 1.1, p.cy + h * 0.04 + (p.ly + sacada.y) * h * 0.016));
|
||||||
|
c.fillStyle = css(o.cor.map((v) => v * 0.42));
|
||||||
|
c.beginPath(); c.arc(ix, iy, R, 0, Math.PI * 2); c.fill();
|
||||||
|
c.globalCompositeOperation = 'destination-out';
|
||||||
|
const pupila = p.ab > 1.05 ? 0.38 : e.bot > 0.2 ? 0.62 : 0.5; // susto contrai, alegria dilata
|
||||||
|
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fill();
|
||||||
|
c.globalCompositeOperation = 'source-over';
|
||||||
|
if (est.brilho) {
|
||||||
|
c.fillStyle = 'rgba(245,250,255,0.9)';
|
||||||
|
c.beginPath(); c.arc(ix - R * 0.42, iy - R * 0.42, R * 0.2, 0, Math.PI * 2); c.fill();
|
||||||
|
}
|
||||||
|
} else if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar
|
||||||
const hx = Math.max(x + w * 0.18, Math.min(x + w * 0.62, x + w * 0.3 + (p.lx + sacada.x) * w * 0.012));
|
const hx = Math.max(x + w * 0.18, Math.min(x + w * 0.62, x + w * 0.3 + (p.lx + sacada.x) * w * 0.012));
|
||||||
const hy = Math.max(y + h * 0.18, Math.min(y + h * 0.5, y + h * 0.26 + (p.ly + sacada.y) * h * 0.012));
|
const hy = Math.max(y + h * 0.18, Math.min(y + h * 0.5, y + h * 0.26 + (p.ly + sacada.y) * h * 0.012));
|
||||||
c.fillStyle = 'rgba(245,250,255,0.9)';
|
c.fillStyle = 'rgba(245,250,255,0.9)';
|
||||||
@@ -205,6 +224,15 @@
|
|||||||
bctx.clearRect(0, 0, colunas, linhas);
|
bctx.clearRect(0, 0, colunas, linhas);
|
||||||
bctx.save(); bctx.scale(f, f);
|
bctx.save(); bctx.scale(f, f);
|
||||||
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i]));
|
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i]));
|
||||||
|
for (const traco of o.tracos || []) {
|
||||||
|
if (typeof traco.d !== 'string') continue;
|
||||||
|
const caminho = new Path2D(traco.d);
|
||||||
|
bctx.strokeStyle = bctx.fillStyle = css(o.cor);
|
||||||
|
bctx.lineWidth = traco.largura; bctx.lineCap = 'round'; bctx.lineJoin = 'round';
|
||||||
|
if (traco.cheio) bctx.fill(caminho); else bctx.stroke(caminho);
|
||||||
|
}
|
||||||
|
desenharMaos(bctx, o, olhos, tela.height / d);
|
||||||
|
desenharZzz(bctx, o, olhos, 1.6); // em LEDs o Z precisa ser maior para ler
|
||||||
bctx.restore();
|
bctx.restore();
|
||||||
const px = bctx.getImageData(0, 0, colunas, linhas).data;
|
const px = bctx.getImageData(0, 0, colunas, linhas).data;
|
||||||
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
|
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
|
||||||
@@ -239,14 +267,221 @@
|
|||||||
ry: p.ry * d,
|
ry: p.ry * d,
|
||||||
}));
|
}));
|
||||||
ctx.clearRect(0, 0, tela.width, tela.height);
|
ctx.clearRect(0, 0, tela.width, tela.height);
|
||||||
if (estilo.pontos) return desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
|
if (estilo.pontos) {
|
||||||
if (estilo.lente) { olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); return; }
|
desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
|
||||||
cctx.clearRect(0, 0, camada.width, camada.height);
|
} else if (estilo.lente) {
|
||||||
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
|
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
|
||||||
ctx.save();
|
desenharMaos(ctx, o, olhos, tela.height);
|
||||||
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
|
desenharZzz(ctx, o, olhos);
|
||||||
ctx.drawImage(camada, 0, 0);
|
} else {
|
||||||
ctx.restore();
|
cctx.clearRect(0, 0, camada.width, camada.height);
|
||||||
|
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
|
||||||
|
desenharMaos(cctx, o, olhos, tela.height);
|
||||||
|
desenharZzz(cctx, o, olhos);
|
||||||
|
ctx.save();
|
||||||
|
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
|
||||||
|
ctx.drawImage(camada, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
desenharFaixa(ctx, o, tela.width, tela.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ mãos
|
||||||
|
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências
|
||||||
|
// de rosto de mesmo nome no carinha.html.
|
||||||
|
const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8 };
|
||||||
|
const maos = { modo: null, t0: null };
|
||||||
|
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); };
|
||||||
|
const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b
|
||||||
|
|
||||||
|
// Mão de desenho animado: palma, quatro dedos (o do meio maior), polegar para o lado do rosto
|
||||||
|
// e um punho de robô. dobra (0–1) fecha os dedos; mexe (0–1) agita os dedos ao acenar.
|
||||||
|
// O contorno é traçado antes do preenchimento: assim só a borda externa aparece.
|
||||||
|
function capsula(c, x0, y0, x1, y1, r) {
|
||||||
|
const a = Math.atan2(y1 - y0, x1 - x0) + Math.PI / 2;
|
||||||
|
c.moveTo(x0 + Math.cos(a) * r, y0 + Math.sin(a) * r);
|
||||||
|
c.arc(x0, y0, r, a, a + Math.PI);
|
||||||
|
c.arc(x1, y1, r, a + Math.PI, a + 2 * Math.PI);
|
||||||
|
c.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formaMao(c, s, dobra, mexe, t, indicador) {
|
||||||
|
c.beginPath();
|
||||||
|
// punho (manga do robô)
|
||||||
|
caixa(c, -0.42 * s, 0.5 * s, 0.84 * s, 0.38 * s, 0.12 * s);
|
||||||
|
// palma
|
||||||
|
caixa(c, -0.5 * s, -0.36 * s, 1.0 * s, 0.95 * s, 0.34 * s);
|
||||||
|
// dedos: x da base, comprimento e abertura
|
||||||
|
// mindinho, anelar, médio e indicador (o indicador fica do lado do polegar)
|
||||||
|
const dedos = [[-0.355, 0.5, -0.16], [-0.12, 0.68, -0.05], [0.12, 0.74, 0.05], [0.355, 0.66, 0.14]];
|
||||||
|
dedos.forEach(([x, comp, abre], i) => {
|
||||||
|
const onda = mexe ? Math.sin(t * 14 + i * 0.9) * 0.06 * mexe : 0;
|
||||||
|
const fecha = indicador && i === 3 ? 0 : dobra; // "pensando": só o indicador esticado
|
||||||
|
const ang = abre + onda, l = comp * (1 - 0.7 * fecha) * s;
|
||||||
|
const bx = x * s, by = -0.26 * s;
|
||||||
|
capsula(c, bx, by, bx + Math.sin(ang) * l, by - Math.cos(ang) * l, 0.115 * s);
|
||||||
|
});
|
||||||
|
// polegar, saindo para o lado
|
||||||
|
capsula(c, 0.38 * s, 0.2 * s, (0.38 + 0.42 * (1 - 0.4 * dobra)) * s, (0.2 - 0.34) * s, 0.13 * s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mao(c, o, x, y, s, ang, espelho, dobra, mexe, indicador, pulso, costas) {
|
||||||
|
dobra = dobra || 0; mexe = mexe || 0;
|
||||||
|
s *= 0.82;
|
||||||
|
const escura = o.cor.map((v) => v * 0.34);
|
||||||
|
c.save();
|
||||||
|
c.translate(x, y); c.rotate(ang); c.scale(espelho, 1);
|
||||||
|
if (pulso) c.translate(0, -0.7 * s); // (x, y) é o punho: gira como uma mão de verdade
|
||||||
|
c.lineJoin = 'round'; c.lineCap = 'round';
|
||||||
|
formaMao(c, s, dobra, mexe, o.t || 0, indicador);
|
||||||
|
c.strokeStyle = css(o.cor); c.lineWidth = s * 0.17; c.stroke(); // contorno (metade some sob o preenchimento)
|
||||||
|
c.fillStyle = css(escura); c.fill();
|
||||||
|
// detalhes por dentro, finos: linha da palma, vincos dos dedos e a costura do punho
|
||||||
|
c.strokeStyle = css(o.cor, 0.55); c.lineWidth = s * 0.045;
|
||||||
|
if (costas) { // dorso da mão: nós dos dedos em vez da linha da palma
|
||||||
|
for (const nx of [-0.355, -0.12, 0.12, 0.355]) { c.beginPath(); c.arc(nx * s, -0.2 * s, 0.075 * s, Math.PI * 1.15, Math.PI * 1.85); c.stroke(); }
|
||||||
|
} else {
|
||||||
|
c.beginPath(); c.moveTo(-0.3 * s, 0.12 * s); c.quadraticCurveTo(0.02 * s, 0.26 * s, 0.3 * s, 0.06 * s); c.stroke();
|
||||||
|
}
|
||||||
|
c.beginPath(); c.moveTo(-0.36 * s, 0.6 * s); c.lineTo(0.36 * s, 0.6 * s); c.stroke();
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sobe com um leve "passa e volta" no fim (como um braço que freia).
|
||||||
|
const voltinha = (x) => { x = Math.max(0, Math.min(1, x)); const k = 1.6; return 1 + (k + 1) * Math.pow(x - 1, 3) + k * Math.pow(x - 1, 2); };
|
||||||
|
|
||||||
|
function desenharMaos(c, o, olhos, H) {
|
||||||
|
if (!maos.modo) return;
|
||||||
|
if (maos.t0 === null) maos.t0 = o.t;
|
||||||
|
const k = (o.t - maos.t0) / DUR_MAOS[maos.modo];
|
||||||
|
if (k >= 1) { maos.modo = null; return; }
|
||||||
|
const [e, d] = olhos.slice().sort((a, b) => a.cx - b.cx); // olho da esquerda e da direita na tela
|
||||||
|
const s = e.ry * 0.95, fora = H + s * 1.8;
|
||||||
|
if (maos.modo === 'esconder' || maos.modo === 'espiar') {
|
||||||
|
// Cada mão vem do canto de baixo, por fora, num arco, girando até ficar inclinada para dentro.
|
||||||
|
// A da direita chega um pouco depois: duas mãos nunca se mexem igualzinho.
|
||||||
|
const cobrir = (olho, lado, atraso) => {
|
||||||
|
const ida = voltinha((k - atraso) / 0.24), volta = faixaDe(k, 0.8 + atraso * 0.5, 1);
|
||||||
|
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta); // 0 = fora da tela, 1 = cobrindo
|
||||||
|
const alvoX = olho.cx - lado * olho.ry * 0.05, alvoY = olho.cy + olho.ry * 0.38;
|
||||||
|
const x = alvoX - lado * olho.ry * 1.6 * (1 - Math.min(1, v));
|
||||||
|
const y = fora + (alvoY - fora) * v;
|
||||||
|
const respira = Math.sin(o.t * 3 + lado) * s * 0.03 * Math.min(1, v);
|
||||||
|
const ang = lado * (0.42 + 0.55 * (1 - Math.min(1, v))); // gira enquanto sobe, termina a ~24°
|
||||||
|
return { x, y: y + respira, ang };
|
||||||
|
};
|
||||||
|
const pe = cobrir(e, 1, 0), pd = cobrir(d, -1, 0.05);
|
||||||
|
if (maos.modo === 'espiar') {
|
||||||
|
// a mão da esquerda gira pelo punho para fora, como uma porta, e o olho aparece
|
||||||
|
const abre = faixaDe(k, 0.3, 0.44) - faixaDe(k, 0.64, 0.76);
|
||||||
|
pe.ang -= abre * 1.05; pe.x -= abre * e.ry * 0.35; pe.y += abre * e.ry * 0.2;
|
||||||
|
}
|
||||||
|
const treme = Math.sin(o.t * 22) * s * 0.012; // tímido: tremidinha
|
||||||
|
// dorso virado para quem olha: polegar para fora, perto da bochecha
|
||||||
|
mao(c, o, pe.x + treme, pe.y, s * 1.1, pe.ang, -1, 0, 0, false, true, true);
|
||||||
|
mao(c, o, pd.x - treme, pd.y, s * 1.1, pd.ang, 1, 0, 0, false, true, true);
|
||||||
|
} else if (maos.modo === 'acenar') {
|
||||||
|
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
|
||||||
|
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
|
||||||
|
const x = d.cx + d.ry * 2.2 + d.ry * 0.6 * (1 - Math.min(1, v)), y0 = d.cy + d.ry * 1.55;
|
||||||
|
const y = fora + (y0 - fora) * v;
|
||||||
|
const balanco = k > 0.22 && k < 0.82 ? Math.sin((k - 0.22) * Math.PI * 7) * 0.32 : 0;
|
||||||
|
mao(c, o, x, y, s * 0.95, -0.18 + balanco, -1, 0, 1, false, true);
|
||||||
|
} else if (maos.modo === 'pensar') {
|
||||||
|
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
|
||||||
|
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
|
||||||
|
const x = (e.cx + d.cx) / 2 + e.ry * 1.05, y0 = e.cy + e.ry * 1.4;
|
||||||
|
const y = fora + (y0 - fora) * v + Math.sin(o.t * 9) * s * 0.03;
|
||||||
|
mao(c, o, x, y, s * 0.85, -0.5, -1, 0.95, 0, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Zzz
|
||||||
|
function desenharZzz(c, o, olhos, fator) {
|
||||||
|
fator = fator || 1;
|
||||||
|
if (!o.dormindo) return;
|
||||||
|
const d = olhos.slice().sort((a, b) => a.cx - b.cx)[1]; // olho da direita na tela
|
||||||
|
const x0 = d.cx + d.ry * 0.7, y0 = d.cy - d.ry * 0.55;
|
||||||
|
c.save();
|
||||||
|
c.strokeStyle = css(o.cor); c.lineCap = 'round'; c.lineJoin = 'round';
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const k = ((o.t || 0) * 0.32 + i / 3) % 1; // cada Z num ponto do ciclo
|
||||||
|
const tam = d.ry * (0.28 + 0.3 * k) * fator;
|
||||||
|
const x = x0 + d.ry * 0.7 * k + Math.sin(k * Math.PI * 2) * d.ry * 0.08, y = y0 - d.ry * 1.7 * k;
|
||||||
|
c.globalAlpha = Math.min(1, k / 0.15) * (1 - k);
|
||||||
|
c.lineWidth = Math.max(1.5, tam * 0.2);
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(x - tam / 2, y - tam / 2); c.lineTo(x + tam / 2, y - tam / 2);
|
||||||
|
c.lineTo(x - tam / 2, y + tam / 2); c.lineTo(x + tam / 2, y + tam / 2);
|
||||||
|
c.stroke();
|
||||||
|
}
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------- faixa de baixo
|
||||||
|
// A parte de baixo da tela vira palco: legenda da conversa, pensando, equalizador e carga.
|
||||||
|
const faixa = { modo: null, texto: '', t0: null, dur: 0 };
|
||||||
|
|
||||||
|
function definirFaixa(modo, opcoes) {
|
||||||
|
opcoes = opcoes || {};
|
||||||
|
if (!modo) {
|
||||||
|
if (!opcoes.so || faixa.modo === opcoes.so) faixa.modo = null; // so: só apaga esse modo
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
faixa.modo = modo; faixa.t0 = performance.now() / 1000; faixa.texto = String(opcoes.texto || '').replace(/\s+/g, ' ').trim().slice(0, 400);
|
||||||
|
faixa.dur = opcoes.segundos || (modo === 'legenda' ? Math.max(3, Math.min(14, faixa.texto.length / 14 + 2.5)) : modo === 'digitando' ? 20 : 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function desenharFaixa(c, o, W, H) {
|
||||||
|
const y = H * 0.955, fonte = Math.round(H * 0.042);
|
||||||
|
c.save();
|
||||||
|
c.shadowColor = css(o.cor, 0.6); c.shadowBlur = H * 0.02;
|
||||||
|
if (o.carga && o.carga.carregando && typeof o.carga.nivel === 'number') { // carregando: canto direito
|
||||||
|
const n = Math.max(0, Math.min(100, o.carga.nivel)), bw = H * 0.075, bh = H * 0.036, bx = W - H * 0.13, by = y - bh / 2;
|
||||||
|
c.globalAlpha = 0.55 + 0.25 * Math.sin(o.t * 2.5);
|
||||||
|
c.strokeStyle = css(o.cor); c.lineWidth = Math.max(1, H * 0.005);
|
||||||
|
caixa(c, bx, by, bw, bh, bh * 0.25); c.stroke();
|
||||||
|
c.fillStyle = css(o.cor); c.fillRect(bx + bw, y - bh * 0.2, bh * 0.15, bh * 0.4);
|
||||||
|
c.fillRect(bx + bh * 0.12, by + bh * 0.15, (bw - bh * 0.24) * n / 100, bh * 0.7);
|
||||||
|
c.font = '600 ' + Math.round(fonte * 0.8) + 'px system-ui,sans-serif'; c.textAlign = 'right'; c.textBaseline = 'middle';
|
||||||
|
const tx = bx - H * 0.012;
|
||||||
|
c.fillText(Math.round(n) + '%', tx, y);
|
||||||
|
const rx = tx - c.measureText(Math.round(n) + '%').width - H * 0.022, r = H * 0.016; // raio desenhado na cor dos olhos
|
||||||
|
c.beginPath(); c.moveTo(rx + r * 0.3, y - r); c.lineTo(rx - r * 0.5, y + r * 0.15); c.lineTo(rx, y + r * 0.15);
|
||||||
|
c.lineTo(rx - r * 0.3, y + r); c.lineTo(rx + r * 0.5, y - r * 0.15); c.lineTo(rx, y - r * 0.15); c.closePath(); c.fill();
|
||||||
|
c.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
if (faixa.modo) {
|
||||||
|
if (faixa.t0 === null) faixa.t0 = o.t;
|
||||||
|
const k = (o.t - faixa.t0) / faixa.dur;
|
||||||
|
if (k >= 1) faixa.modo = null;
|
||||||
|
else {
|
||||||
|
const some = Math.min(1, (faixa.dur - (o.t - faixa.t0)) / 0.4);
|
||||||
|
c.globalAlpha = Math.max(0, some);
|
||||||
|
c.fillStyle = css(o.cor);
|
||||||
|
if (faixa.modo === 'legenda') {
|
||||||
|
// efeito de digitação; se não couber, mostra o final do que já foi "dito"
|
||||||
|
let txt = faixa.texto.slice(0, Math.floor((o.t - faixa.t0) * 30));
|
||||||
|
c.font = '500 ' + fonte + 'px system-ui,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle';
|
||||||
|
const max = W * 0.8;
|
||||||
|
while (txt.length > 1 && c.measureText(txt).width > max) txt = txt.slice(1);
|
||||||
|
c.fillText(txt, W / 2, y);
|
||||||
|
} else if (faixa.modo === 'digitando') {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const pulo = Math.max(0, Math.sin(o.t * 7 - i * 0.7)) * H * 0.012;
|
||||||
|
c.beginPath(); c.arc(W / 2 + (i - 1) * H * 0.035, y - pulo, H * 0.011, 0, Math.PI * 2); c.fill();
|
||||||
|
}
|
||||||
|
} else if (faixa.modo === 'equalizador') {
|
||||||
|
const n = 15, larg = H * 0.016, passo = H * 0.026;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = 0.25 + 0.75 * Math.abs(Math.sin(o.t * (5 + i % 4) + i * 1.3)) * Math.sin(Math.PI * (i + 0.5) / n);
|
||||||
|
const h = H * 0.04 * a;
|
||||||
|
caixa(c, W / 2 + (i - (n - 1) / 2) * passo - larg / 2, H * 0.988 - h, larg, h, larg / 2); c.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -285,5 +520,14 @@
|
|||||||
|
|
||||||
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
|
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
|
||||||
|
|
||||||
window.MotorRosto = { desenhar, retrato, esconder, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
|
window.MotorRosto = {
|
||||||
|
desenhar, retrato, esconder,
|
||||||
|
/** Metade da altura do olho (em frações de RY) para o estilo/formato: posiciona as sobrancelhas. */
|
||||||
|
metadeAltura: (estilo, formato) => {
|
||||||
|
const base = ESTILOS[estilo]; if (!base) return 1;
|
||||||
|
if (base.lente) return base.w * 0.5 * 1.12;
|
||||||
|
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
|
||||||
|
},
|
||||||
|
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
|
||||||
|
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -12,8 +12,13 @@ let config = {}, signature = '', movementEnabled = true, aviso = '', capacidades
|
|||||||
let allowed = true;
|
let allowed = true;
|
||||||
let lastMotionFrame = -1;
|
let lastMotionFrame = -1;
|
||||||
function chave(c) { return JSON.stringify([c.cameraLado || 'user', c.videoResolucao || 'auto', c.videoFPS || 0]); }
|
function chave(c) { return JSON.stringify([c.cameraLado || 'user', c.videoResolucao || 'auto', c.videoFPS || 0]); }
|
||||||
|
/* Nada pode esperar para sempre: se a câmera estiver presa (ex.: servidor caiu no meio e o
|
||||||
|
sensor de movimento ainda a segura), desiste e a próxima mensagem do painel tenta de novo. */
|
||||||
|
function comPrazo(promessa, ms, oque) {
|
||||||
|
return Promise.race([promessa, new Promise((_, rej) => setTimeout(() => rej(new Error(oque + ' demorou mais de ' + ms / 1000 + ' s')), ms))]);
|
||||||
|
}
|
||||||
async function capturar(c) {
|
async function capturar(c) {
|
||||||
const media = await navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:c.cameraLado || 'user'},width:{ideal:640},height:{ideal:360},frameRate:{ideal:30}}});
|
const media = await comPrazo(navigator.mediaDevices.getUserMedia({audio:false,video:{facingMode:{ideal:c.cameraLado || 'user'},width:{ideal:640},height:{ideal:360},frameRate:{ideal:30}}}), 8000, 'Abrir a câmera');
|
||||||
const track = media.getVideoTracks()[0];
|
const track = media.getVideoTracks()[0];
|
||||||
const facing = track.getSettings().facingMode;
|
const facing = track.getSettings().facingMode;
|
||||||
if (facing && facing !== (c.cameraLado || 'user')) {
|
if (facing && facing !== (c.cameraLado || 'user')) {
|
||||||
@@ -74,6 +79,12 @@ async function iniciar(id) {
|
|||||||
const media = await capturar(config); signature = chave(config);
|
const media = await capturar(config); signature = chave(config);
|
||||||
if (!wanted || captureGeneration !== generation) { media.getTracks().forEach(t => t.stop()); return; }
|
if (!wanted || captureGeneration !== generation) { media.getTracks().forEach(t => t.stop()); return; }
|
||||||
stream = media; video.srcObject = stream; await video.play();
|
stream = media; video.srcObject = stream; await video.play();
|
||||||
|
// câmera fechada de repente (outro uso, erro do sistema): desfaz tudo; o próximo pacote reabre
|
||||||
|
stream.getVideoTracks()[0].onended = () => {
|
||||||
|
if (captureGeneration !== generation) return;
|
||||||
|
console.log('RTC câmera encerrada; recomeçando no próximo pedido');
|
||||||
|
parar(); sid = null;
|
||||||
|
};
|
||||||
pc = new RTCPeerConnection({iceServers:[]});
|
pc = new RTCPeerConnection({iceServers:[]});
|
||||||
sender = pc.addTrack(stream.getVideoTracks()[0], stream);
|
sender = pc.addTrack(stream.getVideoTracks()[0], stream);
|
||||||
const mine = pc;
|
const mine = pc;
|
||||||
@@ -133,11 +144,11 @@ async function receber(packet) {
|
|||||||
}
|
}
|
||||||
window.rtcReceber = packet => {
|
window.rtcReceber = packet => {
|
||||||
allowed = true;
|
allowed = true;
|
||||||
tasks = tasks.then(() => allowed ? receber(packet) : undefined).catch(e => {
|
tasks = tasks.then(() => allowed ? comPrazo(receber(packet), 12000, 'Pedido de vídeo') : undefined).catch(e => {
|
||||||
console.log('RTC erro=' + e.name + ': ' + e.message);
|
console.log('RTC erro=' + e.name + ': ' + e.message);
|
||||||
sinal({tipo:'erro',erro:e.name + ': ' + e.message});
|
sinal({tipo:'erro',erro:e.name + ': ' + e.message});
|
||||||
estado({tipo:'webrtc',estado:'erro',erro:e.message});
|
estado({tipo:'webrtc',estado:'erro',erro:e.message});
|
||||||
parar();
|
parar(); sid = null;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
window.rtcParar = () => { allowed = false; parar(); sid = null; };
|
window.rtcParar = () => { allowed = false; parar(); sid = null; };
|
||||||
|
|||||||
@@ -0,0 +1,188 @@
|
|||||||
|
/* voz-robo.js — voz sintetizada do Carinha, no estilo dos robozinhos de cinema.
|
||||||
|
*
|
||||||
|
* Tudo é gerado na hora com Web Audio: nenhuma gravação, nenhum arquivo de terceiros.
|
||||||
|
* Cada som é uma sequência de "sílabas": um oscilador que desliza de um tom a outro, com vibrato,
|
||||||
|
* passando por um filtro de formante (a "vogal": o→i faz o "uou"). Motores das mãos viram ruído
|
||||||
|
* filtrado, e a resposta da conversa vira um balbucio que sobe no fim quando é pergunta.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let ruido = null; // buffer de ruído reaproveitado
|
||||||
|
const TOM = 1.25; // todas as sílabas 25% acima do tom escrito
|
||||||
|
const saidas = new WeakMap(); // AudioContext → nó de saída (compressor)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Cadeia de "brilho metálico" (criada uma vez por AudioContext):
|
||||||
|
* voz → ressonância de lata fina (agudo, ~850 Hz) → saturação leve → compressor → saída
|
||||||
|
* Nada grave: zumbido e ressonância graves deixavam a voz com cara de sapo.
|
||||||
|
*/
|
||||||
|
function saida(c) {
|
||||||
|
if (saidas.has(c)) return saidas.get(c);
|
||||||
|
const entrada = c.createGain();
|
||||||
|
const seca = c.createGain(); seca.gain.value = 0.8;
|
||||||
|
const atraso = c.createDelay(0.05); atraso.delayTime.value = 0.00118;
|
||||||
|
const volta = c.createGain(); volta.gain.value = 0.42;
|
||||||
|
const molhada = c.createGain(); molhada.gain.value = 0.35;
|
||||||
|
entrada.connect(seca);
|
||||||
|
entrada.connect(atraso); atraso.connect(volta); volta.connect(atraso); atraso.connect(molhada);
|
||||||
|
const satura = c.createWaveShaper();
|
||||||
|
const n = 2048, curva = new Float32Array(n);
|
||||||
|
for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * 2 - 1; curva[i] = Math.tanh(x * 1.3); }
|
||||||
|
satura.curve = curva;
|
||||||
|
seca.connect(satura); molhada.connect(satura);
|
||||||
|
const brilho = c.createBiquadFilter(); // realce leve nos agudos: "metal" limpo
|
||||||
|
brilho.type = 'peaking'; brilho.frequency.value = 3200; brilho.Q.value = 1.2; brilho.gain.value = 4;
|
||||||
|
const comp = c.createDynamicsCompressor();
|
||||||
|
comp.threshold.value = -16; comp.ratio.value = 4; comp.attack.value = 0.003; comp.release.value = 0.15;
|
||||||
|
const passaBaixa = c.createBiquadFilter();
|
||||||
|
passaBaixa.type = 'lowpass'; passaBaixa.frequency.value = 8000;
|
||||||
|
const volume = c.createGain(); volume.gain.value = 0.8;
|
||||||
|
satura.connect(brilho); brilho.connect(comp); comp.connect(passaBaixa); passaBaixa.connect(volume); volume.connect(c.destination);
|
||||||
|
saidas.set(c, entrada);
|
||||||
|
return entrada;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uma sílaba. de/para: tom em Hz; vogal: [formante inicial, final] em Hz;
|
||||||
|
* vib: [Hz, profundidade em Hz]; forma: tipo de onda.
|
||||||
|
*/
|
||||||
|
function silaba(c, t0, dur, de, para, vol, op) {
|
||||||
|
op = op || {};
|
||||||
|
vol *= 2.4; // compensa a energia que o filtro de formante tira
|
||||||
|
de *= TOM; para *= TOM; // voz de robozinho: aguda
|
||||||
|
const osc = c.createOscillator();
|
||||||
|
osc.type = op.forma || 'sawtooth';
|
||||||
|
osc.frequency.setValueAtTime(de, t0);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(Math.max(20, para), t0 + dur);
|
||||||
|
if (op.vib) {
|
||||||
|
const lfo = c.createOscillator(), prof = c.createGain();
|
||||||
|
lfo.frequency.value = op.vib[0]; prof.gain.value = op.vib[1];
|
||||||
|
lfo.connect(prof); prof.connect(osc.frequency);
|
||||||
|
lfo.start(t0); lfo.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
// FM harmônico e leve: brilho de metal sem aspereza
|
||||||
|
const mod = c.createOscillator(), modProf = c.createGain();
|
||||||
|
mod.type = 'sine';
|
||||||
|
mod.frequency.setValueAtTime(de * 2, t0);
|
||||||
|
mod.frequency.exponentialRampToValueAtTime(Math.max(20, para * 2), t0 + dur);
|
||||||
|
modProf.gain.setValueAtTime(de * (op.metal ?? 0.22), t0);
|
||||||
|
modProf.gain.exponentialRampToValueAtTime(Math.max(1, para * (op.metal ?? 0.22)), t0 + dur);
|
||||||
|
mod.connect(modProf); modProf.connect(osc.frequency);
|
||||||
|
mod.start(t0); mod.stop(t0 + dur + 0.05);
|
||||||
|
// corpo: uma senoide uma oitava abaixo deixa a voz menos "apito"
|
||||||
|
const sub = c.createOscillator();
|
||||||
|
sub.type = 'sine';
|
||||||
|
sub.frequency.setValueAtTime(de / 2, t0);
|
||||||
|
sub.frequency.exponentialRampToValueAtTime(Math.max(20, para / 2), t0 + dur);
|
||||||
|
const vogal = op.vogal || [900, 1500];
|
||||||
|
const filtro = c.createBiquadFilter();
|
||||||
|
filtro.type = 'bandpass'; filtro.Q.value = op.q || 5;
|
||||||
|
filtro.frequency.setValueAtTime(vogal[0], t0);
|
||||||
|
filtro.frequency.exponentialRampToValueAtTime(vogal[1], t0 + dur);
|
||||||
|
const g = c.createGain(), gs = c.createGain();
|
||||||
|
const ataque = Math.min(0.02, dur * 0.25);
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(vol, t0 + ataque);
|
||||||
|
g.gain.setValueAtTime(vol, t0 + dur * 0.7);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||||
|
gs.gain.value = 0.12; // pouca oitava de baixo: grave demais vira coaxar
|
||||||
|
osc.connect(filtro); sub.connect(gs); gs.connect(filtro); filtro.connect(g); g.connect(saida(c));
|
||||||
|
osc.start(t0); sub.start(t0);
|
||||||
|
osc.stop(t0 + dur + 0.05); sub.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferRuido(c) {
|
||||||
|
if (ruido && ruido.sampleRate === c.sampleRate) return ruido;
|
||||||
|
ruido = c.createBuffer(1, c.sampleRate, c.sampleRate);
|
||||||
|
const d = ruido.getChannelData(0);
|
||||||
|
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
||||||
|
return ruido;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Motorzinho: ruído filtrado que sobe de tom, com um tremor mecânico. */
|
||||||
|
function servo(c, t0, dur, vol, sobe) {
|
||||||
|
const fonte = c.createBufferSource();
|
||||||
|
fonte.buffer = bufferRuido(c); fonte.loop = true;
|
||||||
|
const f = c.createBiquadFilter();
|
||||||
|
f.type = 'bandpass'; f.Q.value = 9;
|
||||||
|
f.frequency.setValueAtTime(sobe === false ? 1500 : 500, t0);
|
||||||
|
f.frequency.exponentialRampToValueAtTime(sobe === false ? 500 : 1500, t0 + dur);
|
||||||
|
const g = c.createGain(), trem = c.createOscillator(), tg = c.createGain();
|
||||||
|
trem.frequency.value = 38; tg.gain.value = vol * 0.35;
|
||||||
|
trem.connect(tg); tg.connect(g.gain);
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(vol * 0.6, t0 + 0.05);
|
||||||
|
g.gain.setValueAtTime(vol * 0.6, t0 + dur - 0.06);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||||
|
fonte.connect(f); f.connect(g); g.connect(saida(c));
|
||||||
|
fonte.start(t0); trem.start(t0);
|
||||||
|
fonte.stop(t0 + dur + 0.05); trem.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clique(c, t0, vol) {
|
||||||
|
const fonte = c.createBufferSource();
|
||||||
|
fonte.buffer = bufferRuido(c);
|
||||||
|
const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 3000;
|
||||||
|
const g = c.createGain();
|
||||||
|
g.gain.setValueAtTime(vol * 0.5, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.03);
|
||||||
|
fonte.connect(f); f.connect(g); g.connect(saida(c));
|
||||||
|
fonte.start(t0); fonte.stop(t0 + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
const O = [500, 900], I = [1800, 2400], U = [350, 700], A = [900, 1300]; // vogais (formantes)
|
||||||
|
|
||||||
|
// Cada som: (c, t, v) → agenda as sílabas. Volumes relativos ao volume do app.
|
||||||
|
const SONS = {
|
||||||
|
feliz: (c, t, v) => { silaba(c, t, 0.12, 620, 900, v, { vogal: [1200, 2000] }); silaba(c, t + 0.13, 0.18, 900, 1250, v, { vogal: I, vib: [9, 18] }); },
|
||||||
|
alegre: (c, t, v) => [560, 700, 860, 1100].forEach((f, i) => silaba(c, t + i * 0.085, 0.09, f, f * 1.25, v * 0.85, { vogal: I, forma: 'square' })),
|
||||||
|
coracao: (c, t, v) => { silaba(c, t, 0.55, 760, 560, v, { vogal: [800, 1300], vib: [7, 28] }); },
|
||||||
|
piscadinha: (c, t, v) => { clique(c, t, v); silaba(c, t + 0.04, 0.08, 1000, 1300, v * 0.6, { forma: 'square', vogal: I }); },
|
||||||
|
surpreso: (c, t, v) => { silaba(c, t, 0.22, 260, 1100, v, { vogal: [450, 2000] }); silaba(c, t + 0.22, 0.12, 1100, 820, v * 0.8, { vogal: I }); },
|
||||||
|
bravo: (c, t, v) => [0, 0.13, 0.26].forEach((d, i) => silaba(c, t + d, 0.1, 480 - i * 30, 380 - i * 30, v * 0.85, { forma: 'square', vogal: [1300, 900] })), // bipes zangados
|
||||||
|
triste: (c, t, v) => { silaba(c, t, 0.65, 640, 380, v * 0.85, { vogal: [1200, 750], vib: [5, 14] }); },
|
||||||
|
dormir: (c, t, v) => { silaba(c, t, 0.35, 460, 760, v * 0.7, { vogal: [900, 1500] }); silaba(c, t + 0.36, 0.75, 760, 340, v * 0.6, { vogal: [1500, 700], vib: [4, 8] }); },
|
||||||
|
acordar: (c, t, v) => { servo(c, t, 0.25, v * 0.5); [300, 450, 700].forEach((f, i) => silaba(c, t + 0.22 + i * 0.1, 0.1, f, f * 1.2, v * 0.8, { vogal: I, forma: 'square' })); silaba(c, t + 0.56, 0.2, 700, 1050, v, { vogal: [1200, 2200], vib: [8, 15] }); },
|
||||||
|
acenar: (c, t, v) => { servo(c, t, 0.3, v * 0.45); silaba(c, t + 0.28, 0.13, 520, 820, v, { vogal: [1000, 1900] }); silaba(c, t + 0.44, 0.2, 760, 1080, v, { vogal: I, vib: [9, 20] }); },
|
||||||
|
esconder: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 0.1, 0.08, 950, 1350, v * 0.8, { vogal: I }); for (let i = 0; i < 5; i++) silaba(c, t + 0.6 + i * 0.09, 0.05, 1200 + (i % 2) * 180, 1300, v * 0.35, { forma: 'square', vogal: I }); servo(c, t + 2.05, 0.4, v * 0.45, false); },
|
||||||
|
espiar: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 1.0, 0.3, 480, 760, v * 0.9, { vogal: [600, 1700] }); silaba(c, t + 2.0, 0.16, 800, 1150, v, { vogal: I }); },
|
||||||
|
pensar: (c, t, v) => { servo(c, t, 0.35, v * 0.4); silaba(c, t + 0.35, 0.5, 520, 640, v * 0.7, { vogal: [900, 1600], vib: [5, 10] }); [0.95, 1.12].forEach((d) => silaba(c, t + d, 0.05, 1500, 1600, v * 0.35, { forma: 'square', vogal: I })); }, // "hmm?" e bipinhos
|
||||||
|
musica: (c, t, v) => [523, 659, 784, 659, 880, 784, 659, 1047].forEach((f, i) => silaba(c, t + i * 0.26, 0.2, f, f * 1.02, v * 0.6, { forma: 'triangle', vogal: A, vib: [6, 5] })),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gerador previsível: a mesma frase sempre "soa" igual.
|
||||||
|
function semente(texto) {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < texto.length; i++) h = Math.imul(h ^ texto.charCodeAt(i), 16777619);
|
||||||
|
return () => { h = Math.imul(h ^ (h >>> 15), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return ((h ^= h >>> 16) >>> 0) / 4294967296; };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Balbucio de robô para uma resposta: ~1 sílaba a cada 6 letras, no máximo 16. */
|
||||||
|
function balbuciar(c, texto, v) {
|
||||||
|
texto = String(texto || '').trim();
|
||||||
|
if (!texto) return 0;
|
||||||
|
const rnd = semente(texto), n = Math.max(2, Math.min(16, Math.ceil(texto.length / 6)));
|
||||||
|
const pergunta = /\?\s*$/.test(texto), exclama = /!\s*$/.test(texto);
|
||||||
|
const vogais = [O, I, A, U];
|
||||||
|
let t = c.currentTime + 0.03, tom = 520 + rnd() * 180;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const ultima = i === n - 1;
|
||||||
|
const dur = 0.07 + rnd() * 0.1;
|
||||||
|
let para = tom * (0.8 + rnd() * 0.5);
|
||||||
|
if (ultima) para = pergunta ? tom * 1.6 : exclama ? tom * 1.35 : tom * 0.7;
|
||||||
|
silaba(c, t, ultima ? dur + 0.08 : dur, tom, para, v * (0.65 + rnd() * 0.3), { vogal: vogais[Math.floor(rnd() * 4)], vib: rnd() < 0.3 ? [8, 14] : null, forma: rnd() < 0.25 ? 'square' : 'sawtooth' });
|
||||||
|
t += dur + 0.025 + rnd() * 0.05;
|
||||||
|
tom = Math.max(330, Math.min(1000, para * (0.9 + rnd() * 0.25)));
|
||||||
|
}
|
||||||
|
return t - c.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tocar(c, nome, v) {
|
||||||
|
const f = SONS[nome];
|
||||||
|
if (!f || !c) return false;
|
||||||
|
f(c, c.currentTime + 0.02, v);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.VozRobo = { tocar, balbuciar, servo: (c, dur, v) => servo(c, c.currentTime + 0.01, dur, v), sons: Object.keys(SONS) };
|
||||||
|
})();
|
||||||
@@ -30,7 +30,7 @@ def main():
|
|||||||
def run(*command):
|
def run(*command):
|
||||||
subprocess.run([str(x) for x in command], check=True)
|
subprocess.run([str(x) for x in command], check=True)
|
||||||
|
|
||||||
for name in ('carinha.html', 'camera.html', 'rosto-motor.js'):
|
for name in ('carinha.html', 'camera.html', 'rosto-motor.js', 'voz-robo.js'):
|
||||||
shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name)
|
shutil.copy2(HERE.parent / name, HERE / 'app/assets' / name)
|
||||||
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
|
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
|
||||||
tmp = Path(folder)
|
tmp = Path(folder)
|
||||||
|
|||||||
+84
-9
@@ -26,6 +26,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
<div id="fps"></div>
|
<div id="fps"></div>
|
||||||
<script src="rosto-motor.js"></script>
|
<script src="rosto-motor.js"></script>
|
||||||
|
<script src="voz-robo.js"></script>
|
||||||
<script>
|
<script>
|
||||||
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
|
/* 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. */
|
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
|
||||||
@@ -91,7 +92,7 @@ addEventListener("resize", ajustar);
|
|||||||
|
|
||||||
const PRETO = [6, 8, 14];
|
const PRETO = [6, 8, 14];
|
||||||
const CIANO = [92, 214, 255];
|
const CIANO = [92, 214, 255];
|
||||||
const EXTRAS = { boca: true, sobrancelhas: false };
|
const EXTRAS = { boca: true, sobrancelhas: false, iris: false, subirBoca: 0, distSobrancelha: 16 };
|
||||||
const CIANO_E = [150, 232, 255];
|
const CIANO_E = [150, 232, 255];
|
||||||
const VERM = [255, 78, 78];
|
const VERM = [255, 78, 78];
|
||||||
const ROSA = [255, 112, 176];
|
const ROSA = [255, 112, 176];
|
||||||
@@ -149,8 +150,18 @@ function caminhoLinha(x0, y0, x1, y1) {
|
|||||||
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
|
return "M " + x0 + "," + y0 + " L " + x1 + "," + y1;
|
||||||
}
|
}
|
||||||
/* formas da boca (o humor escolhe qual) */
|
/* formas da boca (o humor escolhe qual) */
|
||||||
|
function deslocamentoBoca() {
|
||||||
|
const auto = ROSTO && ROSTO.motor ? 22 : 0; // rostos novos têm olhos mais baixos
|
||||||
|
return -(auto + (EXTRAS.subirBoca || 0)) * ESC;
|
||||||
|
}
|
||||||
|
/* Topo do olho atual (para a sobrancelha ficar perto dele, em qualquer estilo/formato). */
|
||||||
|
function metadeOlho(esc) {
|
||||||
|
const m = ROSTO && ROSTO.motor && window.MotorRosto && window.MotorRosto.metadeAltura
|
||||||
|
? window.MotorRosto.metadeAltura(ROSTO.motor, FORMATO) : 1;
|
||||||
|
return m * RY * esc;
|
||||||
|
}
|
||||||
function caminhoBoca(tipo) {
|
function caminhoBoca(tipo) {
|
||||||
const cx = BOCA[0], cy = BOCA[1], l = BOCA_L, meia = l / 2;
|
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca(), l = BOCA_L, meia = l / 2;
|
||||||
if (tipo === "aberta") { // surpresa: um "O"
|
if (tipo === "aberta") { // surpresa: um "O"
|
||||||
const rx = l * 0.17, ry = l * 0.22;
|
const rx = l * 0.17, ry = l * 0.22;
|
||||||
return caminhoElipse(cx, cy, rx, ry);
|
return caminhoElipse(cx, cy, rx, ry);
|
||||||
@@ -304,9 +315,17 @@ function sequencias() {
|
|||||||
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
|
[{ forma: "aberto", cor: CIANO, dy: 0 }, 0.3]];
|
||||||
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
const piscadinha = [[{ forma2: "dormir", esc: 1.04, boca: "sorriso" }, 0.12], [{ esc: 1.08 }, 0.5],
|
||||||
[{ forma2: "aberto", esc: 1.0 }, 0.18]];
|
[{ 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],
|
const alegre = [[{ forma: "feliz", cor: AMARELO, boca: "sorriso" }, 0.14], [{ cor: VERDE }, 0.14],
|
||||||
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
|
[{ cor: CIANO }, 0.14], [{ forma: "aberto" }, 0.2]];
|
||||||
return { piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
|
return { esconder, espiar, acenar, pensar, musica,
|
||||||
|
piscar, piscar2, olhar_esq, olhar_dir, olhar_baixo, feliz, surpreso,
|
||||||
bravo, coracao, triste, piscadinha, alegre };
|
bravo, coracao, triste, piscadinha, alegre };
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,6 +413,9 @@ function desenhar() {
|
|||||||
window.MotorRosto.desenhar({
|
window.MotorRosto.desenhar({
|
||||||
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
|
estilo: ROSTO.motor, formato: FORMATO, t: tAgora, cor: r.cor, dormindo: r.dormindo,
|
||||||
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
|
icone: !r.dormindo && !window.carinhaOlhosDesenho ? window.carinhaOlhosIcone || null : null,
|
||||||
|
carga: window.carinhaBateria || null,
|
||||||
|
iris: EXTRAS.iris,
|
||||||
|
tracos: ROSTO.motor === "matriz" ? tracosDoRosto(esc) : null,
|
||||||
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
|
desenho: !r.dormindo ? window.carinhaOlhosDesenho || null : null,
|
||||||
parado: !r.kf && !seguindo && !r.dormindo,
|
parado: !r.kf && !seguindo && !r.dormindo,
|
||||||
olhos: olhos.map(([c, abx, fx, lado]) => ({
|
olhos: olhos.map(([c, abx, fx, lado]) => ({
|
||||||
@@ -407,16 +429,17 @@ function desenhar() {
|
|||||||
olho(i, c[0] + dx * mover * ESC, c[1] + dy * mover * ESC, RX * esc, RY * esc,
|
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);
|
abx, fx, r.cor, lado, dx, dy * 0.6);
|
||||||
}
|
}
|
||||||
if (ROSTO.motor && !ROSTO.boca) elBoca.style.display = "none";
|
const matriz = comMotor && ROSTO.motor === "matriz";
|
||||||
|
if (matriz) elBoca.style.display = "none"; // a Matriz desenha a boca em LEDs
|
||||||
else desenharBoca();
|
else desenharBoca();
|
||||||
for (let i = 0; i < 2; i++) {
|
for (let i = 0; i < 2; i++) {
|
||||||
const brow = document.getElementById('sobrancelha' + (i + 1));
|
const brow = document.getElementById('sobrancelha' + (i + 1));
|
||||||
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo ? '' : 'none';
|
brow.style.display = EXTRAS.sobrancelhas && !r.dormindo && !matriz ? '' : 'none';
|
||||||
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
|
if (!EXTRAS.sobrancelhas || r.dormindo) continue;
|
||||||
const c = i === 0 ? E1 : E2;
|
const c = i === 0 ? E1 : E2;
|
||||||
const lado = i === 0 ? 1 : -1;
|
const lado = i === 0 ? 1 : -1;
|
||||||
const largura = RX * esc * 0.85;
|
const largura = RX * esc * 0.85;
|
||||||
const y = c[1] - RY * esc - 20 * ESC;
|
const y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
|
||||||
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
|
const inclinacao = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
|
||||||
const elevacao = r.esc > 1.1 ? -15 * 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('d', 'M ' + (c[0]-largura) + ',' + (y-inclinacao+elevacao) + ' Q ' + c[0] + ',' + (y-10*ESC+elevacao) + ' ' + (c[0]+largura) + ',' + (y+inclinacao+elevacao));
|
||||||
@@ -425,6 +448,27 @@ function desenhar() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Boca e sobrancelhas como caminhos SVG, para a Matriz acender em LEDs (mesmas formas do SVG). */
|
||||||
|
function tracosDoRosto(esc) {
|
||||||
|
const tracos = [];
|
||||||
|
if (EXTRAS.boca) {
|
||||||
|
const forma = r.boca;
|
||||||
|
if (r.dormindo) { // dormindo na Matriz: "– _ –"
|
||||||
|
const cx = BOCA[0], cy = BOCA[1] + deslocamentoBoca() + BOCA_L * 0.08, l = BOCA_L * 0.32;
|
||||||
|
tracos.push({ d: 'M ' + (cx - l) + ',' + cy + ' L ' + (cx + l) + ',' + cy, largura: BOCA_L * 0.16 });
|
||||||
|
} else tracos.push({ d: caminhoBoca(forma), largura: BOCA_L * 0.18, cheio: forma === "aberta" });
|
||||||
|
}
|
||||||
|
if (EXTRAS.sobrancelhas && !r.dormindo) {
|
||||||
|
[E1, E2].forEach((c, i) => {
|
||||||
|
const lado = i === 0 ? 1 : -1, largura = RX * esc * 0.85, y = c[1] - metadeOlho(esc) - (EXTRAS.distSobrancelha + 4) * ESC;
|
||||||
|
const inc = r.forma === 'bravo' ? 18 * lado * ESC : r.forma === 'triste' ? -14 * lado * ESC : 0;
|
||||||
|
const ele = r.esc > 1.1 ? -15 * ESC : 0;
|
||||||
|
tracos.push({ d: 'M ' + (c[0] - largura) + ',' + (y - inc + ele) + ' Q ' + c[0] + ',' + (y - 10 * ESC + ele) + ' ' + (c[0] + largura) + ',' + (y + inc + ele), largura: 9 * ESC });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return tracos;
|
||||||
|
}
|
||||||
|
|
||||||
// ------------------------------------------------------------------ controle
|
// ------------------------------------------------------------------ controle
|
||||||
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
|
/* Troca de rosto (biblioteca acima). Vale na hora, sem recarregar a pagina. */
|
||||||
function trocarRosto(nome) {
|
function trocarRosto(nome) {
|
||||||
@@ -468,6 +512,10 @@ function trata(ordem) {
|
|||||||
} else if (ordem === "aleatorio") {
|
} else if (ordem === "aleatorio") {
|
||||||
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
animar(LIVRES[Math.floor(Math.random() * LIVRES.length)]);
|
||||||
} else if (SEQS[ordem]) {
|
} else if (SEQS[ordem]) {
|
||||||
|
if (window.MotorRosto) {
|
||||||
|
if (ordem === "musica") window.MotorRosto.faixa("equalizador", { segundos: 2.3 });
|
||||||
|
else window.MotorRosto.maos(ordem);
|
||||||
|
}
|
||||||
animar(ordem);
|
animar(ordem);
|
||||||
} else if (ordem === "calma") {
|
} else if (ordem === "calma") {
|
||||||
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
// E-Stop do painel: interrompe a animação e volta ao rosto neutro.
|
||||||
@@ -543,6 +591,7 @@ if (MODO_PAINEL) {
|
|||||||
const m = e.data;
|
const m = e.data;
|
||||||
if (m.tipo === 'ordem' && typeof m.ordem === 'string') trata(m.ordem);
|
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 === '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);
|
} else if (new URLSearchParams(location.search).get('espelho') === '1') setInterval(aplicarAparencia, 1000);
|
||||||
|
|
||||||
@@ -879,7 +928,9 @@ if (window.eilik) window.eilik.camera = CAM; // so depois do const: sem TDZ
|
|||||||
|
|
||||||
let configRecebida = null;
|
let configRecebida = null;
|
||||||
function configurarApp(c) {
|
function configurarApp(c) {
|
||||||
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true;
|
EXTRAS.boca = c.boca !== false; EXTRAS.sobrancelhas = c.sobrancelhas === true; EXTRAS.iris = c.iris === true;
|
||||||
|
if (typeof c.subirBoca === 'number') EXTRAS.subirBoca = c.subirBoca;
|
||||||
|
if (typeof c.distSobrancelha === 'number') EXTRAS.distSobrancelha = c.distSobrancelha;
|
||||||
AUTO = c.auto;
|
AUTO = c.auto;
|
||||||
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
AUDIO.ligado = c.audio && par.get('espelho') !== '1' && par.get('audio') !== '0'; AUDIO.volume = c.volume;
|
||||||
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
|
COMPORTAMENTO.ouvir = c.ouvir; COMPORTAMENTO.reagir = c.reagir;
|
||||||
@@ -955,15 +1006,31 @@ const SONS = {
|
|||||||
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); },
|
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); },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/* Balbucio de robô para a resposta da conversa (voz-robo.js). Enquanto ele "fala",
|
||||||
|
os sons dos gestos ficam quietos para não embolar. */
|
||||||
|
function falar(texto) {
|
||||||
|
if (!AUDIO.ligado || !window.VozRobo) return;
|
||||||
|
const c = contextoAudio();
|
||||||
|
if (!c) return;
|
||||||
|
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||||
|
try {
|
||||||
|
const dur = window.VozRobo.balbuciar(c, texto, AUDIO.volume * 0.85);
|
||||||
|
AUDIO.mudoAte = performance.now() + dur * 1000;
|
||||||
|
} catch (e) { /* segue sem voz */ }
|
||||||
|
}
|
||||||
|
|
||||||
function tocarSom(gesto) {
|
function tocarSom(gesto) {
|
||||||
if (!AUDIO.ligado) return false;
|
if (!AUDIO.ligado) return false;
|
||||||
|
if (AUDIO.mudoAte && performance.now() < AUDIO.mudoAte) return false;
|
||||||
|
const voz = window.VozRobo && window.VozRobo.sons.indexOf(gesto) >= 0;
|
||||||
const f = SONS[gesto];
|
const f = SONS[gesto];
|
||||||
if (!f) return false;
|
if (!voz && !f) return false;
|
||||||
const c = contextoAudio();
|
const c = contextoAudio();
|
||||||
if (!c) return false;
|
if (!c) return false;
|
||||||
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
|
||||||
try {
|
try {
|
||||||
f(c, c.currentTime + 0.02, AUDIO.volume);
|
if (voz) window.VozRobo.tocar(c, gesto, AUDIO.volume); // voz de robô sintetizada
|
||||||
|
else f(c, c.currentTime + 0.02, AUDIO.volume); // bipes antigos (reserva)
|
||||||
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
|
// aparece no logcat como CarinhaJS: ajuda a diagnosticar audio mudo
|
||||||
console.log("voz: " + gesto + " ctx=" + c.state);
|
console.log("voz: " + gesto + " ctx=" + c.state);
|
||||||
return true;
|
return true;
|
||||||
@@ -1050,7 +1117,9 @@ setTimeout(relatar, 1500);
|
|||||||
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
|
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;}
|
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;
|
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
|
||||||
|
falar(m.texto);
|
||||||
MIC.ultimoSom=performance.now()/1000;trata('acordar');trata(m.gesto);
|
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;}
|
}catch(e){}finally{fetching=false;}
|
||||||
}
|
}
|
||||||
setInterval(poll,1000);poll();
|
setInterval(poll,1000);poll();
|
||||||
@@ -1091,10 +1160,16 @@ if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchPa
|
|||||||
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
|
ouvir('recarregar', () => { if (aparelho) trata('recarregar'); });
|
||||||
ouvir('app_config', (c) => configurarApp(c));
|
ouvir('app_config', (c) => configurarApp(c));
|
||||||
ouvir('conversa', (s) => {
|
ouvir('conversa', (s) => {
|
||||||
|
if (window.MotorRosto) {
|
||||||
|
if (s.ocupado) window.MotorRosto.faixa('digitando');
|
||||||
|
else window.MotorRosto.faixa(null, { so: 'digitando' });
|
||||||
|
}
|
||||||
const m = s.ultima;
|
const m = s.ultima;
|
||||||
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
|
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
|
||||||
ultimaConversa = m.id;
|
ultimaConversa = m.id;
|
||||||
|
falar(m.texto);
|
||||||
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
|
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
|
||||||
|
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
|
||||||
});
|
});
|
||||||
ouvir('reacao', (d) => {
|
ouvir('reacao', (d) => {
|
||||||
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
|
if (!d || d.id === ultimaReacao || Date.now() / 1000 - d.horario > 60) return;
|
||||||
|
|||||||
+1
-1
@@ -7,7 +7,7 @@ import time
|
|||||||
from urllib.request import Request,urlopen
|
from urllib.request import Request,urlopen
|
||||||
from comum import gravar_atomico, ler_json
|
from comum import gravar_atomico, ler_json
|
||||||
|
|
||||||
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo')
|
GESTOS=('piscar','piscadinha','feliz','surpreso','coracao','olhar_esq','olhar_dir','olhar_baixo','acenar','esconder','pensar')
|
||||||
EVENTOS=('som','movimento','saudacao','sozinho')
|
EVENTOS=('som','movimento','saudacao','sozinho')
|
||||||
def validar_intencao(data):
|
def validar_intencao(data):
|
||||||
if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS:
|
if not isinstance(data,dict) or set(data)!= {'gesto'} or data['gesto'] not in GESTOS:
|
||||||
|
|||||||
+3
-2
@@ -20,7 +20,7 @@ PADRAO = dict(preview=False, auto=True, audio=True, volume=0.35,
|
|||||||
somaltolim=0.55, sompausa=20, somcooldown=5,
|
somaltolim=0.55, sompausa=20, somcooldown=5,
|
||||||
movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40,
|
movlim=0.05, movforte=0.12, movcooldown=6, movolhar=40,
|
||||||
economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30,
|
economia=True, brilho=0.7, brilhoRepouso=0.08, repousoTela=30,
|
||||||
boca=True, sobrancelhas=False, videoRTC=True,
|
boca=True, sobrancelhas=False, iris=False, subirBoca=0, distSobrancelha=16, videoRTC=True,
|
||||||
sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False,
|
sensorMic=True, sensorCamera=True, micSono=True, cameraSono=False,
|
||||||
cameraLado='user', videoResolucao='auto', videoFPS=0,
|
cameraLado='user', videoResolucao='auto', videoFPS=0,
|
||||||
rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True)
|
rastrearRosto=True, rastrearMaos=True, seguirRastreamento=True, mostrarRastreamento=True)
|
||||||
@@ -30,7 +30,8 @@ ESCOLHAS = dict(cameraLado=('user', 'environment'),
|
|||||||
LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1),
|
LIMITES = dict(volume=(0, 1), somlim=(0, 1), somganho=(0, 1),
|
||||||
somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
|
somaltolim=(0, 1), sompausa=(1, 3600), somcooldown=(0, 120),
|
||||||
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100),
|
movlim=(0, 1), movforte=(0, 1), movcooldown=(0, 120), movolhar=(0, 100),
|
||||||
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600))
|
brilho=(0.01, 1), brilhoRepouso=(0.01, 1), repousoTela=(5, 3600),
|
||||||
|
subirBoca=(-20, 60), distSobrancelha=(0, 60))
|
||||||
|
|
||||||
|
|
||||||
def validar(dados):
|
def validar(dados):
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop
|
|||||||
$arquivos = @(
|
$arquivos = @(
|
||||||
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
|
'kiosk-server.py', 'comum.py', 'eventos.py', 'controle_pc.py', 'sinalizacao.py', 'servos.py', 'braco.py',
|
||||||
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py',
|
'cerebro.py', 'conversa.py', 'gemini.py', 'treinamento.py', 'figurinhas.py', 'api_config.py',
|
||||||
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'version.json',
|
'carinha.html', 'camera.html', 'reacoes-render.js', 'rastreador-worker.js', 'rosto-motor.js', 'voz-robo.js', 'version.json',
|
||||||
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
|
'obter-rastreamento.py', 'android/app/assets/qualidade-auto.js'
|
||||||
)
|
)
|
||||||
if (Test-Path 'android/carinha-rosto.apk') { $arquivos += 'android/carinha-rosto.apk' }
|
if (Test-Path 'android/carinha-rosto.apk') { $arquivos += 'android/carinha-rosto.apk' }
|
||||||
|
|||||||
+3
-2
@@ -51,7 +51,8 @@ FORMATOS = ["padrao", "redondo", "pilula", "quadrado", "largo"]
|
|||||||
# Espelho dos gestos do carinha.html (para o painel montar os botoes)
|
# Espelho dos gestos do carinha.html (para o painel montar os botoes)
|
||||||
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
|
GESTOS = ["piscar", "piscar2", "olhar_esq", "olhar_dir", "olhar_baixo", "feliz",
|
||||||
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
|
"surpreso", "bravo", "coracao", "triste", "piscadinha", "alegre",
|
||||||
"dormir", "acordar", "aleatorio", "parar"]
|
"dormir", "acordar", "aleatorio", "parar",
|
||||||
|
"esconder", "espiar", "acenar", "pensar", "musica"]
|
||||||
# Ordens internas que o rosto entende mas não viram botão.
|
# Ordens internas que o rosto entende mas não viram botão.
|
||||||
ORDENS_INTERNAS = {"calma"}
|
ORDENS_INTERNAS = {"calma"}
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255], "formato": "padrao"}
|
|||||||
ARQUIVO_APARENCIA = "aparencia.json"
|
ARQUIVO_APARENCIA = "aparencia.json"
|
||||||
|
|
||||||
# Arquivos servidos como estão: o rosto do celular e o rastreamento do painel.
|
# Arquivos servidos como estão: o rosto do celular e o rastreamento do painel.
|
||||||
ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js", "/rosto-motor.js",
|
ESTATICOS = {"/carinha.html", "/camera.html", "/reacoes-render.js", "/rastreador-worker.js", "/rosto-motor.js", "/voz-robo.js",
|
||||||
"/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task",
|
"/vendor/mediapipe/vision_bundle.mjs", "/vendor/mediapipe/face.tflite", "/vendor/mediapipe/hands.task",
|
||||||
"/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm",
|
"/vendor/mediapipe/wasm/vision_wasm_internal.js", "/vendor/mediapipe/wasm/vision_wasm_internal.wasm",
|
||||||
"/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"}
|
"/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.js", "/vendor/mediapipe/wasm/vision_wasm_nosimd_internal.wasm"}
|
||||||
|
|||||||
+254
-10
@@ -118,6 +118,11 @@
|
|||||||
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
|
const h = est.h * p.ry * respira * abre * (1 - amassa * 0.6);
|
||||||
const x = p.cx - w / 2, y = p.cy - h / 2;
|
const x = p.cx - w / 2, y = p.cy - h / 2;
|
||||||
c.fillStyle = css(o.cor);
|
c.fillStyle = css(o.cor);
|
||||||
|
if (o.dormindo && !temConteudo) { // dormindo: "–" (olho fechado)
|
||||||
|
const esp = Math.max(2, p.ry * 0.2);
|
||||||
|
caixa(c, p.cx - w * 0.5, p.cy + h * 0.08 - esp / 2, w, esp, esp / 2); c.fill();
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
|
if (e.coracao > 0.5) { coracao(c, p.cx, p.cy, Math.min(w, h) * 0.55 * respira); return; }
|
||||||
caixa(c, x, y, w, h, est.r * p.ry * respira);
|
caixa(c, x, y, w, h, est.r * p.ry * respira);
|
||||||
c.fill();
|
c.fill();
|
||||||
@@ -132,7 +137,21 @@
|
|||||||
c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill();
|
c.beginPath(); c.ellipse(p.cx, y + h * (1.62 - e.bot), w * 0.75, h * 0.62, 0, 0, Math.PI * 2); c.fill();
|
||||||
c.globalCompositeOperation = 'source-over';
|
c.globalCompositeOperation = 'source-over';
|
||||||
if (temConteudo && fecha < 0.6) conteudo(c, o, p.cx, p.cy, Math.min(w, h) * 0.74);
|
if (temConteudo && fecha < 0.6) conteudo(c, o, p.cx, p.cy, Math.min(w, h) * 0.74);
|
||||||
else if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar
|
else if (o.iris && fecha < 0.6 && e.bot < 0.35) {
|
||||||
|
const R = Math.min(w, h) * 0.3;
|
||||||
|
const ix = Math.max(x + R * 1.1, Math.min(x + w - R * 1.1, p.cx + (p.lx + sacada.x) * w * 0.016));
|
||||||
|
const iy = Math.max(y + R * 1.1, Math.min(y + h - R * 1.1, p.cy + h * 0.04 + (p.ly + sacada.y) * h * 0.016));
|
||||||
|
c.fillStyle = css(o.cor.map((v) => v * 0.42));
|
||||||
|
c.beginPath(); c.arc(ix, iy, R, 0, Math.PI * 2); c.fill();
|
||||||
|
c.globalCompositeOperation = 'destination-out';
|
||||||
|
const pupila = p.ab > 1.05 ? 0.38 : e.bot > 0.2 ? 0.62 : 0.5; // susto contrai, alegria dilata
|
||||||
|
c.beginPath(); c.arc(ix, iy, R * pupila, 0, Math.PI * 2); c.fill();
|
||||||
|
c.globalCompositeOperation = 'source-over';
|
||||||
|
if (est.brilho) {
|
||||||
|
c.fillStyle = 'rgba(245,250,255,0.9)';
|
||||||
|
c.beginPath(); c.arc(ix - R * 0.42, iy - R * 0.42, R * 0.2, 0, Math.PI * 2); c.fill();
|
||||||
|
}
|
||||||
|
} else if (est.brilho && fecha < 0.55 && e.bot < 0.3) { // reflexo que acompanha o olhar
|
||||||
const hx = Math.max(x + w * 0.18, Math.min(x + w * 0.62, x + w * 0.3 + (p.lx + sacada.x) * w * 0.012));
|
const hx = Math.max(x + w * 0.18, Math.min(x + w * 0.62, x + w * 0.3 + (p.lx + sacada.x) * w * 0.012));
|
||||||
const hy = Math.max(y + h * 0.18, Math.min(y + h * 0.5, y + h * 0.26 + (p.ly + sacada.y) * h * 0.012));
|
const hy = Math.max(y + h * 0.18, Math.min(y + h * 0.5, y + h * 0.26 + (p.ly + sacada.y) * h * 0.012));
|
||||||
c.fillStyle = 'rgba(245,250,255,0.9)';
|
c.fillStyle = 'rgba(245,250,255,0.9)';
|
||||||
@@ -205,6 +224,15 @@
|
|||||||
bctx.clearRect(0, 0, colunas, linhas);
|
bctx.clearRect(0, 0, colunas, linhas);
|
||||||
bctx.save(); bctx.scale(f, f);
|
bctx.save(); bctx.scale(f, f);
|
||||||
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i]));
|
olhos.forEach((p, i) => olhoPlano(bctx, o, p, estilo, estado[i]));
|
||||||
|
for (const traco of o.tracos || []) {
|
||||||
|
if (typeof traco.d !== 'string') continue;
|
||||||
|
const caminho = new Path2D(traco.d);
|
||||||
|
bctx.strokeStyle = bctx.fillStyle = css(o.cor);
|
||||||
|
bctx.lineWidth = traco.largura; bctx.lineCap = 'round'; bctx.lineJoin = 'round';
|
||||||
|
if (traco.cheio) bctx.fill(caminho); else bctx.stroke(caminho);
|
||||||
|
}
|
||||||
|
desenharMaos(bctx, o, olhos, tela.height / d);
|
||||||
|
desenharZzz(bctx, o, olhos, 1.6); // em LEDs o Z precisa ser maior para ler
|
||||||
bctx.restore();
|
bctx.restore();
|
||||||
const px = bctx.getImageData(0, 0, colunas, linhas).data;
|
const px = bctx.getImageData(0, 0, colunas, linhas).data;
|
||||||
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
|
ctx.fillStyle = '#000'; ctx.fillRect(0, 0, tela.width, tela.height);
|
||||||
@@ -239,14 +267,221 @@
|
|||||||
ry: p.ry * d,
|
ry: p.ry * d,
|
||||||
}));
|
}));
|
||||||
ctx.clearRect(0, 0, tela.width, tela.height);
|
ctx.clearRect(0, 0, tela.width, tela.height);
|
||||||
if (estilo.pontos) return desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
|
if (estilo.pontos) {
|
||||||
if (estilo.lente) { olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i])); return; }
|
desenharPontos(estilo, o, olhos.map((p) => Object.assign({}, p, { cx: p.cx / d, cy: p.cy / d, ry: p.ry / d })), d);
|
||||||
cctx.clearRect(0, 0, camada.width, camada.height);
|
} else if (estilo.lente) {
|
||||||
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
|
olhos.forEach((p, i) => olhoLente(ctx, o, p, estilo, estado[i]));
|
||||||
ctx.save();
|
desenharMaos(ctx, o, olhos, tela.height);
|
||||||
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
|
desenharZzz(ctx, o, olhos);
|
||||||
ctx.drawImage(camada, 0, 0);
|
} else {
|
||||||
ctx.restore();
|
cctx.clearRect(0, 0, camada.width, camada.height);
|
||||||
|
olhos.forEach((p, i) => olhoPlano(cctx, o, p, estilo, estado[i]));
|
||||||
|
desenharMaos(cctx, o, olhos, tela.height);
|
||||||
|
desenharZzz(cctx, o, olhos);
|
||||||
|
ctx.save();
|
||||||
|
if (estilo.brilho) { ctx.shadowColor = css(o.cor, 0.7); ctx.shadowBlur = Math.min(60, tela.height * 0.05); }
|
||||||
|
ctx.drawImage(camada, 0, 0);
|
||||||
|
ctx.restore();
|
||||||
|
}
|
||||||
|
desenharFaixa(ctx, o, tela.width, tela.height);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ mãos
|
||||||
|
// Mãozinhas na cor dos olhos que sobem da borda da tela. Duração casada com as sequências
|
||||||
|
// de rosto de mesmo nome no carinha.html.
|
||||||
|
const DUR_MAOS = { esconder: 2.6, espiar: 3.2, acenar: 2.4, pensar: 2.8 };
|
||||||
|
const maos = { modo: null, t0: null };
|
||||||
|
const suave = (x) => { x = Math.max(0, Math.min(1, x)); return x * x * (3 - 2 * x); };
|
||||||
|
const faixaDe = (k, a, b) => suave((k - a) / (b - a)); // 0→1 entre a e b
|
||||||
|
|
||||||
|
// Mão de desenho animado: palma, quatro dedos (o do meio maior), polegar para o lado do rosto
|
||||||
|
// e um punho de robô. dobra (0–1) fecha os dedos; mexe (0–1) agita os dedos ao acenar.
|
||||||
|
// O contorno é traçado antes do preenchimento: assim só a borda externa aparece.
|
||||||
|
function capsula(c, x0, y0, x1, y1, r) {
|
||||||
|
const a = Math.atan2(y1 - y0, x1 - x0) + Math.PI / 2;
|
||||||
|
c.moveTo(x0 + Math.cos(a) * r, y0 + Math.sin(a) * r);
|
||||||
|
c.arc(x0, y0, r, a, a + Math.PI);
|
||||||
|
c.arc(x1, y1, r, a + Math.PI, a + 2 * Math.PI);
|
||||||
|
c.closePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formaMao(c, s, dobra, mexe, t, indicador) {
|
||||||
|
c.beginPath();
|
||||||
|
// punho (manga do robô)
|
||||||
|
caixa(c, -0.42 * s, 0.5 * s, 0.84 * s, 0.38 * s, 0.12 * s);
|
||||||
|
// palma
|
||||||
|
caixa(c, -0.5 * s, -0.36 * s, 1.0 * s, 0.95 * s, 0.34 * s);
|
||||||
|
// dedos: x da base, comprimento e abertura
|
||||||
|
// mindinho, anelar, médio e indicador (o indicador fica do lado do polegar)
|
||||||
|
const dedos = [[-0.355, 0.5, -0.16], [-0.12, 0.68, -0.05], [0.12, 0.74, 0.05], [0.355, 0.66, 0.14]];
|
||||||
|
dedos.forEach(([x, comp, abre], i) => {
|
||||||
|
const onda = mexe ? Math.sin(t * 14 + i * 0.9) * 0.06 * mexe : 0;
|
||||||
|
const fecha = indicador && i === 3 ? 0 : dobra; // "pensando": só o indicador esticado
|
||||||
|
const ang = abre + onda, l = comp * (1 - 0.7 * fecha) * s;
|
||||||
|
const bx = x * s, by = -0.26 * s;
|
||||||
|
capsula(c, bx, by, bx + Math.sin(ang) * l, by - Math.cos(ang) * l, 0.115 * s);
|
||||||
|
});
|
||||||
|
// polegar, saindo para o lado
|
||||||
|
capsula(c, 0.38 * s, 0.2 * s, (0.38 + 0.42 * (1 - 0.4 * dobra)) * s, (0.2 - 0.34) * s, 0.13 * s);
|
||||||
|
}
|
||||||
|
|
||||||
|
function mao(c, o, x, y, s, ang, espelho, dobra, mexe, indicador, pulso, costas) {
|
||||||
|
dobra = dobra || 0; mexe = mexe || 0;
|
||||||
|
s *= 0.82;
|
||||||
|
const escura = o.cor.map((v) => v * 0.34);
|
||||||
|
c.save();
|
||||||
|
c.translate(x, y); c.rotate(ang); c.scale(espelho, 1);
|
||||||
|
if (pulso) c.translate(0, -0.7 * s); // (x, y) é o punho: gira como uma mão de verdade
|
||||||
|
c.lineJoin = 'round'; c.lineCap = 'round';
|
||||||
|
formaMao(c, s, dobra, mexe, o.t || 0, indicador);
|
||||||
|
c.strokeStyle = css(o.cor); c.lineWidth = s * 0.17; c.stroke(); // contorno (metade some sob o preenchimento)
|
||||||
|
c.fillStyle = css(escura); c.fill();
|
||||||
|
// detalhes por dentro, finos: linha da palma, vincos dos dedos e a costura do punho
|
||||||
|
c.strokeStyle = css(o.cor, 0.55); c.lineWidth = s * 0.045;
|
||||||
|
if (costas) { // dorso da mão: nós dos dedos em vez da linha da palma
|
||||||
|
for (const nx of [-0.355, -0.12, 0.12, 0.355]) { c.beginPath(); c.arc(nx * s, -0.2 * s, 0.075 * s, Math.PI * 1.15, Math.PI * 1.85); c.stroke(); }
|
||||||
|
} else {
|
||||||
|
c.beginPath(); c.moveTo(-0.3 * s, 0.12 * s); c.quadraticCurveTo(0.02 * s, 0.26 * s, 0.3 * s, 0.06 * s); c.stroke();
|
||||||
|
}
|
||||||
|
c.beginPath(); c.moveTo(-0.36 * s, 0.6 * s); c.lineTo(0.36 * s, 0.6 * s); c.stroke();
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sobe com um leve "passa e volta" no fim (como um braço que freia).
|
||||||
|
const voltinha = (x) => { x = Math.max(0, Math.min(1, x)); const k = 1.6; return 1 + (k + 1) * Math.pow(x - 1, 3) + k * Math.pow(x - 1, 2); };
|
||||||
|
|
||||||
|
function desenharMaos(c, o, olhos, H) {
|
||||||
|
if (!maos.modo) return;
|
||||||
|
if (maos.t0 === null) maos.t0 = o.t;
|
||||||
|
const k = (o.t - maos.t0) / DUR_MAOS[maos.modo];
|
||||||
|
if (k >= 1) { maos.modo = null; return; }
|
||||||
|
const [e, d] = olhos.slice().sort((a, b) => a.cx - b.cx); // olho da esquerda e da direita na tela
|
||||||
|
const s = e.ry * 0.95, fora = H + s * 1.8;
|
||||||
|
if (maos.modo === 'esconder' || maos.modo === 'espiar') {
|
||||||
|
// Cada mão vem do canto de baixo, por fora, num arco, girando até ficar inclinada para dentro.
|
||||||
|
// A da direita chega um pouco depois: duas mãos nunca se mexem igualzinho.
|
||||||
|
const cobrir = (olho, lado, atraso) => {
|
||||||
|
const ida = voltinha((k - atraso) / 0.24), volta = faixaDe(k, 0.8 + atraso * 0.5, 1);
|
||||||
|
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta); // 0 = fora da tela, 1 = cobrindo
|
||||||
|
const alvoX = olho.cx - lado * olho.ry * 0.05, alvoY = olho.cy + olho.ry * 0.38;
|
||||||
|
const x = alvoX - lado * olho.ry * 1.6 * (1 - Math.min(1, v));
|
||||||
|
const y = fora + (alvoY - fora) * v;
|
||||||
|
const respira = Math.sin(o.t * 3 + lado) * s * 0.03 * Math.min(1, v);
|
||||||
|
const ang = lado * (0.42 + 0.55 * (1 - Math.min(1, v))); // gira enquanto sobe, termina a ~24°
|
||||||
|
return { x, y: y + respira, ang };
|
||||||
|
};
|
||||||
|
const pe = cobrir(e, 1, 0), pd = cobrir(d, -1, 0.05);
|
||||||
|
if (maos.modo === 'espiar') {
|
||||||
|
// a mão da esquerda gira pelo punho para fora, como uma porta, e o olho aparece
|
||||||
|
const abre = faixaDe(k, 0.3, 0.44) - faixaDe(k, 0.64, 0.76);
|
||||||
|
pe.ang -= abre * 1.05; pe.x -= abre * e.ry * 0.35; pe.y += abre * e.ry * 0.2;
|
||||||
|
}
|
||||||
|
const treme = Math.sin(o.t * 22) * s * 0.012; // tímido: tremidinha
|
||||||
|
// dorso virado para quem olha: polegar para fora, perto da bochecha
|
||||||
|
mao(c, o, pe.x + treme, pe.y, s * 1.1, pe.ang, -1, 0, 0, false, true, true);
|
||||||
|
mao(c, o, pd.x - treme, pd.y, s * 1.1, pd.ang, 1, 0, 0, false, true, true);
|
||||||
|
} else if (maos.modo === 'acenar') {
|
||||||
|
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
|
||||||
|
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
|
||||||
|
const x = d.cx + d.ry * 2.2 + d.ry * 0.6 * (1 - Math.min(1, v)), y0 = d.cy + d.ry * 1.55;
|
||||||
|
const y = fora + (y0 - fora) * v;
|
||||||
|
const balanco = k > 0.22 && k < 0.82 ? Math.sin((k - 0.22) * Math.PI * 7) * 0.32 : 0;
|
||||||
|
mao(c, o, x, y, s * 0.95, -0.18 + balanco, -1, 0, 1, false, true);
|
||||||
|
} else if (maos.modo === 'pensar') {
|
||||||
|
const ida = voltinha(k / 0.22), volta = faixaDe(k, 0.82, 1);
|
||||||
|
const v = Math.max(0, Math.min(1.08, ida)) * (1 - volta);
|
||||||
|
const x = (e.cx + d.cx) / 2 + e.ry * 1.05, y0 = e.cy + e.ry * 1.4;
|
||||||
|
const y = fora + (y0 - fora) * v + Math.sin(o.t * 9) * s * 0.03;
|
||||||
|
mao(c, o, x, y, s * 0.85, -0.5, -1, 0.95, 0, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------------------ Zzz
|
||||||
|
function desenharZzz(c, o, olhos, fator) {
|
||||||
|
fator = fator || 1;
|
||||||
|
if (!o.dormindo) return;
|
||||||
|
const d = olhos.slice().sort((a, b) => a.cx - b.cx)[1]; // olho da direita na tela
|
||||||
|
const x0 = d.cx + d.ry * 0.7, y0 = d.cy - d.ry * 0.55;
|
||||||
|
c.save();
|
||||||
|
c.strokeStyle = css(o.cor); c.lineCap = 'round'; c.lineJoin = 'round';
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const k = ((o.t || 0) * 0.32 + i / 3) % 1; // cada Z num ponto do ciclo
|
||||||
|
const tam = d.ry * (0.28 + 0.3 * k) * fator;
|
||||||
|
const x = x0 + d.ry * 0.7 * k + Math.sin(k * Math.PI * 2) * d.ry * 0.08, y = y0 - d.ry * 1.7 * k;
|
||||||
|
c.globalAlpha = Math.min(1, k / 0.15) * (1 - k);
|
||||||
|
c.lineWidth = Math.max(1.5, tam * 0.2);
|
||||||
|
c.beginPath();
|
||||||
|
c.moveTo(x - tam / 2, y - tam / 2); c.lineTo(x + tam / 2, y - tam / 2);
|
||||||
|
c.lineTo(x - tam / 2, y + tam / 2); c.lineTo(x + tam / 2, y + tam / 2);
|
||||||
|
c.stroke();
|
||||||
|
}
|
||||||
|
c.restore();
|
||||||
|
}
|
||||||
|
|
||||||
|
// --------------------------------------------------------- faixa de baixo
|
||||||
|
// A parte de baixo da tela vira palco: legenda da conversa, pensando, equalizador e carga.
|
||||||
|
const faixa = { modo: null, texto: '', t0: null, dur: 0 };
|
||||||
|
|
||||||
|
function definirFaixa(modo, opcoes) {
|
||||||
|
opcoes = opcoes || {};
|
||||||
|
if (!modo) {
|
||||||
|
if (!opcoes.so || faixa.modo === opcoes.so) faixa.modo = null; // so: só apaga esse modo
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
faixa.modo = modo; faixa.t0 = performance.now() / 1000; faixa.texto = String(opcoes.texto || '').replace(/\s+/g, ' ').trim().slice(0, 400);
|
||||||
|
faixa.dur = opcoes.segundos || (modo === 'legenda' ? Math.max(3, Math.min(14, faixa.texto.length / 14 + 2.5)) : modo === 'digitando' ? 20 : 2.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
function desenharFaixa(c, o, W, H) {
|
||||||
|
const y = H * 0.955, fonte = Math.round(H * 0.042);
|
||||||
|
c.save();
|
||||||
|
c.shadowColor = css(o.cor, 0.6); c.shadowBlur = H * 0.02;
|
||||||
|
if (o.carga && o.carga.carregando && typeof o.carga.nivel === 'number') { // carregando: canto direito
|
||||||
|
const n = Math.max(0, Math.min(100, o.carga.nivel)), bw = H * 0.075, bh = H * 0.036, bx = W - H * 0.13, by = y - bh / 2;
|
||||||
|
c.globalAlpha = 0.55 + 0.25 * Math.sin(o.t * 2.5);
|
||||||
|
c.strokeStyle = css(o.cor); c.lineWidth = Math.max(1, H * 0.005);
|
||||||
|
caixa(c, bx, by, bw, bh, bh * 0.25); c.stroke();
|
||||||
|
c.fillStyle = css(o.cor); c.fillRect(bx + bw, y - bh * 0.2, bh * 0.15, bh * 0.4);
|
||||||
|
c.fillRect(bx + bh * 0.12, by + bh * 0.15, (bw - bh * 0.24) * n / 100, bh * 0.7);
|
||||||
|
c.font = '600 ' + Math.round(fonte * 0.8) + 'px system-ui,sans-serif'; c.textAlign = 'right'; c.textBaseline = 'middle';
|
||||||
|
const tx = bx - H * 0.012;
|
||||||
|
c.fillText(Math.round(n) + '%', tx, y);
|
||||||
|
const rx = tx - c.measureText(Math.round(n) + '%').width - H * 0.022, r = H * 0.016; // raio desenhado na cor dos olhos
|
||||||
|
c.beginPath(); c.moveTo(rx + r * 0.3, y - r); c.lineTo(rx - r * 0.5, y + r * 0.15); c.lineTo(rx, y + r * 0.15);
|
||||||
|
c.lineTo(rx - r * 0.3, y + r); c.lineTo(rx + r * 0.5, y - r * 0.15); c.lineTo(rx, y - r * 0.15); c.closePath(); c.fill();
|
||||||
|
c.globalAlpha = 1;
|
||||||
|
}
|
||||||
|
if (faixa.modo) {
|
||||||
|
if (faixa.t0 === null) faixa.t0 = o.t;
|
||||||
|
const k = (o.t - faixa.t0) / faixa.dur;
|
||||||
|
if (k >= 1) faixa.modo = null;
|
||||||
|
else {
|
||||||
|
const some = Math.min(1, (faixa.dur - (o.t - faixa.t0)) / 0.4);
|
||||||
|
c.globalAlpha = Math.max(0, some);
|
||||||
|
c.fillStyle = css(o.cor);
|
||||||
|
if (faixa.modo === 'legenda') {
|
||||||
|
// efeito de digitação; se não couber, mostra o final do que já foi "dito"
|
||||||
|
let txt = faixa.texto.slice(0, Math.floor((o.t - faixa.t0) * 30));
|
||||||
|
c.font = '500 ' + fonte + 'px system-ui,sans-serif'; c.textAlign = 'center'; c.textBaseline = 'middle';
|
||||||
|
const max = W * 0.8;
|
||||||
|
while (txt.length > 1 && c.measureText(txt).width > max) txt = txt.slice(1);
|
||||||
|
c.fillText(txt, W / 2, y);
|
||||||
|
} else if (faixa.modo === 'digitando') {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
const pulo = Math.max(0, Math.sin(o.t * 7 - i * 0.7)) * H * 0.012;
|
||||||
|
c.beginPath(); c.arc(W / 2 + (i - 1) * H * 0.035, y - pulo, H * 0.011, 0, Math.PI * 2); c.fill();
|
||||||
|
}
|
||||||
|
} else if (faixa.modo === 'equalizador') {
|
||||||
|
const n = 15, larg = H * 0.016, passo = H * 0.026;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const a = 0.25 + 0.75 * Math.abs(Math.sin(o.t * (5 + i % 4) + i * 1.3)) * Math.sin(Math.PI * (i + 0.5) / n);
|
||||||
|
const h = H * 0.04 * a;
|
||||||
|
caixa(c, W / 2 + (i - (n - 1) / 2) * passo - larg / 2, H * 0.988 - h, larg, h, larg / 2); c.fill();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
c.restore();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -285,5 +520,14 @@
|
|||||||
|
|
||||||
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
|
function esconder() { if (tela) tela.style.display = 'none'; antes = 0; }
|
||||||
|
|
||||||
window.MotorRosto = { desenhar, retrato, esconder, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
|
window.MotorRosto = {
|
||||||
|
desenhar, retrato, esconder,
|
||||||
|
/** Metade da altura do olho (em frações de RY) para o estilo/formato: posiciona as sobrancelhas. */
|
||||||
|
metadeAltura: (estilo, formato) => {
|
||||||
|
const base = ESTILOS[estilo]; if (!base) return 1;
|
||||||
|
if (base.lente) return base.w * 0.5 * 1.12;
|
||||||
|
return ((!base.lente && FORMATOS[formato]) || base).h / 2;
|
||||||
|
},
|
||||||
|
maos: (modo) => { if (DUR_MAOS[modo]) { maos.modo = modo; maos.t0 = performance.now() / 1000; } },
|
||||||
|
faixa: definirFaixa, estilos: Object.keys(ESTILOS), formatos: Object.keys(FORMATOS) };
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -106,6 +106,10 @@ class ServidorTest(unittest.TestCase):
|
|||||||
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'rm -rf'})[0], 400)
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': 'rm -rf'})[0], 400)
|
||||||
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400)
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'rosto', 'valor': 'nada'})[0], 400)
|
||||||
self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo'])
|
self.assertEqual(kiosk.FILA.ordens(), ['feliz', 'rosto miudo'])
|
||||||
|
for novo in ('acenar', 'esconder', 'espiar', 'pensar', 'musica'): # gestos com mãos e faixa
|
||||||
|
self.assertEqual(self.pedir('POST', '/comando', {'tipo': 'gesto', 'valor': novo})[0], 200)
|
||||||
|
kiosk.FILA.limpar()
|
||||||
|
kiosk.FILA.adicionar('feliz'); kiosk.FILA.adicionar('rosto miudo')
|
||||||
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], ['feliz', 'rosto miudo'])
|
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], ['feliz', 'rosto miudo'])
|
||||||
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], [])
|
self.assertEqual(self.pedir('GET', '/fila?aparelho=1')[1], [])
|
||||||
|
|
||||||
|
|||||||
+188
@@ -0,0 +1,188 @@
|
|||||||
|
/* voz-robo.js — voz sintetizada do Carinha, no estilo dos robozinhos de cinema.
|
||||||
|
*
|
||||||
|
* Tudo é gerado na hora com Web Audio: nenhuma gravação, nenhum arquivo de terceiros.
|
||||||
|
* Cada som é uma sequência de "sílabas": um oscilador que desliza de um tom a outro, com vibrato,
|
||||||
|
* passando por um filtro de formante (a "vogal": o→i faz o "uou"). Motores das mãos viram ruído
|
||||||
|
* filtrado, e a resposta da conversa vira um balbucio que sobe no fim quando é pergunta.
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
let ruido = null; // buffer de ruído reaproveitado
|
||||||
|
const TOM = 1.25; // todas as sílabas 25% acima do tom escrito
|
||||||
|
const saidas = new WeakMap(); // AudioContext → nó de saída (compressor)
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Cadeia de "brilho metálico" (criada uma vez por AudioContext):
|
||||||
|
* voz → ressonância de lata fina (agudo, ~850 Hz) → saturação leve → compressor → saída
|
||||||
|
* Nada grave: zumbido e ressonância graves deixavam a voz com cara de sapo.
|
||||||
|
*/
|
||||||
|
function saida(c) {
|
||||||
|
if (saidas.has(c)) return saidas.get(c);
|
||||||
|
const entrada = c.createGain();
|
||||||
|
const seca = c.createGain(); seca.gain.value = 0.8;
|
||||||
|
const atraso = c.createDelay(0.05); atraso.delayTime.value = 0.00118;
|
||||||
|
const volta = c.createGain(); volta.gain.value = 0.42;
|
||||||
|
const molhada = c.createGain(); molhada.gain.value = 0.35;
|
||||||
|
entrada.connect(seca);
|
||||||
|
entrada.connect(atraso); atraso.connect(volta); volta.connect(atraso); atraso.connect(molhada);
|
||||||
|
const satura = c.createWaveShaper();
|
||||||
|
const n = 2048, curva = new Float32Array(n);
|
||||||
|
for (let i = 0; i < n; i++) { const x = (i / (n - 1)) * 2 - 1; curva[i] = Math.tanh(x * 1.3); }
|
||||||
|
satura.curve = curva;
|
||||||
|
seca.connect(satura); molhada.connect(satura);
|
||||||
|
const brilho = c.createBiquadFilter(); // realce leve nos agudos: "metal" limpo
|
||||||
|
brilho.type = 'peaking'; brilho.frequency.value = 3200; brilho.Q.value = 1.2; brilho.gain.value = 4;
|
||||||
|
const comp = c.createDynamicsCompressor();
|
||||||
|
comp.threshold.value = -16; comp.ratio.value = 4; comp.attack.value = 0.003; comp.release.value = 0.15;
|
||||||
|
const passaBaixa = c.createBiquadFilter();
|
||||||
|
passaBaixa.type = 'lowpass'; passaBaixa.frequency.value = 8000;
|
||||||
|
const volume = c.createGain(); volume.gain.value = 0.8;
|
||||||
|
satura.connect(brilho); brilho.connect(comp); comp.connect(passaBaixa); passaBaixa.connect(volume); volume.connect(c.destination);
|
||||||
|
saidas.set(c, entrada);
|
||||||
|
return entrada;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uma sílaba. de/para: tom em Hz; vogal: [formante inicial, final] em Hz;
|
||||||
|
* vib: [Hz, profundidade em Hz]; forma: tipo de onda.
|
||||||
|
*/
|
||||||
|
function silaba(c, t0, dur, de, para, vol, op) {
|
||||||
|
op = op || {};
|
||||||
|
vol *= 2.4; // compensa a energia que o filtro de formante tira
|
||||||
|
de *= TOM; para *= TOM; // voz de robozinho: aguda
|
||||||
|
const osc = c.createOscillator();
|
||||||
|
osc.type = op.forma || 'sawtooth';
|
||||||
|
osc.frequency.setValueAtTime(de, t0);
|
||||||
|
osc.frequency.exponentialRampToValueAtTime(Math.max(20, para), t0 + dur);
|
||||||
|
if (op.vib) {
|
||||||
|
const lfo = c.createOscillator(), prof = c.createGain();
|
||||||
|
lfo.frequency.value = op.vib[0]; prof.gain.value = op.vib[1];
|
||||||
|
lfo.connect(prof); prof.connect(osc.frequency);
|
||||||
|
lfo.start(t0); lfo.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
// FM harmônico e leve: brilho de metal sem aspereza
|
||||||
|
const mod = c.createOscillator(), modProf = c.createGain();
|
||||||
|
mod.type = 'sine';
|
||||||
|
mod.frequency.setValueAtTime(de * 2, t0);
|
||||||
|
mod.frequency.exponentialRampToValueAtTime(Math.max(20, para * 2), t0 + dur);
|
||||||
|
modProf.gain.setValueAtTime(de * (op.metal ?? 0.22), t0);
|
||||||
|
modProf.gain.exponentialRampToValueAtTime(Math.max(1, para * (op.metal ?? 0.22)), t0 + dur);
|
||||||
|
mod.connect(modProf); modProf.connect(osc.frequency);
|
||||||
|
mod.start(t0); mod.stop(t0 + dur + 0.05);
|
||||||
|
// corpo: uma senoide uma oitava abaixo deixa a voz menos "apito"
|
||||||
|
const sub = c.createOscillator();
|
||||||
|
sub.type = 'sine';
|
||||||
|
sub.frequency.setValueAtTime(de / 2, t0);
|
||||||
|
sub.frequency.exponentialRampToValueAtTime(Math.max(20, para / 2), t0 + dur);
|
||||||
|
const vogal = op.vogal || [900, 1500];
|
||||||
|
const filtro = c.createBiquadFilter();
|
||||||
|
filtro.type = 'bandpass'; filtro.Q.value = op.q || 5;
|
||||||
|
filtro.frequency.setValueAtTime(vogal[0], t0);
|
||||||
|
filtro.frequency.exponentialRampToValueAtTime(vogal[1], t0 + dur);
|
||||||
|
const g = c.createGain(), gs = c.createGain();
|
||||||
|
const ataque = Math.min(0.02, dur * 0.25);
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(vol, t0 + ataque);
|
||||||
|
g.gain.setValueAtTime(vol, t0 + dur * 0.7);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||||
|
gs.gain.value = 0.12; // pouca oitava de baixo: grave demais vira coaxar
|
||||||
|
osc.connect(filtro); sub.connect(gs); gs.connect(filtro); filtro.connect(g); g.connect(saida(c));
|
||||||
|
osc.start(t0); sub.start(t0);
|
||||||
|
osc.stop(t0 + dur + 0.05); sub.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function bufferRuido(c) {
|
||||||
|
if (ruido && ruido.sampleRate === c.sampleRate) return ruido;
|
||||||
|
ruido = c.createBuffer(1, c.sampleRate, c.sampleRate);
|
||||||
|
const d = ruido.getChannelData(0);
|
||||||
|
for (let i = 0; i < d.length; i++) d[i] = Math.random() * 2 - 1;
|
||||||
|
return ruido;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Motorzinho: ruído filtrado que sobe de tom, com um tremor mecânico. */
|
||||||
|
function servo(c, t0, dur, vol, sobe) {
|
||||||
|
const fonte = c.createBufferSource();
|
||||||
|
fonte.buffer = bufferRuido(c); fonte.loop = true;
|
||||||
|
const f = c.createBiquadFilter();
|
||||||
|
f.type = 'bandpass'; f.Q.value = 9;
|
||||||
|
f.frequency.setValueAtTime(sobe === false ? 1500 : 500, t0);
|
||||||
|
f.frequency.exponentialRampToValueAtTime(sobe === false ? 500 : 1500, t0 + dur);
|
||||||
|
const g = c.createGain(), trem = c.createOscillator(), tg = c.createGain();
|
||||||
|
trem.frequency.value = 38; tg.gain.value = vol * 0.35;
|
||||||
|
trem.connect(tg); tg.connect(g.gain);
|
||||||
|
g.gain.setValueAtTime(0.0001, t0);
|
||||||
|
g.gain.exponentialRampToValueAtTime(vol * 0.6, t0 + 0.05);
|
||||||
|
g.gain.setValueAtTime(vol * 0.6, t0 + dur - 0.06);
|
||||||
|
g.gain.exponentialRampToValueAtTime(0.0001, t0 + dur);
|
||||||
|
fonte.connect(f); f.connect(g); g.connect(saida(c));
|
||||||
|
fonte.start(t0); trem.start(t0);
|
||||||
|
fonte.stop(t0 + dur + 0.05); trem.stop(t0 + dur + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clique(c, t0, vol) {
|
||||||
|
const fonte = c.createBufferSource();
|
||||||
|
fonte.buffer = bufferRuido(c);
|
||||||
|
const f = c.createBiquadFilter(); f.type = 'highpass'; f.frequency.value = 3000;
|
||||||
|
const g = c.createGain();
|
||||||
|
g.gain.setValueAtTime(vol * 0.5, t0); g.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.03);
|
||||||
|
fonte.connect(f); f.connect(g); g.connect(saida(c));
|
||||||
|
fonte.start(t0); fonte.stop(t0 + 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
const O = [500, 900], I = [1800, 2400], U = [350, 700], A = [900, 1300]; // vogais (formantes)
|
||||||
|
|
||||||
|
// Cada som: (c, t, v) → agenda as sílabas. Volumes relativos ao volume do app.
|
||||||
|
const SONS = {
|
||||||
|
feliz: (c, t, v) => { silaba(c, t, 0.12, 620, 900, v, { vogal: [1200, 2000] }); silaba(c, t + 0.13, 0.18, 900, 1250, v, { vogal: I, vib: [9, 18] }); },
|
||||||
|
alegre: (c, t, v) => [560, 700, 860, 1100].forEach((f, i) => silaba(c, t + i * 0.085, 0.09, f, f * 1.25, v * 0.85, { vogal: I, forma: 'square' })),
|
||||||
|
coracao: (c, t, v) => { silaba(c, t, 0.55, 760, 560, v, { vogal: [800, 1300], vib: [7, 28] }); },
|
||||||
|
piscadinha: (c, t, v) => { clique(c, t, v); silaba(c, t + 0.04, 0.08, 1000, 1300, v * 0.6, { forma: 'square', vogal: I }); },
|
||||||
|
surpreso: (c, t, v) => { silaba(c, t, 0.22, 260, 1100, v, { vogal: [450, 2000] }); silaba(c, t + 0.22, 0.12, 1100, 820, v * 0.8, { vogal: I }); },
|
||||||
|
bravo: (c, t, v) => [0, 0.13, 0.26].forEach((d, i) => silaba(c, t + d, 0.1, 480 - i * 30, 380 - i * 30, v * 0.85, { forma: 'square', vogal: [1300, 900] })), // bipes zangados
|
||||||
|
triste: (c, t, v) => { silaba(c, t, 0.65, 640, 380, v * 0.85, { vogal: [1200, 750], vib: [5, 14] }); },
|
||||||
|
dormir: (c, t, v) => { silaba(c, t, 0.35, 460, 760, v * 0.7, { vogal: [900, 1500] }); silaba(c, t + 0.36, 0.75, 760, 340, v * 0.6, { vogal: [1500, 700], vib: [4, 8] }); },
|
||||||
|
acordar: (c, t, v) => { servo(c, t, 0.25, v * 0.5); [300, 450, 700].forEach((f, i) => silaba(c, t + 0.22 + i * 0.1, 0.1, f, f * 1.2, v * 0.8, { vogal: I, forma: 'square' })); silaba(c, t + 0.56, 0.2, 700, 1050, v, { vogal: [1200, 2200], vib: [8, 15] }); },
|
||||||
|
acenar: (c, t, v) => { servo(c, t, 0.3, v * 0.45); silaba(c, t + 0.28, 0.13, 520, 820, v, { vogal: [1000, 1900] }); silaba(c, t + 0.44, 0.2, 760, 1080, v, { vogal: I, vib: [9, 20] }); },
|
||||||
|
esconder: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 0.1, 0.08, 950, 1350, v * 0.8, { vogal: I }); for (let i = 0; i < 5; i++) silaba(c, t + 0.6 + i * 0.09, 0.05, 1200 + (i % 2) * 180, 1300, v * 0.35, { forma: 'square', vogal: I }); servo(c, t + 2.05, 0.4, v * 0.45, false); },
|
||||||
|
espiar: (c, t, v) => { servo(c, t, 0.45, v * 0.5); silaba(c, t + 1.0, 0.3, 480, 760, v * 0.9, { vogal: [600, 1700] }); silaba(c, t + 2.0, 0.16, 800, 1150, v, { vogal: I }); },
|
||||||
|
pensar: (c, t, v) => { servo(c, t, 0.35, v * 0.4); silaba(c, t + 0.35, 0.5, 520, 640, v * 0.7, { vogal: [900, 1600], vib: [5, 10] }); [0.95, 1.12].forEach((d) => silaba(c, t + d, 0.05, 1500, 1600, v * 0.35, { forma: 'square', vogal: I })); }, // "hmm?" e bipinhos
|
||||||
|
musica: (c, t, v) => [523, 659, 784, 659, 880, 784, 659, 1047].forEach((f, i) => silaba(c, t + i * 0.26, 0.2, f, f * 1.02, v * 0.6, { forma: 'triangle', vogal: A, vib: [6, 5] })),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Gerador previsível: a mesma frase sempre "soa" igual.
|
||||||
|
function semente(texto) {
|
||||||
|
let h = 2166136261;
|
||||||
|
for (let i = 0; i < texto.length; i++) h = Math.imul(h ^ texto.charCodeAt(i), 16777619);
|
||||||
|
return () => { h = Math.imul(h ^ (h >>> 15), 2246822507); h = Math.imul(h ^ (h >>> 13), 3266489909); return ((h ^= h >>> 16) >>> 0) / 4294967296; };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Balbucio de robô para uma resposta: ~1 sílaba a cada 6 letras, no máximo 16. */
|
||||||
|
function balbuciar(c, texto, v) {
|
||||||
|
texto = String(texto || '').trim();
|
||||||
|
if (!texto) return 0;
|
||||||
|
const rnd = semente(texto), n = Math.max(2, Math.min(16, Math.ceil(texto.length / 6)));
|
||||||
|
const pergunta = /\?\s*$/.test(texto), exclama = /!\s*$/.test(texto);
|
||||||
|
const vogais = [O, I, A, U];
|
||||||
|
let t = c.currentTime + 0.03, tom = 520 + rnd() * 180;
|
||||||
|
for (let i = 0; i < n; i++) {
|
||||||
|
const ultima = i === n - 1;
|
||||||
|
const dur = 0.07 + rnd() * 0.1;
|
||||||
|
let para = tom * (0.8 + rnd() * 0.5);
|
||||||
|
if (ultima) para = pergunta ? tom * 1.6 : exclama ? tom * 1.35 : tom * 0.7;
|
||||||
|
silaba(c, t, ultima ? dur + 0.08 : dur, tom, para, v * (0.65 + rnd() * 0.3), { vogal: vogais[Math.floor(rnd() * 4)], vib: rnd() < 0.3 ? [8, 14] : null, forma: rnd() < 0.25 ? 'square' : 'sawtooth' });
|
||||||
|
t += dur + 0.025 + rnd() * 0.05;
|
||||||
|
tom = Math.max(330, Math.min(1000, para * (0.9 + rnd() * 0.25)));
|
||||||
|
}
|
||||||
|
return t - c.currentTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
function tocar(c, nome, v) {
|
||||||
|
const f = SONS[nome];
|
||||||
|
if (!f || !c) return false;
|
||||||
|
f(c, c.currentTime + 0.02, v);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.VozRobo = { tocar, balbuciar, servo: (c, dur, v) => servo(c, c.currentTime + 0.01, dur, v), sons: Object.keys(SONS) };
|
||||||
|
})();
|
||||||
@@ -11,6 +11,7 @@
|
|||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
<!-- mesmo motor de rosto do celular, usado nas miniaturas das reações -->
|
<!-- mesmo motor de rosto do celular, usado nas miniaturas das reações -->
|
||||||
<script src="/rosto-motor.js"></script>
|
<script src="/rosto-motor.js"></script>
|
||||||
|
<script src="/voz-robo.js"></script>
|
||||||
<script type="module" src="/src/main.ts"></script>
|
<script type="module" src="/src/main.ts"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ export const GRUPOS: { titulo: string; descricao: string; campos: Campo[] }[] =
|
|||||||
{ chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
|
{ chave: 'auto', rotulo: 'Gestos espontâneos', ajuda: 'Pisca e olha em volta sem comando', tipo: 'toggle' },
|
||||||
{ chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' },
|
{ chave: 'boca', rotulo: 'Mostrar boca', tipo: 'toggle' },
|
||||||
{ chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' },
|
{ chave: 'sobrancelhas', rotulo: 'Mostrar sobrancelhas', tipo: 'toggle' },
|
||||||
|
{ chave: 'iris', rotulo: 'Mostrar íris', ajuda: 'Pupila que acompanha o olhar (Viva, Bloco e Matriz)', tipo: 'toggle' },
|
||||||
|
{ chave: 'subirBoca', rotulo: 'Subir a boca', tipo: 'slider', min: -20, max: 60, passo: 2 },
|
||||||
|
{ chave: 'distSobrancelha', rotulo: 'Distância das sobrancelhas', tipo: 'slider', min: 0, max: 60, passo: 2 },
|
||||||
{ chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' },
|
{ chave: 'audio', rotulo: 'Sons no celular', ajuda: 'Bipes do próprio robô', tipo: 'toggle' },
|
||||||
{ chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 },
|
{ chave: 'volume', rotulo: 'Volume dos sons', tipo: 'slider', min: 0, max: 1, passo: 0.05 },
|
||||||
{ chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
|
{ chave: 'brilho', rotulo: 'Brilho normal', tipo: 'slider', min: 0.01, max: 1, passo: 0.01 },
|
||||||
|
|||||||
@@ -20,17 +20,34 @@ export const GESTOS_INFO: Record<string, { nome: string; emoji: string }> = {
|
|||||||
dormir: { nome: 'Dormir', emoji: '😴' },
|
dormir: { nome: 'Dormir', emoji: '😴' },
|
||||||
acordar: { nome: 'Acordar', emoji: '🌅' },
|
acordar: { nome: 'Acordar', emoji: '🌅' },
|
||||||
aleatorio: { nome: 'Aleatório', emoji: '🎲' },
|
aleatorio: { nome: 'Aleatório', emoji: '🎲' },
|
||||||
|
acenar: { nome: 'Acenar', emoji: '👋' },
|
||||||
|
esconder: { nome: 'Esconder o rosto', emoji: '🙈' },
|
||||||
|
espiar: { nome: 'Espiar', emoji: '🫣' },
|
||||||
|
pensar: { nome: 'Pensar', emoji: '🤔' },
|
||||||
|
musica: { nome: 'Dançar', emoji: '🎵' },
|
||||||
};
|
};
|
||||||
|
|
||||||
export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
|
export const gestosDisponiveis = () => estado.gestos.filter((g) => g in GESTOS_INFO);
|
||||||
|
|
||||||
const CHAVE_FAVORITOS = 'carinha_favoritos';
|
const CHAVE_FAVORITOS = 'carinha_favoritos';
|
||||||
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'piscadinha', 'surpreso', 'triste', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar'];
|
const CHAVE_MAOS = 'carinha_favoritos_maos'; // marca que os gestos com mãos já entraram
|
||||||
|
/** Até 12 favoritos; as teclas 1 a 9 valem para os nove primeiros. */
|
||||||
|
export const MAX_FAVORITOS = 12;
|
||||||
|
const GESTOS_MAOS = ['acenar', 'esconder', 'espiar', 'pensar', 'musica'];
|
||||||
|
const PADRAO_FAVORITOS = ['feliz', 'coracao', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'piscadinha', 'surpreso', 'triste', 'dormir', 'acordar'];
|
||||||
|
|
||||||
function lerFavoritos(): string[] {
|
function lerFavoritos(): string[] {
|
||||||
try {
|
try {
|
||||||
const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null');
|
const v = JSON.parse(localStorage.getItem(CHAVE_FAVORITOS) || 'null');
|
||||||
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) return v.slice(0, 9);
|
if (Array.isArray(v) && v.every((x) => typeof x === 'string')) {
|
||||||
|
if (localStorage.getItem(CHAVE_MAOS)) return v.slice(0, MAX_FAVORITOS);
|
||||||
|
// Quem já tinha favoritos salvos ganha os gestos com mãos logo depois dos dois primeiros.
|
||||||
|
const novos = GESTOS_MAOS.filter((g) => !v.includes(g));
|
||||||
|
const lista = [...v.slice(0, 2), ...novos, ...v.slice(2)].slice(0, MAX_FAVORITOS);
|
||||||
|
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(lista));
|
||||||
|
localStorage.setItem(CHAVE_MAOS, '1');
|
||||||
|
return lista;
|
||||||
|
}
|
||||||
} catch {
|
} catch {
|
||||||
/* sem armazenamento */
|
/* sem armazenamento */
|
||||||
}
|
}
|
||||||
@@ -40,9 +57,10 @@ function lerFavoritos(): string[] {
|
|||||||
class Favoritos {
|
class Favoritos {
|
||||||
lista = $state<string[]>(lerFavoritos());
|
lista = $state<string[]>(lerFavoritos());
|
||||||
alternar(g: string) {
|
alternar(g: string) {
|
||||||
this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, 9);
|
this.lista = this.lista.includes(g) ? this.lista.filter((x) => x !== g) : [...this.lista, g].slice(0, MAX_FAVORITOS);
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
|
localStorage.setItem(CHAVE_FAVORITOS, JSON.stringify(this.lista));
|
||||||
|
localStorage.setItem(CHAVE_MAOS, '1');
|
||||||
} catch {
|
} catch {
|
||||||
/* ok */
|
/* ok */
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,10 +11,10 @@
|
|||||||
{#each lista as g, i (g)}
|
{#each lista as g, i (g)}
|
||||||
{@const info = GESTOS_INFO[g]}
|
{@const info = GESTOS_INFO[g]}
|
||||||
<div class="item">
|
<div class="item">
|
||||||
<button class="gesto" onclick={() => gesto(g)} title={completo ? info.nome : `${info.nome} (tecla ${i + 1})`}>
|
<button class="gesto" onclick={() => gesto(g)} title={completo || i > 8 ? info.nome : `${info.nome} (tecla ${i + 1})`}>
|
||||||
<span class="emoji" aria-hidden="true">{info.emoji}</span>
|
<span class="emoji" aria-hidden="true">{info.emoji}</span>
|
||||||
<span class="nome">{info.nome}</span>
|
<span class="nome">{info.nome}</span>
|
||||||
{#if !completo}<kbd>{i + 1}</kbd>{/if}
|
{#if !completo && i < 9}<kbd>{i + 1}</kbd>{/if}
|
||||||
</button>
|
</button>
|
||||||
{#if completo}
|
{#if completo}
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
ultimaConversa = c.ultima.id;
|
ultimaConversa = c.ultima.id;
|
||||||
enviar({ tipo: 'ordem', ordem: 'acordar' });
|
enviar({ tipo: 'ordem', ordem: 'acordar' });
|
||||||
enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
|
enviar({ tipo: 'ordem', ordem: c.ultima.gesto });
|
||||||
|
enviar({ tipo: 'legenda', texto: c.ultima.texto });
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
];
|
];
|
||||||
@@ -41,6 +42,11 @@
|
|||||||
enviar({ tipo: 'ordem', ordem: texto });
|
enviar({ tipo: 'ordem', ordem: texto });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Mostra uma legenda na faixa de baixo da prévia. */
|
||||||
|
export function legenda(texto: string) {
|
||||||
|
enviar({ tipo: 'legenda', texto });
|
||||||
|
}
|
||||||
|
|
||||||
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */
|
/** Para a tela de reações: mostra uma receita sem mandar ao celular. */
|
||||||
export function previa(receita: unknown) {
|
export function previa(receita: unknown) {
|
||||||
enviar({ tipo: 'previa-reacao', receita });
|
enviar({ tipo: 'previa-reacao', receita });
|
||||||
|
|||||||
@@ -146,7 +146,7 @@
|
|||||||
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
|
<Linha icone={Settings2} cor="#8e8e93" rotulo="Qualidade, câmera frontal/traseira e sensores" href="#/ajustes" />
|
||||||
</Grupo>
|
</Grupo>
|
||||||
|
|
||||||
<Grupo titulo="Gestos" rodape="Teclas 1 a 9 no teclado. Ctrl+K busca qualquer gesto.">
|
<Grupo titulo="Gestos" rodape="Teclas 1 a 9 disparam os nove primeiros. Ctrl+K busca qualquer gesto.">
|
||||||
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
|
<Linha icone={Smile} cor="#ffcc00" rotulo="Favoritos">
|
||||||
<Gestos />
|
<Gestos />
|
||||||
</Linha>
|
</Linha>
|
||||||
|
|||||||
@@ -204,7 +204,7 @@
|
|||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
{:else if aba === 'gestos'}
|
{:else if aba === 'gestos'}
|
||||||
<Card titulo="Gestos do rosto" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (atalhos 1 a 9).">
|
<Card titulo="Gestos do rosto" descricao="Toque para mandar ao celular. A estrela fixa o gesto nos favoritos (até 12; as teclas 1 a 9 valem para os nove primeiros).">
|
||||||
<Gestos completo />
|
<Gestos completo />
|
||||||
</Card>
|
</Card>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
+81
-10
@@ -4,12 +4,30 @@
|
|||||||
cor, CORES, FORMATOS_INFO, formato, gesto, GESTOS_INFO, hex, nomeRosto, rgb, rosto, ROSTOS_INFO,
|
cor, CORES, FORMATOS_INFO, formato, gesto, GESTOS_INFO, hex, nomeRosto, rgb, rosto, ROSTOS_INFO,
|
||||||
} from '../lib/comandos.svelte';
|
} from '../lib/comandos.svelte';
|
||||||
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
import RostoPrevia from '../lib/componentes/RostoPrevia.svelte';
|
||||||
|
import Slider from '../lib/componentes/Slider.svelte';
|
||||||
import Toggle from '../lib/componentes/Toggle.svelte';
|
import Toggle from '../lib/componentes/Toggle.svelte';
|
||||||
|
import { salvarApp } from '../lib/ajustes.svelte';
|
||||||
import { estado } from '../lib/estado.svelte';
|
import { estado } from '../lib/estado.svelte';
|
||||||
|
|
||||||
let previa = $state<{ ordem: (t: string) => void }>();
|
let previa = $state<{ ordem: (t: string) => void; legenda: (t: string) => void }>();
|
||||||
// Por padrão as emoções só aparecem aqui na prévia; ligado, vão também para o celular.
|
// Ouvir a voz de robô aqui no PC (o clique na tecla libera o áudio do navegador).
|
||||||
let noCelular = $state(false);
|
let ouvirAqui = $state(true);
|
||||||
|
let audio: AudioContext | null = null;
|
||||||
|
function voz(): AudioContext | null {
|
||||||
|
const AC = window.AudioContext || (window as any).webkitAudioContext;
|
||||||
|
if (!ouvirAqui || !AC || !(window as any).VozRobo) return null;
|
||||||
|
audio ??= new AC();
|
||||||
|
if (audio.state === 'suspended') audio.resume();
|
||||||
|
return audio;
|
||||||
|
}
|
||||||
|
const FRASE = 'Oi! Eu sou o Carinha. Quer brincar comigo?';
|
||||||
|
function testarFala() {
|
||||||
|
previa?.legenda(FRASE);
|
||||||
|
const c = voz();
|
||||||
|
if (c) (window as any).VozRobo.balbuciar(c, FRASE, 0.5);
|
||||||
|
}
|
||||||
|
// As emoções vão para o celular (e a prévia mostra junto); "só na prévia" serve para testar quieto.
|
||||||
|
let soPrevia = $state(false);
|
||||||
|
|
||||||
const atual = $derived(estado.aparencia.rosto);
|
const atual = $derived(estado.aparencia.rosto);
|
||||||
const formatoAtual = $derived(estado.aparencia.formato ?? 'padrao');
|
const formatoAtual = $derived(estado.aparencia.formato ?? 'padrao');
|
||||||
@@ -18,16 +36,21 @@
|
|||||||
const classicos = $derived(estado.rostos.filter((r) => !ROSTOS_INFO[r]?.novo));
|
const classicos = $derived(estado.rostos.filter((r) => !ROSTOS_INFO[r]?.novo));
|
||||||
const formatos = $derived(estado.formatos.length ? estado.formatos : Object.keys(FORMATOS_INFO));
|
const formatos = $derived(estado.formatos.length ? estado.formatos : Object.keys(FORMATOS_INFO));
|
||||||
|
|
||||||
const EMOCOES = ['feliz', 'coracao', 'surpreso', 'triste', 'bravo', 'piscadinha', 'olhar_esq', 'olhar_dir', 'dormir', 'acordar'];
|
const EMOCOES = ['feliz', 'coracao', 'surpreso', 'triste', 'bravo', 'piscadinha', 'acenar', 'esconder', 'espiar', 'pensar', 'musica', 'dormir', 'acordar'];
|
||||||
function emocao(g: string) {
|
function emocao(g: string) {
|
||||||
if (noCelular) gesto(g);
|
if (soPrevia) previa?.ordem(g);
|
||||||
else previa?.ordem(g);
|
else gesto(g);
|
||||||
|
const c = voz();
|
||||||
|
if (c) (window as any).VozRobo.tocar(c, g, 0.5);
|
||||||
}
|
}
|
||||||
|
|
||||||
const IDEIAS: { titulo: string; texto: string; feito: boolean; onde: string }[] = [
|
const IDEIAS: { titulo: string; texto: string; feito: boolean; onde: string }[] = [
|
||||||
{ titulo: 'Pálpebras no lugar de formas prontas', texto: 'Cada emoção é posição de pálpebra; as trocas ficam suaves em vez de saltar.', feito: true, onde: 'rosto' },
|
{ titulo: 'Pálpebras no lugar de formas prontas', texto: 'Cada emoção é posição de pálpebra; as trocas ficam suaves em vez de saltar.', feito: true, onde: 'rosto' },
|
||||||
{ titulo: 'Nunca parado', texto: 'Respiração, sacadas e piscada que achata o olho. Também protege a tela AMOLED.', feito: true, onde: 'rosto' },
|
{ titulo: 'Nunca parado', texto: 'Respiração, sacadas e piscada que achata o olho. Também protege a tela AMOLED.', feito: true, onde: 'rosto' },
|
||||||
{ titulo: 'Toque na tela', texto: 'Cutucar dá susto e risada; deslizar o dedo é carinho; tocar acorda.', feito: true, onde: 'rosto' },
|
{ titulo: 'Toque na tela', texto: 'Cutucar dá susto e risada; deslizar o dedo é carinho; tocar acorda.', feito: true, onde: 'rosto' },
|
||||||
|
{ titulo: 'Mãos', texto: 'Mãozinhas na cor dos olhos: esconde o rosto, espia, acena e pensa com a mão no queixo.', feito: true, onde: 'rosto' },
|
||||||
|
{ titulo: 'Faixa de baixo', texto: 'Legenda da conversa sendo digitada, pontinhos enquanto pensa, equalizador ao dançar e carga quando está no carregador.', feito: true, onde: 'rosto' },
|
||||||
|
{ titulo: 'Voz de robô', texto: 'Chilreios, “uou!”, “ohh” e um balbucio quando responde, sintetizados na hora; motorzinho quando as mãos se mexem.', feito: true, onde: 'rosto' },
|
||||||
{ titulo: 'Fala pelo próprio celular', texto: 'A resposta da conversa sai no alto-falante do S10, com os olhos pulsando na voz.', feito: false, onde: 'app' },
|
{ titulo: 'Fala pelo próprio celular', texto: 'A resposta da conversa sai no alto-falante do S10, com os olhos pulsando na voz.', feito: false, onde: 'app' },
|
||||||
{ titulo: 'Ritmo do dia', texto: 'Boceja à noite, fica sonolento com bateria baixa, mostra um raio quando carrega.', feito: false, onde: 'rosto e servidor' },
|
{ titulo: 'Ritmo do dia', texto: 'Boceja à noite, fica sonolento com bateria baixa, mostra um raio quando carrega.', feito: false, onde: 'rosto e servidor' },
|
||||||
{ titulo: 'Contato visual', texto: 'Sustenta o olhar de quem a câmera vê e desvia quando fica tímido.', feito: false, onde: 'rosto' },
|
{ titulo: 'Contato visual', texto: 'Sustenta o olhar de quem a câmera vê e desvia quando fica tímido.', feito: false, onde: 'rosto' },
|
||||||
@@ -57,11 +80,20 @@
|
|||||||
<button class="tecla" onclick={() => emocao(g)}>{GESTOS_INFO[g].emoji} {GESTOS_INFO[g].nome}</button>
|
<button class="tecla" onclick={() => emocao(g)}>{GESTOS_INFO[g].emoji} {GESTOS_INFO[g].nome}</button>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="linha">
|
||||||
|
<button class="tecla" onclick={testarFala}>🗣️ Testar fala</button>
|
||||||
|
</div>
|
||||||
<Toggle
|
<Toggle
|
||||||
rotulo="Mandar as emoções também para o celular"
|
rotulo="Ouvir a voz de robô aqui no PC"
|
||||||
ajuda="Desligado, elas aparecem só na prévia"
|
ajuda="Sons sintetizados na hora, inspirados nos robozinhos de cinema"
|
||||||
checked={noCelular}
|
checked={ouvirAqui}
|
||||||
onchange={(v) => (noCelular = v)}
|
onchange={(v) => (ouvirAqui = v)}
|
||||||
|
/>
|
||||||
|
<Toggle
|
||||||
|
rotulo="Testar só na prévia, sem mandar ao celular"
|
||||||
|
ajuda="Desligado, cada emoção aparece no celular na hora"
|
||||||
|
checked={soPrevia}
|
||||||
|
onchange={(v) => (soPrevia = v)}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@@ -100,6 +132,39 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="bloco-escolha" aria-labelledby="t-detalhes">
|
||||||
|
<h2 id="t-detalhes">Detalhes do rosto</h2>
|
||||||
|
<div class="detalhes">
|
||||||
|
<Toggle rotulo="Boca" checked={estado.appConfig?.boca !== false} disabled={!estado.appConfig} onchange={(v) => salvarApp({ boca: v })} />
|
||||||
|
<Toggle rotulo="Sobrancelhas" checked={!!estado.appConfig?.sobrancelhas} disabled={!estado.appConfig} onchange={(v) => salvarApp({ sobrancelhas: v })} />
|
||||||
|
<Toggle
|
||||||
|
rotulo="Íris"
|
||||||
|
ajuda={aceitaFormato ? 'Pupila que acompanha o olhar' : 'Vale para Viva, Bloco e Matriz'}
|
||||||
|
checked={!!estado.appConfig?.iris}
|
||||||
|
disabled={!estado.appConfig}
|
||||||
|
onchange={(v) => salvarApp({ iris: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="detalhes">
|
||||||
|
<Slider
|
||||||
|
rotulo="Subir a boca"
|
||||||
|
valor={Number(estado.appConfig?.subirBoca ?? 0)}
|
||||||
|
min={-20}
|
||||||
|
max={60}
|
||||||
|
passo={2}
|
||||||
|
onchange={(v) => salvarApp({ subirBoca: v })}
|
||||||
|
/>
|
||||||
|
<Slider
|
||||||
|
rotulo="Distância das sobrancelhas"
|
||||||
|
valor={Number(estado.appConfig?.distSobrancelha ?? 16)}
|
||||||
|
min={0}
|
||||||
|
max={60}
|
||||||
|
passo={2}
|
||||||
|
onchange={(v) => salvarApp({ distSobrancelha: v })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="bloco-escolha" aria-labelledby="t-cor">
|
<section class="bloco-escolha" aria-labelledby="t-cor">
|
||||||
<h2 id="t-cor">Cor dos olhos</h2>
|
<h2 id="t-cor">Cor dos olhos</h2>
|
||||||
<div class="cores">
|
<div class="cores">
|
||||||
@@ -326,6 +391,12 @@
|
|||||||
height: 18px;
|
height: 18px;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
}
|
}
|
||||||
|
.detalhes {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(min(100%, 220px), 1fr));
|
||||||
|
gap: var(--e-2) var(--e-6);
|
||||||
|
max-width: 780px;
|
||||||
|
}
|
||||||
.cores {
|
.cores {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import { svelte } from '@sveltejs/vite-plugin-svelte';
|
|||||||
const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
|
const alvo = process.env.CARINHA_SERVIDOR || 'http://127.0.0.1:8100';
|
||||||
const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
|
const api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
|
||||||
'/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face',
|
'/app', '/rtc', '/camera', '/conversa', '/treinamento', '/braco', '/servos', '/ia', '/api', '/gesto', '/face',
|
||||||
'/carinha.html', '/reacoes-render.js', '/rosto-motor.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk'];
|
'/carinha.html', '/reacoes-render.js', '/rosto-motor.js', '/voz-robo.js', '/rastreador-worker.js', '/vendor', '/qualidade-auto.js', '/app.apk'];
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [svelte()],
|
plugins: [svelte()],
|
||||||
|
|||||||
Reference in New Issue
Block a user