feat(calls): Phase 2 - transcription for enclave voice channels (LC-393) #406

Merged
longjacksonle merged 1 commit from feat/lc-393-phase2-voice-transcription into main 2026-06-20 07:36:52 +02:00

Phase 2 of LC-393: extends call transcription from 1:1 DM calls to enclave voice channels (full-mesh, N participants), reusing the Phase 1 tables, endpoints, and client. Phase 1 (#PR) is merged.

The hard part: participant-scoped targeting

A voice room has many room members but only some are joined to the channel at any moment. Broadcasting transcript events (which auto-start each receiver's mic capture) to all room members would capture the mics of people who never joined the call. So:

  • The mutating endpoints (start / segment / end) now require an active participant: a DM member, or - for a voice channel - a user currently joined to the channel (hub.voice_room_users). A room member who never joined gets 403 and is never auto-captured.
  • Live transcript events are fanned out to the channel's actual participants (hub.voice_room_users) for voice rooms, the DM members otherwise.
  • Reading a saved transcript stays room-access gated (a member can review it after leaving).

What changed

Server (routes/transcripts.rs, ws.rs):

  • fetch_call_room accepts is_voice rooms as call-capable; require_participant (mutations) vs require_access (view); recipients() picks participants-vs-members by room type.
  • Empty-channel backstop: when the last participant leaves, handle_voice_leave finalizes any session still open for the channel (save + post the notice) - the shared-channel equivalent of Phase 1's per-user disconnect backstop.

Client:

  • The banner + live caption panel moved out of the 1:1 call overlay into a shared standalone fixed overlay in layout.html, so one #lc-caption-log serves both surfaces.
  • The voice page gets a transcription toggle (data-lc-room), shown on join; voice.js publishes the channel room + lc:voice-joined/lc:voice-left. transcribe.js reads the room from the clicked toggle and, on leaving a channel, stops its own capture without ending the shared session (any participant can end it for everyone via the toggle).

Consent is unchanged: turning it on broadcasts the "being transcribed" banner to everyone in the call.

Testing

  • tests/transcripts.rs extended (5 tests green): new voice_channel_participant_transcribes_nonparticipant_forbidden registers a hub voice participant and asserts a non-participant gets 403 while a joined participant can start + post + end; the Phase 1 DM tests still pass.
  • just check, just test, just test-saas all green (exit 0); just build-css regenerated.
  • Manual (browser API): the Web Speech capture and the multi-participant live-caption fan-out. The empty-channel finalize fires from the WS leave path (not reachable from the HTTP test harness); the underlying finalize is covered by the explicit /end test.

Known limitation (noted for Phase 3+)

Re-joining a channel that already has an active session does not auto-resume capture (the join roster doesn't yet surface the open session). No correctness impact; tracked for a follow-up.

No operator action: tables already shipped in Phase 1, no new env var.

Phase 2 of LC-393: extends call transcription from 1:1 DM calls to **enclave voice channels** (full-mesh, N participants), reusing the Phase 1 tables, endpoints, and client. Phase 1 (#PR) is merged. ## The hard part: participant-scoped targeting A voice room has many room members but only some are *joined* to the channel at any moment. Broadcasting transcript events (which auto-start each receiver's mic capture) to all room members would capture the mics of people who never joined the call. So: - The mutating endpoints (start / segment / end) now require an active **participant**: a DM member, or - for a voice channel - a user **currently joined** to the channel (`hub.voice_room_users`). A room member who never joined gets 403 and is never auto-captured. - Live transcript events are fanned out to the channel's actual participants (`hub.voice_room_users`) for voice rooms, the DM members otherwise. - Reading a saved transcript stays room-access gated (a member can review it after leaving). ## What changed **Server (`routes/transcripts.rs`, `ws.rs`):** - `fetch_call_room` accepts `is_voice` rooms as call-capable; `require_participant` (mutations) vs `require_access` (view); `recipients()` picks participants-vs-members by room type. - Empty-channel backstop: when the last participant leaves, `handle_voice_leave` finalizes any session still open for the channel (save + post the notice) - the shared-channel equivalent of Phase 1's per-user disconnect backstop. **Client:** - The banner + live caption panel moved out of the 1:1 call overlay into a **shared standalone fixed overlay** in `layout.html`, so one `#lc-caption-log` serves both surfaces. - The voice page gets a transcription toggle (`data-lc-room`), shown on join; `voice.js` publishes the channel room + `lc:voice-joined`/`lc:voice-left`. `transcribe.js` reads the room from the clicked toggle and, on leaving a channel, stops its own capture **without** ending the shared session (any participant can end it for everyone via the toggle). Consent is unchanged: turning it on broadcasts the "being transcribed" banner to everyone in the call. ## Testing - `tests/transcripts.rs` extended (5 tests green): new `voice_channel_participant_transcribes_nonparticipant_forbidden` registers a hub voice participant and asserts a non-participant gets 403 while a joined participant can start + post + end; the Phase 1 DM tests still pass. - `just check`, `just test`, `just test-saas` all green (exit 0); `just build-css` regenerated. - Manual (browser API): the Web Speech capture and the multi-participant live-caption fan-out. The empty-channel finalize fires from the WS leave path (not reachable from the HTTP test harness); the underlying `finalize` is covered by the explicit `/end` test. ## Known limitation (noted for Phase 3+) Re-joining a channel that already has an active session does not auto-resume capture (the join roster doesn't yet surface the open session). No correctness impact; tracked for a follow-up. No operator action: tables already shipped in Phase 1, no new env var.
feat(calls): Phase 2 - transcription for enclave voice channels (LC-393)
All checks were successful
check-secrets / Nosey parker (pull_request) Successful in 5s
check-secrets / TruffleHog (pull_request) Successful in 5s
check-secrets / Kingfisher (pull_request) Successful in 5s
Create release / Create release from merged PR (pull_request) Has been skipped
Check / clippy + fmt + tests (pull_request) Successful in 3m28s
check-secrets / Nosey parker (push) Successful in 4s
check-secrets / Kingfisher (push) Successful in 4s
check-secrets / TruffleHog (push) Successful in 5s
5ea34bae3d
Extend call transcription from 1:1 DM calls to enclave voice channels (full-mesh, N participants), reusing the Phase 1 tables, endpoints, and client.

Server:
- The call-room gate now accepts a voice channel (is_voice) as well as a DM. The mutating endpoints (start/segment/end) require an active PARTICIPANT - a DM member, or for a voice channel a user currently joined to the channel (hub.voice_room_users) - so a room member who never joined can neither start transcription nor be auto-captured. Reading a saved transcript stays room-access gated.
- Live transcript events are scoped to the channel's actual participants (hub.voice_room_users) for voice rooms, the DM members otherwise - the key targeting change that keeps a non-participant's mic from auto-starting.
- New empty-channel backstop: when the last participant leaves a voice channel, handle_voice_leave finalizes any session still open for it (save + post the notice), the shared-channel equivalent of the per-user disconnect backstop.

Client:
- The banner + live caption panel moved out of the 1:1 call overlay into a shared standalone fixed overlay in layout.html, so one #lc-caption-log serves both surfaces.
- Voice page gets a transcription toggle (data-lc-room), shown on join; voice.js publishes the channel room + lc:voice-joined/left lifecycle. transcribe.js reads the room from the clicked toggle and, on leaving a channel, stops its own capture without ending the shared session.

Consent unchanged: any participant turning it on broadcasts the "being transcribed" banner to everyone in the call.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
longjacksonle deleted branch feat/lc-393-phase2-voice-transcription 2026-06-20 07:36:52 +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!406
No description provided.