3-agent survey of the in-call UI at phone width + 2-agent review of the
staged diff. All changes are mobile-gated CSS (mobile-first @media on
Compound tokens), so desktop rendering is provably unchanged.
- CallFooter: wrap the control row and tighten gap/padding at <=500px so
the buttons (incl. the destructive hangup) can never be clipped by the
grid's overflow-x:hidden. A loudspeaker button was added to the bar
without a shed rule, pushing 6-7 lg buttons past the viewport at
320-500px.
- OneOnOnePortraitLayout: give the 1:1 self-view the same safe-area-aware
inset as SpotlightExpandedLayout so it clears the home indicator /
floating footer (was a bare 16px inset). Phone-only layout.
- GridTile: enlarge the always-visible camera-flip control to a 44px touch
target on coarse pointers (was ~28px).
- ReactionToggleButton: enlarge reaction-picker emoji buttons to 44px at
<=420px (were ~32px); five still fit a 360px drawer row.
- Tabs: scroll the horizontal tab row on the inline axis (overflow-x) so
the Settings tabs don't clip in a phone drawer.
- SpotlightLandscapeLayout: shrink the 180px filmstrip rail to 132px on
short landscape phones (max-height:400px) so the spotlight isn't a sliver.
Gates: tsc 0, prettier clean, affected vitest pass. No TS changed.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- EC1: lotusQuality — track + clearTimeout the 500ms settle re-apply per room
(was leaking a timer that fired on torn-down rooms).
- EC2/EC3: lotusQuality + lotusAudioInject drive off vm.allConnections$ instead
of the remote-gated livekitRoomItems$ (were no-ops when alone), matching
lotusDenoise.
- EC4: lotusDecorations resets its roster to {} on teardown so a decoration from
a previous call can't render on a shared user in the next one.
- EC5: hoisted a stable useSyncExternalStore subscribe fn (was re-subscribing
every tile render).
- EC6: lotusFocus only sets the spotlight when the userId field is present
(a partial payload no longer clears the pin).
tsc clean. Needs a republish to ship.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
New toWidget action { deafened, screenshareAudioMuted } that sets each remote
RemoteParticipant.setVolume per source (Microphone + ScreenShareAudio), applied
to existing participants + re-applied to late joiners via
RoomEvent.ParticipantConnected (subscribed through vm.livekitRoomItems$). Closure-
scoped state, matching the sibling lotus modules; the cinny host re-sends on join
so a fresh call never inherits stale deafen state.
Replaces cinny's brittle iframe-DOM <audio>.muted hack (which broke on EC
re-render / late tracks). Folded into unpublished 0.20.1-lotus.2.
Note: injected/soundboard audio (Track.Source.Unknown) is not silenced — the
livekit-client setVolume type only accepts Microphone|ScreenShareAudio.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The in-source ML denoiser is gated on lotusDenoiseSource (see lotusDenoise.ts
gate), not the legacy build-time shim flag lotusDenoise=ml. Correct the two
stale references (lotusDenoise.ts JSDoc + InCallView.tsx comment) to
lotusDenoiseSource=1.
Bump the embedded package to 0.20.1-lotus.2 for the next publish and update the
CI comment: the checked-in version now tracks the intended release, while a
pushed tag still wins (npm version "$TAG" overwrites at publish time).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
playInjectedClip only registered its cleanup (and thus became abortable by a
later clip's replace-mode loop) AFTER publishing. Two inject actions fired in
quick succession could both pass their fetch/decode/publish awaits before
either was registered, so both tracks got published.
Register a synchronous placeholder abort BEFORE the first await: it aborts the
in-flight fetch and flips an `aborted` flag checked after every await, so a
newer clip cancels the older one during the vulnerable window. The real
cleanup replaces the placeholder once the track is live, and if we were
superseded mid-publish we tear the just-published track down immediately.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Remove the rebase hazard from CallViewModel: upstream's spotlightSpeaker$
auto-selection had been renamed to autoSpotlightSpeaker$ and an inline
manual-override + screenshare-coexistence block was spliced into
spotlightAndPip$. Both diverge from upstream and would conflict on every
rebase.
Restore spotlightSpeaker$ to its byte-for-byte upstream form and move the
[lotus #4] override into a pure wrapper, overrideSpotlight$(), invoked at a
single call point in spotlightAndPip$. Behaviour is unchanged: identical to
upstream while manualSpotlightUserId$ is null (the default), and preserves the
"pin a participant" and "focus camera during screenshare" (#4 / A5) rules when
the host sends io.lotus.focus_participant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
playInjectedClip now stops any in-flight clip (via the existing idempotent
cleanup) before starting a new one, so rapid taps replace rather than
overlap/stack — no track leak. The host also debounces the button.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AEC/AGC audit fix + two hardening items from the engine review.
- Add an `autoGainControl` capture param (UrlParams -> CallViewModel ->
ConnectionFactory audioCaptureDefaults), mirroring echoCancellation/
noiseSuppression. Defaults true (unchanged); the host sets it false only for
the ML tier so the browser's auto gain control doesn't fight the in-source ML
denoiser (pumping). Echo cancellation stays on. Tests cover the URL parse and
the audioCaptureDefaults wiring.
- L1: init() now closes the owned AudioContext on a build failure (was orphaned;
browsers cap live contexts, so repeated failures could exhaust them).
- L2: buildGraph() disposes its partially-built nodes on failure (disposeGraph
previously only cleaned the prior graph).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Track-B audio-quality changes to reduce the "robotic/underwater" artifact.
- Dry/wet attenuation floor (default 0.15 ≈ -16 dB) blends a little of the raw
mic under the denoised signal so suppression can't fully collapse the noise
floor between words (the main cause of the RNNoise "underwater"/pumping
sound). Applied ONLY to the low-latency flat models (RNNoise/Speex); DTLN/DFN
add algorithmic latency that would comb-filter an undelayed dry mix, so they
rely on their own level instead. Tunable via `lotusDenoiseFloor`.
- Noise gate now runs AFTER the ML model, not before — gating the raw signal
fed hard-zeroed frames into the model and tuned the threshold on pre-denoise
levels.
- DeepFilterNet 3 noiseReductionLevel 80 -> 60: full strength was the main
"over-processed" contributor; 60 keeps voice natural.
Defaults are conservative and tunable; final values are meant to be dialed in
with real-call A/B listening.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Track-A robustness fixes from the engine review; no quality/model changes.
- H1: auto-resume the AudioContext on `statechange` if it suspends mid-call
(mobile backgrounding / audio interruption). Previously the dest node emitted
digital silence with no recovery — a silent mute of the sender.
- H2: `resumeCtx()` races `resume()` against a timeout. A suspended context can
only resume on a user gesture; the action can arrive via postMessage, so a
bare `await resume()` inside LiveKit's track-change lock could hang and
deadlock all later mute/unmute/device-switch. Now it proceeds and the H1
watcher heals it.
- M1: don't cache a REJECTED wasm fetch — a transient blip during a reconnect
used to permanently disable denoise for the session. Evict on failure.
- M2: activate denoise off `allConnections$` (local participant's connections)
instead of `livekitRoomItems$`, which excludes the local participant and only
surfaces rooms with a remote member — so denoise now also runs when you're
alone and no longer couples to a remote-render concern.
- Context lifecycle: `closeContext()` removes the state watcher before closing;
`ensureContext()` closes a half-initialised context on any failure (no leak).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
audioOutputId comes from `useMediaDevices().audioOutput.selected$?.id`, which is
undefined until a device is selected. On the Tauri desktop webview the observable
emits undefined first, so setSinkId(undefined) threw "The provided value is not
of type 'AudioSinkOptions'" on every call join (repeatedly). Guard on a string
(the default device is the empty string, still valid).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>