# Voice Button

Voice Button [#voice-button]

**Voice Button** is a press-to-record / release-to-transcribe push-to-talk control with an explicit visual state machine: `idle → recording` (animated pulse rings + live waveform) `→ processing` (loader) `→ success` / `error`. Recording is the app's job ([`useVoiceRecorder`](https://localmode.dev/docs/react) → `getUserMedia` / `MediaRecorder`); transcription routes to local Whisper ([`useTranscribe`](https://localmode.dev/docs/react)). This component renders the state and emits press/release events.

**When to use it:** a single hands-on-keyboard (or touch) affordance to capture and transcribe a short spoken phrase — search-by-voice, quick notes, command input.

Preview [#preview]

```tsx
'use client';

import { useRef, useState } from 'react';
import { VoiceButton, type VoiceButtonState } from '@/components/voice-button';

/**
 * Demo for {@link VoiceButton}. Walks the push-to-talk state machine on a timer
 * (press → recording → processing → success) and feeds a simulated mic volume,
 * so the visual transitions are visible without microphone permission. The real
 * app wires `onStart`/`onStop` to `useVoiceRecorder` + `useTranscribe`.
 */
export default function VoiceButtonDemo() {
  const [state, setState] = useState<VoiceButtonState>('idle');
  const phaseRef = useRef(0);
  const [volume, setVolume] = useState(0);

  const tick = () => {
    phaseRef.current += 0.2;
    setVolume((Math.sin(phaseRef.current) * 0.5 + 0.5) * 0.9);
  };

  const start = () => {
    setState('recording');
    const id = setInterval(tick, 60);
    // Auto-stop after a short window to drive the rest of the machine.
    setTimeout(() => {
      clearInterval(id);
      stop();
    }, 1500);
  };

  const stop = () => {
    setState('processing');
    setTimeout(() => setState('success'), 1200);
    setTimeout(() => setState('idle'), 2600);
  };

  return (
    <div className="flex flex-col items-start gap-4">
      <VoiceButton state={state} onStart={start} onStop={stop} volume={volume} />
      <p className="text-xs text-muted-foreground">
        Press the button to run the simulated push-to-talk cycle.
      </p>
    </div>
  );
}
```

Installation [#installation]

```bash
npx shadcn@latest add @localmode/ui/audio/voice-button
```

Data source & dependencies [#data-source--dependencies]

**Data source:** renders the recording state you pass and emits press/release events — works with any backend. Recommended producer: `useVoiceRecorder` (recording, `deviceId` selection, live `getVolume()`) + `useTranscribe` (with the Whisper STT model from `@localmode/transformers`) from `@localmode/react` (on-device, optional).

* `clsx` + `tailwind-merge` — via the shared `cn()` util

Files installed [#files-installed]

* `voice-button.tsx` — the component
* `waveform-activity-bars.tsx` — the in-button recording waveform (registry dependency)
* `lib/utils.ts` — the `cn()` helper (if not already present)

Props [#props]

**VoiceButton**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `state` | `VoiceButtonState` | — | **Required.** The current state in the push-to-talk machine. Controlled by the app: `idle → recording` on press, `processing` while Whisper runs, then `success`/`error`. |
| `onStart` | `function` | — | Fired when the user presses to start recording (from `idle`). |
| `onStop` | `function` | — | Fired when the user releases / clicks to stop recording. |
| `volume` | `number` | — | Live mic volume in `[0, 1]` for the in-button waveform during recording. Wire it to `useVoiceRecorder().getVolume()` sampled in a `requestAnimationFrame` loop (or any local `AnalyserNode` source). |
| `label` | `string` | — | Label shown next to the icon. Defaults to a state-appropriate string. |
| `size` | `number` | `56` | Diameter in pixels. |

Examples [#examples]

Push-to-talk with local Whisper [#push-to-talk-with-local-whisper]

```tsx
import { VoiceButton, type VoiceButtonState } from '@/components/voice-button';
import { useVoiceRecorder, useTranscribe } from '@localmode/react';
import { transformers } from '@localmode/transformers';

export function VoiceSearch({ onResult }: { onResult: (text: string) => void }) {
  const [state, setState] = useState<VoiceButtonState>('idle');
  const [volume, setVolume] = useState(0);
  const recorder = useVoiceRecorder();
  const stt = useTranscribe({ model: transformers.speechToText('onnx-community/whisper-base') });

  // Sample the live mic level while recording to drive the in-button waveform.
  useEffect(() => {
    if (!recorder.isRecording) return;
    let raf = requestAnimationFrame(function tick() {
      setVolume(recorder.getVolume());
      raf = requestAnimationFrame(tick);
    });
    return () => cancelAnimationFrame(raf);
  }, [recorder.isRecording]);

  return (
    <VoiceButton
      state={state}
      volume={volume}
      onStart={() => { setState('recording'); recorder.startRecording(); }}
      onStop={async () => {
        setState('processing');
        const blob = await recorder.stopRecording();
        const res = blob ? await stt.execute(blob) : null;
        if (res) { onResult(res.text); setState('success'); } else { setState('error'); }
        setTimeout(() => setState('idle'), 1500);
      }}
    />
  );
}
```

Customization [#customization]

The button is fully controlled — you own the `state` and decide when to advance the machine. The recording state shows the in-button `WaveformActivityBars`; pass `volume` (0..1) from `useVoiceRecorder().getVolume()` (sampled in a `requestAnimationFrame` loop, as above) to make it react to real mic loudness — no hand-rolled `AnalyserNode` needed. State colors use `bg-primary` / `bg-destructive` / `bg-emerald-600` — swap in the copied file to match your tokens.