# Voice Picker

Voice Picker [#voice-picker]

**Voice Picker** is TTS voice selection from one data contract. Ships three pieces:

* `VoicePicker` — a compact `<select>` partitioned into `<optgroup>` by language, each option showing the voice name + a gender glyph.
* `VoiceCard` — a rich card: voice name, color-coded gender badge, monospace voice id, and a circular play/stop preview button with a loading state.
* `VoiceGrid` — language-grouped grid of cards with a count header and a search box.

All three consume one `VoiceOption[]` contract (`id`, `name`, `gender`, `languageLabel`), which matches the `KokoroVoice` shape from `@localmode/transformers` (29 English voices).

**When to use it:** let users pick — and preview — a Kokoro voice before synthesizing, in either a compact (select) or rich (grid) surface.

Preview [#preview]

```tsx
'use client';

import { useState } from 'react';
import {
  VoicePicker,
  VoiceGrid,
  type VoiceOption,
} from '@/components/voice-picker';

/**
 * Demo for {@link VoicePicker} / {@link VoiceGrid}. Uses a small fixture that
 * mirrors the `KokoroVoice` shape across two languages so the language grouping
 * and gender badges are visible without a model download. The preview button
 * simulates a synth → play cycle (the real app wires `onPreview` to
 * `useSynthesizeSpeech`).
 */
const VOICES: VoiceOption[] = [
  { id: 'af_heart', name: 'Heart', gender: 'female', languageLabel: 'American English' },
  { id: 'af_bella', name: 'Bella', gender: 'female', languageLabel: 'American English' },
  { id: 'am_adam', name: 'Adam', gender: 'male', languageLabel: 'American English' },
  { id: 'am_echo', name: 'Echo', gender: 'male', languageLabel: 'American English' },
  { id: 'bf_emma', name: 'Emma', gender: 'female', languageLabel: 'British English' },
  { id: 'bm_george', name: 'George', gender: 'male', languageLabel: 'British English' },
];

export default function VoicePickerDemo() {
  const [voice, setVoice] = useState('af_heart');
  const [loadingId, setLoadingId] = useState<string | null>(null);
  const [playingId, setPlayingId] = useState<string | null>(null);

  // Simulate a synth → play cycle so the preview button states are visible.
  const handlePreview = (id: string) => {
    if (playingId === id) {
      setPlayingId(null);
      return;
    }
    setLoadingId(id);
    setTimeout(() => {
      setLoadingId(null);
      setPlayingId(id);
      setTimeout(() => setPlayingId((p) => (p === id ? null : p)), 1500);
    }, 700);
  };

  return (
    <div className="flex flex-col gap-6">
      <div className="flex items-center gap-3">
        <span className="text-sm text-muted-foreground">Compact select:</span>
        <VoicePicker voices={VOICES} value={voice} onValueChange={setVoice} />
        <code className="text-xs text-muted-foreground">{voice}</code>
      </div>

      <VoiceGrid
        voices={VOICES}
        value={voice}
        onValueChange={setVoice}
        onPreview={handlePreview}
        loadingVoiceId={loadingId}
        playingVoiceId={playingId}
      />
    </div>
  );
}
```

Installation [#installation]

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

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

**Data source:** renders the `VoiceOption[]` you pass and emits selection/preview events — works with any backend. Recommended producer: `useSynthesizeSpeech` for inline preview (`execute(text, { voice })` overrides the voice per call) with the Kokoro TTS model + `KOKORO_VOICES` catalog from `@localmode/transformers`, via `@localmode/react` (on-device, optional).

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

Files installed [#files-installed]

* `voice-picker.tsx` — `VoicePicker`, `VoiceCard`, `VoiceGrid`
* `waveform-activity-bars.tsx` — used by the preview button's loading state (registry dependency)
* `lib/utils.ts` — the `cn()` helper (if not already present)

Props [#props]

**VoicePicker**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `voices` | `array` | — | **Required.** The voices to choose from. |
| `value` | `string` | — | Currently selected voice id. |
| `onValueChange` | `function` | — | Fired with the chosen voice id. |
| `disabled` | `boolean` | — | Disable the control. |
| `label` | `string` | `"Voice"` | Accessible label. |

**VoiceGrid**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `voices` | `array` | — | **Required.** The voices to render as cards. |
| `value` | `string` | — | Currently selected voice id. |
| `onValueChange` | `function` | — | Fired when a card is selected. |
| `onPreview` | `function` | — | Fired when a card's preview is pressed. |
| `loadingVoiceId` | `string \| null` | — | The voice id currently synthesizing a preview. |
| `playingVoiceId` | `string \| null` | — | The voice id currently playing a preview. |
| `filterable` | `boolean` | `true` | When true, show a search box that filters voices by name / id. |

**VoiceCard**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `voice` | `object` | — | **Required.** The voice this card represents. |
| `selected` | `boolean` | — | Whether this card is the selected voice. |
| `onSelect` | `function` | — | Fired when the card body is clicked (selection). |
| `onPreview` | `function` | — | Fired when the preview button is pressed. Receives the voice id; the app synthesizes a local sample (e.g. via `useSynthesizeSpeech`). Omit to hide the preview button. |
| `loading` | `boolean` | — | True while this voice's preview is being synthesized. |
| `playing` | `boolean` | — | True while this voice's preview is playing (toggles to a stop affordance). |

Examples [#examples]

Compact select [#compact-select]

```tsx
import { VoicePicker } from '@/components/voice-picker';
import { KOKORO_VOICES } from '@localmode/transformers';

export function Example() {
  const [voice, setVoice] = useState('af_heart');
  return <VoicePicker voices={KOKORO_VOICES} value={voice} onValueChange={setVoice} />;
}
```

Grid with local preview [#grid-with-local-preview]

```tsx
import { VoiceGrid } from '@/components/voice-picker';
import { useSynthesizeSpeech } from '@localmode/react';
import { transformers, KOKORO_VOICES } from '@localmode/transformers';

export function VoiceBrowser() {
  const [voice, setVoice] = useState<string>();
  const [previewing, setPreviewing] = useState<string | null>(null);
  const tts = useSynthesizeSpeech({ model: transformers.textToSpeech('onnx-community/Kokoro-82M-v1.0-ONNX') });

  const preview = async (id: string) => {
    setPreviewing(id);
    const res = await tts.execute('Hello from LocalMode.', { voice: id }); // per-call voice override
    if (res) new Audio(URL.createObjectURL(res.audio)).play();
    setPreviewing(null);
  };

  return (
    <VoiceGrid
      voices={KOKORO_VOICES}
      value={voice}
      onValueChange={setVoice}
      onPreview={preview}
      loadingVoiceId={previewing}
    />
  );
}
```

`useSynthesizeSpeech` accepts `voice` / `speed` / `pitch` at the hook level and per call — `execute(text, { voice })` is what makes inline per-voice previews possible from a single hook instance.

Customization [#customization]

Voices are grouped by `languageLabel`, so any provider that exposes that field groups correctly. Gender badge colors use Tailwind's `pink` / `sky` palettes — swap them in the copied file to match your design system. The grid's search filters on `name` and `id`; widen the predicate in `VoiceGrid` if you add more searchable metadata.

Accessibility [#accessibility]

A `VoiceCard` exposes its preview and selection as **two distinct sibling buttons** — it never nests an interactive element inside another (no `<button>` inside a `role="button"` card), which would be an invalid, non-operable a11y tree. The optional preview button plays a locally-synthesized sample; the selection button is labelled `"Select {name}"` and announces its state with `aria-pressed`, so screen readers convey which voice is active and both actions are independently focusable and keyboard-operable.