Skip to content

Add on-device live captions for remote call participants#157

Open
rexbron wants to merge 1 commit into
subpop:mainfrom
rexbron:livekit-captions
Open

Add on-device live captions for remote call participants#157
rexbron wants to merge 1 commit into
subpop:mainfrom
rexbron:livekit-captions

Conversation

@rexbron

@rexbron rexbron commented Jun 30, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds opt-in live captions to MatrixRTC calls. Each remote participant's
audio is transcribed on-device with Apple's SpeechAnalyzer /
SpeechTranscriber (macOS 26+) and the text is rendered over their video.
Nothing leaves the device — the locale model is fetched via AssetInventory
on first use.

How it works

  • CaptionTranscriber (new) implements LiveKit's AudioRenderer. It
    drives one SpeechTranscriber (.progressiveTranscription +
    .volatileResults) and feeds it audio converted to the analyzer's
    preferred format via a cached AVAudioConverter.
  • CallViewModel attaches a transcriber per remote audio track, keyed by
    (identity, track sid). The full lifecycle is covered: already-present
    participants on toggle (setCaptionsEnabled), late joiners via
    didSubscribeTrack, and teardown via didUnsubscribeTrack,
    participantDidDisconnect, and call disconnect(). Sid-keying makes a
    leave→rejoin that reuses an identity attach a fresh transcriber regardless
    of the order subscribe/unsubscribe events arrive.
  • Per-participant state is a rolling history + volatile buffer. Volatile
    partials are debounced (180 ms) so the recognizer's rapid mid-utterance
    revisions don't make on-screen words jump; finals apply immediately. Idle
    fade scales with reading rate (clamp(chars/17, 5⁄6s, 7s)).

UI

  • New toggle (captions.bubble.fill) on the call control pill, between the
    camera and end-call buttons.
  • Caption strip: white .title3-semibold on a near-opaque rounded rect,
    ≤2 lines, Netflix-style 42-char word-aware wrapping. Renders on each tile
    in the group grid and under the primary video in 1:1.

Privacy

Speech content is never logged — diagnostics emit metadata only.

Requirements

macOS 26 (Tahoe)+ — SpeechAnalyzer/SpeechTranscriber are 26-only.

Testing

  • 1:1 and group calls; captions toggled on before and during a call.
  • Participant joining mid-call (transcriber attaches), leaving (tears down),
    and leave→rejoin (captions return on the second join).
  • Verified on-device; no caption text in logs.

🤖 Generated with Claude Code

Pipes each subscribed remote audio track through Apple's SpeechAnalyzer
+ SpeechTranscriber (macOS 26+) and renders the transcribed text over
each participant's video. All recognition is on-device; the locale
model is downloaded via AssetInventory on first use.

Pipeline:

- New CaptionTranscriber implements LiveKit's AudioRenderer protocol.
  It owns one SpeechTranscriber configured with the
  .progressiveTranscription preset plus .volatileResults reporting for
  a low-latency partial-result stream. Audio buffers are converted to
  the analyzer's preferred format with a cached AVAudioConverter and
  yielded into the analyzer's AsyncStream<AnalyzerInput>.
- CallViewModel.setCaptionsEnabled(_:) walks every currently-subscribed
  remote audio track, attaches a transcriber, and starts pulling
  results. Participants who join later are picked up via the
  didSubscribeTrack delegate path; tracks gone via didUnsubscribeTrack
  and departing participants via participantDidDisconnect tear their
  transcriber down. All transcribers stop on disconnect.
- Per-participant caption state is a rolling buffer:
    history (committed text) + volatile (in-progress utterance)
  Volatile results replace volatile in place; final results append to
  history with a space separator and clear volatile. History is
  clamped from the head at 168 chars.
- Idle fade scales with reading rate: hold = clamp(chars/17, 5⁄6s, 7s)
  per Netflix's English Timed Text Style Guide.
- Volatile partials are debounced into a single visual update inside a
  180 ms window so the recognizer's rapid mid-utterance revisions don't
  make on-screen words visibly "jump." Final results bypass the
  debounce — they're authoritative and apply immediately.

UI:

- New control-bar toggle (`captions.bubble.fill`) on CallView's bottom
  pill, between the camera button and end-call button.
- Caption strip is white .title3-semibold with a tight glyph shadow
  on a near-opaque black rounded rect with a hairline edge — readable
  against any video frame, sized to its widest line (capped at 540pt).
- Wrapping follows Netflix style: 42 chars/line, max 2 lines visible.
  Word-aware wrapping; hard-break for tokens longer than 42.
- Renders on every ParticipantTile in the group grid AND below the
  primary video in the 1:1 layout.

Speech content is never logged — diagnostics emit metadata only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant