<!-- AvatarFactory Docs · https://avatarfactory.in/docs/sdk/react/transcript · Full map: https://avatarfactory.in/llms.txt -->

# Transcript & captions

A live, word-timed record of the conversation — rendered as captions, read as data, or both.

Turn the transcript on and the SDK assembles both sides of the conversation as
it happens: the user's speech as it is recognised, and the avatar's as it is
spoken. You get a built-in caption overlay for free, and the underlying data if
you want to draw your own.

```tsx
const config = {
  getSessionToken: async () => fetchSessionToken(),
  mode: "call",
  transcript: { enabled: true },
  avatar: { avatarId: "default" },
};
```

> Off is genuinely off. Without `enabled: true` no transcript is assembled at
>   all — `useAvatar().transcript` stays empty and the `transcript` event never
>   fires. It is not a hidden buffer you can reach into later.

## Configuration

| Prop | Type | Default | Description |
| --- | --- | --- | --- |
| `transcript.enabled` **(required)** | `boolean` | — | Assemble the transcript. Nothing below applies until this is true. |
| `transcript.captions` | `boolean` | `true` | Render the built-in caption overlay. Set false to keep the data and draw your own UI. |
| `transcript.position` | `CaptionPosition` | `"bottom-center"` | Where the overlay anchors: top-left, top-center, top-right, bottom-left, bottom-center, bottom-right. The bottom anchors lift clear of the controls bar when it is on screen. |
| `transcript.maxEntries` | `number` | `50` | How many lines to retain. Older lines fall off the front. |

## Captions only

The common case: you want captions on screen and nothing else. One line of
config, and the overlay handles its own positioning, growth, and clearing.

```tsx
transcript: { enabled: true, position: "bottom-center" }
```

Style it with the `Avatar` props rather than fighting the overlay's own CSS:

```tsx
<Avatar
  captionClassName="font-medium tracking-tight"
  captionStyle={{ fontSize: 18, maxWidth: "80%" }}
/>
```

## Reading the data

`useAvatar().transcript` is the full list, oldest first. Set
`captions: false` to suppress the built-in overlay while keeping it.

```tsx
function TranscriptPanel() {
  const { transcript } = useAvatar();

  return (
    <ol>
      {transcript?.map((entry) => (
        <li key={entry.id} data-speaker={entry.speaker}>
          <strong>{entry.speaker === "user" ? "You" : "Avatar"}:</strong>{" "}
          <span style={{ opacity: entry.isFinal ? 1 : 0.6 }}>{entry.text}</span>
        </li>
      ))}
    </ol>
  );
}
```

### TranscriptEntry

```tsx
type TranscriptEntry = {
  id: string;
  speaker: "user" | "avatar";
  text: string;
  words: TranscriptWord[];
  isFinal: boolean;
  turnId: number;
  createdAt: number;
};

type TranscriptWord = {
  text: string;
  start: number; // seconds, on the utterance's own audio clock
  end: number;
};
```

| Field | Notes |
| --- | --- |
| id | Stable for the life of the line. A line is rewritten in place as it grows, so key your list on this — not on the index. |
| isFinal | False while the line is still growing (interim speech recognition, or an in-flight utterance). True once it is closed and will not change again. |
| turnId | Groups a user line and the avatar's reply into one exchange. |
| words | Word-level timings. Avatar words carry real timings from the speech alignment; user words do not — their speech was already said by the time recognition landed, so those timings are not meaningful. |
| createdAt | When the line was first created, as a millisecond timestamp. |

> **Lines mutate.** An entry is rewritten in place while it grows, then closed
>   with `isFinal`. Render off `entry.id` and treat the array as a snapshot — do
>   not accumulate it yourself, or interim text will pile up as duplicates.

## Reacting to changes

The `transcript` event re-emits the whole list on every change. Use it for
side effects — logging, saving, scrolling — and use the state for rendering.

```tsx
useAvatarEvent("transcript", (entries) => {
  const last = entries.at(-1);
  if (last?.isFinal) {
    saveLine({ speaker: last.speaker, text: last.text, turn: last.turnId });
  }
});
```

> Filtering on `isFinal` is usually what you want when persisting. Interim lines
>   are provisional by definition and will be replaced.

## Auto-scrolling a custom panel

```tsx
function ScrollingTranscript() {
  const { transcript } = useAvatar();
  const endRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
  }, [transcript]);

  return (
    <div className="h-64 overflow-y-auto">
      {transcript?.map((entry) => (
        <p key={entry.id}>{entry.text}</p>
      ))}
      <div ref={endRef} />
    </div>
  );
}
```

> Respect `prefers-reduced-motion` before using `behavior: "smooth"` — a panel
>   that animates on every interim word is exactly the kind of motion the setting
>   exists to suppress.

## Accessibility

Captions are an accessibility feature, so treat them as one:

- Turning them on makes the avatar's speech readable, which is the whole point
  for a deaf or hard-of-hearing user. Consider offering it as a **user setting**
  rather than a decision you make for them.
- If you render your own panel, mark the live region as
  `aria-live="polite"` so a screen reader announces new final lines without
  interrupting itself on every interim update.
- Keep caption contrast at 4.5:1 against whatever sits behind it. The overlay
  sits on top of an animated character, so test against the busiest frame, not a
  still.

## Cost and privacy

The transcript is assembled from data the session already produces — turning it
on does not add a recognition pass or change what you are billed for.

It does mean the conversation exists as text in your app's memory, and anywhere
you choose to send it. If you persist it, that is your record to disclose and
retain, and the SDK has no part in it.
