Files
Jannis Braun 3c134fe0e8 feat(sounds): self-made Ogg cues + watch/mute/pop fixes
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).
2026-06-20 00:52:58 +02:00

195 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`):
```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 0200 | 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.ogg` for forced disconnects)
- Friend request received / accepted
- Mention-everyone / @here (Backspace doesn't currently parse these)