Skip to contentVibraUI
Part of the AI dashboardinstalls at /ai/voice

Voice

Text to speech: a script with a counter, five voices as selectable rows, speed, pitch and stability sliders, and a history whose rows play as a stepping waveform.

Open the live page

Installs at /ai/voice. The page reads db.voiceJobs on the server and shapes each row for the history — an excerpt cut on a word, the date in words, and a waveform off seeded(id) — and hands the rows to one island that owns the script, the voice, the three knobs and the history. Nothing plays and nothing is rendered: synthesize writes a finished job through the repository, timed by the one rule the seeded jobs follow (the words at 2.5 a second, slower at a lower speed), and Play is a pressed toggle whose waveform steps along under use-interval, held still under reduced motion; one job plays at a time. A blank script, one past 2,000 characters, or a knob outside its range is refused in a callout under the control it names; Preview and Download say plainly that a synthesiser is not connected. The voice picker is a radio group of rows with an initial in a tinted circle — a voice has no face — and its Preview button sits beside the row, because a radio must not contain a button. Composes AppShell, PageHeader, Card, FormRow, Textarea, SelectableCard, SliderInput, AsyncButton, Tabs, Select, ToggleRow, Sparkline, StatusBadge, Badge, Callout and EmptyState.

Preview

Install

npx shadcn@latest add @vibra/ai-voice

Needs the @vibra registry in your components.json — set it up once.

Source

app/ai/voice/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"

import { signOut } from "./actions"
import { VoiceView } from "./components/voice-view"
import { currentUser, history, MAX_CHARS, shellNotifications } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/ai/nav"

/**
 * Text to speech. The page is a server component: it reads every job through
 * `db.voiceJobs`, shapes each one — its excerpt, its waveform off the seed,
 * its date into words — and hands the rows to one client island that owns
 * the script, the voice, the knobs and the history, and the header whose
 * job count follows it.
 */
export default function VoicePage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.voice}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <VoiceView rows={history()} maxChars={MAX_CHARS} />
    </AppShell>
  )
}
app/ai/voice/data.ts
/**
 * What this page reads. Jobs come from `db.voiceJobs`; every row is shaped
 * here, on the server, into what the history draws — the bars behind a Play
 * toggle come off `seeded(id)` once, so the island animates numbers it was
 * handed and never needs the store. "Now" is `REFERENCE_DATE`.
 */
import { formatRelative, getInitials } from "@/lib/format"
import { db, REFERENCE_DATE, seeded, type VoiceJob } from "@/lib/sample-data"

export type VoiceName = VoiceJob["voice"]

/** The five voices, as the entity names them. */
export const VOICE_IDS: readonly VoiceName[] = ["Ada", "Kai", "Noor", "Sol", "Yuki"]

/** The most a script may run to. */
export const MAX_CHARS = 2_000

/** A job as the history reads it: the row, its excerpt, its bars and its dates in words. */
export type VoiceJobRow = {
  id: string
  text: string
  /** The first line of the script, for a row that cannot hold all of it. */
  excerpt: string
  voice: VoiceName
  speed: number
  pitch: number
  stability: number
  durationSec: number
  status: VoiceJob["status"]
  createdAt: Date
  /** How long ago, against REFERENCE_DATE, or the date once that stops being useful. */
  when: string
  /** The waveform behind the Play toggle: heights between 0 and 1, from the job's id. */
  bars: number[]
}

const EXCERPT_MAX = 72
const BAR_COUNT = 28
const DAY_MS = 86_400_000

const shortDay = new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric", timeZone: "UTC" })

/** "2h ago" against `REFERENCE_DATE`, and the date itself past a week. */
export function since(at: Date): string {
  const days = (REFERENCE_DATE.getTime() - at.getTime()) / DAY_MS
  return days < 7 ? formatRelative(at, REFERENCE_DATE) : shortDay.format(at)
}

/** The script cut to one line, on a word boundary, with an ellipsis when it was longer. */
export function excerptOf(text: string): string {
  const clean = text.trim().replace(/\s+/g, " ")
  if (clean.length <= EXCERPT_MAX) return clean
  const cut = clean.slice(0, EXCERPT_MAX)
  const atWord = cut.lastIndexOf(" ")
  return `${(atWord > EXCERPT_MAX / 2 ? cut.slice(0, atWord) : cut).replace(/[.,;:]$/, "")}…`
}

/**
 * A waveform for the row: a rise, a plateau and a fall shaped by the id's
 * own generator, so every job has a different picture and the same job
 * always the same one. Values sit between 0.15 and 1 so no bar vanishes.
 */
export function barsFor(id: string): number[] {
  const rand = seeded(`voice-wave:${id}`)
  return Array.from({ length: BAR_COUNT }, (_, index) => {
    const envelope = Math.sin((Math.PI * (index + 0.5)) / BAR_COUNT)
    return Math.round((0.15 + envelope * (0.35 + rand() * 0.5)) * 100) / 100
  })
}

/** A row shaped for the page; the action uses it too, so a job made at runtime reads like a seeded one. */
export function toRow(job: VoiceJob): VoiceJobRow {
  return {
    id: job.id,
    text: job.text,
    excerpt: excerptOf(job.text),
    voice: job.voice,
    speed: job.speed,
    pitch: job.pitch,
    stability: job.stability,
    durationSec: job.durationSec,
    status: job.status,
    createdAt: job.createdAt,
    when: since(job.createdAt),
    bars: barsFor(job.id),
  }
}

/** Every job, newest first. */
export function history(): VoiceJobRow[] {
  return db.voiceJobs
    .all()
    .sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())
    .map(toRow)
}

// Read per call, never held at module scope: a rename on the profile page
// shows here on the next render.
const memberRow = () => db.members.all()[0]

/** The person looking at the page: the first member of the workspace, who owns it. */
export function currentUser() {
  const member = memberRow()
  return { name: member.name, email: member.email, initials: getInitials(member.name), avatarUrl: member.avatarUrl }
}

export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/ai/voice/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { asString, db, durationFor, fields, REFERENCE_DATE, type Result } from "@/lib/sample-data"

import { MAX_CHARS, toRow, VOICE_IDS, type VoiceJobRow, type VoiceName } from "./data"

/**
 * What this page changes. Server actions so the page can stay a server
 * component, and `Result` so every caller reads one success-or-error shape:
 * a refusal names the field it is about and is rendered under it.
 */
export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

/** The form as it was submitted: the script and the four knobs. */
export type SynthesizeInput = {
  text: string
  voice: string
  speed: number
  pitch: number
  stability: number
}

const isVoice = (value: string): value is VoiceName => (VOICE_IDS as readonly string[]).includes(value)

const within = (value: unknown, min: number, max: number): value is number =>
  typeof value === "number" && Number.isFinite(value) && value >= min && value <= max

/**
 * Reads a script in a voice. There is no synthesiser behind the page: the job
 * is written to `db.voiceJobs` as done, timed by the one rule the seeded jobs
 * follow — the words at 2.5 a second, slower at a lower speed — and comes
 * back shaped for the history, so it reads exactly like a seeded row.
 */
export async function synthesize(sent: SynthesizeInput): Promise<Result<{ row: VoiceJobRow }>> {
  const input = fields(sent)
  const text = asString(input.text).trim()
  if (!text) {
    return { ok: false, error: { code: "required", message: "Write something to read before generating.", field: "text" } }
  }
  if (text.length > MAX_CHARS) {
    return {
      ok: false,
      error: { code: "too_long", message: `A script runs to ${MAX_CHARS.toLocaleString("en-US")} characters at most; this one is ${text.length.toLocaleString("en-US")}.`, field: "text" },
    }
  }
  const voice = asString(input.voice)
  if (!isVoice(voice)) {
    return { ok: false, error: { code: "invalid", message: "Pick one of the five voices.", field: "voice" } }
  }
  const { speed, pitch, stability } = input
  if (!within(speed, 0.5, 2)) {
    return { ok: false, error: { code: "invalid", message: "Speed runs from 0.5× to 2×.", field: "speed" } }
  }
  // Whole semitones, as the slider steps: a pitch of 0.5 is not one it offers.
  if (!within(pitch, -6, 6) || !Number.isInteger(pitch)) {
    return { ok: false, error: { code: "invalid", message: "Pitch runs from −6 to +6 semitones.", field: "pitch" } }
  }
  if (!within(stability, 0, 1)) {
    return { ok: false, error: { code: "invalid", message: "Stability runs from 0 to 1.", field: "stability" } }
  }

  const created = await db.voiceJobs.create({
    text,
    voice,
    speed,
    pitch,
    stability,
    durationSec: durationFor(text, speed),
    status: "done",
    createdAt: REFERENCE_DATE,
  })
  if (!created.ok) return created
  return { ok: true, data: { row: toRow(created.data) } }
}
app/ai/voice/components/voice-form.tsx
"use client"

import * as React from "react"
import { AudioLinesIcon } from "lucide-react"

import { formatNumber } from "@/lib/format"
import { AsyncButton } from "@/components/ui/async-button"
import { Callout } from "@/components/ui/callout"
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { FormRow } from "@/components/ui/form-section"
import { SliderInput } from "@/components/ui/slider-input"
import { Textarea } from "@/components/ui/textarea"

import { type SynthesizeInput } from "../actions"
import { type VoiceName } from "../data"
import { VoicePicker } from "./voice-picker"
import { KNOBS } from "./voice-vocabulary"

export type FormError = { message: string; field?: string }

export type VoiceFormProps = {
  value: SynthesizeInput
  onChange: (next: SynthesizeInput) => void
  onGenerate: () => void
  onPreview: (voice: VoiceName) => void
  generating: boolean
  error?: FormError
  notice?: string
  /** The most a script may run to; the counter reads against it. */
  maxChars: number
  className?: string
}

/** The left card: the script, the voice, the three knobs and the one button. */
export function VoiceForm({
  value,
  onChange,
  onGenerate,
  onPreview,
  generating,
  error,
  notice,
  maxChars,
  className,
}: VoiceFormProps) {
  const scriptId = React.useId()
  const set = <K extends keyof SynthesizeInput>(key: K, next: SynthesizeInput[K]) => onChange({ ...value, [key]: next })
  const scriptError = error?.field === "text" ? error.message : undefined
  // A refusal about a knob or the voice lands with the button, where the
  // reader pressed; one about the script lands under the script.
  const otherError = error && error.field !== "text" ? error.message : undefined

  return (
    <Card className={className}>
      <CardHeader>
        <CardTitle>Script</CardTitle>
      </CardHeader>
      <form
        onSubmit={(event) => {
          event.preventDefault()
          onGenerate()
        }}
      >
        <CardContent className="flex flex-col gap-5">
          <div className="flex flex-col gap-2">
            <FormRow label="Script" htmlFor={scriptId} required>
              <Textarea
                id={scriptId}
                rows={9}
                maxLength={maxChars}
                placeholder="Paste or write what should be read aloud…"
                value={value.text}
                aria-invalid={scriptError ? true : undefined}
                aria-describedby={scriptError ? `${scriptId}-error` : `${scriptId}-count`}
                onChange={(event) => set("text", event.target.value)}
                className="resize-y text-base leading-relaxed"
              />
            </FormRow>
            <p id={`${scriptId}-count`} className="text-right text-xs tabular-nums text-muted-foreground">
              {formatNumber(value.text.length)} / {formatNumber(maxChars)} characters
            </p>
            {scriptError ? (
              <Callout id={`${scriptId}-error`} variant="danger" role="alert">
                {scriptError}
              </Callout>
            ) : null}
          </div>

          <VoicePicker value={value.voice as VoiceName} onValueChange={(voice) => set("voice", voice)} onPreview={onPreview} />

          <div className="flex flex-col gap-4 border-t pt-4">
            <SliderInput
              label="Speed"
              value={value.speed}
              onValueChange={(next) => set("speed", next)}
              min={KNOBS.speed.min}
              max={KNOBS.speed.max}
              step={KNOBS.speed.step}
              unit="×"
              showTicks
            />
            <SliderInput
              label="Pitch"
              value={value.pitch}
              onValueChange={(next) => set("pitch", next)}
              min={KNOBS.pitch.min}
              max={KNOBS.pitch.max}
              step={KNOBS.pitch.step}
              unit="st"
              showTicks
            />
            <SliderInput
              label="Stability"
              value={value.stability}
              onValueChange={(next) => set("stability", next)}
              min={KNOBS.stability.min}
              max={KNOBS.stability.max}
              step={KNOBS.stability.step}
              showTicks
            />
          </div>
        </CardContent>

        <CardFooter className="flex-col items-stretch gap-2">
          {otherError ? (
            <Callout variant="danger" role="alert">
              {otherError}
            </Callout>
          ) : null}
          <div className="flex flex-wrap items-center justify-between gap-3">
            <p role="status" className="text-xs text-muted-foreground">
              {notice}
            </p>
            <AsyncButton type="submit" loading={generating} loadingText="Generating…">
              <AudioLinesIcon data-icon="inline-start" aria-hidden="true" />
              Generate speech
            </AsyncButton>
          </div>
        </CardFooter>
      </form>
    </Card>
  )
}
app/ai/voice/components/voice-history.tsx
"use client"

import * as React from "react"
import { DownloadIcon, PauseIcon, PlayIcon } from "lucide-react"

import { useInterval } from "@/hooks/use-interval"
import { useReducedMotion } from "@/hooks/use-reduced-motion"
import { Badge } from "@/components/ui/badge"
import { Button } from "@/components/ui/button"
import { EmptyState } from "@/components/ui/empty-state"
import { Sparkline } from "@/components/ui/sparkline"
import { StatusBadge } from "@/components/ui/status-badge"

import { type VoiceJobRow } from "../data"
import { formatClock } from "./voice-vocabulary"

// "done" is not in StatusBadge's own vocabulary, and "playing" is this page's.
const STATUS_MAP = { done: "success", queued: "primary", playing: "info" } as const

/** How often the bars step while a job "plays": brisk enough to read as sound. */
const STEP_MS = 120

/** The bars shifted by `by`, so a static waveform reads as one moving under a playhead. */
const rotate = (bars: number[], by: number): number[] => {
  const shift = by % bars.length
  return [...bars.slice(shift), ...bars.slice(0, shift)]
}

function JobRow({
  row,
  playing,
  onToggle,
  onDownload,
}: {
  row: VoiceJobRow
  playing: boolean
  onToggle: () => void
  onDownload: () => void
}) {
  const reduced = useReducedMotion()
  const [tick, setTick] = React.useState(0)

  // Nothing plays: "playing" is a pressed toggle and a waveform that steps
  // along under it. The interval pauses under reduced motion — the toggle
  // still reports pressed, the picture just holds still.
  useInterval(() => setTick((current) => current + 1), playing && !reduced ? STEP_MS : null)

  const bars = playing && !reduced ? rotate(row.bars, tick) : row.bars

  return (
    <li className="flex items-start gap-3 py-3">
      <Button
        type="button"
        variant={playing ? "default" : "outline"}
        size="icon"
        aria-label={`Play ${row.excerpt}`}
        aria-pressed={playing}
        className="mt-0.5 shrink-0 rounded-full"
        onClick={onToggle}
      >
        {playing ? <PauseIcon aria-hidden="true" /> : <PlayIcon aria-hidden="true" />}
      </Button>

      <div className="flex min-w-0 flex-1 flex-col gap-1.5">
        <p className="line-clamp-2 text-sm text-foreground">{row.excerpt}</p>
        <div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
          <Badge variant="outline">{row.voice}</Badge>
          <StatusBadge
            status={playing ? "playing" : row.status}
            map={STATUS_MAP}
            size="sm"
            dot={playing}
            pulse={playing && !reduced}
          />
          <span className="tabular-nums">{formatClock(row.durationSec)}</span>
          <span aria-hidden="true">·</span>
          <span className="tabular-nums">{row.speed}×</span>
          <span aria-hidden="true">·</span>
          <span>{row.when}</span>
        </div>
      </div>

      {/* Always rendered: a chart inside a display:none box measures as 0×0
          and recharts complains on every row. Narrower on a phone instead. */}
      <div className="w-14 shrink-0 sm:w-20" aria-hidden="true">
        <Sparkline
          type="bar"
          data={bars}
          height={32}
          showLast={false}
          color={playing ? "chart-2" : "chart-1"}
          className="[--chart-surface:var(--card)]"
        />
      </div>

      <Button
        type="button"
        variant="ghost"
        size="icon-sm"
        aria-label={`Download ${row.excerpt}`}
        className="shrink-0 text-muted-foreground"
        onClick={onDownload}
      >
        <DownloadIcon aria-hidden="true" />
      </Button>
    </li>
  )
}

export type VoiceHistoryProps = {
  rows: VoiceJobRow[]
  /** The job whose toggle is pressed, if any; one at a time. */
  playingId: string | null
  onTogglePlay: (id: string) => void
  onDownload: (row: VoiceJobRow) => void
}

/** Every job, newest first, each with its play toggle and its waveform. */
export function VoiceHistory({ rows, playingId, onTogglePlay, onDownload }: VoiceHistoryProps) {
  if (rows.length === 0) {
    return <EmptyState size="sm" title="Nothing read yet" description="Generate a script and it lands here." />
  }

  return (
    <ol aria-label="History" className="flex flex-col divide-y">
      {rows.map((row) => (
        <JobRow
          key={row.id}
          row={row}
          playing={row.id === playingId}
          onToggle={() => onTogglePlay(row.id)}
          onDownload={() => onDownload(row)}
        />
      ))}
    </ol>
  )
}
app/ai/voice/components/voice-picker.tsx
"use client"

import * as React from "react"
import { Volume2Icon } from "lucide-react"

import { cn } from "@/lib/utils"
import { Button } from "@/components/ui/button"
import { SelectableCard } from "@/components/ui/selectable-card"

import { type VoiceName } from "../data"
import { VOICES } from "./voice-vocabulary"

export type VoicePickerProps = {
  value: VoiceName
  onValueChange: (voice: VoiceName) => void
  /** The Preview button beside a voice was pressed. */
  onPreview: (voice: VoiceName) => void
  className?: string
}

/**
 * The five voices as a radio group of rows: an initial in a tinted circle
 * (no photo, no face — a voice has neither), the name, the languages it
 * reads, and a Preview button that sits beside the row rather than inside it,
 * because a radio must not contain another button.
 */
export function VoicePicker({ value, onValueChange, onPreview, className }: VoicePickerProps) {
  const labelId = React.useId()

  return (
    <div className={cn("flex flex-col gap-2", className)}>
      <p id={labelId} className="type-label">
        Voice
      </p>
      <div role="radiogroup" aria-labelledby={labelId} className="flex flex-col gap-1.5">
        {VOICES.map((voice) => (
          <div key={voice.value} className="flex items-center gap-2">
            <SelectableCard
              indicator="radio"
              selected={voice.value === value}
              onSelectedChange={() => onValueChange(voice.value)}
              className="items-center py-2"
              icon={
                <span
                  className={cn(
                    "flex size-7 items-center justify-center rounded-full text-xs font-medium",
                    voice.value === value ? "bg-brand-muted text-brand" : "bg-muted text-muted-foreground"
                  )}
                >
                  {voice.value.charAt(0)}
                </span>
              }
              title={voice.value}
              description={
                <>
                  <span>{voice.language}</span>
                  <span aria-hidden="true"> · </span>
                  <span>{voice.character}</span>
                </>
              }
            />
            <Button
              type="button"
              variant="ghost"
              size="icon-sm"
              aria-label={`Preview ${voice.value}`}
              className="shrink-0 text-muted-foreground"
              onClick={() => onPreview(voice.value)}
            >
              <Volume2Icon aria-hidden="true" />
            </Button>
          </div>
        ))}
      </div>
    </div>
  )
}
app/ai/voice/components/voice-view.tsx
"use client"

import * as React from "react"

import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
import { FormRow } from "@/components/ui/form-section"
import { PageHeader } from "@/components/ui/page-header"
import {
  Select,
  SelectContent,
  SelectItem,
  SelectTrigger,
  SelectValue,
} from "@/components/ui/select"
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { ToggleRow } from "@/components/ui/toggle-row"

import { synthesize, type SynthesizeInput } from "../actions"
import { type VoiceJobRow, type VoiceName } from "../data"
import { VoiceForm, type FormError } from "./voice-form"
import { VoiceHistory } from "./voice-history"
import { FORMATS, KNOBS, SAMPLE_RATES, VOICES } from "./voice-vocabulary"

const INITIAL: SynthesizeInput = {
  text: "",
  voice: VOICES[0].value,
  speed: KNOBS.speed.initial,
  pitch: KNOBS.pitch.initial,
  stability: KNOBS.stability.initial,
}

const NOT_WIRED = "Nothing is rendered in the sample workspace; previews and downloads start once a synthesiser is connected."

/** One labelled select on the settings tab. */
function OutputSelect({
  id,
  label,
  options,
  value,
  onValueChange,
}: {
  id: string
  label: string
  options: { value: string; label: string }[]
  value: string
  onValueChange: (value: string) => void
}) {
  return (
    <FormRow label={label} htmlFor={id}>
      {(field) => (
        <Select value={value} onValueChange={(next) => onValueChange(String(next ?? value))} items={options}>
          <SelectTrigger id={field.id} aria-label={label} className="w-full sm:w-56">
            <SelectValue />
          </SelectTrigger>
          <SelectContent>
            {options.map((option) => (
              <SelectItem key={option.value} value={option.value}>
                {option.label}
              </SelectItem>
            ))}
          </SelectContent>
        </Select>
      )}
    </FormRow>
  )
}

export type VoiceViewProps = {
  rows: VoiceJobRow[]
  maxChars: number
}

/**
 * The voice page. One island, because the form, the history it prepends to
 * and the one job "playing" are the same piece of state. Every change goes
 * through a server action and is applied only once that action says it took.
 */
export function VoiceView({ rows: initial, maxChars }: VoiceViewProps) {
  const [rows, setRows] = React.useState(initial)
  const [form, setForm] = React.useState<SynthesizeInput>(INITIAL)
  const [generating, setGenerating] = React.useState(false)
  const [error, setError] = React.useState<FormError | undefined>(undefined)
  const [notice, setNotice] = React.useState<string | undefined>(undefined)
  const [playingId, setPlayingId] = React.useState<string | null>(null)
  const [historyNotice, setHistoryNotice] = React.useState<string | undefined>(undefined)
  const [format, setFormat] = React.useState(FORMATS[0].value)
  const [rate, setRate] = React.useState(SAMPLE_RATES[1].value)
  const [saveDefault, setSaveDefault] = React.useState(false)
  const settingsBase = React.useId()

  async function generate() {
    setGenerating(true)
    const result = await synthesize(form)
    setGenerating(false)

    if (!result.ok) {
      setError({ message: result.error.message, field: result.error.field })
      setNotice(undefined)
      return
    }
    setError(undefined)
    setRows((current) => [result.data.row, ...current])
    setForm((current) => ({ ...current, text: "" }))
    setNotice(`Speech generated in ${result.data.row.voice}'s voice — it is first in the history.`)
  }

  return (
    <div className="flex flex-col gap-4">
      <PageHeader
        title="Voice"
        description="Turn a script into natural speech, in one of five voices."
        meta={`${rows.length} ${rows.length === 1 ? "job" : "jobs"}`}
      />

      <div className="grid items-start gap-4 lg:grid-cols-[minmax(0,1fr)_26rem]">
      <VoiceForm
        value={form}
        onChange={(next) => {
          setForm(next)
          if (error) setError(undefined)
        }}
        onGenerate={() => void generate()}
        onPreview={(voice: VoiceName) => setNotice(`${voice} reads a sample line here once a synthesiser is connected.`)}
        generating={generating}
        error={error}
        notice={notice}
        maxChars={maxChars}
      />

      {/* Beside the form on a wide page, scrolling inside its own sheet, so a
          long history never pushes the form's button off the page. */}
      <Card className="lg:sticky lg:top-4 lg:max-h-[calc(100svh-6rem)]">
        <Tabs defaultValue="history" className="min-h-0 flex-1">
          <CardHeader className="border-b pb-0">
            <CardTitle className="sr-only">History and settings</CardTitle>
            <TabsList variant="line" aria-label="History and settings">
              <TabsTrigger value="history">History</TabsTrigger>
              <TabsTrigger value="settings">Settings</TabsTrigger>
            </TabsList>
          </CardHeader>
          <CardContent className="min-h-0 overflow-y-auto py-1">
            <TabsContent value="history" className="flex flex-col gap-2">
              <p role="status" aria-live="polite" className="min-h-4 pt-2 text-xs text-muted-foreground">
                {historyNotice}
              </p>
              <VoiceHistory
                rows={rows}
                playingId={playingId}
                onTogglePlay={(id) => setPlayingId((current) => (current === id ? null : id))}
                onDownload={() => setHistoryNotice(NOT_WIRED)}
              />
            </TabsContent>
            <TabsContent value="settings" className="flex flex-col gap-4 py-3">
              <OutputSelect
                id={`${settingsBase}-format`}
                label="Output format"
                options={FORMATS}
                value={format}
                onValueChange={setFormat}
              />
              <OutputSelect
                id={`${settingsBase}-rate`}
                label="Sample rate"
                options={SAMPLE_RATES}
                value={rate}
                onValueChange={setRate}
              />
              <ToggleRow
                label="Save as default"
                description="Every new job starts with these settings."
                checked={saveDefault}
                onCheckedChange={setSaveDefault}
                className="border-t"
              />
            </TabsContent>
          </CardContent>
        </Tabs>
      </Card>
      </div>
    </div>
  )
}
app/ai/voice/components/voice-vocabulary.ts
/**
 * The words the page offers: the five voices as a reader picks them, the
 * output formats and the sample rates. UI vocabulary, so it lives beside the
 * island rather than in data.ts, which reads the store and must never reach
 * the browser.
 */
import { type VoiceName } from "../data"

export type VoiceOption = {
  value: VoiceName
  /** The languages the voice reads in, the first being its own. */
  language: string
  /** How it sounds, in three words. */
  character: string
}

export const VOICES: VoiceOption[] = [
  { value: "Ada", language: "English (UK)", character: "Warm and measured" },
  { value: "Kai", language: "English (US)", character: "Bright and quick" },
  { value: "Noor", language: "Arabic · English", character: "Calm and low" },
  { value: "Sol", language: "Spanish · English", character: "Clear and friendly" },
  { value: "Yuki", language: "Japanese · English", character: "Soft and precise" },
]

export const FORMATS = [
  { value: "mp3", label: "MP3" },
  { value: "wav", label: "WAV" },
  { value: "ogg", label: "OGG" },
]

export const SAMPLE_RATES = [
  { value: "22050", label: "22.05 kHz" },
  { value: "44100", label: "44.1 kHz" },
  { value: "48000", label: "48 kHz" },
]

/** Seconds as a clock reads them: 5.6 → "0:06", 125 → "2:05". */
export function formatClock(seconds: number): string {
  const whole = Math.round(seconds)
  const minutes = Math.floor(whole / 60)
  const rest = whole % 60
  return `${minutes}:${String(rest).padStart(2, "0")}`
}

/** The knobs' ranges and where a fresh form starts. */
export const KNOBS = {
  speed: { min: 0.5, max: 2, step: 0.05, initial: 1 },
  pitch: { min: -6, max: 6, step: 1, initial: 0 },
  stability: { min: 0, max: 1, step: 0.05, initial: 0.5 },
} as const