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
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+25 -3
View File
@@ -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;
}
@@ -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;
@@ -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):
+13
View File
@@ -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 (0200, 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);
}
@@ -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');
});
});
@@ -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;
}