Skip to content
Dethink Components

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 files

Import the component into your page or component file.

Usage
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
examples/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
examples/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
examples/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
examples/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.

Voice memo

Record a note and play it back in place.

Uses your mic

Tap the mic to start. Tap again to stop.

Show sourceexamples/sound-input/recipe-voice-memo.tsx
examples/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&apos;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&apos;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.

Type or start voice input

Show sourceexamples/sound-input/recipe-ai-composer.tsx
examples/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.

SoundInput props
PropWhat it doesDefault
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"
mutedbooleanExternally controlled mute state. Active audio tracks are disabled while the stream stays open.false
audioboolean | MediaTrackConstraintsAudio 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) => voidCalled with the live stream after microphone permission succeeds.Not set
onStop(stream, reason: "user" | "unmount" | "track-ended") => voidCalled after SoundInput stops its owned tracks because of user stop, unmount, or track end.Not set
onError(error: unknown, state: SoundInputState) => voidCalled when microphone access is unsupported or permission is rejected.Not set
onStateChange(state: SoundInputState) => voidReceives the public state whenever SoundInput changes state.Not set
…native button propsButtonHTMLAttributesRenders a real button and supports disabled, type, form, className, and event props except aria-label/aria-labelledby.Not set