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:
Jannis Braun
2026-04-28 14:43:05 +02:00
parent 7c0b67bcc9
commit 494585adf2
@@ -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 (0200). */
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;
}