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).
11 KiB
System Sounds
Single source of truth for the in-app audio cue layer. Files live in
packages/web/public/sounds/ as Ogg Vorbis (.ogg). Most cues are wired
to exactly one event; the two stream_user_* cues are dual-audience (see
their rows). All cues are self-authored (no third-party/Discord audio).
Source files:
- Controller:
packages/web/src/components/voice/SoundController.tsx - Viewer-action cues:
packages/web/src/components/voice/StreamTile.tsx(handleViewerWatchToggle) - Audio engine:
packages/web/src/audio/AudioManager.ts - Pure helpers:
packages/web/src/utils/notificationFilters.ts,packages/web/src/utils/streamWatchProtocol.ts,packages/web/src/utils/voiceSoundTransitions.ts(mute/deafen cue selection) - SFX volume:
packages/web/src/utils/sfx.ts(getSfxVolume,SFX_BASE_VOLUME) - Settings:
packages/web/src/stores/voiceStore.ts(soundEffectVolume,messageSoundAllChannels),packages/web/src/components/modals/settingsPanels/VoicePanel.tsx
Inventory & Trigger Map
| File | Event | Audience | Trigger |
|---|---|---|---|
mute.ogg |
"I am now muted" (any cause) | self | effectiveMuted flips true while LK-connected. Effective = self toggle ∪ space-mute ∪ permission-mute. Suppressed when the deafen state also flipped this tick (see Mute/deafen cue selection). |
unmute.ogg |
"I am no longer muted" | self | effectiveMuted flips false while LK-connected. Suppressed when the deafen state also flipped this tick. |
deafen.ogg |
"I am now deafened" | self | effectiveDeafened flips true while LK-connected. Takes priority over the coincident mute cue. |
undeafen.ogg |
"I am no longer deafened" | self | effectiveDeafened flips false while LK-connected. Takes priority over the coincident unmute cue. |
camera_on.ogg |
self camera enabled | self | voiceStore.isCameraOn flips true. |
camera_off.ogg |
self camera disabled | self | voiceStore.isCameraOn flips false. |
user_join.ogg |
someone (incl. self) joined the voice channel | everyone in call | self isLiveKitConnected flips true OR a remote participant appears in participants[]. |
user_leave.ogg |
a remote participant left voice | everyone in call (excl. the leaver) | a userId disappears from participants[]. Suppressed for self (uses disconnect.ogg) and during teardown (justDisconnected guard). |
disconnect.ogg |
self left voice | self | isLiveKitConnected flips false. |
call_ringing.ogg |
incoming DM call (loop) | callee | voiceStore.incomingCall !== null. Loops while ringing; cleaned up on accept/reject/timeout. |
call_calling.ogg |
outgoing DM call (loop) | caller | voiceStore.outgoingCall !== null. |
stream_started.ogg |
any participant started a screen share | everyone in call (incl. the streamer) | a userId appears in the participants[].isScreenSharing set. |
stream_ended.ogg |
any participant stopped a screen share | everyone in call | a userId leaves the participants[].isScreenSharing set. |
stream_user_joined.ogg |
(a) a viewer started watching my stream; (b) I started watching someone's stream | streamer and the acting viewer | (a) streamer-side: streamWatchers[selfUserId] gains a watcher identity. (b) viewer-side: local feedback played by handleViewerWatchToggle(_, true) on the explicit "Watch Stream" action. |
stream_user_left.ogg |
(a) a viewer stopped watching my stream; (b) I stopped watching someone's stream | streamer and the acting viewer | (a) streamer-side: streamWatchers[selfUserId] loses a watcher identity (suppressed for the whole set when self-stream-end fires — see Mechanism Notes). (b) viewer-side: local feedback played by handleViewerWatchToggle(_, false) on the explicit "Stop Watching" action. |
message.ogg |
new chat message arrived | self | shouldPlayMessageSound returns true (DM channel OR content mentions any of the user's self-ids). User can flip messageSoundAllChannels to fire on every channel. |
Mechanism Notes
Effective-mute / deafen gating
SoundController computes effectiveMuted and effectiveDeafened on each
voice-store transition. The formulas mirror useLiveKit.ts (line ~322):
effectiveMuted = isMuted || spaceMutedUserIds.has(key) || permissionMutedUserIds.has(key)
effectiveDeafened = isDeafened || spaceDeafenedUserIds.has(key)
Where key = "${spaceId}:${userId}" for the current voice channel.
LK-connect-boundary rule: the cue only fires when both the previous and
current samples were captured while isLiveKitConnected === true. This prevents
a phantom mute cue on join (where you might be pre-muted by a moderator before
ever entering the channel — the effectiveMuted flag flips true after
connect because the keyed lookup resolves only once currentVoiceChannelId is
set). Mid-call mod-mute remains audible.
Mute/deafen cue selection
Deafening is not independent of muting: voiceStore.toggleDeafen flips
isMuted together with isDeafened in a single atomic set() (deafen ⇒
muted, undeafen ⇒ unmuted), mirroring Discord. SoundController samples both
effective states on the same store tick, so firing a cue per changed flag would
play mute and deafen at once when the user hits deafen.
selectVoiceStateSound(prev, next) (utils/voiceSoundTransitions.ts) resolves
this to a single cue: if the deafen state changed it returns deafen/undeafen
and the coincident mute change is treated as a side effect and suppressed;
otherwise a mute change returns mute/unmute. Pure helper, unit-tested.
Viewer-side watch feedback
stream_user_joined / stream_user_left also play on the viewer's own
machine as feedback for an explicit watch/stop action, via
handleViewerWatchToggle in StreamTile.tsx — the same chokepoint that
broadcasts the stream_watch ping. The cue is played directly (not derived
from a watchingStreams diff) for two reasons: (1) automatic teardown paths
(streamer stops sharing, participant disconnect) mutate watchingStreams
without being the viewer's action and must stay silent on the viewer side —
they already get stream_ended; (2) a direct local play is independent of the
data-channel round-trip to the streamer, so the viewer gets identical feedback
on every platform (Safari and the Electron desktop app alike). This is
orthogonal to the streamer-side diff below, which fires on a different
machine for that streamer's watcher set — the two never double on one client.
Playback envelope (anti-pop)
AudioManager.playSound wraps every cue in a short gain envelope — a 10ms
fade-in on start and (for non-looping cues) a 10ms fade-out before the buffer
ends. Starting a buffer at a non-zero sample amplitude produces an audible
click/pop; the envelope removes it. Most noticeable on the looping call cues
(call_calling / call_ringing), which previously popped on every start.
Mirrors the envelope already used by playTestTone.
Viewer tracking — data-channel protocol
LiveKit JS 2.17 does not expose per-subscriber events on the publisher
side: LocalTrackSubscribed only fires for the first subscriber and has no
unsubscribe twin; there is no public numSubscribers API. Backspace uses a
small data-channel ping instead, mirroring the existing deafen pattern in
useLiveKit.ts.
Wire format (streamWatchProtocol.ts):
interface StreamWatchPayload {
type: 'stream_watch';
target: string; // streamer userId
watching: boolean;
}
Senders. StreamTile.tsx is the only broadcast site, and only on
explicit user actions:
- "Watch Stream" / "Stop Watching" context-menu items.
- Click-to-watch handler.
The viewer-side automatic-teardown paths in useLiveKit.ts
(LocalTrackUnpublished for self, TrackUnpublished for the streamer's
removed track) call voiceStore.unwatchStream directly and do not
broadcast. This is deliberate: if they did, a streamer who just stopped
sharing would receive a flurry of watching: false pings from every former
viewer and play stream_user_left on top of their own stream_ended cue.
Receiver. useLiveKit.handleDataReceived parses the payload and calls
voiceStore.recordStreamWatch(target, watcherIdentity, watching).
SoundController watches streamWatchers[selfUserId] for diff transitions
and fires the streamer-only sounds.
Crash / drop cleanup. RoomEvent.ParticipantDisconnected evicts the
disconnecting participant identity from every watcher set
(voiceStore.evictWatcher). The stream_user_left cue plays on the streamer
side at that point.
Self-stream-end suppression. When the streamer themselves stops sharing,
SoundController detects this in the same set-diff that fires stream_ended
and synchronously calls clearStreamWatchers(myUserId). The watcher diff is
gated on selfIsSharing (which is now false), so neither the outer
subscriber tick nor the re-entered subscriber tick triggered by the clear
fires any per-watcher sound. The same gate also makes a stop-then-restart
cycle fire clearStreamWatchers on selfStreamJustStarted, dropping any
stale watcher entries from the previous run.
Why not LocalTrackSubscribed? Insufficient: fires only for the first
subscriber and has no unsubscribe counterpart in LiveKit JS 2.17.
Federation
LiveKit data channels are room-scoped, so the protocol works unchanged for
federated DM calls: a single LiveKit room hosted by one instance with all
participants attached directly. The federation-aware myIds set
({currentUser.id, currentUser.homeUserId}) is used for self-detection in
both viewer-tracking and message-mention matching, so a remote-instance
mention by homeUserId correctly triggers the message sound.
Settings
| Key | Type | Default | Storage |
|---|---|---|---|
voiceStore.soundEffectVolume |
number 0–200 | 100 | persisted |
voiceStore.messageSoundAllChannels |
boolean | false | persisted |
UI lives in VoicePanel.tsx for both today.
Long-term placement note:
messageSoundAllChannelsis conceptually a notifications preference, not a voice preference. It currently lives in the Voice settings panel because that's wheresoundEffectVolumealready lives. When a Notifications settings panel is added, both this toggle and the SFX volume slider should move there.
Out of Scope (no existing audio file)
The following events do not have an audio file in
packages/web/public/sounds/ and are intentionally not wired. Adding any
of them is a future change that requires sourcing new audio:
- DM call accepted / connected (the moment ringing transitions to active)
- DM call missed / declined / ended-remotely
- Moderator move-to-channel / kick-from-voice (the LK disconnect already plays
disconnect.oggfor forced disconnects) - Friend request received / accepted
- Mention-everyone / @here (Backspace doesn't currently parse these)