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:
+63
-21
@@ -1,14 +1,19 @@
|
|||||||
# System Sounds
|
# System Sounds
|
||||||
|
|
||||||
Single source of truth for the in-app audio cue layer. Every file in
|
Single source of truth for the in-app audio cue layer. Files live in
|
||||||
`packages/web/public/sounds/` is wired to exactly one event with a defined
|
`packages/web/public/sounds/` as **Ogg Vorbis** (`.ogg`). Most cues are wired
|
||||||
audience.
|
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:
|
Source files:
|
||||||
- Controller: `packages/web/src/components/voice/SoundController.tsx`
|
- 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`
|
- Audio engine: `packages/web/src/audio/AudioManager.ts`
|
||||||
- Pure helpers: `packages/web/src/utils/notificationFilters.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`
|
- Settings: `packages/web/src/stores/voiceStore.ts`
|
||||||
(`soundEffectVolume`, `messageSoundAllChannels`),
|
(`soundEffectVolume`, `messageSoundAllChannels`),
|
||||||
`packages/web/src/components/modals/settingsPanels/VoicePanel.tsx`
|
`packages/web/src/components/modals/settingsPanels/VoicePanel.tsx`
|
||||||
@@ -19,22 +24,22 @@ Source files:
|
|||||||
|
|
||||||
| File | Event | Audience | Trigger |
|
| 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. |
|
| `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.mp3` | "I am no longer muted" | self | `effectiveMuted` flips false while LK-connected. |
|
| `unmute.ogg` | "I am no longer muted" | self | `effectiveMuted` flips false while LK-connected. Suppressed when the deafen state also flipped this tick. |
|
||||||
| `deafen.mp3` | "I am now deafened" | self | `effectiveDeafened` flips true while LK-connected. |
|
| `deafen.ogg` | "I am now deafened" | self | `effectiveDeafened` flips true while LK-connected. Takes priority over the coincident mute cue. |
|
||||||
| `undeafen.mp3` | "I am no longer deafened" | self | `effectiveDeafened` flips false while LK-connected. |
|
| `undeafen.ogg` | "I am no longer deafened" | self | `effectiveDeafened` flips false while LK-connected. Takes priority over the coincident unmute cue. |
|
||||||
| `camera_on.mp3` | self camera enabled | self | `voiceStore.isCameraOn` flips true. |
|
| `camera_on.ogg` | self camera enabled | self | `voiceStore.isCameraOn` flips true. |
|
||||||
| `camera_off.mp3` | self camera disabled | self | `voiceStore.isCameraOn` flips false. |
|
| `camera_off.ogg` | 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_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.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). |
|
| `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.mp3` | self left voice | self | `isLiveKitConnected` flips false. |
|
| `disconnect.ogg` | 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_ringing.ogg` | 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`. |
|
| `call_calling.ogg` | 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_started.ogg` | 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_ended.ogg` | 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_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.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). |
|
| `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.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. |
|
| `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
|
connect because the keyed lookup resolves only once `currentVoiceChannelId` is
|
||||||
set). Mid-call mod-mute remains audible.
|
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
|
### Viewer tracking — data-channel protocol
|
||||||
|
|
||||||
LiveKit JS 2.17 does **not** expose per-subscriber events on the publisher
|
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 accepted / connected (the moment ringing transitions to active)
|
||||||
- DM call missed / declined / ended-remotely
|
- DM call missed / declined / ended-remotely
|
||||||
- Moderator move-to-channel / kick-from-voice (the LK disconnect already plays
|
- 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
|
- Friend request received / accepted
|
||||||
- Mention-everyone / @here (Backspace doesn't currently parse these)
|
- Mention-everyone / @here (Backspace doesn't currently parse these)
|
||||||
|
|||||||
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.
@@ -169,7 +169,7 @@ export class AudioManager {
|
|||||||
if (!this.ctx) this.initContext();
|
if (!this.ctx) this.initContext();
|
||||||
|
|
||||||
try {
|
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}`);
|
if (!response.ok) throw new Error(`Failed to load sound: ${name}`);
|
||||||
const arrayBuffer = await response.arrayBuffer();
|
const arrayBuffer = await response.arrayBuffer();
|
||||||
const audioBuffer = await this.ctx!.decodeAudioData(arrayBuffer);
|
const audioBuffer = await this.ctx!.decodeAudioData(arrayBuffer);
|
||||||
@@ -191,12 +191,34 @@ export class AudioManager {
|
|||||||
source.loop = options.loop || false;
|
source.loop = options.loop || false;
|
||||||
|
|
||||||
const gainNode = this.ctx.createGain();
|
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);
|
source.connect(gainNode);
|
||||||
gainNode.connect(this.masterBoost!);
|
gainNode.connect(this.masterBoost!);
|
||||||
|
|
||||||
source.start(0);
|
source.start(now);
|
||||||
return source;
|
return source;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,11 +5,8 @@ import { useAuthStore } from '../../stores/authStore';
|
|||||||
import { useSpaceStore, isDmChannel, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
import { useSpaceStore, isDmChannel, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
|
||||||
import { AudioManager } from '../../audio/AudioManager';
|
import { AudioManager } from '../../audio/AudioManager';
|
||||||
import { shouldPlayMessageSound } from '../../utils/notificationFilters';
|
import { shouldPlayMessageSound } from '../../utils/notificationFilters';
|
||||||
|
import { selectVoiceStateSound } from '../../utils/voiceSoundTransitions';
|
||||||
/** Compute the effective sound effect gain: base volume (0.8) scaled by the user's SFX slider (0–200). */
|
import { getSfxVolume } from '../../utils/sfx';
|
||||||
function getSfxVolume(): number {
|
|
||||||
return 0.8 * (useVoiceStore.getState().soundEffectVolume / 100);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Replicates the `useLiveKit` effective-mute formula on demand. Returns whether
|
* Replicates the `useLiveKit` effective-mute formula on demand. Returns whether
|
||||||
@@ -84,12 +81,14 @@ export function SoundController() {
|
|||||||
// current effective state without firing.
|
// current effective state without firing.
|
||||||
const eff = computeEffectiveSelfState(state);
|
const eff = computeEffectiveSelfState(state);
|
||||||
if (state.isLiveKitConnected && prev.current.isLiveKitConnected) {
|
if (state.isLiveKitConnected && prev.current.isLiveKitConnected) {
|
||||||
if (eff.muted !== prev.current.effectiveMuted) {
|
// Deafen toggles mute as an atomic side effect (see
|
||||||
audioManager.playSound(eff.muted ? 'mute' : 'unmute', sfxOpts);
|
// selectVoiceStateSound) — pick the single correct cue so deafening
|
||||||
}
|
// doesn't play the mute sound on top of the deafen sound.
|
||||||
if (eff.deafened !== prev.current.effectiveDeafened) {
|
const sound = selectVoiceStateSound(
|
||||||
audioManager.playSound(eff.deafened ? 'deafen' : 'undeafen', sfxOpts);
|
{ 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.effectiveMuted = eff.muted;
|
||||||
prev.current.effectiveDeafened = eff.deafened;
|
prev.current.effectiveDeafened = eff.deafened;
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import { useContextMenuStore, type ContextMenuItem } from '../../stores/contextM
|
|||||||
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
import { getActiveRoom, setStreamSubscription } from '../../hooks/useLiveKit';
|
||||||
import { stopScreenShare, changeScreenShare } from '../../utils/screenShare';
|
import { stopScreenShare, changeScreenShare } from '../../utils/screenShare';
|
||||||
import { encodeStreamWatch } from '../../utils/streamWatchProtocol';
|
import { encodeStreamWatch } from '../../utils/streamWatchProtocol';
|
||||||
|
import { AudioManager } from '../../audio/AudioManager';
|
||||||
|
import { getSfxVolume } from '../../utils/sfx';
|
||||||
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
import { ScreenShareSettingsPopover } from './ScreenShareSettingsPopover';
|
||||||
import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta';
|
import { useVoiceParticipantMeta } from '../../hooks/useVoiceParticipantMeta';
|
||||||
import type { StreamTile as StreamTileType } from '../../hooks/useLiveKit';
|
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();
|
const room = getActiveRoom();
|
||||||
if (!room) return;
|
if (!room) return;
|
||||||
const payload = encodeStreamWatch({ type: 'stream_watch', target: streamerUserId, watching });
|
const payload = encodeStreamWatch({ type: 'stream_watch', target: streamerUserId, watching });
|
||||||
@@ -275,7 +296,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
useVoiceStore.getState().unwatchStream(userId);
|
useVoiceStore.getState().unwatchStream(userId);
|
||||||
setStreamSubscription(getActiveRoom(), identity, false);
|
setStreamSubscription(getActiveRoom(), identity, false);
|
||||||
broadcastStreamWatch(userId, false);
|
handleViewerWatchToggle(userId, false);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
@@ -289,7 +310,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
onClick: () => {
|
onClick: () => {
|
||||||
useVoiceStore.getState().watchStream(userId);
|
useVoiceStore.getState().watchStream(userId);
|
||||||
setStreamSubscription(getActiveRoom(), identity, true);
|
setStreamSubscription(getActiveRoom(), identity, true);
|
||||||
broadcastStreamWatch(userId, true);
|
handleViewerWatchToggle(userId, true);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -333,7 +354,7 @@ export function StreamTile({ tile, large }: StreamTileProps) {
|
|||||||
const handleWatch = useCallback(() => {
|
const handleWatch = useCallback(() => {
|
||||||
useVoiceStore.getState().watchStream(userId);
|
useVoiceStore.getState().watchStream(userId);
|
||||||
setStreamSubscription(getActiveRoom(), participant.identity, true);
|
setStreamSubscription(getActiveRoom(), participant.identity, true);
|
||||||
broadcastStreamWatch(userId, true);
|
handleViewerWatchToggle(userId, true);
|
||||||
}, [userId, participant.identity]);
|
}, [userId, participant.identity]);
|
||||||
|
|
||||||
const hasVideo = liveScreenTrack !== null;
|
const hasVideo = liveScreenTrack !== null;
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/**
|
/**
|
||||||
* Decides whether a freshly-arrived chat message should fire the in-app
|
* 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).
|
* caller's known self-ids).
|
||||||
*
|
*
|
||||||
* Rule (Discord-default):
|
* 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