Soundboard: the trigger travels over the WebSocket and every client in the
call plays the clip locally, instead of mixing it into the presser's
microphone or publishing a LiveKit track. No upstream bandwidth, no media
stack changes, and the clip is not degraded by voice processing.
Fan-out uses a new sendToRoomParticipants rather than sendToRoom: the latter
broadcasts a space room to the whole space, which is right for the presence
the sidebar shows and wrong for anything audible. The cooldown is enforced
server-side — a client-side one only slows down people not trying to abuse it,
and a soundboard is the easiest thing here to turn into a weapon. Playing is
open to anyone in the call; deciding what the buttons are needs MANAGE_SPACE.
Account menu: the name in the user bar had cursor-pointer and no handler, so
the interface was already promising a click that did nothing. Offers profile,
status and copy-id — not the Clips or account switching the reference design
shows, which would be dead UI here.
Call timer: startedAt comes from the server, so a late joiner sees the call's
age rather than their own arrival. Empty space rooms are destroyed already,
which is what makes the next call start from zero — no reset logic needed.
The level meter only measured a stream a call had already opened, so settings
offered no way to check a mic before joining — the panel said as much.
Add startMicTest/stopMicTest on AudioManager: the processed input bus is
routed to the master output through a dedicated gain node, so the loopback can
be disconnected precisely. Settings had deliberately never opened the mic
itself; a mic test cannot honour that, so the test hands the mic back when it
stops.
Releasing needs two independent guards, because the user may join a call
mid-test: AudioManager only stops the exact stream it opened (identity check,
not a flag), and the caller must consent — the UI reads the call state, which
AudioManager cannot, as it does not import stores. Unmounting mid-test tears
the loopback down too.
Replace ripped Discord audio with self-authored Ogg Vorbis files (avoids
copyright on open-source launch) and fix three cue bugs:
- Anti-pop envelope: playSound now applies a 10ms fade-in (+ tail fade-out
for one-shots) so buffers no longer start at a non-zero amplitude. Kills
the pop on the looping call cues. Mirrors playTestTone.
- Deafen no longer plays mute+deafen at once: toggleDeafen flips isMuted and
isDeafened atomically, so SoundController saw both transitions. New tested
pure helper selectVoiceStateSound() suppresses the side-effect mute cue.
- Viewer-side watch feedback: stream_user_joined/left now play locally on the
watcher's own machine for explicit Watch/Stop actions, via
handleViewerWatchToggle. Direct local playback (not a watchingStreams diff)
keeps auto-teardown silent and works identically on Safari and Electron.
Docs: docs/systems/sounds.md updated (Ogg, dual-audience cue rows, mute/deafen
selection, viewer feedback, anti-pop envelope).
Snapshot of in-progress work parked here so group-DM-polish can land
cleanly on main. Touches MainContent + mobile-ui.md which overlap with
group-DM-polish; rebase onto post-merge main and resolve conflicts on
those two files manually.
Files: MainContent, MessageInput, MobileVoiceFullScreen (+test), StreamTile,
VoiceUser, useLiveKit, useVisualViewportInset, AudioManager, voiceStore,
voice utils, mobile-ui.md, voice.md, mobile-parity handoff doc.
The previous Task 4 handler installed onended on the *published* mic track,
but that track is a clone of AudioManager's MediaStreamAudioDestinationNode
output — destination-node tracks never end on upstream loss, they just go
silent. The handler also called setInputDevice for silent-recovery and assumed
syncMic would re-publish, but syncMic's dep array does not depend on
streamGeneration, so the recovery never republished.
This commit moves loss detection into AudioManager (where the upstream
getUserMedia track lives) via a new onInputTrackEnded subscription, extracts
republishMicrophone from syncMic into a module-level helper that both the
normal device-change path and the recovery path call directly, and removes
the published-track Microphone branch from RoomEvent.LocalTrackPublished.
The plan and Task 9 spec text are updated to match.
Three changes to bring output volume closer to native apps:
- Insert masterBoost GainNode (+3dB) before the compressor/limiter
- Raise default system sound volume from 0.5 to 0.8
- Add configurable Sound Effects Volume slider (0–200%) in Voice settings
The echo gate architecture bypassed the serialized mic management chain
and introduced race conditions. Chrome's AEC handles echo properly for
both headphone and speaker users without custom intervention.
RNNoise worklet outputs mono (1ch) which played only in the left ear.
Replace unreliable automatic up-mixing (channelCount/channelCountMode on
inputGain) with a ChannelMergerNode that duplicates the mono signal to
both L and R channels — guaranteed stereo by the Web Audio spec.
Inject @sapphi-red/web-noise-suppressor into AudioManager's input pipeline
as a toggleable AudioWorkletNode. The worklet is loaded lazily on first
enable, then kept alive — toggling bypasses by rewiring the graph without
destroying the WASM instance. Browser NS is forced off when RNNoise is
active to avoid double-processing. InputGain forced to stereo up-mix to
prevent mono-left-only output from the worklet. Also wires Phase 2 output
device routing (setSinkId) into AppLayout/ChannelSidebar.
Chrome's AEC uses getDisplayMedia audio as a reference signal and
aggressively ducks the microphone even when headphones are used.
This adds a screenShareActive flag to AudioManager that forces
echoCancellation off during screen share, plus Chromium-specific
goog* constraints as belt-and-suspenders.
Disable AGC by default to prevent Chrome from crushing mic sensitivity
when stream audio is playing. Add user-facing toggles for echo
cancellation, noise suppression, and auto gain control. Decouple voice
and stream audio by routing through ctx.destination instead of shared
compressor. Track mic stream generation to re-publish when settings change.
Add MutationObserver to neutralize LiveKit's re-attached <audio> elements,
mark our keep-alive elements with data-opencord, detach tracks on unsubscribe,
remove dangerous blanket .play(), and soften compressor to transparent limiter.