feat(calls): meeting transcription - live captions + saved transcript (LC-393) #405

Merged
longjacksonle merged 1 commit from feat/lc-393-call-transcription into main 2026-06-20 07:20:25 +02:00

Implements Phase 1 of LC-393: meeting transcription for 1:1 DM calls - live captions during the call and a saved, reviewable transcript posted back into the DM.

The constraint that shaped it

Call media is peer-to-peer WebRTC; the server never sees the audio (ws/events.rs, call.js). So transcription is captured client-side: each participant's browser transcribes its OWN mic with the Web Speech API and POSTs final segments. The happy consequence - speaker attribution is automatic (a segment's speaker is whoever's browser produced it), no diarization.

What's here

Server (fully tested):

  • chat.db tables call_transcripts + transcript_segments (migration 0066), db::transcripts module (start/append/list/end + a disconnect-backstop closer). Segment text length-capped at MAX_SEGMENT_CHARS (stored + rendered as plain text).
  • DM-member-gated routes: POST /call/{room}/transcript/start, POST /call/transcript/{id}/segment, POST /call/transcript/{id}/end (idempotent; posts the linked "transcript saved" notice exactly once), GET /transcripts/{id} (saved page).
  • ChatEvent::{TranscriptStarted,Segment,Ended} rendered per-recipient like the call signals; control events to #lc-transcript-bus, caption lines OOB-appended straight into the visible #lc-caption-log.
  • WS-disconnect backstop finalizes a session a hard drop left open (mirrors LC-186's remote-control backstop).

Consent (non-negotiable): transcription is opt-in and the moment it starts a "this call is being transcribed" banner is broadcast to BOTH parties for the duration. No silent recording.

Client: transcribe.js runs SpeechRecognition on the existing local mic, POSTs finals, and drives the banner/toggle; three tiny hooks in call.js publish the active room and a call-ended signal. Feature-detects SpeechRecognition and disables the toggle with a localized note where unsupported (Firefox; Chrome works but routes audio to Google - noted in the ticket as a reason to add a self-hosted Whisper provider in Phase 3).

Pluggable STT: the data model + UI are engine-agnostic, so the Phase 2 (enclave voice channels) and Phase 3 (self-hosted Whisper) work in the ticket drop in without schema/UI changes.

Testing

  • tests/transcripts.rs (4 tests, all green): member-start + outsider-403 + non-DM-404, segment persistence + ordering + outsider-403 + gated saved page, length cap, idempotent /end posting exactly one notice + late-segment drop.
  • just check, just test, just test-saas all green (exit 0). just build-css regenerated.
  • Manual: the Web Speech capture itself (browser API) is verified by hand, not the integration suites.

Operator-visible

New tables via migration only (auto-applied, no operator action). No new env var. Phase 1 transcription uses the browser engine, so no server STT dependency is added. Not tagging [operator-action].

Out of scope (tracked in LC-393)

Phase 2 enclave voice-channel transcription; Phase 3 self-hosted Whisper provider; Phase 4 transcript search/export/summary.

Implements **Phase 1** of LC-393: meeting transcription for 1:1 DM calls - live captions during the call and a saved, reviewable transcript posted back into the DM. ## The constraint that shaped it Call media is peer-to-peer WebRTC; the server never sees the audio (`ws/events.rs`, `call.js`). So transcription is captured **client-side**: each participant's browser transcribes its OWN mic with the Web Speech API and POSTs final segments. The happy consequence - **speaker attribution is automatic** (a segment's speaker is whoever's browser produced it), no diarization. ## What's here **Server (fully tested):** - chat.db tables `call_transcripts` + `transcript_segments` (migration `0066`), `db::transcripts` module (start/append/list/end + a disconnect-backstop closer). Segment text length-capped at `MAX_SEGMENT_CHARS` (stored + rendered as plain text). - DM-member-gated routes: `POST /call/{room}/transcript/start`, `POST /call/transcript/{id}/segment`, `POST /call/transcript/{id}/end` (idempotent; posts the linked "transcript saved" notice exactly once), `GET /transcripts/{id}` (saved page). - `ChatEvent::{TranscriptStarted,Segment,Ended}` rendered per-recipient like the call signals; control events to `#lc-transcript-bus`, caption lines OOB-appended straight into the visible `#lc-caption-log`. - WS-disconnect backstop finalizes a session a hard drop left open (mirrors LC-186's remote-control backstop). **Consent (non-negotiable):** transcription is opt-in and the moment it starts a "this call is being transcribed" banner is broadcast to BOTH parties for the duration. No silent recording. **Client:** `transcribe.js` runs `SpeechRecognition` on the existing local mic, POSTs finals, and drives the banner/toggle; three tiny hooks in `call.js` publish the active room and a call-ended signal. Feature-detects `SpeechRecognition` and disables the toggle with a localized note where unsupported (Firefox; Chrome works but routes audio to Google - noted in the ticket as a reason to add a self-hosted Whisper provider in Phase 3). **Pluggable STT:** the data model + UI are engine-agnostic, so the Phase 2 (enclave voice channels) and Phase 3 (self-hosted Whisper) work in the ticket drop in without schema/UI changes. ## Testing - `tests/transcripts.rs` (4 tests, all green): member-start + outsider-403 + non-DM-404, segment persistence + ordering + outsider-403 + gated saved page, length cap, idempotent /end posting exactly one notice + late-segment drop. - `just check`, `just test`, `just test-saas` all green (exit 0). `just build-css` regenerated. - Manual: the Web Speech capture itself (browser API) is verified by hand, not the integration suites. ## Operator-visible New tables via migration only (auto-applied, no operator action). No new env var. Phase 1 transcription uses the browser engine, so no server STT dependency is added. Not tagging `[operator-action]`. ## Out of scope (tracked in LC-393) Phase 2 enclave voice-channel transcription; Phase 3 self-hosted Whisper provider; Phase 4 transcript search/export/summary.
feat(calls): meeting transcription - live captions + saved transcript (LC-393)
All checks were successful
check-secrets / TruffleHog (push) Successful in 4s
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / Nosey parker (push) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 4s
check-secrets / Nosey parker (pull_request) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 6s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 3m32s
563f082559
Phase 1 of call transcription for 1:1 DM calls. Call media is peer-to-peer (the server never sees the audio), so each participant's browser transcribes its OWN mic with the Web Speech API and POSTs final segments; the server stores them, fans out attributed live captions to both parties, and on hangup saves the transcript and drops a linked notice in the DM. Speaker attribution is automatic because each browser only transcribes its own mic - no diarization.

Server (fully tested):
- New chat.db tables call_transcripts + transcript_segments (migration 0066), db module db::transcripts (start/append/list/end + a disconnect-backstop closer), segment text length-capped at MAX_SEGMENT_CHARS.
- Routes (DM-member gated): POST /call/{room}/transcript/start, POST /call/transcript/{id}/segment, POST /call/transcript/{id}/end (idempotent; posts the "transcript saved" notice exactly once), GET /transcripts/{id} (saved-transcript page).
- ChatEvent TranscriptStarted/Segment/Ended, rendered per-recipient like the call signals; control events into #lc-transcript-bus, caption lines OOB-appended straight into #lc-caption-log.
- WS-disconnect backstop finalizes a session a hard drop left open (mirrors the LC-186 remote-control backstop).

Consent: transcription is opt-in and the moment it starts a "this call is being transcribed" banner is broadcast to BOTH parties; no silent recording.

Client: transcribe.js runs SpeechRecognition on the existing local mic, POSTs finals, and drives the banner/toggle; three small hooks in call.js publish the active room and a call-ended signal. Feature-detects SpeechRecognition and disables the toggle with a localized note where unsupported.

STT source is pluggable: the data model + UI are engine-agnostic so a self-hosted Whisper provider (Phase 3) and enclave voice-channel transcription (Phase 2) drop in without schema/UI changes. See LC-393 for the phase plan.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/lc-393-call-transcription 2026-06-20 07:20:25 +02:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
psa-systems/lets-chat!405
No description provided.