[research] Per-participant volume: is it remembered across calls, and where is it stored? #36
Closed
opened 2026-09-17 13:27:08 -04:00 by jared
·
3 comments
No Branch/Tag Specified
lotus
chore/dev-tool-security
feat/expected-devices
lotus-upstream-v0.28.0
feat/call-summary-details
feat/soundboard-mic-mix
feat/mobile-self-tile
feat/screenshare-audio-state
feat/decorations-origin
docs/lotus-call-error
lotus-stall-retry
docs/lotus-protocol
lotus-soundboard-not-permitted
lotus-mic-sampler-churn
lotus-call-error
fix/audio-output-list
lotus-notify-none
lotus-screenshare-prompt
lotus-call-origin
v0.28.0-lotus.4
v0.28.0-lotus.3
v0.28.0-lotus.2
v0.28.0-lotus.1
v0.25.0-lotus.29
v0.25.0-lotus.28
v0.25.0-lotus.27
v0.25.0-lotus.26
v0.25.0-lotus.25
v0.25.0-lotus.24
v0.25.0-lotus.23
v0.25.0-lotus.22
v0.25.0-lotus.21
v0.25.0-lotus.20
v0.25.0-lotus.19
v0.25.0-lotus.18
v0.25.0-lotus.17
v0.25.0-lotus.16
v0.25.0-lotus.15
v0.25.0-lotus.14
v0.25.0-lotus.13
v0.25.0-lotus.12
v0.25.0-lotus.11
v0.25.0-lotus.10
v0.25.0-lotus.9
v0.25.0-lotus.8
v0.25.0-lotus.7
v0.25.0-lotus.6
v0.25.0-lotus.5
v0.25.0-lotus.4
v0.25.0-lotus.3
v0.25.0-lotus.2
v0.25.0-lotus.1
v0.20.1
Labels
Clear labels
a11y
area: audio-pipeline
area: build-publish
area: deafen
area: decorations
area: denoise
area: livekit
area: mobile-ui
area: soundboard-inject
area: upstream-sync
area: widget-actions
bug
dependencies
docs
enhancement
performance
priority: critical
priority: high
priority: low
priority: medium
research
security
tech-debt
ux
Accessibility
Fork area: audio-pipeline
Fork area: build-publish
Fork area: deafen
Fork area: decorations
Fork area: denoise
Fork area: livekit
Fork area: mobile-ui
Fork area: soundboard-inject
Fork area: upstream-sync
Fork area: widget-actions
Something is not working
Package versions and advisories
Docs wrong or missing
New feature
Audio pipeline cost, re-render storms, leaks
Data loss, security hole, or crash on a main path
Broken feature or serious usability problem
Minor issue or polish
Wrong behaviour in an edge case or notable degradation
Origin checks, capability leaks, media/key handling
Code health, fragile patterns
Usability or visual inconsistency
Milestone
No items
No Milestone
Features 2026-Q4
Projects
Clear projects
No projects
Notifications
Due Date
No due date set.
Dependencies
No dependencies set.
Reference: LotusGuild/element-call#36
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Jared believes per-user volume is already persisted; confirm before building anything.
Questions (file:line evidence)
setVolumeon the remote audio track /MediaViewModel)? Is it a Setting (persistedlocalStorage) keyed by user or device id, or React state that dies with the call?userId:deviceId), and does it survive a page reload / a fork republish?io.lotus.set_deafenusessetAudioEnabled$/ setVolume — does deafen clobber a stored per-user volume on undeafen)?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).
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
Settingwritten tolocalStorage.1. Where the volume state actually lives
src/state/VolumeControls.ts:44-101(createVolumeControls) — the volume/mute state is an RxJSBehaviorbuilt withaccumulate({ volume: 1, committedVolume: 1 }, …)over threeSubjects (toggleMuted$,adjustVolume$,commitVolume$). Every instance starts atvolume: 1— there is no read from storage anywhere in this function.src/state/media/RemoteUserMediaViewModel.ts:39-56(createRemoteUserMedia) — wirescreateVolumeControls'ssink$straight to LiveKit'sRemoteParticipant.setVolume(volume)(line 53). No lookup/keying byuserIdoruserId:deviceIdhappens here either.src/state/media/WrappedUserMediaViewModel.ts:94-114(createWrappedUserMedia) just forwards thescopeit's given intocreateRemoteUserMedia; it doesn't add persistence.src/state/CallViewModel/CallViewModel.ts:751-807(userMedia$) is where thatscopeis actually created, viagenerateItems(..., mediaId, ...)withmediaId = \${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— theSetting<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 noSetting` 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
userIdoruserId:deviceIdfor 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.tsis 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 useRemoteParticipant.setVolumefor deafen any more: EC's per-participant volume slider / per-tile mute writes the samevolumeMapfromcreateVolumeControls... whenever itssink$re-emits ... so asetVolume(0)deafen was silently undone for anyone joining while deafened, and an undeafensetVolume(1)clobbered the user's own per-tile volume/mute state."setAudioEnabled$(imported from../controls, used atsrc/lotus/lotusDeafen.ts:18,93,106), which feedsmuteAllAudio$and is applied at theLivekitRoomAudioRendererlevel — it never touchesRemoteParticipant.setVolumeor the per-tileVolumeControlsstate at all for the mic/deafen path. So today: no clobbering of per-user volume from the main deafen action.screenshareAudioMuted(a separate host control) still callsp.setVolume(0/1, Track.Source.ScreenShareAudio)directly atsrc/lotus/lotusDeafen.ts:116,119, bypassingVolumeControlsentirely. 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 tovm.playbackVolume$/vm.adjustPlaybackVolume/vm.commitPlaybackVolumeinside amenuStartfragment (lines 383-405), alongside a "mute for me"ToggleMenuItem(lines 385-390).menucontent is passed intoUserMediaTile(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'sprimaryButton(src/tile/GridTile.tsx:161-215) — anaria-label="common.options""..." button.ContextMenuis passedhasAccessibleAlternative(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.@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 avisible-without-hoveraffordance 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).
Setting<Record<string, number>>(e.g.perUserVolume, next tosoundEffectVolumeatsrc/settings/settings.ts:132), stored undermatrix-setting-perUserVolumeinlocalStorageper the existingSettingmechanism — local per-browser-profile, never round-tripped through room state or the widget API, satisfying "never synced".userId, notuserId: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.commitPlaybackVolume(write-through) and seed theaccumulateinitial state increateVolumeControls(src/state/VolumeControls.ts:54) from the stored map for thatuserIdinstead of the hardcoded1, threadinguserIddown fromRemoteUserMediaViewModel/WrappedUserMediaViewModel.setVolumeinterceptor), it does not touch the Lotus deafen path — deafen no longer callsVolumeControls/setVolumefor mic audio (see above), so no extra clobbering risk is introduced. The screenshare-mute sub-path remains a pre-existing, separately-scoped limitation.localStorageis unbounded growth otherwise.Setting, a few lines threadinguserIdintoVolumeControls's init + commit, plus tests mirroring the existing coverage insrc/state/media/MediaViewModel.test.ts:40-150(which already exercises volume/mute/commit behavior thoroughly and would need a persistence-seed case added).Findings (2026-09-20, fork at 0.25.0-lotus.11)
1. Where the per-participant volume lives.
src/state/VolumeControls.ts:13-36—createVolumeControlsbuildsplaybackVolume$withaccumulate({ volume: 1, committedVolume: 1 }, …)inside the media view model'sObservableScope. The sink isRemoteParticipant.setVolume(volume)(state/media/RemoteUserMediaViewModel.ts:53) andsetVolume(volume, Track.Source.ScreenShareAudio)for screenshares (RemoteScreenShareViewModel.ts:52). It is observable state scoped to the view model — noSetting, nolocalStorage, nothing keyed by user or device. It dies with the tile's view model, i.e. when that participant leaves or the call ends.2. Persistence. None. Jared's belief is wrong for the fork and upstream: a per-user slider you drag resets to 1.0 on the next call, on a page reload, and even if the same participant reconnects mid-call (the remote view model is recreated →
accumulaterestarts at 1). Republishing the fork changes nothing here.3. Deafen interaction. Safe: the Lotus deafen path (
lotusDeafen.ts:29-42) deliberately usessetAudioEnabled$(the renderer'smutedprop) instead ofsetVolume, precisely so an undeafen doesn't clobber per-tile volumes; screenshare-audio-mute likewise goes through the renderer (lotusScreenshareAudio.ts). Within a call the slider value survives deafen/undeafen.4. Mobile. The slider is in the tile's overflow menu (
GridTile.tsx:363-374,SpotlightTile.tsx:331-343) and it is reachable on touch — the menu is the same component; the range input is finger-sized enough but not great.Recommendation
Not "already works". Small follow-up (fork only, local only, never synced): a
Setting<Record<string, number>>per-user-volumeinsettings.tskeyed by userId (not device — you want "Bob is loud" to stick when Bob switches devices), written oncommitPlaybackVolumewhen the value ≠ 1, read as theaccumulateseed increateRemoteUserMedia, capped at ~50 entries LRU. Screenshare audio is not remembered (it's per-share). ~40 lines + a test. Opened as a separate issue only if you want it — say the word.Done: the volume slider is remembered per person (
v0.25.0-lotus.15, cinnye91b5fe1)lotus-per-user-volume. It is never synced to the server.commitPlaybackVolumenow takes the slider's committed value.Verified in a two-person local call (Synapse + LiveKit):
Tests: the memory module (LRU, reset, junk in storage), plus restore-remembered and commit-with-value in
MediaViewModel.test.ts. The state, tile, room and lotus suites pass on Node 22 (410 tests).(The first CI run for
lotus.15failed only onoxfmt; nothing was published. The formatting is fixed in44023a4c, and the tag was re-pointed to it before it published.)