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
@@ -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;
}