# Live transcript

Stream speech into a transcript with partial results, speakers and connection states.

Import: @slices.sh/transcription/headless

Public parts: 17

## 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

## Anatomy

### Composition

```text
Transcript.Live
  Transcript.LiveIdle
  Transcript.Devices
  Transcript.LiveToggle
  Transcript.Listen when={true}
  Transcript.LiveStatus
  Transcript.Log
    Transcript.Segments
      Transcript.Segment
        Transcript.Time
        Transcript.Speaker
        Transcript.Text
    Transcript.Partial
  Transcript.Heard
  Transcript.Latest
  Transcript.Reconnecting
  Transcript.LiveFailed
  Transcript.MicDenied
  Transcript.Supports capability="diarization"
  Transcript.Lacks capability="diarization"
  Transcript.MissingConfiguration
```

## Listen and show segments

```tsx
"use client"

import "@/services"
import { Transcript } from "@slices.sh/transcription/headless"

export function Example() {
  return (
    <Transcript.Live enabled="live">
      <Transcript.LiveToggle>
        {({ listening }) => (listening ? "Stop" : "Start listening")}
      </Transcript.LiveToggle>
      <Transcript.LiveIdle>Press Start to begin.</Transcript.LiveIdle>
      <Transcript.Log>
        <Transcript.Segments>
          <Transcript.Segment>
            <Transcript.Time />
            <Transcript.Speaker />
            <Transcript.Text />
          </Transcript.Segment>
        </Transcript.Segments>
        <Transcript.Partial />
      </Transcript.Log>
      <Transcript.Reconnecting>Reconnecting…</Transcript.Reconnecting>
      <Transcript.LiveFailed>
        {({ message }) => <p>{message}</p>}
      </Transcript.LiveFailed>
      <Transcript.Export format="txt">Save transcript</Transcript.Export>
    </Transcript.Live>
  )
}

```

Select a live provider such as browser, Deepgram or Gladia. Speakers and timecodes only appear when the provider supplies them.

## Only where the server transcribes

```tsx
"use client"

import "@/services"
import { Transcript } from "@slices.sh/transcription/headless"

export function Example() {
  return (
    <Transcript.Configured>
      <Transcript.Live enabled="live">
        <Transcript.LiveToggle>
          {({ listening }) => (listening ? "Stop captions" : "Captions")}
        </Transcript.LiveToggle>
        <Transcript.Log>
          <Transcript.Segments>
            <Transcript.Segment>
              <Transcript.Text />
            </Transcript.Segment>
          </Transcript.Segments>
        </Transcript.Log>
      </Transcript.Live>
    </Transcript.Configured>
  )
}

```

Configured needs no root. It hides the controls once the server says it has no provider key, keeps them while the answer is unknown, and hides nothing for the browser provider, which has no server.

## Complete API reference

### Transcript.Live

scope

Live is a root over a microphone transcribed as somebody speaks. LiveToggle starts or finishes, and renders nothing where the config cannot listen in this browser. Listen when={…} device={…} renders nothing and listens while when holds, for a transcriber something else switches, such as a call's captions. LiveStatus is the always-mounted announcer. LiveIdle invites before anything is heard, LiveFailed gives { error, message } when the session dropped for good. Partial is the hypothesis row at the end, hidden from assistive technology. Heard hands { text, final } for passing words on, to Room.PublishCaption for example. Reconnecting, MicDenied and Devices mean what they mean for Dictation.

Import: @slices.sh/transcription/headless

Capability: live

```tsx
LiveRootProps

type LiveRootProps = RootProps & TranscriptionRootProps & {
  languages?: string[]
  keywords?: string[]
  maxDurationMs?: number
}
```

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

### Transcript.LiveToggle

action · Element: button

Starts listening, or finishes. One control, as a microphone always is.

Absent where the instance cannot listen at all — Firefox, on the browser's recogniser —
rather than a button that did nothing when pressed and said nothing about why.
→ docs/rules/capability-honesty.md

Import: @slices.sh/transcription/headless

Capability: live

Transcript.LiveToggle must be inside <Transcript.Live>.

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

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

### Transcript.Listen

scope

Live is a root over a microphone transcribed as somebody speaks. LiveToggle starts or finishes, and renders nothing where the config cannot listen in this browser. Listen when={…} device={…} renders nothing and listens while when holds, for a transcriber something else switches, such as a call's captions. LiveStatus is the always-mounted announcer. LiveIdle invites before anything is heard, LiveFailed gives { error, message } when the session dropped for good. Partial is the hypothesis row at the end, hidden from assistive technology. Heard hands { text, final } for passing words on, to Room.PublishCaption for example. Reconnecting, MicDenied and Devices mean what they mean for Dictation.

Import: @slices.sh/transcription/headless

Capability: live

Transcript.Listen must be inside <Transcript.Live>.

```tsx
ListenProps

type ListenProps = RootProps & {
  when: boolean
  /** The microphone to hear, when something else already chose one; `null` is the browser's. */
  device?: string | null
}
```

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

### Transcript.LiveStatus

content · Element: p

What a live transcript tells a screen reader.

Always mounted, because a live region that appears with its content already in it
announces nothing. It carries the state and how many lines have settled rather than the
lines themselves: the log below reads those out as they arrive, and repeating them here
would say everything twice.
→ components/headless/transcription/transcript-log.tsx

Import: @slices.sh/transcription/headless

Capability: live

Transcript.LiveStatus must be inside <Transcript.Live>.

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

type Announced = { state: string; connection: string; settled: number }
```

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

### Transcript.LiveIdle

gate

Nothing has been heard yet.

Where the invitation goes. A live transcript with no gate here drew an empty bordered box
the first time anybody opened it, which reads as broken rather than as ready — and it is
the state every visitor sees first. → docs/rules/states-gate-themselves.md

Import: @slices.sh/transcription/headless

Capability: live

Transcript.LiveIdle must be inside <Transcript.Live>.

```tsx
GateProps
```

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

### Transcript.LiveFailed

gate

The session dropped for good, with the reason.

`LiveState` has had a `failed` case from the beginning and nothing rendered it, so a
stream that died left the last few lines on screen and no explanation at all — which
reads as a transcript that has simply stopped being interesting.

`message` is the service's own words for why, `null` where it had none.

Import: @slices.sh/transcription/headless

Capability: live

Transcript.LiveFailed must be inside <Transcript.Live>.

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

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

### Transcript.Partial

content · Element: span

The hypothesis at the end of a live transcript.

Apart from the segments because it is the one part that moves: a list that mixed them
would re-key and re-animate the whole transcript on every guess.

It is the whole row rather than something inside one, so a transcript with nothing
pending is a list of exactly the lines that were said — an empty row left standing for
the next guess is one a screen reader counts and moves through.

And hidden from assistive technology, because it sits inside the live region the settled
lines arrive in: a guess appearing there is an addition like any other, and a half-word
read out and then read again as part of the finished line is the machine talking over the
room. → components/headless/transcription/transcript-log.tsx

Import: @slices.sh/transcription/headless

Capability: live

Transcript.Partial must be inside <Transcript.Live>.

```tsx
PartProps<"span">
```

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

### Transcript.Heard

gate

What was just heard: the hypothesis while it grows, then the line it settled into.

For handing a live transcript on — to captions in a call, say — rather than for drawing it,
which the segments and the partial already do. Nothing while listening has heard nothing.

Import: @slices.sh/transcription/headless

Capability: live

Transcript.Heard must be inside <Transcript.Live>.

```tsx
GateProps<{ text: string; final: boolean }>
```

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

### Transcript.Log

content · Element: ul

The scrolling list a live transcript arrives in.

It stays at the newest line while it is already there, and stops the moment somebody
scrolls up — sticking to the bottom unconditionally is the version everybody writes first
and nobody can use, because reading what was said a minute ago gets you yanked back down
by the next sentence. `Transcript.Latest` is the way back.

A live region on the list itself, and deliberately not `role="log"`. What `log` adds is
exactly the two attributes below — and what it takes away is the list: a role replaces
the element's own, so the rows underneath stop being items of anything and a screen
reader can no longer say how many there are or move between them.
→ hooks/transcription/use-follow.ts

Import: @slices.sh/transcription/headless

Transcript.Log must be inside <Transcript.Live>.

```tsx
PartProps<"ul", { following: boolean }>
```

Source: @slices.sh/transcription/src/headless/transcript-log.tsx

### Transcript.Latest

action · Element: button

Back to the newest line.

Rendered only while the list is not already there, because that is the only time it means
anything — and because a button that is always present beside a list that is always at the
bottom teaches people to ignore it.

Import: @slices.sh/transcription/headless

Transcript.Latest must be inside <Transcript.Live>.

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

Source: @slices.sh/transcription/src/headless/transcript-latest.tsx

### Transcript.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

```tsx
GateProps
```

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

### Transcript.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

Capability: live

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

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

### Transcript.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

Capability: live

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

### Transcript.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

### Transcript.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

### Transcript.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

### Transcript.Configured

gate

Draws its children unless the server has said it cannot transcribe.

For what only makes sense with a transcriber behind it — a Captions button, a transcript
panel — so a room whose server has no provider key shows neither, rather than a button
that turns on captions nobody's words will ever reach and a note ten seconds later saying
so. No root needed: a call's controls sit nowhere near its transcriber. Kept while the
answer is unknown, and where no instance is installed it hides nothing, because neither
is the server saying no. → docs/rules/capability-honesty.md

Import: @slices.sh/transcription/headless

```tsx
GateProps
```

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

### Transcript.Segments

scope

Every settled segment, each in its own scope.

One part for a live transcript and for a finished one, because a screen that drew them
separately would be two transcripts whose rows, timestamps and speaker columns drifted
apart — and the second one is usually the first one, revised.

A finished transcript comes through the corrections rather than straight from the job, so
a line somebody fixed stays fixed — in the list, under the playhead and in the export.
→ hooks/transcription/use-transcript-edits.ts

Import: @slices.sh/transcription/headless

```tsx
RootProps
```

Source: @slices.sh/transcription/src/headless/transcript-segments.tsx

### Transcript.Segment

content · Element: li

One segment's row.

`data-state` is how settled it is, which is what a skin fades by: a hypothesis that
looked as solid as a final would be a transcript that seems to change its mind.

Import: @slices.sh/transcription/headless

Transcript.Segment must be inside <Transcript.Segments>.

```tsx
PartProps<"li", SegmentState>

type SegmentState = { stability: "partial" | "final" | "revised"; current: boolean }
```

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

### Transcript.Time

content · Element: time

When a segment starts.

Renders nothing where the instance cannot place text in time — a column of zeros would be
worse than no column. → docs/rules/capability-honesty.md

Import: @slices.sh/transcription/headless

Transcript.Time must be inside <Transcript.Segments>.

```tsx
PartProps<"time">
```

Source: @slices.sh/transcription/src/headless/segment-time.tsx

### Transcript.Speaker

content · Element: span

Who said it. Nothing where the instance cannot tell voices apart.

Import: @slices.sh/transcription/headless

Transcript.Speaker must be inside <Transcript.Segments>.

```tsx
PartProps<"span">
```

Source: @slices.sh/transcription/src/headless/segment-speaker.tsx

### Transcript.Text

content · Element: span

A segment's words.

Text, and not a control. Every line used to be a `<button>` that jumped the audio, and
disabled itself where there was nothing to jump to — which is most of the time, because a
live transcript has no recording behind it and a submitted job has no player. Two things
follow from a button here and both cost more than the click bought: a screen reader reads
forty lines as forty dimmed buttons, and a press inside a button activates it rather than
starting a selection — so in Chrome and Safari the words cannot be dragged over at all,
and the one way to keep a transcript that is kept nowhere was gone.

Jumping to a moment is `Transcript.Seek`, on the timestamp, and only where there is a
recording to jump in. → components/headless/transcription/segment-seek.tsx

Import: @slices.sh/transcription/headless

Transcript.Text must be inside <Transcript.Segments>.

```tsx
PartProps<"span">
```

Source: @slices.sh/transcription/src/headless/segment-text.tsx
