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.
140 lines
5.4 KiB
TypeScript
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>
|
|
);
|
|
})}
|
|
</>
|
|
);
|
|
}
|