49 / Agent UI
Voice Input
A microphone that draws what it is hearing, so a live one is told apart from a dead one at a glance.
"use client"
import * as React from "react"
import { VoiceInput } from "mischief-ui/voice-input"
export function VoiceInputDemo() {
const [recording, setRecording] = React.useState<{
url: string
seconds: number
} | null>(null)
const startedAt = React.useRef(0)
React.useEffect(
() => () => {
if (recording) URL.revokeObjectURL(recording.url)
},
[recording]
)
return (
<div className="w-full max-w-md space-y-3">
<VoiceInput
maxDuration={30}
onStart={() => {
startedAt.current = Date.now()
setRecording(null)
}}
onResult={(blob) => {
setRecording({
url: URL.createObjectURL(blob),
seconds: (Date.now() - startedAt.current) / 1000,
})
}}
/>
{recording ? (
<div className="border-border bg-muted/40 space-y-2 rounded-lg border p-3">
<p className="text-muted-foreground text-xs">
{recording.seconds.toFixed(1)} seconds captured. Nothing left this
page: the recording is a blob in memory, for you to send wherever it
gets read.
</p>
<audio controls src={recording.url} className="w-full" />
</div>
) : null}
</div>
)
}Installation
Copy the source into your project, or keep it behind a package.
npx shadcn@latest add Tinkerers-Labs/mischief-ui/voice-inputimport { VoiceInput } from "mischief-ui/voice-input"Or paste it in yourself. The source imports the shared cn helper from @/lib/utils, so point that at your own copy.
"use client" import * as React from "react"import { Mic, MicOff, Square } from "lucide-react" import { RenderSurface, useThemeColors, type SurfaceColor,} from "@/registry/default/render-surface/render-surface"import { cn } from "@/lib/utils" export type VoiceInputStatus = "unsupported" | "idle" | "requesting" | "listening" | "denied" | "error"Usage
export function Composer() {
return (
<VoiceInput
maxDuration={60}
onResult={(recording) => transcribe(recording)}
/>
)
}It records; it does not transcribe
Turning speech into text is a service, not a component. Putting one inside something you copy into your own project would decide your vendor, your billing and your privacy posture on your behalf, so this stops at the recording and hands it to you.
<VoiceInput
onResult={async (recording) => {
const body = new FormData()
body.append("audio", recording, "speech.webm")
setText(await (await fetch("/api/transcribe", { method: "POST", body })).text())
}}
/>Why it draws
A microphone button that only changes colour asks to be trusted. There is no way to tell a working microphone from a muted one, a wrong input device, or a permission that was granted to the page and then revoked by the operating system, until the recording comes back empty.
Drawing the incoming samples settles it in the first half second: if the trace moves when you speak, the microphone the browser handed over is the one you are talking into.
The trace is drawn on the shared render surface, so it takes its colour from your theme, stops when it is scrolled out of view, and survives a lost GPU context like every other surface here.
The states it can be in
A refusal, a missing device and a browser that cannot record are three different problems with three different remedies, so they are three different messages rather than one failure.
| Status | What happened |
|---|---|
unsupported | No MediaRecorder, so the control is disabled rather than dead |
idle | Ready, nothing held |
requesting | Waiting on the permission prompt |
listening | Recording, and drawing what it hears |
denied | Permission refused |
error | The device could not be started |
The status is also on the element as data-status, so a composer can style around it without lifting the state.
Letting go of the microphone
The recording indicator staying lit after a component thinks it has stopped is the usual bug here, and it is a privacy one. Every track is stopped and the audio context is closed when recording ends, when the component unmounts, and when a start fails partway through.
API
onResult(recording: Blob) => voidThe audio, once recording stops.onStart, onStop() => voidEither end of a recording.onStatusChange(status: VoiceInputStatus) => voidEvery state change, if you are mirroring it elsewhere.maxDurationnumberSeconds after which it stops on its own. Off by default.colorstringA theme token for the trace. Defaults to "--primary".mimeTypestringPreferred container. Ignored when the browser cannot honour it.labelstringThe button's accessible name when idle.disabledbooleanTurns the control off without changing its state.Accessibility
The button carries aria-pressed, so the difference between recording and not is in the accessibility tree rather than only in the icon. Every state change is announced through a polite live region, and the visible message is marked aria-hidden because it is the same sentence: it is said once, not once on screen and once aloud. The trace is decoration and never carries meaning the words do not, which matters because under prefers-reduced-motion it is not drawn at all -- a single painted frame would sit frozen while the microphone was open, so the words and the elapsed time take over instead.