Ir para o conteúdo

Áudio

Áudio no navegador, nas duas direções.

Reprodução — notificação sonora (chime de mensagem, confirmação de pagamento) com playAudio, useAudio e createAudioPlayer, e o componente AudioPlayer quando você precisa de transporte (play/pause, barra, tempos).

CapturaAudioRecorder pra uma nota de voz em uma linha, e por baixo useMediaPermission, useMediaDevices, useMicrophone, useAudioRecorder, createLevelMeter e blobToWav. Nenhuma dependência nova: a fatia inteira mede 5,50 KB brotli.

Se você só quer gravar uma nota de voz, pule para Gravação

O resto da página é a camada de baixo, pra quando o componente não serve.

Por que um wrapper em volta de new Audio()?

Tocar som no navegador esbarra na autoplay policy e em vazamento de elementos Audio. O SDK encapsula: rastreia o clipe atual (pra dar stop), normaliza volume, trata o bloqueio de autoplay devolvendo null em vez de estourar, e limpa no unmount quando você usa o hook.

playAudio — one-off no player compartilhado

Ideal pra um som disparado por um evento, sem estado de UI:

import { playAudio, useEventStream } from "tempest-react-sdk";

interface StreamEvent {
  type: "NOTIFY" | "PAYMENT-SUCCESS";
}

export function PaymentSounds() {
  useEventStream<StreamEvent>(`${import.meta.env.VITE_API_URL}/notifications`, {
    onMessage: ({ data }) => {
      if (data.type === "PAYMENT-SUCCESS") {
        void playAudio("/audio/dinheiro.mp3", { volume: 0.5 });
      }
    },
  });

  return null;
}

playAudio(src, options) retorna Promise<HTMLAudioElement | null>null quando o navegador bloqueou o autoplay. Opções: volume (0–1, default 1), loop, autoplay, stopPrevious, onEnded, onError. Pra parar o que o player compartilhado está tocando, use stopAudio().

useAudio — player privado por componente

Cada instância do hook tem seu próprio player, então desmontar para o áudio automaticamente:

import { useAudio } from "tempest-react-sdk";

export function NotificationBell() {
  const audio = useAudio();

  return (
    <button onClick={() => audio.play("/audio/plim.wav", { volume: 0.8 })}>
      🔔 {audio.unlocked ? "" : "(toque pra ativar som)"}
    </button>
  );
}
  • audio.play(src, options) — toca no player privado (mesmas options do playAudio).
  • audio.stop() — para o clipe atual.
  • audio.unlocked — vira true após o primeiro play() bem-sucedido. Útil pra esconder UI que pede a interação inicial.
  • Cleanup automático no unmount.

Use unlocked pra guiar o usuário

Antes do primeiro clique, o navegador bloqueia áudio. Mostre uma dica ("toque pra ativar som") enquanto unlocked === false e esconda assim que ele virar true.

createAudioPlayer — canais isolados

createAudioPlayer() cria um tracker independente do default. Use quando precisar tocar dois sons simultaneamente sem que um corte o outro (ex.: música de fundo + efeito sonoro):

import { createAudioPlayer } from "tempest-react-sdk";

const music = createAudioPlayer();
const sfx = createAudioPlayer();

await music.play("/audio/loop.mp3", { loop: true, volume: 0.3 });
await sfx.play("/audio/coin.wav", { volume: 1 }); // não corta a música

music.stop(); // para só a música
console.log(sfx.current()); // HTMLAudioElement | null

Cada player rastreia um clipe atual. stopPrevious: true no play() para o clipe anterior daquele mesmo player antes de tocar o novo.

createSfxPool / useSfxPool — efeitos sonoros curtos

new Audio(src) a cada disparo aloca um elemento e re-entra na pilha de rede por um arquivo que o navegador já tem. Para um som que toca dezenas de vezes por minuto — um blip de menu, um hit, um pickup — é a forma errada. O pool aloca uma vez por fonte e reproduz.

import { useSfxPool } from "tempest-react-sdk";

function Menu({ sfxVolume }: { sfxVolume: number }) {
  const sfx = useSfxPool({ volume: sfxVolume / 100, baseUrl: import.meta.env.BASE_URL });

  useEffect(() => {
    sfx.preload(["sfx/move.mp3", "sfx/select.mp3", "sfx/back.mp3"]);
  }, [sfx]);

  return <button onClick={() => sfx.play("sfx/select.mp3")}>Confirmar</button>;
}
  • play(src, { volume }) — o volume por play é multiplicado pelo master do pool.
  • preload(src | src[]) — busca antes do primeiro disparo, pra ele não sair mudo enquanto o arquivo baixa.
  • setVolume(v) — aplica também no que já está soando, reescalando pelo ganho de cada um: um clipe que começou a meio volume não é puxado pra cima.
  • stop(src?) — para uma fonte, ou todas.
  • dispose() — libera tudo. O useSfxPool já chama no unmount.

voices: repetir ou sobrepor

O default (voices: 1) reinicia o clipe a cada play, que é o que um blip de menu quer. Suba pra deixar o som se sobrepor — um hit tocando enquanto o anterior ainda ressoa:

const hits = createSfxPool({ voices: 3 });

Não confunda com createAudioPlayer

createAudioPlayer rastreia um clipe atual, com loop, roteamento de saída e callbacks de ciclo de vida — é o que música de fundo precisa. Efeitos são o caso oposto: muitas fontes, todas curtas, dispara-e-esquece, e o que importa é que disparar seja barato.

Mudar volume no useSfxPool chama setVolume no pool existente em vez de recriá-lo — recriar jogaria fora todo elemento que o usuário já baixou, que é exatamente o custo que o pool existe pra evitar. baseUrl, voices e maxSources são lidos uma vez, na criação.

Autoplay policy

Navegadores bloqueiam playback antes da primeira interação do usuário. playAudio / play() retornam null quando bloqueado (e chamam onError se passado) — em vez de lançar.

Destrave o áudio no primeiro clique

Não dá pra tocar som antes de qualquer interação. Desenhe o app pra disparar um play() (mesmo de um clipe silencioso curto) no primeiro clique de qualquer botão; a partir daí o navegador libera os próximos.

Assets

O SDK não embute áudios. Sirva em /audio/* (ou CDN) e passe a URL. Inspiração de paleta sonora (alofans):

export const AUDIOS = {
  plim: "/audio/plim.wav",
  dinheiro: "/audio/dinheiro.mp3",
  notification: "/audio/bell_sound.wav",
};

Gravação: comece pelo componente

Se você só quer uma nota de voz, é uma linha. O AudioRecorder cuida da permissão, do medidor de nível, do relógio e da revisão antes de você receber o áudio:

import { AudioRecorder } from "tempest-react-sdk";

export function NotaDeVoz({ ticketId }: { ticketId: string }) {
  return (
    <AudioRecorder
      maxDurationMs={120_000}
      onRecorded={({ blob, mimeType, durationMs }) => {
        const form = new FormData();
        form.append("audio", blob, `nota.${mimeType.includes("mp4") ? "m4a" : "webm"}`);
        form.append("duracao", String(durationMs));
        void fetch(`/api/tickets/${ticketId}/audio`, { method: "POST", body: form });
      }}
      footer={<small>Máximo 2 minutos.</small>}
    />
  );
}

O que você ganha sem escrever nada:

Você fez O componente faz
nada Não pede o microfone no mount — só no primeiro toque em Gravar
nada Se a permissão já está denied, diz isso e como resolver, em vez de oferecer um botão que não funciona
nada Medidor de nível ao vivo, relógio que desconta pausa, pausar/continuar
maxDurationMs Para sozinho no limite, e mostra o limite ao lado do relógio
nada Player de revisão com transporte, e "Gravar de novo" reaproveitando o mesmo stream
format="wav" Converte antes de te entregar, então o onRecorded sempre dá o formato que você pediu

O prompt de permissão não é disparado no mount, e isso é a decisão mais importante da página

Um prompt que o usuário não provocou é a forma mais confiável de ganhar um Block permanente — e depois disso o getUserMedia rejeita sem nunca mais perguntar. Por isso o microfone abre no primeiro toque em Gravar, e o toque sobrevive ao round-trip: o componente arma a gravação e começa quando o stream chega. Esperar um segundo clique faria o primeiro parecer quebrado.

Props

Prop Tipo Default O que faz
onRecorded (recording: AudioRecording) => void Recebe o áudio pronto. Não dispara em cancelamento.
maxDurationMs number Para sozinho. Vale sempre setar em tela pública.
deviceId string Microfone específico, de useMediaDevices().audioInputs.
format "native" \| "wav" "native" "wav" converte no stop. Veja o custo abaixo.
wavOptions WavOptions { mono: true, sampleRate: 16000 } serve pra fala.
audioBitsPerSecond number 32000–64000 basta pra voz em Opus.
review boolean true Player de revisão antes de entregar.
locale "pt-BR" \| "en" "pt-BR" Rótulos.
footer ReactNode Dica, contagem, aviso legal.
onError (error: unknown) => void Falha do gravador, ou conversão WAV que não deu.

AudioRecording = { blob, mimeType, durationMs }.

AudioPlayer — transporte pra um clipe

Aceita Blob direto, porque o que um app mais toca é a gravação que ele acabou de fazer:

<AudioPlayer src={recording.blob} durationMs={recording.durationMs} />
<AudioPlayer src="/audio/briefing.mp3" sinkId={saidaEscolhida} />

Passe durationMs sempre que tiver — o <audio> mente sobre gravação nova

O MediaRecorder escreve WebM sem duração no header, então <audio>.duration de uma gravação fresca é Infinity. É por isso que o gravador mantém o próprio relógio e você deve repassá-lo. Sem ele o componente aplica o único contorno que existe — buscar além do fim pra forçar o browser a demuxar até o último frame — e a barra fica travada até isso resolver.

Prop Tipo Default O que faz
src string \| Blob \| null URL ou blob. Blob vira object URL com revoke automático.
durationMs number Duração conhecida. Ganha do <audio>.
sinkId string Saída escolhida. Só Chromium.
autoPlay · loop boolean false Como no elemento nativo.
actions ReactNode À direita dos tempos — baixar, apagar.
onEnded · onError () => void Fim e falha de decode/rede.

Permissão sem disparar o prompt

useMediaPermission lê o estado sem pedir o dispositivo. É o que torna um fluxo decente possível:

import { useMediaPermission } from "tempest-react-sdk";

export function BotaoDeGravar({ onStart }: { onStart: () => void }) {
  const { state, supported } = useMediaPermission("microphone");

  if (state === "denied") {
    return <p>Microfone bloqueado. Libere nas configurações do site e recarregue.</p>;
  }
  return (
    <button onClick={onStart}>
      {state === "prompt" || !supported ? "Permitir microfone e gravar" : "Gravar"}
    </button>
  );
}
Estado Significa O que a UI deve fazer
"prompt" nunca pediu botão; pedir mostra o prompt
"granted" liberado botão normal
"denied" sticky — pedir de novo rejeita na hora, sem prompt instrução pras configurações do site, não botão
"unknown" Permissions API não respondeu (Safari não expõe microphone) trate como "vai ter que pedir pra descobrir"

O estado é ao vivo: se o usuário mudar a permissão nas configurações do site, o hook atualiza.

Dispositivos: microfone e saída

import { useMediaDevices, isAudioOutputSelectionSupported } from "tempest-react-sdk";

const { audioInputs, audioOutputs, labelsAvailable } = useMediaDevices();

Os nomes só aparecem depois da permissão

Antes de o usuário liberar uma captura, todo label é "" — os ids e a contagem são reais, os nomes não. Um seletor renderizado ali é uma coluna de vazios. Use labelsAvailable pra decidir quando mostrar. Peça o microfone primeiro, depois ofereça a escolha.

A lista muda com a página aberta

Plugar um fone no meio da gravação é o caso normal, não borda. O hook assina devicechange em vez de enumerar uma vez no mount.

audioOutputs vem vazio onde o browser não tem roteamento de saída — Safari e Firefox não implementam setSinkId. Vazio ali significa "você não pode oferecer essa escolha", não "não há alto-falantes". Cheque com isAudioOutputSelectionSupported() antes de renderizar o seletor, ou o controle é uma mentira em dois dos três motores.

import { setAudioOutput } from "tempest-react-sdk";

const ok = await setAudioOutput(audioRef.current, dispositivoEscolhido);
if (!ok) toast("Este navegador não permite escolher a saída de som.");

playAudio também aceita sinkId, útil pra um chime cair no fone enquanto o áudio da chamada fica no alto-falante.

Montando você mesmo: os hooks

import { useMicrophone, useAudioRecorder } from "tempest-react-sdk";

export function GravadorProprio() {
  const mic = useMicrophone({ deviceId: undefined, noiseSuppression: true });
  const rec = useAudioRecorder(mic.stream, { maxDurationMs: 60_000 });

  return (
    <>
      <button onClick={mic.start} disabled={mic.status === "ready"}>
        Liberar microfone
      </button>
      <button onClick={rec.start} disabled={!rec.ready}>
        Gravar
      </button>
      <button onClick={() => void rec.stop()}>Parar</button>
      <meter min={0} max={1} value={rec.level} />
      <span>{rec.durationMs} ms</span>
      {mic.error && <p role="alert">{mic.error.message}</p>}
    </>
  );
}

stop() no microfone não é opcional

Soltar a última referência de um MediaStream não desliga o microfone. Cada track tem que ser parada à mão — senão o browser continua mostrando o indicador de gravação, o SO mantém o dispositivo ocupado, e o próximo getUserMedia (em outra aba do mesmo app, tipicamente) falha com NotReadableError. O useMicrophone para as tracks no stop(), no unmount e antes de reabrir.

O gravador não é dono do stream

rec.stop() deixa o microfone aberto de propósito, pra uma segunda gravação não precisar de outro round-trip de permissão. Fechar é do mic.stop().

Erros classificados

useMicrophone().error já vem traduzido de DOMException para algo em que dá pra ramificar:

kind Causa Ação
insecure página em HTTP puro a correção é uma URL, não uma configuração
unsupported motor sem captura outro navegador
permission-denied negado configurações do site
not-found nenhum dispositivo, ou nenhum que casa com as constraints relaxar deviceId
in-use hardware ocupado fechar o outro app/aba
unknown o resto mostra a mensagem original

A ordem importa: um mediaDevices ausente é quase nunca "este navegador não faz áudio" — é uma página em HTTP, onde a API inteira simplesmente não existe. Reportar unsupported ali manda o dev procurar polyfill pra um problema que um https:// resolve.

Nível de gravação

useAudioRecorder já publica level (0–1) a 10 Hz. Pra uma barra por frame, use o createLevelMeter direto e escreva no DOM:

const meter = createLevelMeter(stream);
const tick = () => {
  barra.style.transform = `scaleX(${meter.level()})`;
  raf = requestAnimationFrame(tick);
};
// ...e sempre meter.stop() no cleanup

Medidor não é enfeite

Uma entrada mutada no SO, ou um headset com o braço do mic dobrado pra cima, produz uma gravação perfeitamente bem-sucedida de silêncio — e sem nível visível o usuário só descobre depois de terminar de falar.

O valor é RMS, não pico: pico reage a uma amostra só e pisca, RMS acompanha volume percebido. O ataque é instantâneo e a queda é suavizada, que é como todo medidor de hardware se comporta — um que atrasa na subida parece "não está gravando".

Feche o AudioContext

O medidor cria um AudioContext e o stop() fecha. Navegadores limitam contextos vivos (Chrome permite ~6), então um medidor esquecido em unmount quebra todos os próximos da página. Os hooks e os componentes já fecham; se você usar o createLevelMeter cru, o stop() é seu.

Formato: o que dá e o que não dá

MediaRecorder não produz MP3 nem WAV — em nenhum navegador

Chromium e Firefox produzem Opus (em WebM ou Ogg); Safari produz AAC (em MP4). Nenhum motor implementa encoder MP3 ou WAV pra ele. O padrão do SDK negocia nessa ordem e devolve o que saiu de fato — AudioRecording.mimeType é o que o browser reportou, não o que você pediu.

Se o backend só aceita WAV, o blobToWav converte no cliente, com zero dependência: decodifica com o decoder do próprio browser (decodeAudioData) e reencoda RIFF/PCM 16-bit.

import { blobToWav } from "tempest-react-sdk";

const wav = await blobToWav(recording.blob, { mono: true, sampleRate: 16000 });

WAV custa ~10× mais bytes

A mesma nota de voz que tem 40 KB em Opus fica em torno de 500 KB em WAV a 48 kHz estéreo. { mono: true, sampleRate: 16000 } leva isso pra ~80 KB — e 16 kHz mono é o que um endpoint de speech-to-text quer de todo jeito. O reamostrador é o OfflineAudioContext, ou seja o do próprio browser, não um escrito à mão.

Se o backend só aceita MP3: transcodifique no servidor. Um encoder MP3 no cliente significa um build WASM da ordem de 150 KB no bundle de todo consumidor do SDK pra servir um formato — é a troca que este SDK não faz.

Fora do React: os primitivos

useAudioRecorder é uma casca fina em cima de um gravador que não sabe nada de React. Quando a gravação acontece longe de um componente — num store, num worker, numa máquina de estados — use o primitivo direto:

import { createAudioRecorder, isAudioRecordingSupported } from "tempest-react-sdk";

if (!isAudioRecordingSupported()) throw new Error("Este navegador não grava áudio");

const rec = createAudioRecorder(stream, { audioBitsPerSecond: 48_000 });
rec.start();
// ...
const recording = await rec.stop(); // { blob, mimeType, durationMs }

O handle expõe start, pause, resume, stop, cancel, mais os leitores status() e durationMs() (que descontam o tempo pausado) e o mimeType negociado.

Símbolo O que é
createAudioRecorder(stream, opts) O gravador por trás do hook. Não é dono do stream — pare o mic você mesmo.
isAudioRecordingSupported() MediaRecorder existe e algum container é produzível.
isMediaCaptureSupported() A mesma pergunta para captura em geral (getUserMedia + MediaRecorder) — use antes de oferecer qualquer botão de gravar.
pickAudioMimeType(preferred?) Primeiro container da lista que o browser realmente produz, ou null.
AUDIO_MIME_CANDIDATES A ordem que o SDK negocia: Opus (WebM/Ogg) → AAC (MP4).
encodeWav({ channels, sampleRate }) RIFF/PCM 16-bit a partir de canais Float32 — o motor do blobToWav.

isAudioRecordingSupported() responde a pergunta certa

Checar só typeof MediaRecorder !== "undefined" deixa passar o motor que tem a API e não produz nenhum dos containers — a falha aparece no start(), com o usuário já esperando. Esta função checa as duas coisas.

Upload longo: chunks

const rec = useAudioRecorder(mic.stream, {
  timesliceMs: 5_000,
  onChunk: (chunk) => void upload(chunk),
});

Sem timesliceMs a gravação inteira fica em memória até o stop() — ok pra nota de voz, não ok pra uma reunião de uma hora. Os chunks não são tocáveis isoladamente: só o conjunto forma um arquivo válido.

Recap

  • playAudio(src, options) — som one-off no player compartilhado; retorna null se o autoplay foi bloqueado. stopAudio() para esse player.
  • useAudio() — player privado por componente com play/stop/unlocked e cleanup no unmount.
  • createAudioPlayer() — canal isolado pra tocar sons simultâneos sem um cortar o outro.
  • A autoplay policy é tratada devolvendo null; destrave o áudio na primeira interação.
  • O SDK não traz arquivos de áudio — você serve e passa a URL.
  • AudioRecorder — nota de voz completa: permissão, nível, relógio, revisão, retake.
  • AudioPlayer — transporte pra um clipe; aceita Blob e precisa de durationMs pra gravação nova.
  • useMediaPermission — estado da permissão sem disparar o prompt; separa "nunca pedi" de "negado" (que é sticky).
  • useMediaDevices — mics e saídas, reage a devicechange; labelsAvailable diz quando o seletor vale a pena.
  • useMicrophone — stream + erro classificado; stop() precisa ser chamado ou o indicador de gravação não apaga.
  • useAudioRecorder — status, relógio que desconta pausa, nível, maxDurationMs, chunks.
  • blobToWav — WAV sem dependência, ~10× os bytes; MP3 fica no servidor.
  • setAudioOutput / isAudioOutputSelectionSupported — roteamento de saída, só Chromium.

Veja também

  • SSE / Push — gatilhos típicos de áudio