# Captions and transcript

Build captions & transcript with the room's shared state and capability-aware controls.

Import: @slices.sh/realtime/headless

Public parts: 10

## 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/realtime@^0.1.6
```

### 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/realtime.md

## Anatomy

### Composition

```text
Room.Root room="standup"
  Room.CaptionsToggle
  Room.Captions
    Room.PeerAvatar
    Room.PeerName
    Room.CaptionText
  Room.TranscriptLog
    Room.TranscriptLines
      Room.TranscriptLine
        Room.PeerAvatar
        Room.PeerName
        Room.CaptionText
    Room.NoTranscript
  Room.Transcriber
    Room.PublishCaption
  Room.CaptionsNeeded
```

## Minimal composition

```tsx
"use client"

import "@/services"
import { Room } from "@slices.sh/realtime/headless"

export function Example() {
  return (
    <Room.Root room="studio">
      <Room.CaptionsToggle>Toggle captions</Room.CaptionsToggle>
      <Room.Captions>
        <Room.CaptionText />
      </Room.Captions>
      <Room.TranscriptLog>
        <Room.TranscriptLines>
          <Room.TranscriptLine />
        </Room.TranscriptLines>
      </Room.TranscriptLog>
    </Room.Root>
  )
}

```

Configure transcription as well when publishing your own captions. The room shares captions; it does not manufacture text when no transcriber is configured.

## Complete API reference

### Room.CaptionsToggle

action · Element: button

Read captions, or stop. Yours only; nobody else's screen changes — though while anybody
reads them, everybody who talks is transcribed, which is what makes them worth reading.

Import: @slices.sh/realtime/headless

Capability: transcription: live

Room.CaptionsToggle must be inside <Room.Root>.

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

Source: @slices.sh/realtime/src/headless/captions-toggle.tsx

### Room.Captions

scope

What people are saying, as their own transcribers heard it — each line in its speaker's
scope, so `Room.PeerName` and `Room.PeerAvatar` say who. Nothing while captions are off.

Import: @slices.sh/realtime/headless

Capability: transcription: live

Room.Captions must be inside <Room.Root>.

```tsx
CaptionsProps

type CaptionsProps = RootProps & {
  /** The most recent lines to show, by who spoke last. One is a caption bar; more is a log. */
  limit?: number
  /** What the lines are grouped in: a Motion presence group lets a line leave rather than vanish. */
  group?: GateGroupComponent
  /**
   * Whether your own words are among the lines. Off hides them, and the limit counts what is
   * left: a screen that hid yours after choosing the latest line would show nothing at all
   * while you spoke over somebody.
   */
  self?: boolean
}
```

Source: @slices.sh/realtime/src/headless/captions.tsx

### Room.CaptionText

content · Element: span

The words of one caption line. `data-pending` while the speaker's transcriber may still change them.

Import: @slices.sh/realtime/headless

Capability: transcription: live

Room.CaptionText must be inside <Room.Captions>.

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

Source: @slices.sh/realtime/src/headless/caption-text.tsx

### Room.TranscriptLog

content · Element: div

The scrolling region the room's spoken words collect in: a call's transcript, rather than
the caption line of the moment.

A log, for the reasons `Room.Transcript` is one: a screen reader hears a sentence as it
settles, and a region that scrolls has to be a tab stop or a keyboard cannot reach what is
above the fold. It follows the newest line while it is at the bottom and lets go the
moment somebody scrolls up; `Room.NewMessages` and `Room.ScrollToLatest` inside it say what
arrived meanwhile. `data-state` is `pinned` or `scrolled`.

Import: @slices.sh/realtime/headless

Capability: transcription: live

Room.TranscriptLog must be inside <Room.Root>.

```tsx
PartProps<"div", { pinned: boolean; unseen: number }>
```

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

### Room.TranscriptLines

scope

Everything said since you joined, oldest first, each line in its speaker's scope so
`Room.PeerName` and `Room.PeerAvatar` say who and `Room.CaptionText` says what. What is
being said right now follows the settled lines, one per speaker, until its transcriber
settles it.

A speaker who has left keeps their lines, under the name they were heard with: a transcript
that lost a line when its speaker hung up would be a transcript with holes in it.

Import: @slices.sh/realtime/headless

Capability: transcription: live

Room.TranscriptLines must be inside <Room.Root>.

```tsx
TranscriptLinesProps

type TranscriptLinesProps = RootProps & {
  /** What the lines are grouped in: a Motion presence group lets a new line arrive rather than appear. */
  group?: GateGroupComponent
  /** Whether your own words are among the lines. Off hides them, as it does for `Room.Captions`. */
  self?: boolean
}
```

Source: @slices.sh/realtime/src/headless/transcript-lines.tsx

### Room.TranscriptLine

content · Element: li

One line of the transcript. `data-state` is `partial` while its speaker's transcriber may
still change the words and `final` once it will not, which is what a skin fades by.

A partial line is hidden from a screen reader: the log announces what is added, and
announcing a sentence word by word and then again whole is a transcript read twice.

Import: @slices.sh/realtime/headless

Capability: transcription: live

Room.TranscriptLine must be inside <Room.TranscriptLines>.

```tsx
PartProps<"li", { final: boolean }>
```

Source: @slices.sh/realtime/src/headless/transcript-line.tsx

### Room.NoTranscript

gate

Nothing has been said since you joined. `shown` is whether you read captions: a transcript
that is empty because captions are off wants a different sentence from one nobody has
spoken into yet, since only the second will fill by itself.

Import: @slices.sh/realtime/headless

Capability: transcription: live

Room.NoTranscript must be inside <Room.Root>.

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

Source: @slices.sh/realtime/src/headless/no-transcript.tsx

### Room.Transcriber

gate

Whether this browser should be transcribing its own voice right now, for a transcriber to
follow — `Transcript.Listen`, say — without the realtime family reaching into that one.

Only while somebody in the room reads captions, and only while the microphone is live:
what somebody says on mute was never said to the room, and sending it to a transcription
provider anyway would be a leak with a caption on it. And from the microphone the room
hears, which a device picker may have changed.

Import: @slices.sh/realtime/headless

Capability: transcription: live

Room.Transcriber must be inside <Room.Root>.

```tsx
GateProps<{ needed: boolean; device: string | null }>
```

Source: @slices.sh/realtime/src/headless/transcriber.tsx

### Room.PublishCaption

scope

Hands what this browser's own transcriber heard to everybody in the room, as captions.

Renders nothing. Fed from a transcript's parts — `Transcript.Heard` — so the realtime family
never reaches into the transcription family, and a room without a transcriber simply sends
no captions. Only a change is sent: a hypothesis as it grows, and the settled line once.

Import: @slices.sh/realtime/headless

Capability: transcription: live

Room.PublishCaption must be inside <Room.Root>.

```tsx
RootProps & { text: string; final: boolean }
```

Source: @slices.sh/realtime/src/headless/publish-caption.tsx

### Room.CaptionsNeeded

gate

Somebody is reading captions and this microphone is live: this browser's words are wanted.
Where a screen says so when nothing can transcribe them, rather than leave the readers
wondering why one voice never appears.

Import: @slices.sh/realtime/headless

Capability: transcription: live

Room.CaptionsNeeded must be inside <Room.Root>.

```tsx
CaptionsNeededProps

type CaptionsNeededProps = GateProps & {
  /**
   * How long the need has to stand before it shows, in milliseconds. A transcriber that is
   * still arriving, a chunk loading or a token being fetched, looks to the room exactly like
   * one that never will, and a sentence about a failure that has not happened is worse than
   * a late one.
   */
  grace?: number
}
```

Source: @slices.sh/realtime/src/headless/captions-needed.tsx

### Room.Root

scope

The room everything under here is in.

Joining happens in the provider's effect rather than here, so a component that renders
twice before it commits does not put two peers in the roster for one tab.

A root inside a root for the same room shares its connection, and its follow too: a
presence list placed on a canvas has to move the canvas when somebody on it is picked, and
two follows would be two lists of whom somebody is following. A root inside a root for the
same room needs no service of its own, and says nothing when there is none.

Optional with no service, or off, there is no room: its status is "off", so every gate stays
shut and `Room.Present` hides what the room is drawn inside, and its sink is switched off, so
a Composer inside renders nothing. → ../hooks/use-realtime-scope.ts

Import: @slices.sh/realtime/headless

```tsx
RoomRootProps

type RoomRootProps = RootProps & RealtimeRootProps & {
  room: string
  /**
   * Who this browser shows itself as. Without one, a visitor gets an animal of their own; and
   * somebody signed in is their session's name and picture whatever this says.
   */
  profile?: PeerProfile
  /**
   * One person across this browser's tabs: every tab takes the same name, the one a screen
   * that counts people shows once. Off, each tab is somebody of its own.
   */
  perPerson?: boolean
  /** Off, the transport never asks for a camera or a microphone. */
  media?: boolean
  /** Captions start shown; each person can still hide them for themselves. */
  captions?: boolean
}
```

Source: @slices.sh/realtime/src/headless/room-root.tsx

### Room.PeerAvatar

content · Element: span

A peer's face: their picture once it has loaded, their initials until then and for good if
it never does, with their colour as `--peer` and the ink that reads on it as `--peer-ink`,
for the skin to paint with. → ./peer-look.ts

The picture is painted as the face's background and the initials are dropped when it
arrives, so a skin's size, shape and colour work unchanged and a slow or dead address
costs nothing but the picture. → ./use-peer-picture.ts

Named after the person rather than after the two letters standing in for them: a pile of
these is the whole of a presence list, and without a name it reads out as "AB CD EF".
The name is the peer's own and is never a word a skin or a copy chooses, so it is locked
here rather than asked for. (WCAG 1.1.1)

Import: @slices.sh/realtime/headless

Room.PeerAvatar must be inside <Room.Peers>.

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

Source: @slices.sh/realtime/src/headless/peer-avatar.tsx

### Room.PeerName

content · Element: span

Peers repeats the people here, up to max, and Peer is one scope. PeerName, PeerAvatar (with --peer colour), PeerStatus (the data path, in words), PeerActivity (here, idle, away) describe the peer in scope. PeerCount counts them. IsMe, NotMe and Me pick out you. MorePeers gives { hidden, names } for the ones a pile leaves out. Unreachable renders for a peer this browser has no route to, which needs a relay. Roster, RosterSearch and NoMatches make a searchable list for a big room.

Import: @slices.sh/realtime/headless

Room.PeerName must be inside <Room.Peers>.

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

Source: @slices.sh/realtime/src/headless/peer-name.tsx
