# Voice Comparison Panel

Voice Comparison Panel [#voice-comparison-panel]

**Voice Comparison Panel** is an A/B voice comparison surface. Two labeled columns each carry a language-grouped voice select and a native `<audio>` player (shown once audio is set), plus a shared comparison textarea and a Compare button with a loading state.

Wire `onCompare` to synthesize the shared text through both voices — one `useSynthesizeSpeech` hook, two `execute(text, { voice })` calls — and pass the resulting Blobs back via `columnA.audio` / `columnB.audio`.

**When to use it:** let users hear the same line in two Kokoro voices side by side before choosing one.

Preview [#preview]

```tsx
'use client';

import { useState } from 'react';
import {
  VoiceComparisonPanel,
  type ComparisonColumn,
} from '@/components/voice-comparison-panel';
import type { VoiceOption } from '@/components/voice-picker';

/**
 * Demo for {@link VoiceComparisonPanel}. Uses a tiny voice fixture and a
 * simulated Compare that produces two short silent WAV Blobs so both native
 * players render distinct sources without a model download. The real app wires
 * `onCompare` to two `useSynthesizeSpeech` runs.
 */
const VOICES: VoiceOption[] = [
  { id: 'af_heart', name: 'Heart', gender: 'female', languageLabel: 'American English' },
  { id: 'am_adam', name: 'Adam', gender: 'male', languageLabel: 'American English' },
  { id: 'bf_emma', name: 'Emma', gender: 'female', languageLabel: 'British English' },
];

/** Build a tiny valid silent WAV Blob of the given length so players load. */
function makeSilentWav(seconds: number): Blob {
  const sampleRate = 8000;
  const samples = Math.floor(sampleRate * seconds);
  const buffer = new ArrayBuffer(44 + samples * 2);
  const view = new DataView(buffer);
  const writeStr = (offset: number, s: string) => {
    for (let i = 0; i < s.length; i++) view.setUint8(offset + i, s.charCodeAt(i));
  };
  writeStr(0, 'RIFF');
  view.setUint32(4, 36 + samples * 2, true);
  writeStr(8, 'WAVE');
  writeStr(12, 'fmt ');
  view.setUint32(16, 16, true);
  view.setUint16(20, 1, true);
  view.setUint16(22, 1, true);
  view.setUint32(24, sampleRate, true);
  view.setUint32(28, sampleRate * 2, true);
  view.setUint16(32, 2, true);
  view.setUint16(34, 16, true);
  writeStr(36, 'data');
  view.setUint32(40, samples * 2, true);
  return new Blob([buffer], { type: 'audio/wav' });
}

export default function VoiceComparisonPanelDemo() {
  const [a, setA] = useState('af_heart');
  const [b, setB] = useState('am_adam');
  const [text, setText] = useState('Run models entirely in your browser.');
  const [columnA, setColumnA] = useState<ComparisonColumn>({ voiceId: a });
  const [columnB, setColumnB] = useState<ComparisonColumn>({ voiceId: b });
  const [loading, setLoading] = useState(false);

  const compare = () => {
    setLoading(true);
    setTimeout(() => {
      setColumnA({ voiceId: a, audio: makeSilentWav(1) });
      setColumnB({ voiceId: b, audio: makeSilentWav(1.4) });
      setLoading(false);
    }, 800);
  };

  return (
    <VoiceComparisonPanel
      voices={VOICES}
      columnA={{ ...columnA, voiceId: a }}
      columnB={{ ...columnB, voiceId: b }}
      onVoiceAChange={setA}
      onVoiceBChange={setB}
      text={text}
      onTextChange={setText}
      onCompare={compare}
      loading={loading}
    />
  );
}
```

Installation [#installation]

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

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

**Data source:** renders the two voice columns + audio Blobs you pass and emits `onCompare` — works with any backend. Recommended producer: `useSynthesizeSpeech` (per-call `{ voice }` overrides, with the Kokoro TTS model + voice catalog from `@localmode/transformers`) from `@localmode/react` (on-device, optional).

* `@localmode/ui/lib/browser-utils` — `useObjectUrl` for Blob lifecycle (installed automatically as a registry dependency)
* `clsx` + `tailwind-merge` — via the shared `cn()` util

Files installed [#files-installed]

* `voice-comparison-panel.tsx` — the component
* `voice-picker.tsx` — the language-grouped select used in each column (registry dependency)
* `lib/browser-utils.ts` — generic browser helpers (`useObjectUrl`)
* `lib/utils.ts` — the `cn()` helper (if not already present)

Props [#props]

**VoiceComparisonPanel**

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `voices` | `array` | — | **Required.** The voices available in both column pickers. |
| `columnA` | `object` | — | **Required.** State for column A. |
| `columnB` | `object` | — | **Required.** State for column B. |
| `onVoiceAChange` | `function` | — | Fired when column A's voice changes. |
| `onVoiceBChange` | `function` | — | Fired when column B's voice changes. |
| `text` | `string` | — | **Required.** The shared comparison text. |
| `onTextChange` | `function` | — | Fired when the shared text changes. |
| `onCompare` | `function` | — | Fired when Compare is clicked — synthesize both columns from `text`. |
| `loading` | `boolean` | — | True while a comparison is synthesizing (disables Compare, shows loader). |
| `labels` | `[string, string]` | `["Voice A", "Voice B"]` | Labels for the two columns. |

Examples [#examples]

Compare two voices [#compare-two-voices]

```tsx
import { VoiceComparisonPanel } from '@/components/voice-comparison-panel';
import { useSynthesizeSpeech } from '@localmode/react';
import { transformers, KOKORO_VOICES } from '@localmode/transformers';

export function Compare() {
  const [a, setA] = useState('af_heart');
  const [b, setB] = useState('am_adam');
  const [text, setText] = useState('Run models entirely in your browser.');
  const [audioA, setAudioA] = useState<Blob | null>(null);
  const [audioB, setAudioB] = useState<Blob | null>(null);
  const [loading, setLoading] = useState(false);

  const tts = useSynthesizeSpeech({ model: transformers.textToSpeech('onnx-community/Kokoro-82M-v1.0-ONNX') });

  const compare = async () => {
    setLoading(true);
    // One hook, two per-call voice overrides (sequential — TTS runs one at a time).
    const ra = await tts.execute(text, { voice: a });
    const rb = await tts.execute(text, { voice: b });
    setAudioA(ra?.audio ?? null);
    setAudioB(rb?.audio ?? null);
    setLoading(false);
  };

  return (
    <VoiceComparisonPanel
      voices={KOKORO_VOICES}
      columnA={{ voiceId: a, audio: audioA }}
      columnB={{ voiceId: b, audio: audioB }}
      onVoiceAChange={setA}
      onVoiceBChange={setB}
      text={text}
      onTextChange={setText}
      onCompare={compare}
      loading={loading}
    />
  );
}
```

Customization [#customization]

Each column accepts a `Blob` or an object-URL string for `audio`; the component manages the object-URL lifecycle when given a Blob. Relabel the columns via `labels`. Add more columns by composing additional `VoicePicker` + `<audio>` pairs in the copied file.