# Recording transcript

Upload a recording, follow transcription progress and inspect the completed result.

Import: @slices.sh/transcription/headless

Public parts: 29

## 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.Job timestamps="word"
  Transcript.JobIdle
    Transcript.JobDrop
      Transcript.JobPicker
      Transcript.JobLimit
  Transcript.JobRunning
    Transcript.JobStatus
    Transcript.JobProgress
    Transcript.CancelJob
  Transcript.JobDone
    Transcript.JobEmpty
    Transcript.Playback
      Transcript.Audio
      Transcript.PlayToggle
      Transcript.Rate
      Transcript.Segments
        Transcript.Segment
          Transcript.Seek
            Transcript.Time
          Transcript.Speaker
          Transcript.SpeakerName
          Transcript.Text
          Transcript.Edit
          Transcript.Words
            Transcript.Word
      Transcript.Export format="srt"
  Transcript.JobStopped
  Transcript.JobFailed
    Transcript.Retry
```

## Transcribe a recording

```tsx
"use client"

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

export function Example() {
  return (
    <Transcript.Job>
      <Transcript.JobIdle>
        <Transcript.JobDrop>
          Choose or drop a recording
          <Transcript.JobPicker aria-label="Recording" />
        </Transcript.JobDrop>
      </Transcript.JobIdle>
      <Transcript.JobRunning>
        <Transcript.JobProgress />
        <Transcript.CancelJob>Cancel</Transcript.CancelJob>
      </Transcript.JobRunning>
      <Transcript.JobDone>
        <Transcript.Segments>
          <Transcript.Segment>
            <Transcript.Text />
          </Transcript.Segment>
        </Transcript.Segments>
      </Transcript.JobDone>
      <Transcript.JobFailed>
        <p>Transcription failed.</p>
        <Transcript.Retry>Try again</Transcript.Retry>
      </Transcript.JobFailed>
    </Transcript.Job>
  )
}

```

Use a provider with file transcription (for example OpenAI, Deepgram or Gladia). The browser recognizer only supports live speech.

## Playback, correction and export

```tsx
"use client"

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

export function Example() {
  return (
    <Transcript.Job timestamps="word">
      <Transcript.JobDrop>
        <Transcript.JobPicker aria-label="Recording" />
      </Transcript.JobDrop>
      <Transcript.JobDone>
        <Transcript.Playback>
          <Transcript.Audio />
          <Transcript.PlayToggle>Play / pause</Transcript.PlayToggle>
          <Transcript.Rate aria-label="Playback speed" />
          <Transcript.Segments>
            <Transcript.Segment>
              <Transcript.Seek>
                <Transcript.Time />
              </Transcript.Seek>
              <Transcript.Edit aria-label="Correct this segment" />
              <Transcript.Words>
                <Transcript.Word />
              </Transcript.Words>
            </Transcript.Segment>
          </Transcript.Segments>
          <Transcript.Export format="vtt">
            Export captions
          </Transcript.Export>
        </Transcript.Playback>
      </Transcript.JobDone>
    </Transcript.Job>
  )
}

```

Playback uses the local recording. Corrections stay in this page and are included in exports. Timed exports hide when timestamps are unavailable.

## Complete API reference

### Transcript.Job

scope

A recording submitted and waited for.

Import: @slices.sh/transcription/headless

Capability: file

```tsx
JobRootProps

type JobRootProps = RootProps & TranscriptionRootProps & {
  /**
   * How finely the transcript should be placed in time.
   *
   * `"word"` costs nothing extra and is what a transcript that highlights along with the
   * audio needs; `"segment"` is enough for a list of clickable lines. The instance decides
   * whether it can do either, and the parts hide what it cannot.
   * → services/transcription/capabilities.ts
   */
  timestamps?: "none" | "segment" | "word"
}
```

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

### Transcript.JobStatus

content · Element: p

What a submitted recording tells a screen reader.

Always mounted, for the reason the other two announcers are: a live region that appears
with its content already in it announces nothing. The card that said "Transcribing…"
carried `role="status"` and appeared exactly that way, so the upload said nothing, and
the transcript arriving — which is the entire point of the screen, after a wait long
enough to have gone and done something else — said nothing either.
→ components/headless/transcription/dictation-status.tsx

Import: @slices.sh/transcription/headless

Capability: file

Transcript.JobStatus must be inside <Transcript.Job>.

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

type Announced = { state: string; lines: number }
```

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

### Transcript.JobDrop

content · Element: label

A dashed box that actually takes a dropped recording.

The dashed border is the universal promise of drag-and-drop, and both job screens drew
one around a file input that ignored a drop entirely — so the one gesture the shape
invites did nothing at all. It is a `<label>` around that input rather than a div with
handlers, so the keyboard and the screen reader still get a real control: dropping is the
shortcut, and choosing is the way.

Only the first file, and whatever it is. A second one would need a queue, and a queue
that silently transcribed the wrong recording first is worse than a screen that takes one
at a time. Sorting audio from everything else is the job's, because a dropzone that
quietly kept nothing is indistinguishable from one that is broken — and because a
recording whose type the browser cannot name is most `.m4a` files rather than a mistake.
→ hooks/transcription/use-transcription-job.ts

Import: @slices.sh/transcription/headless

Capability: file

Transcript.JobDrop must be inside <Transcript.Job>.

```tsx
PartProps<"label", { over: boolean }>
```

Source: @slices.sh/transcription/src/headless/job-drop.tsx

### Transcript.JobPicker

input · Element: input

The audio file to transcribe.

Import: @slices.sh/transcription/headless

Capability: file

Transcript.JobPicker must be inside <Transcript.Job>.

```tsx
PartProps<"input">
```

Source: @slices.sh/transcription/src/headless/job-picker.tsx

### Transcript.JobProgress

content · Element: progress

How far along a job is.

Indeterminate when the provider does not say — most do not — because a bar sitting at
zero for four minutes looks exactly like one that is stuck.
→ docs/rules/capability-honesty.md

Import: @slices.sh/transcription/headless

Capability: file

Transcript.JobProgress must be inside <Transcript.Job>.

```tsx
PartProps<"progress", { progress: number | null }>
```

Source: @slices.sh/transcription/src/headless/job-progress.tsx

### Transcript.JobLimit

content · Element: span

The largest recording one request can carry.

Rendered only where the instance published a number. A ceiling is worth saying in advance
— it is the difference between choosing a shorter file and uploading an hour of audio to
be told it was always too big — and a box that announced "up to 0 MB" because nobody knew
would be worse than one that says nothing. → docs/rules/capability-honesty.md

Import: @slices.sh/transcription/headless

Capability: file

Transcript.JobLimit must be inside <Transcript.Job>.

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

Source: @slices.sh/transcription/src/headless/job-limit.tsx

### Transcript.JobIdle

gate

Nothing submitted yet: where the invitation to choose a file goes.

Import: @slices.sh/transcription/headless

Capability: file

Transcript.JobIdle must be inside <Transcript.Job>.

```tsx
GateProps
```

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

### Transcript.JobRunning

gate

Uploading, or waiting for the provider.

`uploading` is told apart because the two wait for different things: one is this
browser's link, the other is somebody else's queue, and only the first has a size.

Import: @slices.sh/transcription/headless

Capability: file

Transcript.JobRunning must be inside <Transcript.Job>.

```tsx
GateProps<{ uploading: boolean }>
```

Source: @slices.sh/transcription/src/headless/job-running.tsx

### Transcript.JobDone

gate

There is a transcript.

Import: @slices.sh/transcription/headless

Capability: file

Transcript.JobDone must be inside <Transcript.Job>.

```tsx
GateProps
```

Source: @slices.sh/transcription/src/headless/job-done.tsx

### Transcript.JobEmpty

gate

It came back, and there was nothing in it.

A recording of a quiet room transcribes to no segments at all, and the screen drew a card
with an empty list in it — which reads as a transcript that failed to load rather than as
a recording with nothing said in it. The provider did its job and the answer is the
answer. → docs/rules/states-gate-themselves.md

Import: @slices.sh/transcription/headless

Capability: file

Transcript.JobEmpty must be inside <Transcript.Job>.

```tsx
GateProps
```

Source: @slices.sh/transcription/src/headless/job-empty.tsx

### Transcript.JobStopped

gate

Somebody stopped waiting.

Its own gate, and not the failure one, because they are different news: the provider did
nothing wrong, the audio it already has is still billed, and a red banner reading "the
service did not answer" blames it for a decision it knew nothing about.

Import: @slices.sh/transcription/headless

Capability: file

Transcript.JobStopped must be inside <Transcript.Job>.

```tsx
GateProps
```

Source: @slices.sh/transcription/src/headless/job-stopped.tsx

### Transcript.JobFailed

gate

It was refused, with the reason.

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

Import: @slices.sh/transcription/headless

Capability: file

Transcript.JobFailed must be inside <Transcript.Job>.

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

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

### Transcript.CancelJob

action · Element: button

Stops waiting.

Not "stops transcribing": the request is already at the provider and will be billed. The
copy beside this should say so rather than promising a refund nobody arranged.

Import: @slices.sh/transcription/headless

Capability: file

Transcript.CancelJob must be inside <Transcript.Job>.

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

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

### Transcript.Retry

action · Element: button

Back to the invitation.

Not "try again with the same file": a stopped job has already been billed and a refused
one was usually refused for something about the recording, so the useful next step is
choosing. Rendered from every state a job is finished in — including the one where it
worked, because a transcript on screen is exactly when somebody has the next recording
ready and the dropzone is gone.

Import: @slices.sh/transcription/headless

Capability: file

Transcript.Retry must be inside <Transcript.Job>.

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

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

### Transcript.Playback

scope

The transcript following the audio, and the audio following a click on the transcript.

The recording is the one the job was given: it never left the browser on its way here, so
playing it back costs nothing and needs no URL from anybody. Revoked on unmount, because
an object URL that outlives its page holds the whole file in memory.

Import: @slices.sh/transcription/headless

```tsx
RootProps
```

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

### Transcript.Audio

content · Element: audio

The audio element itself.

Rendered only once there is something to play: an `<audio>` with no source shows a
disabled player, which reads as broken rather than as empty.

Import: @slices.sh/transcription/headless

Transcript.Audio must be inside <Transcript.Playback>.

```tsx
PartProps<"audio">
```

Source: @slices.sh/transcription/src/headless/playback-audio.tsx

### Transcript.PlayToggle

action · Element: button

Plays or pauses. `data-active` while it is playing.

Import: @slices.sh/transcription/headless

Transcript.PlayToggle must be inside <Transcript.Playback>.

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

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

### Transcript.Rate

input · Element: select

How fast the recording plays.

A `<select>`, which on a phone is the operating system's own wheel and beside a pointer is
a menu — both already keyboard-operable and both already announced properly. Checking a
transcript against an hour of audio at one times real time is the reason nobody does it.

The rates are handed over and the options are drawn one level up: an `<option>` is a host
element, and how a multiplier is written — "1.5×", "1.5x", "150%" — is words.
→ docs/rules/words-in-copy-looks-in-skin.md

Import: @slices.sh/transcription/headless

Transcript.Rate must be inside <Transcript.Playback>.

```tsx
PartProps<"select", { rates: readonly number[]; rate: number }>
```

Source: @slices.sh/transcription/src/headless/playback-rate.tsx

### Transcript.Export

action · Element: button

Saves the transcript as a file.

A live transcript as well as a finished one, and for the live one it is the only way out
there is: the session is held in this page and in nothing else, which the screen says out
loud — so a screen that said it and then offered no way to keep the words was telling
somebody their meeting was about to be lost and handing them nothing.

`srt` and `vtt` are timed formats, so the button is not rendered at all where the
transcript has no times — a subtitle file whose every cue starts at zero is worse than no
subtitle file. → docs/rules/capability-honesty.md

Import: @slices.sh/transcription/headless

```tsx
ExportProps

type ExportProps = PartProps<"button"> & { format: "txt" | "srt" | "vtt" }
```

Source: @slices.sh/transcription/src/headless/export-transcript.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.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

### Transcript.Edit

input · Element: input

A line somebody can fix.

A model mishears a name, a number or a term of art two or three times an hour, and a
transcript nobody can correct is a transcript that stays wrong — including in the subtitle
file it exports. So this is a real text input over the segment, and the export reads the
corrected text rather than what came back.

Nothing is sent anywhere: no instance implements `update`, so the fix lives as long as
the page and a button offering to "save" it would be lying.
→ docs/plans/next-families/transcription.md

Import: @slices.sh/transcription/headless

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

Transcript.Edit must be inside <Transcript.Job>.

```tsx
PartProps<"input">
```

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

### Transcript.Seek

action · Element: button

Move the audio to where this line starts.

Its own control because the line's text stops being one as soon as the line is editable:
a click inside an input has to put the caret there, so the way to that moment in the
recording moves onto the timestamp beside it.

Rendered only where there is a time to jump to, like everything else here: an instance
without timestamps produces a transcript that is simply not clickable rather than one
that is and takes you to the wrong place. → docs/rules/capability-honesty.md

Import: @slices.sh/transcription/headless

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

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

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

### Transcript.Words

scope

One scope per timed word, for a transcript that lights up as it plays.

`Word { text, startMs, endMs }` has been in the contract from the beginning and one
backend has populated it for `timestamps: "word"` all along with nothing anywhere reading
it — so a transcript highlighted whole lines while the times to do better sat in the data.
→ services/transcription/transcript.ts

An instance that did not time words gets one scope for the whole line instead, carrying
the line's own times. The same markup then draws a transcript that highlights by line,
which is what it did before, rather than an empty row — and nothing is ever lit from a
time nobody measured. → docs/rules/capability-honesty.md

Import: @slices.sh/transcription/headless

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

```tsx
RootProps
```

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

### Transcript.Word

content · Element: span

One word, marked while the playhead is inside it.

`data-active` and nothing else: what a highlight looks like is the skin's, and a headless
part that chose a colour would be making a decision one level too low.
→ docs/rules/words-in-copy-looks-in-skin.md

Import: @slices.sh/transcription/headless

Transcript.Word must be inside <Transcript.Words>.

```tsx
PartProps<"span", { current: boolean }>
```

Source: @slices.sh/transcription/src/headless/segment-word.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.SpeakerName

input · Element: input

A speaker's name, renamed everywhere at once.

Diarization hands back "Speaker 1" and "Speaker 2", and the only thing anybody wants to
do with those is put the real names in. One at a time is forty edits and one line still
reading "Speaker 2", so this renames every segment that speaker said.
→ hooks/transcription/use-transcript-edits.ts

Import: @slices.sh/transcription/headless

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

Transcript.SpeakerName must be inside <Transcript.Job>.

```tsx
PartProps<"input">
```

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