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).
This commit is contained in:
Jannis Braun
2026-06-20 00:52:58 +02:00
parent 4829746efc
commit 3c134fe0e8
40 changed files with 228 additions and 40 deletions
@@ -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 (0200). */
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;
@@ -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;