Skip to content

Audio

Audio in the browser, both directions.

Playback — sound notifications (message chime, payment confirmation) with playAudio, useAudio and createAudioPlayer, plus the AudioPlayer component when you need a transport (play/pause, bar, times).

CaptureAudioRecorder for a one-line voice note, and underneath it useMediaPermission, useMediaDevices, useMicrophone, useAudioRecorder, createLevelMeter and blobToWav. No new dependency: the whole slice measures 5.50 KB brotli.

If you just want to record a voice note, skip to Recording

The rest of the page is the layer underneath, for when the component does not fit.

Why a wrapper around new Audio()?

Playing sound in the browser runs into the autoplay policy and leaking Audio elements. The SDK encapsulates it: it tracks the current clip (so you can stop it), normalizes volume, handles the autoplay block by returning null instead of throwing, and cleans up on unmount when you use the hook.

playAudio — one-off on the shared player

Ideal for a sound fired by an event, with no UI state:

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/money.mp3", { volume: 0.5 });
      }
    },
  });

  return null;
}

playAudio(src, options) returns Promise<HTMLAudioElement | null>null when the browser blocked autoplay. Options: volume (0–1, default 1), loop, autoplay, stopPrevious, onEnded, onError. To stop whatever the shared player is playing, use stopAudio().

useAudio — private per-component player

Each hook instance gets its own player, so unmounting stops audio automatically:

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 ? "" : "(tap to enable sound)"}
    </button>
  );
}
  • audio.play(src, options) — plays on the private player (same options as playAudio).
  • audio.stop() — stops the current clip.
  • audio.unlocked — becomes true after the first successful play(). Useful to hide UI asking for the initial interaction.
  • Automatic cleanup on unmount.

Use unlocked to guide the user

Before the first click, the browser blocks audio. Show a hint ("tap to enable sound") while unlocked === false and hide it as soon as it flips to true.

createAudioPlayer — isolated channels

createAudioPlayer() creates a tracker independent from the default. Use it when you need to play two sounds simultaneously without one cutting off the other (e.g. background music + a sound effect):

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 }); // does not cut the music

music.stop(); // stops only the music
console.log(sfx.current()); // HTMLAudioElement | null

Each player tracks one current clip. stopPrevious: true in play() stops that same player's previous clip before playing the new one.

createSfxPool / useSfxPool — short sound effects

new Audio(src) on every play allocates an element and re-enters the network stack for a file the browser already has. For a sound that fires dozens of times a minute — a menu blip, a hit, a pickup — that is the wrong shape. The pool allocates once per source and replays.

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")}>Confirm</button>;
}
  • play(src, { volume }) — the per-play volume is multiplied by the pool's master.
  • preload(src | src[]) — fetch ahead of the first play, so it is not silent while the file downloads.
  • setVolume(v) — also applies to whatever is already sounding, rescaled by each clip's own gain: one that started at half volume is not yanked up.
  • stop(src?) — stop one source, or all of them.
  • dispose() — release everything. useSfxPool calls it on unmount.

voices: restart or overlap

The default (voices: 1) restarts the clip on every play, which is what a menu blip wants. Raise it to let the sound overlap itself — a hit landing while the previous one still rings:

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

Not the same as createAudioPlayer

createAudioPlayer tracks one current clip, with loop, output routing and lifecycle callbacks — what background music needs. Effects are the opposite case: many sources, all short, fire-and-forget, and the only thing that matters is that firing one is cheap.

Changing volume on useSfxPool calls setVolume on the existing pool rather than rebuilding it — rebuilding would throw away every element the user has already downloaded, which is exactly the cost the pool exists to avoid. baseUrl, voices and maxSources are read once, at creation.

Autoplay policy

Browsers block playback before the user's first interaction. playAudio / play() return null when blocked (and call onError if provided) — instead of throwing.

Unlock audio on the first click

You can't play sound before any interaction. Design the app to fire a play() (even of a short silent clip) on the first click of any button; from then on the browser allows the rest.

Assets

The SDK does not bundle audio files. Serve them at /audio/* (or a CDN) and pass the URL. Sound-palette inspiration (alofans):

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

Recording: start with the component

If all you want is a voice note, it is one line. AudioRecorder handles the permission, the level meter, the clock and the review before you ever see the audio:

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

export function VoiceNote({ ticketId }: { ticketId: string }) {
  return (
    <AudioRecorder
      maxDurationMs={120_000}
      onRecorded={({ blob, mimeType, durationMs }) => {
        const form = new FormData();
        form.append("audio", blob, `note.${mimeType.includes("mp4") ? "m4a" : "webm"}`);
        form.append("duration", String(durationMs));
        void fetch(`/api/tickets/${ticketId}/audio`, { method: "POST", body: form });
      }}
      footer={<small>Two minutes maximum.</small>}
    />
  );
}

What you get without writing any of it:

You did The component does
nothing Does not ask for the microphone on mount — only on the first press of Record
nothing When the permission is already denied, says so and how to fix it, instead of offering a button that cannot work
nothing Live level meter, a clock that subtracts paused time, pause/resume
maxDurationMs Stops itself at the cap, and shows the cap next to the clock
nothing Review player with a transport, and "Record again" reusing the same stream
format="wav" Converts before handing over, so onRecorded always gives you the format you asked for

The permission prompt does not fire on mount, and that is the most important decision on this page

A prompt the user did not provoke is the most reliable way to earn a permanent Block — after which getUserMedia rejects without ever prompting again. So the microphone opens on the first press of Record, and the press survives the round-trip: the component arms the recording and starts when the stream lands. Waiting for a second click would make the first one look broken.

Props

Prop Type Default What it does
onRecorded (recording: AudioRecording) => void Receives the finished audio. Never fires on cancel.
maxDurationMs number Stops itself. Always worth setting on a public screen.
deviceId string Specific microphone, from useMediaDevices().audioInputs.
format "native" \| "wav" "native" "wav" converts on stop. See the cost below.
wavOptions WavOptions { mono: true, sampleRate: 16000 } suits speech.
audioBitsPerSecond number 32000–64000 is plenty for voice in Opus.
review boolean true Review player before handing over.
locale "pt-BR" \| "en" "pt-BR" Labels.
footer ReactNode A hint, a count, a legal notice.
onError (error: unknown) => void Recorder failure, or a WAV conversion that did not work.

AudioRecording = { blob, mimeType, durationMs }.

AudioPlayer — a transport for one clip

Takes a Blob directly, because the thing an app most often plays is the recording it just made:

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

Pass durationMs whenever you have it — <audio> lies about a fresh recording

MediaRecorder writes WebM with no duration in the header, so <audio>.duration on a fresh recording is Infinity. That is why the recorder keeps its own clock and you should pass it along. Without it the component applies the only workaround there is — seeking past the end to force the browser to demux to the last frame — and the bar stays stuck until that resolves.

Prop Type Default What it does
src string \| Blob \| null URL or blob. A blob becomes an object URL that is revoked for you.
durationMs number Known length. Wins over the element's.
sinkId string Chosen output. Chromium only.
autoPlay · loop boolean false As on the native element.
actions ReactNode Right of the times — download, delete.
onEnded · onError () => void End, and decode/network failure.

Permission without firing the prompt

useMediaPermission reads the state without asking for the device. It is what makes a decent flow possible at all:

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

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

  if (state === "denied") {
    return <p>Microphone blocked. Allow it in site settings and reload.</p>;
  }
  return (
    <button onClick={onStart}>
      {state === "prompt" || !supported ? "Allow microphone and record" : "Record"}
    </button>
  );
}
State Means What the UI should do
"prompt" never asked a button; asking shows the prompt
"granted" allowed a normal button
"denied" sticky — asking again rejects immediately, with no prompt instructions for site settings, not a button
"unknown" the Permissions API did not answer (Safari does not expose microphone) treat as "you will have to ask to find out"

The state is live: if the user changes the permission in site settings, the hook updates.

Devices: microphone and output

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

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

The names only appear after permission

Before the user allows a capture, every label is "" — the ids and the count are real, the names are not. A picker rendered there is a column of blanks. Use labelsAvailable to decide when to show it. Ask for the microphone first, then offer the choice.

The list changes while the page is open

Plugging in a headset mid-recording is the normal case, not an edge case. The hook subscribes to devicechange instead of enumerating once on mount.

audioOutputs comes back empty where the browser has no output routing — Safari and Firefox do not implement setSinkId. Empty there means "you cannot offer this choice", not "there are no speakers". Check isAudioOutputSelectionSupported() before rendering the picker, or the control is a lie on two of three engines.

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

const ok = await setAudioOutput(audioRef.current, chosenDevice);
if (!ok) toast("This browser cannot choose the sound output.");

playAudio takes sinkId too, which is useful for a chime that must land on a headset while the call audio stays on the speakers.

Building it yourself: the hooks

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

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

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

stop() on the microphone is not optional

Dropping the last reference to a MediaStream does not turn off the microphone. Every track has to be stopped by hand — otherwise the browser keeps showing its recording indicator, the OS keeps the device busy, and the next getUserMedia (in another tab of the same app, typically) fails with NotReadableError. useMicrophone stops tracks on stop(), on unmount, and before re-opening.

The recorder does not own the stream

rec.stop() deliberately leaves the microphone open, so a second take does not need another permission round-trip. Closing belongs to mic.stop().

Classified errors

useMicrophone().error arrives already translated from DOMException into something you can branch on:

kind Cause Action
insecure page on plain HTTP the fix is a URL, not a setting
unsupported engine with no capture another browser
permission-denied denied site settings
not-found no device, or none matching the constraints relax deviceId
in-use hardware busy close the other app/tab
unknown everything else shows the original message

The order matters: a missing mediaDevices is almost never "this browser cannot do audio" — it is a page on HTTP, where the whole API simply does not exist. Reporting unsupported there sends the developer looking for a polyfill for a problem an https:// URL fixes.

Recording level

useAudioRecorder already publishes level (0–1) at 10 Hz. For a per-frame bar, use createLevelMeter directly and write to the DOM:

const meter = createLevelMeter(stream);
const tick = () => {
  bar.style.transform = `scaleX(${meter.level()})`;
  raf = requestAnimationFrame(tick);
};
// ...and always meter.stop() in cleanup

A meter is not decoration

A muted OS input, or a headset with its mic arm folded up, produces a perfectly successful recording of silence — and with no visible level the user only finds out after they finish talking.

The value is RMS, not peak: peak reacts to a single sample and flickers, RMS tracks perceived loudness. Attack is instant and release is eased, which is how every hardware meter behaves — one that lags on the way up reads as "not recording".

Close the AudioContext

The meter creates an AudioContext and stop() closes it. Browsers cap live contexts (Chrome allows around six), so a meter left running on unmount breaks every later one on the page. The hooks and the components close it for you; with a raw createLevelMeter, stop() is yours.

Format: what you can and cannot have

MediaRecorder produces neither MP3 nor WAV — in any browser

Chromium and Firefox produce Opus (in WebM or Ogg); Safari produces AAC (in MP4). No engine implements an MP3 or WAV encoder for it. The SDK default negotiates in that order and reports what actually came out — AudioRecording.mimeType is what the browser said, not what you asked for.

If the backend only takes WAV, blobToWav converts client-side with zero dependency: it decodes with the browser's own decoder (decodeAudioData) and re-encodes RIFF/PCM 16-bit.

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

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

WAV costs about 10× the bytes

The same voice note that is 40 KB in Opus lands around 500 KB as WAV at 48 kHz stereo. { mono: true, sampleRate: 16000 } takes that to roughly 80 KB — and 16 kHz mono is what a speech-to-text endpoint wants anyway. The resampler is OfflineAudioContext, i.e. the browser's own, not a hand-rolled one.

If the backend only takes MP3: transcode on the server. An MP3 encoder in the client means a WASM build of the order of 150 KB in every SDK consumer's bundle to serve one format — the trade this SDK does not make.

Outside React: the primitives

useAudioRecorder is a thin shell over a recorder that knows nothing about React. When recording happens away from a component — in a store, a worker, a state machine — reach for the primitive directly:

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

if (!isAudioRecordingSupported()) throw new Error("This browser cannot record audio");

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

The handle exposes start, pause, resume, stop, cancel, plus the readers status() and durationMs() (both discounting paused time) and the negotiated mimeType.

Symbol What it is
createAudioRecorder(stream, opts) The recorder behind the hook. It does not own the stream — stop the mic yourself.
isAudioRecordingSupported() MediaRecorder exists and some container is producible.
isMediaCaptureSupported() The same question for capture as a whole (getUserMedia + MediaRecorder) — use it before offering a record button of any kind.
pickAudioMimeType(preferred?) First container in the list the browser actually produces, or null.
AUDIO_MIME_CANDIDATES The order the SDK negotiates: Opus (WebM/Ogg) → AAC (MP4).
encodeWav({ channels, sampleRate }) RIFF/PCM 16-bit from Float32 channels — the engine behind blobToWav.

isAudioRecordingSupported() asks the right question

Checking only typeof MediaRecorder !== "undefined" lets through the engine that has the API and produces none of the containers — the failure then surfaces at start(), with the user already waiting. This function checks both.

Long uploads: chunks

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

Without timesliceMs the whole recording sits in memory until stop() — fine for a voice note, not fine for an hour-long meeting. Chunks are not independently playable: only the set forms a valid file.

Recap

  • playAudio(src, options) — one-off sound on the shared player; returns null if autoplay was blocked. stopAudio() stops that player.
  • useAudio() — private per-component player with play/stop/unlocked and unmount cleanup.
  • createAudioPlayer() — isolated channel to play simultaneous sounds without one cutting off the other.
  • The autoplay policy is handled by returning null; unlock audio on the first interaction.
  • The SDK ships no audio files — you serve them and pass the URL.
  • AudioRecorder — a complete voice note: permission, level, clock, review, retake.
  • AudioPlayer — a transport for one clip; takes a Blob and needs durationMs for a fresh recording.
  • useMediaPermission — permission state without firing the prompt; separates "never asked" from "denied" (which is sticky).
  • useMediaDevices — mics and outputs, reacts to devicechange; labelsAvailable says when a picker is worth showing.
  • useMicrophone — stream plus classified error; stop() must be called or the recording indicator never clears.
  • useAudioRecorder — status, a clock that subtracts pauses, level, maxDurationMs, chunks.
  • blobToWav — WAV with no dependency, about 10× the bytes; MP3 stays on the server.
  • setAudioOutput / isAudioOutputSelectionSupported — output routing, Chromium only.

See also

  • SSE / Push — typical audio triggers