Implementando "text blips" (pseudo-speech) na web com web audio API
Voltar para blog

Implementando "text blips" (pseudo-speech) na web com web audio API

07/06/2026 · 4 min · Desenvolvimento

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:

  1. OscillatorNode: gera a onda base (sine, square, triangle, sawtooth).
  2. GainNode: controla envelope de volume (ataque/decay) para evitar click/clipping.
  3. 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.

flowchart LR OSC["🎵 OscillatorNode\nFreq base ± variação\n(sine / square / triangle)"] GAIN["🔊 GainNode\nEnvelope Attack/Decay\n0 → 0.1 × vol → 0.001"] DEST["🔈 AudioDestinationNode\n(Saída do dispositivo)"] OSC -->|"osc.connect(gain)"| GAIN GAIN -->|"gain.connect(destination)"| DEST style OSC fill:#1e3a5f,color:#93c5fd style GAIN fill:#0e2a3a,stroke:#4fd8ff,color:#4fd8ff style DEST fill:#14532d,color:#86efac

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.

PerfilPitch (Hz)WaveformCaracterística
Herói200 - 300triangleequilibrado, amigável
NPC feminina400 - 550sinesuave, limpo
Robô/antagonista80 - 150squareáspero, harmônicos agressivos

Além disso, costumo variar também:

4.1 compatibilidade com navegadores#

A Web Audio API possui amplo suporte nos navegadores modernos:

NavegadorVersão MínimaSuporteObservações
Chrome14+✅ SimNativo
Firefox25+✅ SimNativo
Safari14.1+✅ SimVersões antigas requerem prefixo webkitAudioContext
Edge12+✅ SimNativo
Opera15+✅ SimNativo
iOS Safari14.5+✅ SimSujeito à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:

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#

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:

CC BY-NC

Este post está licenciado sob CC BY-NC.

Comentários

Participe da discussão abaixo.

0 comentários