Files
backspace/packages/web/src/components/voice/GlobalAudioRenderer.tsx
T
Jannis Braun fc72e424d6 refactor: rename "Server Mute/Deafen" to "Space Mute/Deafen" across entire stack
Aligns voice moderation terminology with Backspace's "Spaces" branding.
Renames WS protocol strings, backend handlers, frontend store/hooks/utils,
user-facing labels, and documentation — 15 files, zero functional changes.
2026-03-12 00:12:33 +01:00

140 lines
5.4 KiB
TypeScript

import React from 'react';
import { useVoiceStore } from '../../stores/voiceStore';
import { useSpaceStore, getChannelOrigin, getMyUserIdForOrigin } from '../../stores/spaceStore';
import { useAudioTrackPlayer } from '../../hooks/useAudioTrackPlayer';
import type { ParticipantInfo } from '../../hooks/useLiveKit';
/**
* Manages a Web Audio pipeline for a single audio track.
* Renders a muted <audio> element as a Chrome keep-alive for the WebRTC track.
* All actual audio output goes through Web Audio (GainNode -> ctx.destination).
*/
function AudioTrackElement({
track,
globalVolume,
perSourceVolume,
isDeafened,
isMuted,
attenuate,
someoneIsSpeaking,
attenuationEnabled,
attenuationStrength,
}: {
track: MediaStreamTrack | null;
globalVolume: number;
perSourceVolume: number;
isDeafened: boolean;
isMuted: boolean;
attenuate: boolean;
someoneIsSpeaking: boolean;
attenuationEnabled: boolean;
attenuationStrength: number;
}) {
const globalScale = globalVolume / 100;
const sourceScale = perSourceVolume / 100;
let finalVolume = globalScale * sourceScale;
// Stream attenuation: duck when someone is speaking
if (attenuate && attenuationEnabled && someoneIsSpeaking) {
finalVolume *= 1 - attenuationStrength / 100;
}
const shouldMute = isDeafened || isMuted;
const audioRef = useAudioTrackPlayer({
track,
volume: finalVolume,
muted: shouldMute,
});
// The <audio> element is always muted — it serves only as a Chrome
// keep-alive so Chrome continues processing the WebRTC track.
// Real audio output goes through the Web Audio pipeline.
return <audio ref={audioRef} autoPlay playsInline data-backspace="keepalive" />;
}
/**
* Always-mounted component that manages Web Audio pipelines
* for every remote participant's mic and screen audio tracks.
*
* Rendered in AppLayout alongside PictureInPicture and SoundController.
* Never unmounts during navigation, so audio persists even when
* VoiceGrid / VoiceUser / StreamTile are not rendered.
*
* All audio is routed through the Web Audio API (GainNodes connected
* to a shared AudioContext.destination). Muted <audio> elements serve
* as Chrome keep-alives for WebRTC tracks but produce no sound.
*/
export function GlobalAudioRenderer() {
const participants = useVoiceStore((s) => s.participants);
const isDeafenedIntent = useVoiceStore((s) => s.isDeafened);
const spaceDeafenedUserIds = useVoiceStore((s) => s.spaceDeafenedUserIds);
const currentVoiceChannelId = useVoiceStore((s) => s.currentVoiceChannelId);
const outputVolume = useVoiceStore((s) => s.outputVolume);
const participantVolumes = useVoiceStore((s) => s.participantVolumes);
const streamVolumes = useVoiceStore((s) => s.streamVolumes);
const participantMutes = useVoiceStore((s) => s.participantMutes);
const streamMutes = useVoiceStore((s) => s.streamMutes);
const watchingStreams = useVoiceStore((s) => s.watchingStreams);
const streamAttenuationEnabled = useVoiceStore((s) => s.streamAttenuationEnabled);
const streamAttenuationStrength = useVoiceStore((s) => s.streamAttenuationStrength);
const speakingParticipantIds = useVoiceStore((s) => s.speakingParticipantIds);
// Compute effective deafened: user intent || server enforcement
const spaceId = useSpaceStore((s) => currentVoiceChannelId ? s.channelToSpaceMap.get(currentVoiceChannelId) : null);
const myOriginId = currentVoiceChannelId ? getMyUserIdForOrigin(getChannelOrigin(currentVoiceChannelId)) : undefined;
const spaceKey = (spaceId && myOriginId) ? `${spaceId}:${myOriginId}` : '';
const isDeafened = isDeafenedIntent || spaceDeafenedUserIds.has(spaceKey);
// Determine if someone is currently speaking (for stream attenuation)
const someoneIsSpeaking = participants.some((p) => !p.isLocal && speakingParticipantIds.has(p.identity));
// Only render audio for remote participants
const remoteParticipants = participants.filter((p) => !p.isLocal);
return (
<>
{remoteParticipants.map((p: ParticipantInfo) => {
const micVolume = participantVolumes.get(p.userId) ?? 100;
const isMicMuted = participantMutes.get(p.userId) ?? false;
const streamVol = streamVolumes.get(p.userId) ?? 100;
const isStreamMuted = streamMutes.get(p.userId) ?? false;
return (
<React.Fragment key={p.identity}>
{/* Mic audio */}
{p.audioTrack && (
<AudioTrackElement
track={p.audioTrack}
globalVolume={outputVolume}
perSourceVolume={micVolume}
isDeafened={isDeafened}
isMuted={isMicMuted}
attenuate={false}
someoneIsSpeaking={false}
attenuationEnabled={false}
attenuationStrength={0}
/>
)}
{/* Screen share audio — only when user opted in to watch */}
{p.screenAudioTrack && watchingStreams.has(p.userId) && (
<AudioTrackElement
track={p.screenAudioTrack}
globalVolume={outputVolume}
perSourceVolume={streamVol}
isDeafened={isDeafened}
isMuted={isStreamMuted}
attenuate={true}
someoneIsSpeaking={someoneIsSpeaking}
attenuationEnabled={streamAttenuationEnabled}
attenuationStrength={streamAttenuationStrength}
/>
)}
</React.Fragment>
);
})}
</>
);
}