Files
backspace/packages/web/src/components/voice/SoundController.tsx
T
Jannis Braun 3c134fe0e8 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).
2026-06-20 00:52:58 +02:00

277 lines
12 KiB
TypeScript

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';
import { selectVoiceStateSound } from '../../utils/voiceSoundTransitions';
import { getSfxVolume } from '../../utils/sfx';
/**
* 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);
// Federation-aware self-check: in federated calls, the participant userId
// can flip between localSnowflake (when activeDmCall resolves identity) and
// homeUserId (when activeDmCall is cleared during disconnect). We must
// recognize BOTH as "self" to prevent phantom join/leave sounds.
const myIds = new Set<string>();
if (currentUser?.id) myIds.add(currentUser.id);
if (currentUser?.homeUserId) myIds.add(currentUser.homeUserId);
const isSelf = (id: string) => myIds.has(id);
const isInitialMount = useRef(true);
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);
const outgoingCallLoop = useRef<AudioBufferSourceNode | null>(null);
const outgoingCallLoading = useRef(false);
useEffect(() => {
const timer = setTimeout(() => {
isInitialMount.current = false;
}, 1000);
const unsubscribeVoice = useVoiceStore.subscribe((state) => {
if (isInitialMount.current) return;
const sfxOpts = { volume: getSfxVolume() };
// -------- 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) {
// 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;
// -------- Camera Toggle (self) --------
if (state.isCameraOn !== prev.current.isCameraOn) {
audioManager.playSound(state.isCameraOn ? 'camera_on' : 'camera_off', sfxOpts);
prev.current.isCameraOn = state.isCameraOn;
}
// -------- Self connect / disconnect --------
const justDisconnected = prev.current.isLiveKitConnected && !state.isLiveKitConnected;
const justConnected = !prev.current.isLiveKitConnected && state.isLiveKitConnected;
if (justDisconnected) {
audioManager.playSound('disconnect', sfxOpts);
}
if (justConnected) {
audioManager.playSound('user_join', sfxOpts);
}
prev.current.isLiveKitConnected = state.isLiveKitConnected;
// -------- 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),
);
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);
}
});
// user_leave (others)
prev.current.participantIds.forEach((userId) => {
if (!currentParticipantIds.has(userId) && !isSelf(userId)) {
audioManager.playSound('user_leave', 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);
}
});
// stream_ended — ANY participant, audible to all
prev.current.screenShareUserIds.forEach((userId) => {
if (!currentScreenShareUserIds.has(userId)) {
audioManager.playSound('stream_ended', sfxOpts);
}
});
}
prev.current.participantIds = currentParticipantIds;
prev.current.screenShareUserIds = currentScreenShareUserIds;
// -------- 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 (!useVoiceStore.getState().incomingCall) {
source?.stop();
} else {
incomingCallLoop.current = source;
}
incomingCallLoading.current = false;
});
} else if (!state.incomingCall) {
if (incomingCallLoop.current) {
incomingCallLoop.current.stop();
incomingCallLoop.current = null;
}
incomingCallLoading.current = false;
}
// -------- Outgoing Call (Calling) --------
if (state.outgoingCall && !outgoingCallLoop.current && !outgoingCallLoading.current) {
outgoingCallLoading.current = true;
audioManager
.playSound('call_calling', { loop: true, volume: getSfxVolume() })
.then((source) => {
if (!useVoiceStore.getState().outgoingCall) {
source?.stop();
} else {
outgoingCallLoop.current = source;
}
outgoingCallLoading.current = false;
});
} else if (!state.outgoingCall) {
if (outgoingCallLoop.current) {
outgoingCallLoop.current.stop();
outgoingCallLoop.current = null;
}
outgoingCallLoading.current = false;
}
});
// -------- Chat: message sound (DM + mention default, federation-aware) --------
const unsubscribeChat = useChatStore.subscribe((state, prevState) => {
if (isInitialMount.current) return;
if (state.realtimeMessageEvents.length > prevState.realtimeMessageEvents.length) {
const newEvents = state.realtimeMessageEvents.slice(prevState.realtimeMessageEvents.length);
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;
}
}
}
});
return () => {
clearTimeout(timer);
unsubscribeVoice();
unsubscribeChat();
if (incomingCallLoop.current) incomingCallLoop.current.stop();
if (outgoingCallLoop.current) outgoingCallLoop.current.stop();
};
}, [currentUser?.id, currentUser?.homeUserId]);
return null;
}