ComponentsSoundInput
SoundInput
Capture microphone audio and show its input level.
On this page
Use this component from the local workspace package. Follow the setup guide first. A public npm package and registry are not available yet.
View registry filesImport the component into your page or component file.
import { SoundInput } from "@dethink/components";
export function Example() {
return <SoundInput onStream={(stream) => startTranscription(stream)} />;
}Try the examples, then open the code to use them in your app. The docs examples use local fake streams so they can be exercised without granting microphone access.
Basic
Activation requests a stream, calls onStream, and a second activation stops the owned tracks.
Ready for voice input
Show sourceexamples/sound-input/basic.tsx
"use client";
import { useState } from "react";
import { SoundInput, type SoundInputState } from "@dethink/components";
import { SoundInputDemoMedia } from "@/examples/_shared/sound-input-demo-media";
export function SoundInputBasic() {
const [state, setState] = useState<SoundInputState>("idle");
return (
<div className="flex w-full flex-col items-center gap-3">
<SoundInputDemoMedia>
<SoundInput
onStateChange={setState}
onStream={() => setState("recording")}
onStop={() => setState("idle")}
/>
</SoundInputDemoMedia>
<p aria-live="polite" className="text-muted-foreground text-sm">
{state === "idle"
? "Ready for voice input"
: state === "recording"
? "Live stream handed to the app"
: state === "muted"
? "Stream is open, audio tracks are muted"
: state === "permission-denied"
? "Permission was denied"
: state === "unsupported"
? "Microphone APIs are unavailable"
: "Requesting microphone permission"}
</p>
</div>
);
}Variants
SoundInput follows the same action hierarchy as Button and RevealButton.
Show sourceexamples/sound-input/variants.tsx
"use client";
import { SoundInput, type SoundInputVariant } from "@dethink/components";
import { SoundInputDemoMedia } from "@/examples/_shared/sound-input-demo-media";
const variants: SoundInputVariant[] = [
"solid",
"soft",
"outline",
"ghost",
"destructive",
];
export function SoundInputVariants() {
return (
<SoundInputDemoMedia>
<div className="gap-density-gap flex flex-wrap items-center justify-center">
{variants.map((variant) => (
<SoundInput
key={variant}
labels={{ idle: `${variant} voice input` }}
variant={variant}
/>
))}
</div>
</SoundInputDemoMedia>
);
}Sizes
The collapsed circle tracks component size; the active pill expands inline from that same height.
Show sourceexamples/sound-input/sizes.tsx
"use client";
import { SoundInput, type SoundInputSize } from "@dethink/components";
import { SoundInputDemoMedia } from "@/examples/_shared/sound-input-demo-media";
const sizes: SoundInputSize[] = ["xs", "sm", "md", "lg", "xl"];
export function SoundInputSizes() {
return (
<SoundInputDemoMedia>
<div className="gap-density-gap flex flex-wrap items-center justify-center">
{sizes.map((size) => (
<SoundInput
key={size}
labels={{ idle: `${size} voice input` }}
size={size}
variant="outline"
/>
))}
</div>
</SoundInputDemoMedia>
);
}States
Recording, muted, denied, unsupported, disabled, and reduced-motion states all keep native button semantics.
Recording
Muted
Permission denied
Unsupported
Disabled
Reduced motion
Show sourceexamples/sound-input/states.tsx
"use client";
import { useState } from "react";
import { Button, SoundInput } from "@dethink/components";
import { SoundInputDemoMedia } from "@/examples/_shared/sound-input-demo-media";
export function SoundInputStates() {
const [muted, setMuted] = useState(true);
return (
<div className="grid w-full gap-4 sm:grid-cols-2">
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">Recording</p>
<SoundInputDemoMedia>
<SoundInput labels={{ idle: "Start demo recording" }} />
</SoundInputDemoMedia>
</div>
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">Muted</p>
<div className="flex items-center gap-2">
<SoundInputDemoMedia>
<SoundInput
muted={muted}
labels={{ idle: "Start muted recording" }}
/>
</SoundInputDemoMedia>
<Button
size="sm"
variant="outline"
onClick={() => setMuted((value) => !value)}
>
{muted ? "Unmute" : "Mute"}
</Button>
</div>
</div>
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">
Permission denied
</p>
<SoundInputDemoMedia mode="denied">
<SoundInput labels={{ idle: "Try denied microphone" }} />
</SoundInputDemoMedia>
</div>
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">Unsupported</p>
<SoundInputDemoMedia mode="unsupported">
<SoundInput labels={{ idle: "Try unsupported microphone" }} />
</SoundInputDemoMedia>
</div>
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">Disabled</p>
<SoundInput disabled labels={{ idle: "Voice input unavailable" }} />
</div>
<div className="space-y-2">
<p className="text-muted-foreground text-sm font-medium">
Reduced motion
</p>
<SoundInputDemoMedia>
<SoundInput motion="none" labels={{ idle: "Start without motion" }} />
</SoundInputDemoMedia>
</div>
</div>
);
}Production-shaped compositions that wire SoundInput into app-owned workflows. The voice memo recipe uses your real microphone so the waveform reacts to live sound; the others run on local fake streams.
Record and play back
SoundInput hands the app a live MediaStream. Here the app records it with MediaRecorder and plays the take back inline — grant microphone access to try it.
Show sourceexamples/sound-input/recipe-voice-memo.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import {
Badge,
Button,
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
SoundInput,
} from "@dethink/components";
import { SoundInputLiveMedia } from "@/examples/_shared/sound-input-live-media";
type MemoStatus =
| "idle"
| "recording"
| "recorded"
| "playback-error"
| "denied"
| "unsupported";
interface VoiceMemo {
url: string;
durationMs: number;
type: string;
}
function formatDuration(ms: number) {
const totalSeconds = Math.round(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${String(seconds).padStart(2, "0")}`;
}
function pickMimeType() {
if (
typeof MediaRecorder === "undefined" ||
typeof MediaRecorder.isTypeSupported !== "function"
) {
return undefined;
}
// Prefer a container the recording browser can also play back in <audio>.
// Safari records/plays MP4/AAC (not Opus-in-WebM); Chrome/Firefox use WebM.
const candidates = [
"audio/mp4",
"audio/webm;codecs=opus",
"audio/webm",
"audio/ogg;codecs=opus",
"audio/ogg",
];
for (const type of candidates) {
if (MediaRecorder.isTypeSupported(type)) {
return type;
}
}
return undefined;
}
export function SoundInputRecipeVoiceMemo() {
const [status, setStatus] = useState<MemoStatus>("idle");
const [memo, setMemo] = useState<VoiceMemo | null>(null);
const [elapsedMs, setElapsedMs] = useState(0);
const recorderRef = useRef<MediaRecorder | null>(null);
const chunksRef = useRef<BlobPart[]>([]);
const startRef = useRef(0);
const timerRef = useRef<number | null>(null);
const memoRef = useRef<VoiceMemo | null>(null);
// Keep a live ref so cleanup can revoke the object URL without re-subscribing.
useEffect(() => {
memoRef.current = memo;
}, [memo]);
const clearTimer = () => {
if (timerRef.current !== null) {
window.clearInterval(timerRef.current);
timerRef.current = null;
}
};
useEffect(() => {
return () => {
clearTimer();
if (recorderRef.current && recorderRef.current.state !== "inactive") {
recorderRef.current.stop();
}
if (memoRef.current) {
URL.revokeObjectURL(memoRef.current.url);
}
};
}, []);
const handleStream = (stream: MediaStream) => {
if (typeof MediaRecorder === "undefined") {
setStatus("unsupported");
return;
}
// Drop any previous take so its blob URL is released.
if (memo) {
URL.revokeObjectURL(memo.url);
setMemo(null);
}
const mimeType = pickMimeType();
let recorder: MediaRecorder;
try {
recorder = new MediaRecorder(stream, mimeType ? { mimeType } : undefined);
} catch {
// Not a real MediaStream (e.g. a fake demo stream leaked in).
setStatus("unsupported");
return;
}
chunksRef.current = [];
recorder.addEventListener("dataavailable", (event) => {
if (event.data.size > 0) {
chunksRef.current.push(event.data);
}
});
recorder.addEventListener("stop", () => {
const type = recorder.mimeType || "audio/webm";
const blob = new Blob(chunksRef.current, { type });
chunksRef.current = [];
if (blob.size === 0) {
return;
}
setMemo({
url: URL.createObjectURL(blob),
durationMs: Date.now() - startRef.current,
type,
});
setStatus("recorded");
});
recorderRef.current = recorder;
startRef.current = Date.now();
setElapsedMs(0);
setStatus("recording");
// Timeslice so chunks flush during recording, not only at stop.
recorder.start(1000);
clearTimer();
timerRef.current = window.setInterval(() => {
setElapsedMs(Date.now() - startRef.current);
}, 100);
};
// Stop the recorder while the mic tracks are still live so the file is
// finalized cleanly. SoundInput stops its tracks on the click that follows
// this capture-phase pointerdown, so flushing here avoids a truncated blob.
const flushBeforeStop = () => {
const recorder = recorderRef.current;
if (recorder && recorder.state === "recording") {
clearTimer();
recorder.stop();
}
};
const handleStop = () => {
clearTimer();
const recorder = recorderRef.current;
// Fallback for track-ended (e.g. unplugged mic) with no preceding tap.
if (recorder && recorder.state !== "inactive") {
recorder.stop();
}
};
const discard = () => {
if (memo) {
URL.revokeObjectURL(memo.url);
}
setMemo(null);
setStatus("idle");
};
return (
<Card className="mx-auto w-full max-w-md">
<CardHeader>
<div className="flex items-start justify-between gap-3">
<div className="space-y-1">
<CardTitle>Voice memo</CardTitle>
<CardDescription>
Record a note and play it back in place.
</CardDescription>
</div>
<Badge
variant="soft"
tone={status === "recording" ? "destructive" : "neutral"}
size="sm"
>
{status === "recording" ? "● Live mic" : "Uses your mic"}
</Badge>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<SoundInputLiveMedia>
<span
className="inline-flex"
onPointerDownCapture={flushBeforeStop}
onKeyDownCapture={(event) => {
if (event.key === "Enter" || event.key === " ") {
flushBeforeStop();
}
}}
>
<SoundInput
variant="solid"
size="lg"
onStream={handleStream}
onStop={handleStop}
onError={(_error, state) => {
clearTimer();
setStatus(state === "unsupported" ? "unsupported" : "denied");
}}
labels={{
idle: "Record voice memo",
recording: "Stop recording",
}}
/>
</span>
</SoundInputLiveMedia>
<div aria-live="polite" className="min-w-0 text-sm">
{status === "recording" ? (
<p className="text-foreground font-medium tabular-nums">
Recording… {formatDuration(elapsedMs)}
</p>
) : status === "recorded" ? (
<p className="text-muted-foreground">
Saved a {formatDuration(memo?.durationMs ?? 0)} memo.
</p>
) : status === "playback-error" ? (
<p className="text-destructive">
Saved, but this browser can't play the recording back.
</p>
) : status === "denied" ? (
<p className="text-destructive">
Microphone permission was denied.
</p>
) : status === "unsupported" ? (
<p className="text-muted-foreground">
Recording isn't supported in this browser.
</p>
) : (
<p className="text-muted-foreground">
Tap the mic to start. Tap again to stop.
</p>
)}
</div>
</div>
{memo ? (
// eslint-disable-next-line jsx-a11y/media-has-caption -- user-recorded audio has no caption track.
<audio
className="w-full"
controls
preload="metadata"
src={memo.url}
aria-label="Voice memo playback"
onError={() => setStatus("playback-error")}
/>
) : null}
</CardContent>
{memo ? (
<CardFooter justify="between">
<span className="text-muted-foreground text-xs tracking-wide uppercase">
{memo.type.replace("audio/", "")}
</span>
<Button size="sm" variant="ghost" onClick={discard}>
Discard
</Button>
</CardFooter>
) : null}
</Card>
);
}AI composer
The app owns transcription or streaming work. SoundInput only owns permission, stream lifecycle, and visual state.
Show sourceexamples/sound-input/recipe-ai-composer.tsx
"use client";
import { useState } from "react";
import {
Button,
Field,
FieldControl,
FieldLabel,
SoundInput,
Textarea,
type SoundInputState,
} from "@dethink/components";
import { SoundInputDemoMedia } from "@/examples/_shared/sound-input-demo-media";
export function SoundInputRecipeAiComposer() {
const [state, setState] = useState<SoundInputState>("idle");
const [muted, setMuted] = useState(false);
return (
<form
className="border-border bg-background mx-auto max-w-2xl rounded-lg border p-4"
onSubmit={(event) => event.preventDefault()}
>
<Field id="voice-prompt">
<FieldLabel>Ask the assistant</FieldLabel>
<FieldControl asChild>
<Textarea
rows={4}
resize="none"
placeholder="Summarize the deployment blockers from this incident..."
/>
</FieldControl>
</Field>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<p aria-live="polite" className="text-muted-foreground text-sm">
{state === "recording"
? "Voice stream is live"
: state === "muted"
? "Voice stream is muted"
: state === "permission-denied"
? "Microphone permission denied"
: "Type or start voice input"}
</p>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="ghost"
onClick={() => setMuted((value) => !value)}
>
{muted ? "Unmute" : "Mute"}
</Button>
<SoundInputDemoMedia>
<SoundInput
muted={muted}
variant="solid"
onStateChange={setState}
onStop={() => setState("idle")}
/>
</SoundInputDemoMedia>
<Button size="sm">Send</Button>
</div>
</div>
</form>
);
}SoundInputProps extends native button props, except it owns children and accessible names through state labels.
| Prop | What it does | Default |
|---|---|---|
variant"solid" | "soft" | "outline" | "ghost" | "destructive" | Visual treatment matching Button and RevealButton variants. | "soft" |
size"xs" | "sm" | "md" | "lg" | "xl" | Collapsed control size. The active pill expands inline from the same height. | "md" |
motion"none" | "subtle" | "standard" | Controls the pill and waveform choreography with Motion primitives. | "standard" |
mutedboolean | Externally controlled mute state. Active audio tracks are disabled while the stream stays open. | false |
audioboolean | MediaTrackConstraints | Audio constraints passed to getUserMedia when the user activates the control. | true |
labelsPartial<Record<SoundInputState, string>> | State-specific accessible names for idle, requesting, denied, recording, muted, and unsupported states. | built-in copy |
onStream(stream: MediaStream) => void | Called with the live stream after microphone permission succeeds. | Not set |
onStop(stream, reason: "user" | "unmount" | "track-ended") => void | Called after SoundInput stops its owned tracks because of user stop, unmount, or track end. | Not set |
onError(error: unknown, state: SoundInputState) => void | Called when microphone access is unsupported or permission is rejected. | Not set |
onStateChange(state: SoundInputState) => void | Receives the public state whenever SoundInput changes state. | Not set |
…native button propsButtonHTMLAttributes | Renders a real button and supports disabled, type, form, className, and event props except aria-label/aria-labelledby. | Not set |