153 lines
7.7 KiB
Markdown
153 lines
7.7 KiB
Markdown
# System Sounds
|
||
|
||
Single source of truth for the in-app audio cue layer. Every file in
|
||
`packages/web/public/sounds/` is wired to exactly one event with a defined
|
||
audience.
|
||
|
||
Source files:
|
||
- Controller: `packages/web/src/components/voice/SoundController.tsx`
|
||
- Audio engine: `packages/web/src/audio/AudioManager.ts`
|
||
- Pure helpers: `packages/web/src/utils/notificationFilters.ts`,
|
||
`packages/web/src/utils/streamWatchProtocol.ts`
|
||
- 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.mp3` | "I am now muted" (any cause) | self | `effectiveMuted` flips true while LK-connected. Effective = self toggle ∪ space-mute ∪ permission-mute. |
|
||
| `unmute.mp3` | "I am no longer muted" | self | `effectiveMuted` flips false while LK-connected. |
|
||
| `deafen.mp3` | "I am now deafened" | self | `effectiveDeafened` flips true while LK-connected. |
|
||
| `undeafen.mp3` | "I am no longer deafened" | self | `effectiveDeafened` flips false while LK-connected. |
|
||
| `camera_on.mp3` | self camera enabled | self | `voiceStore.isCameraOn` flips true. |
|
||
| `camera_off.mp3` | self camera disabled | self | `voiceStore.isCameraOn` flips false. |
|
||
| `user_join.mp3` | someone (incl. self) joined the voice channel | everyone in call | self `isLiveKitConnected` flips true OR a remote participant appears in `participants[]`. |
|
||
| `user_leave.mp3` | a remote participant left voice | everyone in call (excl. the leaver) | a userId disappears from `participants[]`. Suppressed for self (uses `disconnect.mp3`) and during teardown (`justDisconnected` guard). |
|
||
| `disconnect.mp3` | self left voice | self | `isLiveKitConnected` flips false. |
|
||
| `call_ringing.mp3` | incoming DM call (loop) | callee | `voiceStore.incomingCall !== null`. Loops while ringing; cleaned up on accept/reject/timeout. |
|
||
| `call_calling.mp3` | outgoing DM call (loop) | caller | `voiceStore.outgoingCall !== null`. |
|
||
| `stream_started.mp3` | any participant started a screen share | everyone in call (incl. the streamer) | a userId appears in the `participants[].isScreenSharing` set. |
|
||
| `stream_ended.mp3` | any participant stopped a screen share | everyone in call | a userId leaves the `participants[].isScreenSharing` set. |
|
||
| `stream_user_joined.mp3` | a viewer started watching **my** stream | streamer only | `streamWatchers[selfUserId]` gains a watcher identity. |
|
||
| `stream_user_left.mp3` | a viewer stopped watching **my** stream | streamer only | `streamWatchers[selfUserId]` loses a watcher identity. Suppressed for the entire watcher set when self-stream-end fires (see Mechanism Notes). |
|
||
| `message.mp3` | 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.
|
||
|
||
### 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`):
|
||
```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:** `messageSoundAllChannels` is conceptually a
|
||
> notifications preference, not a voice preference. It currently lives in the
|
||
> Voice settings panel because that's where `soundEffectVolume` already 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.mp3` for forced disconnects)
|
||
- Friend request received / accepted
|
||
- Mention-everyone / @here (Backspace doesn't currently parse these)
|