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:
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -0,0 +1,157 @@
|
||||
/* 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 saidas = new WeakMap(); // AudioContext → nó de saída (compressor)
|
||||
|
||||
function saida(c) {
|
||||
if (saidas.has(c)) return saidas.get(c);
|
||||
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 = 7000; // tira o "chiado" de osciladores crus
|
||||
comp.connect(passaBaixa); passaBaixa.connect(c.destination);
|
||||
saidas.set(c, comp);
|
||||
return comp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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);
|
||||
}
|
||||
// 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.35;
|
||||
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) => { silaba(c, t, 0.42, 190, 120, v * 0.9, { forma: 'square', vogal: [500, 380], vib: [18, 22], q: 3 }); },
|
||||
triste: (c, t, v) => { silaba(c, t, 0.65, 540, 290, v * 0.85, { vogal: [700, 420], vib: [5, 14] }); },
|
||||
dormir: (c, t, v) => { silaba(c, t, 0.35, 380, 640, v * 0.7, { vogal: [500, 1000] }); silaba(c, t + 0.36, 0.75, 640, 210, v * 0.6, { vogal: [1000, 380], 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.85, 330, 365, v * 0.6, { vogal: U, vib: [4, 6], forma: 'triangle' }); },
|
||||
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):
|
||||
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)
|
||||
with tempfile.TemporaryDirectory(prefix='carinha-build-', dir=work) as folder:
|
||||
tmp = Path(folder)
|
||||
|
||||
+21
-2
@@ -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 });
|
||||
});
|
||||
|
||||
+1
-1
@@ -28,7 +28,7 @@ Set-Location $PSScriptRoot -ErrorAction Stop
|
||||
$arquivos = @(
|
||||
'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',
|
||||
'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'
|
||||
)
|
||||
if (Test-Path 'android/carinha-rosto.apk') { $arquivos += 'android/carinha-rosto.apk' }
|
||||
|
||||
+1
-1
@@ -62,7 +62,7 @@ APARENCIA = {"rosto": "carinha", "cor": [92, 214, 255], "formato": "padrao"}
|
||||
ARQUIVO_APARENCIA = "aparencia.json"
|
||||
|
||||
# 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/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"}
|
||||
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
/* 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 saidas = new WeakMap(); // AudioContext → nó de saída (compressor)
|
||||
|
||||
function saida(c) {
|
||||
if (saidas.has(c)) return saidas.get(c);
|
||||
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 = 7000; // tira o "chiado" de osciladores crus
|
||||
comp.connect(passaBaixa); passaBaixa.connect(c.destination);
|
||||
saidas.set(c, comp);
|
||||
return comp;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
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);
|
||||
}
|
||||
// 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.35;
|
||||
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) => { silaba(c, t, 0.42, 190, 120, v * 0.9, { forma: 'square', vogal: [500, 380], vib: [18, 22], q: 3 }); },
|
||||
triste: (c, t, v) => { silaba(c, t, 0.65, 540, 290, v * 0.85, { vogal: [700, 420], vib: [5, 14] }); },
|
||||
dormir: (c, t, v) => { silaba(c, t, 0.35, 380, 640, v * 0.7, { vogal: [500, 1000] }); silaba(c, t + 0.36, 0.75, 640, 210, v * 0.6, { vogal: [1000, 380], 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.85, 330, 365, v * 0.6, { vogal: U, vib: [4, 6], forma: 'triangle' }); },
|
||||
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>
|
||||
<!-- mesmo motor de rosto do celular, usado nas miniaturas das reações -->
|
||||
<script src="/rosto-motor.js"></script>
|
||||
<script src="/voz-robo.js"></script>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -42,6 +42,11 @@
|
||||
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. */
|
||||
export function previa(receita: unknown) {
|
||||
enviar({ tipo: 'previa-reacao', receita });
|
||||
|
||||
@@ -7,7 +7,23 @@
|
||||
import Toggle from '../lib/componentes/Toggle.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 }>();
|
||||
// Ouvir a voz de robô aqui no PC (o clique na tecla libera o áudio do navegador).
|
||||
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);
|
||||
}
|
||||
// Por padrão as emoções só aparecem aqui na prévia; ligado, vão também para o celular.
|
||||
let noCelular = $state(false);
|
||||
|
||||
@@ -22,6 +38,8 @@
|
||||
function emocao(g: string) {
|
||||
if (noCelular) gesto(g);
|
||||
else previa?.ordem(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 }[] = [
|
||||
@@ -30,6 +48,7 @@
|
||||
{ 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: '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' },
|
||||
@@ -59,6 +78,15 @@
|
||||
<button class="tecla" onclick={() => emocao(g)}>{GESTOS_INFO[g].emoji} {GESTOS_INFO[g].nome}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="linha">
|
||||
<button class="tecla" onclick={testarFala}>🗣️ Testar fala</button>
|
||||
</div>
|
||||
<Toggle
|
||||
rotulo="Ouvir a voz de robô aqui no PC"
|
||||
ajuda="Sons sintetizados na hora, inspirados nos robozinhos de cinema"
|
||||
checked={ouvirAqui}
|
||||
onchange={(v) => (ouvirAqui = v)}
|
||||
/>
|
||||
<Toggle
|
||||
rotulo="Mandar as emoções também para o celular"
|
||||
ajuda="Desligado, elas aparecem só na prévia"
|
||||
|
||||
+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 api = ['/eventos', '/estado', '/comando', '/parar-tudo', '/rearmar', '/fila', '/config', '/faces', '/gestos',
|
||||
'/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({
|
||||
plugins: [svelte()],
|
||||
|
||||
Reference in New Issue
Block a user