Voice Recorder
A real MediaRecorder-backed voice recorder — live AnalyserNode waveform, pause/resume, mic-permission handling, and a listen-before-you-send preview.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/voice-recorder.jsonPrompt
The prompt behind this component — paste it into your AI assistant to recreate or adapt it.
Build a React + TypeScript + Tailwind "VoiceRecorder" component using the
real browser MediaRecorder + Web Audio APIs — no fake waveforms, no canned
audio.
Contract
- Export a forwardRef div extending React.HTMLAttributes<HTMLDivElement>.
- Props: onRecordingComplete(blob: Blob, durationMs: number) — required,
fires when the user confirms a take; maxDurationMs (default 120_000,
auto-stops the recording once elapsed); mimeType?: string (only honored
if MediaRecorder.isTypeSupported approves it, else falls back through a
candidate list); barCount (default 32, number of live waveform bars);
showPlayback (default true, shows a play/pause control on the finished
take); labels (partial copy override object); disabled (blocks *starting*
a new recording — never traps one already in progress).
Behavior — state machine
- States: unsupported | idle | requesting | recording | paused | preview |
denied | error.
- unsupported is a capability read (`typeof window.MediaRecorder !==
"undefined" && navigator.mediaDevices?.getUserMedia`), never taken from
the other 7 states — read it via useSyncExternalStore with a `false`
server snapshot (capability checks can't be read at render time: SSR and
first paint always render "unsupported", then silently re-render with the
real client snapshot right after hydration).
- getUserMedia resolves only after the user answers the permission prompt, which
can outlive the component: guard the resolution with a mounted ref and, if the
component is gone, stop the returned stream's tracks right there. Without it the
late resolution installs a live mic + rAF loop on an unmounted tree that nothing
can release (the unmount cleanup already ran, and it could not see a stream that
did not exist yet) — the browser's recording indicator stays lit until reload.
Re-arm that ref on mount, or StrictMode's mount -> cleanup -> mount leaves it
false for the live instance.
- idle → tap record → requesting → await getUserMedia({audio:true}):
resolve → recording; reject with NotAllowedError/PermissionDeniedError →
denied (show the reason + a Retry button that calls getUserMedia again,
for real); any other DOMException/Error → error (show a generic message,
optionally the caught error's message, + Retry).
- recording: build an AudioContext → createMediaStreamSource(stream) →
createAnalyser() (fftSize 256), connect source→analyser ONLY (never to
destination, or the mic echoes back out of the speakers). Create the
MediaRecorder on the same stream, picking a mimeType via
MediaRecorder.isTypeSupported. A requestAnimationFrame loop calls
analyser.getByteFrequencyData, downsamples the frequency bins into
`barCount` averaged buckets (0..1), and writes them into state for the
bars. The same loop tracks elapsed time as accumulated-so-far +
(performance.now() - segmentStart) so pause/resume math is exact, and
auto-stops once elapsed >= maxDurationMs. Timer text is mm:ss, computed
by hand (no Intl — locale-dependent formatting during SSR/hydration would
desync from the client).
- Pause calls mediaRecorder.pause(), folds the elapsed segment into an
accumulator, and cancels the rAF loop — the waveform freezes on its last
frame instead of animating on stale data. Resume calls .resume(), starts
a fresh segment clock, and restarts the loop.
- Stop (button or maxDurationMs) cancels the rAF loop, calls
mediaRecorder.stop(), and — without waiting for the async "stop" event —
immediately stops every stream track and closes the AudioContext, so the
browser's recording indicator turns off right away. The "stop" event
assembles the Blob from the collected chunks, creates one objectURL, and
moves to preview.
- preview shows the duration, an optional play/pause control bound to the
objectURL, "Re-record" (revokes the URL, discards the take, and jumps
straight back into requesting) and "Use recording" (calls
onRecordingComplete(blob, durationMs), revokes the URL, and resets to
idle so the control is ready for another take).
- Cleanup is centralized: cancelAnimationFrame, mediaRecorder.stop() only
if still active, stream.getTracks().forEach(t => t.stop()),
audioContext.close(), URL.revokeObjectURL — all reachable from one
teardown path that also runs on unmount (clearing the recorder's
ondataavailable/onerror/onstop handlers first, so a late async event
can't fire into an unmounted component).
Rendering & styling
- Semantic tokens only: bg-card/border for the shell, bg-primary +
text-primary-foreground for the primary record/stop button and the
waveform bars, bg-muted for secondary icon buttons and the idle waveform
track, text-muted-foreground for supporting text, text-destructive /
bg-destructive for the error icon and the recording indicator dot.
- Waveform bars are aria-hidden (the timer already conveys enough for
assistive tech) and use transition-[height] for a smooth per-frame update,
turned off via motion-reduce:transition-none.
- The recording indicator dot is a purely decorative pulse
(motion-safe:animate-pulse) — it adds nothing functional beyond the
"Recording" label + timer, so it's fully disabled under reduced motion
instead of just slowed down.
- Timer: role="timer" aria-live="off" while ticking (announcing mm:ss every
second would be constant screen-reader noise); a separate sr-only
role="status" aria-live="polite" element announces the total duration
once, exactly when the take finishes — the two are deliberately split so
the live ticking never gets announced but the final result always does.
- All icon buttons: focus-visible:ring-2 ring-ring, disabled:opacity-50.
Customization levers
- Waveform density / feel: barCount (any positive int — the analyser's
frequency bins get re-averaged into however many bars you ask for) and
fftSize (256 by default; raise for finer frequency resolution at a
slightly higher CPU cost).
- Recording ceiling: maxDurationMs — short for voice-message-style UIs,
long (or a very large number) for voice-memo style.
- Preview surface: showPlayback=false to hide the built-in play/pause and
drive your own playback UI from the confirmed Blob instead.
- Copy: labels accepts a partial override object (idle/requesting/
recording/paused/preview/record/pause/resume/stop/reRecord/use/retry/
denied/error/unsupported) — swap any subset for i18n or brand voice.
- Encoding: mimeType lets you request a specific container/codec; it's only
applied if the browser's MediaRecorder.isTypeSupported agrees, otherwise
the component silently falls back through a small candidate list.
- Palette: swap bg-primary / bg-muted / bg-destructive for other tokens
(e.g. bg-chart-2) to match a chat bubble or brand accent color.Concepts
- Capability probe, not a state-machine branch —
unsupportedis read once viauseSyncExternalStore(server snapshotfalse) rather than being a transition any other state can reach; a browser either hasMediaRecorderor it never leaves that branch. - Segment-accumulated timer — elapsed time is
accumulated + (now - segmentStart), re-anchored on every pause/resume, so the displayed duration (and themaxDurationMsauto-stop check) is exact even across multiple pauses. - AnalyserNode waveform, never wired to output — the mic stream feeds an
AnalyserNodeforgetByteFrequencyDataonly; it's deliberately never connected toaudioContext.destination, or the user would hear their own voice echoed back live. - Split live-region announcement — the visible mm:ss timer is
aria-live="off"(ticking it every second would be constant noise), while a separatearia-live="polite"region announces the finished duration exactly once, when the take completes. - Centralized teardown — one code path stops the MediaRecorder, stops every stream track, closes the AudioContext, cancels the animation frame, and revokes the object URL; it runs on Stop, on Re-record, and again (idempotently) on unmount, so a recording in progress can never leak a live mic after the component disappears.
- Preview owns its own Blob URL — the objectURL created for playback is internal and revoked on Re-record/Use/unmount; the consumer receives the raw
BlobviaonRecordingCompleteand is free to create its own URL from it.