diff --git a/docs/systems/sounds.md b/docs/systems/sounds.md index e7d59a8c..f1f8ea03 100644 --- a/docs/systems/sounds.md +++ b/docs/systems/sounds.md @@ -1,14 +1,19 @@ # 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. +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/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` @@ -19,22 +24,22 @@ Source files: | 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. | +| `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. | --- @@ -59,6 +64,43 @@ 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 @@ -147,6 +189,6 @@ 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) + `disconnect.ogg` for forced disconnects) - Friend request received / accepted - Mention-everyone / @here (Backspace doesn't currently parse these) diff --git a/packages/web/public/sounds/call_calling.mp3 b/packages/web/public/sounds/call_calling.mp3 deleted file mode 100644 index f0d7813c..00000000 Binary files a/packages/web/public/sounds/call_calling.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/call_calling.ogg b/packages/web/public/sounds/call_calling.ogg new file mode 100644 index 00000000..726e4e4c Binary files /dev/null and b/packages/web/public/sounds/call_calling.ogg differ diff --git a/packages/web/public/sounds/call_ringing.mp3 b/packages/web/public/sounds/call_ringing.mp3 deleted file mode 100644 index fa4202b5..00000000 Binary files a/packages/web/public/sounds/call_ringing.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/call_ringing.ogg b/packages/web/public/sounds/call_ringing.ogg new file mode 100644 index 00000000..01a5115f Binary files /dev/null and b/packages/web/public/sounds/call_ringing.ogg differ diff --git a/packages/web/public/sounds/camera_off.mp3 b/packages/web/public/sounds/camera_off.mp3 deleted file mode 100644 index 41537e86..00000000 Binary files a/packages/web/public/sounds/camera_off.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/camera_off.ogg b/packages/web/public/sounds/camera_off.ogg new file mode 100644 index 00000000..6b7665b2 Binary files /dev/null and b/packages/web/public/sounds/camera_off.ogg differ diff --git a/packages/web/public/sounds/camera_on.mp3 b/packages/web/public/sounds/camera_on.mp3 deleted file mode 100644 index d1a2db04..00000000 Binary files a/packages/web/public/sounds/camera_on.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/camera_on.ogg b/packages/web/public/sounds/camera_on.ogg new file mode 100644 index 00000000..14c40645 Binary files /dev/null and b/packages/web/public/sounds/camera_on.ogg differ diff --git a/packages/web/public/sounds/deafen.mp3 b/packages/web/public/sounds/deafen.mp3 deleted file mode 100644 index 705b4ecc..00000000 Binary files a/packages/web/public/sounds/deafen.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/deafen.ogg b/packages/web/public/sounds/deafen.ogg new file mode 100644 index 00000000..a9ceebc7 Binary files /dev/null and b/packages/web/public/sounds/deafen.ogg differ diff --git a/packages/web/public/sounds/disconnect.mp3 b/packages/web/public/sounds/disconnect.mp3 deleted file mode 100644 index 07ff77e2..00000000 Binary files a/packages/web/public/sounds/disconnect.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/disconnect.ogg b/packages/web/public/sounds/disconnect.ogg new file mode 100644 index 00000000..80a9b9da Binary files /dev/null and b/packages/web/public/sounds/disconnect.ogg differ diff --git a/packages/web/public/sounds/message.mp3 b/packages/web/public/sounds/message.mp3 deleted file mode 100644 index 71724cd4..00000000 Binary files a/packages/web/public/sounds/message.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/message.ogg b/packages/web/public/sounds/message.ogg new file mode 100644 index 00000000..5a7b0ff2 Binary files /dev/null and b/packages/web/public/sounds/message.ogg differ diff --git a/packages/web/public/sounds/mute.mp3 b/packages/web/public/sounds/mute.mp3 deleted file mode 100644 index 70124ec2..00000000 Binary files a/packages/web/public/sounds/mute.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/mute.ogg b/packages/web/public/sounds/mute.ogg new file mode 100644 index 00000000..ce25d90f Binary files /dev/null and b/packages/web/public/sounds/mute.ogg differ diff --git a/packages/web/public/sounds/stream_ended.mp3 b/packages/web/public/sounds/stream_ended.mp3 deleted file mode 100644 index d6e00589..00000000 Binary files a/packages/web/public/sounds/stream_ended.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/stream_ended.ogg b/packages/web/public/sounds/stream_ended.ogg new file mode 100644 index 00000000..e2cdcd83 Binary files /dev/null and b/packages/web/public/sounds/stream_ended.ogg differ diff --git a/packages/web/public/sounds/stream_started.mp3 b/packages/web/public/sounds/stream_started.mp3 deleted file mode 100644 index 3c7aaee9..00000000 Binary files a/packages/web/public/sounds/stream_started.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/stream_started.ogg b/packages/web/public/sounds/stream_started.ogg new file mode 100644 index 00000000..55530cc4 Binary files /dev/null and b/packages/web/public/sounds/stream_started.ogg differ diff --git a/packages/web/public/sounds/stream_user_joined.mp3 b/packages/web/public/sounds/stream_user_joined.mp3 deleted file mode 100644 index 1d13728d..00000000 Binary files a/packages/web/public/sounds/stream_user_joined.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/stream_user_joined.ogg b/packages/web/public/sounds/stream_user_joined.ogg new file mode 100644 index 00000000..b6806e8e Binary files /dev/null and b/packages/web/public/sounds/stream_user_joined.ogg differ diff --git a/packages/web/public/sounds/stream_user_left.mp3 b/packages/web/public/sounds/stream_user_left.mp3 deleted file mode 100644 index 5c733e60..00000000 Binary files a/packages/web/public/sounds/stream_user_left.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/stream_user_left.ogg b/packages/web/public/sounds/stream_user_left.ogg new file mode 100644 index 00000000..b333375f Binary files /dev/null and b/packages/web/public/sounds/stream_user_left.ogg differ diff --git a/packages/web/public/sounds/undeafen.mp3 b/packages/web/public/sounds/undeafen.mp3 deleted file mode 100644 index 390d6c2f..00000000 Binary files a/packages/web/public/sounds/undeafen.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/undeafen.ogg b/packages/web/public/sounds/undeafen.ogg new file mode 100644 index 00000000..21d5da96 Binary files /dev/null and b/packages/web/public/sounds/undeafen.ogg differ diff --git a/packages/web/public/sounds/unmute.mp3 b/packages/web/public/sounds/unmute.mp3 deleted file mode 100644 index c45905fb..00000000 Binary files a/packages/web/public/sounds/unmute.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/unmute.ogg b/packages/web/public/sounds/unmute.ogg new file mode 100644 index 00000000..5f929273 Binary files /dev/null and b/packages/web/public/sounds/unmute.ogg differ diff --git a/packages/web/public/sounds/user_join.mp3 b/packages/web/public/sounds/user_join.mp3 deleted file mode 100644 index 2ab7491d..00000000 Binary files a/packages/web/public/sounds/user_join.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/user_join.ogg b/packages/web/public/sounds/user_join.ogg new file mode 100644 index 00000000..9bd924dd Binary files /dev/null and b/packages/web/public/sounds/user_join.ogg differ diff --git a/packages/web/public/sounds/user_leave.mp3 b/packages/web/public/sounds/user_leave.mp3 deleted file mode 100644 index 4169a259..00000000 Binary files a/packages/web/public/sounds/user_leave.mp3 and /dev/null differ diff --git a/packages/web/public/sounds/user_leave.ogg b/packages/web/public/sounds/user_leave.ogg new file mode 100644 index 00000000..8305e422 Binary files /dev/null and b/packages/web/public/sounds/user_leave.ogg differ diff --git a/packages/web/src/audio/AudioManager.ts b/packages/web/src/audio/AudioManager.ts index 5578d284..0b18ff6b 100644 --- a/packages/web/src/audio/AudioManager.ts +++ b/packages/web/src/audio/AudioManager.ts @@ -169,7 +169,7 @@ export class AudioManager { if (!this.ctx) this.initContext(); try { - const response = await fetch(`/sounds/${name}.mp3`); + const response = await fetch(`/sounds/${name}.ogg`); if (!response.ok) throw new Error(`Failed to load sound: ${name}`); const arrayBuffer = await response.arrayBuffer(); const audioBuffer = await this.ctx!.decodeAudioData(arrayBuffer); @@ -191,12 +191,34 @@ export class AudioManager { source.loop = options.loop || false; const gainNode = this.ctx.createGain(); - gainNode.gain.value = options.volume ?? 0.8; + const targetVolume = options.volume ?? 0.8; + + // Short attack/release envelope to avoid the click/pop that occurs when a + // buffer starts (or ends) at a non-zero sample amplitude. Without this the + // waveform's first sample jumps from silence instantly, producing an + // audible pop — most noticeable on the looping call cues. Mirrors the + // envelope used by playTestTone(). + const now = this.ctx.currentTime; + const attack = 0.01; // 10ms fade-in — kills the start pop, imperceptible + gainNode.gain.setValueAtTime(0, now); + gainNode.gain.linearRampToValueAtTime(targetVolume, now + attack); + + // Tail fade-out for one-shots so they don't pop at the natural end of the + // buffer. Looping cues are stopped explicitly by the caller, so they ramp + // in once and hold; no tail ramp is scheduled for them. + if (!source.loop) { + const release = 0.01; // 10ms fade-out + const dur = buffer.duration; + if (dur > attack + release) { + gainNode.gain.setValueAtTime(targetVolume, now + dur - release); + gainNode.gain.linearRampToValueAtTime(0, now + dur); + } + } source.connect(gainNode); gainNode.connect(this.masterBoost!); - source.start(0); + source.start(now); return source; } diff --git a/packages/web/src/components/voice/SoundController.tsx b/packages/web/src/components/voice/SoundController.tsx index c69cff60..4c7ba806 100644 --- a/packages/web/src/components/voice/SoundController.tsx +++ b/packages/web/src/components/voice/SoundController.tsx @@ -5,11 +5,8 @@ import { useAuthStore } from '../../stores/authStore'; import { useSpaceStore, isDmChannel, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore'; import { AudioManager } from '../../audio/AudioManager'; import { shouldPlayMessageSound } from '../../utils/notificationFilters'; - -/** Compute the effective sound effect gain: base volume (0.8) scaled by the user's SFX slider (0–200). */ -function getSfxVolume(): number { - return 0.8 * (useVoiceStore.getState().soundEffectVolume / 100); -} +import { selectVoiceStateSound } from '../../utils/voiceSoundTransitions'; +import { getSfxVolume } from '../../utils/sfx'; /** * Replicates the `useLiveKit` effective-mute formula on demand. Returns whether @@ -84,12 +81,14 @@ export function SoundController() { // current effective state without firing. const eff = computeEffectiveSelfState(state); if (state.isLiveKitConnected && prev.current.isLiveKitConnected) { - if (eff.muted !== prev.current.effectiveMuted) { - audioManager.playSound(eff.muted ? 'mute' : 'unmute', sfxOpts); - } - if (eff.deafened !== prev.current.effectiveDeafened) { - audioManager.playSound(eff.deafened ? 'deafen' : 'undeafen', sfxOpts); - } + // Deafen toggles mute as an atomic side effect (see + // selectVoiceStateSound) — pick the single correct cue so deafening + // doesn't play the mute sound on top of the deafen sound. + const sound = selectVoiceStateSound( + { muted: prev.current.effectiveMuted, deafened: prev.current.effectiveDeafened }, + { muted: eff.muted, deafened: eff.deafened }, + ); + if (sound) audioManager.playSound(sound, sfxOpts); } prev.current.effectiveMuted = eff.muted; prev.current.effectiveDeafened = eff.deafened; diff --git a/packages/web/src/components/voice/StreamTile.tsx b/packages/web/src/components/voice/StreamTile.tsx index fc866938..d5b8b359 100644 --- a/packages/web/src/components/voice/StreamTile.tsx +++ b/packages/web/src/components/voice/StreamTile.tsx @@ -5,6 +5,8 @@ import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextM import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit'; import { stopScreenShare, changeScreenShare } from '../../utils/screenShare'; import { encodeStreamWatch } from '../../utils/streamWatchProtocol'; +import { AudioManager } from '../../audio/AudioManager'; +import { getSfxVolume } from '../../utils/sfx'; import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover'; import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta'; import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit'; @@ -141,7 +143,26 @@ function StreamAttenuationItem() { ); } -function broadcastStreamWatch(streamerUserId: string, watching: boolean): void { +/** + * Handles a deliberate viewer watch / stop-watch action: plays the local + * feedback cue on the viewer's OWN machine and notifies the streamer so their + * streamer-side watcher set updates. + * + * Called only from explicit user-action sites (the "Watch Stream" button and + * the watch/stop context-menu items) — never from automatic teardown paths + * (streamer stops sharing, participant disconnect), which must stay silent on + * the viewer side. The local cue is played directly rather than derived from a + * `watchingStreams` diff precisely so those teardown paths don't trigger it. + * + * The cue plays unconditionally (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. + */ +function handleViewerWatchToggle(streamerUserId: string, watching: boolean): void { + AudioManager.getInstance().playSound( + watching ? 'stream_user_joined' : 'stream_user_left', + { volume: getSfxVolume() }, + ); const room = getActiveRoom(); if (!room) return; const payload = encodeStreamWatch({ type: 'stream_watch', target: streamerUserId, watching }); @@ -275,7 +296,7 @@ export function StreamTile({ tile, large }: StreamTileProps) { onClick: () => { useVoiceStore.getState().unwatchStream(userId); setStreamSubscription(getActiveRoom(), identity, false); - broadcastStreamWatch(userId, false); + handleViewerWatchToggle(userId, false); }, }); } else { @@ -289,7 +310,7 @@ export function StreamTile({ tile, large }: StreamTileProps) { onClick: () => { useVoiceStore.getState().watchStream(userId); setStreamSubscription(getActiveRoom(), identity, true); - broadcastStreamWatch(userId, true); + handleViewerWatchToggle(userId, true); }, }); } @@ -333,7 +354,7 @@ export function StreamTile({ tile, large }: StreamTileProps) { const handleWatch = useCallback(() => { useVoiceStore.getState().watchStream(userId); setStreamSubscription(getActiveRoom(), participant.identity, true); - broadcastStreamWatch(userId, true); + handleViewerWatchToggle(userId, true); }, [userId, participant.identity]); const hasVideo = liveScreenTrack !== null; diff --git a/packages/web/src/utils/notificationFilters.ts b/packages/web/src/utils/notificationFilters.ts index 073d61f8..7fd22697 100644 --- a/packages/web/src/utils/notificationFilters.ts +++ b/packages/web/src/utils/notificationFilters.ts @@ -1,6 +1,6 @@ /** * Decides whether a freshly-arrived chat message should fire the in-app - * `message.mp3` cue. Pure, federation-aware (matches against any of the + * `message.ogg` cue. Pure, federation-aware (matches against any of the * caller's known self-ids). * * Rule (Discord-default): diff --git a/packages/web/src/utils/sfx.ts b/packages/web/src/utils/sfx.ts new file mode 100644 index 00000000..7e16b93e --- /dev/null +++ b/packages/web/src/utils/sfx.ts @@ -0,0 +1,13 @@ +import { useVoiceStore } from '../stores/voiceStore'; + +/** Base gain applied to every sound effect before the user's SFX slider. */ +export const SFX_BASE_VOLUME = 0.8; + +/** + * Effective sound-effect gain: the base SFX volume scaled by the user's SFX + * slider (0–200, where 100 = unity). Read at play time so volume changes take + * effect on the next cue without re-subscribing. + */ +export function getSfxVolume(): number { + return SFX_BASE_VOLUME * (useVoiceStore.getState().soundEffectVolume / 100); +} diff --git a/packages/web/src/utils/voiceSoundTransitions.test.ts b/packages/web/src/utils/voiceSoundTransitions.test.ts new file mode 100644 index 00000000..efd416de --- /dev/null +++ b/packages/web/src/utils/voiceSoundTransitions.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from 'vitest'; +import { selectVoiceStateSound } from './voiceSoundTransitions'; + +describe('selectVoiceStateSound', () => { + it('returns null when nothing changed', () => { + expect( + selectVoiceStateSound({ muted: false, deafened: false }, { muted: false, deafened: false }), + ).toBeNull(); + expect( + selectVoiceStateSound({ muted: true, deafened: false }, { muted: true, deafened: false }), + ).toBeNull(); + }); + + it('plays mute when only mute flips on', () => { + expect( + selectVoiceStateSound({ muted: false, deafened: false }, { muted: true, deafened: false }), + ).toBe('mute'); + }); + + it('plays unmute when only mute flips off', () => { + expect( + selectVoiceStateSound({ muted: true, deafened: false }, { muted: false, deafened: false }), + ).toBe('unmute'); + }); + + it('plays only deafen (not mute) when deafening also forces mute on', () => { + // toggleDeafen sets { isMuted: true, isDeafened: true } atomically. + expect( + selectVoiceStateSound({ muted: false, deafened: false }, { muted: true, deafened: true }), + ).toBe('deafen'); + }); + + it('plays only undeafen (not unmute) when undeafening also clears mute', () => { + // toggleDeafen sets { isMuted: false, isDeafened: false } atomically. + expect( + selectVoiceStateSound({ muted: true, deafened: true }, { muted: false, deafened: false }), + ).toBe('undeafen'); + }); + + it('plays deafen when deafening while already muted (mute unchanged)', () => { + expect( + selectVoiceStateSound({ muted: true, deafened: false }, { muted: true, deafened: true }), + ).toBe('deafen'); + }); + + it('plays undeafen when un-deafened but still muted (e.g. moderator space-mute persists)', () => { + expect( + selectVoiceStateSound({ muted: true, deafened: true }, { muted: true, deafened: false }), + ).toBe('undeafen'); + }); + + it('prioritizes the deafen cue when both states change in one tick', () => { + expect( + selectVoiceStateSound({ muted: false, deafened: true }, { muted: true, deafened: false }), + ).toBe('undeafen'); + }); +}); diff --git a/packages/web/src/utils/voiceSoundTransitions.ts b/packages/web/src/utils/voiceSoundTransitions.ts new file mode 100644 index 00000000..6325dbd2 --- /dev/null +++ b/packages/web/src/utils/voiceSoundTransitions.ts @@ -0,0 +1,34 @@ +/** + * Pure decision logic for the self mute/deafen audio cues. + * + * Deafening is not an independent state: `voiceStore.toggleDeafen` flips + * `isMuted` together with `isDeafened` in a single atomic update (deafen ⇒ + * muted, undeafen ⇒ unmuted), mirroring Discord. The SoundController samples + * both effective states on the same store tick, so a naive "fire on each + * change" approach plays the mute *and* deafen cue at once when the user hits + * deafen. + * + * The coincident mute change is a side effect of the deafen action, not a + * distinct user intent, so it must be suppressed: when the deafen state + * changed, only the deafen/undeafen cue plays. Pure mute toggles (deafen + * unchanged) still play the mute/unmute cue. + */ +export interface VoiceMuteState { + muted: boolean; + deafened: boolean; +} + +export type VoiceStateSound = 'mute' | 'unmute' | 'deafen' | 'undeafen' | null; + +export function selectVoiceStateSound( + prev: VoiceMuteState, + next: VoiceMuteState, +): VoiceStateSound { + const deafenedChanged = prev.deafened !== next.deafened; + if (deafenedChanged) return next.deafened ? 'deafen' : 'undeafen'; + + const mutedChanged = prev.muted !== next.muted; + if (mutedChanged) return next.muted ? 'mute' : 'unmute'; + + return null; +}