# Dictation

Dictate into an existing field with microphone, revision and recovery controls.

Import: @slices.sh/transcription/headless

Public parts: 37

## Installation

### 1. Install the service registry

services/index.ts, which `import "@/services"` loads. A provider setup writes the same file; skip this if your app has one.

```bash
npx shadcn@latest add "https://slices.sh/r/services.json"
```

### 2. Install the headless library

```bash
npm install @slices.sh/transcription@^0.1.11
```

### 3. Install the service

Install the provider configuration via shadcn or the slices CLI. Skip this step if your app already has the service configured. Provider commands and provisioning: https://slices.sh/docs/services/transcription.md

### Optional: Voice visualizers

```bash
npm install @slices.sh/voice-canvas
```

## Anatomy

### Composition

```text
Dictation.Root
  Dictation.Available
    Dictation.Field
    Dictation.Marks
    Dictation.Devices
    Dictation.MicButton
    Dictation.Idle
    Dictation.Listening
      Dictation.Speaking
      Dictation.Partial
      Dictation.Elapsed
      Dictation.Engine
      Dictation.Countdown
      Dictation.MicLevel
      Dictation.Level
      Dictation.Signal
        BarVisualizer
        LiveWaveform
      Dictation.Waveform
      Dictation.Cancel
    Dictation.Finalizing
    Dictation.Revised
      Dictation.AcceptRevision
      Dictation.KeepMine
    Dictation.Status
    Dictation.Undo
    Dictation.Failed
      Dictation.Retry
      Dictation.DiscardRecording
    Dictation.NothingHeard
    Dictation.MicDenied
    Dictation.Reconnecting
    Dictation.NotLive
    Dictation.Supports capability="live"
    Dictation.Lacks capability="code-switching"
  Dictation.Unavailable
  Dictation.MissingConfiguration
```

## Dictate into a field

```tsx
"use client"

// services/index.ts comes from https://slices.sh/r/services.json (or any provider setup)
import "@/services"
import { Dictation } from "@slices.sh/transcription/headless"

export function Example() {
  return (
    <Dictation.Root>
      <Dictation.Field render={<textarea aria-label="Notes" />} />
      <Dictation.MicButton>Dictate</Dictation.MicButton>
      <Dictation.Listening>Listening…</Dictation.Listening>
      <Dictation.MicDenied>
        Allow microphone access to continue.
      </Dictation.MicDenied>
    </Dictation.Root>
  )
}

```

The browser provider requires a supported browser and microphone permission. Remote providers have different capabilities.

## An optional microphone

```tsx
"use client"

// services/index.ts comes from https://slices.sh/r/services.json (or any provider setup)
import "@/services"
import { Dictation } from "@slices.sh/transcription/headless"

export function Example() {
  return (
    <Dictation.Root enabled="optional">
      <Dictation.Field render={<textarea aria-label="Notes" />} />
      <Dictation.MicButton>Dictate</Dictation.MicButton>
    </Dictation.Root>
  )
}

```

Without transcription, the textarea remains usable and the microphone disappears.

## Complete API reference

### Dictation.Root

scope

Root holds the dictation state for the field below it. With the service it opens the microphone, picks the mode from the config's capabilities (live when it can, record then transcribe when it has file only) and keeps one recording for a retry. With enabled="optional" and no service, it renders its children and nothing else.

Import: @slices.sh/transcription/headless

```tsx
DictationRootProps

type DictationRootProps = RootProps & {
  /**
   * Left out or `true`: required, and a missing service throws `slices/service-missing`.
   * `"optional"`: renders its children with no transcription service, and the parts degrade:
   * `Dictation.Field` renders its input untouched and the microphone renders nothing.
   * `false`: off. A capability name (`"live"`) requires the service and that capability.
   */
  enabled?: Enabled
  /** The transcription config, `"default"` when left out. */
  config?: string
  /**
   * The field's text, where an app already holds it — in a form, say. Left out, this root
   * holds it, which is what lets a pattern be nothing but markup.
   * → docs/rules/patterns-read-no-state.md
   */
  value?: string
  onChange?(value: string): void
  languages?: string[]
  /** A ceiling: a microphone left open bills without producing anything. */
  maxDurationMs?: number
  /**
   * How long a quiet has to last before it finishes on its own. `0` never does.
   *
   * The one failure here that costs money quietly is a microphone somebody walked away
   * from. → hooks/transcription/use-silence.ts
   */
  stopAfterSilenceMs?: number
  /**
   * ⌘⇧Space starts and finishes, Escape discards.
   *
   * On the document, so somebody already typing need not reach for the microphone. Off by
   * default, because a chord bound by a component nobody asked to bind it is a chord that
   * fights whatever the page around it already does with that key.
   * → hooks/transcription/use-dictation-keys.ts
   */
  shortcuts?: boolean
  /**
   * Offer the definitive text when it disagrees with an edit. Off, for a screen with no
   * `Revised` part: the edit stands, rather than a choice nobody can see being announced.
   * → hooks/transcription/use-dictation.ts
   */
  revisions?: boolean
}
```

Source: @slices.sh/transcription/src/headless/dictation-root.tsx

### Dictation.Field

input · Element: textarea

The field being dictated into, which somebody may also type in. It wraps any text input:
give it the input as `render` (a `Composer.Input`, an address input, a plain textarea) and
it takes over four props of it: `value` and `onChange`, through which recognised words land
at the caret, `aria-busy`, true while listening or finalizing, which is the whole of its link
to a form, and `onKeyDown`, where Escape cancels. It knows nothing about forms or messages.
Without a transcription service it renders its input untouched.

It is a part rather than the pattern's own textarea so the text lives in the root: what a
person types and what dictation writes are the same value, and two owners of it would
disagree the first time both happened at once.

It also owns the caret, in both directions. Where it is goes up, because dictation starts
there. Where it belongs comes back down as arithmetic rather than as a number from
anybody: a controlled textarea whose value changed puts the caret at the end, and every
committed segment changes the value — so on a change this element did not make, the caret
goes to the end of what changed, which is exactly the end of the dictated run.

Import: @slices.sh/transcription/headless

Dictation.Field must be inside <Dictation.Root>.

```tsx
PartProps<"textarea">
```

Source: @slices.sh/transcription/src/headless/field.tsx

### Dictation.Marks

content · Element: div

The field's text again, with the span dictation just wrote singled out.

A textarea cannot colour a range of its own value. The only way to point at the words
that have just arrived is a second element behind it with the same metrics, holding the
same text invisibly and marking the run — so the eye finds the new text and the field was
never anything but a textarea, with its own caret, its own selection and its own
spellcheck.

The three pieces are handed over rather than drawn, because which of them is marked is
this layer's and what a mark looks like is not.
→ docs/rules/words-in-copy-looks-in-skin.md

Import: @slices.sh/transcription/headless

Dictation.Marks must be inside <Dictation.Root>.

```tsx
PartProps<"div", { before: string; run: string; after: string; at: Marked }>
```

Source: @slices.sh/transcription/src/headless/marks.tsx

### Dictation.MicButton

action · Element: button

The one button: starts when idle, finishes when listening.

One control rather than two, because that is what everybody expects from a microphone,
and because a separate stop button spends most of its life disabled beside it.

Import: @slices.sh/transcription/headless

Dictation.MicButton must be inside <Dictation.Root>.

```tsx
PartProps<"button", { listening: boolean }>
```

Source: @slices.sh/transcription/src/headless/mic-button.tsx

### Dictation.MicLevel

content · Element: meter

How loud it is, as a real `<meter>`.

Read from the local analyser rather than from anything the provider sends, so it follows
the voice rather than the network. Rendered only while listening: a meter at zero next to
an idle button says nothing.

`Dictation.Level` is the same number without the element, for a skin that draws its own.

Import: @slices.sh/transcription/headless

Dictation.MicLevel must be inside <Dictation.Root>.

```tsx
PartProps<"meter", { level: number }>
```

Source: @slices.sh/transcription/src/headless/mic-level.tsx

### Dictation.Level

content · Element: div

How loud it is, as a number somebody else draws.

`Dictation.MicLevel` is a real `<meter>`, which is the right answer when a meter is what
is wanted. This is for the other one: twelve bars, a ring, a blob that breathes — a skin
that is given 0–1 and decides. Neither belongs in the other, because an element the
browser styles and an element a designer draws are different things.

Rendered only while listening: a level of zero beside an idle button says nothing.

Import: @slices.sh/transcription/headless

Dictation.Level must be inside <Dictation.Root>.

```tsx
PartProps<"div", { level: number; speaking: boolean }>
```

Source: @slices.sh/transcription/src/headless/level.tsx

### Dictation.Signal

content · Element: div

The voice itself, handed to whatever draws it.

The one part here that gives out an object with functions on it rather than a number, and
deliberately: a waveform wants a fresh reading every frame, and a part that put those
numbers into React state would re-render the field somebody is dictating into sixty times
a second. What goes across is the analyser; the drawing loop belongs to the skin, and it
reads from its own `requestAnimationFrame` without this layer hearing about any of it.
→ services/transcription/signal.ts

`mode` is what the drawing is of rather than what it looks like: a voice arriving, a pass
being waited on, or a microphone that is not open. Which of those is a fact about the
dictation, and the only one this layer is entitled to state.

Import: @slices.sh/transcription/headless

Dictation.Signal must be inside <Dictation.Root>.

```tsx
PartProps<"div", { source: SignalSource | null; mode: "idle" | "listening" | "processing" }>
```

Source: @slices.sh/transcription/src/headless/signal.tsx

### Dictation.Waveform

content · Element: div

The last couple of seconds of voice, oldest first, for a skin to draw as it likes.

A history rather than a spectrum. A bar per frequency band is a picture of the sound; a
bar per moment is a picture of the *speaking*, and it is the second one people read —
which is also why it scrolls, since a shape that only rises and falls in place cannot
show a pause.

The list is handed over whole and is never drawn here. What it looks like is the skin's,
and drawing anything at all would put a decision in the headless layer that belongs one
level up. → docs/rules/words-in-copy-looks-in-skin.md

Import: @slices.sh/transcription/headless

Dictation.Waveform must be inside <Dictation.Root>.

```tsx
PartProps<"div", { peaks: readonly number[]; speaking: boolean }>
```

Source: @slices.sh/transcription/src/headless/waveform.tsx

### Dictation.Speaking

gate

Somebody is talking, rather than the room merely being on.

Not the same as `Dictation.Listening`, which is about the microphone: an open microphone
in a quiet room is listening and nobody is speaking. The difference is what a screen needs
to say "go on, I'm getting this" without saying it into silence.

The timing that makes it watchable — a syllable turns it on, a pause between words does
not turn it off — is in `hooks/transcription/level.ts`.

Import: @slices.sh/transcription/headless

Dictation.Speaking must be inside <Dictation.Root>.

```tsx
GateProps
```

Source: @slices.sh/transcription/src/headless/speaking.tsx

### Dictation.Partial

content · Element: span

The hypothesis being revised, which never goes into the field.

Shown beside it instead, so a person watching their own words appear is not also watching
them rewrite themselves inside something they might be about to edit.

Split at the last word, because that is the one that is about to be rewritten and a
hypothesis that looks as settled as the text beside it is a hypothesis nobody reads as
one. Which word is the unstable one is a fact; drawing it lighter is a look.
→ docs/rules/words-in-copy-looks-in-skin.md

Import: @slices.sh/transcription/headless

Capability: live, partials

Dictation.Partial must be inside <Dictation.Root>.

```tsx
PartProps<"span", { text: string; settled: string; last: string }>
```

Source: @slices.sh/transcription/src/headless/partial.tsx

### Dictation.Elapsed

content · Element: span

How long it has been listening, as m:ss.

Import: @slices.sh/transcription/headless

Dictation.Elapsed must be inside <Dictation.Root>.

```tsx
PartProps<"span", { elapsedMs: number }>
```

Source: @slices.sh/transcription/src/headless/elapsed.tsx

### Dictation.Engine

content · Element: span

Who is transcribing, and on which model.

A dictation that fell back to the browser's own recogniser looked exactly like one on a
provider until the words came out wrong; this is where it says which it is. Hidden until the
instance has answered, and wherever it never does. → docs/rules/capability-honesty.md

Import: @slices.sh/transcription/headless

Dictation.Engine must be inside <Dictation.Root>.

```tsx
PartProps<"span", TranscriptionEngine>
```

Source: @slices.sh/transcription/src/headless/engine.tsx

### Dictation.Countdown

content · Element: span

How many seconds of quiet are left before it stops itself.

Rendered only while something is counting, because that is the only time it is true — and
it is worth saying out loud rather than only acting on: a dictation that ends in the
middle of a thought reads as one that broke, and somebody who is pausing to think has no
way to know a word would cancel it. → hooks/transcription/use-silence.ts

Import: @slices.sh/transcription/headless

Dictation.Countdown must be inside <Dictation.Root>.

```tsx
PartProps<"span", { seconds: number }>
```

Source: @slices.sh/transcription/src/headless/countdown.tsx

### Dictation.Idle

gate

Nothing is happening: no microphone open, nothing waiting to be decided.

Where a hint about how to start belongs. It is a gate rather than the absence of the
others because "idle" is four of the seven states, and a pattern that wrote the negation
itself would be a pattern reading the state. → docs/rules/states-gate-themselves.md

Import: @slices.sh/transcription/headless

Dictation.Idle must be inside <Dictation.Root>.

```tsx
GateProps
```

Source: @slices.sh/transcription/src/headless/idle.tsx

### Dictation.Listening

gate

A microphone is open.

Import: @slices.sh/transcription/headless

Dictation.Listening must be inside <Dictation.Root>.

```tsx
GateProps
```

Source: @slices.sh/transcription/src/headless/listening.tsx

### Dictation.Finalizing

gate

Stopped, and waiting for the definitive text.

Its own state because the wait is real and worth showing: without `live` it is the whole
transcription, and a button that looked idle here would be pressed again.

Import: @slices.sh/transcription/headless

Dictation.Finalizing must be inside <Dictation.Root>.

```tsx
GateProps
```

Source: @slices.sh/transcription/src/headless/finalizing.tsx

### Dictation.Revised

gate

The definitive text disagrees with what is in the field.

Only reached when somebody typed while it was listening — otherwise the better text is
simply taken and nobody sees this. Here, they choose. → hooks/transcription/types.ts

`diff` is what changed, word by word, because the alternative is asking somebody to read
two sentences and spot the difference — which for the correction this usually is, one
name in the middle of a line they have already read, is the whole decision hidden.
What a removed or an added word looks like is the skin's.
→ hooks/transcription/word-diff.ts

Import: @slices.sh/transcription/headless

Capability: live, file

Dictation.Revised must be inside <Dictation.Root>.

```tsx
GateProps<{ revision: string; committed: string; diff: readonly DiffPart[] }>
```

Source: @slices.sh/transcription/src/headless/revised.tsx

### Dictation.AcceptRevision

action · Element: button

Takes the machine's version.

Import: @slices.sh/transcription/headless

Capability: live, file

Dictation.AcceptRevision must be inside <Dictation.Root>.

```tsx
PartProps<"button">
```

Source: @slices.sh/transcription/src/headless/accept-revision.tsx

### Dictation.KeepMine

action · Element: button

Keeps what is in the field.

Import: @slices.sh/transcription/headless

Capability: live, file

Dictation.KeepMine must be inside <Dictation.Root>.

```tsx
PartProps<"button">
```

Source: @slices.sh/transcription/src/headless/keep-mine.tsx

### Dictation.Cancel

action · Element: button

Gives up and puts the field back.

Not the same as the microphone button: that one finishes and waits for the text, this one
throws it away. A dictation needs both. → services/transcription/ports.ts

Import: @slices.sh/transcription/headless

Dictation.Cancel must be inside <Dictation.Root>.

```tsx
PartProps<"button">
```

Source: @slices.sh/transcription/src/headless/cancel-dictation.tsx

### Dictation.Retry

action · Element: button

Sends the kept recording again.

Rendered only where there is one. A live session's words arrived as text or not at all,
so on that path there is nothing behind this button and it is not drawn — which is the
difference between offering a retry and offering a shape.
→ hooks/transcription/use-dictation.ts

Import: @slices.sh/transcription/headless

Dictation.Retry must be inside <Dictation.Root>.

```tsx
PartProps<"button">
```

Source: @slices.sh/transcription/src/headless/retry-dictation.tsx

### Dictation.DiscardRecording

action · Element: button

Throws the kept recording away, and with it the offer to send it again.

Import: @slices.sh/transcription/headless

Dictation.DiscardRecording must be inside <Dictation.Root>.

```tsx
PartProps<"button">
```

Source: @slices.sh/transcription/src/headless/discard-recording.tsx

### Dictation.Undo

action · Element: button

Removes what dictation wrote, and nothing anybody typed.

Import: @slices.sh/transcription/headless

Dictation.Undo must be inside <Dictation.Root>.

```tsx
PartProps<"button">
```

Source: @slices.sh/transcription/src/headless/undo-dictation.tsx

### Dictation.MicDenied

gate

The browser will not give this page a microphone.

Three refusals rather than one, because only one of them is a setting: no device, an
insecure page or a browser without `getUserMedia` is nobody's to grant; a refusal can be
taken back in the address bar; and a microphone another application is holding needs
nothing changed at all, only the other application closed.

Which one it was is read from the rejection `getUserMedia` threw, not from the Permissions
API — Firefox has no `microphone` name and Safari's answer is unreliable, so a gate that
waited for that one never appeared outside Chrome.
→ services/transcription/microphone.ts

Import: @slices.sh/transcription/headless

```tsx
GateProps<{ unavailable: boolean; busy: boolean }>
```

Source: @slices.sh/transcription/src/headless/mic-denied.tsx

### Dictation.Devices

input · Element: select

Which microphone to listen to.

A `<select>`, which is the one control that is already the right thing on both ends: a
menu beside a pointer and the operating system's own wheel or sheet on a phone, with its
keyboard, its type-ahead and its screen-reader behaviour already correct. A popover built
out of buttons would be a worse version of all of that on the smaller screen.

The list is handed over and the options are drawn one level up, because an `<option>` is a
host element and this layer renders none. What the labels say is the browser's — they are
data rather than words, and are empty until permission has been given once, which is it
declining to be a fingerprint rather than a bug.

Three things a picker has to be honest about:

- Before that first permission it is also the request. There is one device to list and no
  names for it, so it is shown anyway and opening it is what asks; the names arrive when
  the browser has said yes. Hiding it until then meant the first press always used the
  default, whatever somebody had meant to pick.
- While listening it shows the microphone that is actually open, which is not always the
  one that was chosen, and it is disabled: a change then would be a choice that nothing
  acts on until the next press, and a select that looks live and is not is worse than one
  that says so.
- Otherwise it is rendered only where there is a choice to make: one microphone and a
  picker beside it is a control that cannot do anything.

Import: @slices.sh/transcription/headless

Mic.Devices must be inside a transcription root.

```tsx
PartProps<"select", { devices: MicDevice[]; active: MicDevice | null; listening: boolean }>
```

Source: @slices.sh/transcription/src/headless/mic-devices.tsx

### Dictation.Reconnecting

gate

The recogniser dropped and is coming back.

Its own gate because it is not a failure and not idleness: Chrome's recogniser ends itself
after a silence even when it was told to be continuous, and a screen with only "listening"
to show left somebody talking into a socket that had closed. The contract has modelled
`reconnecting` from the start and nothing was reading it.
→ services/transcription/ports.ts

Import: @slices.sh/transcription/headless

Capability: live, partials

```tsx
GateProps
```

Source: @slices.sh/transcription/src/headless/reconnecting.tsx

### Dictation.Status

content · Element: p

The one region a screen reader is told about, and the reason it is always here.

A live region has to exist *before* the text arrives in it: one that mounts with its
content already in place announces nothing, which is why this is not simply an
`aria-live` on the hypothesis. So it renders whatever a pattern puts in it at every state,
empty included, and assistive technology watches one place from the start.

What it says is the committed text, never the hypothesis. A partial is rewritten three or
four times a second and reading each version out loud is not dictation feedback, it is a
machine talking over the person. → components/headless/transcription/partial.tsx

Import: @slices.sh/transcription/headless

Dictation.Status must be inside <Dictation.Root>.

```tsx
PartProps<"p", Announced>

type Announced = { state: string; committed: string; live: boolean }
```

Source: @slices.sh/transcription/src/headless/dictation-status.tsx

### Dictation.Failed

gate

The service refused, with the reason. Never a microphone: that is the gate beside this.

`recorded` says whether the audio that pass was made from is still in hand, which decides
whether there is anything to offer beyond an apology: a recorded pass can be sent again,
and a live session's words arrived as text or not at all.
→ components/headless/transcription/retry-dictation.tsx

`message` is the service's own words for it, `null` where it had none. Say it as written
when it is there: a sentence picked from `error` alone is a guess about which refusal this
was, and it was once wrong about a provider's 429.

Import: @slices.sh/transcription/headless

Dictation.Failed must be inside <Dictation.Root>.

```tsx
GateProps<{ error: TranscriptionErrorSlug; message: string | null; recorded: boolean }>
```

Source: @slices.sh/transcription/src/headless/dictation-failed.tsx

### Dictation.NotLive

gate

This instance cannot transcribe as somebody speaks.

The button still works — it records and submits at the end — so this is a note about
*when* the text will appear, not a refusal. A capability that is absent removes an
affordance; it never breaks the screen. → docs/rules/capability-honesty.md

Only where there is an "at the end" to speak of: an instance that cannot take the
recording either has no button to make one with, and this would promise a transcript.

Import: @slices.sh/transcription/headless

Dictation.NotLive must be inside <Dictation.Root>.

```tsx
GateProps
```

Source: @slices.sh/transcription/src/headless/not-live.tsx

### Dictation.MissingConfiguration

gate

The transcription backend answered that its server lacks a key it needs.

Pinned to this family's service so a screen does not repeat the scope, and so another
service's missing variable is never its notice. Where the instance can do without the
key — a microphone that hides itself when no engine is available — nothing is asked of
this gate: the server's warning is what says so. → docs/rules/missing-configuration-is-said.md

Import: @slices.sh/transcription/headless

```tsx
GateProps<{ scopes: readonly string[] }>
```

Source: @slices.sh/transcription/src/headless/missing-configuration.tsx

### Dictation.Available

gate

Draws its children only where `Dictation.MicButton` would draw one.

It exists so what surrounds the button goes with it: a wrapper left behind an absent
button is an empty element that still takes its place in a row's gap.
→ docs/rules/capability-honesty.md

Import: @slices.sh/transcription/headless

Dictation.Available must be inside <Dictation.Root>.

```tsx
GateProps
```

Source: @slices.sh/transcription/src/headless/available.tsx

### Dictation.Unavailable

gate

An instance is installed and cannot take a microphone here: the browser's recogniser in a
browser that has none, say.

The place for a sentence saying so, because a button that is simply absent leaves
somebody looking for one. Not drawn where no instance is installed at all: that screen
chose not to dictate, and has nothing to explain. → docs/rules/capability-honesty.md

Import: @slices.sh/transcription/headless

Dictation.Unavailable must be inside <Dictation.Root>.

```tsx
GateProps
```

Source: @slices.sh/transcription/src/headless/unavailable.tsx

### Dictation.NothingHeard

gate

The dictation finished and no word came out of it.

A quiet room, a microphone nobody spoke into, a session that closed with nothing said: all
end `done`, and a screen that announced "inserted" over an empty field would be saying
something untrue. → docs/rules/capability-honesty.md

Import: @slices.sh/transcription/headless

Dictation.NothingHeard must be inside <Dictation.Root>.

```tsx
GateProps
```

Source: @slices.sh/transcription/src/headless/nothing-heard.tsx

### Dictation.Supports

gate

Draws its children only where this instance can do the thing.

The gate exists because capability belongs to the configured instance rather than to the
provider: the same key with a different model has different answers, and a screen that
assumed would show a speaker column full of nothing.
→ services/transcription/capabilities.ts

Import: @slices.sh/transcription/headless

Transcript.Supports must be inside a transcription root.

```tsx
SupportsProps

type SupportsProps = GateProps & { capability: Capability }
```

Source: @slices.sh/transcription/src/headless/supports.tsx

### Dictation.Lacks

gate

Draws its children only where this instance *cannot* do the thing.

The other half of `Supports`, and it is not the same screen with the condition flipped: a
capability that is absent usually removes an affordance silently, and the cases worth a
sentence are the ones where somebody would otherwise be surprised later — a model that
hears one language at a time, given a list of three.

Saying it is the alternative to pretending. → docs/rules/capability-honesty.md

Import: @slices.sh/transcription/headless

Transcript.Lacks must be inside a transcription root.

```tsx
LacksProps

type LacksProps = GateProps & { capability: Capability }
```

Source: @slices.sh/transcription/src/headless/lacks.tsx

### BarVisualizer

content · Generic

Visual renderer — not purely headless.

A few bars that rise with the voice, for the places a scrolling strip will not fit.

Bands rather than moments: inside a mic button there is no room for a history, so the
shape is the symmetric silhouette of one instant — the lowest band in the middle, the
highest at the edges — which reads as "a voice" at sixteen pixels where a waveform reads
as a smudge.

Written to the elements' own styles from an animation frame, never through React: there
are five of them and they change sixty times a second.

Import: @slices.sh/voice-canvas

```tsx
BarVisualizerProps

export type BarVisualizerProps = {
  source: VoiceSignal | null
  /** What is happening, which decides the resting animation when nobody is talking. */
  state?: "idle" | "connecting" | "listening" | "thinking"
  bars?: number
  size?: keyof typeof SIZES
  className?: string
}
```

Source: @slices.sh/voice-canvas/src/bar-visualizer.tsx

### LiveWaveform

content · Generic

Visual renderer — not purely headless.

The last couple of seconds of voice, scrolling right to left.

A history rather than a spectrum, and the difference matters: a bar per frequency band is
a picture of the sound, a bar per moment is a picture of the *speaking* — and it is the
second one people read, because it is the only one that can show a pause.

Every number it draws comes from the signal it was handed, read inside its own animation
frame. It never renders, never sets state and never opens a microphone.

Import: @slices.sh/voice-canvas

```tsx
LiveWaveformProps

export type LiveWaveformProps = {
  /** `null` before a microphone is open, which draws the resting baseline. */
  source: VoiceSignal | null
  /** `listening` scrolls the voice, `processing` freezes and collapses it, `idle` rests. */
  mode?: "idle" | "listening" | "processing"
  barWidth?: number
  gap?: number
  /** One new bar every `sampleMs`; the bars glide between samples, so the scroll is smooth. */
  sampleMs?: number
  className?: string
  "aria-label"?: string
}
```

Source: @slices.sh/voice-canvas/src/live-waveform.tsx
