Implementando "Text Blips" (Pseudo-Speech) na Web com Web Audio API#
O charme do som procedural#
Se você já jogou Undertale, Celeste ou até mergulhou em atmosferas narrativas como MiSide, sabe que identidade de personagem passa muito pelo ritmo da fala. Na web, subir dezenas de .wav por caractere é custo de banda, latência e manutenção.
A abordagem que usei aqui foi síntese de áudio procedural em tempo real com Web Audio API. Resultado: controle fino de pitch, timbre e envelope sem depender de assets externos.
1) Arquitetura da solução#
Para um sistema de diálogo de nível produção, eu separo em três blocos:
- OscillatorNode: gera a onda base (sine, square, triangle, sawtooth).
- GainNode: controla envelope de volume (ataque/decay) para evitar click/clipping.
- Timing Engine: sincroniza som com efeito typewriter e pausas semânticas.
Essa divisão facilita ajuste de identidade sonora sem reescrever lógica de render de texto.
2) Desafios reais de trincheira#
2.1 autoplay policy (bloqueio de contexto de áudio)#
Browsers modernos bloqueiam AudioContext sem gesto do usuário.
Correção operacional: criar/resumir contexto dentro de clique explícito (botão de iniciar diálogo, por exemplo).
2.2 efeito "metralhadora"#
Tocar exatamente o mesmo blip para toda letra deixa a experiência mecânica.
Correção: randomização leve de frequência por caractere para simular pequenas variações humanas. O código aplica uma variação de ±20Hz (ou seja, um range total de 40Hz): Math.random() 40 - 20. Se preferir uma variação ainda mais sutil, reduza para Math.random() 20 - 10 (±10Hz, range de 20Hz).
2.3 estalos no início/fim (click artifacts)#
Sem envelope, você ouve transiente seco no start/stop.
Correção: ataque rápido + decay exponencial curto via GainNode (fade in/out rápido).
3) Implementação otimizada (com compatibilidade e cleanup)#
<div id="dialog-box" style="font-family: 'Courier New', monospace; min-height: 50px;"></div>
<button id="btn-start">Iniciar Diálogo</button>
<button id="btn-stop">Parar</button>
<input type="range" id="volume" min="0" max="100" value="50">
<label for="volume">Volume</label>
<script>
let audioCtx = null;
let isTyping = false;
let muted = false;
let globalVolume = 0.5;
// Inicialização segura com verificação de compatibilidade do navegador
const initAudio = () => {
if (!window.AudioContext && !window.webkitAudioContext) {
console.warn('Web Audio API não é suportada neste navegador.');
return false;
}
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
// Retoma o contexto se suspenso (necessário para políticas de autoplay)
if (audioCtx.state === 'suspended') {
audioCtx.resume();
}
return true;
};
// Liberação de recursos (cleanup) para evitar vazamento de memória
const cleanupAudio = () => {
if (audioCtx && audioCtx.state !== 'closed') {
audioCtx.close();
audioCtx = null;
}
};
// Suspender para economia de bateria
const suspendAudio = () => {
if (audioCtx && audioCtx.state === 'running') {
audioCtx.suspend();
}
};
/**
* Gera o blip sonoro
* @param {number} freq - Frequência base em Hz
* @param {string} type - Tipo da onda (sine, square, triangle, sawtooth)
*/
function playBlip(freq = 440, type = 'sine') {
if (muted || !audioCtx) return;
// Proteção: volume zero quebraria exponentialRampToValueAtTime
if (globalVolume <= 0) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
// Randomização de frequência de ±20Hz (range total de 40Hz)
osc.frequency.setValueAtTime(freq + (Math.random() * 40 - 20), audioCtx.currentTime);
// Envelope: attack 10ms → peak → decay exponencial 60ms
// Nota: exponentialRampToValueAtTime não aceita 0 - usar valor mínimo seguro (0.001)
gain.gain.setValueAtTime(0, audioCtx.currentTime);
gain.gain.linearRampToValueAtTime(0.1 * globalVolume, audioCtx.currentTime + 0.01);
gain.gain.exponentialRampToValueAtTime(0.001, audioCtx.currentTime + 0.06);
osc.connect(gain);
gain.connect(audioCtx.destination);
try {
osc.start();
osc.stop(audioCtx.currentTime + 0.06);
} catch (err) {
// Captura erros se o AudioContext for fechado externamente entre chamadas
console.warn('playBlip: erro ao iniciar oscilador -', err.message);
gain.disconnect();
return;
}
// Cleanup para evitar acúmulo de nós em diálogos longos
osc.onended = () => {
gain.disconnect();
osc.disconnect();
};
}
/**
* Efeito de digitação com controle de estado e tratamento de erros
*/
async function typeWriter(text, freq, wave) {
if (isTyping) return;
isTyping = true;
// Garante que o contexto esteja ativo mesmo que suspendAudio() tenha sido chamado antes
if (audioCtx && audioCtx.state === 'suspended') {
await audioCtx.resume();
}
const el = document.getElementById('dialog-box');
if (!el) {
console.error('Elemento dialog-box não encontrado.');
isTyping = false;
return;
}
el.textContent = '';
for (let i = 0; i < text.length; i++) {
// Interrupção caso isTyping seja definido como false externamente
if (!isTyping) break;
const char = text[i];
el.textContent += char;
// Toca som em caracteres que não sejam espaços
if (char !== ' ') {
playBlip(freq, wave);
}
// Pausas semânticas baseadas em pontuação
// Nota: setTimeout é preferível a requestAnimationFrame para delays de texto, // rAF pausa em abas em background e tem overhead de frame (~16ms) desnecessário aqui.
let delay = 50;
if (char === ',') delay = 200;
if ('.!?'.includes(char)) delay = 500;
await new Promise(resolve => setTimeout(resolve, delay));
}
isTyping = false;
suspendAudio(); // Suspende o áudio ao terminar para economizar recursos/bateria
}
// Event Listeners
document.getElementById('btn-start').addEventListener('click', () => {
if (initAudio()) {
typeWriter('Olá, user! Isso é áudio procedural rodando em tempo real.', 220, 'triangle');
}
});
document.getElementById('btn-stop').addEventListener('click', () => {
isTyping = false;
muted = true;
setTimeout(() => { muted = false; }, 100);
});
document.getElementById('volume').addEventListener('input', (e) => {
globalVolume = e.target.value / 100;
});
// Executa cleanup ao fechar a janela
window.addEventListener('beforeunload', cleanupAudio);
</script>
4) Estratégia de identidade sonora (branding de personagem)#
No design de áudio, não é "som por som". É identidade.
| Perfil | Pitch (Hz) | Waveform | Característica |
|---|---|---|---|
| Herói | 200 - 300 | triangle | equilibrado, amigável |
| NPC feminina | 400 - 550 | sine | suave, limpo |
| Robô/antagonista | 80 - 150 | square | áspero, harmônicos agressivos |
Além disso, costumo variar também:
- duração do blip por emoção (curto = urgência, longo = calma);
- ganho máximo por personagem;
- intervalo base do typewriter para mudar "cadência de fala".
4.1 compatibilidade com navegadores#
A Web Audio API possui amplo suporte nos navegadores modernos:
| Navegador | Versão Mínima | Suporte | Observações |
|---|---|---|---|
| Chrome | 14+ | ✅ Sim | Nativo |
| Firefox | 25+ | ✅ Sim | Nativo |
| Safari | 14.1+ | ✅ Sim | Versões antigas requerem prefixo webkitAudioContext |
| Edge | 12+ | ✅ Sim | Nativo |
| Opera | 15+ | ✅ Sim | Nativo |
| iOS Safari | 14.5+ | ✅ Sim | Sujeito às restrições de autoplay e gestos do usuário |
Estudo de caso de branding: evy (assistente virtual coruja)#
No projeto de suporte da assistente virtual Evy (cuja identidade visual é uma coruja), calibramos as seguintes diretrizes acústicas em tempo real:
- Waveform (Timbre):
triangle(onda triangular de tom amigável, abafado e confortável para leitura prolongada). - Pitch (Frequência Base):
580Hz(tom agudo leve e amigável). - Typewriter Speed (Ticks):
35ms(uma cadência rápida, simulando uma máquina ágil e prestativa).
5) Hardening técnico (segurança, performance e UX)#
5.1 CSP e fontes externas#
Se você migrar para samples externos no futuro, ajuste CSP (connect-src, media-src) para domínios confiáveis. Sem isso, produção pode bloquear carregamento silenciosamente.
5.2 performance mobile e timing de texto#
OscillatorNode é extremamente leve, mas diálogos contínuos geram gargalo no Garbage Collector sem o devido descarte. Por isso o onended desconectando nós não é opcional em sessão longa.
Para os delays do typewriter, use setTimeout diretamente - é a ferramenta correta para intervalos de texto (50ms–500ms):
// ✅ Correto: setTimeout puro para delays de texto
await new Promise(resolve => setTimeout(resolve, delay));
E no ciclo de vida da aplicação (por exemplo, ao descarregar a página ou destruir um componente SPA), fechar o contexto via audioCtx.close() e liberá-lo é uma boa prática mandatória:
function cleanup() {
if (audioCtx && audioCtx.state !== 'closed') {
audioCtx.close();
audioCtx = null;
}
}
window.addEventListener('beforeunload', cleanup);
5.3 UX corporativo#
Sempre incluir controle de volume global e mute. O que é imersão para game pode ser atrito em escritório.
5.4 acessibilidade#
Para acessibilidade, não dependa só de áudio para transmitir estado narrativo. Mantenha texto e sinais visuais equivalentes.
6) Checklist de produção#
- [ ]
AudioContextinicializado por gesto do usuário - [ ] Verificação de compatibilidade/presença do
AudioContext - [ ] Tratamento de múltiplos cliques e cancelamento (flag
isTyping) - [ ] Envelope aplicado para eliminar clicks (fade in/out suave via ramps)
- [ ] Randomização de pitch calibrada (ex: ±20Hz = range de 40Hz)
- [ ] Proteção contra
globalVolume === 0para evitar erro noexponentialRampToValueAtTime - [ ]
try/catchemosc.start()para capturar erros de contexto fechado - [ ]
audioCtx.resume()dentro dotypeWriterpara reativar contexto suspenso - [ ] Controle de volume global integrado
- [ ] Pausas por pontuação implementadas
- [ ] Cleanup de nós (
disconnect) no callbackonended - [ ] Mute global disponível
- [ ]
AudioContext.close()implementado para descarte de recursos - [ ]
AudioContext.suspend()para economia de bateria em idle - [ ] CSP revisada se houver mídia ou scripts remotos
Considerações práticas#
Pseudo-speech na web é um exemplo clássico de como UX de alto nível pode ser entregue com stack nativa, sem payload desnecessário e com controle criativo total.
Com Web Audio API, você transforma diálogo em experiência - e, tecnicamente, mantém o sistema leve, previsível e pronto para escalar.
Este artigo foi útil?
Deixe uma reação rápida para apoiar o conteúdo:
Este post está licenciado sob CC BY-NC.



Comentários
Participe da discussão abaixo.
0 comentários