AI

Voice Picker

Pick the TTS voice a reply is spoken in — cards or a combobox, language and accent chips, neutral timbre tags, and a play button that auditions without selecting, with exactly one sample sounding at a time.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import {
  AlertCircle,
  AudioLines,
  Check,
  ChevronsUpDown,
  LoaderCircle,
  Pause,
  Play,
  RefreshCcw,
  VolumeX,
} from "lucide-react"

Installation

npx shadcn@latest add https://ui.zyeon.ai/r/voice-picker.json

Prompt

Build a React + TypeScript + Tailwind "VoicePicker" component with zod and
lucide-react. No audio library: one native <audio> element does the whole job.

Contract
- A zod schema (`voicePickerItemSchema` in a sibling contract file) is the single
  source of truth for ONE voice: { id, name, lang (BCP-47), accent (human
  words), tags: string[] (neutral timbre descriptors — "warm", "crisp",
  "calm"), previewUrl?: string, description?: string }.
- `previewUrl` is optional on purpose: a catalogue always contains a voice whose
  sample has not been rendered yet, and the picker must say "no sample" rather
  than ship a play button that does nothing. Any URL a media element accepts
  works, including `data:` and `blob:` — that is how you feed it audio you just
  synthesised.
- Deliberately NOT in the contract: gender. A synthetic voice does not have one,
  provider labels disagree, and the thing a listener actually compares is how it
  SOUNDS. `tags` carries that.
- An envelope status — "loading" | "empty" | "error" | "ready" — is the state of
  the CATALOGUE, independent of playback: a sample can fail while the list is
  perfectly ready, and those two failures read differently on screen.
- Props: items; status; value?/defaultValue?/onValueChange?(id, voice) (the
  usual controlled/uncontrolled pair, `null` = nothing chosen);
  variant?: "grid" | "dropdown"; columns?: 1|2|3 (grid only); previewingId? +
  previewPhase?: "loading"|"playing" + onPreviewChange?(id|null, voice|null);
  maxTags? (3, clamped >= 1); formatLanguage?(voice); placeholder?; emptyState?;
  emptyMessage?; errorMessage?; onRetry?; skeletonCount? (4, clamped >= 1);
  label? ("Speaking voice"); className plus the rest of the div props, ref
  forwarded to the root.

Behavior
- AUDITIONING IS NOT CHOOSING. The play button never commits a voice: it is a
  real <button> that is a SIBLING of the selection element, and its click handler
  stops propagation. A picker where hearing a voice also picks it makes
  comparison impossible — you cannot listen to option three while option two is
  still your choice.
- ONE PREVIEW AT A TIME, structurally. State is a single `{ id, phase }` slot and
  a single <audio> element, never a per-card `isPlaying` flag. Starting a preview
  pauses the element, swaps `src` and plays — so the previous voice stops because
  there is nowhere for it to keep running, not because five booleans agreed to
  reset. Every path that ends a preview (stop button, sample ended, failure, the
  voice disappearing from `items`) does the same one thing: set the slot to null.
  A single effect watches for null and silences the element.
- The phase flips to "playing" on the element's `playing` EVENT, not when
  `play()`'s promise resolves: the promise only means playback was permitted, the
  event means sound is actually coming out. Until then the button shows a
  spinner, because a play button that looks idle for 400 ms of buffering gets
  pressed twice.
- Stale playbacks cannot paint. Each start bumps a token held in a ref; a
  rejected `play()` compares the token before reporting anything, so the
  AbortError produced by the pause that started the NEXT preview is discarded
  instead of drawing a failure over the voice that is currently sounding.
- Failures are attributed and readable: NotAllowedError ("your browser blocked
  playback"), NotSupportedError ("this format can't be played here"), plus the
  element's own `error` event for a sample that 404s or fails to decode. The
  message renders under that voice and is announced; the failed URL is dropped
  from the "already loaded" marker so pressing play again is a real retry rather
  than a replay of a poisoned element.
- Controlled preview: pass `previewingId` (even as null) and the component stops
  owning audio entirely — its element is never handed a source, nothing is
  played, an internally-sounding voice is dropped the moment ownership changes,
  and clicks only report intent through `onPreviewChange`. That is the seam for a
  streaming TTS engine, a shared page-level player or a Web Audio graph.
  `previewPhase` then drives the spinner-vs-waveform.
- Selection has two ARIA models for one contract. `grid` is a radiogroup with
  roving tabindex: Tab lands on the chosen card, arrows WRAP and move the choice
  (radio semantics), Home/End jump. `dropdown` is a combobox + listbox with
  aria-activedescendant: arrows CLAMP at both ends (APG — a wrapping list makes
  "am I at the bottom?" unanswerable), Enter commits and closes, Escape closes
  without writing, Tab closes and moves on.
- A radio must not contain focusable descendants, so the card's radio is ONE
  stretched transparent <button role="radio"> covering the card, with the play
  button as its sibling; the visible content is aria-hidden and the radio's
  aria-label is the whole card as a sentence (name, language, accent, every tag
  including the ones folded into "+N", and "no sample available").
- The same rule bites harder in the listbox, where an option may not hold a
  button at all: there the row's play glyph is a pointer-only affordance and the
  keyboard path is the P key on the combobox (and on a focused card in the grid),
  which auditions the highlighted row WITHOUT closing the panel and without
  selecting it. P is ignored with Ctrl/Cmd/Alt held so it never eats Cmd+P.
- Two sr-only live regions, not one: what is SELECTED and what is SOUNDING are
  different facts, and folding them into one string re-announces the choice every
  time a sample ends.
- A committed id that is not in `items` is surfaced ("that voice isn't in this
  catalogue"), never silently reset — a voice that vanished with a plan change is
  something the user has to act on.
- Cleanup is explicit, and the media element is mounted in EVERY branch —
  envelopes included — because detaching a media node does NOT reliably stop it:
  the resource outlives the node and keeps talking over whatever renders next. So
  a catalogue that flips to "loading" mid-playback silences the element first
  instead of orphaning it, and on unmount the element is paused, its `src`
  removed and `load()` called. The placement rAF is cancelled and the
  outside-pointerdown listener removed.

Rendering & styling
- Semantic tokens only: bg-card / border / text-muted-foreground for chrome,
  border-primary + ring-primary + bg-primary/5 for the chosen card, bg-primary
  for the waveform bars, bg-accent/40 for hover, text-destructive with
  border-destructive/40 + bg-destructive/5 for the catalogue failure. No
  hard-coded colours anywhere.
- The waveform is five bars with per-bar duration and delay set inline (the
  keyframes ride a hoisted <style href precedence> so the component stays one
  droppable file). Each bar has a base height, so `motion-reduce:animate-none`
  freezes them into a static equaliser glyph: the motion goes, the meaning
  ("this one is sounding") stays. Every transition and the loading spinner are
  motion-reduce-guarded too.
- Long names use `wrap-anywhere` plus `min-w-0`, NOT `break-words`: only
  overflow-wrap:anywhere lowers the min-content width, which is what actually
  stops a 51-character provider name from widening the whole grid. The grid's
  columns are container queries (@xl/@4xl), so a picker in a 380px settings
  drawer reflows on ITS width, not the viewport's.
- Play / pause / spinner / muted-speaker are four distinct SHAPES, not four
  colours, so the state survives a monochrome theme and a colour-blind reader.
  Both the play control and the empty-sample state use aria-disabled rather than
  the native attribute, which would drop the one element that explains why out of
  the tab order.
- cn() merges the consumer's className into the root, which also spreads the
  remaining div props and forwards its ref.

Customization levers
- Density and shape: `variant` picks the ARIA model ("grid" for comparison,
  "dropdown" for a toolbar), `columns` sets the card tracks, `maxTags` decides
  how many timbre words survive before "+N".
- Wording: `formatLanguage` turns the raw BCP-47 tag into whatever your audience
  reads — `Intl.DisplayNames` is the obvious upgrade, but resolve it client-side
  or SSR and the browser can disagree and hydrate mismatched. `placeholder`,
  `label`, `emptyMessage`, `errorMessage` and `emptyState` own the rest of the
  copy.
- Who owns the sound: leave `previewingId` off and the built-in element handles
  it; pass it and you own playback (streaming TTS, one shared player for the
  page, an offline cache) while the picker keeps the lock semantics.
- Failure envelope: `onRetry` renders Try again; `errorMessage` carries the
  request id on its own line so a support ticket gets a usable copy.
- Motion: drop the waveform entirely by rendering nothing for the sounding state
  — the play/pause shape already carries it — or raise the bar count and heights
  for a chunkier meter. Do not swap the base heights for a pure opacity pulse:
  that is the part that disappears under prefers-reduced-motion.

Concepts

  • Auditioning is not choosing — the play button is a sibling of the selection element, not a child of it, and it stops propagation. That one rule is what lets you listen to the fourth voice while the second is still your answer; a picker that commits on play forces you to overwrite your choice just to compare it.
  • The one-at-a-time lock is structural — there is a single { id, phase } slot and a single <audio> element, so starting a preview stops the previous one because there is nowhere left for it to run. Nothing has to remember to reset a neighbour's isPlaying, which is the bug every per-card boolean eventually ships.
  • "Playing" is an event, not a resolved promiseplay() resolving only means playback was permitted; the element's playing event means sound is coming out. Holding the spinner until then is what stops a buffering sample from being pressed twice.
  • A stale playback must not paint — pausing to start the next preview rejects the previous play() with an AbortError. A token stored in a ref, compared inside the rejection handler, is what keeps that dead request from drawing a failure over the voice that is currently sounding.
  • Controlled preview = you own the sound — pass previewingId and the built-in element is never handed a source; the picker becomes a renderer of "who is sounding" plus an intent reporter. Streaming TTS, a shared page player and an offline cache all plug in there without forking the component.
  • Reduced motion drops the movement, keeps the meaning — the waveform bars carry their own base heights, so freezing the animation leaves a static equaliser glyph rather than a blank gap; the state stays legible, only the motion goes.

On This Page