fix(sounds): full SoundController rewrite — every file fires for the right audience
- stream_started/ended audible to all (was self-only) - stream_user_joined/left wired to the watcher data-channel diff (was misused) - effective-mute/deafen audible mid-call (was self-toggle only) - message sound DM + federation-aware mention default (was every channel) - self-stream-end suppresses per-watcher stream_user_left stack
This commit is contained in:
@@ -2,13 +2,38 @@ import { useEffect, useRef } from 'react';
|
||||
import { useVoiceStore } from '../../stores/voiceStore';
|
||||
import { useChatStore } from '../../stores/chatStore';
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Replicates the `useLiveKit` effective-mute formula on demand. Returns whether
|
||||
* the local user is currently muted/deafened by ANY mechanism (self toggle,
|
||||
* moderator space-mute, or permission-mute).
|
||||
*/
|
||||
function computeEffectiveSelfState(state: ReturnType<typeof useVoiceStore.getState>): {
|
||||
muted: boolean;
|
||||
deafened: boolean;
|
||||
} {
|
||||
const cvId = state.currentVoiceChannelId;
|
||||
if (!cvId) return { muted: state.isMuted, deafened: state.isDeafened };
|
||||
const origin = getChannelOrigin(cvId);
|
||||
const myId = getMyUserIdForOrigin(origin);
|
||||
const spaceId = useSpaceStore.getState().channelToSpaceMap.get(cvId);
|
||||
const key = spaceId && myId ? `${spaceId}:${myId}` : '';
|
||||
const muted =
|
||||
state.isMuted ||
|
||||
state.spaceMutedUserIds.has(key) ||
|
||||
state.permissionMutedUserIds.has(key);
|
||||
const deafened = state.isDeafened || state.spaceDeafenedUserIds.has(key);
|
||||
return { muted, deafened };
|
||||
}
|
||||
|
||||
export function SoundController() {
|
||||
const audioManager = AudioManager.getInstance();
|
||||
const currentUser = useAuthStore((s) => s.user);
|
||||
@@ -21,60 +46,63 @@ export function SoundController() {
|
||||
if (currentUser?.homeUserId) myIds.add(currentUser.homeUserId);
|
||||
const isSelf = (id: string) => myIds.has(id);
|
||||
|
||||
// Refs to track previous states
|
||||
const isInitialMount = useRef(true);
|
||||
const prevIsMuted = useRef<boolean>(useVoiceStore.getState().isMuted);
|
||||
const prevIsDeafened = useRef<boolean>(useVoiceStore.getState().isDeafened);
|
||||
const prevIsCameraOn = useRef<boolean>(useVoiceStore.getState().isCameraOn);
|
||||
const prevIsScreenSharing = useRef<boolean>(useVoiceStore.getState().isScreenSharing);
|
||||
const prevIsConnected = useRef<boolean>(useVoiceStore.getState().isLiveKitConnected);
|
||||
const prevParticipantIds = useRef<Set<string>>(new Set(useVoiceStore.getState().participants.map(p => p.userId)));
|
||||
const prevScreenShareUserIds = useRef<Set<string>>(new Set(useVoiceStore.getState().participants.filter(p => p.isScreenSharing).map(p => p.userId)));
|
||||
const initialState = useVoiceStore.getState();
|
||||
const initialEff = computeEffectiveSelfState(initialState);
|
||||
|
||||
// All previous-sample state lives in one ref so the subscriber callback updates atomically.
|
||||
const prev = useRef({
|
||||
effectiveMuted: initialEff.muted,
|
||||
effectiveDeafened: initialEff.deafened,
|
||||
isCameraOn: initialState.isCameraOn,
|
||||
isLiveKitConnected: initialState.isLiveKitConnected,
|
||||
participantIds: new Set(initialState.participants.map((p) => p.userId)),
|
||||
screenShareUserIds: new Set(
|
||||
initialState.participants.filter((p) => p.isScreenSharing).map((p) => p.userId),
|
||||
),
|
||||
selfWatchers: new Set<string>(),
|
||||
});
|
||||
|
||||
const incomingCallLoop = useRef<AudioBufferSourceNode | null>(null);
|
||||
const incomingCallLoading = useRef(false); // sync guard for async playSound
|
||||
const incomingCallLoading = useRef(false);
|
||||
const outgoingCallLoop = useRef<AudioBufferSourceNode | null>(null);
|
||||
const outgoingCallLoading = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
// Set initial mount flag to false after first run
|
||||
const timer = setTimeout(() => {
|
||||
isInitialMount.current = false;
|
||||
}, 1000);
|
||||
|
||||
// 1. Listen to Voice State Changes
|
||||
const unsubscribeVoice = useVoiceStore.subscribe((state) => {
|
||||
if (isInitialMount.current) return;
|
||||
|
||||
const sfxOpts = { volume: getSfxVolume() };
|
||||
|
||||
// Mute/Unmute
|
||||
if (state.isMuted !== prevIsMuted.current) {
|
||||
audioManager.playSound(state.isMuted ? 'mute' : 'unmute', sfxOpts);
|
||||
prevIsMuted.current = state.isMuted;
|
||||
// -------- Effective mute / deafen --------
|
||||
// Only play on transitions where BOTH prev and current samples were taken
|
||||
// while LK-connected. On the connect/disconnect boundary we snapshot the
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Deafen/Undeafen
|
||||
if (state.isDeafened !== prevIsDeafened.current) {
|
||||
audioManager.playSound(state.isDeafened ? 'deafen' : 'undeafen', sfxOpts);
|
||||
prevIsDeafened.current = state.isDeafened;
|
||||
if (eff.deafened !== prev.current.effectiveDeafened) {
|
||||
audioManager.playSound(eff.deafened ? 'deafen' : 'undeafen', sfxOpts);
|
||||
}
|
||||
}
|
||||
prev.current.effectiveMuted = eff.muted;
|
||||
prev.current.effectiveDeafened = eff.deafened;
|
||||
|
||||
// Camera Toggle
|
||||
if (state.isCameraOn !== prevIsCameraOn.current) {
|
||||
// -------- Camera Toggle (self) --------
|
||||
if (state.isCameraOn !== prev.current.isCameraOn) {
|
||||
audioManager.playSound(state.isCameraOn ? 'camera_on' : 'camera_off', sfxOpts);
|
||||
prevIsCameraOn.current = state.isCameraOn;
|
||||
prev.current.isCameraOn = state.isCameraOn;
|
||||
}
|
||||
|
||||
// Screen Share Toggle (Self)
|
||||
if (state.isScreenSharing !== prevIsScreenSharing.current) {
|
||||
audioManager.playSound(state.isScreenSharing ? 'stream_started' : 'stream_ended', sfxOpts);
|
||||
prevIsScreenSharing.current = state.isScreenSharing;
|
||||
}
|
||||
|
||||
// Self-disconnect detection — check BEFORE participant sounds
|
||||
const justDisconnected = prevIsConnected.current && !state.isLiveKitConnected;
|
||||
const justConnected = !prevIsConnected.current && state.isLiveKitConnected;
|
||||
// -------- Self connect / disconnect --------
|
||||
const justDisconnected = prev.current.isLiveKitConnected && !state.isLiveKitConnected;
|
||||
const justConnected = !prev.current.isLiveKitConnected && state.isLiveKitConnected;
|
||||
|
||||
if (justDisconnected) {
|
||||
audioManager.playSound('disconnect', sfxOpts);
|
||||
@@ -82,53 +110,93 @@ export function SoundController() {
|
||||
if (justConnected) {
|
||||
audioManager.playSound('user_join', sfxOpts);
|
||||
}
|
||||
prevIsConnected.current = state.isLiveKitConnected;
|
||||
prev.current.isLiveKitConnected = state.isLiveKitConnected;
|
||||
|
||||
// Participant Joins/Leaves & Screen Sharing
|
||||
// CRITICAL: Skip entirely if we just disconnected or are not connected.
|
||||
// Without this guard, hanging up plays "user_leave" for every participant
|
||||
// (they "left" from our perspective) simultaneously with the disconnect sound.
|
||||
const currentParticipantIds = new Set(state.participants.map(p => p.userId));
|
||||
const currentScreenShareUserIds = new Set(state.participants.filter(p => p.isScreenSharing).map(p => p.userId));
|
||||
// -------- Participant set diff --------
|
||||
const currentParticipantIds = new Set(state.participants.map((p) => p.userId));
|
||||
const currentScreenShareUserIds = new Set(
|
||||
state.participants.filter((p) => p.isScreenSharing).map((p) => p.userId),
|
||||
);
|
||||
|
||||
if (state.isLiveKitConnected && !justDisconnected) {
|
||||
// Someone joined voice (Others only)
|
||||
state.participants.forEach(p => {
|
||||
if (!prevParticipantIds.current.has(p.userId) && !isSelf(p.userId)) {
|
||||
const myStreamerId = currentUser?.id;
|
||||
const selfIsSharing = myStreamerId ? currentScreenShareUserIds.has(myStreamerId) : false;
|
||||
const selfStreamJustStarted =
|
||||
!!myStreamerId &&
|
||||
currentScreenShareUserIds.has(myStreamerId) &&
|
||||
!prev.current.screenShareUserIds.has(myStreamerId);
|
||||
const selfStreamJustEnded =
|
||||
!!myStreamerId &&
|
||||
!currentScreenShareUserIds.has(myStreamerId) &&
|
||||
prev.current.screenShareUserIds.has(myStreamerId);
|
||||
|
||||
// Suppress join/leave + stream sounds on the connect tick. On
|
||||
// justConnected, prev.participantIds is the empty/initial set, so the
|
||||
// naive diff would fire user_join (and possibly stream_started) once per
|
||||
// pre-existing remote participant. Snapshot baseline only; sounds come
|
||||
// from real future transitions.
|
||||
if (state.isLiveKitConnected && !justDisconnected && !justConnected) {
|
||||
// user_join (others)
|
||||
state.participants.forEach((p) => {
|
||||
if (!prev.current.participantIds.has(p.userId) && !isSelf(p.userId)) {
|
||||
audioManager.playSound('user_join', sfxOpts);
|
||||
}
|
||||
});
|
||||
|
||||
// Someone left voice (Others only)
|
||||
prevParticipantIds.current.forEach(userId => {
|
||||
// user_leave (others)
|
||||
prev.current.participantIds.forEach((userId) => {
|
||||
if (!currentParticipantIds.has(userId) && !isSelf(userId)) {
|
||||
audioManager.playSound('user_leave', sfxOpts);
|
||||
}
|
||||
});
|
||||
|
||||
// Someone started screen sharing (Others only)
|
||||
state.participants.forEach(p => {
|
||||
if (p.isScreenSharing && !prevScreenShareUserIds.current.has(p.userId) && !isSelf(p.userId)) {
|
||||
audioManager.playSound('stream_user_joined', sfxOpts);
|
||||
// stream_started — ANY participant (incl. self), audible to all
|
||||
state.participants.forEach((p) => {
|
||||
if (p.isScreenSharing && !prev.current.screenShareUserIds.has(p.userId)) {
|
||||
audioManager.playSound('stream_started', sfxOpts);
|
||||
}
|
||||
});
|
||||
|
||||
// Someone stopped screen sharing (Others only)
|
||||
prevScreenShareUserIds.current.forEach(userId => {
|
||||
if (!currentScreenShareUserIds.has(userId) && !isSelf(userId)) {
|
||||
audioManager.playSound('stream_user_left', sfxOpts);
|
||||
// stream_ended — ANY participant, audible to all
|
||||
prev.current.screenShareUserIds.forEach((userId) => {
|
||||
if (!currentScreenShareUserIds.has(userId)) {
|
||||
audioManager.playSound('stream_ended', sfxOpts);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
prevParticipantIds.current = currentParticipantIds;
|
||||
prevScreenShareUserIds.current = currentScreenShareUserIds;
|
||||
prev.current.participantIds = currentParticipantIds;
|
||||
prev.current.screenShareUserIds = currentScreenShareUserIds;
|
||||
|
||||
// Incoming Call (Ringing) — sync guard prevents multiple playSound during async load
|
||||
// -------- Viewer tracking — streamer-side only (§3.2) --------
|
||||
// The full diff is gated on `selfIsSharing`. When self isn't sharing,
|
||||
// both prev and current are forced to ∅ — no sounds fire even if a late
|
||||
// ping mutates the store. On both stream-start and stream-end transitions
|
||||
// for self, eagerly call clearStreamWatchers; the gate makes the
|
||||
// re-entered subscriber tick silent. This eliminates the race where a
|
||||
// late "Stop Watching" ping arriving between a deferred clear's schedule
|
||||
// and execution would fire phantom stream_user_joined / left.
|
||||
if (myStreamerId && (selfStreamJustStarted || selfStreamJustEnded)) {
|
||||
useVoiceStore.getState().clearStreamWatchers(myStreamerId);
|
||||
}
|
||||
|
||||
if (myStreamerId && state.isLiveKitConnected && !justDisconnected && selfIsSharing) {
|
||||
const live = new Set(state.streamWatchers.get(myStreamerId) ?? []);
|
||||
const past = prev.current.selfWatchers;
|
||||
|
||||
live.forEach((identity) => {
|
||||
if (!past.has(identity)) audioManager.playSound('stream_user_joined', sfxOpts);
|
||||
});
|
||||
past.forEach((identity) => {
|
||||
if (!live.has(identity)) audioManager.playSound('stream_user_left', sfxOpts);
|
||||
});
|
||||
prev.current.selfWatchers = live;
|
||||
} else {
|
||||
prev.current.selfWatchers = new Set();
|
||||
}
|
||||
|
||||
// -------- Incoming Call (Ringing) --------
|
||||
if (state.incomingCall && !incomingCallLoop.current && !incomingCallLoading.current) {
|
||||
incomingCallLoading.current = true;
|
||||
audioManager.playSound('call_ringing', { loop: true, volume: getSfxVolume() }).then(source => {
|
||||
// If call was cancelled while sound was loading, stop immediately
|
||||
audioManager
|
||||
.playSound('call_ringing', { loop: true, volume: getSfxVolume() })
|
||||
.then((source) => {
|
||||
if (!useVoiceStore.getState().incomingCall) {
|
||||
source?.stop();
|
||||
} else {
|
||||
@@ -144,10 +212,12 @@ export function SoundController() {
|
||||
incomingCallLoading.current = false;
|
||||
}
|
||||
|
||||
// Outgoing Call (Calling) — same sync guard pattern
|
||||
// -------- Outgoing Call (Calling) --------
|
||||
if (state.outgoingCall && !outgoingCallLoop.current && !outgoingCallLoading.current) {
|
||||
outgoingCallLoading.current = true;
|
||||
audioManager.playSound('call_calling', { loop: true, volume: getSfxVolume() }).then(source => {
|
||||
audioManager
|
||||
.playSound('call_calling', { loop: true, volume: getSfxVolume() })
|
||||
.then((source) => {
|
||||
if (!useVoiceStore.getState().outgoingCall) {
|
||||
source?.stop();
|
||||
} else {
|
||||
@@ -164,17 +234,31 @@ export function SoundController() {
|
||||
}
|
||||
});
|
||||
|
||||
// 2. Listen to Chat State Changes (New Real-Time Messages from any channel)
|
||||
// -------- Chat: message sound (DM + mention default, federation-aware) --------
|
||||
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
|
||||
if (isInitialMount.current) return;
|
||||
|
||||
// Only trigger on NEW realtimeMessageEvents entries (not API loads)
|
||||
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
|
||||
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
|
||||
for (const { message } of newEvents) {
|
||||
if (message.userId !== currentUser?.id) {
|
||||
const allChannels = useVoiceStore.getState().messageSoundAllChannels;
|
||||
// Use the wrapper-level channelId from RealtimeMessageEvent — set by
|
||||
// addRealtimeMessage(channelId, message). It's authoritative for both
|
||||
// space and DM messages. Avoids reaching into the heterogeneous Message
|
||||
// shape (where DM messages may carry dmChannelId at runtime).
|
||||
for (const { channelId, message } of newEvents) {
|
||||
if (!channelId) continue;
|
||||
const isDm = isDmChannel(channelId);
|
||||
if (
|
||||
shouldPlayMessageSound({
|
||||
authorUserId: message.userId,
|
||||
myIds,
|
||||
isDmChannel: isDm,
|
||||
content: message.content,
|
||||
allChannels,
|
||||
})
|
||||
) {
|
||||
audioManager.playSound('message', { volume: getSfxVolume() });
|
||||
break; // one sound per batch
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -191,4 +275,3 @@ export function SoundController() {
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user