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 { useVoiceStore } from '../../stores/voiceStore';
import { useChatStore } from '../../stores/chatStore'; import { useChatStore } from '../../stores/chatStore';
import { useAuthStore } from '../../stores/authStore'; import { useAuthStore } from '../../stores/authStore';
import { useSpaceStore, isDmChannel, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
import { AudioManager } from '../../audio/AudioManager'; 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). */ /** Compute the effective sound effect gain: base volume (0.8) scaled by the user's SFX slider (0200). */
function getSfxVolume(): number { function getSfxVolume(): number {
return 0.8 * (useVoiceStore.getState().soundEffectVolume / 100); 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() { export function SoundController() {
const audioManager = AudioManager.getInstance(); const audioManager = AudioManager.getInstance();
const currentUser = useAuthStore((s) => s.user); const currentUser = useAuthStore((s) => s.user);
@@ -21,60 +46,63 @@ export function SoundController() {
if (currentUser?.homeUserId) myIds.add(currentUser.homeUserId); if (currentUser?.homeUserId) myIds.add(currentUser.homeUserId);
const isSelf = (id: string) => myIds.has(id); const isSelf = (id: string) => myIds.has(id);
// Refs to track previous states
const isInitialMount = useRef(true); const isInitialMount = useRef(true);
const prevIsMuted = useRef<boolean>(useVoiceStore.getState().isMuted); const initialState = useVoiceStore.getState();
const prevIsDeafened = useRef<boolean>(useVoiceStore.getState().isDeafened); const initialEff = computeEffectiveSelfState(initialState);
const prevIsCameraOn = useRef<boolean>(useVoiceStore.getState().isCameraOn);
const prevIsScreenSharing = useRef<boolean>(useVoiceStore.getState().isScreenSharing); // All previous-sample state lives in one ref so the subscriber callback updates atomically.
const prevIsConnected = useRef<boolean>(useVoiceStore.getState().isLiveKitConnected); const prev = useRef({
const prevParticipantIds = useRef<Set<string>>(new Set(useVoiceStore.getState().participants.map(p => p.userId))); effectiveMuted: initialEff.muted,
const prevScreenShareUserIds = useRef<Set<string>>(new Set(useVoiceStore.getState().participants.filter(p => p.isScreenSharing).map(p => p.userId))); 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 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 outgoingCallLoop = useRef<AudioBufferSourceNode | null>(null);
const outgoingCallLoading = useRef(false); const outgoingCallLoading = useRef(false);
useEffect(() => { useEffect(() => {
// Set initial mount flag to false after first run
const timer = setTimeout(() => { const timer = setTimeout(() => {
isInitialMount.current = false; isInitialMount.current = false;
}, 1000); }, 1000);
// 1. Listen to Voice State Changes
const unsubscribeVoice = useVoiceStore.subscribe((state) => { const unsubscribeVoice = useVoiceStore.subscribe((state) => {
if (isInitialMount.current) return; if (isInitialMount.current) return;
const sfxOpts = { volume: getSfxVolume() }; const sfxOpts = { volume: getSfxVolume() };
// Mute/Unmute // -------- Effective mute / deafen --------
if (state.isMuted !== prevIsMuted.current) { // Only play on transitions where BOTH prev and current samples were taken
audioManager.playSound(state.isMuted ? 'mute' : 'unmute', sfxOpts); // while LK-connected. On the connect/disconnect boundary we snapshot the
prevIsMuted.current = state.isMuted; // 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) {
// Deafen/Undeafen audioManager.playSound(eff.deafened ? 'deafen' : 'undeafen', sfxOpts);
if (state.isDeafened !== prevIsDeafened.current) {
audioManager.playSound(state.isDeafened ? 'deafen' : 'undeafen', sfxOpts);
prevIsDeafened.current = state.isDeafened;
} }
}
prev.current.effectiveMuted = eff.muted;
prev.current.effectiveDeafened = eff.deafened;
// Camera Toggle // -------- Camera Toggle (self) --------
if (state.isCameraOn !== prevIsCameraOn.current) { if (state.isCameraOn !== prev.current.isCameraOn) {
audioManager.playSound(state.isCameraOn ? 'camera_on' : 'camera_off', sfxOpts); audioManager.playSound(state.isCameraOn ? 'camera_on' : 'camera_off', sfxOpts);
prevIsCameraOn.current = state.isCameraOn; prev.current.isCameraOn = state.isCameraOn;
} }
// Screen Share Toggle (Self) // -------- Self connect / disconnect --------
if (state.isScreenSharing !== prevIsScreenSharing.current) { const justDisconnected = prev.current.isLiveKitConnected && !state.isLiveKitConnected;
audioManager.playSound(state.isScreenSharing ? 'stream_started' : 'stream_ended', sfxOpts); const justConnected = !prev.current.isLiveKitConnected && state.isLiveKitConnected;
prevIsScreenSharing.current = state.isScreenSharing;
}
// Self-disconnect detection — check BEFORE participant sounds
const justDisconnected = prevIsConnected.current && !state.isLiveKitConnected;
const justConnected = !prevIsConnected.current && state.isLiveKitConnected;
if (justDisconnected) { if (justDisconnected) {
audioManager.playSound('disconnect', sfxOpts); audioManager.playSound('disconnect', sfxOpts);
@@ -82,53 +110,93 @@ export function SoundController() {
if (justConnected) { if (justConnected) {
audioManager.playSound('user_join', sfxOpts); audioManager.playSound('user_join', sfxOpts);
} }
prevIsConnected.current = state.isLiveKitConnected; prev.current.isLiveKitConnected = state.isLiveKitConnected;
// Participant Joins/Leaves & Screen Sharing // -------- Participant set diff --------
// CRITICAL: Skip entirely if we just disconnected or are not connected. const currentParticipantIds = new Set(state.participants.map((p) => p.userId));
// Without this guard, hanging up plays "user_leave" for every participant const currentScreenShareUserIds = new Set(
// (they "left" from our perspective) simultaneously with the disconnect sound. state.participants.filter((p) => p.isScreenSharing).map((p) => p.userId),
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) { const myStreamerId = currentUser?.id;
// Someone joined voice (Others only) const selfIsSharing = myStreamerId ? currentScreenShareUserIds.has(myStreamerId) : false;
state.participants.forEach(p => { const selfStreamJustStarted =
if (!prevParticipantIds.current.has(p.userId) && !isSelf(p.userId)) { !!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); audioManager.playSound('user_join', sfxOpts);
} }
}); });
// user_leave (others)
// Someone left voice (Others only) prev.current.participantIds.forEach((userId) => {
prevParticipantIds.current.forEach(userId => {
if (!currentParticipantIds.has(userId) && !isSelf(userId)) { if (!currentParticipantIds.has(userId) && !isSelf(userId)) {
audioManager.playSound('user_leave', sfxOpts); audioManager.playSound('user_leave', sfxOpts);
} }
}); });
// stream_started — ANY participant (incl. self), audible to all
// Someone started screen sharing (Others only) state.participants.forEach((p) => {
state.participants.forEach(p => { if (p.isScreenSharing && !prev.current.screenShareUserIds.has(p.userId)) {
if (p.isScreenSharing && !prevScreenShareUserIds.current.has(p.userId) && !isSelf(p.userId)) { audioManager.playSound('stream_started', sfxOpts);
audioManager.playSound('stream_user_joined', sfxOpts);
} }
}); });
// stream_ended — ANY participant, audible to all
// Someone stopped screen sharing (Others only) prev.current.screenShareUserIds.forEach((userId) => {
prevScreenShareUserIds.current.forEach(userId => { if (!currentScreenShareUserIds.has(userId)) {
if (!currentScreenShareUserIds.has(userId) && !isSelf(userId)) { audioManager.playSound('stream_ended', sfxOpts);
audioManager.playSound('stream_user_left', sfxOpts);
} }
}); });
} }
prevParticipantIds.current = currentParticipantIds; prev.current.participantIds = currentParticipantIds;
prevScreenShareUserIds.current = currentScreenShareUserIds; 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) { if (state.incomingCall && !incomingCallLoop.current && !incomingCallLoading.current) {
incomingCallLoading.current = true; incomingCallLoading.current = true;
audioManager.playSound('call_ringing', { loop: true, volume: getSfxVolume() }).then(source => { audioManager
// If call was cancelled while sound was loading, stop immediately .playSound('call_ringing', { loop: true, volume: getSfxVolume() })
.then((source) => {
if (!useVoiceStore.getState().incomingCall) { if (!useVoiceStore.getState().incomingCall) {
source?.stop(); source?.stop();
} else { } else {
@@ -144,10 +212,12 @@ export function SoundController() {
incomingCallLoading.current = false; incomingCallLoading.current = false;
} }
// Outgoing Call (Calling) — same sync guard pattern // -------- Outgoing Call (Calling) --------
if (state.outgoingCall && !outgoingCallLoop.current && !outgoingCallLoading.current) { if (state.outgoingCall && !outgoingCallLoop.current && !outgoingCallLoading.current) {
outgoingCallLoading.current = true; 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) { if (!useVoiceStore.getState().outgoingCall) {
source?.stop(); source?.stop();
} else { } 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) => { const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
if (isInitialMount.current) return; if (isInitialMount.current) return;
// Only trigger on NEW realtimeMessageEvents entries (not API loads)
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) { if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length); const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
for (const { message } of newEvents) { const allChannels = useVoiceStore.getState().messageSoundAllChannels;
if (message.userId !== currentUser?.id) { // 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() }); audioManager.playSound('message', { volume: getSfxVolume() });
break; // one sound per batch break;
} }
} }
} }
@@ -191,4 +275,3 @@ export function SoundController() {
return null; return null;
} }