feat(voz): voz de robo sintetizada no estilo dos robozinhos de cinema

- voz-robo.js: silabas com deslize de tom, vibrato e filtro de formante
  ("uou", "ohh"), motorzinho de ruido filtrado e cliques; tudo gerado na
  hora com Web Audio, sem gravacoes nem arquivos de terceiros
- um som proprio para cada gesto, incluindo maos (motor ao subir e descer)
- balbucio de robo na resposta da conversa (mesma frase, mesmo som; sobe
  no fim quando e pergunta) e silencio dos gestos enquanto ele "fala"
- tela Rosto: ouvir a voz no PC e botao "Testar fala"; bipes antigos
  ficam como reserva

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
julio
2026-10-06 02:57:32 -03:00
co-authored by Claude Opus 5.5
parent 319e63cc42
commit f7c4793dc2
11 changed files with 395 additions and 9 deletions
+21 -2
View File
@@ -26,6 +26,7 @@
</svg>
<div id="fps"></div>
<script src="rosto-motor.js"></script>
<script src="voz-robo.js"></script>
<script>
/* Token do servidor (o app Android abre a página com ?t=...): vai em todo pedido ao
próprio servidor, inclusive os de reacoes-render.js. Sem ?t= nada muda. */
@@ -969,15 +970,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); },
};
/* 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) {
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];
if (!f) return false;
if (!voz && !f) return false;
const c = contextoAudio();
if (!c) return false;
if (c.state === "suspended") { try { c.resume(); } catch (e) { /* sem gesto */ } }
try {
f(c, c.currentTime + 0.02, AUDIO.volume);
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
console.log("voz: " + gesto + " ctx=" + c.state);
return true;
@@ -1064,6 +1081,7 @@ setTimeout(relatar, 1500);
try{const response=await fetch('/conversa/status',{cache:'no-store',signal:AbortSignal.timeout(2500)});if(!response.ok)return;
const s=await response.json(),m=s.ultima;if(!m){seen=null;return;}
if(m.id===seen||Date.now()/1000-m.horario>60)return;seen=m.id;
falar(m.texto);
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;}
@@ -1113,6 +1131,7 @@ if(new URLSearchParams(location.search).get('aparelho')==='1' && new URLSearchPa
const m = s.ultima;
if (!m || m.id === ultimaConversa || Date.now() / 1000 - m.horario > 60) return;
ultimaConversa = m.id;
falar(m.texto);
MIC.ultimoSom = performance.now() / 1000; trata('acordar'); trata(m.gesto);
if (window.MotorRosto) window.MotorRosto.faixa('legenda', { texto: m.texto });
});