Ready
Network activity
0 requests
Connected - models run on-device
No recent network activity
0 groups0 total requests
Browse blocks
Voice Explorer
Browse and preview 29 text-to-speech voices grouped by language. Type any text, hear each voice read it, and play two side by side to compare. Everything runs on-device; the voice model (~86MB) downloads only on the first preview or comparison.
Install this block
npx shadcn@latest add @localmode/ui/blocks/audio/voice-explorerPreparing…
'use client';/** * @file voice-explorer.tsx * @description Voice Explorer — browse, search, preview, and A/B-compare all 29 Kokoro voices; Kokoro downloads only on the first preview or comparison. */import { useState } from 'react';import { useModelLoad, useSynthesizeSpeech, type UseModelLoadReturn } from '@localmode/react';import { synthesizeSpeech } from '@localmode/core';import type { TextToSpeechModel } from '@localmode/core';import { transformers, isModelCached, KOKORO_VOICES, KOKORO_DEFAULT_VOICE,} from '@localmode/transformers';import { VoiceCard, type VoiceOption } from '@/components/voice-picker';import { VoiceComparisonPanel } from '@/components/voice-comparison-panel';import { AudioScrubPlayer } from '@/components/audio-scrub-player';import { ModelLoadingPanel } from '@/components/model-loading-panel';import { ErrorAlert } from '@/components/error-alert';const TTS_MODEL_ID = 'onnx-community/Kokoro-82M-v1.0-ONNX';const TTS_MODEL_SIZE = '~86MB';const PREVIEW_TEXT = 'Hello! This is a preview of this voice.';const VOICE_OPTIONS: VoiceOption[] = KOKORO_VOICES.map((v) => ({ id: v.id, name: v.name, gender: v.gender, languageLabel: v.languageLabel,}));const DEFAULT_COMPARE_TEXT = 'The quick brown fox jumps over the lazy dog.';export function VoiceExplorerBlock() { const ttsLoad = useModelLoad<TextToSpeechModel>({ key: `voice-explorer-tts:${TTS_MODEL_ID}`, create: (onProgress) => transformers.textToSpeech(TTS_MODEL_ID, { onProgress: (p) => onProgress(p as Parameters<typeof onProgress>[0]), }), warmup: (model) => synthesizeSpeech({ model, text: 'Ready.', voice: KOKORO_DEFAULT_VOICE }), isCached: () => isModelCached(TTS_MODEL_ID), }); const ttsModel = ttsLoad.model; if (!ttsModel) return <p className="p-4 text-sm text-muted-foreground">Preparing…</p>; return <VoiceExplorerSurface ttsLoad={ttsLoad} ttsModel={ttsModel} />;}interface VoiceExplorerSurfaceProps { ttsLoad: UseModelLoadReturn<TextToSpeechModel>; ttsModel: TextToSpeechModel;}function VoiceExplorerSurface({ ttsLoad, ttsModel }: VoiceExplorerSurfaceProps) { const previewTts = useSynthesizeSpeech({ model: ttsModel }); const compareTts = useSynthesizeSpeech({ model: ttsModel }); const [search, setSearch] = useState(''); const [selectedVoiceId, setSelectedVoiceId] = useState<string | undefined>(); const [pendingPreviewId, setPendingPreviewId] = useState<string | null>(null); const [preview, setPreview] = useState<{ voiceId: string; audio: Blob } | null>(null); const [dismissedError, setDismissedError] = useState<string | null>(null); const [voiceA, setVoiceA] = useState('af_heart'); const [voiceB, setVoiceB] = useState('am_michael'); const [compareText, setCompareText] = useState(DEFAULT_COMPARE_TEXT); const [audioA, setAudioA] = useState<Blob | null>(null); const [audioB, setAudioB] = useState<Blob | null>(null); const [comparing, setComparing] = useState(false); const query = search.trim().toLowerCase(); const filtered = query ? VOICE_OPTIONS.filter( (v) => v.name.toLowerCase().includes(query) || v.id.toLowerCase().includes(query) || v.gender.includes(query), ) : VOICE_OPTIONS; const groups = [...new Set(filtered.map((v) => v.languageLabel))].map((label) => ({ label, voices: filtered.filter((v) => v.languageLabel === label), })); const previewVoice = async (voiceId: string) => { if (preview?.voiceId === voiceId) { setPreview(null); return; } setPendingPreviewId(voiceId); setPreview(null); setDismissedError(null); try { await ttsLoad.load(); const result = await previewTts.execute(PREVIEW_TEXT, { voice: voiceId }); if (result) setPreview({ voiceId, audio: result.audio }); } catch { } finally { setPendingPreviewId(null); } }; const runCompare = async () => { if (comparing || !compareText.trim()) return; setComparing(true); setDismissedError(null); setAudioA(null); setAudioB(null); try { await ttsLoad.load(); const a = await compareTts.execute(compareText, { voice: voiceA }); if (!a) return; setAudioA(a.audio); const b = await compareTts.execute(compareText, { voice: voiceB }); if (!b) return; setAudioB(b.audio); } catch { } finally { setComparing(false); } }; const cancelCompare = () => { compareTts.cancel(); setComparing(false); }; const rawErrorText = previewTts.error?.message ?? compareTts.error?.message ?? ttsLoad.error?.message ?? null; const errorText = rawErrorText && rawErrorText !== dismissedError ? rawErrorText : null; const busy = previewTts.isLoading || comparing; const statusText = busy ? 'synthesizing' : errorText ? 'error' : preview || audioA || audioB ? 'ready' : 'idle'; const statusLabel = statusText === 'synthesizing' ? 'Synthesizing…' : statusText === 'error' ? 'Error' : statusText === 'ready' ? 'Ready' : 'Idle'; return ( <div className="flex flex-col gap-4 p-4"> <p data-status={statusText} role="status" aria-live="polite" aria-label="Status" className="text-xs text-muted-foreground" > {statusLabel} </p> {errorText && ( <ErrorAlert message={errorText} onDismiss={() => { setDismissedError(rawErrorText); previewTts.reset(); compareTts.reset(); }} /> )} {ttsLoad.status === 'loading' && ( <ModelLoadingPanel name="Kokoro 82M" size={TTS_MODEL_SIZE} category="Text-to-speech" progress={ttsLoad.progressValue} cached={ttsLoad.cached === true} /> )} {} <section className="flex flex-col gap-3 rounded-lg border border-border p-4"> <div className="flex flex-wrap items-center justify-between gap-2"> <h2 className="text-sm font-medium"> Voices <span className="text-xs text-muted-foreground">({VOICE_OPTIONS.length} Kokoro voices)</span> </h2> <input value={search} onChange={(e) => setSearch(e.target.value)} placeholder="Search name, id, or gender…" aria-label="Search voices" className="h-8 w-full min-w-0 rounded-md border border-input bg-background px-3 text-sm focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50 sm:w-56" /> </div> <div className="flex flex-col gap-4"> {groups.length === 0 && ( <p className="text-sm text-muted-foreground">No voices match “{search}”.</p> )} {groups.map((group) => ( <div key={group.label} className="flex flex-col gap-2"> <h3 className="text-xs font-medium tracking-wide text-muted-foreground uppercase"> {group.label} <span className="normal-case">({group.voices.length})</span> </h3> <div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3"> {group.voices.map((voice) => ( <div key={voice.id} data-voice-id={voice.id}> <VoiceCard voice={voice} selected={selectedVoiceId === voice.id} onSelect={setSelectedVoiceId} onPreview={(id) => void previewVoice(id)} loading={pendingPreviewId === voice.id} playing={preview?.voiceId === voice.id} /> </div> ))} </div> </div> ))} </div> {preview && ( <div data-voice-id={preview.voiceId} role="region" aria-label="Voice preview" className="flex flex-col gap-1" > <p className="text-xs text-muted-foreground"> Previewing <span className="font-medium">{preview.voiceId}</span>: “{PREVIEW_TEXT}” </p> <AudioScrubPlayer audio={preview.audio} autoPlay /> </div> )} </section> {} <section className="flex flex-col gap-3 rounded-lg border border-border p-4"> <div className="flex flex-wrap items-center gap-3"> <h2 className="text-sm font-medium">Compare two voices</h2> <span data-ready={audioA ? 'true' : 'false'} data-voice={voiceA} className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums text-foreground/70" > A: {voiceA} · {audioA ? 'Ready' : 'Pending'} </span> <span data-ready={audioB ? 'true' : 'false'} data-voice={voiceB} className="rounded-md bg-muted px-1.5 py-0.5 text-xs tabular-nums text-foreground/70" > B: {voiceB} · {audioB ? 'Ready' : 'Pending'} </span> {comparing && ( <button type="button" onClick={cancelCompare} className="inline-flex h-7 items-center rounded-md border border-border px-2.5 text-xs focus-visible:outline-none focus-visible:ring-[3px] focus-visible:ring-ring/50" > Cancel </button> )} </div> <div role="group" aria-label="Voice comparison"> <VoiceComparisonPanel voices={VOICE_OPTIONS} columnA={{ voiceId: voiceA, audio: audioA }} columnB={{ voiceId: voiceB, audio: audioB }} onVoiceAChange={setVoiceA} onVoiceBChange={setVoiceB} text={compareText} onTextChange={setCompareText} onCompare={() => void runCompare()} loading={comparing} /> </div> </section> </div> );}