[research] Per-participant volume: is it remembered across calls, and where is it stored? #36

Open
opened 2026-09-17 13:27:08 -04:00 by jared · 1 comment
Owner

Jared believes per-user volume is already persisted; confirm before building anything.

Questions (file:line evidence)

  1. Where does EC v0.25.0 keep the per-participant volume (setVolume on the remote audio track / MediaViewModel)? Is it a Setting (persisted localStorage) keyed by user or device id, or React state that dies with the call?
  2. If persisted: keyed how (userId vs userId:deviceId), and does it survive a page reload / a fork republish?
  3. Interaction with the Lotus deafen path (io.lotus.set_deafen uses setAudioEnabled$ / setVolume — does deafen clobber a stored per-user volume on undeafen)?
  4. Is the volume slider reachable in the fork's mobile UI?

Deliverable

A write-up here. If it is already persisted and keyed sensibly → close as "already works". If not → a small follow-up issue with the storage design (local only, never synced).

Jared believes per-user volume is already persisted; confirm before building anything. ### Questions (file:line evidence) 1. Where does EC v0.25.0 keep the per-participant volume (`setVolume` on the remote audio track / `MediaViewModel`)? Is it a Setting (persisted `localStorage`) keyed by user or device id, or React state that dies with the call? 2. If persisted: keyed how (userId vs `userId:deviceId`), and does it survive a page reload / a fork republish? 3. Interaction with the Lotus deafen path (`io.lotus.set_deafen` uses `setAudioEnabled$` / setVolume — does deafen clobber a stored per-user volume on undeafen)? 4. Is the volume slider reachable in the fork's mobile UI? ### Deliverable A write-up here. If it is already persisted and keyed sensibly → close as "already works". If not → a small follow-up issue with the storage design (local only, never synced).
jared added this to the Features 2026-Q4 milestone 2026-09-17 13:27:08 -04:00
jared added the priority: mediumarea: livekitresearch labels 2026-09-17 13:27:08 -04:00
jared self-assigned this 2026-09-17 13:27:08 -04:00
Author
Owner

Findings

Per-participant volume is NOT persisted today. It's pure in-memory RxJS state, scoped to the lifetime of a single call's tile, not a Setting written to localStorage.

1. Where the volume state actually lives

  • src/state/VolumeControls.ts:44-101 (createVolumeControls) — the volume/mute state is an RxJS Behavior built with accumulate({ volume: 1, committedVolume: 1 }, …) over three Subjects (toggleMuted$, adjustVolume$, commitVolume$). Every instance starts at volume: 1 — there is no read from storage anywhere in this function.
  • src/state/media/RemoteUserMediaViewModel.ts:39-56 (createRemoteUserMedia) — wires createVolumeControls's sink$ straight to LiveKit's RemoteParticipant.setVolume(volume) (line 53). No lookup/keying by userId or userId:deviceId happens here either.
  • src/state/media/WrappedUserMediaViewModel.ts:94-114 (createWrappedUserMedia) just forwards the scope it's given into createRemoteUserMedia; it doesn't add persistence.
  • src/state/CallViewModel/CallViewModel.ts:751-807 (userMedia$) is where that scope is actually created, via generateItems(..., mediaId, ...) with mediaId = \${userId}:${membership$.value.deviceId}`(line 765).generateItemsallocates a **new**ObservableScopeper distinct key and tears it down when the item disappears from the list (participant leaves the call / call ends). SinceCallViewModelitself is instantiated fresh per call, and the volumeBehaviorlives inside that per-item scope, the volume resets to1` (unmuted, full volume) on every new tile — including a rejoin within the same call, and certainly across separate calls.
  • src/settings/settings.ts:16-54 — the Setting<T> class is the fork's actual localStorage-persistence mechanism (this.key = \matrix-setting-${key}`;line 21,localStorage.setItem(...)line 54). Grepping the file for anything volume-related turns up exactly one hit:soundEffectVolumeatsrc/settings/settings.ts:132-133, a single **global** number (reaction/sound-effect volume), unrelated to per-participant playback volume. There is no Setting` for per-user/per-device volume anywhere in the tree.

Conclusion for Q1/Q2: it's plain React/RxJS state that dies with the call (in fact dies with the tile). Nothing is keyed by userId or userId:deviceId for this purpose, nothing survives a page reload, and there's nothing that would survive (or need to survive) a fork republish — because there is no stored key at all today.

2. Interaction with the Lotus deafen path

src/lotus/lotusDeafen.ts is actually where this exact clobbering hazard is already documented and was already fixed for the deafen case:

  • src/lotus/lotusDeafen.ts:35-41 (doc comment): "This deliberately does NOT use RemoteParticipant.setVolume for deafen any more: EC's per-participant volume slider / per-tile mute writes the same volumeMap from createVolumeControls... whenever its sink$ re-emits ... so a setVolume(0) deafen was silently undone for anyone joining while deafened, and an undeafen setVolume(1) clobbered the user's own per-tile volume/mute state."
  • Instead, deafen/undeafen now drives a global mute via setAudioEnabled$ (imported from ../controls, used at src/lotus/lotusDeafen.ts:18,93,106), which feeds muteAllAudio$ and is applied at the LivekitRoomAudioRenderer level — it never touches RemoteParticipant.setVolume or the per-tile VolumeControls state at all for the mic/deafen path. So today: no clobbering of per-user volume from the main deafen action.
  • The one remaining narrow exception: screenshareAudioMuted (a separate host control) still calls p.setVolume(0/1, Track.Source.ScreenShareAudio) directly at src/lotus/lotusDeafen.ts:116,119, bypassing VolumeControls entirely. The module's own comment at lines 50-53 calls this out as a known, accepted limitation: EC's screenshare-volume slider (src/state/media/RemoteScreenShareViewModel.ts:52) writes to the same LiveKit track and "a user who moves that slider while screenshare audio is host-muted wins" — but this is scoped to screenshare audio only, not the mic volume this issue is about.

3. Mobile reachability of the volume slider

Yes, it's reachable from a tap, not just right-click/hover:

  • src/tile/GridTile.tsx:355-409 (RemoteUserMediaTile) renders a <Slider> bound to vm.playbackVolume$ / vm.adjustPlaybackVolume / vm.commitPlaybackVolume inside a menuStart fragment (lines 383-405), alongside a "mute for me" ToggleMenuItem (lines 385-390).
  • That menu content is passed into UserMediaTile (src/tile/GridTile.tsx:237-266), which wraps it in both a <ContextMenu> (right-click/long-press) and an explicit <Menu trigger={menuTrigger}> button rendered as the tile's primaryButton (src/tile/GridTile.tsx:161-215) — an aria-label="common.options" "..." button. ContextMenu is passed hasAccessibleAlternative (line 261) specifically so the same menu is reachable via that tappable button, not only via a gesture a touchscreen may not deliver the same way.
  • Minor caveat (not the core answer, but worth flagging): the button's default CSS opacity is 0 outside of @media (hover)/focus/open state (src/tile/MediaView.module.css:280-306), so it's not visibly rendered on a stock mobile layout — it wants a visible-without-hover affordance for touch, but the wiring (component + accessible alternative) is there today, it's a small polish gap rather than a missing feature.

Verdict

Not persisted → proposed storage design (local only, never synced).

  • Add a new Setting<Record<string, number>> (e.g. perUserVolume, next to soundEffectVolume at src/settings/settings.ts:132), stored under matrix-setting-perUserVolume in localStorage per the existing Setting mechanism — local per-browser-profile, never round-tripped through room state or the widget API, satisfying "never synced".
  • Key by bare Matrix userId, not userId:deviceId. A volume preference is about the person, not their call-membership device id (which is realistically a per-session/reconnect identifier, not a stable hardware id) — keying by device would silently reset on every reconnect, defeating the point of the feature.
  • Hook the persistence into commitPlaybackVolume (write-through) and seed the accumulate initial state in createVolumeControls (src/state/VolumeControls.ts:54) from the stored map for that userId instead of the hardcoded 1, threading userId down from RemoteUserMediaViewModel/WrappedUserMediaViewModel.
  • Because this hooks the same commit path the slider already uses (not a low-level setVolume interceptor), it does not touch the Lotus deafen path — deafen no longer calls VolumeControls/setVolume for mic audio (see above), so no extra clobbering risk is introduced. The screenshare-mute sub-path remains a pre-existing, separately-scoped limitation.
  • Should add basic hygiene: cap map size / prune entries for users not seen recently, since localStorage is unbounded growth otherwise.
  • Effort estimate: small, roughly 0.5-1 day — one new Setting, a few lines threading userId into VolumeControls's init + commit, plus tests mirroring the existing coverage in src/state/media/MediaViewModel.test.ts:40-150 (which already exercises volume/mute/commit behavior thoroughly and would need a persistence-seed case added).
## Findings **Per-participant volume is NOT persisted today.** It's pure in-memory RxJS state, scoped to the lifetime of a single call's tile, not a `Setting` written to `localStorage`. ### 1. Where the volume state actually lives - `src/state/VolumeControls.ts:44-101` (`createVolumeControls`) — the volume/mute state is an RxJS `Behavior` built with `accumulate({ volume: 1, committedVolume: 1 }, …)` over three `Subject`s (`toggleMuted$`, `adjustVolume$`, `commitVolume$`). Every instance starts at `volume: 1` — there is no read from storage anywhere in this function. - `src/state/media/RemoteUserMediaViewModel.ts:39-56` (`createRemoteUserMedia`) — wires `createVolumeControls`'s `sink$` straight to LiveKit's `RemoteParticipant.setVolume(volume)` (line 53). No lookup/keying by `userId` or `userId:deviceId` happens here either. - `src/state/media/WrappedUserMediaViewModel.ts:94-114` (`createWrappedUserMedia`) just forwards the `scope` it's given into `createRemoteUserMedia`; it doesn't add persistence. - `src/state/CallViewModel/CallViewModel.ts:751-807` (`userMedia$`) is where that `scope` is actually created, via `generateItems(..., mediaId, ...)` with `mediaId = \`${userId}:${membership$.value.deviceId}\`` (line 765). `generateItems` allocates a **new** `ObservableScope` per distinct key and tears it down when the item disappears from the list (participant leaves the call / call ends). Since `CallViewModel` itself is instantiated fresh per call, and the volume `Behavior` lives inside that per-item scope, the volume resets to `1` (unmuted, full volume) on every new tile — including a rejoin within the *same* call, and certainly across separate calls. - `src/settings/settings.ts:16-54` — the `Setting<T>` class *is* the fork's actual localStorage-persistence mechanism (`this.key = \`matrix-setting-${key}\`;` line 21, `localStorage.setItem(...)` line 54). Grepping the file for anything volume-related turns up exactly one hit: `soundEffectVolume` at `src/settings/settings.ts:132-133`, a single **global** number (reaction/sound-effect volume), unrelated to per-participant playback volume. There is no `Setting` for per-user/per-device volume anywhere in the tree. **Conclusion for Q1/Q2:** it's plain React/RxJS state that dies with the call (in fact dies with the tile). Nothing is keyed by `userId` or `userId:deviceId` for this purpose, nothing survives a page reload, and there's nothing that would survive (or need to survive) a fork republish — because there is no stored key at all today. ### 2. Interaction with the Lotus deafen path `src/lotus/lotusDeafen.ts` is actually where this exact clobbering hazard is already documented and was already fixed for the deafen case: - `src/lotus/lotusDeafen.ts:35-41` (doc comment): *"This deliberately does NOT use `RemoteParticipant.setVolume` for deafen any more: EC's per-participant volume slider / per-tile mute writes the same `volumeMap` from `createVolumeControls`... whenever its `sink$` re-emits ... so a `setVolume(0)` deafen was silently undone for anyone joining while deafened, and an undeafen `setVolume(1)` clobbered the user's own per-tile volume/mute state."* - Instead, deafen/undeafen now drives a **global** mute via `setAudioEnabled$` (imported from `../controls`, used at `src/lotus/lotusDeafen.ts:18,93,106`), which feeds `muteAllAudio$` and is applied at the `LivekitRoomAudioRenderer` level — it never touches `RemoteParticipant.setVolume` or the per-tile `VolumeControls` state at all for the mic/deafen path. So today: **no clobbering** of per-user volume from the main deafen action. - The one remaining narrow exception: `screenshareAudioMuted` (a separate host control) still calls `p.setVolume(0/1, Track.Source.ScreenShareAudio)` directly at `src/lotus/lotusDeafen.ts:116,119`, bypassing `VolumeControls` entirely. The module's own comment at lines 50-53 calls this out as a known, accepted limitation: EC's screenshare-volume slider (`src/state/media/RemoteScreenShareViewModel.ts:52`) writes to the same LiveKit track and "a user who moves that slider while screenshare audio is host-muted wins" — but this is scoped to screenshare audio only, not the mic volume this issue is about. ### 3. Mobile reachability of the volume slider Yes, it's reachable from a tap, not just right-click/hover: - `src/tile/GridTile.tsx:355-409` (`RemoteUserMediaTile`) renders a `<Slider>` bound to `vm.playbackVolume$` / `vm.adjustPlaybackVolume` / `vm.commitPlaybackVolume` inside a `menuStart` fragment (lines 383-405), alongside a "mute for me" `ToggleMenuItem` (lines 385-390). - That `menu` content is passed into `UserMediaTile` (`src/tile/GridTile.tsx:237-266`), which wraps it in both a `<ContextMenu>` (right-click/long-press) **and** an explicit `<Menu trigger={menuTrigger}>` button rendered as the tile's `primaryButton` (`src/tile/GridTile.tsx:161-215`) — an `aria-label="common.options"` "..." button. `ContextMenu` is passed `hasAccessibleAlternative` (line 261) specifically so the same menu is reachable via that tappable button, not only via a gesture a touchscreen may not deliver the same way. - Minor caveat (not the core answer, but worth flagging): the button's default CSS opacity is 0 outside of `@media (hover)`/focus/open state (`src/tile/MediaView.module.css:280-306`), so it's not visibly rendered on a stock mobile layout — it wants a `visible-without-hover` affordance for touch, but the wiring (component + accessible alternative) is there today, it's a small polish gap rather than a missing feature. ## Verdict **Not persisted → proposed storage design (local only, never synced).** - Add a new `Setting<Record<string, number>>` (e.g. `perUserVolume`, next to `soundEffectVolume` at `src/settings/settings.ts:132`), stored under `matrix-setting-perUserVolume` in `localStorage` per the existing `Setting` mechanism — local per-browser-profile, never round-tripped through room state or the widget API, satisfying "never synced". - **Key by bare Matrix `userId`, not `userId:deviceId`.** A volume preference is about the person, not their call-membership device id (which is realistically a per-session/reconnect identifier, not a stable hardware id) — keying by device would silently reset on every reconnect, defeating the point of the feature. - Hook the persistence into `commitPlaybackVolume` (write-through) and seed the `accumulate` initial state in `createVolumeControls` (`src/state/VolumeControls.ts:54`) from the stored map for that `userId` instead of the hardcoded `1`, threading `userId` down from `RemoteUserMediaViewModel`/`WrappedUserMediaViewModel`. - Because this hooks the same commit path the slider already uses (not a low-level `setVolume` interceptor), it does **not** touch the Lotus deafen path — deafen no longer calls `VolumeControls`/`setVolume` for mic audio (see above), so no extra clobbering risk is introduced. The screenshare-mute sub-path remains a pre-existing, separately-scoped limitation. - Should add basic hygiene: cap map size / prune entries for users not seen recently, since `localStorage` is unbounded growth otherwise. - **Effort estimate: small, roughly 0.5-1 day** — one new `Setting`, a few lines threading `userId` into `VolumeControls`'s init + commit, plus tests mirroring the existing coverage in `src/state/media/MediaViewModel.test.ts:40-150` (which already exercises volume/mute/commit behavior thoroughly and would need a persistence-seed case added).
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: LotusGuild/element-call#36