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:
@@ -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 (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;
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user