Transcribe a Stage, gated to whoever holds the floor (LC-597) #568

Merged
longjacksonle merged 1 commit from feat/lc597-stage-transcription into main 2026-07-20 20:42:36 +02:00

Closes LC-597, with one acceptance criterion explicitly not met and explained at the bottom.

The gap

The Stage carries audio over the LiveKit SFU rather than the WebRTC mesh, so a speaker never appears in Hub::voice_room_users and require_participant refused them. Transcription reached DMs, voice channels, and huddles; the one broadcast surface, where an accessible transcript matters most, had none.

Gate

require_participant now falls through to a new is_stage_speaker for non-DM rooms. The mesh check runs first: it is in-memory and already covers every other surface, so only a caller it rejects pays for the stage lookup.

Only the floor is captured. A listener is audience and publishes no track, so capturing them would record a room rather than a call. Keeping the capture set equal to the set of published tracks is also what lets the whole existing server path be reused unchanged: /call/{room}/transcript/start|segment|audio|end, the segment store, the live caption drawer, and the archive all work as-is.

is_stage_speaker re-reads rooms.stage_enabled instead of trusting the roster. This is not redundant with the join path. The roster lives in Hub::stages and is only mutated by the WS stage frames, so turning the stage off in room settings clears neither the participant set nor the speaker set. Without the re-check, every user holding the floor at the moment a host disabled the stage would keep opening sessions and posting segments indefinitely. disabling_the_stage_revokes_the_floor asserts the roster is deliberately left stale and the request is still refused.

Controls

The panel offers the transcribe toggle and the drawer toggle to speakers only, alongside the existing step-down button. data-lc-room is what transcribe.js already resolves a session from, so starting one needed no stage-specific JS.

The hot mic

This is the part that was not obvious from the ticket. A host demoting a speaker, or a speaker stepping down, re-renders the panel with data-lc-stage-speaker="0". The server refuses their segments from that moment (proven in stage_speaker_transcribes_listener_forbidden), but the microphone would have kept recording into a session that no longer accepts it - a hot mic with nowhere to go, invisible to the user because the toggle had already vanished from the re-rendered panel.

transcribe.js now reconciles against the panel on htmx:afterSettle and stops local capture. It deliberately does not call /end: the session belongs to the stage and the remaining speakers are still on it. Same reasoning as the existing lc:voice-left handler. The same hook re-runs the browser-support gate, since the panel arrives over the WebSocket after load and its toggle would otherwise never be disabled on an unsupported browser.

Tests

Five, in server/tests/transcripts.rs and server/tests/stage_control.rs:

  • a listener on the stage is refused, a speaker is not
  • segments persist and are attributed for a stage room
  • a demoted speaker is refused mid-session, and the refused segment is not stored
  • disabling the stage revokes a live floor despite the stale roster
  • the panel renders the transcribe toggle for a speaker and not for a listener, and it carries data-lc-room

Each was confirmed failing without its fix: the gate tests by deleting the is_stage_speaker branch (both fail on the start call), the render test by deleting the template markup. The render test is also the first render-level coverage the stage panel has had.

Full suite green, 181 test binaries, 0 failures. just check clean. No new Fluent keys, so en/es symmetry is untouched.

Acceptance criterion not met

The transcription consent notice is shown for a transcribed Stage.

There is no consent notice on any surface. transcript-banner = This call is being transcribed is an orphaned Fluent key with zero template or JS references; the floating pill was deleted in LC-416 (cc1f8b7). Several doc comments still describe it as a "consent banner" and are stale.

Stage listeners do get the drawer opening and the record dot, via the existing TranscriptStarted bus broadcast, which is the same awareness every other surface has today. But that is not the notice the ticket asks for, and restoring it is a cross-surface change that would land on 1:1 calls, voice channels, and huddles too. Filing it separately rather than reintroducing a global UI element inside a Stage ticket.

Noted, not changed

recipients() keys on room.is_voice, so for a stage room (not is_voice) live transcript events fan out to all room members rather than to stage participants. Listeners therefore do receive captions, which is what the acceptance criteria want, but so does a room member who never opened the Stage. This is pre-existing and identical for huddles, and it discloses nothing new - require_access already lets any room member read the saved transcript. Left alone rather than changed silently under this ticket.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5

Closes LC-597, with one acceptance criterion explicitly not met and explained at the bottom. ## The gap The Stage carries audio over the LiveKit SFU rather than the WebRTC mesh, so a speaker never appears in `Hub::voice_room_users` and `require_participant` refused them. Transcription reached DMs, voice channels, and huddles; the one broadcast surface, where an accessible transcript matters most, had none. ## Gate `require_participant` now falls through to a new `is_stage_speaker` for non-DM rooms. The mesh check runs first: it is in-memory and already covers every other surface, so only a caller it rejects pays for the stage lookup. Only the floor is captured. A listener is audience and publishes no track, so capturing them would record a room rather than a call. Keeping the capture set equal to the set of published tracks is also what lets the whole existing server path be reused unchanged: `/call/{room}/transcript/start|segment|audio|end`, the segment store, the live caption drawer, and the archive all work as-is. `is_stage_speaker` re-reads `rooms.stage_enabled` instead of trusting the roster. This is not redundant with the join path. The roster lives in `Hub::stages` and is only mutated by the WS stage frames, so turning the stage off in room settings clears neither the participant set nor the speaker set. Without the re-check, every user holding the floor at the moment a host disabled the stage would keep opening sessions and posting segments indefinitely. `disabling_the_stage_revokes_the_floor` asserts the roster is deliberately left stale and the request is still refused. ## Controls The panel offers the transcribe toggle and the drawer toggle to speakers only, alongside the existing step-down button. `data-lc-room` is what `transcribe.js` already resolves a session from, so starting one needed no stage-specific JS. ## The hot mic This is the part that was not obvious from the ticket. A host demoting a speaker, or a speaker stepping down, re-renders the panel with `data-lc-stage-speaker="0"`. The server refuses their segments from that moment (proven in `stage_speaker_transcribes_listener_forbidden`), but the microphone would have kept recording into a session that no longer accepts it - a hot mic with nowhere to go, invisible to the user because the toggle had already vanished from the re-rendered panel. `transcribe.js` now reconciles against the panel on `htmx:afterSettle` and stops local capture. It deliberately does not call `/end`: the session belongs to the stage and the remaining speakers are still on it. Same reasoning as the existing `lc:voice-left` handler. The same hook re-runs the browser-support gate, since the panel arrives over the WebSocket after load and its toggle would otherwise never be disabled on an unsupported browser. ## Tests Five, in `server/tests/transcripts.rs` and `server/tests/stage_control.rs`: - a listener on the stage is refused, a speaker is not - segments persist and are attributed for a stage room - a demoted speaker is refused mid-session, and the refused segment is not stored - disabling the stage revokes a live floor despite the stale roster - the panel renders the transcribe toggle for a speaker and not for a listener, and it carries `data-lc-room` Each was confirmed failing without its fix: the gate tests by deleting the `is_stage_speaker` branch (both fail on the start call), the render test by deleting the template markup. The render test is also the first render-level coverage the stage panel has had. Full suite green, 181 test binaries, 0 failures. `just check` clean. No new Fluent keys, so en/es symmetry is untouched. ## Acceptance criterion not met > The transcription consent notice is shown for a transcribed Stage. There is no consent notice on any surface. `transcript-banner = This call is being transcribed` is an orphaned Fluent key with zero template or JS references; the floating pill was deleted in LC-416 (`cc1f8b7`). Several doc comments still describe it as a "consent banner" and are stale. Stage listeners do get the drawer opening and the record dot, via the existing `TranscriptStarted` bus broadcast, which is the same awareness every other surface has today. But that is not the notice the ticket asks for, and restoring it is a cross-surface change that would land on 1:1 calls, voice channels, and huddles too. Filing it separately rather than reintroducing a global UI element inside a Stage ticket. ## Noted, not changed `recipients()` keys on `room.is_voice`, so for a stage room (not `is_voice`) live transcript events fan out to all room members rather than to stage participants. Listeners therefore do receive captions, which is what the acceptance criteria want, but so does a room member who never opened the Stage. This is pre-existing and identical for huddles, and it discloses nothing new - `require_access` already lets any room member read the saved transcript. Left alone rather than changed silently under this ticket. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5
feat(stage): transcribe a Stage, gated to whoever holds the floor (LC-597)
All checks were successful
check-secrets / Nosey parker (push) Successful in 5s
check-secrets / TruffleHog (push) Successful in 7s
check-secrets / Kingfisher (push) Successful in 8s
check-secrets / Nosey parker (pull_request) Successful in 4s
check-secrets / TruffleHog (pull_request) Successful in 7s
check-secrets / Kingfisher (pull_request) Successful in 7s
Check / clippy + fmt + tests (pull_request) Successful in 6m33s
Create release / Create release from merged PR (pull_request) Has been skipped
5a82f5563f
The Stage was the one real-time surface with no transcription. Its audio rides the LiveKit SFU rather than the WebRTC mesh, so a Stage speaker never appears in `Hub::voice_room_users` and `require_participant` refused them - which is why this is the surface where captions matter most and the only one without them.

`require_participant` now falls through to `is_stage_speaker` for non-DM rooms. The mesh check runs first because it is in-memory and already covers DMs, voice channels, and huddles; only a caller it rejects pays for the stage lookup.

Only the floor is captured. A Stage listener is audience and publishes no track, so capturing them would record a room rather than a call. That keeps the capture set equal to the set of published tracks, and it means the existing server path (`/call/{room}/transcript/*`, the segment store, the archive) is reused unchanged.

`is_stage_speaker` re-reads `rooms.stage_enabled` rather than trusting the roster. The roster lives in the hub and is only mutated by the WS stage frames, so turning the stage off in room settings clears nothing; without the re-check, everyone holding the floor at that moment would keep their grant indefinitely.

The panel offers the transcribe and drawer toggles to speakers only. `data-lc-room` is what transcribe.js already resolves a session from, so starting one needs no stage-specific JS.

Losing the floor now stops the microphone. A host demoting a speaker (or the speaker stepping down) re-renders the panel with `data-lc-stage-speaker="0"`; the server would refuse their segments from that moment, but the mic would have kept recording into a session that no longer accepts it. transcribe.js reconciles against the panel on `htmx:afterSettle` and stops local capture without calling `/end`, since the session belongs to the stage and the remaining speakers are still on it. Same reasoning as `lc:voice-left`.

Tests: a listener is refused and a speaker is not, segments persist for a stage room, a demoted speaker is refused mid-session, disabling the stage revokes a live floor, and the panel renders the toggle for a speaker but not a listener. Each was confirmed failing without its fix - the gate tests by deleting the `is_stage_speaker` branch, the render test by deleting the markup.

Not covered: the in-call consent notice named in the ticket does not exist on any surface. `transcript-banner` is an orphaned Fluent key; the pill was deleted in LC-416. Stage listeners do get the drawer and the record dot via the existing bus broadcast, the same awareness every other surface has, but that is not the notice the ticket asks for and it is not a Stage-specific gap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FhDVMViNHqUbwmfe2aXTv5
longjacksonle scheduled this pull request to auto merge when all checks succeed 2026-07-20 20:42:18 +02:00
longjacksonle deleted branch feat/lc597-stage-transcription 2026-07-20 20:42:37 +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!568
No description provided.